@sberbusiness/triplex-next-mcp-server 1.43.0-beta → 1.44.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,9 @@
1
1
  ---
2
2
  component: DatePickerExtended
3
- category: Internal
4
- figma-node: ""
5
- figma-file: ""
6
- related: [DateField, DateRange, MonthYearField, Calendar, Dropdown, FocusTrap]
3
+ category: Date components
4
+ related: [Calendar, Dropdown, DateField, MonthYearField, ChipDatePicker]
7
5
  tokens: []
8
- stories: ""
6
+ stories: stories/DatePickerExtended/DatePickerExtended.stories.tsx
9
7
  version: "1.0"
10
8
  ---
11
9
 
@@ -13,330 +11,479 @@ version: "1.0"
13
11
 
14
12
  ## Назначение
15
13
 
16
- Внутренняя "база" для всех полей выбора даты: объединяет `Dropdown`
17
- (с desktop + mobile веткой), `Calendar` (внутри dropdown'а) и
18
- `FocusTrap` (клавиатурная навигация). Управляет открытием/закрытием
19
- dropdown'а (controlled/uncontrolled внутри себя — state), ловит
20
- `Esc` для закрытия, отличает "мышиное" взаимодействие от "клавиатурного"
21
- (флаг `mouseUsedRef`) для настройки `FocusTrap`.
14
+ Связка «произвольный целевой элемент + выпадающий календарь». Компонент рендерит целевой элемент (render-prop `renderTarget`) и `Calendar` внутри `Dropdown`, хранит состояние открытости календаря и отдаёт его целевому элементу через `DatePickerExtendedContext`. На мобильной ширине экрана вместо десктопного дропдауна открывается полноэкранный `DropdownMobile` с заголовком из `renderDropdownHeaderTarget`.
22
15
 
23
- Публично импортируется потребителями вроде `DateField`, `DateRange`,
24
- `MonthYearField`, но **render-props-интерфейс** (`renderTarget`,
25
- `renderDropdownHeaderTarget`) делает его сложным для прямого
26
- использования в приложениях — обычно его оборачивают.
16
+ Используй когда: строишь собственный компонент выбора даты с нестандартным триггером (поле, чип, кнопка) и хочешь переиспользовать готовую механику дропдауна с календарём. Именно так устроены `DateField`, `MonthYearField` и `ChipDatePicker`.
27
17
 
28
- Используй когда: строишь новый компонент выбора даты со своим target'ом
29
- и своей валидацией — DatePickerExtended даёт dropdown + calendar +
30
- focus management бесплатно.
31
- Не используй как замену `DateField`/`DateRange`/`MonthYearField` —
32
- бери эти готовые обёртки.
18
+ Не используй когда: подходит готовый компонент — поле ввода даты (`DateField`), выбор месяца и года (`MonthYearField`), диапазон (`DateRange`, `MonthYearRange`), чип-фильтр (`ChipDatePicker`); или календарь нужен без дропдауна — тогда бери `Calendar` напрямую.
33
19
 
34
20
  ---
35
21
 
36
- ## Файловая структура
22
+ ## Варианты и props
37
23
 
38
- ```text
39
- src/components/DatePickerExtended/
40
- ├── DatePickerExtended.tsx # forwardRef, root
41
- ├── DatePickerExtendedContext.tsx # Context { dropdownOpen, mouseUsedRef, setDropdownOpen }
42
- ├── DatePickerExtendedDropdown.tsx # forwardRef, обёртка Dropdown + FocusTrap + mobile ветка
43
- └── styles/DatePickerExtended.module.less # Только .datePickerExtendedMobileDropdown { max-height: 100vh }
44
- ```
24
+ `IDatePickerExtendedProps` собирается из трёх источников: собственные props (ниже), props календаря (`ICalendarProps` без `adaptiveMode`, включая `ICalendarNestedProps`) и `alignment` из `IDropdownProps`. Плюс `React.HTMLAttributes<HTMLDivElement>` без `onChange` — они уходят на корневой `div`.
45
25
 
46
- Особенности:
47
- - **Нет `index.ts`** — импортируется как `import { DatePickerExtended } from ".../DatePickerExtended/DatePickerExtended"`.
48
- Это намеренно — компонент считается "полупубличным" (используется
49
- внутри библиотеки и потребителями при необходимости, но без красивого
50
- barrel-a).
51
- - **Нет `__tests__`** — покрывается через тесты `DateField`.
52
- - **Нет собственных stories** — только через обёртки.
53
- - **Почти нет LESS** — один класс для мобильного max-height.
26
+ ### Обязательные props
54
27
 
55
- ---
28
+ | Prop | Тип | Описание |
29
+ |---|---|---|
30
+ | `renderTarget` | `() => ReactNode` | Рендер целевого элемента. Вызывается внутри `DatePickerExtendedContext.Provider`, поэтому возвращённое дерево может читать и менять открытость календаря через контекст. |
31
+ | `renderDropdownHeaderTarget` | `() => ReactNode` | Рендер целевого элемента в заголовке мобильного дропдауна (`DropdownMobileHeader`). Обычно — `DropdownMobileInput` с текущим значением. |
32
+ | `pickedDate` | `string \| Moment \| null` | Выбранная дата (prop `Calendar`). Компонент неуправляемый только в части открытости — выбранную дату хранит потребитель. |
33
+ | `onDateChange` | `(date: Moment) => void` | Вызывается при выборе даты в календаре. Компонент дополнительно закрывает дропдаун. |
56
34
 
57
- ## Варианты и props
35
+ ### Собственные опциональные props
58
36
 
59
- ### `IDatePickerExtendedProps`
37
+ | Prop | Тип | По умолчанию | Описание |
38
+ |---|---|---|---|
39
+ | `dropdownTargetRef` | `RefObject<HTMLElement>` | корневой элемент | Элемент, относительно которого позиционируется дропдаун. Нужен, когда целевой элемент занимает не всю ширину корневого `div` (так делает `DateField`). |
40
+ | `alignment` | `EDropdownAlignment` | `LEFT` | Выравнивание дропдауна относительно целевого элемента. |
41
+ | `onDropdownOpen` | `() => void` | — | Дропдаун открылся (проброшен в `Dropdown.onOpen`). |
42
+ | `onDropdownClose` | `() => void` | — | Дропдаун закрылся (проброшен в `Dropdown.onClose`). |
43
+ | `focusTrapProps` | `FocusTrapProps` | — | Props `FocusTrap` (`focus-trap-react`) вокруг десктопного календаря. Переданный `focusTrapOptions` объединяется с внутренним: пользовательские ключи имеют приоритет. |
60
44
 
61
- ```ts
62
- extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">,
63
- Pick<IDropdownProps, "alignment">,
64
- ICalendarProps
65
- ```
45
+ ### Props календаря
66
46
 
67
- **Рендер-пропсы (обязательные):**
47
+ Пробрасываются в `Calendar` как есть: `pickType`, `format`, `defaultViewDate`, `limitRange`, `markedDays`, `disabledDays`, `reversedPick`, `onPageChange`, `onViewChange`, `dayHtmlAttributes`, `monthHtmlAttributes`, `yearHtmlAttributes`, `prevButtonProps`, `nextButtonProps`, `viewButtonProps`, `yesterdayButtonProps`, `todayButtonProps`, `tomorrowButtonProps`. Семантика — в `Calendar-ai.md`.
68
48
 
69
- | Prop | Тип | Описание |
70
- |---|---|---|
71
- | `renderTarget` | `() => React.ReactNode` | Target (поле ввода) на десктопе. |
72
- | `renderDropdownHeaderTarget` | `() => React.ReactNode` | Target в заголовке dropdown'а в мобильном режиме (обычно тот же input, но в другом контейнере). |
49
+ Исключение — `adaptiveMode`: он входит в `ICalendarProps`, но исключён из `IDatePickerExtendedProps` через `Omit`. Режим определяет версия дропдауна (десктопный — `false`, мобильный — `true`), задать его снаружи нельзя.
73
50
 
74
- **Dropdown:**
51
+ Футер календаря (`yesterdayButtonProps`, `todayButtonProps`, `tomorrowButtonProps`) рендерится только при переданном `todayButtonProps` — без него «Вчера» и «Завтра» не появятся. «Вчера» и «Завтра» дополнительно требуют открытой страницы текущего месяца в режиме `DAYS`.
75
52
 
76
- | Prop | Тип | Default | Описание |
77
- |---|---|---|---|
78
- | `alignment` | `EDropdownAlignment` | `LEFT` | Выравнивание dropdown'а относительно target'а. |
79
- | `focusTrapProps` | `FocusTrapProps` | — | Прокидывается в `FocusTrap` (`focus-trap-react`). |
80
- | `onDropdownOpen` | `() => void` | — | Callback при открытии. |
81
- | `onDropdownClose` | `() => void` | — | Callback при закрытии. |
82
-
83
- **Calendar (все пропсы `ICalendarProps` форвардятся):** `pickType`,
84
- `format`, `defaultViewDate`, `pickedDate`, `limitRange`, `markedDays`,
85
- `disabledDays`, `reversedPick`, `dayHtmlAttributes`, `monthHtmlAttributes`,
86
- `yearHtmlAttributes`, `prevButtonProps`, `nextButtonProps`, `viewButtonProps`,
87
- `todayButtonProps`, `onDateChange`, `onPageChange`, `onViewChange`.
88
-
89
- **HTMLDivAttributes:** все, кроме `onChange` (конфликт с Calendar).
90
-
91
- ### `DatePickerExtendedContext`
92
-
93
- ```ts
94
- interface IDatePickerExtendedContext {
95
- dropdownOpen: boolean;
96
- mouseUsedRef: React.MutableRefObject<boolean>;
97
- setDropdownOpen: (open: boolean) => void;
98
- }
99
- ```
53
+ ### Что уходит на корневой элемент
54
+
55
+ Всё, что не перечислено выше (`className`, `style`, `id`, `data-*`, `aria-*`, обработчики), спредится на корневой `div`. Собственные `onKeyDown` и `onMouseDown` компонента вызываются первыми, пользовательские — после них (не отменяются).
100
56
 
101
- Передаётся в `renderTarget` через контекст (target живёт в другой ветке
102
- дерева). `DateFieldTarget` и аналоги используют `useContext` для
103
- управления dropdown'ом из input'а.
57
+ ---
104
58
 
105
- ### `DatePickerExtendedDropdown` — внутренний compound
59
+ ## Дизайн-токены
106
60
 
107
- `IDatePickerExtendedDropdownProps extends IDropdownProps` +
108
- `renderCalendar`, `renderHeaderTarget`, `focusTrapProps`.
109
- Не экспортируется публично.
61
+ Собственных CSS-токенов нет. Единственный стиль компонента — `datePickerExtendedMobileDropdown` (`max-height: 100vh` для мобильного дропдауна). Внешний вид определяется `Calendar`, `Dropdown` и целевым элементом.
110
62
 
111
63
  ---
112
64
 
113
- ## Ключевые особенности реализации
65
+ ## Инварианты
114
66
 
115
- ### `mouseUsedRef` — отличение мышь vs клавиатура
67
+ - `forwardRef` на компоненте — не убирать. Ref ведёт на корневой `div` и одновременно используется внутри как `containerRef` (позиционирование дропдауна и определение кликов «снаружи»), поэтому менять ref-target нельзя.
68
+ - Публичный API (`DatePickerExtended`, `IDatePickerExtendedProps`, `DatePickerExtendedContext`, `IDatePickerExtendedContext`) экспортируется через `src/components/DatePickerExtended/index.ts` — переименования props и смена их типов недопустимы.
69
+ - **Открыть календарь снаружи можно только через `DatePickerExtendedContext`** — управляемого prop открытости у компонента нет. Контекст экспортируется из barrel, поэтому целевой элемент пишется и внутри библиотеки (`DateField`, `MonthYearField`, `ChipDatePicker`), и в коде потребителя.
70
+ - Форма контекста (`dropdownOpen`, `mouseUsedRef`, `setDropdownOpen`) — часть публичного API и одновременно контракт трёх внутренних потребителей. Её изменение ломает `DateFieldTarget`, `MonthYearFieldTarget` и `ChipDatePickerTarget` — править только вместе с ними.
71
+ - `mouseUsedRef` — флаг «взаимодействие было мышью». Он выставляется на `mousedown` по корневому элементу и сбрасывается при закрытии дропдауна; от него зависят `initialFocus` и `returnFocusOnDeactivate` у `FocusTrap`. Не заменять на state: значение читается во время рендера дропдауна и не должно вызывать перерисовку.
72
+ - `DatePickerExtendedDropdown` в barrel не экспортируется.
73
+ - Размер дропдауна зафиксирован (`EComponentSize.MD`) и наружу не выведен.
74
+ - Компонент не хардкодит текст: подписи кнопок календаря и `aria-label` целевого элемента задаёт потребитель. Библиотека мультиязычная.
75
+ - React 17-совместимость (ветка `release-0`): не переводить на `useId` и другие React 18-only API.
116
76
 
117
- ```ts
118
- const mouseUsedRef = useRef(false);
77
+ ---
119
78
 
120
- const handleMouseDown = (event) => {
121
- mouseUsedRef.current = true;
122
- onMouseDown?.(event);
123
- };
124
- ```
79
+ ## Accessibility
125
80
 
126
- Сбрасывается при закрытии dropdown'а (в `useEffect`, ветка else).
127
- Используется в `FocusTrap`:
81
+ - Дропдаун имеет `role="dialog"` и `aria-modal="true"`. Доступное имя диалога компонент не задаёт, и передать его через props нельзя: `...restProps` уходят на корневой `div`, а в `DatePickerExtendedDropdown` передаются только явно перечисленные props. Aria-атрибуты, переданные в `DatePickerExtended`, окажутся на корневом элементе, а не на диалоге.
82
+ - Десктопный календарь обёрнут в `FocusTrap` (`focus-trap-react`):
83
+ - при открытии с клавиатуры фокус уходит внутрь календаря, при закрытии возвращается на целевой элемент;
84
+ - при открытии мышью (`mouseUsedRef`) фокус не перехватывается и не возвращается — иначе после клика мышью фокус «прыгал» бы в календарь;
85
+ - `clickOutsideDeactivates: true`; `fallbackFocus` — контейнер календаря внутри ловушки (`tabIndex={-1}`), поэтому при отсутствии tabbable-элементов фокус остаётся внутри диалога. Задаётся ленивой функцией, т.к. на момент рендера ref ещё не заполнен; целевой элемент и `document.body` оставлены запасными вариантами, потому что focus-trap бросает исключение на пустом fallback.
86
+ - `Escape` на корневом элементе закрывает календарь (только когда он открыт), после чего вызывается пользовательский `onKeyDown`.
87
+ - `mousedown` вне корневого элемента и вне дропдауна закрывает календарь; клик внутри календаря — нет.
88
+ - Открытие календаря — ответственность целевого элемента: он же должен нести `aria-haspopup="dialog"` и `aria-expanded` (значение берётся из контекста).
89
+ - Клавиатурная навигация внутри сетки календаря — на стороне `Calendar`.
128
90
 
129
- ```tsx
130
- <FocusTrap
131
- focusTrapOptions={{
132
- initialFocus: !mouseUsedRef.current && undefined,
133
- returnFocusOnDeactivate: !mouseUsedRef.current,
134
- }}
135
- >
136
- ```
91
+ ---
137
92
 
138
- **Зачем:** если пользователь кликнул по полю мышью → не ставить
139
- программный initialFocus внутрь dropdown'а (пользователь не хочет,
140
- чтобы фокус "прыгал") и не возвращать фокус на target при закрытии.
141
- Если клавиатурой (Enter/Space на input) → focus-trap работает, по Esc
142
- фокус вернётся на input.
143
-
144
- **Это UX-тонкость**, нельзя просто удалить `mouseUsedRef` — пострадает
145
- клавиатурная доступность.
146
-
147
- ### Outside mousedown → закрытие dropdown'а
148
-
149
- ```ts
150
- useEffect(() => {
151
- const handleOutsideMouseDown = (event: MouseEvent) => {
152
- if (
153
- !containerRef.current?.contains(event.target as Node) &&
154
- !dropdownRef.current?.contains(event.target as Node)
155
- ) {
156
- setDropdownOpen(false);
157
- }
158
- };
159
- if (dropdownOpen) {
160
- document.addEventListener("mousedown", handleOutsideMouseDown);
161
- return () => document.removeEventListener("mousedown", handleOutsideMouseDown);
162
- } else {
163
- mouseUsedRef.current = false;
164
- }
165
- }, [dropdownOpen]);
166
- ```
93
+ ## Связанные компоненты
167
94
 
168
- **Проверяет два контейнера** — корневой (`containerRef`, target) и
169
- `dropdownRef` (сам dropdown). Если клик не в них обоих — закрыть.
170
- `dropdownRef` нужен потому что Dropdown рендерится через Portal и
171
- не является DOM-потомком `containerRef`.
95
+ - `Calendar` — календарная сетка, рендерится внутри дропдауна; её props входят в `IDatePickerExtendedProps`, кроме `adaptiveMode`.
96
+ - `Dropdown` — выпадающее меню; из его props наружу выведен только `alignment`, мобильная версия конфигурируется внутри.
97
+ - `DateField` — поле ввода даты; передаёт `dropdownTargetRef` и открывает календарь из `DateFieldTarget` через контекст.
98
+ - `MonthYearField` — выбор месяца и года; конфигурирует `pickType={ECalendarPickType.MONTH_YEAR}`.
99
+ - `ChipDatePicker` — чип-фильтр с календарём; открывает дропдаун из `ChipDatePickerTarget` через контекст.
100
+ - `DatePickerExtendedContext` — контекст открытости дропдауна для целевого элемента. Экспортируется из barrel вместе с типом `IDatePickerExtendedContext`, отдельного AI.md не имеет.
172
101
 
173
- **`mouseUsedRef` сбрасывается в else-ветке** — при каждом закрытии
174
- флаг становится `false`, чтобы следующее открытие корректно решало,
175
- как вести себя с focus trap'ом.
102
+ Внутренние части (в barrel не экспортируются, отдельного AI.md не имеют):
176
103
 
177
- ### Esc → закрыть dropdown (локальный onKeyDown)
104
+ - `DatePickerExtendedDropdown` — дропдаун с `FocusTrap` вокруг календаря и мобильной версткой (`DropdownMobileHeader` + `DropdownMobileBody`).
178
105
 
179
- ```ts
180
- const handleKeyDown = (event) => {
181
- if (isKey(key, "ESCAPE")) {
182
- if (dropdownOpen) setDropdownOpen(false);
183
- }
184
- onKeyDown?.(event);
185
- };
186
- ```
106
+ ---
187
107
 
188
- **Esc обрабатывается на root div**, не на document. Нужно, чтобы Esc
189
- внутри input'а (фокус в target) → закрыть dropdown. Не фильтруется
190
- `event.defaultPrevented` — если потребитель уже обработал Esc, он
191
- всё равно закроет dropdown. Это компромисс.
108
+ ## Stories
192
109
 
193
- ### `renderCalendar(adaptiveMode)` — две ветки
110
+ Основные истории: `stories/DatePickerExtended/DatePickerExtended.stories.tsx`
111
+ Файлы примеров: `stories/DatePickerExtended/examples/`
194
112
 
195
- ```tsx
196
- const renderMobileContent = () => (
197
- <>
198
- <DropdownMobileHeader controlButtons={<DropdownMobileClose onClick={...} />}>
199
- {renderHeaderTarget()}
200
- </DropdownMobileHeader>
201
- <DropdownMobileBody>{renderCalendar(true)}</DropdownMobileBody>
202
- </>
203
- );
204
- // desktop:
205
- <FocusTrap ...>
206
- <div role="presentation">{renderCalendar(false)}</div>
207
- </FocusTrap>
208
- ```
113
+ | Story | Example file | Что демонстрирует |
114
+ |---|---|---|
115
+ | `Playground` | `Playground.tsx` | Интерактивный контроль `alignment`, `pickType`, `reversedPick` и кнопок футера |
116
+ | `Default` | `Default.tsx` | Минимальный пример: кнопка-триггер открывает календарь через контекст |
117
+ | `Alignments` | `Alignments.tsx` | Выравнивание дропдауна LEFT / RIGHT |
118
+ | `PickTypes` | `PickTypes.tsx` | Выбор дня (DATE) и месяца с годом (MONTH_YEAR) |
119
+ | `WithMarkedAndDisabledDays` | `WithMarkedAndDisabledDays.tsx` | Отмеченные и недоступные дни, ограничение периода |
120
+ | `WithFooterButtons` | `WithFooterButtons.tsx` | Кнопки футера «Вчера» / «Сегодня» / «Завтра» |
121
+ | `VisualTests` | `VisualTests.tsx` | Скриншот-регрессия: раскрытый календарь с отметками, ограничением периода и футером (на xs — мобильный дропдаун) |
122
+ | `VisualTestsMonthYear` | `VisualTestsMonthYear.tsx` | Скриншот-регрессия: раскрытый календарь в режиме MONTH_YEAR |
123
+ | `VisualTestsAlignmentRight` | `VisualTestsAlignmentRight.tsx` | Скриншот-регрессия: раскрытый календарь с выравниванием RIGHT |
124
+
125
+ Примеры импортируют `DatePickerExtendedContext` из `@sberbusiness/triplex-next` наравне с самим компонентом — без контекста целевой элемент не может открыть календарь, см. «Инварианты».
209
126
 
210
- **`adaptiveMode=true`** — календарь на мобильном (полноэкранный,
211
- другие размеры и поведение).
212
- **`adaptiveMode=false`** — десктопный компактный календарь в dropdown'е.
127
+ ---
213
128
 
214
- Calendar сам смотрит на `adaptiveMode` и меняет поведение.
129
+ ## История изменений
130
+
131
+ | Дата | Изменение |
132
+ |---|---|
133
+ | 2026-08-24 | `DatePickerExtendedContext` и `IDatePickerExtendedContext` экспортируются из barrel — целевой элемент можно написать снаружи библиотеки. `adaptiveMode` исключён из `IDatePickerExtendedProps` (ломающее изменение, затрагивает также `DateField`, `MonthYearField`, `ChipDatePicker`) |
134
+ | 2026-08-20 | Создан документ. AI-рефакторинг, unit-тесты (`__tests__/DatePickerExtended.test.tsx`), stories по modern pattern. Исправлено: `yesterdayButtonProps` / `tomorrowButtonProps` пробрасываются в `Calendar`, `adaptiveMode` больше не попадает в DOM |
135
+
136
+ ---
215
137
 
216
- ### `onDateChange` → закрыть dropdown + вызвать внешний
138
+ ## Примеры использования
139
+
140
+ ### Default
141
+
142
+ `stories/DatePickerExtended/examples/Default.tsx`
143
+
144
+ ```tsx
145
+ import React, { useContext, useState } from "react";
146
+ import moment from "moment";
147
+ import {
148
+ Button,
149
+ DatePickerExtended,
150
+ DatePickerExtendedContext,
151
+ DropdownMobileInput,
152
+ EButtonTheme,
153
+ EComponentSize,
154
+ } from "@sberbusiness/triplex-next";
155
+
156
+ const DATE_FORMAT = "YYYYMMDD";
157
+ const DISPLAY_FORMAT = "DD.MM.YYYY";
158
+
159
+ interface ITargetProps {
160
+ /** Отображаемое значение даты. */
161
+ children: React.ReactNode;
162
+ }
217
163
 
218
- ```ts
219
- const handleChangeDate = (date: moment.Moment) => {
220
- setDropdownOpen(false);
221
- onDateChange(date);
164
+ const Target = ({ children }: ITargetProps) => {
165
+ const { dropdownOpen, setDropdownOpen } = useContext(DatePickerExtendedContext);
166
+
167
+ return (
168
+ <Button
169
+ theme={EButtonTheme.SECONDARY}
170
+ size={EComponentSize.MD}
171
+ aria-haspopup="dialog"
172
+ aria-expanded={dropdownOpen}
173
+ onClick={() => setDropdownOpen(!dropdownOpen)}
174
+ >
175
+ {children}
176
+ </Button>
177
+ );
178
+ };
179
+
180
+ export const Default = () => {
181
+ const [value, setValue] = useState("19700115");
182
+ const displayedValue = moment(value, DATE_FORMAT).format(DISPLAY_FORMAT);
183
+
184
+ return (
185
+ <DatePickerExtended
186
+ pickedDate={value}
187
+ format={DATE_FORMAT}
188
+ onDateChange={(date) => setValue(date.format(DATE_FORMAT))}
189
+ renderTarget={() => <Target>{displayedValue}</Target>}
190
+ renderDropdownHeaderTarget={() => <DropdownMobileInput value={displayedValue} readOnly />}
191
+ />
192
+ );
222
193
  };
223
194
  ```
224
195
 
225
- Выбор даты → dropdown закрывается автоматически. Это жёсткое
226
- поведение, потребитель не может отключить. Если нужно "мультидневный
227
- выбор" — Calendar не подходит, нужен другой компонент.
196
+ ### Alignments
228
197
 
229
- ### `FocusTrap` с `clickOutsideDeactivates`
198
+ `stories/DatePickerExtended/examples/Alignments.tsx`
230
199
 
231
- ```ts
232
- focusTrapOptions={{
233
- clickOutsideDeactivates: true,
234
- fallbackFocus: targetRef.current!,
235
- ...
236
- }}
237
- ```
200
+ ```tsx
201
+ import React, { useContext, useState } from "react";
202
+ import moment from "moment";
203
+ import {
204
+ Button,
205
+ DatePickerExtended,
206
+ DatePickerExtendedContext,
207
+ DropdownMobileInput,
208
+ EButtonTheme,
209
+ EComponentSize,
210
+ EDropdownAlignment,
211
+ } from "@sberbusiness/triplex-next";
212
+
213
+ const DATE_FORMAT = "YYYYMMDD";
214
+ const DISPLAY_FORMAT = "DD.MM.YYYY";
215
+
216
+ const ALIGNMENTS = Object.values(EDropdownAlignment);
217
+
218
+ interface ITargetProps {
219
+ /** Отображаемое значение даты. */
220
+ children: React.ReactNode;
221
+ }
238
222
 
239
- - `clickOutsideDeactivates` — клик вне → FocusTrap отпускает фокус,
240
- но outside-handler всё равно закрывает dropdown. Это **защита**:
241
- FocusTrap не борется с outside-handler'ом.
242
- - `fallbackFocus: targetRef.current!` — если FocusTrap не может найти
243
- focusable элемент внутри, поставить фокус на target (non-null
244
- assertion обязателен потому что при первой активации targetRef
245
- уже mounted).
223
+ const Target = ({ children }: ITargetProps) => {
224
+ const { dropdownOpen, setDropdownOpen } = useContext(DatePickerExtendedContext);
225
+
226
+ return (
227
+ <Button
228
+ theme={EButtonTheme.SECONDARY}
229
+ size={EComponentSize.MD}
230
+ aria-haspopup="dialog"
231
+ aria-expanded={dropdownOpen}
232
+ onClick={() => setDropdownOpen(!dropdownOpen)}
233
+ >
234
+ {children}
235
+ </Button>
236
+ );
237
+ };
246
238
 
247
- ### `role="dialog" aria-modal="true"` на Dropdown
239
+ interface IAlignmentItemProps {
240
+ /** Выравнивание выпадающего календаря относительно целевого элемента. */
241
+ alignment: EDropdownAlignment;
242
+ }
248
243
 
249
- Семантика модального диалога для содержимого (календарь). Screen reader
250
- объявит как модал. **Jарко** (не опционально), `...rest` идёт в
251
- Dropdown после `role`/`aria-modal`, но `role` и `aria-modal` не
252
- подлежат перебиванию снаружи потребителя (IDatePickerExtendedProps не
253
- принимает role).
244
+ const AlignmentItem = ({ alignment }: IAlignmentItemProps) => {
245
+ const [value, setValue] = useState("19700115");
246
+ const displayedValue = moment(value, DATE_FORMAT).format(DISPLAY_FORMAT);
247
+
248
+ return (
249
+ <div>
250
+ <div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: "700" }}>{alignment.toUpperCase()}</div>
251
+ <DatePickerExtended
252
+ alignment={alignment}
253
+ pickedDate={value}
254
+ format={DATE_FORMAT}
255
+ onDateChange={(date) => setValue(date.format(DATE_FORMAT))}
256
+ renderTarget={() => <Target>{displayedValue}</Target>}
257
+ renderDropdownHeaderTarget={() => <DropdownMobileInput value={displayedValue} readOnly />}
258
+ />
259
+ </div>
260
+ );
261
+ };
254
262
 
255
- ### `data-tx={process.env.npm_package_version}` — есть
263
+ export const Alignments = () => (
264
+ <div style={{ display: "flex", gap: "48px", flexWrap: "wrap" }}>
265
+ {ALIGNMENTS.map((alignment) => (
266
+ <AlignmentItem key={alignment} alignment={alignment} />
267
+ ))}
268
+ </div>
269
+ );
270
+ ```
256
271
 
257
- Стандарт библиотеки. Не удалять.
272
+ ### PickTypes
258
273
 
259
- ### `setRef` — double-duty ref
274
+ `stories/DatePickerExtended/examples/PickTypes.tsx`
260
275
 
261
- ```ts
262
- const setRef = (node: HTMLDivElement | null) => {
263
- containerRef.current = node;
264
- if (typeof ref === "function") ref(node);
265
- else if (ref) ref.current = node;
276
+ ```tsx
277
+ import React, { useContext, useState } from "react";
278
+ import moment from "moment";
279
+ import {
280
+ Button,
281
+ DatePickerExtended,
282
+ DatePickerExtendedContext,
283
+ DropdownMobileInput,
284
+ ECalendarPickType,
285
+ EButtonTheme,
286
+ EComponentSize,
287
+ } from "@sberbusiness/triplex-next";
288
+
289
+ const DATE_FORMAT = "YYYYMMDD";
290
+
291
+ /** Формат отображения даты для каждого варианта выбора. */
292
+ const PICK_TYPE_TO_DISPLAY_FORMAT = {
293
+ [ECalendarPickType.DATE]: "DD.MM.YYYY",
294
+ [ECalendarPickType.MONTH_YEAR]: "MMMM YYYY",
266
295
  };
267
- ```
268
296
 
269
- Один DOM-узел = target для Dropdown (`targetRef`) + forwarded ref.
297
+ const PICK_TYPES = Object.values(ECalendarPickType);
270
298
 
271
- ---
299
+ interface ITargetProps {
300
+ /** Отображаемое значение даты. */
301
+ children: React.ReactNode;
302
+ }
272
303
 
273
- ## Инварианты
304
+ const Target = ({ children }: ITargetProps) => {
305
+ const { dropdownOpen, setDropdownOpen } = useContext(DatePickerExtendedContext);
306
+
307
+ return (
308
+ <Button
309
+ theme={EButtonTheme.SECONDARY}
310
+ size={EComponentSize.MD}
311
+ aria-haspopup="dialog"
312
+ aria-expanded={dropdownOpen}
313
+ onClick={() => setDropdownOpen(!dropdownOpen)}
314
+ >
315
+ {children}
316
+ </Button>
317
+ );
318
+ };
274
319
 
275
- - **`forwardRef`** на root — обязателен (нужен для тулов).
276
- - **`mouseUsedRef`** — не удалять. Управляет UX focus-trap'а.
277
- - **Outside-handler проверяет оба ref'а** (containerRef + dropdownRef) —
278
- не сводить к одному.
279
- - **`mouseUsedRef.current = false` в else-ветке useEffect** — сбросить
280
- флаг при каждом закрытии, иначе следующий цикл не получит корректный
281
- initialFocus.
282
- - **`role="dialog" aria-modal="true"`** на Dropdown — обязательно.
283
- - **`clickOutsideDeactivates: true`** в FocusTrap — иначе outside-click
284
- не закрывает dropdown (FocusTrap борется).
285
- - **`setDropdownOpen(false)` в `handleChangeDate`** — выбор даты
286
- закрывает dropdown. Неопциональное поведение.
287
- - **Esc → закрытие независимо от defaultPrevented** — осознанное
288
- решение. Не фильтровать.
289
- - **Render-props `renderTarget` и `renderDropdownHeaderTarget`** —
290
- единственный способ вставить target. Не принимать children.
320
+ interface IPickTypeItemProps {
321
+ /** Вариант выбора даты. */
322
+ pickType: ECalendarPickType;
323
+ }
291
324
 
292
- ---
325
+ const PickTypeItem = ({ pickType }: IPickTypeItemProps) => {
326
+ const [value, setValue] = useState("19700115");
327
+ const displayedValue = moment(value, DATE_FORMAT).format(PICK_TYPE_TO_DISPLAY_FORMAT[pickType]);
328
+
329
+ return (
330
+ <div>
331
+ <div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: "700" }}>{pickType.toUpperCase()}</div>
332
+ <DatePickerExtended
333
+ pickType={pickType}
334
+ pickedDate={value}
335
+ format={DATE_FORMAT}
336
+ onDateChange={(date) => setValue(date.format(DATE_FORMAT))}
337
+ renderTarget={() => <Target>{displayedValue}</Target>}
338
+ renderDropdownHeaderTarget={() => <DropdownMobileInput value={displayedValue} readOnly />}
339
+ />
340
+ </div>
341
+ );
342
+ };
293
343
 
294
- ## Accessibility
344
+ export const PickTypes = () => (
345
+ <div style={{ display: "flex", gap: "48px", flexWrap: "wrap" }}>
346
+ {PICK_TYPES.map((pickType) => (
347
+ <PickTypeItem key={pickType} pickType={pickType} />
348
+ ))}
349
+ </div>
350
+ );
351
+ ```
295
352
 
296
- - **`FocusTrap`** (`focus-trap-react`) — клавиатурный фокус заперт
297
- внутри dropdown'а, Tab ходит по кнопкам календаря.
298
- - **`role="dialog" aria-modal="true"`** на Dropdown — семантика модала.
299
- - **Esc** — закрывает dropdown.
300
- - **`initialFocus`/`returnFocusOnDeactivate`** — условно, только для
301
- клавиатурного открытия. При мышином открытии фокус не трогается
302
- (UX-паттерн).
303
- - **`<div role="presentation">`** вокруг календаря — убирает лишнюю
304
- семантику. Сам Calendar имеет свою ARIA.
305
- - **Mobile** — собственная разметка с `DropdownMobileHeader`/`Body`,
306
- включая кнопку закрытия (`DropdownMobileClose`).
353
+ ### WithMarkedAndDisabledDays
307
354
 
308
- ---
355
+ `stories/DatePickerExtended/examples/WithMarkedAndDisabledDays.tsx`
309
356
 
310
- ## Дизайн-токены
357
+ ```tsx
358
+ import React, { useContext, useState } from "react";
359
+ import moment from "moment";
360
+ import {
361
+ Button,
362
+ DatePickerExtended,
363
+ DatePickerExtendedContext,
364
+ DropdownMobileInput,
365
+ ECalendarDateMarkType,
366
+ EButtonTheme,
367
+ EComponentSize,
368
+ } from "@sberbusiness/triplex-next";
369
+
370
+ const DATE_FORMAT = "YYYYMMDD";
371
+ const DISPLAY_FORMAT = "DD.MM.YYYY";
372
+
373
+ /** Отмеченные дни: тип отметки задаёт цвет маркера под числом. */
374
+ const MARKED_DAYS = {
375
+ "19700108": ECalendarDateMarkType.STANDARD,
376
+ "19700109": ECalendarDateMarkType.ATTENTION,
377
+ "19700112": ECalendarDateMarkType.CRITICAL,
378
+ };
311
379
 
312
- **Нет собственных.** Dropdown и Calendar — источники всех визуальных
313
- токенов.
380
+ /** Дни, недоступные для выбора. */
381
+ const DISABLED_DAYS = ["19700120", "19700121"];
314
382
 
315
- ---
383
+ /** Ограничение выбираемого периода. */
384
+ const LIMIT_RANGE = {
385
+ dateFrom: moment("19700105", DATE_FORMAT),
386
+ dateTo: moment("19700125", DATE_FORMAT),
387
+ };
316
388
 
317
- ## Связанные компоненты
389
+ interface ITargetProps {
390
+ /** Отображаемое значение даты. */
391
+ children: React.ReactNode;
392
+ }
318
393
 
319
- - `Dropdown` (`src/components/Dropdown`) — контейнер (Portal + positioning).
320
- - `Calendar` (`src/components/Calendar`) — собственно выбор даты.
321
- - `DropdownMobile*` (`src/components/Dropdown/mobile`) — мобильные
322
- подкомпоненты (Header/Body/Close).
323
- - `FocusTrap` (`focus-trap-react`) — внешний пакет для захвата фокуса.
324
- - `DateField` (`src/components/DateField`) — основной потребитель.
325
- - `DateRange` (`src/components/DateRange`) — потребитель для диапазона.
326
- - `MonthYearField` (`src/components/MonthYearField`) — потребитель
327
- для месяца/года.
394
+ const Target = ({ children }: ITargetProps) => {
395
+ const { dropdownOpen, setDropdownOpen } = useContext(DatePickerExtendedContext);
396
+
397
+ return (
398
+ <Button
399
+ theme={EButtonTheme.SECONDARY}
400
+ size={EComponentSize.MD}
401
+ aria-haspopup="dialog"
402
+ aria-expanded={dropdownOpen}
403
+ onClick={() => setDropdownOpen(!dropdownOpen)}
404
+ >
405
+ {children}
406
+ </Button>
407
+ );
408
+ };
328
409
 
329
- ---
410
+ export const WithMarkedAndDisabledDays = () => {
411
+ const [value, setValue] = useState("19700115");
412
+ const displayedValue = moment(value, DATE_FORMAT).format(DISPLAY_FORMAT);
413
+
414
+ return (
415
+ <DatePickerExtended
416
+ pickedDate={value}
417
+ format={DATE_FORMAT}
418
+ limitRange={LIMIT_RANGE}
419
+ markedDays={MARKED_DAYS}
420
+ disabledDays={DISABLED_DAYS}
421
+ onDateChange={(date) => setValue(date.format(DATE_FORMAT))}
422
+ renderTarget={() => <Target>{displayedValue}</Target>}
423
+ renderDropdownHeaderTarget={() => <DropdownMobileInput value={displayedValue} readOnly />}
424
+ />
425
+ );
426
+ };
427
+ ```
330
428
 
331
- ## Stories
429
+ ### WithFooterButtons
332
430
 
333
- **Нет собственных stories.** Документируется косвенно через
334
- `DateField`/`DateRange`/`MonthYearField`.
431
+ `stories/DatePickerExtended/examples/WithFooterButtons.tsx`
335
432
 
336
- ---
433
+ ```tsx
434
+ import React, { useContext, useState } from "react";
435
+ import moment from "moment";
436
+ import {
437
+ Button,
438
+ DatePickerExtended,
439
+ DatePickerExtendedContext,
440
+ DropdownMobileInput,
441
+ EButtonTheme,
442
+ EComponentSize,
443
+ } from "@sberbusiness/triplex-next";
444
+
445
+ const DATE_FORMAT = "YYYYMMDD";
446
+ const DISPLAY_FORMAT = "DD.MM.YYYY";
447
+
448
+ interface ITargetProps {
449
+ /** Отображаемое значение даты. */
450
+ children: React.ReactNode;
451
+ }
337
452
 
338
- ## История изменений
453
+ const Target = ({ children }: ITargetProps) => {
454
+ const { dropdownOpen, setDropdownOpen } = useContext(DatePickerExtendedContext);
455
+
456
+ return (
457
+ <Button
458
+ theme={EButtonTheme.SECONDARY}
459
+ size={EComponentSize.MD}
460
+ aria-haspopup="dialog"
461
+ aria-expanded={dropdownOpen}
462
+ onClick={() => setDropdownOpen(!dropdownOpen)}
463
+ >
464
+ {children}
465
+ </Button>
466
+ );
467
+ };
468
+
469
+ // Кнопки «Вчера» и «Завтра» календарь показывает только на странице текущего месяца,
470
+ // кнопку «Сегодня» — всегда. Текст кнопок задаёт потребитель.
471
+ export const WithFooterButtons = () => {
472
+ const [value, setValue] = useState(moment().format(DATE_FORMAT));
473
+ const displayedValue = moment(value, DATE_FORMAT).format(DISPLAY_FORMAT);
474
+
475
+ return (
476
+ <DatePickerExtended
477
+ pickedDate={value}
478
+ format={DATE_FORMAT}
479
+ yesterdayButtonProps={{ children: "Вчера" }}
480
+ todayButtonProps={{ children: "Сегодня" }}
481
+ tomorrowButtonProps={{ children: "Завтра" }}
482
+ onDateChange={(date) => setValue(date.format(DATE_FORMAT))}
483
+ renderTarget={() => <Target>{displayedValue}</Target>}
484
+ renderDropdownHeaderTarget={() => <DropdownMobileInput value={displayedValue} readOnly />}
485
+ />
486
+ );
487
+ };
488
+ ```
339
489
 
340
- | Дата | Изменение |
341
- |---|---|
342
- | 2026-04-18 | Создан документ |