@olddevs/ui 4.2.1 → 4.3.0
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.
- package/CHANGELOG.md +22 -0
- package/dist/components/navigation/MegaMenu.d.ts +43 -23
- package/dist/components/navigation/MegaMenu.d.ts.map +1 -1
- package/dist/components/navigation/MegaMenu.js +110 -47
- package/dist/components/navigation/MegaMenu.js.map +1 -1
- package/dist/components/navigation/useMenuAim.d.ts +8 -8
- package/dist/components/navigation/useMenuAim.d.ts.map +1 -1
- package/dist/components/overlays/Menu.d.ts +4 -2
- package/dist/components/overlays/Menu.d.ts.map +1 -1
- package/dist/components/overlays/Menu.js.map +1 -1
- package/docs/components/index.en.md +2 -2
- package/docs/components/index.md +1 -1
- package/docs/components/navigation/mega-menu.en.md +91 -61
- package/docs/components/navigation/mega-menu.md +86 -56
- package/docs/components/overlays/menu.en.md +8 -4
- package/docs/components/overlays/menu.md +6 -3
- package/docs/getting-started.en.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/glossary.en.md +23 -23
- package/docs/glossary.md +22 -22
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/components/navigation/MegaMenu.tsx +300 -140
- package/src/components/navigation/useMenuAim.ts +19 -19
- package/src/components/overlays/Menu.tsx +12 -2
|
@@ -39,7 +39,7 @@ import { useMenuAim } from "./useMenuAim.js";
|
|
|
39
39
|
МЕГАМЕНЮ — НАВИГАЦИЯ, А НЕ МЕНЮ КОМАНД.
|
|
40
40
|
|
|
41
41
|
Отсюда шаблон WAI-ARIA Disclosure Navigation, а не `role="menu"`: Категория
|
|
42
|
-
с
|
|
42
|
+
с Полкой — кнопка с `aria-expanded`, Полка — регион со ссылками, ссылки
|
|
43
43
|
— настоящие `a`. Роль `menu` обещает скринридеру меню действий с его
|
|
44
44
|
клавиатурной моделью (стрелки по всем пунктам, Tab закрывает), и ссылки в
|
|
45
45
|
нём перестают быть ссылками: их нельзя открыть в новой вкладке, их не видно
|
|
@@ -51,15 +51,15 @@ import { useMenuAim } from "./useMenuAim.js";
|
|
|
51
51
|
КАСАНИЕ И УЗКИЙ ЭКРАН. Касание наведения не знает: Категорию открывает
|
|
52
52
|
`click`, а `pointerType` «touch» и «pen» в наведении и уходе указателя
|
|
53
53
|
отбрасываются — Прицел им не нужен. На узком экране (граница та же, что у
|
|
54
|
-
подменю кита)
|
|
54
|
+
подменю кита) Полке некуда встать сбоку, и она ЗАМЕНЯЕТ Столбец: «спуск»
|
|
55
55
|
в Категорию со строкой «Назад». Прицела там тоже нет: заменять Столбец под
|
|
56
56
|
курсором по наведению значило бы вырывать его из-под руки.
|
|
57
57
|
*/
|
|
58
58
|
|
|
59
|
-
/** Задержка закрытия, когда курсор ушёл и со Столбца, и с
|
|
59
|
+
/** Задержка закрытия, когда курсор ушёл и со Столбца, и с Полки. */
|
|
60
60
|
const LEAVE_GRACE_MS = 150;
|
|
61
61
|
|
|
62
|
-
/** Что считается фокусируемым внутри
|
|
62
|
+
/** Что считается фокусируемым внутри Полки при переходе в неё с клавиатуры. */
|
|
63
63
|
const FOCUSABLE =
|
|
64
64
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
65
65
|
|
|
@@ -67,27 +67,34 @@ interface MegaMenuContextValue {
|
|
|
67
67
|
baseId: string;
|
|
68
68
|
/** Открытая Категория. */
|
|
69
69
|
value: string | null;
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Категория — единственная остановка Tab в Столбце (roving tabindex).
|
|
72
|
+
* `undefined` — Категории ещё не сообщили о себе (сервер, первая отрисовка):
|
|
73
|
+
* тогда остановка у каждой доступной, иначе Tab проскочил бы каталог.
|
|
74
|
+
*/
|
|
75
|
+
tabStop: string | null | undefined;
|
|
76
|
+
/** Категория сообщает о себе: Столбец не разбирает детей по типу (см. `MegaMenu`). */
|
|
77
|
+
register: (id: string, record: CategoryRecord) => void;
|
|
78
|
+
unregister: (id: string) => void;
|
|
72
79
|
/** Открыть Категорию щелчком или с клавиатуры — сразу, мимо Прицела. */
|
|
73
80
|
openCategory: (id: string, focusShowcase?: boolean) => void;
|
|
74
81
|
close: () => void;
|
|
75
|
-
/** Курсор наведён на строку Столбца: решает Прицел. `null` — строка без
|
|
82
|
+
/** Курсор наведён на строку Столбца: решает Прицел. `null` — строка без Полки. */
|
|
76
83
|
hoverCategory: (target: string | null) => void;
|
|
77
|
-
/**
|
|
84
|
+
/** Полка перевёрнута на другую сторону: со стороны конца строки ей не хватило места. */
|
|
78
85
|
flipped: boolean;
|
|
79
|
-
/** Мегаменю в оверлее `MegaMenuTrigger`: Столбец и
|
|
86
|
+
/** Мегаменю в оверлее `MegaMenuTrigger`: Столбец и Полка прокручиваются сами. */
|
|
80
87
|
inOverlay: boolean;
|
|
81
|
-
/** Узкий экран:
|
|
88
|
+
/** Узкий экран: Полка заменяет Столбец, а не встаёт сбоку. */
|
|
82
89
|
narrow: boolean;
|
|
83
|
-
/** Категория, чья
|
|
90
|
+
/** Категория, чья Полка сейчас заменяет Столбец; `null` — Столбец на месте. */
|
|
84
91
|
drilledId: string | null;
|
|
85
|
-
/** Вернуться из
|
|
92
|
+
/** Вернуться из Полки к Столбцу и поставить фокус на исходную Категорию. */
|
|
86
93
|
back: (id: string) => void;
|
|
87
94
|
onCategoryKeyDown: (event: KeyboardEvent<HTMLElement>, id: string, hasShowcase: boolean) => void;
|
|
88
95
|
onShowcaseKeyDown: (event: KeyboardEvent<HTMLElement>, id: string) => void;
|
|
89
96
|
onCategoryFocus: (id: string) => void;
|
|
90
|
-
/** Категория хочет перевести фокус в свою
|
|
97
|
+
/** Категория хочет перевести фокус в свою Полку, как только та смонтируется. */
|
|
91
98
|
pendingFocus: MutableRefObject<string | null>;
|
|
92
99
|
/** Категория, на которую вернуть фокус, когда её кнопку снова покажут. */
|
|
93
100
|
returnFocus: MutableRefObject<string | null>;
|
|
@@ -106,22 +113,27 @@ function domId(baseId: string, kind: "category" | "showcase", id: string): strin
|
|
|
106
113
|
return `${baseId}-${kind}-${encodeURIComponent(id)}`;
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
/** Что Столбцу нужно знать о Категории, чтобы выбрать остановку Tab и спуск. */
|
|
117
|
+
interface CategoryRecord {
|
|
118
|
+
hasShowcase: boolean;
|
|
119
|
+
disabled: boolean;
|
|
120
|
+
active: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Дети в одном списке, с раскрытием фрагментов: у каждой ссылки колонки — свой пункт. */
|
|
124
|
+
function flattenFragments(children: ReactNode): ReactNode[] {
|
|
125
|
+
return Children.toArray(children).flatMap((child) =>
|
|
126
|
+
isValidElement(child) && child.type === Fragment
|
|
127
|
+
? flattenFragments((child.props as { children?: ReactNode }).children)
|
|
128
|
+
: [child]
|
|
129
|
+
);
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
/** Сколько ждать следующую букву поиска, прежде чем начать слово заново. */
|
|
133
|
+
const TYPEAHEAD_RESET_MS = 500;
|
|
134
|
+
|
|
123
135
|
/**
|
|
124
|
-
* Стрелка «к
|
|
136
|
+
* Стрелка «к Полке» — по тому, где Полка стоит на экране: при письме справа
|
|
125
137
|
* налево она слева, а перевёрнутая у края — на другой стороне.
|
|
126
138
|
*/
|
|
127
139
|
function forwardKey(node: HTMLElement, flipped: boolean): "ArrowRight" | "ArrowLeft" {
|
|
@@ -129,11 +141,11 @@ function forwardKey(node: HTMLElement, flipped: boolean): "ArrowRight" | "ArrowL
|
|
|
129
141
|
return rtl === flipped ? "ArrowRight" : "ArrowLeft";
|
|
130
142
|
}
|
|
131
143
|
|
|
132
|
-
/** Зазор между Столбцом и
|
|
144
|
+
/** Зазор между Столбцом и Полкой (`ms-1`) и запас до края окна, пиксели. */
|
|
133
145
|
const SHOWCASE_GAP = 4;
|
|
134
146
|
const VIEWPORT_MARGIN = 8;
|
|
135
147
|
|
|
136
|
-
/** Клавиши, которые поле ввода в
|
|
148
|
+
/** Клавиши, которые поле ввода в Полке (поиск, промо с формой) обязано оставить себе. */
|
|
137
149
|
function isTextEntry(target: EventTarget): boolean {
|
|
138
150
|
return (
|
|
139
151
|
target instanceof HTMLElement &&
|
|
@@ -149,31 +161,31 @@ export interface MegaMenuProps extends Omit<
|
|
|
149
161
|
"aria-label": string;
|
|
150
162
|
/** Категории — `MegaMenuCategory`, прямыми детьми (фрагменты допустимы). */
|
|
151
163
|
children?: ReactNode;
|
|
152
|
-
/** Открытая Категория — управляемо. `null` —
|
|
164
|
+
/** Открытая Категория — управляемо. `null` — Полка закрыта. */
|
|
153
165
|
value?: string | null;
|
|
154
166
|
/** Открытая Категория при монтировании, без управления. */
|
|
155
167
|
defaultValue?: string | null;
|
|
156
|
-
/** Открылась другая Категория или
|
|
168
|
+
/** Открылась другая Категория или Полка закрылась. */
|
|
157
169
|
onValueChange?: (value: string | null) => void;
|
|
158
170
|
/**
|
|
159
|
-
* Ширина
|
|
171
|
+
* Ширина Полки — длина CSS или пиксели. Одна на все Категории: каталог не
|
|
160
172
|
* должен прыгать, когда у соседних Категорий разный объём.
|
|
161
173
|
*/
|
|
162
174
|
showcaseWidth?: number | string;
|
|
163
175
|
}
|
|
164
176
|
|
|
165
177
|
/**
|
|
166
|
-
* Мегаменю в духе Amazon: Столбец категорий слева,
|
|
178
|
+
* Мегаменю в духе Amazon: Столбец категорий слева, Полка наведённой
|
|
167
179
|
* Категории справа.
|
|
168
180
|
*
|
|
169
|
-
* СОСТАВНОЙ API, как у `Menu`:
|
|
181
|
+
* СОСТАВНОЙ API, как у `Menu`: Полка разнородна — колонки ссылок, промо,
|
|
170
182
|
* своё содержимое, — и описывать её массивом значило бы завести второй язык
|
|
171
183
|
* разметки рядом с JSX.
|
|
172
184
|
*
|
|
173
|
-
*
|
|
185
|
+
* Полка — потомок своей Категории в DOM, а не портал. Отсюда сразу три
|
|
174
186
|
* следствия, и все нужные: порядок Tab идёт «Категория → её ссылки → дальше
|
|
175
|
-
* по странице», уход указателя из Столбца в
|
|
176
|
-
* меню, а
|
|
187
|
+
* по странице», уход указателя из Столбца в Полку не считается уходом из
|
|
188
|
+
* меню, а Полка встаёт вплотную к Столбцу без расчёта позиции — она
|
|
177
189
|
* `absolute` от корня.
|
|
178
190
|
*/
|
|
179
191
|
export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu(
|
|
@@ -194,7 +206,7 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
194
206
|
) {
|
|
195
207
|
const baseId = useId();
|
|
196
208
|
const rootRef = useRef<HTMLElement | null>(null);
|
|
197
|
-
// Внутри `MegaMenuTrigger` Столбец и
|
|
209
|
+
// Внутри `MegaMenuTrigger` Столбец и Полка прокручиваются в оверлее сами.
|
|
198
210
|
const inOverlay = useContext(MegaMenuTriggerContext) !== null;
|
|
199
211
|
const [innerValue, setInnerValue] = useState<string | null>(defaultValue);
|
|
200
212
|
const controlled = valueProp !== undefined;
|
|
@@ -204,10 +216,54 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
204
216
|
const returnFocus = useRef<string | null>(null);
|
|
205
217
|
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
206
218
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
219
|
+
/*
|
|
220
|
+
КАТЕГОРИИ СООБЩАЮТ О СЕБЕ САМИ, а не разбираются по типу детей. Разбор
|
|
221
|
+
`child.type === MegaMenuCategory` видел только прямых детей и ломался
|
|
222
|
+
дважды: на Категориях, обёрнутых своим компонентом, и на пришедших из
|
|
223
|
+
серверного компонента Next.js — там тип элемента на клиенте ленивая
|
|
224
|
+
обёртка, а не сама функция. Порядок записей — порядок монтирования, то есть
|
|
225
|
+
порядок строк в DOM.
|
|
226
|
+
*/
|
|
227
|
+
const [registry, setRegistry] = useState<ReadonlyMap<string, CategoryRecord>>(() => new Map());
|
|
228
|
+
const register = useCallback((id: string, record: CategoryRecord) => {
|
|
229
|
+
setRegistry((prev) => {
|
|
230
|
+
const old = prev.get(id);
|
|
231
|
+
if (
|
|
232
|
+
old &&
|
|
233
|
+
old.hasShowcase === record.hasShowcase &&
|
|
234
|
+
old.disabled === record.disabled &&
|
|
235
|
+
old.active === record.active
|
|
236
|
+
)
|
|
237
|
+
return prev;
|
|
238
|
+
// `set` по существующему ключу место записи не меняет.
|
|
239
|
+
return new Map(prev).set(id, record);
|
|
240
|
+
});
|
|
241
|
+
}, []);
|
|
242
|
+
const unregister = useCallback((id: string) => {
|
|
243
|
+
setRegistry((prev) => {
|
|
244
|
+
if (!prev.has(id)) return prev;
|
|
245
|
+
const next = new Map(prev);
|
|
246
|
+
next.delete(id);
|
|
247
|
+
return next;
|
|
248
|
+
});
|
|
249
|
+
}, []);
|
|
250
|
+
|
|
251
|
+
const tabbable = (id: string | null) =>
|
|
252
|
+
id !== null && registry.has(id) && !registry.get(id)?.disabled ? id : null;
|
|
253
|
+
let firstEnabled: string | null = null;
|
|
254
|
+
let activeId: string | null = null;
|
|
255
|
+
for (const [id, record] of registry) {
|
|
256
|
+
if (record.disabled) continue;
|
|
257
|
+
firstEnabled ??= id;
|
|
258
|
+
if (record.active) activeId ??= id;
|
|
259
|
+
}
|
|
260
|
+
// Остановка Tab: где пользователь был последним, иначе открытая, иначе
|
|
261
|
+
// текущий раздел, иначе первая. Ключ, которого нет среди Категорий (чужой
|
|
262
|
+
// `value`, убранная строка), пропускается: остановка не должна пропасть.
|
|
263
|
+
const tabStop =
|
|
264
|
+
registry.size === 0
|
|
265
|
+
? undefined
|
|
266
|
+
: (tabbable(focusedId) ?? tabbable(value) ?? activeId ?? firstEnabled);
|
|
211
267
|
|
|
212
268
|
const valueRef = useRef(value);
|
|
213
269
|
valueRef.current = value;
|
|
@@ -225,9 +281,9 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
225
281
|
|
|
226
282
|
/*
|
|
227
283
|
Смена или закрытие по указателю (уход мыши, отложенная смена Прицела) не
|
|
228
|
-
должны терять фокус: если он внутри уходящей
|
|
284
|
+
должны терять фокус: если он внутри уходящей Полки, её содержимое
|
|
229
285
|
размонтируется, и фокус упал бы на `body` посреди работы с клавиатуры. Тогда
|
|
230
|
-
он возвращается на Категорию этой
|
|
286
|
+
он возвращается на Категорию этой Полки — через тот же эффект, что у «Назад».
|
|
231
287
|
*/
|
|
232
288
|
const switchByPointer = useCallback(
|
|
233
289
|
(next: string | null) => {
|
|
@@ -256,7 +312,7 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
256
312
|
);
|
|
257
313
|
}, []);
|
|
258
314
|
|
|
259
|
-
// Прицелу нужна открытая
|
|
315
|
+
// Прицелу нужна открытая Полка; ищется по id, а не ref'ом: её рисует Категория.
|
|
260
316
|
const showcaseRef = useMemo(
|
|
261
317
|
() => ({
|
|
262
318
|
get current(): HTMLElement | null {
|
|
@@ -268,31 +324,22 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
268
324
|
const narrow = useNarrowScreen();
|
|
269
325
|
const narrowRef = useRef(narrow);
|
|
270
326
|
narrowRef.current = narrow;
|
|
271
|
-
// Спуск возможен лишь в Категорию с
|
|
327
|
+
// Спуск возможен лишь в Категорию с Полкой: `value` с чужим ключом или
|
|
272
328
|
// Категория без детей Столбец не прячут — иначе не осталось бы ничего.
|
|
273
|
-
// Категории, обёрнутые своим компонентом, отсюда не видны — тогда спуск
|
|
274
|
-
// доверяет `value`: открыть можно только Категорию с Витриной.
|
|
275
329
|
const drilledId =
|
|
276
|
-
narrow && value !== null
|
|
277
|
-
? categories.length === 0
|
|
278
|
-
? value
|
|
279
|
-
: (categories.find(
|
|
280
|
-
(category) =>
|
|
281
|
-
category.props.id === value && Children.toArray(category.props.children).length > 0
|
|
282
|
-
)?.props.id ?? null)
|
|
283
|
-
: null;
|
|
330
|
+
narrow && value !== null && registry.get(value)?.hasShowcase === true ? value : null;
|
|
284
331
|
|
|
285
332
|
const aim = useMenuAim({ value, showcaseRef, commit: switchByPointer });
|
|
286
333
|
const { cancel: cancelAim, request: requestAim } = aim;
|
|
287
334
|
|
|
288
335
|
/*
|
|
289
|
-
ПЕРЕВОРОТ У КРАЯ.
|
|
336
|
+
ПЕРЕВОРОТ У КРАЯ. Полка встаёт со стороны конца строки; если там не
|
|
290
337
|
хватает места, а с другой стороны хватает — она переходит туда. Не хватает
|
|
291
338
|
ни там, ни там — остаётся на своей стороне: `max-w` её ужмёт.
|
|
292
339
|
|
|
293
340
|
Меряется ДО открытия, на монтировании и при смене размера окна, а не по
|
|
294
|
-
открытой
|
|
295
|
-
открытии с клавиатуры. Ширину даёт зонд с той же длиной, что у
|
|
341
|
+
открытой Полке: стрелка «к Полке» должна знать сторону уже при первом
|
|
342
|
+
открытии с клавиатуры. Ширину даёт зонд с той же длиной, что у Полки, —
|
|
296
343
|
длина приходит строкой CSS (`40rem`, `min(…)`), и посчитать её может только
|
|
297
344
|
браузер.
|
|
298
345
|
*/
|
|
@@ -301,7 +348,7 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
301
348
|
Замер — функция, а не только эффект: в выпадающем виде `nav` монтируется
|
|
302
349
|
раньше, чем Radix расставит оверлей, и замер на монтировании видит его не
|
|
303
350
|
там, где он окажется. Поэтому сторона перемеряется ещё и при открытии
|
|
304
|
-
Категории и прямо перед решением, какая стрелка ведёт к
|
|
351
|
+
Категории и прямо перед решением, какая стрелка ведёт к Полке.
|
|
305
352
|
*/
|
|
306
353
|
const measureFlip = useCallback((): boolean => {
|
|
307
354
|
const root = rootRef.current;
|
|
@@ -332,20 +379,14 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
332
379
|
// работа на горячем пути.
|
|
333
380
|
}, [measureFlip, showcaseWidth, value === null]);
|
|
334
381
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
useLayoutEffect(() => {
|
|
344
|
-
const nodes = categoryNodes(true);
|
|
345
|
-
if (nodes.length === 0 || nodes.some((node) => node.tabIndex === 0)) return;
|
|
346
|
-
const first = nodes.find((node) => node.getAttribute("aria-disabled") !== "true");
|
|
347
|
-
if (first?.dataset.megaCategory) setFocusedId(first.dataset.megaCategory);
|
|
348
|
-
});
|
|
382
|
+
/** Набранное поиском по первой букве и таймер, после которого слово начинается заново. */
|
|
383
|
+
const typeahead = useRef({ text: "", timer: null as ReturnType<typeof setTimeout> | null });
|
|
384
|
+
useEffect(
|
|
385
|
+
() => () => {
|
|
386
|
+
if (typeahead.current.timer) clearTimeout(typeahead.current.timer);
|
|
387
|
+
},
|
|
388
|
+
[]
|
|
389
|
+
);
|
|
349
390
|
|
|
350
391
|
const focusCategory = useCallback(
|
|
351
392
|
(id: string) => {
|
|
@@ -359,11 +400,11 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
359
400
|
const openCategory = (id: string, focusShowcase = false) => {
|
|
360
401
|
cancelAim();
|
|
361
402
|
// На узком экране кнопка Категории исчезает вместе со Столбцом: фокус,
|
|
362
|
-
// оставшийся на ней, упал бы на `body`, поэтому он идёт в
|
|
403
|
+
// оставшийся на ней, упал бы на `body`, поэтому он идёт в Полку всегда.
|
|
363
404
|
const intoShowcase = focusShowcase || narrowRef.current;
|
|
364
405
|
if (intoShowcase) pendingFocus.current = id;
|
|
365
406
|
// Уже открыта наведением — значение не меняется, и эффект Категории,
|
|
366
|
-
// который переводит фокус в
|
|
407
|
+
// который переводит фокус в Полку, не сработает. Переводим сами.
|
|
367
408
|
if (intoShowcase && valueRef.current === id) {
|
|
368
409
|
pendingFocus.current = null;
|
|
369
410
|
document
|
|
@@ -379,6 +420,42 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
379
420
|
setValue(null);
|
|
380
421
|
};
|
|
381
422
|
|
|
423
|
+
/*
|
|
424
|
+
ПОИСК ПО ПЕРВОЙ БУКВЕ, как в `Menu`: длинный Столбец не листают стрелками
|
|
425
|
+
по строке. Буквы, набранные подряд, ищут начало подписи; одна и та же
|
|
426
|
+
буква подряд ведёт по Категориям на неё по кругу. Пауза сбрасывает
|
|
427
|
+
набранное. Буква с модификатором — сочетание клавиш, его не трогаем.
|
|
428
|
+
*/
|
|
429
|
+
const matchTypeahead = (
|
|
430
|
+
event: KeyboardEvent<HTMLElement>,
|
|
431
|
+
nodes: HTMLElement[],
|
|
432
|
+
index: number
|
|
433
|
+
): HTMLElement | undefined => {
|
|
434
|
+
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey)
|
|
435
|
+
return undefined;
|
|
436
|
+
const state = typeahead.current;
|
|
437
|
+
if (state.timer) clearTimeout(state.timer);
|
|
438
|
+
state.timer = setTimeout(() => {
|
|
439
|
+
state.text = "";
|
|
440
|
+
state.timer = null;
|
|
441
|
+
}, TYPEAHEAD_RESET_MS);
|
|
442
|
+
const key = event.key.toLocaleLowerCase();
|
|
443
|
+
// После хода по кругу («b», «b») новая буква — новое слово, а не «bbg».
|
|
444
|
+
const cycling = state.text.length > 1 && [...state.text].every((c) => c === state.text[0]);
|
|
445
|
+
state.text = cycling && key !== state.text[0] ? key : state.text + key;
|
|
446
|
+
const repeated = [...state.text].every((char) => char === state.text[0]);
|
|
447
|
+
const query = repeated ? key : state.text;
|
|
448
|
+
// Новая буква ищется со следующей строки, продолжение слова — с текущей.
|
|
449
|
+
const start = repeated ? index + 1 : Math.max(index, 0);
|
|
450
|
+
const ordered = [...nodes.slice(start), ...nodes.slice(0, start)];
|
|
451
|
+
return ordered.find((node) =>
|
|
452
|
+
(node.querySelector("[data-mega-label]")?.textContent ?? "")
|
|
453
|
+
.trim()
|
|
454
|
+
.toLocaleLowerCase()
|
|
455
|
+
.startsWith(query)
|
|
456
|
+
);
|
|
457
|
+
};
|
|
458
|
+
|
|
382
459
|
const onCategoryKeyDown = (
|
|
383
460
|
event: KeyboardEvent<HTMLElement>,
|
|
384
461
|
id: string,
|
|
@@ -387,6 +464,13 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
387
464
|
const node = event.currentTarget;
|
|
388
465
|
const enabled = categoryNodes();
|
|
389
466
|
const index = enabled.indexOf(node);
|
|
467
|
+
// У ссылки-Категории Enter — переход, Пробел — прокрутка страницы:
|
|
468
|
+
// обе роли браузерные, отнимать их незачем.
|
|
469
|
+
const openFromKey = () => {
|
|
470
|
+
if (!hasShowcase || node.getAttribute("aria-disabled") === "true") return;
|
|
471
|
+
event.preventDefault();
|
|
472
|
+
openCategory(id, true);
|
|
473
|
+
};
|
|
390
474
|
let target: HTMLElement | undefined;
|
|
391
475
|
switch (event.key) {
|
|
392
476
|
case "ArrowDown":
|
|
@@ -407,30 +491,40 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
407
491
|
close();
|
|
408
492
|
}
|
|
409
493
|
return;
|
|
410
|
-
case "Enter":
|
|
411
494
|
case " ":
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
if (
|
|
495
|
+
// Пробел посреди набранного — часть подписи («Home and garden»), а
|
|
496
|
+
// не открытие Категории.
|
|
497
|
+
if (typeahead.current.text === "") {
|
|
498
|
+
openFromKey();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// Не открываем и без совпадения: кнопка сработала бы на отпускании.
|
|
415
502
|
event.preventDefault();
|
|
416
|
-
|
|
503
|
+
target = matchTypeahead(event, enabled, index);
|
|
504
|
+
if (!target) return;
|
|
505
|
+
break;
|
|
506
|
+
case "Enter":
|
|
507
|
+
openFromKey();
|
|
417
508
|
return;
|
|
418
509
|
default:
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
510
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
511
|
+
if (
|
|
512
|
+
hasShowcase &&
|
|
513
|
+
event.key === forwardKey(node, measureFlip()) &&
|
|
514
|
+
node.getAttribute("aria-disabled") !== "true"
|
|
515
|
+
) {
|
|
516
|
+
event.preventDefault();
|
|
517
|
+
openCategory(id, true);
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
427
520
|
}
|
|
428
|
-
|
|
521
|
+
target = matchTypeahead(event, enabled, index);
|
|
522
|
+
if (!target) return;
|
|
429
523
|
}
|
|
430
524
|
event.preventDefault();
|
|
431
525
|
if (!target || target === node) return;
|
|
432
526
|
target.focus();
|
|
433
|
-
//
|
|
527
|
+
// Полка открыта — она следует за фокусом, как следует за наведением:
|
|
434
528
|
// иначе подсвечена одна Категория, а открыта другая.
|
|
435
529
|
if (valueRef.current !== null) {
|
|
436
530
|
const targetId = target.dataset.megaCategory ?? null;
|
|
@@ -440,7 +534,7 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
440
534
|
};
|
|
441
535
|
|
|
442
536
|
// Закрытие и возврат фокуса — в таком порядке и через эффект Категории:
|
|
443
|
-
// пока
|
|
537
|
+
// пока Полка заменяет Столбец, кнопка Категории скрыта, и сфокусировать
|
|
444
538
|
// её можно только после того, как Столбец вернулся на место.
|
|
445
539
|
const back = (id: string) => {
|
|
446
540
|
returnFocus.current = id;
|
|
@@ -478,9 +572,11 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
478
572
|
baseId,
|
|
479
573
|
value,
|
|
480
574
|
tabStop,
|
|
575
|
+
register,
|
|
576
|
+
unregister,
|
|
481
577
|
openCategory,
|
|
482
578
|
close,
|
|
483
|
-
// Прицела на узком экране нет:
|
|
579
|
+
// Прицела на узком экране нет: Полку открывает только щелчок или касание.
|
|
484
580
|
hoverCategory: narrow ? () => {} : requestAim,
|
|
485
581
|
flipped,
|
|
486
582
|
narrow,
|
|
@@ -497,6 +593,8 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
497
593
|
baseId,
|
|
498
594
|
value,
|
|
499
595
|
tabStop,
|
|
596
|
+
register,
|
|
597
|
+
unregister,
|
|
500
598
|
flipped,
|
|
501
599
|
narrow,
|
|
502
600
|
drilledId,
|
|
@@ -509,7 +607,7 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
509
607
|
measureFlip,
|
|
510
608
|
]);
|
|
511
609
|
|
|
512
|
-
// Щелчок или касание вне каталога закрывают
|
|
610
|
+
// Щелчок или касание вне каталога закрывают Полку. Уход указателя этого
|
|
513
611
|
// не покрывает: у касания ухода нет.
|
|
514
612
|
useEffect(() => {
|
|
515
613
|
if (value === null) return;
|
|
@@ -538,7 +636,7 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
538
636
|
onPointerLeave={(event: PointerEvent<HTMLElement>) => {
|
|
539
637
|
onPointerLeave?.(event);
|
|
540
638
|
if (event.pointerType === "touch" || event.pointerType === "pen") return;
|
|
541
|
-
// Не сразу: курсор, на миг вышедший за край
|
|
639
|
+
// Не сразу: курсор, на миг вышедший за край Полки или в зазор у
|
|
542
640
|
// Столбца, не должен сбрасывать каталог.
|
|
543
641
|
cancelLeave();
|
|
544
642
|
leaveTimer.current = setTimeout(() => {
|
|
@@ -551,9 +649,9 @@ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu
|
|
|
551
649
|
}}
|
|
552
650
|
onBlur={(event: FocusEvent<HTMLElement>) => {
|
|
553
651
|
onBlur?.(event);
|
|
554
|
-
// Фокус ушёл со страницы каталога (Tab за последней ссылкой) —
|
|
652
|
+
// Фокус ушёл со страницы каталога (Tab за последней ссылкой) — Полка
|
|
555
653
|
// закрывается. `relatedTarget` пуст при щелчке по нефокусируемому
|
|
556
|
-
// месту внутри
|
|
654
|
+
// месту внутри Полки: это не уход, а его закрывает щелчок снаружи.
|
|
557
655
|
const next = event.relatedTarget;
|
|
558
656
|
if (next instanceof Node && !event.currentTarget.contains(next)) setValue(null);
|
|
559
657
|
}}
|
|
@@ -574,8 +672,8 @@ const COLUMN_CLASS =
|
|
|
574
672
|
|
|
575
673
|
/**
|
|
576
674
|
* Строка Категории. Подсветка — по `aria-expanded`, а не по `:hover`:
|
|
577
|
-
* подсвечено ровно то, чья
|
|
578
|
-
* соседней Категорией, а
|
|
675
|
+
* подсвечено ровно то, чья Полка открыта. При «Прицеле» курсор стоит над
|
|
676
|
+
* соседней Категорией, а Полка ещё прежняя — две подсветки разом читались бы
|
|
579
677
|
* как два выбора.
|
|
580
678
|
*/
|
|
581
679
|
const CATEGORY_CLASS =
|
|
@@ -583,9 +681,12 @@ const CATEGORY_CLASS =
|
|
|
583
681
|
"px-2.5 py-2 text-start text-[13px] leading-5 no-underline select-none " +
|
|
584
682
|
"focus-visible:outline-accent-strong focus-visible:outline-2 focus-visible:-outline-offset-2 " +
|
|
585
683
|
"aria-expanded:bg-bg-2 aria-expanded:font-semibold " +
|
|
684
|
+
// Текущий раздел — цветом, как текущая ссылка Полки; открытая — фоном: это
|
|
685
|
+
// два разных признака, и у одной строки бывают оба.
|
|
686
|
+
"aria-[current=true]:text-accent-strong " +
|
|
586
687
|
"aria-disabled:cursor-default aria-disabled:opacity-50";
|
|
587
688
|
|
|
588
|
-
/** Ссылка-Категория без
|
|
689
|
+
/** Ссылка-Категория без Полки подсвечивается наведением: открывать ей нечего. */
|
|
589
690
|
const CATEGORY_LINK_CLASS = "hover:bg-bg-2";
|
|
590
691
|
|
|
591
692
|
const CATEGORY_ICON_CLASS = "text-fg-2 flex shrink-0";
|
|
@@ -596,7 +697,7 @@ const CATEGORY_LABEL_CLASS = "min-w-0 flex-1 break-words";
|
|
|
596
697
|
const CATEGORY_CHEVRON_CLASS = "text-fg-3 flex shrink-0 rtl:rotate-180";
|
|
597
698
|
|
|
598
699
|
/**
|
|
599
|
-
*
|
|
700
|
+
* Полка: вплотную к Столбцу со стороны конца строки, не ниже его, одна ширина
|
|
600
701
|
* на все Категории. Поверхность и тень — как у панели `Menu`: три меню кита
|
|
601
702
|
* должны читаться одной системой.
|
|
602
703
|
*/
|
|
@@ -605,11 +706,11 @@ const SHOWCASE_CLASS =
|
|
|
605
706
|
"w-[var(--mega-menu-showcase-w)] max-w-[calc(100vw-2rem)] rounded-md border p-4 " +
|
|
606
707
|
`shadow-[var(--shadow-2)] ${OVERLAY_LAYER.popover}`;
|
|
607
708
|
|
|
608
|
-
/** Сторона
|
|
709
|
+
/** Сторона Полки: со стороны конца строки или, перевёрнутая у края, — начала. */
|
|
609
710
|
const SHOWCASE_SIDE = { end: "start-full ms-1", start: "end-full me-1" } as const;
|
|
610
711
|
|
|
611
712
|
/**
|
|
612
|
-
*
|
|
713
|
+
* Полка вместо Столбца на узком экране: обычный блок на всю ширину, без
|
|
613
714
|
* собственной поверхности. Рамка и фон — у Столбца, внутри которого она стоит.
|
|
614
715
|
*/
|
|
615
716
|
const SHOWCASE_DRILLED_CLASS = "w-full p-3";
|
|
@@ -628,39 +729,46 @@ const SHOWCASE_HEADING_CLASS =
|
|
|
628
729
|
|
|
629
730
|
const SHOWCASE_GRID_CLASS = "grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-6 gap-y-5";
|
|
630
731
|
|
|
631
|
-
/** Колонки ссылок в заменившей Столбец
|
|
732
|
+
/** Колонки ссылок в заменившей Столбец Полке идут одна под другой: рядом им тесно. */
|
|
632
733
|
const SHOWCASE_GRID_DRILLED_CLASS = "grid grid-cols-1 gap-y-5";
|
|
633
734
|
|
|
634
735
|
export interface MegaMenuCategoryProps {
|
|
635
736
|
/** Ключ Категории — им же её открывают через `value`. */
|
|
636
737
|
id: string;
|
|
637
|
-
/** Подпись в Столбце; она же — доступное имя
|
|
738
|
+
/** Подпись в Столбце; она же — доступное имя Полки. */
|
|
638
739
|
label: ReactNode;
|
|
639
740
|
/** Иконка слева от подписи; скрыта от скринридера. */
|
|
640
741
|
icon?: ReactNode;
|
|
641
742
|
/**
|
|
642
|
-
* Адрес раздела целиком. С
|
|
643
|
-
*
|
|
743
|
+
* Адрес раздела целиком. С Полкой — заголовок-ссылка в её начале, без
|
|
744
|
+
* Полки — сама строка Столбца становится ссылкой.
|
|
644
745
|
*/
|
|
645
746
|
href?: string;
|
|
646
747
|
/**
|
|
647
748
|
* Ссылка роутера вместо `href`: пустой элемент (`<Link href="/x" />`),
|
|
648
749
|
* подпись уезжает внутрь. Отдельным пропом, а не `asChild`: дети Категории —
|
|
649
|
-
* это её
|
|
750
|
+
* это её Полка.
|
|
650
751
|
*/
|
|
651
752
|
link?: ReactElement;
|
|
652
753
|
/** Видна, но не открывается и пропускается стрелками. */
|
|
653
754
|
disabled?: boolean;
|
|
654
|
-
/**
|
|
755
|
+
/**
|
|
756
|
+
* Текущая страница лежит в этом разделе: `aria-current="true"` и вид
|
|
757
|
+
* текущей, открыта Полка или нет. Ничего не открывает; без открытой Полки
|
|
758
|
+
* Tab приходит в Столбец на эту Категорию. Вычисляет потребитель, как
|
|
759
|
+
* `active` у `NavItem`: правила сравнения адресов у каждого роутера свои.
|
|
760
|
+
*/
|
|
761
|
+
active?: boolean;
|
|
762
|
+
/** Содержимое Полки: `MegaMenuGroup` и что угодно своё. Без детей Полки нет. */
|
|
655
763
|
children?: ReactNode;
|
|
656
764
|
/** Классы на строку Столбца. */
|
|
657
765
|
className?: string;
|
|
658
766
|
}
|
|
659
767
|
|
|
660
768
|
/**
|
|
661
|
-
* Категория Столбца и её
|
|
769
|
+
* Категория Столбца и её Полка.
|
|
662
770
|
*
|
|
663
|
-
* Содержимое
|
|
771
|
+
* Содержимое Полки монтируется только у открытой Категории: каталог из
|
|
664
772
|
* сотни ссылок и картинок не должен грузить всё разом. Сам регион при этом
|
|
665
773
|
* есть всегда (скрытым): на него указывает `aria-controls` кнопки.
|
|
666
774
|
*/
|
|
@@ -671,6 +779,7 @@ export function MegaMenuCategory({
|
|
|
671
779
|
href,
|
|
672
780
|
link,
|
|
673
781
|
disabled = false,
|
|
782
|
+
active = false,
|
|
674
783
|
children,
|
|
675
784
|
className,
|
|
676
785
|
}: MegaMenuCategoryProps) {
|
|
@@ -686,7 +795,17 @@ export function MegaMenuCategory({
|
|
|
686
795
|
const drilled = menu.drilledId === id;
|
|
687
796
|
const coveredByOther = menu.drilledId !== null && !drilled;
|
|
688
797
|
|
|
689
|
-
//
|
|
798
|
+
// Сообщить Столбцу о себе. Снятие — отдельным эффектом по `id`: смена
|
|
799
|
+
// `disabled` или `active` обновляет запись на её месте, порядок строк не
|
|
800
|
+
// сбивается.
|
|
801
|
+
const { register, unregister } = menu;
|
|
802
|
+
useLayoutEffect(() => () => unregister(id), [id, unregister]);
|
|
803
|
+
useLayoutEffect(
|
|
804
|
+
() => register(id, { hasShowcase, disabled, active }),
|
|
805
|
+
[id, hasShowcase, disabled, active, register]
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
// Переход в Полку с клавиатуры или касанием: фокус на её первый фокусируемый элемент,
|
|
690
809
|
// как только содержимое смонтировалось.
|
|
691
810
|
useEffect(() => {
|
|
692
811
|
if (!open || menu.pendingFocus.current !== id) return;
|
|
@@ -705,14 +824,17 @@ export function MegaMenuCategory({
|
|
|
705
824
|
id: categoryId,
|
|
706
825
|
"data-mega-category": id,
|
|
707
826
|
"data-mega-showcase": hasShowcase ? "true" : "false",
|
|
708
|
-
|
|
827
|
+
// До того как Категории сообщили о себе (сервер, первая отрисовка), остановка
|
|
828
|
+
// Tab у каждой доступной: какая из них первая, Столбец ещё не знает.
|
|
829
|
+
tabIndex: (menu.tabStop === undefined ? !disabled : menu.tabStop === id) ? 0 : -1,
|
|
709
830
|
"aria-disabled": disabled || undefined,
|
|
831
|
+
"aria-current": active ? ("true" as const) : undefined,
|
|
710
832
|
onFocus: () => menu.onCategoryFocus(id),
|
|
711
833
|
onKeyDown: (event: KeyboardEvent<HTMLElement>) =>
|
|
712
834
|
menu.onCategoryKeyDown(event, id, hasShowcase),
|
|
713
835
|
onPointerEnter: (event: PointerEvent<HTMLElement>) => {
|
|
714
836
|
if (disabled || event.pointerType === "touch" || event.pointerType === "pen") return;
|
|
715
|
-
// Решает Прицел: курсор, идущий к открытой
|
|
837
|
+
// Решает Прицел: курсор, идущий к открытой Полке наискосок, задевает
|
|
716
838
|
// соседние строки — они не должны её перехватывать.
|
|
717
839
|
menu.hoverCategory(hasShowcase ? id : null);
|
|
718
840
|
},
|
|
@@ -725,7 +847,9 @@ export function MegaMenuCategory({
|
|
|
725
847
|
{icon}
|
|
726
848
|
</span>
|
|
727
849
|
)}
|
|
728
|
-
<span className={CATEGORY_LABEL_CLASS}>
|
|
850
|
+
<span data-mega-label="" className={CATEGORY_LABEL_CLASS}>
|
|
851
|
+
{label}
|
|
852
|
+
</span>
|
|
729
853
|
</>
|
|
730
854
|
);
|
|
731
855
|
|
|
@@ -755,7 +879,7 @@ export function MegaMenuCategory({
|
|
|
755
879
|
<button
|
|
756
880
|
{...shared}
|
|
757
881
|
ref={buttonRef}
|
|
758
|
-
// Скрыта, пока
|
|
882
|
+
// Скрыта, пока Полка занимает её место: `aria-expanded` и
|
|
759
883
|
// `aria-controls` остаются в DOM, а имя региону даёт `aria-labelledby`.
|
|
760
884
|
hidden={drilled}
|
|
761
885
|
type="button"
|
|
@@ -764,7 +888,7 @@ export function MegaMenuCategory({
|
|
|
764
888
|
className={cn(CATEGORY_CLASS, className)}
|
|
765
889
|
onClick={() => {
|
|
766
890
|
// Открывает, а не переключает: повторное касание открытой Категории
|
|
767
|
-
// не должно прятать
|
|
891
|
+
// не должно прятать Полку, которую человек только что открыл.
|
|
768
892
|
if (!disabled) menu.openCategory(id);
|
|
769
893
|
}}
|
|
770
894
|
>
|
|
@@ -855,7 +979,15 @@ export interface MegaMenuGroupProps {
|
|
|
855
979
|
title: ReactNode;
|
|
856
980
|
/** Адрес подраздела целиком: заголовок становится ссылкой. */
|
|
857
981
|
href?: string;
|
|
858
|
-
/**
|
|
982
|
+
/**
|
|
983
|
+
* Ссылка роутера вместо `href`: пустой элемент (`<Link href="/x" />`),
|
|
984
|
+
* заголовок уезжает внутрь — как `link` у Категории.
|
|
985
|
+
*/
|
|
986
|
+
link?: ReactElement;
|
|
987
|
+
/**
|
|
988
|
+
* Ссылки колонки — `MegaMenuLink`. Каждая уезжает в свой пункт списка,
|
|
989
|
+
* в том числе пришедшие фрагментом.
|
|
990
|
+
*/
|
|
859
991
|
children?: ReactNode;
|
|
860
992
|
/** Классы на колонку. */
|
|
861
993
|
className?: string;
|
|
@@ -866,17 +998,22 @@ const GROUP_TITLE_LINK_CLASS = "hover:text-accent-strong text-inherit no-underli
|
|
|
866
998
|
const GROUP_LIST_CLASS = "m-0 flex list-none flex-col p-0";
|
|
867
999
|
|
|
868
1000
|
/**
|
|
869
|
-
* Колонка
|
|
1001
|
+
* Колонка Полки: заголовок и список ссылок под ним.
|
|
870
1002
|
*
|
|
871
1003
|
* Список подписан заголовком (`aria-labelledby`): скринридер объявляет
|
|
872
|
-
* «Смартфоны, список, 5 пунктов», а не безымянный список посреди
|
|
1004
|
+
* «Смартфоны, список, 5 пунктов», а не безымянный список посреди Полки.
|
|
873
1005
|
*/
|
|
874
|
-
export function MegaMenuGroup({ title, href, children, className }: MegaMenuGroupProps) {
|
|
1006
|
+
export function MegaMenuGroup({ title, href, link, children, className }: MegaMenuGroupProps) {
|
|
875
1007
|
const titleId = useId();
|
|
876
1008
|
return (
|
|
877
1009
|
<div className={cn("min-w-0", className)}>
|
|
878
1010
|
<div id={titleId} className={GROUP_TITLE_CLASS}>
|
|
879
|
-
{
|
|
1011
|
+
{link != null ? (
|
|
1012
|
+
<Slot className={GROUP_TITLE_LINK_CLASS}>
|
|
1013
|
+
{title}
|
|
1014
|
+
<Slottable>{link}</Slottable>
|
|
1015
|
+
</Slot>
|
|
1016
|
+
) : href != null ? (
|
|
880
1017
|
<a href={href} className={GROUP_TITLE_LINK_CLASS}>
|
|
881
1018
|
{title}
|
|
882
1019
|
</a>
|
|
@@ -885,7 +1022,10 @@ export function MegaMenuGroup({ title, href, children, className }: MegaMenuGrou
|
|
|
885
1022
|
)}
|
|
886
1023
|
</div>
|
|
887
1024
|
<ul aria-labelledby={titleId} className={GROUP_LIST_CLASS}>
|
|
888
|
-
{
|
|
1025
|
+
{/* Ключ — позиция: список статичен, а ключи из разных фрагментов совпали бы. */}
|
|
1026
|
+
{flattenFragments(children).map((child, index) => (
|
|
1027
|
+
<li key={index}>{child}</li>
|
|
1028
|
+
))}
|
|
889
1029
|
</ul>
|
|
890
1030
|
</div>
|
|
891
1031
|
);
|
|
@@ -893,6 +1033,11 @@ export function MegaMenuGroup({ title, href, children, className }: MegaMenuGrou
|
|
|
893
1033
|
|
|
894
1034
|
export interface MegaMenuLinkProps extends ComponentPropsWithoutRef<"a"> {
|
|
895
1035
|
/** Ссылка ведёт на текущую страницу: `aria-current="page"` и вид текущей. */
|
|
1036
|
+
active?: boolean;
|
|
1037
|
+
/**
|
|
1038
|
+
* @deprecated Используйте `active`: текущий пункт навигации в ките — `active`,
|
|
1039
|
+
* как у `NavItem`. Алиас работает до 5.0.
|
|
1040
|
+
*/
|
|
896
1041
|
current?: boolean;
|
|
897
1042
|
/** Иконка слева от подписи; скрыта от скринридера. */
|
|
898
1043
|
icon?: ReactNode;
|
|
@@ -910,20 +1055,20 @@ const LINK_CLASS =
|
|
|
910
1055
|
"aria-[current=page]:text-accent-strong aria-[current=page]:font-semibold";
|
|
911
1056
|
|
|
912
1057
|
/**
|
|
913
|
-
* Ссылка
|
|
1058
|
+
* Ссылка Полки.
|
|
914
1059
|
*
|
|
915
|
-
* Текущую страницу кит не вычисляет — проп `
|
|
916
|
-
*
|
|
1060
|
+
* Текущую страницу кит не вычисляет — проп `active`, как у `NavItem`:
|
|
1061
|
+
* правила сравнения адресов у каждого роутера свои (ADR-0018).
|
|
917
1062
|
*/
|
|
918
1063
|
export const MegaMenuLink = forwardRef<HTMLAnchorElement, MegaMenuLinkProps>(function MegaMenuLink(
|
|
919
|
-
{ current
|
|
1064
|
+
{ active, current, icon, asChild = false, children, className, ...rest },
|
|
920
1065
|
ref
|
|
921
1066
|
) {
|
|
922
1067
|
const Comp = asChild ? Slot : "a";
|
|
923
1068
|
return (
|
|
924
1069
|
<Comp
|
|
925
1070
|
ref={ref}
|
|
926
|
-
aria-current={current ? "page" : undefined}
|
|
1071
|
+
aria-current={(active ?? current) ? "page" : undefined}
|
|
927
1072
|
className={cn(LINK_CLASS, className)}
|
|
928
1073
|
{...rest}
|
|
929
1074
|
>
|
|
@@ -941,7 +1086,7 @@ export const MegaMenuLink = forwardRef<HTMLAnchorElement, MegaMenuLinkProps>(fun
|
|
|
941
1086
|
ВЫПАДАЮЩИЙ ВИД — ТА ЖЕ РАЗМЕТКА, ДРУГАЯ ОБЁРТКА.
|
|
942
1087
|
|
|
943
1088
|
`MegaMenu` внутри `MegaMenuTrigger` не меняется ни в одной строке: Столбец,
|
|
944
|
-
|
|
1089
|
+
Полка, roving tabindex, Escape в Полке — всё то же. Обёртка добавляет
|
|
945
1090
|
только то, чего у встроенного вида нет: кнопку, оверлей под ней и закрытие.
|
|
946
1091
|
|
|
947
1092
|
ОВЕРЛЕЙ — RADIX `Popover`, а не react-aria, как у `Menu`. Причин три.
|
|
@@ -968,20 +1113,35 @@ const DropdownPortal = withoutToolbarAxis(PopoverPrimitive.Portal);
|
|
|
968
1113
|
* Оверлей: слой `popover`, появление по шкале движения кита, прокрутка внутри.
|
|
969
1114
|
*
|
|
970
1115
|
* ПРОКРУТКА — У СТОЛБЦА И У ВИТРИНЫ ПО ОТДЕЛЬНОСТИ, а не у оверлея целиком.
|
|
971
|
-
*
|
|
972
|
-
* оверлее обрезал бы её по его рамке, и от
|
|
973
|
-
* в зазор. Поэтому высотой ограничены `ul` Столбца и `section`
|
|
1116
|
+
* Полка — `absolute` от `nav`, рядом со Столбцом. `overflow: auto` на самом
|
|
1117
|
+
* оверлее обрезал бы её по его рамке, и от Полки осталась бы полоска шириной
|
|
1118
|
+
* в зазор. Поэтому высотой ограничены `ul` Столбца и `section` Полки — классом
|
|
974
1119
|
* `OVERLAY_SCROLL_CLASS`, который ставит сам `MegaMenu`. `ul` не позиционирован,
|
|
975
|
-
* и содержащий блок
|
|
976
|
-
*
|
|
1120
|
+
* и содержащий блок Полки — `nav` выше него, так что `overflow` на `ul`
|
|
1121
|
+
* Полку не режет.
|
|
977
1122
|
*/
|
|
978
1123
|
const DROPDOWN_CONTENT_CLASS =
|
|
979
1124
|
`${OVERLAY_LAYER.popover} outline-none ` +
|
|
1125
|
+
/*
|
|
1126
|
+
Узкий экран — на всю ширину окна за вычетом отступов по 8px с краёв
|
|
1127
|
+
(`collisionPadding`).
|
|
1128
|
+
Обёртка Radix меряет оверлей по содержимому (`min-width: max-content`), и
|
|
1129
|
+
без явной ширины каталог на телефоне сжимался до самой длинной подписи:
|
|
1130
|
+
уже Столбца на десктопе, разный у каждой Категории при спуске, а длинная
|
|
1131
|
+
подпись без пробелов выталкивала его за край. Граница та же, что у
|
|
1132
|
+
`useNarrowScreen`: `sm` — 640px.
|
|
1133
|
+
|
|
1134
|
+
`100vw` считает и полосу прокрутки, если она не накладная (окно настольного
|
|
1135
|
+
браузера, суженное ниже 640px), — тогда оверлей вылез бы за край на её
|
|
1136
|
+
ширину. Потолок — ширина, которую Radix считает по видимой области:
|
|
1137
|
+
`available-width`.
|
|
1138
|
+
*/
|
|
1139
|
+
"max-sm:w-[calc(100vw-1rem)] max-sm:max-w-[var(--radix-popover-content-available-width)] " +
|
|
980
1140
|
// Появление и уход — токены, видимые гейту `prefers-reduced-motion` (ADR-0043, ADR-0047).
|
|
981
1141
|
"data-[state=open]:animate-fadein data-[state=closed]:animate-fadeout";
|
|
982
1142
|
|
|
983
1143
|
/**
|
|
984
|
-
* Прокрутка Столбца и
|
|
1144
|
+
* Прокрутка Столбца и Полки в оверлее: высота до края окна от кнопки
|
|
985
1145
|
* (`available-height` Radix). Классы ставит сам `MegaMenu`, узнав оверлей по
|
|
986
1146
|
* контексту, — а не оверлей селекторами по чужой разметке, которые молча
|
|
987
1147
|
* отвалились бы при первой её правке.
|
|
@@ -995,10 +1155,10 @@ const OVERLAY_SCROLL_CLASS =
|
|
|
995
1155
|
"max-h-[var(--radix-popover-content-available-height)] overflow-y-auto overscroll-contain " +
|
|
996
1156
|
"[@supports(-webkit-touch-callout:none)]:max-h-[min(24rem,60dvh)]!";
|
|
997
1157
|
|
|
998
|
-
/** Поле ввода внутри
|
|
1158
|
+
/** Поле ввода внутри Полки Escape оставляет себе — иначе закрываем оверлей. */
|
|
999
1159
|
const SHOWCASE_SELECTOR = "[data-mega-showcase-region]";
|
|
1000
1160
|
|
|
1001
|
-
/** Остановки Tab внутри оверлея: Столбец — одна, плюс ссылки открытой
|
|
1161
|
+
/** Остановки Tab внутри оверлея: Столбец — одна, плюс ссылки открытой Полки. */
|
|
1002
1162
|
function tabStops(root: HTMLElement): HTMLElement[] {
|
|
1003
1163
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
|
1004
1164
|
(node) => node.tabIndex >= 0 && !node.closest("[hidden]")
|
|
@@ -1094,7 +1254,7 @@ export function MegaMenuTrigger({
|
|
|
1094
1254
|
onOpenAutoFocus={(event) => {
|
|
1095
1255
|
tabbedAway.current = false;
|
|
1096
1256
|
// Фокус — на Категорию, а не на первый фокусируемый (им была бы
|
|
1097
|
-
// она же, но только пока
|
|
1257
|
+
// она же, но только пока Полка закрыта).
|
|
1098
1258
|
event.preventDefault();
|
|
1099
1259
|
const root = contentRef.current;
|
|
1100
1260
|
(root && entryCategory(root))?.focus({ preventScroll: true });
|
|
@@ -1113,10 +1273,10 @@ export function MegaMenuTrigger({
|
|
|
1113
1273
|
triggerRef.current?.focus();
|
|
1114
1274
|
}}
|
|
1115
1275
|
onEscapeKeyDown={(event) => {
|
|
1116
|
-
// Escape в
|
|
1117
|
-
// закрыть
|
|
1276
|
+
// Escape в Полке — её собственный: вернуть фокус на Категорию и
|
|
1277
|
+
// закрыть Полку (обработчик в `MegaMenuCategory`). Оверлей при
|
|
1118
1278
|
// этом остаётся. В поле ввода Escape оверлею отдаём: иначе в
|
|
1119
|
-
//
|
|
1279
|
+
// Полке с поиском он не закрыл бы ничего.
|
|
1120
1280
|
const target = event.target;
|
|
1121
1281
|
if (
|
|
1122
1282
|
target instanceof Element &&
|