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