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..83b18ea87 --- /dev/null +++ b/docs/data/api/meter-root.json @@ -0,0 +1,35 @@ +{ + "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"] } + }, + "max": { "type": { "name": "number" }, "default": "100" }, + "min": { "type": { "name": "number" }, "default": "0" }, + "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..f68e2cb55 --- /dev/null +++ b/docs/data/components/meter/MeterIntroduction.js @@ -0,0 +1,19 @@ +'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 ( +
+ + + Battery Health + + + + + +
+ ); +} diff --git a/docs/data/components/meter/MeterIntroduction.tsx b/docs/data/components/meter/MeterIntroduction.tsx new file mode 100644 index 000000000..f68e2cb55 --- /dev/null +++ b/docs/data/components/meter/MeterIntroduction.tsx @@ -0,0 +1,19 @@ +'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 ( +
+ + + Battery Health + + + + + +
+ ); +} diff --git a/docs/data/components/meter/MeterIntroduction.tsx.preview b/docs/data/components/meter/MeterIntroduction.tsx.preview new file mode 100644 index 000000000..c7e391dd5 --- /dev/null +++ b/docs/data/components/meter/MeterIntroduction.tsx.preview @@ -0,0 +1,8 @@ + + + Battery Health + + + + + \ 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..5849ed08b --- /dev/null +++ b/docs/data/components/meter/styles.module.css @@ -0,0 +1,33 @@ +.demo { + font-family: system-ui, sans-serif; + width: 20rem; + padding: 1rem; +} + +.meter { + display: flex; + flex-flow: column nowrap; + gap: 1rem; +} + +.track { + position: relative; + width: 100%; + height: 18px; + border-radius: 5px; + border: 2px solid #222; + padding: 2px; + background-color: var(--gray-container-1); + display: flex; + overflow: hidden; +} + +.indicator { + background-color: rgb(40, 205, 65); + border-radius: 3px; +} + +.label { + cursor: unset; + font-weight: bold; +} 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..e2615b5c2 --- /dev/null +++ b/docs/data/translations/api-docs/meter-root/meter-root.json @@ -0,0 +1,29 @@ +{ + "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" } + }, + "max": { "description": "The maximum value" }, + "min": { "description": "The minimum value" }, + "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/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..391934cae --- /dev/null +++ b/packages/mui-base/src/Meter/Indicator/MeterIndicator.test.tsx @@ -0,0 +1,53 @@ +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, + ownerState: { + direction: 'ltr', + max: 100, + min: 0, + }, +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => { + return render( + {node}, + ); + }, + refInstanceof: window.HTMLSpanElement, + })); + + describe('internal styles', () => { + it('determinate', 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..9cc09c53e --- /dev/null +++ b/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx @@ -0,0 +1,76 @@ +'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 { 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, max, min, value, ownerState } = useMeterRootContext(); + + const { getRootProps } = useMeterIndicator({ + direction, + max, + min, + value, + }); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'span', + ownerState, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: { + direction: () => null, + max: () => null, + min: () => null, + }, + }); + + return renderElement(); +}); + +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; + +namespace MeterIndicator { + export interface OwnerState extends MeterRoot.OwnerState {} + + export interface Props extends BaseUIComponentProps<'span', OwnerState> {} +} + +export { MeterIndicator }; 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..489de110c --- /dev/null +++ b/packages/mui-base/src/Meter/Indicator/useMeterIndicator.ts @@ -0,0 +1,72 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { valueToPercent } from '../../utils/valueToPercent'; +import { MeterDirection } from '../Root/useMeterRoot'; + +function useMeterIndicator( + parameters: useMeterIndicator.Parameters, +): useMeterIndicator.ReturnValue { + const { direction, max = 100, min = 0, value } = parameters; + + const isRtl = direction === 'rtl'; + + const percentageValue = + Number.isFinite(value) && value !== null ? valueToPercent(value, min, max) : null; + + const getStyles = React.useCallback(() => { + if (!percentageValue) { + return {}; + } + + return { + [isRtl ? 'right' : 'left']: 0, + height: 'inherit', + 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 maximum value + * @default 100 + */ + max?: number; + /** + * The minimum value + * @default 0 + */ + min?: number; + /** + * The current value. + */ + value: number | null; + } + + 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..e885e3c13 --- /dev/null +++ b/packages/mui-base/src/Meter/Root/MeterRoot.tsx @@ -0,0 +1,157 @@ +'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'; +/** + * + * 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, + value, + render, + className, + ...otherProps + } = props; + + const { getRootProps, ...progress } = useMeterRoot({ + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-valuetext': ariaValuetext, + direction, + getAriaLabel, + getAriaValueText, + max, + min, + value, + }); + + const ownerState: MeterRoot.OwnerState = React.useMemo( + () => ({ + direction, + max, + min, + }), + [direction, max, min], + ); + + const contextValue: MeterRootContext = React.useMemo( + () => ({ + ...progress, + ownerState, + }), + [progress, ownerState], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + ownerState, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: { + direction: () => null, + max: () => null, + min: () => null, + }, + }); + + return ( + {renderElement()} + ); +}); + +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, + /** + * The maximum value + * @default 100 + */ + max: PropTypes.number, + /** + * The minimum value + * @default 0 + */ + min: 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; + +namespace MeterRoot { + export type OwnerState = { + direction: MeterDirection; + max: number; + min: number; + }; + + export interface Props extends useMeterRoot.Parameters, BaseUIComponentProps<'div', OwnerState> {} +} + +export { MeterRoot }; 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/useMeterRoot.ts b/packages/mui-base/src/Meter/Root/useMeterRoot.ts new file mode 100644 index 000000000..8bc0dbdd7 --- /dev/null +++ b/packages/mui-base/src/Meter/Root/useMeterRoot.ts @@ -0,0 +1,135 @@ +'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, + value, + } = parameters; + + const percentageValue = valueToPercent(value, min, max); + + 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, + }; +} + +namespace useMeterRoot { + 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; + /** + * The maximum value + * @default 100 + */ + max?: number; + /** + * The minimum value + * @default 0 + */ + min?: number; + /** + * The current value. + */ + value: number; + } + + export interface ReturnValue { + getRootProps: ( + externalProps?: React.ComponentPropsWithRef<'div'>, + ) => React.ComponentPropsWithRef<'div'>; + /** + * The direction that progress bars fill in + */ + 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; + } +} + +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..7b4ae3190 --- /dev/null +++ b/packages/mui-base/src/Meter/Track/MeterTrack.test.tsx @@ -0,0 +1,29 @@ +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, + ownerState: { + direction: 'ltr', + max: 100, + min: 0, + }, +}; + +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..9332bc2a4 --- /dev/null +++ b/packages/mui-base/src/Meter/Track/MeterTrack.tsx @@ -0,0 +1,67 @@ +'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 { 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: { + direction: () => null, + max: () => null, + min: () => null, + }, + }); + + return renderElement(); +}); + +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; + +namespace MeterTrack { + export interface OwnerState extends MeterRoot.OwnerState {} + + export interface Props extends BaseUIComponentProps<'span', OwnerState> {} +} + +export { MeterTrack }; 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); +}