diff --git a/.changeset/honest-peas-sell.md b/.changeset/honest-peas-sell.md new file mode 100644 index 0000000000..19d697bfbe --- /dev/null +++ b/.changeset/honest-peas-sell.md @@ -0,0 +1,5 @@ +--- +"@heroui/slider": patch +--- + +introduce `getTooltipValue` prop for custom tooltip value (#4741) diff --git a/apps/docs/content/components/slider/custom-tooltip.raw.jsx b/apps/docs/content/components/slider/custom-tooltip.raw.jsx new file mode 100644 index 0000000000..b1657f1d39 --- /dev/null +++ b/apps/docs/content/components/slider/custom-tooltip.raw.jsx @@ -0,0 +1,42 @@ +import {Slider} from "@heroui/react"; + +export default function App() { + const formatMillisecondsToHHMMSS = (milliseconds) => { + if (typeof milliseconds !== "number") { + // Handle cases where value might be an array for multi-thumb + milliseconds = Array.isArray(milliseconds) ? milliseconds[0] : milliseconds; + } + if (isNaN(milliseconds) || milliseconds < 0) { + // Default for invalid input + return "00:00:00"; + } + + let totalSeconds = Math.floor(milliseconds / 1000); + let hours = Math.floor(totalSeconds / 3600); + + totalSeconds %= 3600; + let minutes = Math.floor(totalSeconds / 60); + let seconds = totalSeconds % 60; + + const pad = (num) => String(num).padStart(2, "0"); + + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; + }; + + return ( + // The slider's main value will be formatted using default or formatOptions + // The tooltip will use the hh:mm:ss format from + formatMillisecondsToHHMMSS(value)} + label="Video Duration (Tooltip: hh:mm:ss)" + // Example: 2 hours in ms + maxValue={7200000} + // 1-second steps + step={1000} + /> + ); +} diff --git a/apps/docs/content/components/slider/custom-tooltip.raw.tsx b/apps/docs/content/components/slider/custom-tooltip.raw.tsx new file mode 100644 index 0000000000..7ddfe67a19 --- /dev/null +++ b/apps/docs/content/components/slider/custom-tooltip.raw.tsx @@ -0,0 +1,40 @@ +import type {SliderValue} from "@heroui/react"; + +import {Slider} from "@heroui/react"; + +export default function App() { + const formatMillisecondsToHHMMSS = (milliseconds: number) => { + if (isNaN(milliseconds) || milliseconds < 0) { + // Default for invalid input + return "00:00:00"; + } + + let totalSeconds = Math.floor(milliseconds / 1000); + let hours = Math.floor(totalSeconds / 3600); + + totalSeconds %= 3600; + let minutes = Math.floor(totalSeconds / 60); + let seconds = totalSeconds % 60; + + const pad = (num: number) => String(num).padStart(2, "0"); + + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; + }; + + return ( + // The slider's main value will be formatted using default or formatOptions + // The tooltip will use the hh:mm:ss format from getTooltipValue + formatMillisecondsToHHMMSS(value as number)} + label="Video Duration (Tooltip: hh:mm:ss)" + // Example: 2 hours in ms + maxValue={7200000} + // 1-second steps + step={1000} + /> + ); +} diff --git a/apps/docs/content/components/slider/custom-tooltip.ts b/apps/docs/content/components/slider/custom-tooltip.ts new file mode 100644 index 0000000000..615bf46864 --- /dev/null +++ b/apps/docs/content/components/slider/custom-tooltip.ts @@ -0,0 +1,15 @@ +import App from "./custom-tooltip.raw.jsx?raw"; +import AppTs from "./custom-tooltip.raw.tsx?raw"; + +const react = { + "/App.jsx": App, +}; + +const reactTs = { + "/App.tsx": AppTs, +}; + +export default { + ...react, + ...reactTs, +}; diff --git a/apps/docs/content/components/slider/index.ts b/apps/docs/content/components/slider/index.ts index a00c764023..4ff566c203 100644 --- a/apps/docs/content/components/slider/index.ts +++ b/apps/docs/content/components/slider/index.ts @@ -24,6 +24,7 @@ import renderRangeThumb from "./render-range-thumb"; import renderLabel from "./render-label"; import renderValue from "./render-value"; import customStyles from "./custom-styles"; +import customTooltip from "./custom-tooltip"; export const sliderContent = { usage, @@ -37,6 +38,7 @@ export const sliderContent = { range, fillOffset, tooltip, + customTooltip, outline, disableThumbScale, valueFormatting, diff --git a/apps/docs/content/docs/components/slider.mdx b/apps/docs/content/docs/components/slider.mdx index e3e7e31fc2..a67e2e0b22 100644 --- a/apps/docs/content/docs/components/slider.mdx +++ b/apps/docs/content/docs/components/slider.mdx @@ -174,6 +174,16 @@ The Slider component provides a `renderValue` prop that allows you to customize +### Custom Tooltip Content + + +You can use the `getTooltipValue` prop to customize the content displayed in the tooltip by returning a string or a number from the function. the existing `tooltipValueFormatOptions` prop can be used for additional formatting when number is returned. + + + + +> If `tooltipProps.content` is set, it will take precedence over the value generated by `getTooltipValue`. + ### Disabling Thumb Scale In case you want to disable the thumb scale animation, you can pass the `disableThumbScale` prop. @@ -420,6 +430,12 @@ You can customize the `Slider` component by passing custom Tailwind CSS classes description: "A function to format the value. Overrides default formatted number.", default: "-" }, + { + attribute: "getTooltipValue", + type: "(value: SliderValue, index?: number) => string | number", + description: "A function to customize the content of the tooltip. Receives the slider value (number or number[] for range sliders) and an optional thumb index. If it returns a number, `tooltipValueFormatOptions` (an existing prop from 'Slider Props') can be used to format it. If `tooltipProps.content` is set, it takes precedence over the value generated by `getTooltipValue`.", + default: "-" + }, { attribute: "renderLabel", type: "(props: DOMAttributes) => ReactNode", diff --git a/packages/components/slider/__tests__/slider.test.tsx b/packages/components/slider/__tests__/slider.test.tsx index 8f65658171..80b0fd2164 100644 --- a/packages/components/slider/__tests__/slider.test.tsx +++ b/packages/components/slider/__tests__/slider.test.tsx @@ -389,3 +389,156 @@ describe("Slider", () => { expect(rightSlider).toHaveAttribute("aria-valuetext", "0.8"); }); }); + +describe("Slider with getTooltipValue", () => { + describe("getTooltipValue precedence", () => { + it("should use tooltipProps.content over getTooltipValue if both are provided", async () => { + const getTooltipValue = jest.fn((value) => `Custom: ${value}`); + const {getByRole, findByRole} = render( + , + ); + + const slider = getByRole("slider"); + + act(() => { + slider.focus(); + }); + + const tooltip = await findByRole("tooltip"); + + expect(tooltip).toHaveTextContent("Static Tooltip"); + // getTooltipValue should not be called when tooltipProps.content is set + expect(getTooltipValue).not.toHaveBeenCalled(); + }); + }); + + describe("string return value", () => { + it("should display string from getTooltipValue for single thumb", async () => { + const getTooltipValue = jest.fn((value) => `Value: ${value}`); + const {getByRole, findByRole} = render( + , + ); + + const slider = getByRole("slider"); + + act(() => { + slider.focus(); + }); + + const tooltip = await findByRole("tooltip"); + + expect(tooltip).toHaveTextContent("Value: 50"); + expect(getTooltipValue).toHaveBeenCalledWith(50); + }); + + it("should display string from getTooltipValue for multi-thumb", async () => { + const getTooltipValue = jest.fn((value, index) => `Thumb ${index}: ${value[index]}`); + const {getAllByRole, findByText} = render( + , + ); + + const sliders = getAllByRole("slider"); + + // Focus first thumb + act(() => { + sliders[0].focus(); + }); + + let tooltip = await findByText("Thumb 0: 25"); + + expect(tooltip).toBeInTheDocument(); + expect(getTooltipValue).toHaveBeenCalledWith([25, 75], 0); + + // Focus second thumb + act(() => { + sliders[1].focus(); + }); + + tooltip = await findByText("Thumb 1: 75"); + expect(tooltip).toBeInTheDocument(); + expect(getTooltipValue).toHaveBeenCalledWith([25, 75], 1); + }); + }); + + describe("number return value", () => { + it("should display formatted number using tooltipValueFormatOptions for single thumb", async () => { + const getTooltipValue = jest.fn((value) => value); + const {getByRole, findByRole} = render( + , + ); + + const slider = getByRole("slider"); + + act(() => { + slider.focus(); + }); + + const tooltip = await findByRole("tooltip"); + + expect(tooltip).toHaveTextContent("5,000%"); + expect(getTooltipValue).toHaveBeenCalledWith(50); + }); + + it("should fall back to formatOptions if tooltipValueFormatOptions is not provided", async () => { + const getTooltipValue = jest.fn((value) => value); + const {getByRole, findByRole} = render( + , + ); + + const slider = getByRole("slider"); + + act(() => { + slider.focus(); + }); + + const tooltip = await findByRole("tooltip"); + + expect(tooltip).toHaveTextContent("$50.00"); + expect(getTooltipValue).toHaveBeenCalledWith(50); + }); + + it("should display plain number if no format options are provided", async () => { + const getTooltipValue = jest.fn((value) => value); + const {getByRole, findByRole} = render( + , + ); + + const slider = getByRole("slider"); + + act(() => { + slider.focus(); + }); + + const tooltip = await findByRole("tooltip"); + + expect(tooltip).toHaveTextContent("50"); + expect(getTooltipValue).toHaveBeenCalledWith(50); + }); + }); +}); diff --git a/packages/components/slider/src/use-slider-thumb.ts b/packages/components/slider/src/use-slider-thumb.ts index 345ca24d90..5f1f2a6cc1 100644 --- a/packages/components/slider/src/use-slider-thumb.ts +++ b/packages/components/slider/src/use-slider-thumb.ts @@ -5,7 +5,7 @@ import type {RefObject} from "react"; import type {AriaSliderThumbProps} from "@react-aria/slider"; import type {SliderState} from "@react-stately/slider"; import type {TooltipProps} from "@heroui/tooltip"; -import type {UseSliderProps} from "./use-slider"; +import type {SliderValue, UseSliderProps} from "./use-slider"; import {useSliderThumb as useAriaSliderThumb} from "@react-aria/slider"; import {useDOMRef} from "@heroui/react-utils"; @@ -44,6 +44,14 @@ interface Props extends HTMLHeroUIProps<"div"> { * @internal */ tooltipProps?: UseSliderProps["tooltipProps"]; + + /** + * A function that returns the content to display as the tooltip label. (in analogy to getValue) + * @param value - The value of the slider, array or single number. + * @param index - The index of the thumb, if multiple thumbs are used. + * In addition to formatting with tooltipValueFormatOptions if number is returned. + */ + getTooltipValue?: (value: SliderValue, index?: number) => string | number; /** * Function to render the thumb. It can be used to add a tooltip or custom icon. */ @@ -64,6 +72,7 @@ export function useSliderThumb(props: UseSliderThumbProps) { tooltipProps, isVertical, showTooltip, + getTooltipValue, formatOptions, renderThumb, ...otherProps @@ -104,6 +113,8 @@ export function useSliderThumb(props: UseSliderThumbProps) { "data-dragging": dataAttr(isDragging), "data-focused": dataAttr(isFocused), "data-focus-visible": dataAttr(isFocusVisible), + "aria-label": + props["aria-label"] || `Slider thumb ${index !== undefined ? `${index + 1}` : ""}`, ...mergeProps(thumbProps, pressProps, hoverProps, otherProps), className, ...props, @@ -111,16 +122,30 @@ export function useSliderThumb(props: UseSliderThumbProps) { }; const getTooltipProps = () => { - const value = numberFormatter - ? numberFormatter.format(state.values[index ?? 0]) - : state.values[index ?? 0]; + const stateValue = tooltipProps?.content + ? tooltipProps.content + : getTooltipValue + ? state.values.length === 1 + ? getTooltipValue(state.values[index ?? 0]) + : getTooltipValue(state.values, index ?? 0) + : state.values[index ?? 0]; + + const value = + numberFormatter && typeof stateValue === "number" + ? numberFormatter.format(stateValue) + : stateValue; return { ...tooltipProps, placement: tooltipProps?.placement ? tooltipProps?.placement : isVertical ? "right" : "top", content: tooltipProps?.content ? tooltipProps?.content : value, - updatePositionDeps: [isDragging, isHovered, value], - isOpen: tooltipProps?.isOpen !== undefined ? tooltipProps?.isOpen : isHovered || isDragging, + updatePositionDeps: [isDragging, isHovered, isFocused, isFocusVisible, value], + isOpen: + tooltipProps?.isOpen !== undefined + ? tooltipProps?.isOpen + : isHovered || isDragging || isFocused || isFocusVisible, + role: "tooltip", + "aria-label": `Current value: ${value}`, } as TooltipProps; }; diff --git a/packages/components/slider/src/use-slider.ts b/packages/components/slider/src/use-slider.ts index 3db67c24b9..6ee1d00a8d 100644 --- a/packages/components/slider/src/use-slider.ts +++ b/packages/components/slider/src/use-slider.ts @@ -116,6 +116,15 @@ interface Props extends HTMLHeroUIProps<"div"> { * Overrides default formatted number. */ getValue?: (value: SliderValue) => string; + + /** + * A function that returns the content to display as the tooltip label. (in analogy to getValue) + * @param value - The value of the slider, array or single number. + * @param index - The index of the thumb, if multiple thumbs are used. + * In addition to formatting with tooltipValueFormatOptions if number is returned. + */ + getTooltipValue?: (value: SliderValue, index?: number) => string | number; + /** * Function to render the label. */ @@ -164,6 +173,7 @@ export function useSlider(originalProps: UseSliderProps) { onChange, onChangeEnd, getValue, + getTooltipValue, tooltipValueFormatOptions = formatOptions, tooltipProps: userTooltipProps = {}, ...otherProps @@ -375,6 +385,7 @@ export function useSlider(originalProps: UseSliderProps) { orientation, isVertical, tooltipProps, + getTooltipValue, showTooltip, renderThumb, formatOptions: tooltipValueFormatOptions, diff --git a/packages/components/slider/stories/slider.stories.tsx b/packages/components/slider/stories/slider.stories.tsx index f244774d41..2109deecde 100644 --- a/packages/components/slider/stories/slider.stories.tsx +++ b/packages/components/slider/stories/slider.stories.tsx @@ -309,6 +309,67 @@ export const WithTooltip = { }, }; +export const WithCustomTooltipTimeFormat = { + render: Template, + args: { + ...defaultProps, + label: "Video Progress (value in ms, tooltip as hh:mm:ss)", + defaultValue: 3665000, // 1 hour, 1 minute, 5 seconds + minValue: 0, + maxValue: 7200000, // 2 hours + step: 1000, // 1-second steps + showTooltip: true, + getTooltipValue: (value: SliderValue) => { + let milliseconds = typeof value === "number" ? value : Array.isArray(value) ? value[0] : 0; + + if (isNaN(milliseconds) || milliseconds < 0) { + milliseconds = 0; + } + + let totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + + totalSeconds %= 3600; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + const pad = (num: number) => String(num).padStart(2, "0"); + + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; + }, + tooltipProps: { + placement: "top", + }, + }, +}; + +export const WithCustomTooltipMultiThumb = { + render: Template, + args: { + ...defaultProps, + label: "Price Range with Custom Tooltips", + defaultValue: [30, 70], + minValue: 0, + maxValue: 100, + step: 1, + showTooltip: true, + getTooltipValue: (value: SliderValue, index?: number) => { + if (Array.isArray(value) && index !== undefined) { + return `Thumb ${index === 0 ? "Start" : "End"}: $${value[index]}`; + } + // For single value, though this story is for multi-thumb + if (typeof value === "number") { + return `$${value}`; + } + + return ""; + }, + tooltipProps: { + placement: "top", + }, + }, +}; + export const ThumbHidden = { render: Template, args: {