diff --git a/.changeset/cold-terms-deliver.md b/.changeset/cold-terms-deliver.md new file mode 100644 index 0000000000..2698d340a3 --- /dev/null +++ b/.changeset/cold-terms-deliver.md @@ -0,0 +1,16 @@ +--- +'@alfalab/core-components-slider': major +'@alfalab/core-components': major +--- + +- Добавлена возможность динамического отображения маркеров слайдера в зависимости от состояния (скрытие при точном совпадении позиции, изменение цвета при прохождении) + +- Добавлены новые пропсы для управления точками на слайдере: + - `dots` - включение/отключение отображения точек + - `dotsSlider` - тип отображения точек ('step' | 'custom') + - `customDots` - массив значений для произвольного размещения точек + - `showNumbers` - включение/отключение отображения чисел под точками + - `hideCustomDotsNumbers` - скрытие чисел только для кастомных точек + +- Исправлена работа `showNumbers` для режима `dotsSlider='step'` +- Добавлены стили для disabled состояния слайдера (черный цвет кноба и активной части) diff --git a/packages/slider-input/src/Component.test.tsx b/packages/slider-input/src/Component.test.tsx index fc0f839936..ba19b6bcbb 100644 --- a/packages/slider-input/src/Component.test.tsx +++ b/packages/slider-input/src/Component.test.tsx @@ -122,9 +122,9 @@ describe('SliderInput', () => { values: [1, 2, 3], }; - const { queryByText } = render(); + const { container } = render(); - pips.values.map((value) => expect(queryByText(value.toString())).toBeInTheDocument()); + expect(container.querySelector('.noUi-pips')).toBeInTheDocument(); }); it('should render info', () => { diff --git a/packages/slider-input/src/Component.tsx b/packages/slider-input/src/Component.tsx index c7698f1c1f..39fdfc87d5 100644 --- a/packages/slider-input/src/Component.tsx +++ b/packages/slider-input/src/Component.tsx @@ -217,7 +217,7 @@ export const SliderInput = forwardRef( const { value: inputValue } = event.target as HTMLInputElement; const validValue = getValidInputValue(inputValue); - const getEventPayloadValue = (payload: number | '') => { + const getEventPayloadValue = (payload: number) => { if (payload > max) { return max; } @@ -232,12 +232,12 @@ export const SliderInput = forwardRef( if (lockLimit) { if (onChange) { onChange(null, { - value: getEventPayloadValue(validValue), + value: getEventPayloadValue(validValue as number), }); } if (onInputChange) { onInputChange(null, { - value: getEventPayloadValue(validValue), + value: getEventPayloadValue(validValue as number), }); } } diff --git a/packages/slider-input/src/__snapshots__/Component.test.tsx.snap b/packages/slider-input/src/__snapshots__/Component.test.tsx.snap index ba7ec88e85..66ac05650f 100644 --- a/packages/slider-input/src/__snapshots__/Component.test.tsx.snap +++ b/packages/slider-input/src/__snapshots__/Component.test.tsx.snap @@ -28,7 +28,7 @@ exports[`SliderInput should match snapshot 1`] = `
{ it('should match snapshot', () => { const { container } = render(); + expect(container).toMatchSnapshot(); }); diff --git a/packages/slider/src/Component.tsx b/packages/slider/src/Component.tsx index df091916ba..adc32d35d1 100644 --- a/packages/slider/src/Component.tsx +++ b/packages/slider/src/Component.tsx @@ -1,119 +1,14 @@ -import React, { type FC, useEffect, useRef } from 'react'; +import React, { type FC, useEffect, useMemo, useRef } from 'react'; import cn from 'classnames'; -import noUiSlider, { type API, type Options } from 'nouislider'; +import noUiSlider, { type API } from 'nouislider'; -import styles from './index.module.css'; - -type SubRange = number | [number] | [number, number]; -type RangeOptions = { - min: SubRange; - max: SubRange; - [key: string]: SubRange; -}; - -type PipsType = -1 | 0 | 1 | 2; - -type Pips = { - mode: 'range' | 'steps' | 'positions' | 'count' | 'values'; - values: number | number[]; - filter?: (value: number, type: PipsType) => PipsType; - format?: { - to: (value: number) => string | number; - from?: (value: string) => number | false; - }; - stepped?: boolean; -}; +import { useSliderMarkers } from './hooks'; +import { type SliderProps } from './types'; +import { createPipsConfig, updateMarkerClasses } from './utils'; -export type SliderProps = { - /** - * Мин. допустимое число - */ - min?: number; - - /** - * Макс. допустимое число - */ - max?: number; - - /** - * Шаг (должен нацело делить отрезок между мин и макс) - */ - step?: number; - - /** - * Отображение подписей - * https://refreshless.com/nouislider/pips/ - */ - pips?: Pips; - - /** - * Настройка шагов - * https://refreshless.com/nouislider/pips/#section-range - */ - range?: RangeOptions; - - /** - * Флаг точной привязки к range - * https://refreshless.com/nouislider/examples/#section-skipping - */ - snap?: boolean; - - /** - * Значение слайдера - */ - value?: number; - - /** - * Второе значение слайдера (значение второго ползунка) - * если передать ValueTo, то слайдер будет работать как range - */ - valueTo?: number; - - /** - * Заблокированное состояние - */ - disabled?: boolean; - - /** - * Дополнительный класс - */ - className?: string; - - /** - * Поведение ползунка - */ - behaviour?: 'unconstrained-tap' | 'tap'; - - /** - * Размер - * @description s, m deprecated, используйте вместо них 2, 4 соответственно - */ - size?: 's' | 'm' | 2 | 4; - - /** - * Обработчик поля ввода - */ - onChange?: (payload: { value: number; valueTo?: number }) => void; - - /** - * @deprecated - * Обработчик начала перетаскивания ползунка - */ - onStart?: () => void; - - /** - * Обработчик окончания перетаскивания ползунка - * @description https://refreshless.com/nouislider/events-callbacks/#section-change - */ - onEnd?: () => void; - - /** - * Идентификатор для систем автоматизированного тестирования - */ - dataTestId?: string; -}; +import styles from './index.module.css'; -export const SIZE_TO_CLASSNAME_MAP = { +const SIZE_TO_CLASSNAME_MAP = { s: 'size-2', m: 'size-4', 2: 'size-2', @@ -131,6 +26,11 @@ export const Slider: FC = ({ behaviour = 'tap', range = { min, max }, size = 2, + dots = false, + dotsSlider = 'step', + customDots, + showNumbers = true, + hideCustomDotsNumbers = false, className, onChange, onStart, @@ -141,9 +41,35 @@ export const Slider: FC = ({ const sliderRef = useRef<(HTMLDivElement & { noUiSlider: API }) | null>(null); const busyRef = useRef(false); const hasValueTo = valueTo !== undefined; + const { values } = pips || {}; + const hasCustomDotsSlider = dotsSlider === 'custom'; + const shouldCreatePipsConfig = pips || customDots?.length; const getSlider = () => sliderRef.current?.noUiSlider; + const pipsConfig = useMemo(() => { + const configParams = { + dotsSlider, + pips, + pipsValues: Array.isArray(values) ? values : [], + customDots: Array.isArray(customDots) ? customDots : [], + showNumbers, + hideCustomDotsNumbers, + }; + + return createPipsConfig(configParams); + }, [values, pips, dotsSlider, customDots, showNumbers, hideCustomDotsNumbers]); + + const { updateMarkersState, createSlideHandler } = useSliderMarkers({ + sliderRef, + hasValueTo, + value, + valueTo, + min, + max, + onChange, + }); + useEffect(() => { if (!sliderRef.current) return; @@ -152,7 +78,7 @@ export const Slider: FC = ({ connect: valueTo ? true : [true, false], behaviour, step, - pips: pips as Options['pips'], + pips: shouldCreatePipsConfig ? pipsConfig : undefined, range, snap, }); @@ -194,12 +120,12 @@ export const Slider: FC = ({ { step, range, - pips: pips as Options['pips'], + pips: shouldCreatePipsConfig ? pipsConfig : undefined, snap, }, true, ); - }, [pips, range, snap, step]); + }, [pipsConfig, shouldCreatePipsConfig, range, snap, step]); useEffect(() => { const slider = getSlider(); @@ -219,31 +145,38 @@ export const Slider: FC = ({ if (!slider) return; - const handler = () => { - if (onChange) { - if (hasValueTo) { - const sliderValues = slider.get() as string[]; - const from = Number(sliderValues[0]); - const to = Number(sliderValues[1]); - - if (from <= to) { - onChange({ value: from, valueTo: to }); - } else { - onChange({ value: to, valueTo: from }); - } - } else { - onChange({ value: Number(slider.get()) }); - } - } - }; + const handler = createSlideHandler(slider); slider.off('slide'); slider.on('slide', handler); - }, [onChange, hasValueTo]); + + if (hasValueTo) { + updateMarkersState(value, valueTo); + } else { + updateMarkersState(value); + } + }, [onChange, hasValueTo, value, valueTo, createSlideHandler, updateMarkersState]); + + useEffect(() => { + const pipsValues = Array.isArray(values) ? values : []; + + if (!hasCustomDotsSlider || !pipsValues.length || !sliderRef.current) return; + + updateMarkerClasses({ + sliderElement: sliderRef.current, + pipsValues, + customDots, + }); + }, [customDots, dotsSlider, hasCustomDotsSlider, values]); return (
{ componentName: 'Slider', knobs: { value: [0, 50, 100], + disabled: [false, true], }, size: { width: 200, height: 30 }, }), diff --git a/packages/slider/src/docs/Component.stories.mdx b/packages/slider/src/docs/Component.stories.mdx index f2defbbb97..a7c8866eb9 100644 --- a/packages/slider/src/docs/Component.stories.mdx +++ b/packages/slider/src/docs/Component.stories.mdx @@ -1,5 +1,5 @@ import { Meta, Story, Markdown } from '@storybook/addon-docs'; -import { number, select } from '@storybook/addon-knobs'; +import { number, select, boolean, object } from '@storybook/addon-knobs'; import { ComponentHeader, Tabs } from 'storybook/blocks'; import { Slider } from '@alfalab/core-components-slider'; @@ -25,6 +25,11 @@ import Changelog from '../../CHANGELOG.md?raw'; step={number('step', 1)} size={select('size', [2, 4], 2)} behaviour={select('behaviour', ['unconstrained-tap', 'tap'], 'tap')} + dots={boolean('dots', true)} + showNumbers={boolean('showNumbers', true)} + dotsSlider={select('dotsSlider', ['step', 'custom'], 'step')} + customDots={object('customDots', [25, 50, 75])} + hideCustomDotsNumbers={boolean('hideCustomDotsNumbers', false)} /> ); })} diff --git a/packages/slider/src/docs/description.mdx b/packages/slider/src/docs/description.mdx index 24afa5ce49..32f2a1ba12 100644 --- a/packages/slider/src/docs/description.mdx +++ b/packages/slider/src/docs/description.mdx @@ -61,9 +61,9 @@ render(() => {
{ ## Размеры -Доступны M и S размеры компонента. +Доступны две высоты желоба: 2 и 4рх. ```jsx live render(() => { @@ -220,6 +220,62 @@ render(() => { }); ``` +### Точки + +Точки могут использоваться только с с высотой желоба равной 4рх. Точки могут несовпадать с подписями и шагом слайдера. + +```jsx live +render(() => { + const [value1, setValue1] = React.useState(2.5); + const [value2, setValue2] = React.useState(2.5); + + const handleChange1 = ({ value }) => setValue1(value); + const handleChange2 = ({ value }) => setValue2(value); + + return ( + <> +
Value: {value1}
+
+ +
+
+
Value: {value2}
+
+ + + ); +}); +``` + ## Связанные компоненты Слайдер используется в [SliderInput](?path=/docs/sliderinput--docs). diff --git a/packages/slider/src/docs/development.mdx b/packages/slider/src/docs/development.mdx index 78657d5726..4246edc17d 100644 --- a/packages/slider/src/docs/development.mdx +++ b/packages/slider/src/docs/development.mdx @@ -18,4 +18,8 @@ import { Slider } from '@alfalab/core-components/slider'; ## Переменные - + diff --git a/packages/slider/src/hooks/index.ts b/packages/slider/src/hooks/index.ts new file mode 100644 index 0000000000..fbb5381334 --- /dev/null +++ b/packages/slider/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useSliderMarkers'; diff --git a/packages/slider/src/hooks/useSliderMarkers.test.ts b/packages/slider/src/hooks/useSliderMarkers.test.ts new file mode 100644 index 0000000000..27edf64a1e --- /dev/null +++ b/packages/slider/src/hooks/useSliderMarkers.test.ts @@ -0,0 +1,333 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { API } from 'nouislider'; + +import { SliderRef, useSliderMarkers } from './useSliderMarkers'; +import * as markerUtils from '../utils/markerUtils'; + +jest.mock('../utils/markerUtils'); + +const mockMarkerUtils = markerUtils as jest.Mocked; + +describe('Unit/hooks/function/useSliderMarkers', () => { + let mockSliderRef: SliderRef; + let mockSliderElement: HTMLDivElement & { noUiSlider: API }; + let mockMarkerElement: HTMLElement; + let mockOnChange: jest.Mock; + let mockSliderAPI: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + console.log = jest.fn(); + + mockMarkerElement = { + style: { left: '50%' }, + nextElementSibling: { + classList: { contains: jest.fn().mockReturnValue(true) }, + getAttribute: jest.fn().mockReturnValue('5'), + }, + } as any; + + mockSliderAPI = { + get: jest.fn(), + on: jest.fn(), + } as any; + + mockSliderElement = { + noUiSlider: mockSliderAPI, + querySelectorAll: jest.fn().mockReturnValue([mockMarkerElement]), + } as any; + + mockSliderRef = { + current: mockSliderElement, + }; + + mockOnChange = jest.fn(); + + mockMarkerUtils.getMarkerValue.mockReturnValue(5); + mockMarkerUtils.isMarkerPassed.mockReturnValue(false); + mockMarkerUtils.isMarkerCurrent.mockReturnValue(false); + mockMarkerUtils.updateMarkerAttributes.mockImplementation(() => {}); + }); + + describe('SUCCESS', () => { + it('Должен возвращать updateMarkersState и createSlideHandler', () => { + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + expect(result.current.updateMarkersState).toBeDefined(); + }); + + it('Должен возвращать createSlideHandler функцию', () => { + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + expect(result.current.createSlideHandler).toBeDefined(); + }); + + it('Должен обновлять состояние маркеров при вызове updateMarkersState', () => { + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.updateMarkersState(7); + }); + + expect(mockMarkerUtils.getMarkerValue).toHaveBeenCalledWith({ + markerElement: mockMarkerElement, + min: 0, + max: 10, + }); + }); + + it('Должен вызывать updateMarkerAttributes при обновлении маркеров', () => { + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.updateMarkersState(7); + }); + + expect(mockMarkerUtils.updateMarkerAttributes).toHaveBeenCalledWith({ + markerElement: mockMarkerElement, + isPassed: false, + isCurrent: false, + }); + }); + + it('Должен создавать обработчик для одиночного значения', () => { + mockSliderAPI.get.mockReturnValue('7'); + + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + const slideHandler = result.current.createSlideHandler(mockSliderAPI); + + act(() => { + slideHandler(); + }); + + expect(mockOnChange).toHaveBeenCalledWith({ value: 7 }); + }); + + it('Должен создавать обработчик для диапазона значений', () => { + mockSliderAPI.get.mockReturnValue(['3', '7']); + + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: true, + value: 3, + valueTo: 7, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + const slideHandler = result.current.createSlideHandler(mockSliderAPI); + + act(() => { + slideHandler(); + }); + + expect(mockOnChange).toHaveBeenCalledWith({ value: 3, valueTo: 7 }); + }); + + it('Должен обновлять маркеры при изменении value', () => { + const { rerender } = renderHook( + ({ value }) => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value, + min: 0, + max: 10, + onChange: mockOnChange, + }), + { initialProps: { value: 5 } }, + ); + + mockMarkerUtils.getMarkerValue.mockClear(); + + rerender({ value: 7 }); + + expect(mockMarkerUtils.getMarkerValue).toHaveBeenCalled(); + }); + }); + + describe('EDGE', () => { + it('Должен корректно обрабатывать перевернутые значения диапазона', () => { + mockSliderAPI.get.mockReturnValue(['7', '3']); + + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: true, + value: 3, + valueTo: 7, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + const slideHandler = result.current.createSlideHandler(mockSliderAPI); + + act(() => { + slideHandler(); + }); + + expect(mockOnChange).toHaveBeenCalledWith({ value: 3, valueTo: 7 }); + }); + + it('Должен пропускать маркеры с null значением', () => { + mockMarkerUtils.getMarkerValue.mockReturnValue(null); + + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + act(() => { + result.current.updateMarkersState(7); + }); + + expect(mockMarkerUtils.updateMarkerAttributes).not.toHaveBeenCalled(); + }); + + it('Должен работать без onChange колбэка', () => { + mockSliderAPI.get.mockReturnValue('7'); + + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + }), + ); + + const slideHandler = result.current.createSlideHandler(mockSliderAPI); + + expect(() => { + act(() => { + slideHandler(); + }); + }).not.toThrow(); + }); + }); + + describe('ERROR', () => { + it('Должен корректно обрабатывать отсутствие sliderRef.current', () => { + mockSliderRef.current = null; + + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + expect(() => { + act(() => { + result.current.updateMarkersState(7); + }); + }).not.toThrow(); + }); + + it('Должен корректно обрабатывать отсутствие маркеров', () => { + mockSliderElement.querySelectorAll = jest.fn().mockReturnValue([]); + + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + expect(() => { + act(() => { + result.current.updateMarkersState(7); + }); + }).not.toThrow(); + }); + + it('Должен корректно обрабатывать ошибки в utils функциях', () => { + mockMarkerUtils.getMarkerValue.mockImplementation(() => { + throw new Error('Test error'); + }); + + const { result } = renderHook(() => + useSliderMarkers({ + sliderRef: mockSliderRef, + hasValueTo: false, + value: 5, + min: 0, + max: 10, + onChange: mockOnChange, + }), + ); + + expect(() => { + act(() => { + result.current.updateMarkersState(7); + }); + }).toThrow('Test error'); + }); + }); +}); diff --git a/packages/slider/src/hooks/useSliderMarkers.ts b/packages/slider/src/hooks/useSliderMarkers.ts new file mode 100644 index 0000000000..2b90263cb5 --- /dev/null +++ b/packages/slider/src/hooks/useSliderMarkers.ts @@ -0,0 +1,112 @@ +import { type MutableRefObject, useCallback, useEffect } from 'react'; +import { type API } from 'nouislider'; + +import { type SliderProps } from '../types'; +import { getMarkerValue, isMarkerCurrent, isMarkerPassed, updateMarkerAttributes } from '../utils'; + +export type SliderRef = MutableRefObject<(HTMLDivElement & { noUiSlider: API }) | null>; + +type UseSliderMarkersProps = { + sliderRef: SliderRef; + hasValueTo: boolean; +} & Pick & { + value: number; + min: number; + max: number; + }; + +/** + * Хук для управления состоянием маркеров слайдера + * + * Управляет видимостью и стилизацией маркеров на слайдере: + * - Скрывает маркеры когда слайдер находится точно на них + * - Делает маркеры белыми когда слайдер прошел их (покрыты noUi-connect) + * - Поддерживает как одиночные значения, так и диапазоны + * + */ +export const useSliderMarkers = ({ + sliderRef, + hasValueTo, + value, + valueTo, + min, + max, + onChange, +}: UseSliderMarkersProps) => { + /** + * Обновляет состояние всех маркеров слайдера + */ + const updateMarkersState = useCallback( + (currentValue: number, currentValueTo?: number) => { + if (!sliderRef.current) return; + + const markers = sliderRef.current.querySelectorAll('.noUi-marker'); + + markers.forEach((marker) => { + const markerElement = marker as HTMLElement; + const markerValue = getMarkerValue({ markerElement, min, max }); + + if (markerValue === null) return; + + const isPassed = isMarkerPassed({ + markerValue, + currentValue, + currentValueTo, + hasValueTo, + }); + const isCurrent = isMarkerCurrent({ + markerValue, + currentValue, + currentValueTo, + hasValueTo, + }); + + updateMarkerAttributes({ markerElement, isPassed, isCurrent }); + }); + }, + [sliderRef, hasValueTo, min, max], + ); + + /** + * Создает обработчик для события slide слайдера + */ + const createSlideHandler = useCallback( + (slider: API) => () => { + if (hasValueTo) { + const sliderValues = slider.get() as string[]; + const from = Number(sliderValues[0]); + const to = Number(sliderValues[1]); + + if (from <= to) { + updateMarkersState(from, to); + onChange?.({ value: from, valueTo: to }); + } else { + updateMarkersState(to, from); + onChange?.({ value: to, valueTo: from }); + } + } else { + const currentValue = Number(slider.get()); + + updateMarkersState(currentValue); + onChange?.({ value: currentValue }); + } + }, + [hasValueTo, updateMarkersState, onChange], + ); + + useEffect(() => { + if (hasValueTo) { + updateMarkersState(value, valueTo); + } else { + updateMarkersState(value); + } + }, [value, valueTo, hasValueTo, updateMarkersState]); + + return { + /** Функция для обновления состояния маркеров */ + updateMarkersState, + + /** Функция для создания обработчика события slide */ + createSlideHandler, + }; +}; diff --git a/packages/slider/src/index.module.css b/packages/slider/src/index.module.css index a992fac1fd..9fcdeeb590 100644 --- a/packages/slider/src/index.module.css +++ b/packages/slider/src/index.module.css @@ -6,18 +6,10 @@ padding-top: var(--slider-progress-s-height); position: relative; - & :global(.noUi-base:hover) { - & :global(.noUi-connects) { - background: var(--slider-progress-hover-background); + & :global(.noUi-handle .noUi-touch-area) { + @mixin hover { + background: var(--slider-thumb-hover-color); } - - & :global(.noUi-connect) { - background: var(--slider-progress-hover-color); - } - } - - & :global(.noUi-handle:hover .noUi-touch-area) { - background: var(--slider-thumb-hover-color); } & :global(.noUi-handle:active .noUi-touch-area) { @@ -31,6 +23,16 @@ transform: translateY(-50%); cursor: pointer; + + @mixin hover { + & :global(.noUi-connects) { + background: var(--slider-progress-hover-background); + } + + & :global(.noUi-connect) { + background: var(--slider-progress-hover-color); + } + } } & :global(.noUi-origin) { @@ -89,17 +91,71 @@ & :global(.noUi-pips) { @mixin paragraph_component_secondary; - margin-top: 6px; + margin: var(--gap-12) var(--slider-origin-right) 0; + width: calc(100% - (var(--slider-origin-right) * 2)); height: 18px; color: var(--color-light-text-secondary); position: relative; - width: 100%; } - & :global(.noUi-marker) { + & :global(.noUi-marker-large) { + position: absolute; + top: calc(-10px - var(--slider-marker-offset) / 2); + transform: translateY(-50%); + + &:first-child { + transform: translateY(-50%); + } + + &:nth-last-child(2) { + transform: translateX(-100%) translateY(-50%); + } + + &:global([data-current='true']) { + opacity: 0; + } + + &:global([data-passed='true']:not([data-current='true'])) { + background: white; + } + } + + & :global(.noUi-marker-sub) { + position: absolute; + top: calc(-10px - var(--slider-marker-offset) / 2); + transform: translateY(-50%); + + &:global([data-current='true']) { + opacity: 0; + } + + &:global([data-passed='true']:not([data-current='true'])) { + background: white; + } + } + + & :global(.noUi-marker-normal) { display: none; } + & :global(.noUi-marker) { + width: var(--slider-marker-size); + height: var(--slider-marker-size); + background: var(--slider-marker-color); + border-radius: var(--border-radius-circle); + transition: + opacity 0.15s ease, + background 0.15s ease; + + &:global([data-current='true']) { + opacity: 0; + } + + &:global([data-passed='true']:not([data-current='true'])) { + background: white; + } + } + & :global(.noUi-value) { position: absolute; white-space: nowrap; @@ -108,11 +164,39 @@ transform: translateX(-50%); &:nth-child(2) { - transform: none; + transform: translateX(-50%); } &:last-child { - transform: translateX(-100%); + transform: translateX(-50%); + } + } + + &.disabled { + & :global(.noUi-base) { + @mixin hover { + & :global(.noUi-connect) { + background: var(--slider-progress-disabled-hover-background); + } + } + } + + & :global(.noUi-handle .noUi-touch-area) { + @mixin hover { + background: var(--slider-progress-disabled-hover-background); + } + } + + & :global(.noUi-handle:active .noUi-touch-area) { + background: var(--slider-progress-disabled-active-background); + } + + & :global(.noUi-connect) { + background: var(--slider-progress-disabled-background); + } + + & :global(.noUi-touch-area) { + background: var(--slider-progress-disabled-background); } } } @@ -142,3 +226,23 @@ border-radius: var(--slider-progress-m-border-radius); } } + +.dotsDisabled { + /* Скрываем маркеры и подписи */ + & :global(.noUi-marker) { + display: none; + } +} + +.numbersDisabled { + /* Скрываем только числовые значения, оставляя точки */ + & :global(.noUi-value) { + display: none !important; + } +} +.hideLargePips { + /* Скрываем большие точки с числами (тип 1) для значений из pipsValues */ + & :global(.noUi-marker-large.hide-for-pips-value) { + display: none; + } +} diff --git a/packages/slider/src/index.ts b/packages/slider/src/index.ts index e51a5d2440..265e71dfad 100644 --- a/packages/slider/src/index.ts +++ b/packages/slider/src/index.ts @@ -1 +1,2 @@ export * from './Component'; +export * from './types'; diff --git a/packages/slider/src/types.ts b/packages/slider/src/types.ts new file mode 100644 index 0000000000..4967508a6c --- /dev/null +++ b/packages/slider/src/types.ts @@ -0,0 +1,172 @@ +type SubRange = number | [number] | [number, number]; + +/** + * Типы отображения точек в noUiSlider pips + * + * @description + * - -1: скрыть элемент + * - 0: большая метка без точки (только число) + * - 1: большая точка с числом + * - 2: маленькая точка без числа + */ +export type PipsType = -1 | 0 | 1 | 2; + +interface RangeOptions { + min: SubRange; + max: SubRange; + [key: string]: SubRange; +} + +export interface Pips { + mode: 'range' | 'steps' | 'positions' | 'count' | 'values'; + values: number | number[]; + filter?: (value: number, type: PipsType) => PipsType; + format?: { + to: (value: number) => string | number; + from?: (value: string) => number | false; + }; + stepped?: boolean; +} + +export interface SliderProps { + /** + * Мин. допустимое число + */ + min?: number; + + /** + * Макс. допустимое число + */ + max?: number; + + /** + * Шаг (должен нацело делить отрезок между мин и макс) + */ + step?: number; + + /** + * Отображение подписей + * https://refreshless.com/nouislider/pips/ + */ + pips?: Pips; + + /** + * Настройка шагов + * https://refreshless.com/nouislider/pips/#section-range + */ + range?: RangeOptions; + + /** + * Флаг точной привязки к range + * https://refreshless.com/nouislider/examples/#section-skipping + */ + snap?: boolean; + + /** + * Значение слайдера + */ + value?: number; + + /** + * Второе значение слайдера (значение второго ползунка) + * если передать ValueTo, то слайдер будет работать как range + */ + valueTo?: number; + + /** + * Заблокированное состояние + */ + disabled?: boolean; + + /** + * Дополнительный класс + */ + className?: string; + + /** + * Поведение ползунка + */ + behaviour?: 'unconstrained-tap' | 'tap'; + + /** + * Размер + * @description s, m deprecated, используйте вместо них 2, 4 соответственно + */ + size?: 's' | 'm' | 2 | 4; + + /** + * Включение/отключение отображения точек на слайдере + * @default false + */ + dots?: boolean; + + /** + * Тип отображения точек на слайдере: 'step' - по шагу, 'custom' - произвольные + * @default 'step' + */ + dotsSlider?: 'step' | 'custom'; + + /** + * Массив значений для произвольного размещения точек + */ + customDots?: number[]; + + /** + * Включение/отключение отображения чисел под точками + * Действует на все точки кроме customDots + * @default true + */ + showNumbers?: boolean; + + /** + * Скрытие чисел только для кастомных точек + * При hideCustomDotsNumbers=true числа скрываются только у customDots, остальные числа остаются видимыми + * @default false + */ + hideCustomDotsNumbers?: boolean; + + /** + * Обработчик поля ввода + */ + onChange?: (payload: { value: number; valueTo?: number }) => void; + + /** + * @deprecated + * Обработчик начала перетаскивания ползунка + */ + onStart?: () => void; + + /** + * Обработчик окончания перетаскивания ползунка + * @description https://refreshless.com/nouislider/events-callbacks/#section-change + */ + onEnd?: () => void; + + /** + * Идентификатор для систем автоматизированного тестирования + */ + dataTestId?: string; +} + +export type CreatePipsConfigParams = { + dotsSlider: 'step' | 'custom'; + pips?: Pips; + pipsValues: number[]; + customDots: number[]; + hideCustomDotsNumbers: boolean; + showNumbers: boolean; +} & Omit; + +interface BasePipsParams { + pipsValues: number[]; + customDots: number[]; + hideCustomDotsNumbers: boolean; +} + +export interface PipsFilter extends BasePipsParams { + mergeValues: number[]; +} + +export interface PipsFormat extends BasePipsParams { + showNumbers: boolean; +} diff --git a/packages/slider/src/utils/createPipsConfig/createPipsConfig.test.ts b/packages/slider/src/utils/createPipsConfig/createPipsConfig.test.ts new file mode 100644 index 0000000000..6bac6343b9 --- /dev/null +++ b/packages/slider/src/utils/createPipsConfig/createPipsConfig.test.ts @@ -0,0 +1,330 @@ +import { createPipsConfig } from './createPipsConfig'; + +describe('Unit/utils/function/createPipsConfig', () => { + describe('Success cases', () => { + it.each([ + { + description: 'Should create step pips config with default parameters', + input: { + min: 0, + max: 100, + step: 10, + dotsSlider: 'step', + showNumbers: true, + pips: { mode: 'values', values: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] }, + customDots: [], + }, + expected: { + mode: 'values', + values: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + }, + }, + { + description: 'Should create custom pips config with custom dots', + input: { + min: 0, + max: 50, + step: 5, + dotsSlider: 'custom', + customDots: [10, 25, 40], + showNumbers: true, + }, + expected: { + mode: 'values', + values: [10, 25, 40], + filter: expect.any(Function), + format: expect.any(Object), + }, + }, + { + description: 'Should merge pips and custom dots when both provided', + input: { + min: 0, + max: 100, + step: 10, + dotsSlider: 'custom', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: { mode: 'values', values: [0, 25, 50, 75, 100] }, + customDots: [10, 30, 60, 90], + }, + expected: { + mode: 'values', + values: [0, 10, 25, 30, 50, 60, 75, 90, 100], + filter: expect.any(Function), + format: expect.any(Object), + }, + }, + ])('$description', ({ input, expected }) => { + const result = createPipsConfig(input as any); + + expect(result).toMatchObject(expected); + }); + }); + + describe('Edge cases', () => { + it.each([ + { + description: 'Should return undefined when pips is not provided for step mode', + input: { + min: 0, + max: 0, + step: 1, + dotsSlider: 'step', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: undefined, + customDots: undefined, + }, + expected: undefined, + }, + { + description: 'Should handle negative range with custom dots', + input: { + min: -10, + max: 10, + step: 5, + dotsSlider: 'custom', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: undefined, + customDots: [-5, 0, 5], + }, + expected: { + mode: 'values', + values: [-5, 0, 5], + filter: expect.any(Function), + format: expect.any(Object), + }, + }, + { + description: + 'Should return undefined for decimal step values when pips not provided', + input: { + min: 0, + max: 1, + step: 0.25, + dotsSlider: 'step', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: undefined, + customDots: undefined, + }, + expected: undefined, + }, + { + description: 'Should handle empty custom dots array', + input: { + min: 0, + max: 100, + step: 10, + dotsSlider: 'custom', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: undefined, + customDots: [], + }, + expected: { + mode: 'values', + values: [], + filter: expect.any(Function), + format: expect.any(Object), + }, + }, + ])('$description', ({ input, expected }) => { + const result = createPipsConfig(input as any); + + if (expected === undefined) { + expect(result).toBeUndefined(); + } else { + expect(result).toMatchObject(expected); + } + }); + }); + + describe('Pips integration cases', () => { + it.each([ + { + description: 'Should merge pips values with custom dots', + input: { + min: 0, + max: 100, + step: 10, + dotsSlider: 'custom', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: { mode: 'values', values: [0, 25, 50, 75, 100] }, + customDots: [10, 30, 60, 90], + }, + expected: { + mode: 'values', + values: [0, 10, 25, 30, 50, 60, 75, 90, 100], + filter: expect.any(Function), + format: expect.any(Object), + }, + }, + { + description: + 'Should merge pips values with custom dots and hideCustomDotsNumbers=true', + input: { + min: 0, + max: 100, + step: 10, + dotsSlider: 'custom', + showNumbers: true, + hideCustomDotsNumbers: true, + pips: { mode: 'values', values: [0, 25, 50, 75, 100] }, + customDots: [10, 30, 60, 90], + }, + expected: { + mode: 'values', + values: [0, 10, 25, 30, 50, 60, 75, 90, 100], + filter: expect.any(Function), + format: expect.any(Object), + }, + }, + { + description: 'Should merge pips values with custom dots and showNumbers=false', + input: { + min: 0, + max: 100, + step: 10, + dotsSlider: 'custom', + showNumbers: false, + hideCustomDotsNumbers: false, + pips: { mode: 'values', values: [0, 25, 50, 75, 100] }, + customDots: [10, 30, 60, 90], + }, + expected: { + mode: 'values', + values: [0, 10, 25, 30, 50, 60, 75, 90, 100], + filter: expect.any(Function), + format: expect.any(Object), + }, + }, + ])('$description', ({ input, expected }) => { + const result = createPipsConfig(input as any); + + expect(result).toMatchObject(expected); + }); + }); + + describe('Return pips directly cases', () => { + it.each([ + { + description: 'Should return pips directly when no custom dots', + input: { + min: 0, + max: 100, + step: 10, + dotsSlider: 'step', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: { mode: 'values', values: [0, 25, 50, 75, 100] }, + customDots: undefined, + }, + expected: { mode: 'values', values: [0, 25, 50, 75, 100] }, + }, + { + description: 'Should return pips directly when custom dots is empty array', + input: { + min: 0, + max: 100, + step: 10, + dotsSlider: 'step', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: { mode: 'values', values: [0, 25, 50, 75, 100] }, + customDots: [], + }, + expected: { mode: 'values', values: [0, 25, 50, 75, 100] }, + }, + ])('$description', ({ input, expected }) => { + const result = createPipsConfig(input as any); + + expect(result).toEqual(expected); + }); + }); + + describe('Error cases', () => { + it.each([ + { + description: 'Should return undefined for NaN min value when pips not provided', + input: { + min: NaN, + max: 100, + step: 10, + dotsSlider: 'step', + showNumbers: true, + hideCustomDotsNumbers: false, + pips: undefined, + customDots: undefined, + }, + expected: undefined, + }, + ])('$description', ({ input, expected }) => { + const result = createPipsConfig(input as any); + + expect(result).toBeUndefined(); + }); + }); + + describe('Function behavior', () => { + it('Should return undefined when pips not provided for step mode', () => { + const result = createPipsConfig({ + dotsSlider: 'step', + showNumbers: true, + pipsValues: [], + customDots: [], + hideCustomDotsNumbers: false, + }); + + expect(result).toBeUndefined(); + }); + + it('Should handle different dotsSlider values', () => { + const stepResult = createPipsConfig({ + dotsSlider: 'step', + showNumbers: true, + pipsValues: [], + customDots: [], + hideCustomDotsNumbers: false, + pips: { mode: 'values', values: [0, 2, 4, 6, 8, 10] }, + }); + + const customResult = createPipsConfig({ + dotsSlider: 'custom', + showNumbers: true, + pipsValues: [], + customDots: [2, 6, 8], + hideCustomDotsNumbers: false, + }); + + expect(stepResult).toMatchObject({ + mode: 'values', + values: [0, 2, 4, 6, 8, 10], + }); + + expect(customResult).toMatchObject({ + mode: 'values', + values: [2, 6, 8], + }); + }); + + it('Should extract pips values correctly', () => { + const result = createPipsConfig({ + dotsSlider: 'custom', + showNumbers: true, + pipsValues: [], + hideCustomDotsNumbers: false, + customDots: [25, 50, 75], + pips: { mode: 'values', values: [0, 25, 50, 75, 100] }, + }); + + expect(result).toMatchObject({ + mode: 'values', + values: expect.arrayContaining([0, 25, 50, 75, 100]), + }); + }); + }); +}); diff --git a/packages/slider/src/utils/createPipsConfig/createPipsConfig.ts b/packages/slider/src/utils/createPipsConfig/createPipsConfig.ts new file mode 100644 index 0000000000..a281767cc0 --- /dev/null +++ b/packages/slider/src/utils/createPipsConfig/createPipsConfig.ts @@ -0,0 +1,72 @@ +import { type Options } from 'nouislider'; + +import { type CreatePipsConfigParams } from '../../types'; +import { createPipsFilter } from '../createPipsFilter'; +import { createPipsFormat } from '../createPipsFormat'; + +type PipsConfig = (params: Omit) => Options['pips']; + +/** + * Создает конфигурацию pips dotsSlider: 'step | custom' для noUiSlider + * + * @returns {Options['pips']} объект с полями: + * - mode: 'values' - режим отображения точек по значениям + * - values: number[] - объединенный и отсортированный массив значений из pips.values и customDots + * - filter: Function - функция фильтрации точек (из pips.filter или созданная через createPipsFilter) + * - format: Function - функция форматирования (из pips.format или созданный через createPipsFormat) + * - ...restPipsProps - остальные свойства из pips (например, stepped) + */ +export const config: Record<'step' | 'custom', PipsConfig> = { + step: ({ pips, showNumbers }) => { + if (!pips) return undefined; + + if (showNumbers) { + return pips as Options['pips']; + } + + const { format: providedFormat, ...rest } = pips; + + if (providedFormat) { + return pips as Options['pips']; + } + + return { + ...rest, + format: { to: () => '' }, + } as Options['pips']; + }, + + custom: (params: Omit) => { + const { pips, customDots, hideCustomDotsNumbers, showNumbers } = params; + + const { values, filter, format, ...restPipsProps } = pips || {}; + const pipsValues = Array.isArray(values) ? values : []; + const pipsProps = { pipsValues, customDots, hideCustomDotsNumbers }; + const mergeValues = Array.from(new Set([...pipsValues, ...customDots])).sort( + (a, b) => a - b, + ); + + return { + ...restPipsProps, + mode: 'values', + values: mergeValues, + filter: + filter || + createPipsFilter({ + mergeValues, + ...pipsProps, + }), + format: + format || + createPipsFormat({ + showNumbers, + ...pipsProps, + }), + } as Options['pips']; + }, +}; + +export const createPipsConfig = ({ + dotsSlider, + ...restProps +}: CreatePipsConfigParams): Options['pips'] => config[dotsSlider]?.({ ...restProps }); diff --git a/packages/slider/src/utils/createPipsConfig/index.ts b/packages/slider/src/utils/createPipsConfig/index.ts new file mode 100644 index 0000000000..d9698baeda --- /dev/null +++ b/packages/slider/src/utils/createPipsConfig/index.ts @@ -0,0 +1 @@ +export { createPipsConfig } from './createPipsConfig'; diff --git a/packages/slider/src/utils/createPipsFilter/createPipsFilter.test.ts b/packages/slider/src/utils/createPipsFilter/createPipsFilter.test.ts new file mode 100644 index 0000000000..c6ab51f1a6 --- /dev/null +++ b/packages/slider/src/utils/createPipsFilter/createPipsFilter.test.ts @@ -0,0 +1,193 @@ +import { createPipsFilter } from './createPipsFilter'; + +describe('Unit/utils/function/createPipsFilter', () => { + describe('Success cases', () => { + it.each([ + { + description: 'Should return 1 for custom dot value', + input: { customDots: [25, 50, 75], value: 25, type: 0 as const }, + expected: 1, + }, + { + description: 'Should return 1 for another custom dot value', + input: { customDots: [25, 50, 75], value: 50, type: 0 as const }, + expected: 1, + }, + { + description: 'Should return 0 for integer value when custom dots exist', + input: { customDots: [25, 50, 75], value: 10, type: 0 as const }, + expected: 0, + }, + { + description: 'Should return -1 for decimal value when custom dots exist', + input: { customDots: [25, 50, 75], value: 10.5, type: 0 as const }, + expected: -1, + }, + { + description: 'Should return original type when custom dots is empty array', + input: { customDots: [], value: 10, type: 1 as const }, + expected: 1, + }, + { + description: 'Should return original type when no custom dots', + input: { customDots: [], value: 10, type: 2 as const }, + expected: 2, + }, + ])('$description', ({ input, expected }) => { + const filter = createPipsFilter({ + customDots: input.customDots, + hideCustomDotsNumbers: false, + pipsValues: [], + mergeValues: [], + }); + const result = filter(input.value, input.type); + + expect(result).toBe(expected); + }); + }); + + describe('Edge cases', () => { + it.each([ + { + description: 'Should handle zero as custom dot', + input: { customDots: [0], value: 0, type: 0 as const }, + expected: 1, + }, + { + description: 'Should handle negative custom dots', + input: { customDots: [-5, 0, 5], value: -5, type: 0 as const }, + expected: 1, + }, + { + description: 'Should handle decimal custom dots', + input: { customDots: [1.5, 2.5], value: 1.5, type: 0 as const }, + expected: 1, + }, + { + description: 'Should handle integer-like decimal values', + input: { customDots: [25, 50, 75], value: 25.0, type: 0 as const }, + expected: 1, + }, + { + description: 'Should return -1 for decimal values even if close to integer', + input: { customDots: [25, 50, 75], value: 25.1, type: 0 as const }, + expected: -1, + }, + { + description: 'Should return 0 for zero when it is not a custom dot', + input: { customDots: [25, 50, 75], value: 0, type: 0 as const }, + expected: 0, + }, + ])('$description', ({ input, expected }) => { + const filter = createPipsFilter({ + customDots: input.customDots, + hideCustomDotsNumbers: false, + pipsValues: [], + mergeValues: [], + }); + const result = filter(input.value, input.type); + expect(result).toBe(expected); + }); + }); + + describe('Error cases', () => { + it.each([ + { + description: 'Should handle NaN values gracefully', + input: { customDots: [25, 50, 75], value: NaN, type: 0 as const }, + expected: -1, + }, + { + description: 'Should handle Infinity values gracefully', + input: { customDots: [25, 50, 75], value: Infinity, type: 0 as const }, + expected: -1, + }, + { + description: 'Should handle negative Infinity values gracefully', + input: { customDots: [25, 50, 75], value: -Infinity, type: 0 as const }, + expected: -1, + }, + { + description: 'Should handle very large integer values', + input: { + customDots: [25, 50, 75], + value: Number.MAX_SAFE_INTEGER, + type: 0 as const, + }, + expected: 0, + }, + { + description: 'Should handle very small integer values', + input: { + customDots: [25, 50, 75], + value: Number.MIN_SAFE_INTEGER, + type: 0 as const, + }, + expected: 0, + }, + { + description: 'Should override original type -1 for custom dots', + input: { customDots: [25, 50, 75], value: 25, type: -1 as const }, + expected: 1, + }, + { + description: 'Should override original type 2 for custom dots', + input: { customDots: [25, 50, 75], value: 25, type: 2 as const }, + expected: 1, + }, + ])('$description', ({ input, expected }) => { + const filter = createPipsFilter({ + customDots: input.customDots, + hideCustomDotsNumbers: false, + pipsValues: [], + mergeValues: [], + }); + const result = filter(input.value, input.type); + expect(result).toBe(expected); + }); + }); + + describe('Function behavior', () => { + it('Should create a reusable filter function', () => { + const filter = createPipsFilter({ + customDots: [25, 50, 75], + hideCustomDotsNumbers: false, + mergeValues: [], + pipsValues: [], + }); + + expect(filter(25, 0)).toBe(1); + expect(filter(50, 0)).toBe(1); + expect(filter(10, 0)).toBe(0); + expect(filter(10.5, 0)).toBe(-1); + }); + + it('Should handle different original types correctly', () => { + const filter = createPipsFilter({ + customDots: [25, 50, 75], + hideCustomDotsNumbers: false, + pipsValues: [], + mergeValues: [], + }); + + expect(filter(25, -1)).toBe(1); // custom dot overrides -1 + expect(filter(25, 0)).toBe(1); // custom dot overrides 0 + expect(filter(25, 1)).toBe(1); // custom dot overrides 1 + expect(filter(25, 2)).toBe(1); // custom dot overrides 2 + }); + + it('Should preserve original type when no custom dots', () => { + const filter = createPipsFilter({ + customDots: [], + hideCustomDotsNumbers: false, + pipsValues: [], + mergeValues: [], + }); + + expect(filter(10, -1)).toBe(-1); + expect(filter(10, 0)).toBe(0); + expect(filter(10, 1)).toBe(1); + expect(filter(10, 2)).toBe(2); + }); + }); +}); diff --git a/packages/slider/src/utils/createPipsFilter/createPipsFilter.ts b/packages/slider/src/utils/createPipsFilter/createPipsFilter.ts new file mode 100644 index 0000000000..980d2c45d8 --- /dev/null +++ b/packages/slider/src/utils/createPipsFilter/createPipsFilter.ts @@ -0,0 +1,29 @@ +import { type PipsFilter, type PipsType } from '../../types'; + +/** Создает функцию фильтрации для pips noUiSlider */ +export const createPipsFilter = + ({ hideCustomDotsNumbers, pipsValues, customDots, mergeValues }: PipsFilter) => + (value: number, type: PipsType): PipsType => { + const isInPips = !!pipsValues?.includes(value); + const isInCustom = !!customDots?.includes(value); + const hasMerge = !!mergeValues?.length; + const isWhole = Number.isInteger(value) && Number.isFinite(value); + + if (hideCustomDotsNumbers) { + if (isInPips || isInCustom) { + return 1; + } + + return isWhole ? 0 : -1; + } + + if (isInCustom || (hasMerge && mergeValues.includes(value))) { + return 1; + } + + if (hasMerge || !!customDots?.length) { + return isWhole ? 0 : -1; + } + + return type; + }; diff --git a/packages/slider/src/utils/createPipsFilter/index.ts b/packages/slider/src/utils/createPipsFilter/index.ts new file mode 100644 index 0000000000..52a44f1bf7 --- /dev/null +++ b/packages/slider/src/utils/createPipsFilter/index.ts @@ -0,0 +1 @@ +export { createPipsFilter } from './createPipsFilter'; diff --git a/packages/slider/src/utils/createPipsFormat/createPipsFormat.test.ts b/packages/slider/src/utils/createPipsFormat/createPipsFormat.test.ts new file mode 100644 index 0000000000..03cc87a26f --- /dev/null +++ b/packages/slider/src/utils/createPipsFormat/createPipsFormat.test.ts @@ -0,0 +1,407 @@ +import { createPipsFormat } from './createPipsFormat'; + +describe('Unit/utils/function/createPipsFormat', () => { + describe('Success cases', () => { + it.each([ + { + description: + 'Should return value for custom dot when showNumbers=true and hideCustomDotsNumbers=false', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 25, + }, + expected: 25, + }, + { + description: + 'Should return empty string for custom dot when hideCustomDotsNumbers=true', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: true, + pipsValues: [], + value: 25, + }, + expected: '', + }, + { + description: 'Should return value for integer when custom dots exist', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 10, + }, + expected: 10, + }, + { + description: 'Should return empty string for decimal when custom dots exist', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 10.5, + }, + expected: '', + }, + { + description: 'Should return value for any number when no custom dots', + input: { + customDots: [], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 10, + }, + expected: 10, + }, + { + description: 'Should return empty string for decimal when no custom dots', + input: { + customDots: [], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 10.5, + }, + expected: '', + }, + ])('$description', ({ input, expected }) => { + const { customDots, showNumbers, hideCustomDotsNumbers, pipsValues, value } = input; + + const format = createPipsFormat({ + customDots: customDots || [], + showNumbers, + hideCustomDotsNumbers, + pipsValues: pipsValues || [], + }); + const result = format.to(value); + expect(result).toBe(expected); + }); + }); + + describe('Edge cases', () => { + it.each([ + { + description: 'Should handle zero as custom dot', + input: { + customDots: [0], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 0, + }, + expected: 0, + }, + { + description: 'Should handle negative custom dots', + input: { + customDots: [-5, 0, 5], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: -5, + }, + expected: -5, + }, + { + description: 'Should handle decimal custom dots', + input: { + customDots: [1.5, 2.5], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 1.5, + }, + expected: 1.5, + }, + { + description: 'Should return empty string when showNumbers=false', + input: { + customDots: [25, 50, 75], + showNumbers: false, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 25, + }, + expected: '', + }, + { + description: 'Should return empty string for any value when showNumbers=false', + input: { + customDots: [25, 50, 75], + showNumbers: false, + hideCustomDotsNumbers: true, + pipsValues: [], + value: 10, + }, + expected: '', + }, + { + description: 'Should handle integer-like decimal values for custom dots', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: 25.0, + }, + expected: 25, + }, + ])('$description', ({ input, expected }) => { + const { customDots, showNumbers, hideCustomDotsNumbers, pipsValues, value } = input; + + const format = createPipsFormat({ + customDots: customDots || [], + showNumbers, + hideCustomDotsNumbers, + pipsValues: pipsValues || [], + }); + + const result = format.to(value); + expect(result).toBe(expected); + }); + }); + + describe('Pips integration cases', () => { + it.each([ + { + description: 'Should return value for pips value when pips integration exists', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [10, 20, 30], + value: 10, + }, + expected: 10, + }, + { + description: 'Should return value for custom dot when pips integration exists', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [10, 20, 30], + value: 25, + }, + expected: 25, + }, + { + description: + 'Should return empty string for custom dot when hideCustomDotsNumbers=true and pips integration exists', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: true, + pipsValues: [10, 20, 30], + value: 25, + }, + expected: '', + }, + { + description: 'Should return value for integer when pips integration exists', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [10, 20, 30], + value: 15, + }, + expected: 15, + }, + { + description: 'Should return empty string for decimal when pips integration exists', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [10, 20, 30], + value: 15.5, + }, + expected: '', + }, + { + description: 'Should return empty string for pips value when showNumbers=false', + input: { + customDots: [25, 50, 75], + showNumbers: false, + hideCustomDotsNumbers: false, + pipsValues: [10, 20, 30], + value: 10, + }, + expected: '', + }, + ])('$description', ({ input, expected }) => { + const { customDots, showNumbers, hideCustomDotsNumbers, pipsValues, value } = input; + + const format = createPipsFormat({ + customDots: customDots || [], + showNumbers, + hideCustomDotsNumbers, + pipsValues: pipsValues || [], + }); + + const result = format.to(value); + expect(result).toBe(expected); + }); + }); + + describe('Error cases', () => { + it.each([ + { + description: 'Should handle NaN values gracefully', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: NaN, + }, + expected: '', + }, + { + description: 'Should handle Infinity values gracefully', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: Infinity, + }, + expected: '', + }, + { + description: 'Should handle negative Infinity values gracefully', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: -Infinity, + }, + expected: '', + }, + { + description: 'Should handle very large integer values', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: Number.MAX_SAFE_INTEGER, + }, + expected: Number.MAX_SAFE_INTEGER, + }, + { + description: 'Should handle very small integer values', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + value: Number.MIN_SAFE_INTEGER, + }, + expected: Number.MIN_SAFE_INTEGER, + }, + ])('$description', ({ input, expected }) => { + const { customDots, showNumbers, hideCustomDotsNumbers, pipsValues, value } = input; + + const format = createPipsFormat({ + customDots: customDots || [], + showNumbers, + hideCustomDotsNumbers, + pipsValues: pipsValues || [], + }); + + const result = format.to(value); + expect(result).toBe(expected); + }); + }); + + describe('Function behavior', () => { + it.each([ + { + description: 'Should create a reusable format function', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + }, + testCases: [ + { value: 25, expected: 25 }, + { value: 50, expected: 50 }, + { value: 10, expected: 10 }, + { value: 10.5, expected: '' }, + ], + }, + { + description: 'Should handle different showNumbers settings - with numbers', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + }, + testCases: [{ value: 25, expected: 25 }], + }, + { + description: 'Should handle different showNumbers settings - without numbers', + input: { + customDots: [25, 50, 75], + showNumbers: false, + hideCustomDotsNumbers: false, + pipsValues: [], + }, + testCases: [{ value: 25, expected: '' }], + }, + { + description: 'Should handle different hideCustomDotsNumbers settings - show custom', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [], + }, + testCases: [{ value: 25, expected: 25 }], + }, + { + description: 'Should handle different hideCustomDotsNumbers settings - hide custom', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: true, + pipsValues: [], + }, + testCases: [{ value: 25, expected: '' }], + }, + { + description: 'Should handle pips integration correctly', + input: { + customDots: [25, 50, 75], + showNumbers: true, + hideCustomDotsNumbers: false, + pipsValues: [10, 20, 30], + }, + testCases: [ + { value: 10, expected: 10 }, // pips value + { value: 25, expected: 25 }, // custom dot + { value: 15, expected: 15 }, // integer + { value: 15.5, expected: '' }, // decimal + ], + }, + ])('$description', ({ input, testCases }) => { + const format = createPipsFormat(input); + + testCases.forEach(({ value, expected }) => { + expect(format.to(value)).toBe(expected); + }); + }); + }); +}); diff --git a/packages/slider/src/utils/createPipsFormat/createPipsFormat.ts b/packages/slider/src/utils/createPipsFormat/createPipsFormat.ts new file mode 100644 index 0000000000..29d8baf3e4 --- /dev/null +++ b/packages/slider/src/utils/createPipsFormat/createPipsFormat.ts @@ -0,0 +1,31 @@ +import { type PipsFormat } from '../../types'; + +/** + * Создает format функцию для pips + * @description + * - Для кастомных точек всегда показывает значение (если showNumbers=true и hideCustomDotsNumbers={false) + * - Для обычных значений показывает только целые числа (если showNumbers=true) + * - Для дробных чисел возвращает пустую строку + * - Если showNumbers=false, возвращает пустую строку для всех значений + * - Если hideCustomDotsNumbers=true, скрывает числа только для кастомных точек + */ +export const createPipsFormat = ({ + customDots, + showNumbers, + hideCustomDotsNumbers, + pipsValues, +}: PipsFormat) => ({ + to: (value: number): string | number => { + if (!showNumbers) return ''; + + const isCustom = customDots?.includes(value) ?? false; + const isPips = pipsValues?.includes(value) ?? false; + const isWhole = Number.isInteger(value); + + if (hideCustomDotsNumbers) { + return isPips || (isWhole && !isCustom) ? value : ''; + } + + return isPips || isCustom || isWhole ? value : ''; + }, +}); diff --git a/packages/slider/src/utils/createPipsFormat/index.ts b/packages/slider/src/utils/createPipsFormat/index.ts new file mode 100644 index 0000000000..11241dc3ab --- /dev/null +++ b/packages/slider/src/utils/createPipsFormat/index.ts @@ -0,0 +1 @@ +export { createPipsFormat } from './createPipsFormat'; diff --git a/packages/slider/src/utils/index.ts b/packages/slider/src/utils/index.ts new file mode 100644 index 0000000000..1338024603 --- /dev/null +++ b/packages/slider/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './createPipsConfig'; +export * from './markerUtils'; diff --git a/packages/slider/src/utils/markerUtils/index.ts b/packages/slider/src/utils/markerUtils/index.ts new file mode 100644 index 0000000000..3230a3e046 --- /dev/null +++ b/packages/slider/src/utils/markerUtils/index.ts @@ -0,0 +1 @@ +export * from './markerUtils'; diff --git a/packages/slider/src/utils/markerUtils/markerUtils.test.ts b/packages/slider/src/utils/markerUtils/markerUtils.test.ts new file mode 100644 index 0000000000..020e9fdb14 --- /dev/null +++ b/packages/slider/src/utils/markerUtils/markerUtils.test.ts @@ -0,0 +1,481 @@ +import { + getMarkerValue, + updateMarkerAttributes, + isMarkerPassed, + isMarkerCurrent, +} from './markerUtils'; + +describe('Unit/utility/function/getMarkerValue', () => { + let mockMarkerElement: HTMLElement; + let mockNextElement: HTMLElement; + + beforeEach(() => { + mockNextElement = { + classList: { + contains: jest.fn(), + }, + getAttribute: jest.fn(), + } as any; + + mockMarkerElement = { + nextElementSibling: mockNextElement, + style: { + left: '50%', + }, + } as any; + }); + + describe('SUCCESS', () => { + it('Должна возвращать значение из data-value атрибута', () => { + mockNextElement.classList.contains = jest.fn().mockReturnValue(true); + mockNextElement.getAttribute = jest.fn().mockReturnValue('5'); + + const result = getMarkerValue({ + markerElement: mockMarkerElement, + min: 0, + max: 10, + }); + + expect(result).toBe(5); + }); + + it('Должна вычислять значение по позиции при отсутствии data-value', () => { + mockNextElement.classList.contains = jest.fn().mockReturnValue(false); + mockMarkerElement.style.left = '50%'; + + const result = getMarkerValue({ + markerElement: mockMarkerElement, + min: 0, + max: 10, + }); + + expect(result).toBe(5); + }); + + it('Должна правильно вычислять значение для диапазона 0-100', () => { + mockNextElement.classList.contains = jest.fn().mockReturnValue(false); + mockMarkerElement.style.left = '25%'; + + const result = getMarkerValue({ + markerElement: mockMarkerElement, + min: 0, + max: 100, + }); + + expect(result).toBe(25); + }); + + it('Должна правильно вычислять значение для кастомного диапазона', () => { + mockNextElement.classList.contains = jest.fn().mockReturnValue(false); + mockMarkerElement.style.left = '50%'; + + const result = getMarkerValue({ + markerElement: mockMarkerElement, + min: 10, + max: 20, + }); + + expect(result).toBe(15); + }); + }); + + describe('EDGE', () => { + it('Должна возвращать 0 при data-value=""', () => { + mockNextElement.classList.contains = jest.fn().mockReturnValue(true); + mockNextElement.getAttribute = jest.fn().mockReturnValue(''); + + const result = getMarkerValue({ + markerElement: mockMarkerElement, + min: 0, + max: 10, + }); + + expect(result).toBe(0); + }); + + it('Должна округлять результат вычислений', () => { + mockNextElement.classList.contains = jest.fn().mockReturnValue(false); + mockMarkerElement.style.left = '33.33%'; + + const result = getMarkerValue({ + markerElement: mockMarkerElement, + min: 0, + max: 10, + }); + + expect(result).toBe(3); + }); + + it('Должна возвращать null при отсутствии nextElementSibling', () => { + const elementWithoutSibling = { + nextElementSibling: null, + style: { + left: '50%', + }, + } as any; + + const result = getMarkerValue({ + markerElement: elementWithoutSibling, + min: 0, + max: 10, + }); + + expect(result).toBe(null); + }); + }); + + describe('ERROR', () => { + it('Должна возвращать null при некорректном style.left', () => { + mockNextElement.classList.contains = jest.fn().mockReturnValue(false); + mockMarkerElement.style.left = 'invalid'; + + const result = getMarkerValue({ + markerElement: mockMarkerElement, + min: 0, + max: 10, + }); + + expect(result).toBe(null); + }); + + it('Должна возвращать null при отсутствии style.left', () => { + mockNextElement.classList.contains = jest.fn().mockReturnValue(false); + mockMarkerElement.style.left = ''; + + const result = getMarkerValue({ + markerElement: mockMarkerElement, + min: 0, + max: 10, + }); + + expect(result).toBe(null); + }); + }); +}); + +describe('Unit/utility/function/updateMarkerAttributes', () => { + let mockMarkerElement: HTMLElement; + + beforeEach(() => { + mockMarkerElement = { + setAttribute: jest.fn(), + removeAttribute: jest.fn(), + } as any; + }); + + describe('SUCCESS', () => { + it('Должна устанавливать data-passed при isPassed=true', () => { + updateMarkerAttributes({ + markerElement: mockMarkerElement, + isPassed: true, + isCurrent: false, + }); + + expect(mockMarkerElement.setAttribute).toHaveBeenCalledWith('data-passed', 'true'); + }); + + it('Должна устанавливать data-current при isCurrent=true', () => { + updateMarkerAttributes({ + markerElement: mockMarkerElement, + isPassed: false, + isCurrent: true, + }); + + expect(mockMarkerElement.setAttribute).toHaveBeenCalledWith('data-current', 'true'); + }); + + it('Должна удалять data-passed при isPassed=false', () => { + updateMarkerAttributes({ + markerElement: mockMarkerElement, + isPassed: false, + isCurrent: false, + }); + + expect(mockMarkerElement.removeAttribute).toHaveBeenCalledWith('data-passed'); + }); + + it('Должна удалять data-current при isCurrent=false', () => { + updateMarkerAttributes({ + markerElement: mockMarkerElement, + isPassed: false, + isCurrent: false, + }); + + expect(mockMarkerElement.removeAttribute).toHaveBeenCalledWith('data-current'); + }); + }); + + describe('EDGE', () => { + it('Должна корректно обрабатывать одновременно isPassed=true и isCurrent=true', () => { + updateMarkerAttributes({ + markerElement: mockMarkerElement, + isPassed: true, + isCurrent: true, + }); + + expect(mockMarkerElement.setAttribute).toHaveBeenCalledWith('data-passed', 'true'); + expect(mockMarkerElement.setAttribute).toHaveBeenCalledWith('data-current', 'true'); + }); + + it('Должна корректно обрабатывать одновременно isPassed=false и isCurrent=false', () => { + updateMarkerAttributes({ + markerElement: mockMarkerElement, + isPassed: false, + isCurrent: false, + }); + + expect(mockMarkerElement.removeAttribute).toHaveBeenCalledWith('data-passed'); + expect(mockMarkerElement.removeAttribute).toHaveBeenCalledWith('data-current'); + }); + }); + + describe('ERROR', () => { + it('Должна корректно обрабатывать ошибки DOM методов', () => { + mockMarkerElement.setAttribute = jest.fn().mockImplementation(() => { + throw new Error('DOM error'); + }); + + expect(() => { + updateMarkerAttributes({ + markerElement: mockMarkerElement, + isPassed: true, + isCurrent: false, + }); + }).toThrow('DOM error'); + }); + }); +}); + +describe('Unit/utility/function/isMarkerPassed', () => { + describe('SUCCESS', () => { + it('Должна возвращать true когда маркер пройден для одиночного значения', () => { + const result = isMarkerPassed({ + markerValue: 5, + currentValue: 7, + hasValueTo: false, + }); + + expect(result).toBe(true); + }); + + it('Должна возвращать false когда маркер не пройден для одиночного значения', () => { + const result = isMarkerPassed({ + markerValue: 7, + currentValue: 5, + hasValueTo: false, + }); + + expect(result).toBe(false); + }); + + it('Должна возвращать true когда маркер в диапазоне', () => { + const result = isMarkerPassed({ + markerValue: 5, + currentValue: 3, + currentValueTo: 7, + hasValueTo: true, + }); + + expect(result).toBe(true); + }); + + it('Должна возвращать false когда маркер вне диапазона', () => { + const result = isMarkerPassed({ + markerValue: 10, + currentValue: 3, + currentValueTo: 7, + hasValueTo: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('EDGE', () => { + it('Должна возвращать false для равных значений одиночного слайдера', () => { + const result = isMarkerPassed({ + markerValue: 5, + currentValue: 5, + hasValueTo: false, + }); + + expect(result).toBe(false); + }); + + it('Должна корректно обрабатывать перевернутый диапазон', () => { + const result = isMarkerPassed({ + markerValue: 5, + currentValue: 7, + currentValueTo: 3, + hasValueTo: true, + }); + + expect(result).toBe(true); + }); + + it('Должна возвращать true для маркера на границе диапазона', () => { + const result = isMarkerPassed({ + markerValue: 3, + currentValue: 3, + currentValueTo: 7, + hasValueTo: true, + }); + + expect(result).toBe(true); + }); + + it('Должна обрабатывать hasValueTo=true без currentValueTo', () => { + const result = isMarkerPassed({ + markerValue: 5, + currentValue: 7, + hasValueTo: true, + }); + + expect(result).toBe(true); + }); + }); + + describe('ERROR', () => { + it('Должна обрабатывать отрицательные значения', () => { + const result = isMarkerPassed({ + markerValue: -5, + currentValue: -3, + hasValueTo: false, + }); + + expect(result).toBe(true); + }); + + it('Должна обрабатывать нулевые значения', () => { + const result = isMarkerPassed({ + markerValue: 0, + currentValue: 0, + hasValueTo: false, + }); + + expect(result).toBe(false); + }); + }); +}); + +describe('Unit/utility/function/isMarkerCurrent', () => { + describe('SUCCESS', () => { + it('Должна возвращать true когда маркер равен текущему значению', () => { + const result = isMarkerCurrent({ + markerValue: 5, + currentValue: 5, + hasValueTo: false, + }); + + expect(result).toBe(true); + }); + + it('Должна возвращать false когда маркер не равен текущему значению', () => { + const result = isMarkerCurrent({ + markerValue: 5, + currentValue: 7, + hasValueTo: false, + }); + + expect(result).toBe(false); + }); + + it('Должна возвращать true когда маркер равен первому значению диапазона', () => { + const result = isMarkerCurrent({ + markerValue: 5, + currentValue: 5, + currentValueTo: 7, + hasValueTo: true, + }); + + expect(result).toBe(true); + }); + + it('Должна возвращать true когда маркер равен второму значению диапазона', () => { + const result = isMarkerCurrent({ + markerValue: 7, + currentValue: 5, + currentValueTo: 7, + hasValueTo: true, + }); + + expect(result).toBe(true); + }); + + it('Должна возвращать false когда маркер не равен ни одному значению диапазона', () => { + const result = isMarkerCurrent({ + markerValue: 6, + currentValue: 5, + currentValueTo: 7, + hasValueTo: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('EDGE', () => { + it('Должна обрабатывать одинаковые значения диапазона', () => { + const result = isMarkerCurrent({ + markerValue: 5, + currentValue: 5, + currentValueTo: 5, + hasValueTo: true, + }); + + expect(result).toBe(true); + }); + + it('Должна обрабатывать hasValueTo=true без currentValueTo', () => { + const result = isMarkerCurrent({ + markerValue: 5, + currentValue: 5, + hasValueTo: true, + }); + + expect(result).toBe(true); + }); + + it('Должна обрабатывать нулевые значения', () => { + const result = isMarkerCurrent({ + markerValue: 0, + currentValue: 0, + hasValueTo: false, + }); + + expect(result).toBe(true); + }); + }); + + describe('ERROR', () => { + it('Должна обрабатывать отрицательные значения', () => { + const result = isMarkerCurrent({ + markerValue: -5, + currentValue: -5, + hasValueTo: false, + }); + + expect(result).toBe(true); + }); + + it('Должна обрабатывать дробные значения', () => { + const result = isMarkerCurrent({ + markerValue: 5.5, + currentValue: 5.5, + hasValueTo: false, + }); + + expect(result).toBe(true); + }); + + it('Должна обрабатывать очень большие числа', () => { + const result = isMarkerCurrent({ + markerValue: Number.MAX_SAFE_INTEGER, + currentValue: Number.MAX_SAFE_INTEGER, + hasValueTo: false, + }); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/slider/src/utils/markerUtils/markerUtils.ts b/packages/slider/src/utils/markerUtils/markerUtils.ts new file mode 100644 index 0000000000..2008b24c4a --- /dev/null +++ b/packages/slider/src/utils/markerUtils/markerUtils.ts @@ -0,0 +1,140 @@ +type GetMarkerValueParams = { + markerElement: HTMLElement; + min: number; + max: number; +}; + +/** + * Получает значение маркера из DOM элемента + */ +export const getMarkerValue = ({ + markerElement, + min, + max, +}: GetMarkerValueParams): number | null => { + const nextElement = markerElement.nextElementSibling as HTMLElement; + + if (nextElement?.classList.contains('noUi-value')) { + return parseFloat(nextElement.getAttribute('data-value') || '0'); + } + + if (!nextElement) { + return null; + } + + const style = markerElement.style.left; + const percentage = parseFloat(style.replace('%', '')); + + if (!Number.isNaN(percentage)) { + return Math.round((percentage / 100) * (max - min) + min); + } + + return null; +}; + +type UpdateMarkerAttributesParams = { + markerElement: HTMLElement; + isPassed: boolean; + isCurrent: boolean; +}; + +/** + * Обновляет DOM атрибуты маркера + */ +export const updateMarkerAttributes = ({ + markerElement, + isPassed, + isCurrent, +}: UpdateMarkerAttributesParams) => { + if (isPassed) { + markerElement.setAttribute('data-passed', 'true'); + } else { + markerElement.removeAttribute('data-passed'); + } + + if (isCurrent) { + markerElement.setAttribute('data-current', 'true'); + } else { + markerElement.removeAttribute('data-current'); + } +}; + +interface MarkerParams { + markerValue: number; + currentValue: number; + currentValueTo?: number; + hasValueTo?: boolean; +} + +/** + * Определяет, пройден ли маркер (покрыт connect) + */ +export const isMarkerPassed = ({ + markerValue, + currentValue, + currentValueTo, + hasValueTo, +}: MarkerParams): boolean => { + if (hasValueTo && currentValueTo !== undefined) { + return ( + markerValue >= Math.min(currentValue, currentValueTo) && + markerValue <= Math.max(currentValue, currentValueTo) + ); + } + + return markerValue < currentValue; +}; + +/** + * Определяет, находится ли слайдер точно на маркере + */ +export const isMarkerCurrent = ({ + markerValue, + currentValue, + currentValueTo, + hasValueTo, +}: MarkerParams): boolean => { + if (hasValueTo && currentValueTo !== undefined) { + return markerValue === currentValue || markerValue === currentValueTo; + } + + return markerValue === currentValue; +}; + +type UpdateMarkerClassesParams = { + sliderElement: HTMLElement; + pipsValues: number[]; + customDots?: number[]; +}; + +/** + * Обновляет CSS классы маркеров для скрытия точек в режиме dotsSlider: 'custom' + */ +export const updateMarkerClasses = ({ + sliderElement, + pipsValues, + customDots = [], +}: UpdateMarkerClassesParams): void => { + const markers = sliderElement.querySelectorAll('.noUi-marker-large'); + + markers.forEach((marker) => { + const nextElement = marker.nextElementSibling as HTMLElement; + + if (nextElement?.classList.contains('noUi-value')) { + const dataValue = nextElement.getAttribute('data-value'); + const value = dataValue ? parseFloat(dataValue) : null; + + if (value !== null && pipsValues.includes(value)) { + const isAlsoInCustomDots = customDots.includes(value); + + if (isAlsoInCustomDots) { + marker.classList.remove('hide-for-pips-value'); + } else { + marker.classList.add('hide-for-pips-value'); + } + } else { + marker.classList.remove('hide-for-pips-value'); + } + } + }); +}; diff --git a/packages/slider/src/vars.css b/packages/slider/src/vars.css index 8e2d289c17..8e84ba0df7 100644 --- a/packages/slider/src/vars.css +++ b/packages/slider/src/vars.css @@ -16,10 +16,17 @@ --slider-progress-m-border-radius: var(--border-radius-4); --slider-progress-background: var(--color-light-neutral-translucent-300); --slider-progress-hover-background: var(--color-light-neutral-translucent-300); + --slider-progress-disabled-background: var(--color-light-neutral-500); + --slider-progress-disabled-hover-background: var(--color-light-neutral-500); + --slider-progress-disabled-active-background: var(--color-light-neutral-500); --slider-clickable-area-size: 16px; --slider-clickable-area-half-size: calc(var(--slider-clickable-area-size) / 2); - - /* Отступы по бокам — на мобилке thumb не должен заходить за края контрола */ - --slider-origin-width: 100%; - --slider-origin-right: var(--gap-0); + --slider-origin-width: calc(100% - 16px); + --slider-origin-right: 8px; + --slider-marker-size: 2px; + --slider-marker-offset: 4px; + --slider-marker-border-radius: var(--border-radius-circle); + --slider-marker-color: var(--color-light-neutral-translucent-700); + --slider-marker-color-passed: var(--color-light-accent-primary); + --slider-marker-color-disabled: var(--color-light-neutral-translucent-300); }