UNPKG

@kiwicom/orbit-components

Version:

Orbit-components is a React component library which provides developers with the easiest possible way of building Kiwi.com's products.

516 lines (514 loc) 24.3 kB
"use strict"; "use client"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; exports.__esModule = true; exports.default = void 0; var React = _interopRequireWildcard(require("react")); var _clsx = _interopRequireDefault(require("clsx")); var _Text = _interopRequireDefault(require("../Text")); var _Heading = _interopRequireDefault(require("../Heading")); var _Stack = _interopRequireDefault(require("../Stack")); var _Hide = _interopRequireDefault(require("../Hide")); var _Handle = _interopRequireDefault(require("./components/Handle")); var _Bar = _interopRequireDefault(require("./components/Bar")); var _consts = _interopRequireDefault(require("./consts")); var _Histogram = _interopRequireDefault(require("./components/Histogram")); var _utils = require("./utils"); var _useTheme = _interopRequireDefault(require("../hooks/useTheme")); /** * @orbit-doc-start * README * ---------- * # Slider * * To implement Slider component into your project you'll need to add the import: * * ```jsx * import Slider from "@kiwicom/orbit-components/lib/Slider"; * ``` * * After adding import into your project you can use it simply like: * * ```jsx * <Slider defaultValue={5} onChange={value => doSomething(value)} /> * ``` * * ## Props * * Table below contains all types of the props available in the Slider component. * * | Name | Type | Default | Description | * | :------------------- | :------------------------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------- | * | ariaLabel | `string or string[]` | | `aria-label` attribute or attributes for handles. See [functional specs](#functional-specs). | * | ariaValueText | `string` | | Readable text alternative of current value. See [Accessibility](/components/slider/accessibility/). | * | dataTest | `string` | | Optional prop for testing purposes. | * | id | `string` | | Set `id` for `Slider` | * | defaultValue | [`Value`](#value) | `1` | Initial value of the Slider when it mounts. See [value type](#value) for advanced usage. | * | histogramData | `number[]` | | Property for passing the histogram's data. See [Histogram](#histogram) for more info. | * | histogramDescription | `Translation` | | Text property where you should display the total count of displayed data. See [Histogram](#histogram) for more info. | * | histogramLoading | `boolean` | `false` | If `true` the Loading component will replace the Histogram. See [Histogram](#histogram) for more info. | * | histogramLoadingText | `Translation` | | The text of the Histogram when it's loading. See [Histogram](#histogram) for more info. | * | label | `Translation` | | The label of the Slider. Should communicate what is the purpose of it. | * | maxValue | `number` | `100` | The maximum value of the Slider. | * | minValue | `number` | `1` | The minimum value of the Slider. | * | onChange | `Value => void \| Promise` | | Callback for handling onChange event. See [functional specs](#functional-specs) for advanced usage. | * | onChangeAfter | `Value => void \| Promise` | | Calback for handling onChangeAfter event. See [functional specs](#functional-specs) for advanced usage. | * | onChangeBefore | `Value => void \| Promise` | | Callback for handling onChangeBefore event. See [functional specs](#functional-specs) for advanced usage. | * | step | `number` | `1` | Value that should be added or subtracted when Handle moves. The `maxValue` and `minValue` should be divisible by this number and it should be integer. | * | valueDescription | `Translation` | | Text property where you should display the selected value range. | * * ## Value * * The `Slider` component supports usage with one handle and also with multiple handles. * * If you want to use `Slider` with range possibility, just simply pass array of numbers to the `defaultValue` property, for instance `[1, 12]`. * The exact same type will be then returned with all callbacks. e.g.: * * ```jsx * <Slider * defaultValue={[1, 12]} * onChange={value => { * console.log(value); // [X, Y] * }} * /> * ``` * * ## Histogram * * - If you need to use `Slider` component together with `Histogram`, use property `histogramData` for that. * - You need pass the same amount of data that is possible to select by definition of `minValue`, `maxValue` and `step` property. The total count of columns should be `(maxValue - minValue + step) / step`. * - The Histogram won't be visible on desktop devices until the user focuses one of the handles. On mobile devices is the Histogram always shown. * - By default, the `histogramLoadingText` is null and only glyph of `inlineLoader` will appear. * - With Histogram, it's recommended to use also `histogramDescription` property, where you should display the total count of selected data from the array. For it, you can use the [`calculateCountOf`](#calculatecountof) function. * * ## Functional specs * * - When you use range type of the `Slider` component, you should specify `ariaLabel` property as array of labels. For instance: `["First handle", "Second handle]`. If you use simple `Slider`, just one string (not array) is enough. * - In every case of using the `Slider` component on **mobile devices**, the `Slider` should be wrapped in the **Popover**. For instance like this: * * ```jsx * const MobileSlider = () => { * const [value, setValue] = React.useState([1, 24]); * return ( * <Popover * content={ * <Slider defaultValue={[1, 24]} minValue={1} maxValue={24} onChange={val => setValue(val)} /> * } * > * <Tag selected={!!value}>Time of departure</Tag> * </Popover> * ); * }; * ``` * * ## calculateCountOf * * Function `calculateCountOf` will help you to count the total number of selected data and total number of all columns. You can then use these returned values for displaying the `histogramDescription` property to the user, properly. * * For using it, you can use this reference: * * ```jsx * import calculateCountOf from "@kiwicom/orbit-components/lib/Slider/utils/calculateCountOf"; * * const histogramData = [0, 10, 14, 40, 0, 11]; * const value = [1, 3]; // can be just number also * const minValue = 1; * const [selectedCount, totalCount] = calculateCountOf(histogramData, value, minValue); * * console.log(`Showing ${selectedCount} of ${totalCount}`); // Showing 24 of 75 flights * ``` * * ## Data-test * * There is a `dataTest` prop for ability to test the component. There are also hardcoded `data-test` attribute on handlers in format `SliderHandle-${index}` where index starts from `0`. * * * Accessibility * ------------- * ## Accessibility * * The Slider component has been designed with accessibility in mind, providing range selection functionality that is fully keyboard accessible and screen reader compatible. It automatically implements proper ARIA attributes and supports both single value and range selections. * * ### Accessibility Props * * **Slider props:** * * | Name | Type | Description | * | :------------ | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | * | ariaLabel | `string` or `string[]` | Specifies the accessible name for slider handles. For single sliders, use a string. For range sliders, use an array with labels for each handle (e.g., `["From", "To"]`). | * | ariaValueText | `string` | Provides readable text alternative of current value. Use when the numeric value doesn't accurately represent the meaning (e.g., time ranges like "00:00 to 13:00"). | * * ### Automatic Accessibility Features * * - The component automatically manages ARIA attributes: * - `aria-valuemax` is automatically set to the `maxValue` prop * - `aria-valuemin` is automatically set to the `minValue` prop * - `aria-valuenow` is automatically set to the current slider value * - `role="slider"` is applied to each handle * - Focus management is handled automatically: * - Each handle is keyboard focusable with `tabIndex={0}` * - Focus indicators are clearly visible * - State management is handled automatically: * - Current value is announced to screen readers when changed * - Range boundaries are communicated through ARIA attributes * * ### Keyboard Navigation * * The Slider component supports the following keyboard interactions: * * - **Arrow Up/Right:** Increases the value by one step * - **Arrow Down/Left:** Decreases the value by one step * - **Home:** Sets the handle to the minimum value * - **End:** Sets the handle to the maximum value * - **Tab:** Moves focus to the next handle (in range sliders) or next focusable element * - **Shift + Tab:** Moves focus to the previous handle or focusable element * * ### Best Practices * * - Always provide `ariaLabel` to ensure handles have accessible names, especially important for Storybook accessibility compliance * - For range sliders, use descriptive labels like `["Departure time", "Arrival time"]` rather than generic terms * - Use `ariaValueText` when the numeric value doesn't represent the actual meaning: * - Time ranges: `"00:00 to 13:00"` * - Price ranges: `"$50 to $200"` * - Percentage ranges: `"25% to 75%"` * - When using histograms, ensure the visual information is also available through `histogramDescription` for screen reader users * * ### Examples * * #### Basic Slider with Accessibility * * ```jsx * <Slider * label="Departure time" * defaultValue={12} * minValue={0} * maxValue={24} * ariaLabel="Select departure time" * ariaValueText={`Departure at ${value}:00`} * onChange={handleChange} * /> * ``` * * Screen reader announces: "Departure at 12:00, Select departure time, slider" * * #### Range Slider with Accessibility * * ```jsx * <Slider * label="Flight duration" * defaultValue={[2, 8]} * minValue={0} * maxValue={12} * ariaLabel={["Minimum duration", "Maximum duration"]} * ariaValueText={`${values[0]} to ${values[1]} hours`} * onChange={handleChange} * /> * ``` * * Screen reader announces: "2 to 8 hours, Minimum duration, slider" and "2 to 8 hours, Maximum duration, slider" * * * @orbit-doc-end */ const Slider = ({ defaultValue = _consts.default.VALUE, maxValue = _consts.default.MAX, minValue = _consts.default.MIN, step = _consts.default.STEP, onChange, onChangeAfter, onChangeBefore, ariaValueText, ariaLabel, label, histogramData, histogramLoading, histogramDescription, histogramLoadingText, valueDescription, id, dataTest }) => { const bar = React.useRef(null); const [value, setValue] = React.useState(defaultValue); const valueRef = React.useRef(value); const defaultRef = React.useRef(defaultValue); const handleIndex = React.useRef(null); const [focused, setFocused] = React.useState(false); const theme = (0, _useTheme.default)(); const { rtl } = theme; const updateValue = newValue => { valueRef.current = newValue; setValue(newValue); }; React.useEffect(() => { const newValue = Array.isArray(defaultValue) ? defaultValue.map(item => Number(item)) : Number(defaultValue); if ((0, _utils.isNotEqual)(defaultValue, defaultRef.current)) { defaultRef.current = newValue; updateValue(newValue); } }, [defaultValue]); const handleKeyDown = event => { if (event.ctrlKey || event.shiftKey || event.altKey) return; // Return early if not a navigation key if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; switch (event.key) { case "ArrowUp": (0, _utils.pauseEvent)(event); if (onChange) { (0, _utils.injectCallbackAndSetState)(updateValue, onChange, (0, _utils.moveValueByExtraStep)(valueRef.current, maxValue, minValue, step, handleIndex.current, step)); } break; case "ArrowDown": (0, _utils.pauseEvent)(event); if (onChange) { (0, _utils.injectCallbackAndSetState)(updateValue, onChange, (0, _utils.moveValueByExtraStep)(valueRef.current, maxValue, minValue, step, handleIndex.current, -step)); } break; case "ArrowRight": { const switchStep = rtl ? -step : step; (0, _utils.pauseEvent)(event); if (onChange) { (0, _utils.injectCallbackAndSetState)(updateValue, onChange, (0, _utils.moveValueByExtraStep)(valueRef.current, maxValue, minValue, step, handleIndex.current, switchStep)); } } break; case "ArrowLeft": { const switchStep = rtl ? step : -step; (0, _utils.pauseEvent)(event); if (onChange) { (0, _utils.injectCallbackAndSetState)(updateValue, onChange, (0, _utils.moveValueByExtraStep)(valueRef.current, maxValue, minValue, step, handleIndex.current, switchStep)); } } break; case "Home": (0, _utils.pauseEvent)(event); if (onChange) { (0, _utils.injectCallbackAndSetState)(updateValue, onChange, (0, _utils.moveValueByExtraStep)(valueRef.current, maxValue, minValue, step, handleIndex.current, 0, minValue)); } break; case "End": (0, _utils.pauseEvent)(event); if (onChange) { (0, _utils.injectCallbackAndSetState)(updateValue, onChange, (0, _utils.moveValueByExtraStep)(valueRef.current, maxValue, minValue, step, handleIndex.current, 0, maxValue)); } break; default: } }; const handleBlur = () => { setFocused(false); window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("focusout", handleBlur); if (onChangeAfter) { (0, _utils.injectCallbackAndSetState)(updateValue, onChangeAfter, valueRef.current); } }; const handleOnFocus = i => event => { handleIndex.current = i; setFocused(true); (0, _utils.pauseEvent)(event.nativeEvent); window.addEventListener("keydown", handleKeyDown); window.addEventListener("focusout", handleBlur); if (onChangeBefore) { (0, _utils.injectCallbackAndSetState)(updateValue, onChangeBefore, value); } }; const handleMove = newValue => { if (newValue != null) { if (Array.isArray(value)) { return (0, _utils.constrainRangeValue)(valueRef.current, (0, _utils.alignValue)(maxValue, minValue, step, newValue), Number(handleIndex.current)); } return (0, _utils.alignValue)(maxValue, minValue, step, newValue); } return null; }; const handleBarMouseDown = event => { handleIndex.current = null; const newValue = (0, _utils.calculateValueFromPosition)({ histogramData, histogramLoading, maxValue, minValue, handleIndex: handleIndex.current, bar, rtl, value, pageX: event.pageX, throughClick: true }); if (newValue) { if (Array.isArray(value)) { const index = (0, _utils.findClosestKey)(newValue, value); const replacedValue = (0, _utils.constrainRangeValue)(value, (0, _utils.alignValue)(maxValue, minValue, step, newValue), index || 0); if (onChangeBefore) (0, _utils.injectCallbackAndSetState)(updateValue, onChangeBefore, value); if (onChange) (0, _utils.injectCallbackAndSetState)(updateValue, onChange, replacedValue); if (onChangeAfter) (0, _utils.injectCallbackAndSetState)(updateValue, onChangeAfter, replacedValue); } else { const alignedValue = (0, _utils.alignValue)(maxValue, minValue, step, newValue); if (onChangeBefore) (0, _utils.injectCallbackAndSetState)(updateValue, onChangeBefore, value); if (onChange) (0, _utils.injectCallbackAndSetState)(updateValue, onChange, alignedValue); if (onChangeAfter) (0, _utils.injectCallbackAndSetState)(updateValue, onChangeAfter, alignedValue); } } }; const handleMouseMove = event => { const newValue = (0, _utils.calculateValueFromPosition)({ histogramData, histogramLoading, maxValue, minValue, handleIndex: handleIndex.current, bar, rtl, value, pageX: event.pageX }); (0, _utils.pauseEvent)(event); (0, _utils.injectCallbackAndSetState)(updateValue, onChange, handleMove(newValue)); }; const handleMouseUp = () => { setFocused(false); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); if (onChangeAfter) { (0, _utils.injectCallbackAndSetState)(updateValue, onChangeAfter, valueRef.current); } }; const handleMouseDown = i => event => { // just allow left-click if (event.button === 0 && event.buttons !== 2) { setFocused(true); handleIndex.current = i; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); (0, _utils.pauseEvent)(event.nativeEvent); if (onChangeBefore) { (0, _utils.injectCallbackAndSetState)(updateValue, onChangeBefore, value); } } }; const handleOnTouchMove = event => { if (event.touches.length > 1) return; const newValue = (0, _utils.calculateValueFromPosition)({ histogramData, histogramLoading, maxValue, minValue, handleIndex: handleIndex.current, bar, rtl, value, pageX: event.touches[0]?.pageX || 0 }); (0, _utils.pauseEvent)(event); (0, _utils.injectCallbackAndSetState)(updateValue, onChange, handleMove(newValue)); }; const handleTouchEnd = () => { setFocused(false); window.removeEventListener("touchmove", handleOnTouchMove); window.removeEventListener("touchend", handleTouchEnd); if (onChangeAfter) { (0, _utils.injectCallbackAndSetState)(updateValue, onChangeAfter, valueRef.current); } }; const handleOnTouchStart = i => event => { if (event.touches.length <= 1) { setFocused(true); handleIndex.current = i; window.addEventListener("touchmove", handleOnTouchMove, { passive: false }); window.addEventListener("touchend", handleTouchEnd); (0, _utils.stopPropagation)(event.nativeEvent); if (onChangeBefore) { (0, _utils.injectCallbackAndSetState)(updateValue, onChangeBefore, value); } } }; const renderHandle = i => { const key = i && encodeURIComponent(i.toString()); const index = i || 0; return /*#__PURE__*/React.createElement(_Handle.default, { onTop: handleIndex.current === i, valueMax: maxValue, valueMin: minValue, onMouseDown: handleMouseDown(index), onFocus: handleOnFocus(index), onTouchStart: handleOnTouchStart(index), value: valueRef.current, ariaValueText: ariaValueText, ariaLabel: ariaLabel, hasHistogram: histogramLoading || !!histogramData, index: index, key: key, dataTest: `SliderHandle-${index}` }); }; const renderHandles = () => Array.isArray(value) ? value.map((_valueNow, index) => renderHandle(index)) : renderHandle(); const renderSliderTexts = React.useCallback(biggerSpace => { if (!(label || valueDescription || histogramDescription)) return null; return /*#__PURE__*/React.createElement(_Stack.default, { direction: "row", spacing: "none", spaceAfter: biggerSpace ? "medium" : "small" }, (label || histogramDescription) && /*#__PURE__*/React.createElement(_Stack.default, { direction: "column", spacing: "none", basis: "60%", grow: true }, label && /*#__PURE__*/React.createElement(_Heading.default, { type: "title4" }, label), valueDescription && /*#__PURE__*/React.createElement(_Text.default, { type: "secondary", size: "small" }, valueDescription)), histogramDescription && /*#__PURE__*/React.createElement(_Stack.default, { shrink: true, justify: "end", grow: false }, /*#__PURE__*/React.createElement(_Text.default, { type: "primary", size: "small" }, histogramDescription))); }, [histogramDescription, label, valueDescription]); if (histogramData) { const properHistogramLength = (maxValue - minValue + step) / step; if (histogramData.length !== properHistogramLength) { console.warn(`Warning: Length of histogramData array is ${histogramData.length}, but should be ${properHistogramLength}. This will cause broken visuals of the whole Histogram.`); } } const sortedValue = (0, _utils.sortArray)(valueRef.current); const hasHistogram = histogramLoading || !!histogramData; return /*#__PURE__*/React.createElement("div", { className: "orbit-slider relative", "data-test": dataTest, id: id }, hasHistogram ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(_Hide.default, { on: ["smallMobile", "mediumMobile", "largeMobile"], block: true }, renderSliderTexts(true)), /*#__PURE__*/React.createElement("div", { className: (0, _clsx.default)("pb-200 tb:w-[calc(100%+48px)] tb:absolute tb:-bottom-400 tb:-inset-x-600 tb:pt-300 tb:px-600 tb:pb-1200 tb:rounded-100 tb:transition-opacity tb:ease-in-out tb:duration-fast tb:bg-white-normal tb:shadow-level3", focused ? "tb:visible tb:opacity-100" : "tb:invisible tb:opacity-0") }, renderSliderTexts(false), /*#__PURE__*/React.createElement(_Histogram.default, { data: histogramData, value: sortedValue, min: minValue, step: step, loading: histogramLoading, loadingText: histogramLoadingText }))) : renderSliderTexts(true), /*#__PURE__*/React.createElement("div", { className: "h-600 flex w-full items-center" }, /*#__PURE__*/React.createElement(_Bar.default, { ref: bar, onMouseDown: handleBarMouseDown, value: sortedValue, max: maxValue, min: minValue, hasHistogram: hasHistogram }), renderHandles())); }; var _default = exports.default = Slider;