diff --git a/docs/data/api/meter-indicator.json b/docs/data/api/meter-indicator.json new file mode 100644 index 0000000000..06f0c0183f --- /dev/null +++ b/docs/data/api/meter-indicator.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "MeterIndicator", + "imports": [ + "import { Meter } from '@base-ui-components/react/meter';\nconst MeterIndicator = Meter.Indicator;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MeterIndicator", + "forwardsRefTo": "HTMLSpanElement", + "filename": "/packages/react/src/meter/indicator/MeterIndicator.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/meter-root.json b/docs/data/api/meter-root.json new file mode 100644 index 0000000000..1204ebc444 --- /dev/null +++ b/docs/data/api/meter-root.json @@ -0,0 +1,36 @@ +{ + "props": { + "value": { "type": { "name": "number" }, "required": true }, + "aria-label": { "type": { "name": "string" } }, + "aria-labelledby": { "type": { "name": "string" } }, + "aria-valuetext": { "type": { "name": "string" } }, + "className": { "type": { "name": "union", "description": "func
| string" } }, + "getAriaLabel": { + "type": { "name": "func" }, + "signature": { "type": "function(value: number) => string", "describedArgs": ["value"] } + }, + "getAriaValueText": { + "type": { "name": "func" }, + "signature": { "type": "function(value: number) => string", "describedArgs": ["value"] } + }, + "high": { "type": { "name": "number" }, "default": "100" }, + "low": { "type": { "name": "number" }, "default": "0" }, + "max": { "type": { "name": "number" }, "default": "100" }, + "min": { "type": { "name": "number" }, "default": "0" }, + "optimum": { "type": { "name": "number" }, "default": "50" }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "MeterRoot", + "imports": [ + "import { Meter } from '@base-ui-components/react/meter';\nconst MeterRoot = Meter.Root;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MeterRoot", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/react/src/meter/root/MeterRoot.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/meter-track.json b/docs/data/api/meter-track.json new file mode 100644 index 0000000000..9ab94b9fbd --- /dev/null +++ b/docs/data/api/meter-track.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "MeterTrack", + "imports": [ + "import { Meter } from '@base-ui-components/react/meter';\nconst MeterTrack = Meter.Track;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MeterTrack", + "forwardsRefTo": "HTMLSpanElement", + "filename": "/packages/react/src/meter/track/MeterTrack.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/components/meter/MeterIntroduction.js b/docs/data/components/meter/MeterIntroduction.js new file mode 100644 index 0000000000..6ae6f14cb6 --- /dev/null +++ b/docs/data/components/meter/MeterIntroduction.js @@ -0,0 +1,29 @@ +'use client'; +import * as React from 'react'; +import { Meter } from '@base-ui-components/react/meter'; +import classes from './styles.module.css'; + +export default function MeterIntroduction() { + return ( +
+ + + + + + + + +
+ ); +} diff --git a/docs/data/components/meter/MeterIntroduction.tsx b/docs/data/components/meter/MeterIntroduction.tsx new file mode 100644 index 0000000000..6ae6f14cb6 --- /dev/null +++ b/docs/data/components/meter/MeterIntroduction.tsx @@ -0,0 +1,29 @@ +'use client'; +import * as React from 'react'; +import { Meter } from '@base-ui-components/react/meter'; +import classes from './styles.module.css'; + +export default function MeterIntroduction() { + return ( +
+ + + + + + + + +
+ ); +} diff --git a/docs/data/components/meter/meter.mdx b/docs/data/components/meter/meter.mdx new file mode 100644 index 0000000000..862cb8bf79 --- /dev/null +++ b/docs/data/components/meter/meter.mdx @@ -0,0 +1,107 @@ +--- +productId: base-ui +title: React Meter components +description: The Meter component provides a graphical display of a numeric value within a defined range +components: MeterRoot, MeterTrack, MeterIndicator +hooks: useMeterRoot, useMeterIndicator +githubLabel: 'component: meter' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/meter/ +packageName: '@base-ui-components/react' +--- + +# Meter + + + + + + + +## Installation + + + +## Anatomy + +Meter + +- `` is a top-level component that wraps the other components. +- `` renders the rail that represents the full range of possible values. +- `` renders the filled portion of the track. + +```tsx + + + + + +``` + +## Value + +The `value` prop represents the (percentage) value of the Meter component. + +```tsx + + + + + +``` + +## Min/max and high/low + +The `min` and `max` props can be used to establish the lower and upper bound of the range. The default minimum and maximum values are `0` and `100`. + +```tsx + + + + + +``` + +The `high` and `low` props can be used together with `min` and `max` to divide the range into 3 segments: `'low'`, `'medium'`, and `'high'`. +A `[data-segment='low' | 'medium' | 'high']` attribute is set depending on which segment the value lands on. + +```tsx + + + + + +``` + +## Optimum value + +The optimum prop defines whether the low, medium, or high segment of the range is "preferable". For example, for "battery health" higher is better, but for "CPU temperature" lower may be better. A `[data-optimum]` attribute is set when the value is in the "preferable" segment. + +```tsx + + + + + +``` + +## RTL + +Place the component inside any HTML element or component with the HTML dir attribute to change the direction that the `Indicator` fills towards for right-to-left languages: + +```jsx + + + {/* Subcomponents */} + + +``` + +## Overriding default components + +Use the `render` prop to override the rendered element for all subcomponents: + +```jsx +} /> +// or + } /> +``` diff --git a/docs/data/components/meter/styles.module.css b/docs/data/components/meter/styles.module.css new file mode 100644 index 0000000000..cc81605d35 --- /dev/null +++ b/docs/data/components/meter/styles.module.css @@ -0,0 +1,49 @@ +.demo { + --icon-size: 46px; + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + padding: 1rem; +} + +.meter { + display: flex; + flex-flow: column nowrap; + gap: 1rem; + color: var(--gray-text-2); +} + +.track { + position: relative; + width: 48px; + height: 26px; + border-radius: 5px; + border: 3px solid currentColor; + padding: 2px; + display: flex; +} + +.track:after { + content: ''; + background-color: currentColor; + position: absolute; + z-index: 1; + top: 3px; + right: -6px; + width: 3px; + height: 14px; + border-radius: 0 6px 6px 0; +} + +.icon { + position: absolute; + width: var(--icon-size); + height: var(--icon-size); + transform: translate(-3px, -14px); +} + +.indicator { + background-color: rgb(40, 205, 65); + border-radius: 3px; +} diff --git a/docs/data/pages.ts b/docs/data/pages.ts index b0efde5018..51b2adc22e 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -32,6 +32,7 @@ const pages: readonly RouteMetadata[] = [ { pathname: '/components/react-fieldset', title: 'Fieldset' }, { pathname: '/components/react-form', title: 'Form' }, { pathname: '/components/react-menu', title: 'Menu' }, + { pathname: '/components/react-meter', title: 'Meter' }, { pathname: '/components/react-number-field', title: 'Number Field' }, { pathname: '/components/react-popover', title: 'Popover' }, { pathname: '/components/react-preview-card', title: 'Preview Card' }, diff --git a/docs/data/translations/api-docs/meter-indicator/meter-indicator.json b/docs/data/translations/api-docs/meter-indicator/meter-indicator.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/meter-indicator/meter-indicator.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/meter-root/meter-root.json b/docs/data/translations/api-docs/meter-root/meter-root.json new file mode 100644 index 0000000000..1d207a7b5e --- /dev/null +++ b/docs/data/translations/api-docs/meter-root/meter-root.json @@ -0,0 +1,37 @@ +{ + "componentDescription": "", + "propDescriptions": { + "aria-label": { "description": "The label for the Indicator component." }, + "aria-labelledby": { + "description": "An id or space-separated list of ids of elements that label the Indicator component." + }, + "aria-valuetext": { + "description": "A string value that provides a human-readable text alternative for the current value of the meter indicator." + }, + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "getAriaLabel": { + "description": "Accepts a function which returns a string value that provides an accessible name for the Indicator component", + "typeDescriptions": { "value": "The component's value" } + }, + "getAriaValueText": { + "description": "Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the meter indicator.", + "typeDescriptions": { "value": "The component's value to format" } + }, + "high": { + "description": "Sets the lower boundary of the high end of the numeric range represented by the component. If unspecified, or greater than max, it will fall back to max." + }, + "low": { + "description": "Sets the upper boundary of the low end of the numeric range represented by the component. If unspecified, or less than min, it will fall back to min." + }, + "max": { "description": "The maximum value" }, + "min": { "description": "The minimum value" }, + "optimum": { + "description": "Indicates the optimal point in the numeric range represented by the component. If unspecified, it will fall back to the midpoint between min and max." + }, + "render": { "description": "A function to customize rendering of the component." }, + "value": { "description": "The current value." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/meter-track/meter-track.json b/docs/data/translations/api-docs/meter-track/meter-track.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/meter-track/meter-track.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/reference/generated/meter-indicator.json b/docs/reference/generated/meter-indicator.json new file mode 100644 index 0000000000..db3629be94 --- /dev/null +++ b/docs/reference/generated/meter-indicator.json @@ -0,0 +1,14 @@ +{ + "name": "MeterIndicator", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/meter-root.json b/docs/reference/generated/meter-root.json new file mode 100644 index 0000000000..ff189d3ed1 --- /dev/null +++ b/docs/reference/generated/meter-root.json @@ -0,0 +1,64 @@ +{ + "name": "MeterRoot", + "description": "", + "props": { + "value": { + "type": "number", + "required": true, + "description": "The current value." + }, + "aria-label": { + "type": "string", + "description": "The label for the Indicator component." + }, + "aria-labelledby": { + "type": "string", + "description": "An id or space-separated list of ids of elements that label the Indicator component." + }, + "aria-valuetext": { + "type": "string", + "description": "A string value that provides a human-readable text alternative for the current value of the meter indicator." + }, + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "getAriaLabel": { + "type": "function(value: number) => string", + "description": "Accepts a function which returns a string value that provides an accessible name for the Indicator component" + }, + "getAriaValueText": { + "type": "function(value: number) => string", + "description": "Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the meter indicator." + }, + "high": { + "type": "number", + "default": "100", + "description": "Sets the lower boundary of the high end of the numeric range represented by the component.\nIf unspecified, or greater than `max`, it will fall back to `max`." + }, + "low": { + "type": "number", + "default": "0", + "description": "Sets the upper boundary of the low end of the numeric range represented by the component.\nIf unspecified, or less than `min`, it will fall back to `min`." + }, + "max": { + "type": "number", + "default": "100", + "description": "The maximum value" + }, + "min": { + "type": "number", + "default": "0", + "description": "The minimum value" + }, + "optimum": { + "type": "number", + "default": "50", + "description": "Indicates the optimal point in the numeric range represented by the component.\nIf unspecified, it will fall back to the midpoint between `min` and `max`." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/meter-track.json b/docs/reference/generated/meter-track.json new file mode 100644 index 0000000000..15ee067a25 --- /dev/null +++ b/docs/reference/generated/meter-track.json @@ -0,0 +1,14 @@ +{ + "name": "MeterTrack", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/src/app/experiments/meter.module.css b/docs/src/app/experiments/meter.module.css new file mode 100644 index 0000000000..6f589d450c --- /dev/null +++ b/docs/src/app/experiments/meter.module.css @@ -0,0 +1,156 @@ +.wrapper { + font-family: system-ui, sans-serif; + background-color: var(--gray-container-1); + display: flex; + flex-flow: column nowrap; + gap: 3rem; + padding: 3rem; +} + +.grid { + display: grid; + grid-template-columns: 40rem 40rem; + width: 100%; +} + +.demo { + display: flex; + flex-flow: row nowrap; + align-items: center; + margin-left: 4rem; +} + +.meter { + display: flex; + flex-flow: column nowrap; + gap: 1rem; + color: var(--gray-text-2); + width: 30rem; +} + +.track { + position: relative; + width: 100%; + height: 1.5rem; + border-radius: 3px; + border: 2px solid currentColor; + padding: 2px; + display: flex; +} + +.indicator { + --red: rgb(255, 59, 48); + --yellow: rgb(255, 204, 0); + --green: rgb(40, 205, 65); + + position: relative; + background-color: var(--gray-text-1); + border-radius: 3px; + transition: background-color 200ms; +} + +.indicator[data-segment='low'] { + background-color: var(--red); +} + +.indicator[data-segment='low'][data-optimum] { + background-color: var(--green); +} + +.indicator[data-segment='medium'] { + background-color: var(--yellow); +} + +.indicator[data-segment='high'][data-optimum] { + background-color: var(--green); +} + +.indicator[data-segment='high'] { + background-color: var(--red); +} + +.controls { + display: flex; + flex-flow: column nowrap; + gap: 1rem; + align-items: flex-start; + padding-top: 2rem; + padding-bottom: 2rem; +} + +.label { + font-family: monospace; + font-weight: 400; + font-size: 1rem; + cursor: unset; + color: var(--gray-text-2); +} + +.group { + display: flex; + align-items: center; + margin-top: 0.25rem; + border-radius: 0.25rem; + border: 1px solid var(--gray-outline-2); + border-color: var(--gray-outline-2); + overflow: hidden; +} + +.group:focus-within { + outline: 2px solid var(--code-4); + border-color: var(--code-6); +} + +.input { + position: relative; + z-index: 10; + align-self: stretch; + padding: 0.25rem 0.5rem; + font-size: 1rem; + line-height: 1.5; + border: none; + background-color: #fff; + color: var(--gray-text-2); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + overflow: hidden; + max-width: 150px; + font: inherit; +} + +.input:focus { + outline: none; + z-index: 10; +} + +.button { + position: relative; + border: none; + font-weight: bold; + transition-property: background-color, border-color, color; + transition-duration: 100ms; + padding: 0.5rem 0.75rem; + flex: 1; + align-self: stretch; + font-family: inherit; + color: var(--gray-text-1); + margin: 0; + font-family: math; + background-color: var(--gray-container-2); +} + +.button:hover { + background-color: var(--gray-surface-1); + color: var(--gray-text-2); +} + +.decrement { + border-right: 1px solid var(--gray-outline-2); + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.increment { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 1px solid var(--gray-outline-2); +} diff --git a/docs/src/app/experiments/meter.tsx b/docs/src/app/experiments/meter.tsx new file mode 100644 index 0000000000..7b9038cf26 --- /dev/null +++ b/docs/src/app/experiments/meter.tsx @@ -0,0 +1,124 @@ +'use client'; +import * as React from 'react'; +import clsx from 'clsx'; +import { NumberField } from '@base-ui-components/react/number-field'; +import { Meter } from '@base-ui-components/react/meter'; +import classes from './meter.module.css'; + +interface Range { + value: number; + min: number; + max: number; + high: number; + low: number; + optimum: number; +} + +export default function MeterIntroduction() { + const [range, setRange] = React.useState({ + value: 55, + min: 0, + max: 100, + high: 70, + low: 20, + optimum: 80, + }); + + function setValue(name: string, value: number | null) { + if (value != null) { + setRange({ + ...range, + [name]: value, + }); + } + } + + return ( +
+
+
+ + + + + +
+
+ {['value', 'min', 'max', 'high', 'low', 'optimum'].map((v) => { + return ( + + ); + })} +
+
+ +
+
+          This is the same meter as above but wrapped in a div with the `dir="rtl"`
+          attribute so it fills from right-to-left
+        
+ + + + + +
+
+ ); +} + +function Input(props: { + name: string; + label: string; + value: number; + setValue: (key: string, value: number | null) => void; +}) { + const { name, label, value, setValue } = props; + const id = `${name}-input`; + return ( + setValue(name, newValue)} + allowWheelScrub + > + + + + − + + + + + + + + + ); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 857bf20b0d..d166fd0a07 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -8,6 +8,7 @@ export * from './field'; export * from './fieldset'; export * from './form'; export * from './menu'; +export * from './meter'; export * from './number-field'; export * from './popover'; export * from './preview-card'; diff --git a/packages/react/src/meter/index.parts.ts b/packages/react/src/meter/index.parts.ts new file mode 100644 index 0000000000..7add1b32b5 --- /dev/null +++ b/packages/react/src/meter/index.parts.ts @@ -0,0 +1,3 @@ +export { MeterRoot as Root } from './root/MeterRoot'; +export { MeterTrack as Track } from './track/MeterTrack'; +export { MeterIndicator as Indicator } from './indicator/MeterIndicator'; diff --git a/packages/react/src/meter/index.ts b/packages/react/src/meter/index.ts new file mode 100644 index 0000000000..170d463ddf --- /dev/null +++ b/packages/react/src/meter/index.ts @@ -0,0 +1 @@ +export * as Meter from './index.parts'; diff --git a/packages/react/src/meter/indicator/MeterIndicator.test.tsx b/packages/react/src/meter/indicator/MeterIndicator.test.tsx new file mode 100644 index 0000000000..5bea3ee440 --- /dev/null +++ b/packages/react/src/meter/indicator/MeterIndicator.test.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { Meter } from '@base-ui-components/react/meter'; +import { createRenderer, describeConformance } from '#test-utils'; +import { MeterRootContext } from '../root/MeterRootContext'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +const contextValue: MeterRootContext = { + max: 100, + min: 0, + value: 30, + percentageValue: 30, + segment: 'low', + isOptimal: false, + state: { + max: 100, + min: 0, + segment: 'low', + isOptimal: false, + }, +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => { + return render( + {node}, + ); + }, + refInstanceof: window.HTMLSpanElement, + })); + + describe('internal styles', () => { + it('sets positioning styles', async function test(t = {}) { + if (isJSDOM) { + // @ts-expect-error to support mocha and vitest + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this?.skip?.() || t?.skip(); + } + + const { getByTestId } = await render( + + + + + , + ); + + const indicator = getByTestId('indicator'); + + expect(indicator).toHaveComputedStyle({ + left: '0px', + width: '33%', + }); + }); + }); +}); diff --git a/packages/react/src/meter/indicator/MeterIndicator.tsx b/packages/react/src/meter/indicator/MeterIndicator.tsx new file mode 100644 index 0000000000..1eadb35d92 --- /dev/null +++ b/packages/react/src/meter/indicator/MeterIndicator.tsx @@ -0,0 +1,70 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useMeterIndicator } from './useMeterIndicator'; +import { MeterRoot } from '../root/MeterRoot'; +import { useMeterRootContext } from '../root/MeterRootContext'; +import { meterStyleHookMapping } from '../root/styleHooks'; +import { BaseUIComponentProps } from '../../utils/types'; +/** + * + * Demos: + * + * - [Meter](https://base-ui.com/components/react-meter/) + * + * API: + * + * - [MeterIndicator API](https://base-ui.com/components/react-meter/#api-reference-MeterIndicator) + */ +const MeterIndicator = React.forwardRef(function MeterIndicator( + props: MeterIndicator.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...otherProps } = props; + + const { percentageValue, state } = useMeterRootContext(); + + const { getRootProps } = useMeterIndicator({ + percentageValue, + }); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'span', + state, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: meterStyleHookMapping, + }); + + return renderElement(); +}); + +namespace MeterIndicator { + export interface State extends MeterRoot.State {} + + export interface Props extends BaseUIComponentProps<'span', State> {} +} + +export { MeterIndicator }; + +MeterIndicator.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; diff --git a/packages/react/src/meter/indicator/useMeterIndicator.ts b/packages/react/src/meter/indicator/useMeterIndicator.ts new file mode 100644 index 0000000000..c87bda3e67 --- /dev/null +++ b/packages/react/src/meter/indicator/useMeterIndicator.ts @@ -0,0 +1,45 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; + +function useMeterIndicator( + parameters: useMeterIndicator.Parameters, +): useMeterIndicator.ReturnValue { + const { percentageValue } = parameters; + + const getStyles = React.useCallback(() => { + return { + insetInlineStart: 0, + width: `${percentageValue}%`, + }; + }, [percentageValue]); + + const getRootProps: useMeterIndicator.ReturnValue['getRootProps'] = React.useCallback( + (externalProps = {}) => + mergeReactProps<'span'>(externalProps, { + style: getStyles(), + }), + [getStyles], + ); + + return { + getRootProps, + }; +} + +namespace useMeterIndicator { + export interface Parameters { + /** + * The current value. + */ + percentageValue: number; + } + + export interface ReturnValue { + getRootProps: ( + externalProps?: React.ComponentPropsWithRef<'span'>, + ) => React.ComponentPropsWithRef<'span'>; + } +} + +export { useMeterIndicator }; diff --git a/packages/react/src/meter/root/MeterRoot.test.tsx b/packages/react/src/meter/root/MeterRoot.test.tsx new file mode 100644 index 0000000000..3ca8f4ba2e --- /dev/null +++ b/packages/react/src/meter/root/MeterRoot.test.tsx @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import * as React from 'react'; +import { Meter } from '@base-ui-components/react/meter'; +import { createRenderer, describeConformance } from '#test-utils'; +import type { MeterRoot } from './MeterRoot'; + +function TestMeter(props: MeterRoot.Props) { + return ( + + + + + + ); +} + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render, + refInstanceof: window.HTMLDivElement, + })); + + it('renders a meter', async () => { + const { getByRole } = await render( + + + + + , + ); + + expect(getByRole('meter')).to.have.attribute('aria-valuenow', '0.3'); + }); + + describe('ARIA attributes', () => { + it('sets the correct aria attributes', async () => { + const { getByRole } = await render( + + + + + , + ); + + const meter = getByRole('meter'); + + expect(meter).to.have.attribute('aria-valuenow', '0.3'); + expect(meter).to.have.attribute('aria-valuemin', '0'); + expect(meter).to.have.attribute('aria-valuemax', '100'); + expect(meter).to.have.attribute('aria-valuetext', '30%'); + }); + + it('should update aria-valuenow when value changes', async () => { + const { getByRole, setProps } = await render(); + const meter = getByRole('meter'); + setProps({ value: 77 }); + expect(meter).to.have.attribute('aria-valuenow', '0.77'); + }); + }); +}); diff --git a/packages/react/src/meter/root/MeterRoot.tsx b/packages/react/src/meter/root/MeterRoot.tsx new file mode 100644 index 0000000000..a8d52b3dde --- /dev/null +++ b/packages/react/src/meter/root/MeterRoot.tsx @@ -0,0 +1,174 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useMeterRoot } from './useMeterRoot'; +import { MeterRootContext } from './MeterRootContext'; +import { BaseUIComponentProps } from '../../utils/types'; +import { meterStyleHookMapping } from './styleHooks'; + +/** + * + * Demos: + * + * - [Meter](https://base-ui.com/components/react-meter/) + * + * API: + * + * - [MeterRoot API](https://base-ui.com/components/react-meter/#api-reference-MeterRoot) + */ +const MeterRoot = React.forwardRef(function MeterRoot( + props: MeterRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-valuetext': ariaValuetext, + getAriaLabel, + getAriaValueText, + max = 100, + min = 0, + low, + high, + optimum, + value, + render, + className, + ...otherProps + } = props; + + const { getRootProps, ...meter } = useMeterRoot({ + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-valuetext': ariaValuetext, + getAriaLabel, + getAriaValueText, + max, + min, + low, + high, + optimum, + value, + }); + + const state: MeterRoot.State = React.useMemo( + () => ({ + max, + min, + segment: meter.segment, + isOptimal: meter.isOptimal, + }), + [max, min, meter.segment, meter.isOptimal], + ); + + const contextValue: MeterRootContext = React.useMemo( + () => ({ + ...meter, + state, + }), + [meter, state], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + state, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: meterStyleHookMapping, + }); + + return ( + {renderElement()} + ); +}); + +namespace MeterRoot { + export type State = { + max: number; + min: number; + segment: useMeterRoot.Segment; + isOptimal: boolean; + }; + + export interface Props extends useMeterRoot.Parameters, BaseUIComponentProps<'div', State> {} +} + +export { MeterRoot }; + +MeterRoot.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The label for the Indicator component. + */ + 'aria-label': PropTypes.string, + /** + * An id or space-separated list of ids of elements that label the Indicator component. + */ + 'aria-labelledby': PropTypes.string, + /** + * A string value that provides a human-readable text alternative for the current value of the meter indicator. + */ + 'aria-valuetext': PropTypes.string, + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * Accepts a function which returns a string value that provides an accessible name for the Indicator component + * @param {number} value The component's value + * @returns {string} + */ + getAriaLabel: PropTypes.func, + /** + * Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the meter indicator. + * @param {number} value The component's value to format + * @returns {string} + */ + getAriaValueText: PropTypes.func, + /** + * Sets the lower boundary of the high end of the numeric range represented by the component. + * If unspecified, or greater than `max`, it will fall back to `max`. + * @default 100 + */ + high: PropTypes.number, + /** + * Sets the upper boundary of the low end of the numeric range represented by the component. + * If unspecified, or less than `min`, it will fall back to `min`. + * @default 0 + */ + low: PropTypes.number, + /** + * The maximum value + * @default 100 + */ + max: PropTypes.number, + /** + * The minimum value + * @default 0 + */ + min: PropTypes.number, + /** + * Indicates the optimal point in the numeric range represented by the component. + * If unspecified, it will fall back to the midpoint between `min` and `max`. + * @default 50 + */ + optimum: PropTypes.number, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * The current value. + */ + value: PropTypes.number.isRequired, +} as any; diff --git a/packages/react/src/meter/root/MeterRootContext.tsx b/packages/react/src/meter/root/MeterRootContext.tsx new file mode 100644 index 0000000000..7b47b42c90 --- /dev/null +++ b/packages/react/src/meter/root/MeterRootContext.tsx @@ -0,0 +1,28 @@ +'use client'; +import * as React from 'react'; +import type { MeterRoot } from './MeterRoot'; +import type { useMeterRoot } from './useMeterRoot'; + +export type MeterRootContext = Omit & { + state: MeterRoot.State; +}; + +/** + * @ignore - internal component. + */ +export const MeterRootContext = React.createContext(undefined); + +if (process.env.NODE_ENV !== 'production') { + MeterRootContext.displayName = 'MeterRootContext'; +} + +export function useMeterRootContext() { + const context = React.useContext(MeterRootContext); + if (context === undefined) { + throw new Error( + 'Base UI: MeterRootContext is missing. Meter parts must be placed within .', + ); + } + + return context; +} diff --git a/packages/react/src/meter/root/styleHooks.ts b/packages/react/src/meter/root/styleHooks.ts new file mode 100644 index 0000000000..355370c2d3 --- /dev/null +++ b/packages/react/src/meter/root/styleHooks.ts @@ -0,0 +1,16 @@ +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import type { MeterRoot } from './MeterRoot'; + +export const meterStyleHookMapping: CustomStyleHookMapping = { + max: () => null, + min: () => null, + isOptimal: (value: boolean) => { + if (value) { + return { + 'data-optimum': '', + }; + } + + return null; + }, +}; diff --git a/packages/react/src/meter/root/useMeterRoot.ts b/packages/react/src/meter/root/useMeterRoot.ts new file mode 100644 index 0000000000..3dc71545eb --- /dev/null +++ b/packages/react/src/meter/root/useMeterRoot.ts @@ -0,0 +1,179 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { valueToPercent } from '../../utils/valueToPercent'; + +function useMeterRoot(parameters: useMeterRoot.Parameters): useMeterRoot.ReturnValue { + const { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-valuetext': ariaValuetext, + getAriaLabel, + getAriaValueText, + max = 100, + min = 0, + high: highParam = NaN, + low: lowParam = NaN, + optimum: optimumParam = NaN, + value, + } = parameters; + + const percentageValue = valueToPercent(value, min, max); + + const high = highParam ?? max; + const low = lowParam ?? min; + const optimum = optimumParam ?? (max + min) / 2; + + let segment: useMeterRoot.Segment | undefined; + + if (value <= low) { + segment = 'low'; + } else if (value >= high) { + segment = 'high'; + } else { + segment = 'medium'; + } + + // 'low' is preferred if `min <= optimum <= low` + // 'high' is preferred if `high <= optimum <= max` + let isOptimal = false; + + if (min <= optimum && optimum <= low) { + isOptimal = segment === 'low'; + } else if (high <= optimum && optimum <= max) { + isOptimal = segment === 'high'; + } + + const getRootProps: useMeterRoot.ReturnValue['getRootProps'] = React.useCallback( + (externalProps = {}) => + mergeReactProps<'div'>(externalProps, { + 'aria-label': getAriaLabel ? getAriaLabel(value) : ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-valuemax': max, + 'aria-valuemin': min, + 'aria-valuenow': percentageValue / 100, + 'aria-valuetext': getAriaValueText + ? getAriaValueText(value) + : (ariaValuetext ?? `${percentageValue}%`), + role: 'meter', + }), + [ + ariaLabel, + ariaLabelledby, + ariaValuetext, + getAriaLabel, + getAriaValueText, + max, + min, + value, + percentageValue, + ], + ); + + return { + getRootProps, + max, + min, + value, + percentageValue, + segment, + isOptimal, + }; +} + +namespace useMeterRoot { + export type Segment = 'low' | 'medium' | 'high'; + + export interface Parameters { + /** + * The label for the Indicator component. + */ + 'aria-label'?: string; + /** + * An id or space-separated list of ids of elements that label the Indicator component. + */ + 'aria-labelledby'?: string; + /** + * A string value that provides a human-readable text alternative for the current value of the meter indicator. + */ + 'aria-valuetext'?: string; + /** + * Accepts a function which returns a string value that provides an accessible name for the Indicator component + * @param {number} value The component's value + * @returns {string} + */ + getAriaLabel?: (value: number) => string; + /** + * Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the meter indicator. + * @param {number} value The component's value to format + * @returns {string} + */ + getAriaValueText?: (value: number) => string; + /** + * Sets the lower boundary of the high end of the numeric range represented by the component. + * If unspecified, or greater than `max`, it will fall back to `max`. + * @default 100 + */ + high?: number; + /** + * Sets the upper boundary of the low end of the numeric range represented by the component. + * If unspecified, or less than `min`, it will fall back to `min`. + * @default 0 + */ + low?: number; + /** + * The maximum value + * @default 100 + */ + max?: number; + /** + * The minimum value + * @default 0 + */ + min?: number; + /** + * Indicates the optimal point in the numeric range represented by the component. + * If unspecified, it will fall back to the midpoint between `min` and `max`. + * @default 50 + */ + optimum?: number; + /** + * The current value. + */ + value: number; + } + + export interface ReturnValue { + getRootProps: ( + externalProps?: React.ComponentPropsWithRef<'div'>, + ) => React.ComponentPropsWithRef<'div'>; + /** + * The maximum value + */ + max: number; + /** + * The minimum value + */ + min: number; + /** + * Value of the component + */ + value: number; + /** + * Value represented as a percentage of the range between `min` and `max`. + */ + percentageValue: number; + /** + * Which segment the value falls in, where the segment boundaries are defined + * by the `min`, `max`, `high`, `low`, and `optimum` props. + */ + segment: Segment; + /** + * Whether the value is in the preferred end - higher or lower values - of + * the numeric range represented by the component. + */ + isOptimal: boolean; + } +} + +export { useMeterRoot }; diff --git a/packages/react/src/meter/track/MeterTrack.test.tsx b/packages/react/src/meter/track/MeterTrack.test.tsx new file mode 100644 index 0000000000..28d4dd6189 --- /dev/null +++ b/packages/react/src/meter/track/MeterTrack.test.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { Meter } from '@base-ui-components/react/meter'; +import { createRenderer, describeConformance } from '#test-utils'; +import { MeterRootContext } from '../root/MeterRootContext'; + +const contextValue: MeterRootContext = { + max: 100, + min: 0, + value: 30, + percentageValue: 30, + segment: 'low', + isOptimal: false, + state: { + max: 100, + min: 0, + segment: 'low', + isOptimal: false, + }, +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => { + return render( + {node}, + ); + }, + refInstanceof: window.HTMLSpanElement, + })); +}); diff --git a/packages/react/src/meter/track/MeterTrack.tsx b/packages/react/src/meter/track/MeterTrack.tsx new file mode 100644 index 0000000000..1cd9129c9d --- /dev/null +++ b/packages/react/src/meter/track/MeterTrack.tsx @@ -0,0 +1,64 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useMeterRootContext } from '../root/MeterRootContext'; +import { MeterRoot } from '../root/MeterRoot'; +import { meterStyleHookMapping } from '../root/styleHooks'; +import { BaseUIComponentProps } from '../../utils/types'; +/** + * + * Demos: + * + * - [Meter](https://base-ui.com/components/react-meter/) + * + * API: + * + * - [MeterTrack API](https://base-ui.com/components/react-meter/#api-reference-MeterTrack) + */ +const MeterTrack = React.forwardRef(function MeterTrack( + props: MeterTrack.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...otherProps } = props; + + const { state } = useMeterRootContext(); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'span', + state, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: meterStyleHookMapping, + }); + + return renderElement(); +}); + +namespace MeterTrack { + export interface State extends MeterRoot.State {} + + export interface Props extends BaseUIComponentProps<'span', State> {} +} + +export { MeterTrack }; + +MeterTrack.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; diff --git a/packages/react/src/progress/indicator/useProgressIndicator.ts b/packages/react/src/progress/indicator/useProgressIndicator.ts index 73a4e0ecc1..08a5e2ae52 100644 --- a/packages/react/src/progress/indicator/useProgressIndicator.ts +++ b/packages/react/src/progress/indicator/useProgressIndicator.ts @@ -1,12 +1,9 @@ 'use client'; import * as React from 'react'; import { mergeReactProps } from '../../utils/mergeReactProps'; +import { valueToPercent } from '../../utils/valueToPercent'; import { ProgressDirection } from '../root/useProgressRoot'; -function valueToPercent(value: number, min: number, max: number) { - return ((value - min) * 100) / (max - min); -} - function useProgressIndicator( parameters: useProgressIndicator.Parameters, ): useProgressIndicator.ReturnValue { diff --git a/packages/react/src/slider/root/useSliderRoot.ts b/packages/react/src/slider/root/useSliderRoot.ts index 191e8f08b7..7bc6f45368 100644 --- a/packages/react/src/slider/root/useSliderRoot.ts +++ b/packages/react/src/slider/root/useSliderRoot.ts @@ -8,7 +8,8 @@ import { ownerDocument } from '../../utils/owner'; import { useControlled } from '../../utils/useControlled'; import { useForkRef } from '../../utils/useForkRef'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; -import { percentToValue, roundValueToStep, valueToPercent } from '../utils'; +import { valueToPercent } from '../../utils/valueToPercent'; +import { percentToValue, roundValueToStep } from '../utils'; import { useFieldRootContext } from '../../field/root/FieldRootContext'; import { useId } from '../../utils/useId'; import { useFieldControlValidation } from '../../field/control/useFieldControlValidation'; diff --git a/packages/react/src/slider/utils.ts b/packages/react/src/slider/utils.ts index 95927f20ba..ed473cffd4 100644 --- a/packages/react/src/slider/utils.ts +++ b/packages/react/src/slider/utils.ts @@ -19,7 +19,3 @@ export function roundValueToStep(value: number, step: number, min: number) { const nearest = Math.round((value - min) / step) * step + min; return Number(nearest.toFixed(getDecimalPrecision(step))); } - -export function valueToPercent(value: number, min: number, max: number) { - return ((value - min) * 100) / (max - min); -} diff --git a/packages/react/src/utils/valueToPercent.ts b/packages/react/src/utils/valueToPercent.ts new file mode 100644 index 0000000000..9886ee07f2 --- /dev/null +++ b/packages/react/src/utils/valueToPercent.ts @@ -0,0 +1,3 @@ +export function valueToPercent(value: number, min: number, max: number) { + return ((value - min) * 100) / (max - min); +}