diff --git a/.changeset/funny-seahorses-rest.md b/.changeset/funny-seahorses-rest.md new file mode 100644 index 0000000000..e6d3f40297 --- /dev/null +++ b/.changeset/funny-seahorses-rest.md @@ -0,0 +1,6 @@ +--- +'@twilio-paste/meter': major +'@twilio-paste/core': minor +--- + +[Meter] Create Meter package. Meter is a visual representation of a numerical value within a known range. diff --git a/.changeset/poor-zebras-allow.md b/.changeset/poor-zebras-allow.md new file mode 100644 index 0000000000..ad33cf1a18 --- /dev/null +++ b/.changeset/poor-zebras-allow.md @@ -0,0 +1,5 @@ +--- +'@twilio-paste/codemods': minor +--- + +[Codemods] New addition of Meter package. diff --git a/.changeset/swift-squids-destroy.md b/.changeset/swift-squids-destroy.md new file mode 100644 index 0000000000..bbb09903a0 --- /dev/null +++ b/.changeset/swift-squids-destroy.md @@ -0,0 +1,8 @@ +--- +'@twilio-paste/base-radio-checkbox': patch +'@twilio-paste/file-uploader': patch +'@twilio-paste/label': patch +'@twilio-paste/core': patch +--- + +Slightly improved Label component types diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 649be0c8af..a52c5f4069 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -61,6 +61,7 @@ "/packages/paste-core/layout/media-object", "/packages/paste-core/components/menu", "/packages/paste-core/primitives/menu", + "/packages/paste-core/components/meter", "/packages/paste-core/components/minimizable-dialog", "/packages/paste-core/components/modal", "/packages/paste-core/primitives/modal-dialog", diff --git a/packages/paste-codemods/tools/.cache/mappings.json b/packages/paste-codemods/tools/.cache/mappings.json index 02c918183c..78bf8d5130 100644 --- a/packages/paste-codemods/tools/.cache/mappings.json +++ b/packages/paste-codemods/tools/.cache/mappings.json @@ -168,6 +168,8 @@ "SubMenuButton": "@twilio-paste/core/menu", "getComputedVariant": "@twilio-paste/core/menu", "useMenuState": "@twilio-paste/core/menu", + "Meter": "@twilio-paste/core/meter", + "MeterLabel": "@twilio-paste/core/meter", "MinimizableDialog": "@twilio-paste/core/minimizable-dialog", "MinimizableDialogButton": "@twilio-paste/core/minimizable-dialog", "MinimizableDialogContainer": "@twilio-paste/core/minimizable-dialog", diff --git a/packages/paste-core/components/base-radio-checkbox/src/BaseRadioCheckbox.tsx b/packages/paste-core/components/base-radio-checkbox/src/BaseRadioCheckbox.tsx index 3d8a29551f..ced1cb3ec4 100644 --- a/packages/paste-core/components/base-radio-checkbox/src/BaseRadioCheckbox.tsx +++ b/packages/paste-core/components/base-radio-checkbox/src/BaseRadioCheckbox.tsx @@ -118,9 +118,9 @@ BaseRadioCheckboxControl.propTypes = { disabled: PropTypes.bool, }; -export interface BaseRadioCheckboxLabelProps extends LabelProps { +export type BaseRadioCheckboxLabelProps = LabelProps & { children: NonNullable; -} +}; const BaseRadioCheckboxLabel = React.forwardRef( ({children, ...props}, ref) => { return ( diff --git a/packages/paste-core/components/file-uploader/src/FileUploaderLabel.tsx b/packages/paste-core/components/file-uploader/src/FileUploaderLabel.tsx index 43ec1ca23f..a2ca32788c 100644 --- a/packages/paste-core/components/file-uploader/src/FileUploaderLabel.tsx +++ b/packages/paste-core/components/file-uploader/src/FileUploaderLabel.tsx @@ -5,10 +5,10 @@ import type {LabelProps} from '@twilio-paste/label'; import {FileUploaderContext} from './FileUploaderContext'; -export interface FileUploaderLabelProps extends Omit, 'children'> { +export type FileUploaderLabelProps = Omit, 'children'> & { children: LabelProps['children']; element?: LabelProps['element']; -} +}; export const FileUploaderLabel = React.forwardRef( ({children, element = 'FILE_UPLOADER_LABEL', ...props}, ref) => { diff --git a/packages/paste-core/components/label/src/Label.tsx b/packages/paste-core/components/label/src/Label.tsx index aecc3a001f..eb4b88d8ae 100644 --- a/packages/paste-core/components/label/src/Label.tsx +++ b/packages/paste-core/components/label/src/Label.tsx @@ -9,16 +9,37 @@ import type {HTMLPasteProps} from '@twilio-paste/types'; import {RequiredDot} from './RequiredDot'; export type LabelVariants = 'default' | 'inverse'; -export interface LabelProps extends HTMLPasteProps<'label'>, Pick { - as?: 'label' | 'legend' | 'div'; +type LabelBaseProps = Pick & { children: NonNullable; disabled?: boolean; - htmlFor: string | undefined; marginBottom?: 'space0'; required?: boolean; variant?: LabelVariants; i18nRequiredLabel?: string; -} +}; +type LabelElementProps = HTMLPasteProps<'label'> & { + as?: 'label'; + /** + * You must specify the 'htmlFor' prop to associate the label with an input. + */ + htmlFor: string | undefined; +}; +type LabelLegendElementProps = HTMLPasteProps<'legend'> & { + as?: 'legend'; + /** + * You cannot apply htmlFor to a legend element. + */ + htmlFor?: never; +}; +type LabelDivElementProps = HTMLPasteProps<'div'> & { + as?: 'div'; + /** + * You cannot apply htmlFor to a div element. + */ + htmlFor?: never; +}; + +export type LabelProps = LabelBaseProps & (LabelElementProps | LabelLegendElementProps | LabelDivElementProps); const Label = React.forwardRef( ( diff --git a/packages/paste-core/components/meter/__tests__/index.spec.tsx b/packages/paste-core/components/meter/__tests__/index.spec.tsx new file mode 100644 index 0000000000..b6f1d08d51 --- /dev/null +++ b/packages/paste-core/components/meter/__tests__/index.spec.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import {render, screen} from '@testing-library/react'; +import {Theme} from '@twilio-paste/theme'; +import type {RenderOptions} from '@testing-library/react'; + +import {Default, HiddenValueLabelAriaLabel, Customized} from '../stories/index.stories'; + +const ThemeWrapper: RenderOptions['wrapper'] = ({children}) => ( + {children} +); + +const elementAttr = 'data-paste-element'; + +describe('Meter', () => { + describe('base usage', () => { + it('should render correctly', () => { + render(, {wrapper: ThemeWrapper}); + const meter = screen.getByRole('meter'); + expect(meter).toBeInTheDocument(); + expect(meter).toHaveAttribute('aria-valuemin', '0'); + expect(meter).toHaveAttribute('aria-valuemax', '100'); + expect(meter).toHaveAttribute('aria-valuenow', '75'); + expect(meter).toHaveAttribute('aria-valuetext', '75%'); + expect(meter).toHaveAttribute('id', 'meter'); + expect(meter).toHaveAttribute('aria-labelledby', 'meterMETER_LABEL'); + }); + + it('should apply aria-label correctly', () => { + render(, {wrapper: ThemeWrapper}); + const meter = screen.getByRole('meter'); + expect(meter).toHaveAttribute('aria-label', 'Fuel level'); + expect(meter).not.toHaveAttribute('aria-labelledby'); + }); + }); + + describe('Customization', () => { + it('should set default data-paste-element attribute on meter', () => { + render(); + const meterOne = screen.getByTestId('meter_one'); + expect(meterOne).toHaveAttribute(elementAttr, 'METER'); + const meterLabelOne = screen.getByTestId('meter_label_one'); + expect(meterLabelOne).toHaveAttribute(elementAttr, 'METER_LABEL'); + }); + + it('should set custom data-paste-element attribute on meter', () => { + render(); + const meterTwo = screen.getByTestId('meter_two'); + expect(meterTwo).toHaveAttribute(elementAttr, 'FOO'); + const meterLabelTwo = screen.getByTestId('meter_label_two'); + expect(meterLabelTwo).toHaveAttribute(elementAttr, 'FOO_LABEL'); + }); + }); +}); diff --git a/packages/paste-core/components/meter/build.js b/packages/paste-core/components/meter/build.js new file mode 100644 index 0000000000..a4edeab49b --- /dev/null +++ b/packages/paste-core/components/meter/build.js @@ -0,0 +1,3 @@ +const {build} = require('../../../../tools/build/esbuild'); + +build(require('./package.json')); diff --git a/packages/paste-core/components/meter/package.json b/packages/paste-core/components/meter/package.json new file mode 100644 index 0000000000..60ab86723b --- /dev/null +++ b/packages/paste-core/components/meter/package.json @@ -0,0 +1,72 @@ +{ + "name": "@twilio-paste/meter", + "version": "0.0.0", + "category": "data display", + "status": "alpha", + "description": "Meter is a visual representation of a numerical value within a known range.", + "author": "Twilio Inc.", + "license": "MIT", + "main:dev": "src/index.tsx", + "main": "dist/index.js", + "module": "dist/index.es.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "yarn clean && NODE_ENV=production node build.js && tsc", + "build:js": "NODE_ENV=development node build.js", + "clean": "rm -rf ./dist", + "tsc": "tsc" + }, + "peerDependencies": { + "@twilio-paste/animation-library": "^2.0.0", + "@twilio-paste/box": "^10.0.0", + "@twilio-paste/color-contrast-utils": "^5.0.0", + "@twilio-paste/customization": "^8.0.0", + "@twilio-paste/design-tokens": "^10.0.0", + "@twilio-paste/icons": "^12.0.0", + "@twilio-paste/label": "^13.0.0", + "@twilio-paste/media-object": "^10.0.0", + "@twilio-paste/react-spectrum-library": "^2.0.0", + "@twilio-paste/screen-reader-only": "^13.0.0", + "@twilio-paste/style-props": "^9.0.0", + "@twilio-paste/styling-library": "^3.0.0", + "@twilio-paste/text": "^10.0.0", + "@twilio-paste/theme": "^11.0.0", + "@twilio-paste/types": "^6.0.0", + "@twilio-paste/uid-library": "^2.0.0", + "@types/react": "^16.8.6 || ^17.0.2 || ^18.0.27", + "@types/react-dom": "^16.8.6 || ^17.0.2 || ^18.0.10", + "prop-types": "^15.7.2", + "react": "^16.8.6 || ^17.0.2 || ^18.0.0", + "react-dom": "^16.8.6 || ^17.0.2 || ^18.0.0" + }, + "devDependencies": { + "@twilio-paste/animation-library": "^2.0.0", + "@twilio-paste/box": "^10.0.0", + "@twilio-paste/color-contrast-utils": "^5.0.0", + "@twilio-paste/customization": "^8.0.0", + "@twilio-paste/design-tokens": "^10.0.0", + "@twilio-paste/icons": "^12.0.0", + "@twilio-paste/label": "^13.0.0", + "@twilio-paste/media-object": "^10.0.0", + "@twilio-paste/react-spectrum-library": "^2.0.0", + "@twilio-paste/screen-reader-only": "^13.0.0", + "@twilio-paste/style-props": "^9.0.0", + "@twilio-paste/styling-library": "^3.0.0", + "@twilio-paste/text": "^10.0.0", + "@twilio-paste/theme": "^11.0.0", + "@twilio-paste/types": "^6.0.0", + "@twilio-paste/uid-library": "^2.0.0", + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "prop-types": "^15.7.2", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/packages/paste-core/components/meter/src/Meter.tsx b/packages/paste-core/components/meter/src/Meter.tsx new file mode 100644 index 0000000000..8439ba73d7 --- /dev/null +++ b/packages/paste-core/components/meter/src/Meter.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import {type BoxProps, Box} from '@twilio-paste/box'; +import {Text} from '@twilio-paste/text'; +import type {HTMLPasteProps} from '@twilio-paste/types'; +import {useMeter} from '@twilio-paste/react-spectrum-library'; + +import {LABEL_SUFFIX} from './constants'; + +export interface MeterProps extends HTMLPasteProps<'meter'>, Pick { + minValue?: number; + maxValue?: number; + value?: number; + id: string; + showValueLabel?: boolean; + formatOptions?: Intl.NumberFormatOptions; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#options + valueLabel?: string; + 'aria-label'?: string; + 'aria-describedby'?: string; + 'aria-labelledby'?: string; + /* + * The following props don't exist on the react-aria useMeter hook but do exist on the HTML meter element. + * They can be added back into the Paste Meter API depending on the finalized spec & designs. + * + * low?: number; + * high?: number; + * optimum?: number; + */ +} + +const Meter = React.forwardRef(({element = 'METER', id, ...props}, ref) => { + const {value = 0, minValue = 0, maxValue = 100, showValueLabel = true} = props; + const {meterProps} = useMeter(props); + + // Calculate the width of the bar as a percentage + const percentage = (value - minValue) / (maxValue - minValue); + const fillWidth = `${Math.round(percentage * 100)}%`; + + /* + * Since ProgressBar isn't a form element, we cannot use htmlFor from the regular label + * so we create a ProgressBarLabel component that behaves like a regular form Label + * but leverages aria-labelledby instead of htmlFor transparently. + */ + let labelledBy = props['aria-labelledby']; + if (labelledBy == null && props['aria-label'] == null && id != null) { + labelledBy = `${id}${LABEL_SUFFIX}`; + } + + return ( + + + {showValueLabel && ( + + {meterProps['aria-valuetext']} + + )} + + + + + + ); +}); + +Meter.displayName = 'Meter'; + +export {Meter}; diff --git a/packages/paste-core/components/meter/src/MeterLabel.tsx b/packages/paste-core/components/meter/src/MeterLabel.tsx new file mode 100644 index 0000000000..1f3a5100f4 --- /dev/null +++ b/packages/paste-core/components/meter/src/MeterLabel.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import {type BoxProps} from '@twilio-paste/box'; +import {Label} from '@twilio-paste/label'; +import type {HTMLPasteProps} from '@twilio-paste/types'; + +import {LABEL_SUFFIX} from './constants'; + +export interface MeterLabelProps extends HTMLPasteProps<'div'>, Pick { + children: string; + htmlFor: string; +} + +const MeterLabel = React.forwardRef( + ({element = 'METER_LABEL', children, htmlFor, ...labelProps}, ref) => { + return ( + + ); + } +); + +MeterLabel.displayName = 'MeterLabel'; + +export {MeterLabel}; diff --git a/packages/paste-core/components/meter/src/constants.ts b/packages/paste-core/components/meter/src/constants.ts new file mode 100644 index 0000000000..6d6e959861 --- /dev/null +++ b/packages/paste-core/components/meter/src/constants.ts @@ -0,0 +1 @@ +export const LABEL_SUFFIX = 'METER_LABEL'; diff --git a/packages/paste-core/components/meter/src/index.tsx b/packages/paste-core/components/meter/src/index.tsx new file mode 100644 index 0000000000..7a44625526 --- /dev/null +++ b/packages/paste-core/components/meter/src/index.tsx @@ -0,0 +1,2 @@ +export * from './Meter'; +export * from './MeterLabel'; diff --git a/packages/paste-core/components/meter/stories/index.stories.tsx b/packages/paste-core/components/meter/stories/index.stories.tsx new file mode 100644 index 0000000000..6f6368352c --- /dev/null +++ b/packages/paste-core/components/meter/stories/index.stories.tsx @@ -0,0 +1,106 @@ +import {CustomizationProvider} from '@twilio-paste/customization'; +import {useTheme} from '@twilio-paste/theme'; +import {useUID} from '@twilio-paste/uid-library'; +import {HelpText} from '@twilio-paste/help-text'; +import * as React from 'react'; + +import {Meter, MeterLabel} from '../src'; + +// eslint-disable-next-line import/no-default-export +export default { + title: 'Components/Meter', + component: Meter, +}; + +export const Default = (): React.ReactElement => { + const meterId = 'meter'; + return ( + <> + Storage space + + + ); +}; + +export const HiddenValueLabelAriaLabel = (): React.ReactElement => { + const meterId = useUID(); + return ; +}; + +export const FormattedValueLabel = (): React.ReactElement => { + const meterId = useUID(); + return ( + <> + Account funds + + + ); +}; + +export const CustomValueLabelCustomLabel = (): React.ReactElement => { + const labelId = useUID(); + const meterId = useUID(); + return ( + <> + Storage space used + + + ); +}; + +export const WithHelpText = (): React.ReactElement => { + const meterId = useUID(); + const helpTextId = useUID(); + return ( + <> + Storage space used + + Helpful text + + ); +}; + +export const Customized = (): React.ReactElement => { + const theme = useTheme(); + const meterOneId = useUID(); + const meterTwoId = useUID(); + return ( + + + Storage space + + + + Storage space + + + + ); +}; diff --git a/packages/paste-core/components/meter/tsconfig.json b/packages/paste-core/components/meter/tsconfig.json new file mode 100644 index 0000000000..5e8a3b17a2 --- /dev/null +++ b/packages/paste-core/components/meter/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist/" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/paste-core/core-bundle/.gitignore b/packages/paste-core/core-bundle/.gitignore index b54742247f..4b9bddecd7 100644 --- a/packages/paste-core/core-bundle/.gitignore +++ b/packages/paste-core/core-bundle/.gitignore @@ -57,6 +57,7 @@ /media-object /menu /menu-primitive +/meter /minimizable-dialog /modal /modal-dialog-primitive diff --git a/packages/paste-core/core-bundle/package.json b/packages/paste-core/core-bundle/package.json index e6c9c0d70a..9c9cb3a848 100644 --- a/packages/paste-core/core-bundle/package.json +++ b/packages/paste-core/core-bundle/package.json @@ -128,6 +128,7 @@ "@twilio-paste/media-object": "^10.0.0", "@twilio-paste/menu": "^14.0.1", "@twilio-paste/menu-primitive": "^2.0.0", + "@twilio-paste/meter": "^0.0.0", "@twilio-paste/minimizable-dialog": "^4.0.0", "@twilio-paste/modal": "^16.0.0", "@twilio-paste/modal-dialog-primitive": "^2.0.0", diff --git a/packages/paste-core/core-bundle/src/index.tsx b/packages/paste-core/core-bundle/src/index.tsx index 9ff60d47a0..245390a874 100644 --- a/packages/paste-core/core-bundle/src/index.tsx +++ b/packages/paste-core/core-bundle/src/index.tsx @@ -46,6 +46,7 @@ export * from '@twilio-paste/listbox-primitive'; export * from '@twilio-paste/media-object'; export * from '@twilio-paste/menu'; export * from '@twilio-paste/menu-primitive'; +export * from '@twilio-paste/meter'; export * from '@twilio-paste/minimizable-dialog'; export * from '@twilio-paste/modal'; export * from '@twilio-paste/modal-dialog-primitive'; diff --git a/packages/paste-core/core-bundle/src/meter.tsx b/packages/paste-core/core-bundle/src/meter.tsx new file mode 100644 index 0000000000..b4c1cf02cc --- /dev/null +++ b/packages/paste-core/core-bundle/src/meter.tsx @@ -0,0 +1 @@ +export * from '@twilio-paste/meter'; diff --git a/packages/paste-website/src/components/customization-landing-page/LandingPageDesigner/DesignerControls.tsx b/packages/paste-website/src/components/customization-landing-page/LandingPageDesigner/DesignerControls.tsx index ad69befcb0..53b1bd0b9b 100644 --- a/packages/paste-website/src/components/customization-landing-page/LandingPageDesigner/DesignerControls.tsx +++ b/packages/paste-website/src/components/customization-landing-page/LandingPageDesigner/DesignerControls.tsx @@ -35,7 +35,7 @@ export const DesignerControls = (): JSX.Element => { > -