Skip to content

Commit

Permalink
feat(meter): create package (#3430)
Browse files Browse the repository at this point in the history
  • Loading branch information
nkrantz committed Aug 28, 2023
1 parent 5302d13 commit 7bdac72
Show file tree
Hide file tree
Showing 23 changed files with 461 additions and 11 deletions.
6 changes: 6 additions & 0 deletions .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.
5 changes: 5 additions & 0 deletions .changeset/poor-zebras-allow.md
@@ -0,0 +1,5 @@
---
'@twilio-paste/codemods': minor
---

[Codemods] New addition of Meter package.
8 changes: 8 additions & 0 deletions .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
1 change: 1 addition & 0 deletions .codesandbox/ci.json
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/paste-codemods/tools/.cache/mappings.json
Expand Up @@ -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",
Expand Down
Expand Up @@ -118,9 +118,9 @@ BaseRadioCheckboxControl.propTypes = {
disabled: PropTypes.bool,
};

export interface BaseRadioCheckboxLabelProps extends LabelProps {
export type BaseRadioCheckboxLabelProps = LabelProps & {
children: NonNullable<React.ReactNode>;
}
};
const BaseRadioCheckboxLabel = React.forwardRef<HTMLLabelElement, BaseRadioCheckboxLabelProps>(
({children, ...props}, ref) => {
return (
Expand Down
Expand Up @@ -5,10 +5,10 @@ import type {LabelProps} from '@twilio-paste/label';

import {FileUploaderContext} from './FileUploaderContext';

export interface FileUploaderLabelProps extends Omit<React.ComponentPropsWithRef<'label'>, 'children'> {
export type FileUploaderLabelProps = Omit<React.ComponentPropsWithRef<'label'>, 'children'> & {
children: LabelProps['children'];
element?: LabelProps['element'];
}
};

export const FileUploaderLabel = React.forwardRef<HTMLLabelElement, FileUploaderLabelProps>(
({children, element = 'FILE_UPLOADER_LABEL', ...props}, ref) => {
Expand Down
29 changes: 25 additions & 4 deletions packages/paste-core/components/label/src/Label.tsx
Expand Up @@ -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<BoxProps, 'element'> {
as?: 'label' | 'legend' | 'div';
type LabelBaseProps = Pick<BoxProps, 'element'> & {
children: NonNullable<React.ReactNode>;
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<HTMLLabelElement, LabelProps>(
(
Expand Down
53 changes: 53 additions & 0 deletions 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}) => (
<Theme.Provider theme="default">{children}</Theme.Provider>
);

const elementAttr = 'data-paste-element';

describe('Meter', () => {
describe('base usage', () => {
it('should render correctly', () => {
render(<Default />, {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(<HiddenValueLabelAriaLabel />, {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(<Customized />);
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(<Customized />);
const meterTwo = screen.getByTestId('meter_two');
expect(meterTwo).toHaveAttribute(elementAttr, 'FOO');
const meterLabelTwo = screen.getByTestId('meter_label_two');
expect(meterLabelTwo).toHaveAttribute(elementAttr, 'FOO_LABEL');
});
});
});
3 changes: 3 additions & 0 deletions packages/paste-core/components/meter/build.js
@@ -0,0 +1,3 @@
const {build} = require('../../../../tools/build/esbuild');

build(require('./package.json'));
72 changes: 72 additions & 0 deletions 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"
}
}
82 changes: 82 additions & 0 deletions 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<BoxProps, 'element'> {
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<HTMLMeterElement, MeterProps>(({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 (
<Box
as="div"
{...meterProps}
role="meter"
id={id}
maxWidth="size30"
position="relative"
element={element}
aria-labelledby={labelledBy}
>
<Box
display="flex"
width="fit-content"
position="absolute"
right="0"
top="spaceNegative70"
element={`${element}_VALUE_LABEL_WRAPPER`}
>
{showValueLabel && (
<Text as="span" element={`${element}_VALUE_LABEL`}>
{meterProps['aria-valuetext']}
</Text>
)}
</Box>
<Box height="10px" backgroundColor="colorBackground" element={`${element}_BAR`} ref={ref}>
<Box width={fillWidth} height="10px" backgroundColor="colorBackgroundAvailable" element={`${element}_FILL`} />
</Box>
</Box>
);
});

Meter.displayName = 'Meter';

export {Meter};
25 changes: 25 additions & 0 deletions 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<BoxProps, 'element'> {
children: string;
htmlFor: string;
}

const MeterLabel = React.forwardRef<HTMLLabelElement, MeterLabelProps>(
({element = 'METER_LABEL', children, htmlFor, ...labelProps}, ref) => {
return (
<Label {...labelProps} as="div" element={element} id={`${htmlFor}${LABEL_SUFFIX}`} ref={ref}>
{children}
</Label>
);
}
);

MeterLabel.displayName = 'MeterLabel';

export {MeterLabel};
1 change: 1 addition & 0 deletions packages/paste-core/components/meter/src/constants.ts
@@ -0,0 +1 @@
export const LABEL_SUFFIX = 'METER_LABEL';
2 changes: 2 additions & 0 deletions packages/paste-core/components/meter/src/index.tsx
@@ -0,0 +1,2 @@
export * from './Meter';
export * from './MeterLabel';

0 comments on commit 7bdac72

Please sign in to comment.