diff --git a/docs/data/api/meter-indicator.json b/docs/data/api/meter-indicator.json new file mode 100644 index 000000000..c5145f32d --- /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/react/Meter';\nconst MeterIndicator = Meter.Indicator;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MeterIndicator", + "forwardsRefTo": "HTMLSpanElement", + "filename": "/packages/mui-base/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 000000000..f9d9812ca --- /dev/null +++ b/docs/data/api/meter-root.json @@ -0,0 +1,38 @@ +{ + "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" } }, + "direction": { + "type": { "name": "enum", "description": "'ltr'
| 'rtl'" }, + "default": "'ltr'" + }, + "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/react/Meter';\nconst MeterRoot = Meter.Root;"], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MeterRoot", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/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 000000000..beee62e88 --- /dev/null +++ b/docs/data/api/meter-track.json @@ -0,0 +1,17 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "MeterTrack", + "imports": ["import { Meter } from '@base_ui/react/Meter';\nconst MeterTrack = Meter.Track;"], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "MeterTrack", + "forwardsRefTo": "HTMLSpanElement", + "filename": "/packages/mui-base/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 000000000..90018b06a --- /dev/null +++ b/docs/data/components/meter/MeterIntroduction.js @@ -0,0 +1,35 @@ +'use client'; +import * as React from 'react'; +import { Meter } from '@base_ui/react/Meter'; +import classes from './styles.module.css'; + +export default function MeterIntroduction() { + return ( +
+ + + + + + +
+ ); +} + +function BoltIcon(props) { + return ( + + + + ); +} diff --git a/docs/data/components/meter/MeterIntroduction.tsx b/docs/data/components/meter/MeterIntroduction.tsx new file mode 100644 index 000000000..01790be82 --- /dev/null +++ b/docs/data/components/meter/MeterIntroduction.tsx @@ -0,0 +1,35 @@ +'use client'; +import * as React from 'react'; +import { Meter } from '@base_ui/react/Meter'; +import classes from './styles.module.css'; + +export default function MeterIntroduction() { + return ( +
+ + + + + + +
+ ); +} + +function BoltIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/docs/data/components/meter/MeterIntroduction.tsx.preview b/docs/data/components/meter/MeterIntroduction.tsx.preview new file mode 100644 index 000000000..5d04e0bc0 --- /dev/null +++ b/docs/data/components/meter/MeterIntroduction.tsx.preview @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/data/components/meter/meter.mdx b/docs/data/components/meter/meter.mdx new file mode 100644 index 000000000..6d095bc3e --- /dev/null +++ b/docs/data/components/meter/meter.mdx @@ -0,0 +1,38 @@ +--- +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/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 + + + + + +``` diff --git a/docs/data/components/meter/styles.module.css b/docs/data/components/meter/styles.module.css new file mode 100644 index 000000000..0a16c71b2 --- /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: 38px; + height: 16px; + 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 e26c851b4..08f84edb8 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -31,6 +31,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 000000000..4bc12cf1e --- /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 000000000..9e4520bd2 --- /dev/null +++ b/docs/data/translations/api-docs/meter-root/meter-root.json @@ -0,0 +1,38 @@ +{ + "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." + }, + "direction": { "description": "The direction that the meter fills towards" }, + "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 000000000..4bc12cf1e --- /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/src/app/experiments/meter.module.css b/docs/src/app/experiments/meter.module.css new file mode 100644 index 000000000..ae80c123a --- /dev/null +++ b/docs/src/app/experiments/meter.module.css @@ -0,0 +1,143 @@ +.grid { + font-family: system-ui, sans-serif; + display: grid; + grid-template-columns: 640px auto; +} + +.demo { + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +.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 { + position: relative; + background-color: var(--gray-text-1); + border-radius: 3px; + transition: background-color 200ms; +} + +.indicator[data-segment='low'] { + background-color: red; +} + +.indicator[data-segment='low'][data-optimum] { + background-color: green; +} + +.indicator[data-segment='medium'] { + background-color: yellow; +} + +.indicator[data-segment='high'][data-optimum] { + background-color: green; +} + +.indicator[data-segment='high'] { + background-color: red; +} + +.controls { + display: flex; + flex-flow: column nowrap; + gap: 1rem; +} + +.scrubarea { + cursor: ns-resize; + font-weight: bold; + user-select: none; +} + +.label { + font-family: monospace; + font-weight: 400; + font-size: 1rem; + cursor: unset; + color: var(--gray-text-2); +} + +.scrubareacursor { + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.3)); +} + +.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-2); + margin: 0; + font-family: math; +} + +.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 000000000..1d812cf26 --- /dev/null +++ b/docs/src/app/experiments/meter.tsx @@ -0,0 +1,119 @@ +'use client'; +import * as React from 'react'; +import clsx from 'clsx'; +import { NumberField } from '@base_ui/react/NumberField'; +import { Meter } from '@base_ui/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 ( + + ); + })} +
+
+ ); +} + +function Input(props: { + name: string; + label: string; + value: number; + setValue: (key: string, value: number | null) => void; +}) { + const { name, label, value, setValue } = props; + const id = React.useId(); + return ( + setValue(name, newValue)} + allowWheelScrub + > + + + + + + + + + + + − + + + + + + + + + ); +} diff --git a/packages/mui-base/src/Meter/Indicator/MeterIndicator.test.tsx b/packages/mui-base/src/Meter/Indicator/MeterIndicator.test.tsx new file mode 100644 index 000000000..841bc8672 --- /dev/null +++ b/packages/mui-base/src/Meter/Indicator/MeterIndicator.test.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { Meter } from '@base_ui/react/Meter'; +import { createRenderer, describeConformance } from '#test-utils'; +import { MeterRootContext } from '../Root/MeterRootContext'; + +const contextValue: MeterRootContext = { + direction: 'ltr', + max: 100, + min: 0, + value: 30, + percentageValue: 30, + segment: 'low', + isOptimal: false, + ownerState: { + direction: 'ltr', + 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() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const { getByTestId } = await render( + + + + + , + ); + + const indicator = getByTestId('indicator'); + + expect(indicator).toHaveComputedStyle({ + left: '0px', + width: '33%', + }); + }); + }); +}); diff --git a/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx b/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx new file mode 100644 index 000000000..f1509da31 --- /dev/null +++ b/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx @@ -0,0 +1,71 @@ +'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.netlify.app/components/react-meter/) + * + * API: + * + * - [MeterIndicator API](https://base-ui.netlify.app/components/react-meter/#api-reference-MeterIndicator) + */ +const MeterIndicator = React.forwardRef(function MeterIndicator( + props: MeterIndicator.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...otherProps } = props; + + const { direction, percentageValue, ownerState } = useMeterRootContext(); + + const { getRootProps } = useMeterIndicator({ + direction, + percentageValue, + }); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'span', + ownerState, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: meterStyleHookMapping, + }); + + return renderElement(); +}); + +namespace MeterIndicator { + export interface OwnerState extends MeterRoot.OwnerState {} + + export interface Props extends BaseUIComponentProps<'span', OwnerState> {} +} + +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/mui-base/src/Meter/Indicator/useMeterIndicator.ts b/packages/mui-base/src/Meter/Indicator/useMeterIndicator.ts new file mode 100644 index 000000000..6c1c258d0 --- /dev/null +++ b/packages/mui-base/src/Meter/Indicator/useMeterIndicator.ts @@ -0,0 +1,53 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { MeterDirection } from '../Root/useMeterRoot'; + +function useMeterIndicator( + parameters: useMeterIndicator.Parameters, +): useMeterIndicator.ReturnValue { + const { direction, percentageValue } = parameters; + + const isRtl = direction === 'rtl'; + + const getStyles = React.useCallback(() => { + return { + [isRtl ? 'right' : 'left']: 0, + width: `${percentageValue}%`, + }; + }, [isRtl, percentageValue]); + + const getRootProps: useMeterIndicator.ReturnValue['getRootProps'] = React.useCallback( + (externalProps = {}) => + mergeReactProps<'span'>(externalProps, { + style: getStyles(), + }), + [getStyles], + ); + + return { + getRootProps, + }; +} + +namespace useMeterIndicator { + export interface Parameters { + /** + * The direction that the meter fills towards + * @default 'ltr' + */ + direction?: MeterDirection; + /** + * The current value. + */ + percentageValue: number; + } + + export interface ReturnValue { + getRootProps: ( + externalProps?: React.ComponentPropsWithRef<'span'>, + ) => React.ComponentPropsWithRef<'span'>; + } +} + +export { useMeterIndicator }; diff --git a/packages/mui-base/src/Meter/Root/MeterRoot.test.tsx b/packages/mui-base/src/Meter/Root/MeterRoot.test.tsx new file mode 100644 index 000000000..6871edb51 --- /dev/null +++ b/packages/mui-base/src/Meter/Root/MeterRoot.test.tsx @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import * as React from 'react'; +import { Meter } from '@base_ui/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', '30'); + }); + + 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', '30'); + 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', '77'); + }); + }); +}); diff --git a/packages/mui-base/src/Meter/Root/MeterRoot.tsx b/packages/mui-base/src/Meter/Root/MeterRoot.tsx new file mode 100644 index 000000000..85dbbcadb --- /dev/null +++ b/packages/mui-base/src/Meter/Root/MeterRoot.tsx @@ -0,0 +1,183 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { type MeterDirection, useMeterRoot } from './useMeterRoot'; +import { MeterRootContext } from './MeterRootContext'; +import { BaseUIComponentProps } from '../../utils/types'; +import { meterStyleHookMapping } from './styleHooks'; + +/** + * + * Demos: + * + * - [Meter](https://base-ui.netlify.app/components/react-meter/) + * + * API: + * + * - [MeterRoot API](https://base-ui.netlify.app/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, + direction = 'ltr', + 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, + direction, + getAriaLabel, + getAriaValueText, + max, + min, + low, + high, + optimum, + value, + }); + + const ownerState: MeterRoot.OwnerState = React.useMemo( + () => ({ + direction, + max, + min, + segment: meter.segment, + isOptimal: meter.isOptimal, + }), + [direction, max, min, meter.segment, meter.isOptimal], + ); + + const contextValue: MeterRootContext = React.useMemo( + () => ({ + ...meter, + ownerState, + }), + [meter, ownerState], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + ownerState, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: meterStyleHookMapping, + }); + + return ( + {renderElement()} + ); +}); + +namespace MeterRoot { + export type OwnerState = { + direction: MeterDirection; + max: number; + min: number; + segment: useMeterRoot.Segment; + isOptimal: boolean; + }; + + export interface Props extends useMeterRoot.Parameters, BaseUIComponentProps<'div', OwnerState> {} +} + +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]), + /** + * The direction that the meter fills towards + * @default 'ltr' + */ + direction: PropTypes.oneOf(['ltr', 'rtl']), + /** + * 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/mui-base/src/Meter/Root/MeterRootContext.tsx b/packages/mui-base/src/Meter/Root/MeterRootContext.tsx new file mode 100644 index 000000000..eb37f7574 --- /dev/null +++ b/packages/mui-base/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 & { + ownerState: MeterRoot.OwnerState; +}; + +/** + * @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/mui-base/src/Meter/Root/styleHooks.ts b/packages/mui-base/src/Meter/Root/styleHooks.ts new file mode 100644 index 000000000..935654891 --- /dev/null +++ b/packages/mui-base/src/Meter/Root/styleHooks.ts @@ -0,0 +1,17 @@ +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import type { MeterRoot } from './MeterRoot'; + +export const meterStyleHookMapping: CustomStyleHookMapping = { + direction: () => null, + max: () => null, + min: () => null, + isOptimal: (value: boolean) => { + if (value) { + return { + 'data-optimum': '', + }; + } + + return null; + }, +}; diff --git a/packages/mui-base/src/Meter/Root/useMeterRoot.ts b/packages/mui-base/src/Meter/Root/useMeterRoot.ts new file mode 100644 index 000000000..018524281 --- /dev/null +++ b/packages/mui-base/src/Meter/Root/useMeterRoot.ts @@ -0,0 +1,194 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { valueToPercent } from '../../utils/valueToPercent'; + +export type MeterDirection = 'ltr' | 'rtl'; + +function useMeterRoot(parameters: useMeterRoot.Parameters): useMeterRoot.ReturnValue { + const { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-valuetext': ariaValuetext, + direction = 'ltr', + 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, + 'aria-valuetext': getAriaValueText + ? getAriaValueText(value) + : ariaValuetext ?? `${percentageValue}%`, + dir: direction, + role: 'meter', + }), + [ + ariaLabel, + ariaLabelledby, + ariaValuetext, + direction, + getAriaLabel, + getAriaValueText, + max, + min, + value, + percentageValue, + ], + ); + + return { + getRootProps, + direction, + 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; + /** + * The direction that the meter fills towards + * @default 'ltr' + */ + direction?: MeterDirection; + /** + * 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 direction that the meter fills towards + */ + direction: MeterDirection; + /** + * 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/mui-base/src/Meter/Track/MeterTrack.test.tsx b/packages/mui-base/src/Meter/Track/MeterTrack.test.tsx new file mode 100644 index 000000000..2713ca868 --- /dev/null +++ b/packages/mui-base/src/Meter/Track/MeterTrack.test.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Meter } from '@base_ui/react/Meter'; +import { createRenderer, describeConformance } from '#test-utils'; +import { MeterRootContext } from '../Root/MeterRootContext'; + +const contextValue: MeterRootContext = { + direction: 'ltr', + max: 100, + min: 0, + value: 30, + percentageValue: 30, + segment: 'low', + isOptimal: false, + ownerState: { + direction: 'ltr', + 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/mui-base/src/Meter/Track/MeterTrack.tsx b/packages/mui-base/src/Meter/Track/MeterTrack.tsx new file mode 100644 index 000000000..2bc768493 --- /dev/null +++ b/packages/mui-base/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.netlify.app/components/react-meter/) + * + * API: + * + * - [MeterTrack API](https://base-ui.netlify.app/components/react-meter/#api-reference-MeterTrack) + */ +const MeterTrack = React.forwardRef(function MeterTrack( + props: MeterTrack.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...otherProps } = props; + + const { ownerState } = useMeterRootContext(); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'span', + ownerState, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: meterStyleHookMapping, + }); + + return renderElement(); +}); + +namespace MeterTrack { + export interface OwnerState extends MeterRoot.OwnerState {} + + export interface Props extends BaseUIComponentProps<'span', OwnerState> {} +} + +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/mui-base/src/Meter/index.parts.ts b/packages/mui-base/src/Meter/index.parts.ts new file mode 100644 index 000000000..f3115c388 --- /dev/null +++ b/packages/mui-base/src/Meter/index.parts.ts @@ -0,0 +1,5 @@ +export { MeterRoot as Root } from './Root/MeterRoot'; +export { MeterTrack as Track } from './Track/MeterTrack'; +export { MeterIndicator as Indicator } from './Indicator/MeterIndicator'; + +export type { MeterDirection as Direction } from './Root/useMeterRoot'; diff --git a/packages/mui-base/src/Meter/index.ts b/packages/mui-base/src/Meter/index.ts new file mode 100644 index 000000000..170d463dd --- /dev/null +++ b/packages/mui-base/src/Meter/index.ts @@ -0,0 +1 @@ +export * as Meter from './index.parts'; diff --git a/packages/mui-base/src/Progress/Indicator/useProgressIndicator.ts b/packages/mui-base/src/Progress/Indicator/useProgressIndicator.ts index 4e7e76e12..db0475786 100644 --- a/packages/mui-base/src/Progress/Indicator/useProgressIndicator.ts +++ b/packages/mui-base/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/mui-base/src/Slider/Root/useSliderRoot.ts b/packages/mui-base/src/Slider/Root/useSliderRoot.ts index 1984aa5ac..0ef2e22e6 100644 --- a/packages/mui-base/src/Slider/Root/useSliderRoot.ts +++ b/packages/mui-base/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/mui-base/src/Slider/utils.ts b/packages/mui-base/src/Slider/utils.ts index 95927f20b..ed473cffd 100644 --- a/packages/mui-base/src/Slider/utils.ts +++ b/packages/mui-base/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/mui-base/src/index.ts b/packages/mui-base/src/index.ts index 5c2fbaeff..3b3f1bc21 100644 --- a/packages/mui-base/src/index.ts +++ b/packages/mui-base/src/index.ts @@ -7,6 +7,7 @@ export * from './Field'; export * from './Fieldset'; export * from './Form'; export * from './Menu'; +export * from './Meter'; export * from './NumberField'; export * from './Popover'; export * from './PreviewCard'; diff --git a/packages/mui-base/src/utils/valueToPercent.ts b/packages/mui-base/src/utils/valueToPercent.ts new file mode 100644 index 000000000..9886ee07f --- /dev/null +++ b/packages/mui-base/src/utils/valueToPercent.ts @@ -0,0 +1,3 @@ +export function valueToPercent(value: number, min: number, max: number) { + return ((value - min) * 100) / (max - min); +}