Skip to content
5 changes: 5 additions & 0 deletions .changeset/honest-peas-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@heroui/slider": patch
---

introduce `getTooltipValue` prop for custom tooltip value (#4741)
42 changes: 42 additions & 0 deletions apps/docs/content/components/slider/custom-tooltip.raw.jsx
Original file line number Diff line number Diff line change
@@ -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
<Slider
showTooltip
// Example: 1 hour, 1 minute, 5 seconds in ms
defaultValue={3665000}
// Single thumb, SliderValue is a number.
getTooltipValuegetTooltipValue={(value) => formatMillisecondsToHHMMSS(value)}
label="Video Duration (Tooltip: hh:mm:ss)"
// Example: 2 hours in ms
maxValue={7200000}
// 1-second steps
step={1000}
/>
);
}
40 changes: 40 additions & 0 deletions apps/docs/content/components/slider/custom-tooltip.raw.tsx
Original file line number Diff line number Diff line change
@@ -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
<Slider
showTooltip
// Example: 1 hour, 1 minute, 5 seconds in ms
defaultValue={3665000}
// Single thumb, SliderValue is a number.
getTooltipValue={(value: SliderValue) => formatMillisecondsToHHMMSS(value as number)}
label="Video Duration (Tooltip: hh:mm:ss)"
// Example: 2 hours in ms
maxValue={7200000}
// 1-second steps
step={1000}
/>
);
}
15 changes: 15 additions & 0 deletions apps/docs/content/components/slider/custom-tooltip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import App from "./custom-tooltip.raw.jsx?raw";
Comment thread
ararTP marked this conversation as resolved.
import AppTs from "./custom-tooltip.raw.tsx?raw";
Comment thread
ararTP marked this conversation as resolved.

const react = {
"/App.jsx": App,
};

const reactTs = {
"/App.tsx": AppTs,
};

export default {
...react,
...reactTs,
};
2 changes: 2 additions & 0 deletions apps/docs/content/components/slider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,6 +38,7 @@ export const sliderContent = {
range,
fillOffset,
tooltip,
customTooltip,
outline,
disableThumbScale,
valueFormatting,
Expand Down
16 changes: 16 additions & 0 deletions apps/docs/content/docs/components/slider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@ The Slider component provides a `renderValue` prop that allows you to customize

<CodeDemo title="Custom Value" files={sliderContent.renderValue} />

### 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.

<CodeDemo title="Custom tooltip" files={sliderContent.customTooltip} />


> 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.
Expand Down Expand Up @@ -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<HTMLLabelElement>) => ReactNode",
Expand Down
153 changes: 153 additions & 0 deletions packages/components/slider/__tests__/slider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Slider
showTooltip
aria-label="Value slider"
defaultValue={30}
getTooltipValue={getTooltipValue}
tooltipProps={{content: "Static Tooltip"}}
/>,
);

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(
<Slider
showTooltip
aria-label="Single value slider"
defaultValue={50}
getTooltipValue={getTooltipValue}
/>,
);

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(
<Slider
showTooltip
aria-label="Range slider"
defaultValue={[25, 75]}
getTooltipValue={getTooltipValue}
/>,
);

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(
<Slider
showTooltip
defaultValue={50}
getTooltipValue={getTooltipValue}
tooltipValueFormatOptions={{style: "percent", minimumFractionDigits: 0}}
/>,
);

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(
<Slider
showTooltip
defaultValue={50}
formatOptions={{style: "currency", currency: "USD"}}
getTooltipValue={getTooltipValue}
/>,
);

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(
<Slider showTooltip defaultValue={50} getTooltipValue={getTooltipValue} />,
);

const slider = getByRole("slider");

act(() => {
slider.focus();
});

const tooltip = await findByRole("tooltip");

expect(tooltip).toHaveTextContent("50");
expect(getTooltipValue).toHaveBeenCalledWith(50);
});
});
});
37 changes: 31 additions & 6 deletions packages/components/slider/src/use-slider-thumb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*/
Expand All @@ -64,6 +72,7 @@ export function useSliderThumb(props: UseSliderThumbProps) {
tooltipProps,
isVertical,
showTooltip,
getTooltipValue,
formatOptions,
renderThumb,
...otherProps
Expand Down Expand Up @@ -104,23 +113,39 @@ 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,
};
};

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;
};

Expand Down
Loading