@olddevs/ui 4.1.1 → 4.2.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/components/navigation/MegaMenu.d.ts +156 -0
  3. package/dist/components/navigation/MegaMenu.d.ts.map +1 -0
  4. package/dist/components/navigation/MegaMenu.js +512 -0
  5. package/dist/components/navigation/MegaMenu.js.map +1 -0
  6. package/dist/components/navigation/useMenuAim.d.ts +41 -0
  7. package/dist/components/navigation/useMenuAim.d.ts.map +1 -0
  8. package/dist/components/navigation/useMenuAim.js +114 -0
  9. package/dist/components/navigation/useMenuAim.js.map +1 -0
  10. package/dist/components/overlays/Menu.d.ts +19 -1
  11. package/dist/components/overlays/Menu.d.ts.map +1 -1
  12. package/dist/components/overlays/Menu.js +16 -4
  13. package/dist/components/overlays/Menu.js.map +1 -1
  14. package/dist/editor/features/table/TableMenuButton.js +1 -1
  15. package/dist/editor/features/table/TableMenuButton.js.map +1 -1
  16. package/dist/editor/ui/BlockHandle.js +1 -1
  17. package/dist/editor/ui/BlockHandle.js.map +1 -1
  18. package/dist/editor/ui/FormatMenus.js +1 -1
  19. package/dist/editor/ui/FormatMenus.js.map +1 -1
  20. package/dist/{editor/hooks → hooks}/useSubmenuPlacement.d.ts +14 -1
  21. package/dist/hooks/useSubmenuPlacement.d.ts.map +1 -0
  22. package/dist/hooks/useSubmenuPlacement.js +10 -0
  23. package/dist/hooks/useSubmenuPlacement.js.map +1 -0
  24. package/dist/i18n/defaults.d.ts.map +1 -1
  25. package/dist/i18n/defaults.js +3 -0
  26. package/dist/i18n/defaults.js.map +1 -1
  27. package/dist/i18n/messages.d.ts +8 -0
  28. package/dist/i18n/messages.d.ts.map +1 -1
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/locales/ru.d.ts.map +1 -1
  34. package/dist/locales/ru.js +3 -0
  35. package/dist/locales/ru.js.map +1 -1
  36. package/docs/api/messages.md +6 -0
  37. package/docs/components/index.en.md +2 -1
  38. package/docs/components/index.md +1 -0
  39. package/docs/components/navigation/mega-menu.en.md +273 -0
  40. package/docs/components/navigation/mega-menu.md +258 -0
  41. package/docs/components/overlays/menu.en.md +22 -4
  42. package/docs/components/overlays/menu.md +20 -4
  43. package/docs/getting-started.en.md +4 -4
  44. package/docs/getting-started.md +3 -3
  45. package/docs/glossary.en.md +6 -1
  46. package/docs/glossary.md +5 -0
  47. package/llms.txt +1 -0
  48. package/package.json +1 -1
  49. package/src/components/navigation/MegaMenu.tsx +1223 -0
  50. package/src/components/navigation/useMenuAim.ts +202 -0
  51. package/src/components/overlays/Menu.tsx +47 -4
  52. package/src/editor/features/table/TableMenuButton.tsx +1 -1
  53. package/src/editor/ui/BlockHandle.tsx +1 -1
  54. package/src/editor/ui/FormatMenus.tsx +1 -1
  55. package/src/{editor/hooks → hooks}/useSubmenuPlacement.ts +18 -15
  56. package/src/i18n/defaults.ts +4 -0
  57. package/src/i18n/messages.ts +9 -0
  58. package/src/index.ts +17 -0
  59. package/src/locales/ru.ts +4 -0
  60. package/dist/editor/hooks/useSubmenuPlacement.d.ts.map +0 -1
  61. package/dist/editor/hooks/useSubmenuPlacement.js +0 -20
  62. package/dist/editor/hooks/useSubmenuPlacement.js.map +0 -1
@@ -0,0 +1,1223 @@
1
+ "use client";
2
+
3
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
4
+ import { Slot, Slottable } from "@radix-ui/react-slot";
5
+ import {
6
+ Children,
7
+ createContext,
8
+ forwardRef,
9
+ Fragment,
10
+ isValidElement,
11
+ useCallback,
12
+ useContext,
13
+ useEffect,
14
+ useId,
15
+ useLayoutEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ type ComponentPropsWithoutRef,
20
+ type CSSProperties,
21
+ type FocusEvent,
22
+ type KeyboardEvent,
23
+ type MutableRefObject,
24
+ type PointerEvent,
25
+ type ReactElement,
26
+ type ReactNode,
27
+ type RefObject,
28
+ } from "react";
29
+ import { useNarrowScreen } from "../../hooks/useSubmenuPlacement.js";
30
+ import { useMessages } from "../../i18n/useMessages.js";
31
+ import { BackIcon, ChevronRightIcon } from "../../icons.js";
32
+ import { buttonClasses, type ButtonLookOptions } from "../../lib/button.js";
33
+ import { cn } from "../../lib/cn.js";
34
+ import { OVERLAY_LAYER } from "../../lib/overlay-layer.js";
35
+ import { withoutToolbarAxis } from "../../lib/toolbar-axis.js";
36
+ import { useMenuAim } from "./useMenuAim.js";
37
+
38
+ /*
39
+ МЕГАМЕНЮ — НАВИГАЦИЯ, А НЕ МЕНЮ КОМАНД.
40
+
41
+ Отсюда шаблон WAI-ARIA Disclosure Navigation, а не `role="menu"`: Категория
42
+ с Витриной — кнопка с `aria-expanded`, Витрина — регион со ссылками, ссылки
43
+ — настоящие `a`. Роль `menu` обещает скринридеру меню действий с его
44
+ клавиатурной моделью (стрелки по всем пунктам, Tab закрывает), и ссылки в
45
+ нём перестают быть ссылками: их нельзя открыть в новой вкладке, их не видно
46
+ в списке ссылок страницы. `Menu` кита по той же причине навигацию запрещает.
47
+
48
+ Поведение своё, а не react-aria: готового «раскрывающегося каталога» у неё
49
+ нет, а `Menu` на её основе — ровно та роль `menu`, от которой мы уходим.
50
+
51
+ КАСАНИЕ И УЗКИЙ ЭКРАН. Касание наведения не знает: Категорию открывает
52
+ `click`, а `pointerType` «touch» и «pen» в наведении и уходе указателя
53
+ отбрасываются — Прицел им не нужен. На узком экране (граница та же, что у
54
+ подменю кита) Витрине некуда встать сбоку, и она ЗАМЕНЯЕТ Столбец: «спуск»
55
+ в Категорию со строкой «Назад». Прицела там тоже нет: заменять Столбец под
56
+ курсором по наведению значило бы вырывать его из-под руки.
57
+ */
58
+
59
+ /** Задержка закрытия, когда курсор ушёл и со Столбца, и с Витрины. */
60
+ const LEAVE_GRACE_MS = 150;
61
+
62
+ /** Что считается фокусируемым внутри Витрины при переходе в неё с клавиатуры. */
63
+ const FOCUSABLE =
64
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
65
+
66
+ interface MegaMenuContextValue {
67
+ baseId: string;
68
+ /** Открытая Категория. */
69
+ value: string | null;
70
+ /** Категория — единственная остановка Tab в Столбце (roving tabindex). */
71
+ tabStop: string | null;
72
+ /** Открыть Категорию щелчком или с клавиатуры — сразу, мимо Прицела. */
73
+ openCategory: (id: string, focusShowcase?: boolean) => void;
74
+ close: () => void;
75
+ /** Курсор наведён на строку Столбца: решает Прицел. `null` — строка без Витрины. */
76
+ hoverCategory: (target: string | null) => void;
77
+ /** Витрина перевёрнута на другую сторону: со стороны конца строки ей не хватило места. */
78
+ flipped: boolean;
79
+ /** Мегаменю в оверлее `MegaMenuTrigger`: Столбец и Витрина прокручиваются сами. */
80
+ inOverlay: boolean;
81
+ /** Узкий экран: Витрина заменяет Столбец, а не встаёт сбоку. */
82
+ narrow: boolean;
83
+ /** Категория, чья Витрина сейчас заменяет Столбец; `null` — Столбец на месте. */
84
+ drilledId: string | null;
85
+ /** Вернуться из Витрины к Столбцу и поставить фокус на исходную Категорию. */
86
+ back: (id: string) => void;
87
+ onCategoryKeyDown: (event: KeyboardEvent<HTMLElement>, id: string, hasShowcase: boolean) => void;
88
+ onShowcaseKeyDown: (event: KeyboardEvent<HTMLElement>, id: string) => void;
89
+ onCategoryFocus: (id: string) => void;
90
+ /** Категория хочет перевести фокус в свою Витрину, как только та смонтируется. */
91
+ pendingFocus: MutableRefObject<string | null>;
92
+ /** Категория, на которую вернуть фокус, когда её кнопку снова покажут. */
93
+ returnFocus: MutableRefObject<string | null>;
94
+ }
95
+
96
+ const MegaMenuContext = createContext<MegaMenuContextValue | null>(null);
97
+
98
+ function useMegaMenu(part: string): MegaMenuContextValue {
99
+ const context = useContext(MegaMenuContext);
100
+ if (!context) throw new Error(`${part} must be rendered inside MegaMenu`);
101
+ return context;
102
+ }
103
+
104
+ /** id для DOM: id Категории приходит от потребителя и может содержать что угодно. */
105
+ function domId(baseId: string, kind: "category" | "showcase", id: string): string {
106
+ return `${baseId}-${kind}-${encodeURIComponent(id)}`;
107
+ }
108
+
109
+ /** Категории Столбца по порядку, с раскрытием фрагментов. */
110
+ function categoryChildren(children: ReactNode): ReactElement<MegaMenuCategoryProps>[] {
111
+ const result: ReactElement<MegaMenuCategoryProps>[] = [];
112
+ Children.forEach(children, (child) => {
113
+ if (!isValidElement(child)) return;
114
+ if (child.type === Fragment) {
115
+ result.push(...categoryChildren((child.props as { children?: ReactNode }).children));
116
+ } else if (child.type === MegaMenuCategory) {
117
+ result.push(child as ReactElement<MegaMenuCategoryProps>);
118
+ }
119
+ });
120
+ return result;
121
+ }
122
+
123
+ /**
124
+ * Стрелка «к Витрине» — по тому, где Витрина стоит на экране: при письме справа
125
+ * налево она слева, а перевёрнутая у края — на другой стороне.
126
+ */
127
+ function forwardKey(node: HTMLElement, flipped: boolean): "ArrowRight" | "ArrowLeft" {
128
+ const rtl = getComputedStyle(node).direction === "rtl";
129
+ return rtl === flipped ? "ArrowRight" : "ArrowLeft";
130
+ }
131
+
132
+ /** Зазор между Столбцом и Витриной (`ms-1`) и запас до края окна, пиксели. */
133
+ const SHOWCASE_GAP = 4;
134
+ const VIEWPORT_MARGIN = 8;
135
+
136
+ /** Клавиши, которые поле ввода в Витрине (поиск, промо с формой) обязано оставить себе. */
137
+ function isTextEntry(target: EventTarget): boolean {
138
+ return (
139
+ target instanceof HTMLElement &&
140
+ (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))
141
+ );
142
+ }
143
+
144
+ export interface MegaMenuProps extends Omit<
145
+ ComponentPropsWithoutRef<"nav">,
146
+ "children" | "defaultValue"
147
+ > {
148
+ /** Доступное имя навигации: на странице с несколькими `nav` они различимы только по нему. */
149
+ "aria-label": string;
150
+ /** Категории — `MegaMenuCategory`, прямыми детьми (фрагменты допустимы). */
151
+ children?: ReactNode;
152
+ /** Открытая Категория — управляемо. `null` — Витрина закрыта. */
153
+ value?: string | null;
154
+ /** Открытая Категория при монтировании, без управления. */
155
+ defaultValue?: string | null;
156
+ /** Открылась другая Категория или Витрина закрылась. */
157
+ onValueChange?: (value: string | null) => void;
158
+ /**
159
+ * Ширина Витрины — длина CSS или пиксели. Одна на все Категории: каталог не
160
+ * должен прыгать, когда у соседних Категорий разный объём.
161
+ */
162
+ showcaseWidth?: number | string;
163
+ }
164
+
165
+ /**
166
+ * Мегаменю в духе Amazon: Столбец категорий слева, Витрина наведённой
167
+ * Категории справа.
168
+ *
169
+ * СОСТАВНОЙ API, как у `Menu`: Витрина разнородна — колонки ссылок, промо,
170
+ * своё содержимое, — и описывать её массивом значило бы завести второй язык
171
+ * разметки рядом с JSX.
172
+ *
173
+ * Витрина — потомок своей Категории в DOM, а не портал. Отсюда сразу три
174
+ * следствия, и все нужные: порядок Tab идёт «Категория → её ссылки → дальше
175
+ * по странице», уход указателя из Столбца в Витрину не считается уходом из
176
+ * меню, а Витрина встаёт вплотную к Столбцу без расчёта позиции — она
177
+ * `absolute` от корня.
178
+ */
179
+ export const MegaMenu = forwardRef<HTMLElement, MegaMenuProps>(function MegaMenu(
180
+ {
181
+ children,
182
+ value: valueProp,
183
+ defaultValue = null,
184
+ onValueChange,
185
+ showcaseWidth = "40rem",
186
+ className,
187
+ style,
188
+ onPointerEnter,
189
+ onPointerLeave,
190
+ onBlur,
191
+ ...rest
192
+ },
193
+ forwardedRef
194
+ ) {
195
+ const baseId = useId();
196
+ const rootRef = useRef<HTMLElement | null>(null);
197
+ // Внутри `MegaMenuTrigger` Столбец и Витрина прокручиваются в оверлее сами.
198
+ const inOverlay = useContext(MegaMenuTriggerContext) !== null;
199
+ const [innerValue, setInnerValue] = useState<string | null>(defaultValue);
200
+ const controlled = valueProp !== undefined;
201
+ const value = controlled ? valueProp : innerValue;
202
+ const [focusedId, setFocusedId] = useState<string | null>(null);
203
+ const pendingFocus = useRef<string | null>(null);
204
+ const returnFocus = useRef<string | null>(null);
205
+ const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
206
+
207
+ const categories = categoryChildren(children);
208
+ const firstEnabled = categories.find((category) => !category.props.disabled)?.props.id ?? null;
209
+ // Остановка Tab: где пользователь был последним, иначе открытая, иначе первая.
210
+ const tabStop = focusedId ?? value ?? firstEnabled;
211
+
212
+ const valueRef = useRef(value);
213
+ valueRef.current = value;
214
+ const onValueChangeRef = useRef(onValueChange);
215
+ onValueChangeRef.current = onValueChange;
216
+
217
+ const setValue = useCallback(
218
+ (next: string | null) => {
219
+ if (next === valueRef.current) return;
220
+ if (!controlled) setInnerValue(next);
221
+ onValueChangeRef.current?.(next);
222
+ },
223
+ [controlled]
224
+ );
225
+
226
+ /*
227
+ Смена или закрытие по указателю (уход мыши, отложенная смена Прицела) не
228
+ должны терять фокус: если он внутри уходящей Витрины, её содержимое
229
+ размонтируется, и фокус упал бы на `body` посреди работы с клавиатуры. Тогда
230
+ он возвращается на Категорию этой Витрины — через тот же эффект, что у «Назад».
231
+ */
232
+ const switchByPointer = useCallback(
233
+ (next: string | null) => {
234
+ const current = valueRef.current;
235
+ if (current !== null && next !== current) {
236
+ const showcase = document.getElementById(domId(baseId, "showcase", current));
237
+ if (showcase?.contains(document.activeElement)) returnFocus.current = current;
238
+ }
239
+ setValue(next);
240
+ },
241
+ [baseId, setValue]
242
+ );
243
+
244
+ const cancelLeave = () => {
245
+ if (leaveTimer.current) clearTimeout(leaveTimer.current);
246
+ leaveTimer.current = null;
247
+ };
248
+ useEffect(() => cancelLeave, []);
249
+
250
+ /** Категории Столбца в DOM-порядке; выключенные — по запросу. */
251
+ const categoryNodes = useCallback((withDisabled = false): HTMLElement[] => {
252
+ const root = rootRef.current;
253
+ if (!root) return [];
254
+ return Array.from(root.querySelectorAll<HTMLElement>("[data-mega-category]")).filter(
255
+ (node) => withDisabled || node.getAttribute("aria-disabled") !== "true"
256
+ );
257
+ }, []);
258
+
259
+ // Прицелу нужна открытая Витрина; ищется по id, а не ref'ом: её рисует Категория.
260
+ const showcaseRef = useMemo(
261
+ () => ({
262
+ get current(): HTMLElement | null {
263
+ return value === null ? null : document.getElementById(domId(baseId, "showcase", value));
264
+ },
265
+ }),
266
+ [baseId, value]
267
+ );
268
+ const narrow = useNarrowScreen();
269
+ const narrowRef = useRef(narrow);
270
+ narrowRef.current = narrow;
271
+ // Спуск возможен лишь в Категорию с Витриной: `value` с чужим ключом или
272
+ // Категория без детей Столбец не прячут — иначе не осталось бы ничего.
273
+ // Категории, обёрнутые своим компонентом, отсюда не видны — тогда спуск
274
+ // доверяет `value`: открыть можно только Категорию с Витриной.
275
+ 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;
284
+
285
+ const aim = useMenuAim({ value, showcaseRef, commit: switchByPointer });
286
+ const { cancel: cancelAim, request: requestAim } = aim;
287
+
288
+ /*
289
+ ПЕРЕВОРОТ У КРАЯ. Витрина встаёт со стороны конца строки; если там не
290
+ хватает места, а с другой стороны хватает — она переходит туда. Не хватает
291
+ ни там, ни там — остаётся на своей стороне: `max-w` её ужмёт.
292
+
293
+ Меряется ДО открытия, на монтировании и при смене размера окна, а не по
294
+ открытой Витрине: стрелка «к Витрине» должна знать сторону уже при первом
295
+ открытии с клавиатуры. Ширину даёт зонд с той же длиной, что у Витрины, —
296
+ длина приходит строкой CSS (`40rem`, `min(…)`), и посчитать её может только
297
+ браузер.
298
+ */
299
+ const [flipped, setFlipped] = useState(false);
300
+ /*
301
+ Замер — функция, а не только эффект: в выпадающем виде `nav` монтируется
302
+ раньше, чем Radix расставит оверлей, и замер на монтировании видит его не
303
+ там, где он окажется. Поэтому сторона перемеряется ещё и при открытии
304
+ Категории и прямо перед решением, какая стрелка ведёт к Витрине.
305
+ */
306
+ const measureFlip = useCallback((): boolean => {
307
+ const root = rootRef.current;
308
+ if (!root) return false;
309
+ const probe = document.createElement("div");
310
+ probe.style.position = "absolute";
311
+ probe.style.visibility = "hidden";
312
+ probe.style.pointerEvents = "none";
313
+ probe.style.width = "var(--mega-menu-showcase-w)";
314
+ root.appendChild(probe);
315
+ const showcase = Math.min(probe.offsetWidth, window.innerWidth - 32);
316
+ probe.remove();
317
+ const need = showcase + SHOWCASE_GAP + VIEWPORT_MARGIN;
318
+ const rect = root.getBoundingClientRect();
319
+ const rtl = getComputedStyle(root).direction === "rtl";
320
+ const endRoom = rtl ? rect.left : window.innerWidth - rect.right;
321
+ const startRoom = rtl ? window.innerWidth - rect.right : rect.left;
322
+ const next = endRoom < need && startRoom >= need;
323
+ setFlipped(next);
324
+ return next;
325
+ }, []);
326
+ useLayoutEffect(() => {
327
+ measureFlip();
328
+ window.addEventListener("resize", measureFlip);
329
+ return () => window.removeEventListener("resize", measureFlip);
330
+ // Перемер — при открытии каталога, а не на каждой смене Категории: сторона
331
+ // от смены не зависит, а зонд и чтение раскладки на каждое наведение — лишняя
332
+ // работа на горячем пути.
333
+ }, [measureFlip, showcaseWidth, value === null]);
334
+
335
+ /*
336
+ Столбец обязан остаться остановкой Tab. Расчёт выше видит только прямых
337
+ детей и доверяет `value`, а `value` бывает чужим ключом (устаревший адрес),
338
+ запомненная Категория — убранной из списка, Категории — обёрнутыми своим
339
+ компонентом. Тогда ни у одной строки нет `tabIndex=0`, и Tab молча
340
+ перепрыгивает каталог. Проверка — по DOM после отрисовки, остановка
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
+ });
349
+
350
+ const focusCategory = useCallback(
351
+ (id: string) => {
352
+ const node = categoryNodes(true).find((candidate) => candidate.dataset.megaCategory === id);
353
+ node?.focus();
354
+ },
355
+ [categoryNodes]
356
+ );
357
+
358
+ const context = useMemo<MegaMenuContextValue>(() => {
359
+ const openCategory = (id: string, focusShowcase = false) => {
360
+ cancelAim();
361
+ // На узком экране кнопка Категории исчезает вместе со Столбцом: фокус,
362
+ // оставшийся на ней, упал бы на `body`, поэтому он идёт в Витрину всегда.
363
+ const intoShowcase = focusShowcase || narrowRef.current;
364
+ if (intoShowcase) pendingFocus.current = id;
365
+ // Уже открыта наведением — значение не меняется, и эффект Категории,
366
+ // который переводит фокус в Витрину, не сработает. Переводим сами.
367
+ if (intoShowcase && valueRef.current === id) {
368
+ pendingFocus.current = null;
369
+ document
370
+ .getElementById(domId(baseId, "showcase", id))
371
+ ?.querySelector<HTMLElement>(FOCUSABLE)
372
+ ?.focus();
373
+ return;
374
+ }
375
+ setValue(id);
376
+ };
377
+ const close = () => {
378
+ cancelAim();
379
+ setValue(null);
380
+ };
381
+
382
+ const onCategoryKeyDown = (
383
+ event: KeyboardEvent<HTMLElement>,
384
+ id: string,
385
+ hasShowcase: boolean
386
+ ) => {
387
+ const node = event.currentTarget;
388
+ const enabled = categoryNodes();
389
+ const index = enabled.indexOf(node);
390
+ let target: HTMLElement | undefined;
391
+ switch (event.key) {
392
+ case "ArrowDown":
393
+ target = enabled[Math.min(index + 1, enabled.length - 1)];
394
+ break;
395
+ case "ArrowUp":
396
+ target = enabled[Math.max(index - 1, 0)];
397
+ break;
398
+ case "Home":
399
+ target = enabled[0];
400
+ break;
401
+ case "End":
402
+ target = enabled[enabled.length - 1];
403
+ break;
404
+ case "Escape":
405
+ if (valueRef.current !== null) {
406
+ event.preventDefault();
407
+ close();
408
+ }
409
+ return;
410
+ case "Enter":
411
+ case " ":
412
+ // У ссылки-Категории Enter — переход, Пробел — прокрутка страницы:
413
+ // обе роли браузерные, отнимать их незачем.
414
+ if (!hasShowcase || node.getAttribute("aria-disabled") === "true") return;
415
+ event.preventDefault();
416
+ openCategory(id, true);
417
+ return;
418
+ default:
419
+ if (
420
+ hasShowcase &&
421
+ (event.key === "ArrowLeft" || event.key === "ArrowRight") &&
422
+ event.key === forwardKey(node, measureFlip())
423
+ ) {
424
+ if (node.getAttribute("aria-disabled") === "true") return;
425
+ event.preventDefault();
426
+ openCategory(id, true);
427
+ }
428
+ return;
429
+ }
430
+ event.preventDefault();
431
+ if (!target || target === node) return;
432
+ target.focus();
433
+ // Витрина открыта — она следует за фокусом, как следует за наведением:
434
+ // иначе подсвечена одна Категория, а открыта другая.
435
+ if (valueRef.current !== null) {
436
+ const targetId = target.dataset.megaCategory ?? null;
437
+ cancelAim();
438
+ setValue(target.dataset.megaShowcase === "true" ? targetId : null);
439
+ }
440
+ };
441
+
442
+ // Закрытие и возврат фокуса — в таком порядке и через эффект Категории:
443
+ // пока Витрина заменяет Столбец, кнопка Категории скрыта, и сфокусировать
444
+ // её можно только после того, как Столбец вернулся на место.
445
+ const back = (id: string) => {
446
+ returnFocus.current = id;
447
+ close();
448
+ };
449
+
450
+ const onShowcaseKeyDown = (event: KeyboardEvent<HTMLElement>, id: string) => {
451
+ if (isTextEntry(event.target)) return;
452
+ if (narrowRef.current) {
453
+ // Стрелки «к Столбцу» тут не при чём: Столбца рядом нет. Escape — «Назад».
454
+ if (event.key === "Escape") {
455
+ event.preventDefault();
456
+ back(id);
457
+ }
458
+ return;
459
+ }
460
+ if (event.key === "Escape") {
461
+ event.preventDefault();
462
+ // Фокус — до закрытия: закрытие размонтирует ссылку под фокусом, и
463
+ // фокус упал бы на `body`.
464
+ focusCategory(id);
465
+ close();
466
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
467
+ const backKey =
468
+ forwardKey(event.currentTarget, measureFlip()) === "ArrowRight"
469
+ ? "ArrowLeft"
470
+ : "ArrowRight";
471
+ if (event.key !== backKey) return;
472
+ event.preventDefault();
473
+ focusCategory(id);
474
+ }
475
+ };
476
+
477
+ return {
478
+ baseId,
479
+ value,
480
+ tabStop,
481
+ openCategory,
482
+ close,
483
+ // Прицела на узком экране нет: Витрину открывает только щелчок или касание.
484
+ hoverCategory: narrow ? () => {} : requestAim,
485
+ flipped,
486
+ narrow,
487
+ drilledId,
488
+ inOverlay,
489
+ back,
490
+ onCategoryKeyDown,
491
+ onShowcaseKeyDown,
492
+ onCategoryFocus: setFocusedId,
493
+ pendingFocus,
494
+ returnFocus,
495
+ };
496
+ }, [
497
+ baseId,
498
+ value,
499
+ tabStop,
500
+ flipped,
501
+ narrow,
502
+ drilledId,
503
+ inOverlay,
504
+ setValue,
505
+ categoryNodes,
506
+ focusCategory,
507
+ cancelAim,
508
+ requestAim,
509
+ measureFlip,
510
+ ]);
511
+
512
+ // Щелчок или касание вне каталога закрывают Витрину. Уход указателя этого
513
+ // не покрывает: у касания ухода нет.
514
+ useEffect(() => {
515
+ if (value === null) return;
516
+ const onPointerDown = (event: globalThis.PointerEvent) => {
517
+ const root = rootRef.current;
518
+ if (root && event.target instanceof Node && !root.contains(event.target)) setValue(null);
519
+ };
520
+ document.addEventListener("pointerdown", onPointerDown, true);
521
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
522
+ }, [value, setValue]);
523
+
524
+ return (
525
+ <MegaMenuContext.Provider value={context}>
526
+ <nav
527
+ ref={(node) => {
528
+ rootRef.current = node;
529
+ if (typeof forwardedRef === "function") forwardedRef(node);
530
+ else if (forwardedRef) forwardedRef.current = node;
531
+ }}
532
+ className={cn("relative w-64 text-[13px] max-sm:w-full", className)}
533
+ style={{ "--mega-menu-showcase-w": cssLength(showcaseWidth), ...style } as CSSProperties}
534
+ onPointerEnter={(event: PointerEvent<HTMLElement>) => {
535
+ cancelLeave();
536
+ onPointerEnter?.(event);
537
+ }}
538
+ onPointerLeave={(event: PointerEvent<HTMLElement>) => {
539
+ onPointerLeave?.(event);
540
+ if (event.pointerType === "touch" || event.pointerType === "pen") return;
541
+ // Не сразу: курсор, на миг вышедший за край Витрины или в зазор у
542
+ // Столбца, не должен сбрасывать каталог.
543
+ cancelLeave();
544
+ leaveTimer.current = setTimeout(() => {
545
+ // Спуск на узком экране — шаг навигации, а не наведение: уход мыши
546
+ // его не отменяет, возвращает «Назад».
547
+ if (narrowRef.current) return;
548
+ cancelAim();
549
+ switchByPointer(null);
550
+ }, LEAVE_GRACE_MS);
551
+ }}
552
+ onBlur={(event: FocusEvent<HTMLElement>) => {
553
+ onBlur?.(event);
554
+ // Фокус ушёл со страницы каталога (Tab за последней ссылкой) — Витрина
555
+ // закрывается. `relatedTarget` пуст при щелчке по нефокусируемому
556
+ // месту внутри Витрины: это не уход, а его закрывает щелчок снаружи.
557
+ const next = event.relatedTarget;
558
+ if (next instanceof Node && !event.currentTarget.contains(next)) setValue(null);
559
+ }}
560
+ {...rest}
561
+ >
562
+ <ul className={cn(COLUMN_CLASS, inOverlay && OVERLAY_SCROLL_CLASS)}>{children}</ul>
563
+ </nav>
564
+ </MegaMenuContext.Provider>
565
+ );
566
+ });
567
+
568
+ function cssLength(value: number | string): string {
569
+ return typeof value === "number" ? `${value}px` : value;
570
+ }
571
+
572
+ const COLUMN_CLASS =
573
+ "bg-surface-elevated border-line flex flex-col gap-px rounded-md border p-1 list-none m-0";
574
+
575
+ /**
576
+ * Строка Категории. Подсветка — по `aria-expanded`, а не по `:hover`:
577
+ * подсвечено ровно то, чья Витрина открыта. При «Прицеле» курсор стоит над
578
+ * соседней Категорией, а Витрина ещё прежняя — две подсветки разом читались бы
579
+ * как два выбора.
580
+ */
581
+ const CATEGORY_CLASS =
582
+ "text-fg-0 flex w-full cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent " +
583
+ "px-2.5 py-2 text-start text-[13px] leading-5 no-underline select-none " +
584
+ "focus-visible:outline-accent-strong focus-visible:outline-2 focus-visible:-outline-offset-2 " +
585
+ "aria-expanded:bg-bg-2 aria-expanded:font-semibold " +
586
+ "aria-disabled:cursor-default aria-disabled:opacity-50";
587
+
588
+ /** Ссылка-Категория без Витрины подсвечивается наведением: открывать ей нечего. */
589
+ const CATEGORY_LINK_CLASS = "hover:bg-bg-2";
590
+
591
+ const CATEGORY_ICON_CLASS = "text-fg-2 flex shrink-0";
592
+
593
+ /** Подпись переносится, а не обрезается: длинное название раздела читается целиком. */
594
+ const CATEGORY_LABEL_CLASS = "min-w-0 flex-1 break-words";
595
+
596
+ const CATEGORY_CHEVRON_CLASS = "text-fg-3 flex shrink-0 rtl:rotate-180";
597
+
598
+ /**
599
+ * Витрина: вплотную к Столбцу со стороны конца строки, не ниже его, одна ширина
600
+ * на все Категории. Поверхность и тень — как у панели `Menu`: три меню кита
601
+ * должны читаться одной системой.
602
+ */
603
+ const SHOWCASE_CLASS =
604
+ "bg-surface-elevated border-line absolute top-0 min-h-full " +
605
+ "w-[var(--mega-menu-showcase-w)] max-w-[calc(100vw-2rem)] rounded-md border p-4 " +
606
+ `shadow-[var(--shadow-2)] ${OVERLAY_LAYER.popover}`;
607
+
608
+ /** Сторона Витрины: со стороны конца строки или, перевёрнутая у края, — начала. */
609
+ const SHOWCASE_SIDE = { end: "start-full ms-1", start: "end-full me-1" } as const;
610
+
611
+ /**
612
+ * Витрина вместо Столбца на узком экране: обычный блок на всю ширину, без
613
+ * собственной поверхности. Рамка и фон — у Столбца, внутри которого она стоит.
614
+ */
615
+ const SHOWCASE_DRILLED_CLASS = "w-full p-3";
616
+
617
+ /** Строка «Назад» с именем Категории: шаг назад по Столбцу, как в меню телефона. */
618
+ const BACK_ROW_CLASS = "border-line -mx-3 mb-3 flex items-center gap-1 border-b px-1 pb-2";
619
+
620
+ const BACK_BUTTON_CLASS =
621
+ "text-fg-1 hover:text-fg-0 hover:bg-bg-2 flex shrink-0 cursor-pointer items-center gap-1.5 " +
622
+ "rounded-sm border-0 bg-transparent px-2 py-1.5 text-[13px] leading-5 " +
623
+ "focus-visible:outline-accent-strong focus-visible:outline-2 focus-visible:-outline-offset-2";
624
+
625
+ const SHOWCASE_HEADING_CLASS =
626
+ "text-fg-0 hover:text-accent-strong mb-3 inline-flex items-center gap-1 text-[15px] " +
627
+ "font-semibold no-underline";
628
+
629
+ const SHOWCASE_GRID_CLASS = "grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-6 gap-y-5";
630
+
631
+ /** Колонки ссылок в заменившей Столбец Витрине идут одна под другой: рядом им тесно. */
632
+ const SHOWCASE_GRID_DRILLED_CLASS = "grid grid-cols-1 gap-y-5";
633
+
634
+ export interface MegaMenuCategoryProps {
635
+ /** Ключ Категории — им же её открывают через `value`. */
636
+ id: string;
637
+ /** Подпись в Столбце; она же — доступное имя Витрины. */
638
+ label: ReactNode;
639
+ /** Иконка слева от подписи; скрыта от скринридера. */
640
+ icon?: ReactNode;
641
+ /**
642
+ * Адрес раздела целиком. С Витриной — заголовок-ссылка в её начале, без
643
+ * Витрины — сама строка Столбца становится ссылкой.
644
+ */
645
+ href?: string;
646
+ /**
647
+ * Ссылка роутера вместо `href`: пустой элемент (`<Link href="/x" />`),
648
+ * подпись уезжает внутрь. Отдельным пропом, а не `asChild`: дети Категории —
649
+ * это её Витрина.
650
+ */
651
+ link?: ReactElement;
652
+ /** Видна, но не открывается и пропускается стрелками. */
653
+ disabled?: boolean;
654
+ /** Содержимое Витрины: `MegaMenuGroup` и что угодно своё. Без детей Витрины нет. */
655
+ children?: ReactNode;
656
+ /** Классы на строку Столбца. */
657
+ className?: string;
658
+ }
659
+
660
+ /**
661
+ * Категория Столбца и её Витрина.
662
+ *
663
+ * Содержимое Витрины монтируется только у открытой Категории: каталог из
664
+ * сотни ссылок и картинок не должен грузить всё разом. Сам регион при этом
665
+ * есть всегда (скрытым): на него указывает `aria-controls` кнопки.
666
+ */
667
+ export function MegaMenuCategory({
668
+ id,
669
+ label,
670
+ icon,
671
+ href,
672
+ link,
673
+ disabled = false,
674
+ children,
675
+ className,
676
+ }: MegaMenuCategoryProps) {
677
+ const menu = useMegaMenu("MegaMenuCategory");
678
+ const hasShowcase = Children.toArray(children).length > 0;
679
+ const open = hasShowcase && menu.value === id;
680
+ const categoryId = domId(menu.baseId, "category", id);
681
+ const showcaseId = domId(menu.baseId, "showcase", id);
682
+ const showcaseRef = useRef<HTMLElement | null>(null);
683
+ const buttonRef = useRef<HTMLButtonElement | null>(null);
684
+ const messages = useMessages();
685
+ // Эта Категория заменила Столбец / Столбец заменён другой Категорией.
686
+ const drilled = menu.drilledId === id;
687
+ const coveredByOther = menu.drilledId !== null && !drilled;
688
+
689
+ // Переход в Витрину с клавиатуры или касанием: фокус на её первый фокусируемый элемент,
690
+ // как только содержимое смонтировалось.
691
+ useEffect(() => {
692
+ if (!open || menu.pendingFocus.current !== id) return;
693
+ menu.pendingFocus.current = null;
694
+ showcaseRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
695
+ }, [open, id, menu.pendingFocus]);
696
+
697
+ // «Назад»: Столбец вернулся, кнопка Категории снова видна — фокус ей.
698
+ useEffect(() => {
699
+ if (open || menu.returnFocus.current !== id) return;
700
+ menu.returnFocus.current = null;
701
+ buttonRef.current?.focus();
702
+ }, [open, id, menu.returnFocus]);
703
+
704
+ const shared = {
705
+ id: categoryId,
706
+ "data-mega-category": id,
707
+ "data-mega-showcase": hasShowcase ? "true" : "false",
708
+ tabIndex: menu.tabStop === id ? 0 : -1,
709
+ "aria-disabled": disabled || undefined,
710
+ onFocus: () => menu.onCategoryFocus(id),
711
+ onKeyDown: (event: KeyboardEvent<HTMLElement>) =>
712
+ menu.onCategoryKeyDown(event, id, hasShowcase),
713
+ onPointerEnter: (event: PointerEvent<HTMLElement>) => {
714
+ if (disabled || event.pointerType === "touch" || event.pointerType === "pen") return;
715
+ // Решает Прицел: курсор, идущий к открытой Витрине наискосок, задевает
716
+ // соседние строки — они не должны её перехватывать.
717
+ menu.hoverCategory(hasShowcase ? id : null);
718
+ },
719
+ };
720
+
721
+ const content = (
722
+ <>
723
+ {icon != null && (
724
+ <span aria-hidden className={CATEGORY_ICON_CLASS}>
725
+ {icon}
726
+ </span>
727
+ )}
728
+ <span className={CATEGORY_LABEL_CLASS}>{label}</span>
729
+ </>
730
+ );
731
+
732
+ if (!hasShowcase) {
733
+ // Выключенная — не переход, и ссылку роутера она не рисует вовсе: `Slot`
734
+ // зовёт обработчик потомка раньше своего, и роутер успел бы перейти до
735
+ // любого `preventDefault`. Остаётся `a` без адреса — `aria-disabled` у
736
+ // ссылки лишь объявляет недоступность, а не отменяет её.
737
+ const routed = link != null && !disabled;
738
+ const Comp = routed ? Slot : "a";
739
+ return (
740
+ <li hidden={coveredByOther}>
741
+ <Comp
742
+ {...shared}
743
+ href={routed || disabled ? undefined : href}
744
+ className={cn(CATEGORY_CLASS, !disabled && CATEGORY_LINK_CLASS, className)}
745
+ >
746
+ {content}
747
+ {routed ? <Slottable>{link}</Slottable> : null}
748
+ </Comp>
749
+ </li>
750
+ );
751
+ }
752
+
753
+ return (
754
+ <li hidden={coveredByOther}>
755
+ <button
756
+ {...shared}
757
+ ref={buttonRef}
758
+ // Скрыта, пока Витрина занимает её место: `aria-expanded` и
759
+ // `aria-controls` остаются в DOM, а имя региону даёт `aria-labelledby`.
760
+ hidden={drilled}
761
+ type="button"
762
+ aria-expanded={open}
763
+ aria-controls={showcaseId}
764
+ className={cn(CATEGORY_CLASS, className)}
765
+ onClick={() => {
766
+ // Открывает, а не переключает: повторное касание открытой Категории
767
+ // не должно прятать Витрину, которую человек только что открыл.
768
+ if (!disabled) menu.openCategory(id);
769
+ }}
770
+ >
771
+ {content}
772
+ <span aria-hidden className={CATEGORY_CHEVRON_CLASS}>
773
+ <ChevronRightIcon size={14} />
774
+ </span>
775
+ </button>
776
+ <section
777
+ ref={showcaseRef}
778
+ id={showcaseId}
779
+ aria-labelledby={categoryId}
780
+ hidden={!open}
781
+ data-mega-showcase-region=""
782
+ className={
783
+ drilled
784
+ ? SHOWCASE_DRILLED_CLASS
785
+ : cn(
786
+ SHOWCASE_CLASS,
787
+ SHOWCASE_SIDE[menu.flipped ? "start" : "end"],
788
+ menu.inOverlay && OVERLAY_SCROLL_CLASS
789
+ )
790
+ }
791
+ onKeyDown={(event) => menu.onShowcaseKeyDown(event, id)}
792
+ >
793
+ {open && (
794
+ <>
795
+ {drilled && (
796
+ <div className={BACK_ROW_CLASS}>
797
+ <button type="button" className={BACK_BUTTON_CLASS} onClick={() => menu.back(id)}>
798
+ <span aria-hidden className="flex rtl:rotate-180">
799
+ <BackIcon size={16} />
800
+ </span>
801
+ {messages.megaMenu.back}
802
+ </button>
803
+ {/*
804
+ Имя Категории — здесь, только если у неё нет своей ссылки:
805
+ иначе его строкой ниже покажет заголовок-ссылка, и имя на
806
+ телефоне стояло бы дважды подряд.
807
+ */}
808
+ {href == null && link == null && (
809
+ <span className="text-fg-0 min-w-0 flex-1 font-semibold break-words">
810
+ {label}
811
+ </span>
812
+ )}
813
+ </div>
814
+ )}
815
+ {(href != null || link != null) && (
816
+ <ShowcaseHeading href={href} link={link}>
817
+ {label}
818
+ </ShowcaseHeading>
819
+ )}
820
+ <div className={drilled ? SHOWCASE_GRID_DRILLED_CLASS : SHOWCASE_GRID_CLASS}>
821
+ {children}
822
+ </div>
823
+ </>
824
+ )}
825
+ </section>
826
+ </li>
827
+ );
828
+ }
829
+
830
+ function ShowcaseHeading({
831
+ href,
832
+ link,
833
+ children,
834
+ }: {
835
+ href?: string;
836
+ link?: ReactElement;
837
+ children: ReactNode;
838
+ }) {
839
+ const Comp = link ? Slot : "a";
840
+ return (
841
+ <div>
842
+ <Comp href={link ? undefined : href} className={SHOWCASE_HEADING_CLASS}>
843
+ {children}
844
+ <span aria-hidden className={CATEGORY_CHEVRON_CLASS}>
845
+ <ChevronRightIcon size={14} />
846
+ </span>
847
+ {link ? <Slottable>{link}</Slottable> : null}
848
+ </Comp>
849
+ </div>
850
+ );
851
+ }
852
+
853
+ export interface MegaMenuGroupProps {
854
+ /** Заголовок колонки; он же подписывает её список ссылок. */
855
+ title: ReactNode;
856
+ /** Адрес подраздела целиком: заголовок становится ссылкой. */
857
+ href?: string;
858
+ /** Ссылки колонки — `MegaMenuLink`. Каждая уезжает в свой пункт списка. */
859
+ children?: ReactNode;
860
+ /** Классы на колонку. */
861
+ className?: string;
862
+ }
863
+
864
+ const GROUP_TITLE_CLASS = "text-fg-0 mb-1.5 text-[13px] font-semibold";
865
+ const GROUP_TITLE_LINK_CLASS = "hover:text-accent-strong text-inherit no-underline";
866
+ const GROUP_LIST_CLASS = "m-0 flex list-none flex-col p-0";
867
+
868
+ /**
869
+ * Колонка Витрины: заголовок и список ссылок под ним.
870
+ *
871
+ * Список подписан заголовком (`aria-labelledby`): скринридер объявляет
872
+ * «Смартфоны, список, 5 пунктов», а не безымянный список посреди Витрины.
873
+ */
874
+ export function MegaMenuGroup({ title, href, children, className }: MegaMenuGroupProps) {
875
+ const titleId = useId();
876
+ return (
877
+ <div className={cn("min-w-0", className)}>
878
+ <div id={titleId} className={GROUP_TITLE_CLASS}>
879
+ {href != null ? (
880
+ <a href={href} className={GROUP_TITLE_LINK_CLASS}>
881
+ {title}
882
+ </a>
883
+ ) : (
884
+ title
885
+ )}
886
+ </div>
887
+ <ul aria-labelledby={titleId} className={GROUP_LIST_CLASS}>
888
+ {Children.map(children, (child) => (child == null ? null : <li>{child}</li>))}
889
+ </ul>
890
+ </div>
891
+ );
892
+ }
893
+
894
+ export interface MegaMenuLinkProps extends ComponentPropsWithoutRef<"a"> {
895
+ /** Ссылка ведёт на текущую страницу: `aria-current="page"` и вид текущей. */
896
+ current?: boolean;
897
+ /** Иконка слева от подписи; скрыта от скринридера. */
898
+ icon?: ReactNode;
899
+ /**
900
+ * Вывести ссылку элементом роутера: `<MegaMenuLink asChild><Link href="/x">Подпись</Link></MegaMenuLink>`.
901
+ * Стили, пропы и `ref` переходят на него, иконка — внутрь.
902
+ */
903
+ asChild?: boolean;
904
+ }
905
+
906
+ const LINK_CLASS =
907
+ "text-fg-1 hover:text-fg-0 flex items-center gap-2 rounded-sm py-1 text-[13px] leading-5 " +
908
+ "no-underline hover:underline " +
909
+ "focus-visible:outline-accent-strong focus-visible:outline-2 focus-visible:outline-offset-2 " +
910
+ "aria-[current=page]:text-accent-strong aria-[current=page]:font-semibold";
911
+
912
+ /**
913
+ * Ссылка Витрины.
914
+ *
915
+ * Текущую страницу кит не вычисляет — проп `current`, как `active` у
916
+ * `NavItem`: правила сравнения адресов у каждого роутера свои (ADR-0018).
917
+ */
918
+ export const MegaMenuLink = forwardRef<HTMLAnchorElement, MegaMenuLinkProps>(function MegaMenuLink(
919
+ { current = false, icon, asChild = false, children, className, ...rest },
920
+ ref
921
+ ) {
922
+ const Comp = asChild ? Slot : "a";
923
+ return (
924
+ <Comp
925
+ ref={ref}
926
+ aria-current={current ? "page" : undefined}
927
+ className={cn(LINK_CLASS, className)}
928
+ {...rest}
929
+ >
930
+ {icon != null && (
931
+ <span aria-hidden className="text-fg-2 flex shrink-0">
932
+ {icon}
933
+ </span>
934
+ )}
935
+ {asChild ? <Slottable>{children}</Slottable> : children}
936
+ </Comp>
937
+ );
938
+ });
939
+
940
+ /*
941
+ ВЫПАДАЮЩИЙ ВИД — ТА ЖЕ РАЗМЕТКА, ДРУГАЯ ОБЁРТКА.
942
+
943
+ `MegaMenu` внутри `MegaMenuTrigger` не меняется ни в одной строке: Столбец,
944
+ Витрина, roving tabindex, Escape в Витрине — всё то же. Обёртка добавляет
945
+ только то, чего у встроенного вида нет: кнопку, оверлей под ней и закрытие.
946
+
947
+ ОВЕРЛЕЙ — RADIX `Popover`, а не react-aria, как у `Menu`. Причин три.
948
+ Во-первых, это не меню: react-aria ради `Menu` вешает роль `menu` и
949
+ типизированный поиск по пунктам, а нам нужна навигация со ссылками.
950
+ Во-вторых, стек слоёв Radix сам решает «кому Escape» среди своих слоёв:
951
+ `Dialog` кита тоже Radix, поэтому мегаменю внутри окна закрывается первым
952
+ Escape, а окно — вторым, без заслонов (ADR-0041). В-третьих, портал,
953
+ переворот у края экрана и `available-height` для прокрутки уже есть.
954
+ */
955
+
956
+ interface MegaMenuTriggerContextValue {
957
+ /** Кнопка-Раскрыватель — чтобы вернуть ей фокус при закрытии. */
958
+ triggerRef: RefObject<HTMLButtonElement | null>;
959
+ setOpen: (open: boolean) => void;
960
+ }
961
+
962
+ const MegaMenuTriggerContext = createContext<MegaMenuTriggerContextValue | null>(null);
963
+
964
+ /** Портал оверлея: ось `Toolbar`, из которой открыта кнопка, внутрь не проходит. */
965
+ const DropdownPortal = withoutToolbarAxis(PopoverPrimitive.Portal);
966
+
967
+ /**
968
+ * Оверлей: слой `popover`, появление по шкале движения кита, прокрутка внутри.
969
+ *
970
+ * ПРОКРУТКА — У СТОЛБЦА И У ВИТРИНЫ ПО ОТДЕЛЬНОСТИ, а не у оверлея целиком.
971
+ * Витрина — `absolute` от `nav`, рядом со Столбцом. `overflow: auto` на самом
972
+ * оверлее обрезал бы её по его рамке, и от Витрины осталась бы полоска шириной
973
+ * в зазор. Поэтому высотой ограничены `ul` Столбца и `section` Витрины — классом
974
+ * `OVERLAY_SCROLL_CLASS`, который ставит сам `MegaMenu`. `ul` не позиционирован,
975
+ * и содержащий блок Витрины — `nav` выше него, так что `overflow` на `ul`
976
+ * Витрину не режет.
977
+ */
978
+ const DROPDOWN_CONTENT_CLASS =
979
+ `${OVERLAY_LAYER.popover} outline-none ` +
980
+ // Появление и уход — токены, видимые гейту `prefers-reduced-motion` (ADR-0043, ADR-0047).
981
+ "data-[state=open]:animate-fadein data-[state=closed]:animate-fadeout";
982
+
983
+ /**
984
+ * Прокрутка Столбца и Витрины в оверлее: высота до края окна от кнопки
985
+ * (`available-height` Radix). Классы ставит сам `MegaMenu`, узнав оверлей по
986
+ * контексту, — а не оверлей селекторами по чужой разметке, которые молча
987
+ * отвалились бы при первой её правке.
988
+ *
989
+ * Обход высоты на iOS — как у `MENU_PANEL_CLASS`: в Safari на iOS 27
990
+ * `available-height` бывает близок к нулю, и от Столбца остаётся полоска.
991
+ * 24rem или 60 % динамической высоты экрана — что меньше; `!` перебивает
992
+ * правило выше. `-webkit-touch-callout` понимает только WebKit на iOS.
993
+ */
994
+ const OVERLAY_SCROLL_CLASS =
995
+ "max-h-[var(--radix-popover-content-available-height)] overflow-y-auto overscroll-contain " +
996
+ "[@supports(-webkit-touch-callout:none)]:max-h-[min(24rem,60dvh)]!";
997
+
998
+ /** Поле ввода внутри Витрины Escape оставляет себе — иначе закрываем оверлей. */
999
+ const SHOWCASE_SELECTOR = "[data-mega-showcase-region]";
1000
+
1001
+ /** Остановки Tab внутри оверлея: Столбец — одна, плюс ссылки открытой Витрины. */
1002
+ function tabStops(root: HTMLElement): HTMLElement[] {
1003
+ return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
1004
+ (node) => node.tabIndex >= 0 && !node.closest("[hidden]")
1005
+ );
1006
+ }
1007
+
1008
+ /** Куда ставить фокус при открытии: открытая Категория или первая доступная. */
1009
+ function entryCategory(root: HTMLElement): HTMLElement | null {
1010
+ const enabled = Array.from(root.querySelectorAll<HTMLElement>("[data-mega-category]")).filter(
1011
+ (node) => node.getAttribute("aria-disabled") !== "true"
1012
+ );
1013
+ // Остановка Tab у Столбца — как раз открытая Категория, иначе первая.
1014
+ return enabled.find((node) => node.tabIndex === 0) ?? enabled[0] ?? null;
1015
+ }
1016
+
1017
+ export interface MegaMenuTriggerProps {
1018
+ /** Открыто ли мегаменю — управляемо. */
1019
+ open?: boolean;
1020
+ /** Открыто ли мегаменю при монтировании, без управления. */
1021
+ defaultOpen?: boolean;
1022
+ /** Мегаменю открылось или закрылось: щелчком, клавишей, переходом по ссылке. */
1023
+ onOpenChange?: (open: boolean) => void;
1024
+ /**
1025
+ * Первый ребёнок — `MegaMenuButton`, остальные — `MegaMenu`; они рисуются в
1026
+ * оверлее под кнопкой.
1027
+ */
1028
+ children: ReactNode;
1029
+ }
1030
+
1031
+ /**
1032
+ * Выпадающее мегаменю: кнопка и под ней `MegaMenu` в оверлее.
1033
+ *
1034
+ * <MegaMenuTrigger>
1035
+ * <MegaMenuButton>Каталог</MegaMenuButton>
1036
+ * <MegaMenu aria-label="Каталог">…</MegaMenu>
1037
+ * </MegaMenuTrigger>
1038
+ *
1039
+ * Открывается щелчком и клавишами (Enter, Пробел, стрелка вниз), наведением —
1040
+ * нет: мегаменю не должно выпрыгивать от случайного движения мыши. Закрывается
1041
+ * по Escape на Категории, щелчку снаружи, повторному щелчку по кнопке,
1042
+ * переходу по любой ссылке и по Tab за последним элементом. На кнопку фокус
1043
+ * возвращается при Escape и повторном щелчке.
1044
+ *
1045
+ * ПЕРВЫЙ РЕБЁНОК — КНОПКА, ОСТАЛЬНЫЕ — ОВЕРЛЕЙ, и разбор идёт по позиции, а не
1046
+ * по типу: так кнопку можно обернуть своим компонентом (подсказка, счётчик), а
1047
+ * `MegaMenu` — вынести в свой, и ничто не отправит оверлей молча в шапку.
1048
+ */
1049
+ export function MegaMenuTrigger({
1050
+ open: openProp,
1051
+ defaultOpen = false,
1052
+ onOpenChange,
1053
+ children,
1054
+ }: MegaMenuTriggerProps) {
1055
+ const [innerOpen, setInnerOpen] = useState(defaultOpen);
1056
+ const controlled = openProp !== undefined;
1057
+ const open = controlled ? openProp : innerOpen;
1058
+ const openRef = useRef(open);
1059
+ openRef.current = open;
1060
+ const onOpenChangeRef = useRef(onOpenChange);
1061
+ onOpenChangeRef.current = onOpenChange;
1062
+
1063
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
1064
+ const contentRef = useRef<HTMLDivElement | null>(null);
1065
+ /** Tab увёл фокус дальше по странице: возвращать его на кнопку нельзя. */
1066
+ const tabbedAway = useRef(false);
1067
+
1068
+ const setOpen = useCallback(
1069
+ (next: boolean) => {
1070
+ if (next === openRef.current) return;
1071
+ if (!controlled) setInnerOpen(next);
1072
+ onOpenChangeRef.current?.(next);
1073
+ },
1074
+ [controlled]
1075
+ );
1076
+
1077
+ const context = useMemo(() => ({ triggerRef, setOpen }), [setOpen]);
1078
+ const [button, ...overlay] = Children.toArray(children);
1079
+
1080
+ return (
1081
+ <MegaMenuTriggerContext.Provider value={context}>
1082
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
1083
+ {button}
1084
+ <DropdownPortal>
1085
+ <PopoverPrimitive.Content
1086
+ ref={contentRef}
1087
+ // Роль `dialog` Radix ставит по умолчанию, а это навигация: `nav`
1088
+ // внутри `div` без роли, как в APG Disclosure Navigation.
1089
+ role={undefined}
1090
+ align="start"
1091
+ sideOffset={4}
1092
+ collisionPadding={8}
1093
+ className={DROPDOWN_CONTENT_CLASS}
1094
+ onOpenAutoFocus={(event) => {
1095
+ tabbedAway.current = false;
1096
+ // Фокус — на Категорию, а не на первый фокусируемый (им была бы
1097
+ // она же, но только пока Витрина закрыта).
1098
+ event.preventDefault();
1099
+ const root = contentRef.current;
1100
+ (root && entryCategory(root))?.focus({ preventScroll: true });
1101
+ }}
1102
+ onCloseAutoFocus={(event) => {
1103
+ // Возврат фокуса — свой: Radix не возвращает его после щелчка по
1104
+ // самой кнопке (тот считается щелчком снаружи), а нам нужно.
1105
+ event.preventDefault();
1106
+ if (tabbedAway.current) {
1107
+ tabbedAway.current = false;
1108
+ return;
1109
+ }
1110
+ const active = document.activeElement;
1111
+ // Фокус ушёл на другой элемент (щелчок по полю) — не отнимаем его.
1112
+ if (!active || active === document.body || contentRef.current?.contains(active))
1113
+ triggerRef.current?.focus();
1114
+ }}
1115
+ onEscapeKeyDown={(event) => {
1116
+ // Escape в Витрине — её собственный: вернуть фокус на Категорию и
1117
+ // закрыть Витрину (обработчик в `MegaMenuCategory`). Оверлей при
1118
+ // этом остаётся. В поле ввода Escape оверлею отдаём: иначе в
1119
+ // Витрине с поиском он не закрыл бы ничего.
1120
+ const target = event.target;
1121
+ if (
1122
+ target instanceof Element &&
1123
+ target.closest(SHOWCASE_SELECTOR) &&
1124
+ !isTextEntry(target)
1125
+ )
1126
+ event.preventDefault();
1127
+ }}
1128
+ onKeyDownCapture={(event) => {
1129
+ if (event.key !== "Tab") return;
1130
+ const stops = tabStops(event.currentTarget);
1131
+ const edge = event.shiftKey ? stops[0] : stops[stops.length - 1];
1132
+ if (!edge || event.target !== edge) return;
1133
+ // Оверлей лежит в портале в конце `body`: «дальше по Tab» от него
1134
+ // — не следующий элемент страницы. Поэтому фокус сначала встаёт
1135
+ // на кнопку, и браузер продолжает Tab от неё. Назад (Shift+Tab)
1136
+ // кнопка и есть предыдущая остановка — на ней и остаёмся.
1137
+ triggerRef.current?.focus();
1138
+ if (event.shiftKey) event.preventDefault();
1139
+ else tabbedAway.current = true;
1140
+ setOpen(false);
1141
+ }}
1142
+ onClick={(event) => {
1143
+ // Переход по ссылке закрывает мегаменю: после навигации оно не
1144
+ // должно висеть поверх новой страницы. Роутерные ссылки тоже
1145
+ // `a`, а их `preventDefault` закрытию не мешает. Щелчок с
1146
+ // модификатором (Cmd/Ctrl, Shift — новая вкладка или окно) и
1147
+ // средняя кнопка страницу не меняют: каталог остаётся открытым,
1148
+ // чтобы можно было открыть в фоне несколько разделов подряд.
1149
+ const modified =
1150
+ event.metaKey ||
1151
+ event.ctrlKey ||
1152
+ event.shiftKey ||
1153
+ event.altKey ||
1154
+ event.button !== 0;
1155
+ if (!modified && event.target instanceof Element && event.target.closest("a")) {
1156
+ setOpen(false);
1157
+ }
1158
+ }}
1159
+ >
1160
+ {overlay}
1161
+ </PopoverPrimitive.Content>
1162
+ </DropdownPortal>
1163
+ </PopoverPrimitive.Root>
1164
+ </MegaMenuTriggerContext.Provider>
1165
+ );
1166
+ }
1167
+
1168
+ export interface MegaMenuButtonProps extends ButtonLookOptions {
1169
+ /** Подпись кнопки. С `iconOnly` не показывается. */
1170
+ children?: ReactNode;
1171
+ /** Иконка слева. С `iconOnly` — единственное содержимое. */
1172
+ icon?: ReactNode;
1173
+ /** Кнопка заблокирована: мегаменю не открывается. */
1174
+ disabled?: boolean;
1175
+ /** Доступное имя. Обязательно, когда подписи нет. */
1176
+ "aria-label"?: string;
1177
+ /** Классы на кнопку. */
1178
+ className?: string;
1179
+ }
1180
+
1181
+ /**
1182
+ * Кнопка, открывающая мегаменю, — первый ребёнок `MegaMenuTrigger`.
1183
+ *
1184
+ * Вид тот же, что у `MenuButton` (`lib/button.ts`). Семантика — раскрыватель:
1185
+ * `aria-expanded` и `aria-controls`, без `aria-haspopup`. Radix по умолчанию
1186
+ * объявляет `aria-haspopup="dialog"`, а оверлей — не диалог, а навигация, и
1187
+ * скринридер не должен обещать диалог.
1188
+ */
1189
+ export const MegaMenuButton = forwardRef<HTMLButtonElement, MegaMenuButtonProps>(
1190
+ function MegaMenuButton(
1191
+ { children, icon, disabled, variant, tone, size, iconOnly, selected, className, ...rest },
1192
+ forwardedRef
1193
+ ) {
1194
+ const trigger = useContext(MegaMenuTriggerContext);
1195
+ if (!trigger) throw new Error("MegaMenuButton must be rendered inside MegaMenuTrigger");
1196
+ return (
1197
+ <PopoverPrimitive.Trigger asChild>
1198
+ <button
1199
+ ref={(node) => {
1200
+ trigger.triggerRef.current = node;
1201
+ if (typeof forwardedRef === "function") forwardedRef(node);
1202
+ else if (forwardedRef) forwardedRef.current = node;
1203
+ }}
1204
+ type="button"
1205
+ disabled={disabled}
1206
+ aria-haspopup={undefined}
1207
+ className={cn(buttonClasses({ variant, tone, size, iconOnly, selected }), className)}
1208
+ onKeyDown={(event) => {
1209
+ // Щелчок, Enter и Пробел открывают сами (Radix); стрелка вниз — по
1210
+ // привычке к выпадающим спискам.
1211
+ if (event.key !== "ArrowDown") return;
1212
+ event.preventDefault();
1213
+ trigger.setOpen(true);
1214
+ }}
1215
+ {...rest}
1216
+ >
1217
+ {icon}
1218
+ {children != null && !iconOnly ? <span>{children}</span> : null}
1219
+ </button>
1220
+ </PopoverPrimitive.Trigger>
1221
+ );
1222
+ }
1223
+ );