diff --git a/docs/src/app/(private)/experiments/slider-format.tsx b/docs/src/app/(private)/experiments/slider-format.tsx
new file mode 100644
index 0000000000..26756bcee8
--- /dev/null
+++ b/docs/src/app/(private)/experiments/slider-format.tsx
@@ -0,0 +1,56 @@
+'use client';
+import * as React from 'react';
+import { useTheme } from '@mui/system';
+import { Slider } from '@base-ui-components/react/slider';
+import c from './slider.module.css';
+
+export default function UnstyledSliderIntroduction() {
+ // Replace this with your app logic for determining dark mode
+ const isDarkMode = useIsDarkMode();
+ return (
+
+
+
+ Budget
+
+
+ {(_, values) => `$${values[0].toFixed(2)} - ${values[1].toFixed(2)} USD`}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Label(props: React.LabelHTMLAttributes) {
+ const { id, htmlFor, ...otherProps } = props;
+
+ return ;
+}
+
+function useIsDarkMode() {
+ const theme = useTheme();
+ return theme.palette.mode === 'dark';
+}
diff --git a/docs/src/app/(private)/experiments/slider.module.css b/docs/src/app/(private)/experiments/slider.module.css
index 03e930b174..995cb48c6d 100644
--- a/docs/src/app/(private)/experiments/slider.module.css
+++ b/docs/src/app/(private)/experiments/slider.module.css
@@ -8,7 +8,7 @@
position: relative;
-webkit-tap-highlight-color: transparent;
display: grid;
- grid-template-columns: 1fr 1fr;
+ grid-template-columns: 1fr auto;
gap: 1rem;
}
diff --git a/docs/src/app/(public)/(content)/react/components/slider/page.mdx b/docs/src/app/(public)/(content)/react/components/slider/page.mdx
index 718e4e0b68..280b69d66c 100644
--- a/docs/src/app/(public)/(content)/react/components/slider/page.mdx
+++ b/docs/src/app/(public)/(content)/react/components/slider/page.mdx
@@ -16,7 +16,7 @@ Import the component and place its parts the following way:
import { Slider } from '@base-ui-components/react/slider';
-
+
@@ -26,4 +26,4 @@ import { Slider } from '@base-ui-components/react/slider';
;
```
-
+
diff --git a/docs/src/app/(public)/(content)/react/components/switch/demos/hero/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/switch/demos/hero/css-modules/index.module.css
index 76f9f8d1be..1ca9d7fcc0 100644
--- a/docs/src/app/(public)/(content)/react/components/switch/demos/hero/css-modules/index.module.css
+++ b/docs/src/app/(public)/(content)/react/components/switch/demos/hero/css-modules/index.module.css
@@ -1,4 +1,5 @@
.Switch {
+ position: relative;
display: flex;
appearance: none;
border: 0;
@@ -48,6 +49,17 @@
box-shadow: none;
}
}
+
+ &:focus-visible {
+ &::before {
+ content: '';
+ inset: 0;
+ position: absolute;
+ border-radius: inherit;
+ outline: 2px solid var(--color-blue);
+ outline-offset: 2px;
+ }
+ }
}
.Thumb {
@@ -57,10 +69,6 @@
background-color: white;
transition: translate 150ms ease;
- &:focus-visible {
- outline: 2px solid var(--color-blue);
- }
-
&[data-checked] {
translate: 1rem 0;
}
diff --git a/docs/src/app/(public)/(content)/react/components/switch/demos/hero/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/switch/demos/hero/tailwind/index.tsx
index deb3229fed..9892d7dfcb 100644
--- a/docs/src/app/(public)/(content)/react/components/switch/demos/hero/tailwind/index.tsx
+++ b/docs/src/app/(public)/(content)/react/components/switch/demos/hero/tailwind/index.tsx
@@ -5,7 +5,7 @@ export default function ExampleSwitch() {
return (
diff --git a/docs/src/app/(public)/layout.css b/docs/src/app/(public)/layout.css
index 555c97c34d..865491bb06 100644
--- a/docs/src/app/(public)/layout.css
+++ b/docs/src/app/(public)/layout.css
@@ -9,7 +9,7 @@
}
body {
- min-width: 360px;
+ min-width: 320px;
line-height: 1.5;
background-color: var(--color-background);
color: var(--color-foreground);
diff --git a/docs/src/components/Header.css b/docs/src/components/Header.css
index bef9a78aa4..1e15957580 100644
--- a/docs/src/components/Header.css
+++ b/docs/src/components/Header.css
@@ -2,6 +2,7 @@
.Header {
@apply text-sm;
position: absolute;
+ left: 0;
top: 0;
height: var(--header-height);
width: 100%;
diff --git a/docs/src/components/Select.tsx b/docs/src/components/Select.tsx
index b6aaf0e67d..6930853fd8 100644
--- a/docs/src/components/Select.tsx
+++ b/docs/src/components/Select.tsx
@@ -14,7 +14,8 @@ interface TriggerProps extends Select.Trigger.Props {
export function Trigger({ className, ssrFallback, placeholder, ...props }: TriggerProps) {
return (
-
+ // Implicitly relying on , keep it in sync
+
{(value) => value || ssrFallback}
} />
diff --git a/docs/src/components/SideNav.tsx b/docs/src/components/SideNav.tsx
index eedefe8027..f6051a92e2 100644
--- a/docs/src/components/SideNav.tsx
+++ b/docs/src/components/SideNav.tsx
@@ -105,7 +105,7 @@ export function Item({ children, className, href, ...props }: ItemProps) {
// We are scrolling into view, update upstream state
setScrollingIntoView(true);
// Sync flag removal with ScrollArea's own scrolling state timeout
- setTimeout(() => setScrollingIntoView(false), SCROLL_TIMEOUT);
+ setTimeout(() => setScrollingIntoView(false), SCROLL_TIMEOUT + 50);
}
actions.forEach(({ top }) => {
const dir = viewport.scrollTop > top ? -1 : 1;
diff --git a/docs/src/components/demo/Demo.css b/docs/src/components/demo/Demo.css
index b7e20010b2..358e4e5858 100644
--- a/docs/src/components/demo/Demo.css
+++ b/docs/src/components/demo/Demo.css
@@ -152,9 +152,18 @@
display: flex;
cursor: text;
- /* Closed state */
- overflow: hidden;
- max-height: calc(8.6lh + 0.5rem); /* Show 8.6 of code lines and account for top padding */
+ /* Scroll */
+ overflow: auto;
+ overscroll-behavior-x: contain;
+ scrollbar-width: thin;
+
+ /* Scroll containers may be focusable */
+ &:focus-visible {
+ position: relative;
+ outline: 2px solid var(--color-blue);
+ outline-offset: -1px;
+ z-index: 1;
+ }
/* Add a border radius when there is no collapse button */
&:not(.DemoCollapseButton + &) {
@@ -163,57 +172,35 @@
}
}
- /* Scroll containers may be focusable */
- & pre:focus-visible {
- position: relative;
- outline: 2px solid var(--color-blue);
- outline-offset: -1px;
- z-index: 1;
- }
-
- &::before {
- content: '';
- position: absolute;
- pointer-events: none;
- height: 7.5rem;
- bottom: 0;
- left: 0;
- right: 0;
- background: linear-gradient(to bottom, rgb(255 255 255 / 0), rgb(255 255 255 / 60%));
- }
- @media (prefers-color-scheme: dark) {
- &::before {
- background: linear-gradient(to bottom, rgb(0 0 0 / 0), rgb(0 0 0 / 60%));
- }
+ & code {
+ /* Different fonts may introduce vertical align issues */
+ display: block;
+ /* Make sure selection highlight spans full container width in Safari */
+ flex-grow: 1;
}
- &[data-open] {
+ &[data-closed] {
& pre {
- max-height: none;
-
- /* Scroll */
- overflow: auto;
- overscroll-behavior-x: contain;
- scrollbar-width: thin;
-
- /* Scroll containers may be focusable */
- &:focus-visible {
- position: relative;
- outline: 2px solid var(--color-blue);
- outline-offset: -1px;
- z-index: 1;
- }
+ overflow: hidden;
+ max-height: calc(8.6lh + 0.5rem); /* Show 8.6 of code lines and account for top padding */
}
+
&::before {
- content: none;
+ content: '';
+ position: absolute;
+ pointer-events: none;
+ height: 7.5rem;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(to bottom, rgb(255 255 255 / 0), rgb(255 255 255 / 60%));
}
- }
- & code {
- /* Different fonts may introduce vertical align issues */
- display: block;
- /* Make sure selection highlight spans full container width in Safari */
- flex-grow: 1;
+ @media (prefers-color-scheme: dark) {
+ &::before {
+ background: linear-gradient(to bottom, rgb(0 0 0 / 0), rgb(0 0 0 / 60%));
+ }
+ }
}
}
diff --git a/docs/src/components/demo/DemoSourceBrowser.tsx b/docs/src/components/demo/DemoSourceBrowser.tsx
index 4bd74180da..4938c995fa 100644
--- a/docs/src/components/demo/DemoSourceBrowser.tsx
+++ b/docs/src/components/demo/DemoSourceBrowser.tsx
@@ -23,7 +23,13 @@ export function DemoSourceBrowser({
const lineBreaks = selectedFile.content.match(/\n/g) ?? [];
if (lineBreaks.length < collapsibleLinesThreshold) {
- return ;
+ return (
+
+ );
}
return (
@@ -36,31 +42,19 @@ export function DemoSourceBrowser({
}
- />
+ tabIndex={-1}
+ onKeyDown={handleSelectAll}
+ className="DemoCodeBlockContainer"
+ >
+
+
);
}
-function DemoCodeBlock(props: React.ComponentProps) {
- return (
- {
- if (
- event.key === 'a' &&
- (event.metaKey || event.ctrlKey) &&
- !event.shiftKey &&
- !event.altKey
- ) {
- event.preventDefault();
- window.getSelection()?.selectAllChildren(event.currentTarget);
- }
- }}
- />
- );
+function handleSelectAll(event: React.KeyboardEvent) {
+ if (event.key === 'a' && (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey) {
+ event.preventDefault();
+ window.getSelection()?.selectAllChildren(event.currentTarget);
+ }
}
diff --git a/packages/react/src/number-field/root/useNumberFieldRoot.ts b/packages/react/src/number-field/root/useNumberFieldRoot.ts
index 26e8adb422..7dd92e8675 100644
--- a/packages/react/src/number-field/root/useNumberFieldRoot.ts
+++ b/packages/react/src/number-field/root/useNumberFieldRoot.ts
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { useScrub } from './useScrub';
-import { formatNumber } from '../utils/format';
+import { formatNumber } from '../../utils/formatNumber';
import { toValidatedNumber } from '../utils/validate';
import {
ARABIC_RE,
diff --git a/packages/react/src/number-field/utils/parse.ts b/packages/react/src/number-field/utils/parse.ts
index 8f400efdda..87aab83e73 100644
--- a/packages/react/src/number-field/utils/parse.ts
+++ b/packages/react/src/number-field/utils/parse.ts
@@ -1,4 +1,4 @@
-import { getFormatter } from './format';
+import { getFormatter } from '../../utils/formatNumber';
export const HAN_NUMERALS = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
export const ARABIC_NUMERALS = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'];
diff --git a/packages/react/src/number-field/utils/validate.ts b/packages/react/src/number-field/utils/validate.ts
index b8f2438f1b..781ed6add4 100644
--- a/packages/react/src/number-field/utils/validate.ts
+++ b/packages/react/src/number-field/utils/validate.ts
@@ -1,4 +1,4 @@
-import { getFormatter } from './format';
+import { getFormatter } from '../../utils/formatNumber';
import { clamp } from '../../utils/clamp';
export function removeFloatingPointErrors(value: number, format: Intl.NumberFormatOptions = {}) {
diff --git a/packages/react/src/slider/index.parts.ts b/packages/react/src/slider/index.parts.ts
index 7b71402cfa..16ad6a900b 100644
--- a/packages/react/src/slider/index.parts.ts
+++ b/packages/react/src/slider/index.parts.ts
@@ -1,5 +1,5 @@
export { SliderRoot as Root } from './root/SliderRoot';
-export { SliderOutput as Output } from './output/SliderOutput';
+export { SliderValue as Value } from './value/SliderValue';
export { SliderControl as Control } from './control/SliderControl';
export { SliderTrack as Track } from './track/SliderTrack';
export { SliderThumb as Thumb } from './thumb/SliderThumb';
diff --git a/packages/react/src/slider/root/SliderRoot.test.tsx b/packages/react/src/slider/root/SliderRoot.test.tsx
index d95041afdf..400faedbd0 100644
--- a/packages/react/src/slider/root/SliderRoot.test.tsx
+++ b/packages/react/src/slider/root/SliderRoot.test.tsx
@@ -38,7 +38,7 @@ function createTouches(touches: Touches) {
function TestSlider(props: SliderRoot.Props) {
return (
-
+
@@ -52,7 +52,7 @@ function TestSlider(props: SliderRoot.Props) {
function TestRangeSlider(props: SliderRoot.Props) {
return (
-
+
@@ -83,7 +83,7 @@ describeSkipIf(typeof Touch === 'undefined')(' ', () => {
it('renders a slider', async () => {
await render(
-
+
@@ -117,7 +117,7 @@ describeSkipIf(typeof Touch === 'undefined')(' ', () => {
it('it has the correct aria attributes', async () => {
const { container, getByRole, getByTestId } = await render(
-
+
@@ -305,7 +305,7 @@ describeSkipIf(typeof Touch === 'undefined')(' ', () => {
it('should render data-disabled on all subcomponents', async () => {
const { getByTestId } = await render(
-
+
@@ -316,13 +316,13 @@ describeSkipIf(typeof Touch === 'undefined')(' ', () => {
);
const root = getByTestId('root');
- const output = getByTestId('output');
+ const value = getByTestId('value');
const control = getByTestId('control');
const track = getByTestId('track');
const indicator = getByTestId('indicator');
const thumb = getByTestId('thumb');
- [root, output, control, track, indicator, thumb].forEach((subcomponent) => {
+ [root, value, control, track, indicator, thumb].forEach((subcomponent) => {
expect(subcomponent).to.have.attribute('data-disabled', '');
});
});
@@ -419,7 +419,7 @@ describeSkipIf(typeof Touch === 'undefined')(' ', () => {
expect(sliderRoot).to.have.attribute('data-orientation', 'horizontal');
const sliderControl = getByTestId('control');
expect(sliderControl).to.have.attribute('data-orientation', 'horizontal');
- const sliderOutput = getByTestId('output');
+ const sliderOutput = getByTestId('value');
expect(sliderOutput).to.have.attribute('data-orientation', 'horizontal');
});
@@ -1612,4 +1612,41 @@ describeSkipIf(typeof Touch === 'undefined')(' ', () => {
expect(screen.getByRole('slider')).to.have.property('tabIndex', -1);
});
});
+
+ describe('prop: format', () => {
+ it('formats the value', async () => {
+ const format: Intl.NumberFormatOptions = {
+ style: 'currency',
+ currency: 'USD',
+ };
+ function formatValue(v: number) {
+ return new Intl.NumberFormat(undefined, format).format(v);
+ }
+ const { getByRole, getByTestId } = await render(
+ ,
+ );
+ const value = getByTestId('value');
+ const slider = getByRole('slider');
+ expect(value).to.have.text(formatValue(50));
+ expect(slider).to.have.attribute('aria-valuetext', formatValue(50));
+ });
+
+ it('formats range values', async () => {
+ const format: Intl.NumberFormatOptions = {
+ style: 'currency',
+ currency: 'USD',
+ };
+ function formatValue(v: number) {
+ return new Intl.NumberFormat(undefined, format).format(v);
+ }
+ const { getAllByRole, getByTestId } = await render(
+ ,
+ );
+ const value = getByTestId('value');
+ expect(value).to.have.text(`${formatValue(50)} – ${formatValue(75)}`);
+ const [slider1, slider2] = getAllByRole('slider');
+ expect(slider1).to.have.attribute('aria-valuetext', `${formatValue(50)} start range`);
+ expect(slider2).to.have.attribute('aria-valuetext', `${formatValue(75)} end range`);
+ });
+ });
});
diff --git a/packages/react/src/slider/root/SliderRoot.tsx b/packages/react/src/slider/root/SliderRoot.tsx
index 9f938f242c..75f62e04b7 100644
--- a/packages/react/src/slider/root/SliderRoot.tsx
+++ b/packages/react/src/slider/root/SliderRoot.tsx
@@ -27,6 +27,7 @@ const SliderRoot = React.forwardRef(function SliderRoot(
defaultValue,
disabled: disabledProp = false,
id,
+ format,
largeStep,
render,
max,
@@ -97,9 +98,10 @@ const SliderRoot = React.forwardRef(function SliderRoot(
const contextValue = React.useMemo(
() => ({
...slider,
+ format,
state,
}),
- [slider, state],
+ [slider, format, state],
);
const { renderElement } = useComponentRenderer({
@@ -180,6 +182,10 @@ export namespace SliderRoot {
* @default false
*/
disabled?: boolean;
+ /**
+ * Options to format the input value.
+ */
+ format?: Intl.NumberFormatOptions;
/**
* The value of the slider.
* For ranged sliders, provide an array with two values.
@@ -218,6 +224,28 @@ SliderRoot.propTypes /* remove-proptypes */ = {
* @default false
*/
disabled: PropTypes.bool,
+ /**
+ * Options to format the input value.
+ */
+ format: PropTypes.shape({
+ compactDisplay: PropTypes.oneOf(['long', 'short']),
+ currency: PropTypes.string,
+ currencyDisplay: PropTypes.oneOf(['code', 'name', 'narrowSymbol', 'symbol']),
+ currencySign: PropTypes.oneOf(['accounting', 'standard']),
+ localeMatcher: PropTypes.oneOf(['best fit', 'lookup']),
+ maximumFractionDigits: PropTypes.number,
+ maximumSignificantDigits: PropTypes.number,
+ minimumFractionDigits: PropTypes.number,
+ minimumIntegerDigits: PropTypes.number,
+ minimumSignificantDigits: PropTypes.number,
+ notation: PropTypes.oneOf(['compact', 'engineering', 'scientific', 'standard']),
+ numberingSystem: PropTypes.string,
+ signDisplay: PropTypes.oneOf(['always', 'auto', 'exceptZero', 'never']),
+ style: PropTypes.oneOf(['currency', 'decimal', 'percent', 'unit']),
+ unit: PropTypes.string,
+ unitDisplay: PropTypes.oneOf(['long', 'narrow', 'short']),
+ useGrouping: PropTypes.bool,
+ }),
/**
* @ignore
*/
diff --git a/packages/react/src/slider/root/SliderRootContext.ts b/packages/react/src/slider/root/SliderRootContext.ts
index fba2beff6b..a2b3c5f3dd 100644
--- a/packages/react/src/slider/root/SliderRootContext.ts
+++ b/packages/react/src/slider/root/SliderRootContext.ts
@@ -4,6 +4,7 @@ import type { SliderRoot } from './SliderRoot';
import type { useSliderRoot } from './useSliderRoot';
export interface SliderRootContext extends Omit {
+ format?: Intl.NumberFormatOptions;
state: SliderRoot.State;
}
diff --git a/packages/react/src/slider/root/useSliderRoot.ts b/packages/react/src/slider/root/useSliderRoot.ts
index 04b8bf0cc2..d863f3f053 100644
--- a/packages/react/src/slider/root/useSliderRoot.ts
+++ b/packages/react/src/slider/root/useSliderRoot.ts
@@ -9,11 +9,12 @@ import { useControlled } from '../../utils/useControlled';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
import { useForkRef } from '../../utils/useForkRef';
import { useBaseUiId } from '../../utils/useBaseUiId';
+import { valueToPercent } from '../../utils/valueToPercent';
import type { TextDirection } from '../../direction-provider/DirectionContext';
import { useField } from '../../field/useField';
import { useFieldRootContext } from '../../field/root/FieldRootContext';
import { useFieldControlValidation } from '../../field/control/useFieldControlValidation';
-import { percentToValue, roundValueToStep, valueToPercent } from '../utils';
+import { percentToValue, roundValueToStep } from '../utils';
import { asc } from '../utils/asc';
import { setValueIndex } from '../utils/setValueIndex';
import { getSliderValue } from '../utils/getSliderValue';
diff --git a/packages/react/src/slider/thumb/SliderThumb.tsx b/packages/react/src/slider/thumb/SliderThumb.tsx
index 7d22f343ad..87735cb492 100644
--- a/packages/react/src/slider/thumb/SliderThumb.tsx
+++ b/packages/react/src/slider/thumb/SliderThumb.tsx
@@ -43,6 +43,7 @@ const SliderThumb = React.forwardRef(function SliderThumb(
getAriaLabel,
getAriaValueText,
id,
+ inputId,
...otherProps
} = props;
@@ -54,6 +55,7 @@ const SliderThumb = React.forwardRef(function SliderThumb(
changeValue,
direction,
disabled: contextDisabled,
+ format,
largeStep,
max,
min,
@@ -83,9 +85,11 @@ const SliderThumb = React.forwardRef(function SliderThumb(
changeValue,
direction,
disabled: disabledProp || contextDisabled,
+ format,
getAriaLabel,
getAriaValueText,
id,
+ inputId,
largeStep,
max,
min,
@@ -202,8 +206,9 @@ SliderThumb.propTypes /* remove-proptypes */ = {
/**
* Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider.
* This is important for screen reader users.
- * @param {number} value The thumb label's value to format.
- * @param {number} index The thumb label's index to format.
+ * @param {string} formattedValue The thumb's formatted value.
+ * @param {number} value The thumb's numerical value.
+ * @param {number} index The thumb's index.
* @returns {string}
*/
getAriaValueText: PropTypes.func,
@@ -211,6 +216,10 @@ SliderThumb.propTypes /* remove-proptypes */ = {
* @ignore
*/
id: PropTypes.string,
+ /**
+ * @ignore
+ */
+ inputId: PropTypes.string,
/**
* @ignore
*/
diff --git a/packages/react/src/slider/thumb/useSliderThumb.ts b/packages/react/src/slider/thumb/useSliderThumb.ts
index 40d1d063fb..9b1c9d911b 100644
--- a/packages/react/src/slider/thumb/useSliderThumb.ts
+++ b/packages/react/src/slider/thumb/useSliderThumb.ts
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
+import { formatNumber } from '../../utils/formatNumber';
import { mergeReactProps } from '../../utils/mergeReactProps';
import { GenericHTMLProps } from '../../utils/types';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
@@ -22,20 +23,24 @@ function getNewValue(
return direction === 1 ? Math.min(thumbValue + step, max) : Math.max(thumbValue - step, min);
}
-function getDefaultAriaValueText(values: readonly number[], index: number): string | undefined {
+function getDefaultAriaValueText(
+ values: readonly number[],
+ index: number,
+ format: Intl.NumberFormatOptions | undefined,
+): string | undefined {
if (index < 0) {
return undefined;
}
if (values.length === 2) {
if (index === 0) {
- return `${values[index]} start range`;
+ return `${formatNumber(values[index], [], format)} start range`;
}
- return `${values[index]} end range`;
+ return `${formatNumber(values[index], [], format)} end range`;
}
- return undefined;
+ return format ? formatNumber(values[index], [], format) : undefined;
}
export function useSliderThumb(parameters: useSliderThumb.Parameters): useSliderThumb.ReturnValue {
@@ -47,6 +52,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
changeValue,
direction,
disabled,
+ format,
getAriaLabel,
getAriaValueText,
id: idParam,
@@ -236,6 +242,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
if (orientation === 'vertical') {
cssWritingMode = isRtl ? 'vertical-rl' : 'vertical-lr';
}
+
return mergeReactProps(getInputValidationProps(externalProps), {
'aria-label': getAriaLabel ? getAriaLabel(index) : ariaLabel,
'aria-labelledby': ariaLabelledby,
@@ -244,8 +251,8 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
'aria-valuemin': min,
'aria-valuenow': thumbValue,
'aria-valuetext': getAriaValueText
- ? getAriaValueText(thumbValue, index)
- : (ariaValuetext ?? getDefaultAriaValueText(sliderValues, index)),
+ ? getAriaValueText(formatNumber(thumbValue, [], format), thumbValue, index)
+ : (ariaValuetext ?? getDefaultAriaValueText(sliderValues, index, format)),
'data-index': index,
disabled,
id: inputId,
@@ -277,6 +284,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
ariaValuetext,
changeValue,
disabled,
+ format,
getAriaLabel,
getAriaValueText,
getInputValidationProps,
@@ -333,6 +341,10 @@ export namespace useSliderThumb {
* A string value that provides a user-friendly name for the current value of the slider.
*/
'aria-valuetext'?: string;
+ /**
+ * Options to format the input value.
+ */
+ format?: Intl.NumberFormatOptions;
/**
* Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb
* @param {number} index The index of the input
@@ -342,11 +354,12 @@ export namespace useSliderThumb {
/**
* Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider.
* This is important for screen reader users.
- * @param {number} value The thumb label's value to format.
- * @param {number} index The thumb label's index to format.
+ * @param {string} formattedValue The thumb's formatted value.
+ * @param {number} value The thumb's numerical value.
+ * @param {number} index The thumb's index.
* @returns {string}
*/
- getAriaValueText?: (value: number, index: number) => string;
+ getAriaValueText?: (formattedValue: string, value: number, index: number) => string;
id?: React.HTMLAttributes['id'];
inputId?: React.HTMLAttributes['id'];
disabled: boolean;
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/slider/output/SliderOutput.test.tsx b/packages/react/src/slider/value/SliderValue.test.tsx
similarity index 60%
rename from packages/react/src/slider/output/SliderOutput.test.tsx
rename to packages/react/src/slider/value/SliderValue.test.tsx
index 54f02d099d..4e66b1cb2f 100644
--- a/packages/react/src/slider/output/SliderOutput.test.tsx
+++ b/packages/react/src/slider/value/SliderValue.test.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { expect } from 'chai';
+import { spy } from 'sinon';
import { Slider } from '@base-ui-components/react/slider';
import { createRenderer, describeConformance } from '#test-utils';
import { SliderRootContext } from '../root/SliderRootContext';
@@ -51,10 +52,10 @@ const testRootContext: SliderRootContext = {
values: [0],
};
-describe(' ', () => {
+describe(' ', () => {
const { render } = createRenderer();
- describeConformance( , () => ({
+ describeConformance( , () => ({
render: (node) => {
return render(
{node} ,
@@ -66,33 +67,54 @@ describe(' ', () => {
it('renders a single value', async () => {
const { getByTestId } = await render(
-
+
,
);
- const sliderOutput = getByTestId('output');
+ const sliderValue = getByTestId('output');
- expect(sliderOutput).to.have.text('40');
+ expect(sliderValue).to.have.text('40');
});
it('renders a range', async () => {
const { getByTestId } = await render(
-
+
,
);
- const sliderOutput = getByTestId('output');
+ const sliderValue = getByTestId('output');
- expect(sliderOutput).to.have.text('40 – 65');
+ expect(sliderValue).to.have.text('40 – 65');
});
it('renders all thumb values', async () => {
const { getByTestId } = await render(
-
+
,
);
- const sliderOutput = getByTestId('output');
+ const sliderValue = getByTestId('output');
- expect(sliderOutput).to.have.text('40 – 60 – 80 – 95');
+ expect(sliderValue).to.have.text('40 – 60 – 80 – 95');
+ });
+
+ describe('prop: children', () => {
+ it('accepts a render function', async () => {
+ const format: Intl.NumberFormatOptions = {
+ style: 'currency',
+ currency: 'USD',
+ };
+ function formatValue(v: number) {
+ return new Intl.NumberFormat(undefined, format).format(v);
+ }
+ const renderSpy = spy();
+ await render(
+
+ {renderSpy}
+ ,
+ );
+
+ expect(renderSpy.lastCall.args[0]).to.deep.equal([formatValue(40), formatValue(60)]);
+ expect(renderSpy.lastCall.args[1]).to.deep.equal([40, 60]);
+ });
});
});
diff --git a/packages/react/src/slider/output/SliderOutput.tsx b/packages/react/src/slider/value/SliderValue.tsx
similarity index 65%
rename from packages/react/src/slider/output/SliderOutput.tsx
rename to packages/react/src/slider/value/SliderValue.tsx
index cfadc2ca3d..c7dbdd5bad 100644
--- a/packages/react/src/slider/output/SliderOutput.tsx
+++ b/packages/react/src/slider/value/SliderValue.tsx
@@ -6,26 +6,35 @@ import { useComponentRenderer } from '../../utils/useComponentRenderer';
import { useSliderRootContext } from '../root/SliderRootContext';
import { sliderStyleHookMapping } from '../root/styleHooks';
import type { SliderRoot } from '../root/SliderRoot';
-import { useSliderOutput } from './useSliderOutput';
-
+import { useSliderValue } from './useSliderValue';
/**
* Displays the current value of the slider as text.
* Renders an `` element.
*
* Documentation: [Base UI Slider](https://base-ui.com/react/components/slider)
*/
-const SliderOutput = React.forwardRef(function SliderOutput(
- props: SliderOutput.Props,
+const SliderValue = React.forwardRef(function SliderValue(
+ props: SliderValue.Props,
forwardedRef: React.ForwardedRef,
) {
- const { render, className, ...otherProps } = props;
+ const { render, className, children, ...otherProps } = props;
- const { inputIdMap, state, values } = useSliderRootContext();
+ const { inputIdMap, state, values, format } = useSliderRootContext();
- const { getRootProps } = useSliderOutput({
+ const { getRootProps, formattedValues } = useSliderValue({
+ format,
inputIdMap,
+ values,
});
+ const defaultDisplayValue = React.useMemo(() => {
+ const arr = [];
+ for (let i = 0; i < values.length; i += 1) {
+ arr.push(formattedValues[i] || values[i]);
+ }
+ return arr.join(' – ');
+ }, [values, formattedValues]);
+
const { renderElement } = useComponentRenderer({
propGetter: getRootProps,
render: render ?? 'output',
@@ -33,7 +42,8 @@ const SliderOutput = React.forwardRef(function SliderOutput(
className,
ref: forwardedRef,
extraProps: {
- children: values.join(' – '),
+ children:
+ typeof children === 'function' ? children(formattedValues, values) : defaultDisplayValue,
...otherProps,
},
customStyleHookMapping: sliderStyleHookMapping,
@@ -42,13 +52,18 @@ const SliderOutput = React.forwardRef(function SliderOutput(
return renderElement();
});
-export namespace SliderOutput {
- export interface Props extends BaseUIComponentProps<'output', SliderRoot.State> {}
+export namespace SliderValue {
+ export interface Props
+ extends Omit, 'children'> {
+ children?:
+ | null
+ | ((formattedValues: readonly string[], values: readonly number[]) => React.ReactNode);
+ }
}
-export { SliderOutput };
+export { SliderValue };
-SliderOutput.propTypes /* remove-proptypes */ = {
+SliderValue.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
@@ -56,7 +71,7 @@ SliderOutput.propTypes /* remove-proptypes */ = {
/**
* @ignore
*/
- children: PropTypes.node,
+ children: PropTypes.func,
/**
* CSS class applied to the element, or a function that
* returns a class based on the component’s state.
diff --git a/packages/react/src/slider/output/useSliderOutput.ts b/packages/react/src/slider/value/useSliderValue.ts
similarity index 58%
rename from packages/react/src/slider/output/useSliderOutput.ts
rename to packages/react/src/slider/value/useSliderValue.ts
index 6557f0bad7..98b2e2e541 100644
--- a/packages/react/src/slider/output/useSliderOutput.ts
+++ b/packages/react/src/slider/value/useSliderValue.ts
@@ -1,12 +1,11 @@
'use client';
import * as React from 'react';
+import { formatNumber } from '../../utils/formatNumber';
import { mergeReactProps } from '../../utils/mergeReactProps';
import type { useSliderRoot } from '../root/useSliderRoot';
-export function useSliderOutput(
- parameters: useSliderOutput.Parameters,
-): useSliderOutput.ReturnValue {
- const { 'aria-live': ariaLive = 'off', inputIdMap } = parameters;
+export function useSliderValue(parameters: useSliderValue.Parameters): useSliderValue.ReturnValue {
+ const { 'aria-live': ariaLive = 'off', format: formatParam, inputIdMap, values } = parameters;
const outputFor = React.useMemo(() => {
const size = inputIdMap.size;
@@ -21,6 +20,16 @@ export function useSliderOutput(
return htmlFor.trim() === '' ? undefined : htmlFor.trim();
}, [inputIdMap]);
+ const formattedValues = React.useMemo(() => {
+ const arr = [];
+ for (let i = 0; i < values.length; i += 1) {
+ arr.push(
+ formatNumber(values[i], [], Array.isArray(formatParam) ? formatParam[i] : formatParam),
+ );
+ }
+ return arr;
+ }, [formatParam, values]);
+
const getRootProps = React.useCallback(
(externalProps = {}) => {
return mergeReactProps(externalProps, {
@@ -36,19 +45,28 @@ export function useSliderOutput(
return React.useMemo(
() => ({
getRootProps,
+ formattedValues,
}),
- [getRootProps],
+ [getRootProps, formattedValues],
);
}
-export namespace useSliderOutput {
- export interface Parameters extends Pick {
+export namespace useSliderValue {
+ export interface Parameters extends Pick {
'aria-live'?: React.AriaAttributes['aria-live'];
+ /**
+ * Options to format the input value.
+ */
+ format?: Intl.NumberFormatOptions | Intl.NumberFormatOptions[];
}
export interface ReturnValue {
getRootProps: (
externalProps?: React.ComponentPropsWithRef<'output'>,
) => React.ComponentPropsWithRef<'output'>;
+ /**
+ * The formatted value(s) of the slider
+ */
+ formattedValues: readonly string[];
}
}
diff --git a/packages/react/src/tabs/indicator/prehydrationScript.min.ts b/packages/react/src/tabs/indicator/prehydrationScript.min.ts
index 14dd44c60d..e7366479c4 100644
--- a/packages/react/src/tabs/indicator/prehydrationScript.min.ts
+++ b/packages/react/src/tabs/indicator/prehydrationScript.min.ts
@@ -2,4 +2,4 @@
// To update it, modify the corresponding source file and run `pnpm inline-scripts`.
// prettier-ignore
-export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const o=e.querySelector(\'[data-selected="true"]\');if(!o)return;const{left:i,right:n,top:r,bottom:c,width:l}=o.getBoundingClientRect(),{left:u,right:s,top:f,bottom:g,width:d}=e.getBoundingClientRect();if(0===l||0===d)return;const h=s-n,p=r-f,b=g-c,a=n-i,m=c-r;function w(e,o){t.style.setProperty(`--active-tab-${e}`,`${o}px`)}w("left",i-u),w("right",h),w("top",p),w("bottom",b),w("width",a),w("height",m)}();';
+export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const i=e.querySelector("[data-selected]");if(!i)return;const{left:o,right:n,top:r,bottom:c,width:l}=i.getBoundingClientRect(),{left:u,right:s,top:d,bottom:f,width:g}=e.getBoundingClientRect();if(0===l||0===g)return;const h=s-n,b=r-d,p=f-c,m=n-o,a=c-r;function v(e,i){t.style.setProperty(`--active-tab-${e}`,`${i}px`)}v("left",o-u),v("right",h),v("top",b),v("bottom",p),v("width",m),v("height",a),m>0&&a>0&&t.removeAttribute("hidden")}();';
diff --git a/packages/react/src/tabs/indicator/prehydrationScript.template.js b/packages/react/src/tabs/indicator/prehydrationScript.template.js
index 429534b2c6..e117998e0b 100644
--- a/packages/react/src/tabs/indicator/prehydrationScript.template.js
+++ b/packages/react/src/tabs/indicator/prehydrationScript.template.js
@@ -9,7 +9,7 @@
return;
}
- const activeTab = list.querySelector('[data-selected="true"]');
+ const activeTab = list.querySelector('[data-selected]');
if (!activeTab) {
return;
}
@@ -51,4 +51,8 @@
setProp('bottom', bottom);
setProp('width', width);
setProp('height', height);
+
+ if (width > 0 && height > 0) {
+ indicator.removeAttribute('hidden');
+ }
})();
diff --git a/packages/react/src/tabs/indicator/useTabsIndicator.ts b/packages/react/src/tabs/indicator/useTabsIndicator.ts
index 475d1ada0a..f9f811e14c 100644
--- a/packages/react/src/tabs/indicator/useTabsIndicator.ts
+++ b/packages/react/src/tabs/indicator/useTabsIndicator.ts
@@ -98,14 +98,17 @@ export function useTabsIndicator(
} as React.CSSProperties;
}, [left, right, top, bottom, width, height, isTabSelected]);
+ const displayIndicator = isTabSelected && width > 0 && height > 0;
+
const getRootProps = React.useCallback(
(externalProps = {}) => {
return mergeReactProps<'span'>(externalProps, {
role: 'presentation',
style,
+ hidden: !displayIndicator, // do not display the indicator before the layout is settled
});
},
- [style],
+ [style, displayIndicator],
);
return {
diff --git a/packages/react/src/number-field/utils/format.test.ts b/packages/react/src/utils/formatNumber.test.ts
similarity index 95%
rename from packages/react/src/number-field/utils/format.test.ts
rename to packages/react/src/utils/formatNumber.test.ts
index c60eb7d7c5..84a5e1bb72 100644
--- a/packages/react/src/number-field/utils/format.test.ts
+++ b/packages/react/src/utils/formatNumber.test.ts
@@ -1,5 +1,5 @@
import { expect } from 'chai';
-import { getFormatter } from './format';
+import { getFormatter } from './formatNumber';
const getOptions = (): Intl.NumberFormatOptions => ({
currency: 'USD',
diff --git a/packages/react/src/number-field/utils/format.ts b/packages/react/src/utils/formatNumber.ts
similarity index 100%
rename from packages/react/src/number-field/utils/format.ts
rename to packages/react/src/utils/formatNumber.ts
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);
+}