@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.
@@ -39,7 +39,7 @@ import { useMenuAim } from "./useMenuAim.js";
39
39
  МЕГАМЕНЮ — НАВИГАЦИЯ, А НЕ МЕНЮ КОМАНД.
40
40
 
41
41
  Отсюда шаблон WAI-ARIA Disclosure Navigation, а не `role="menu"`: Категория
42
- с Витриной — кнопка с `aria-expanded`, Витрина — регион со ссылками, ссылки
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
- /** Категория — единственная остановка Tab в Столбце (roving tabindex). */
71
- tabStop: string | null;
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
- /** Категория, чья Витрина сейчас заменяет Столбец; `null` — Столбец на месте. */
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
- 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;
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
- /** Зазор между Столбцом и Витриной (`ms-1`) и запас до края окна, пиксели. */
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
- * Ширина Витрины — длина CSS или пиксели. Одна на все Категории: каталог не
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
- * Витрина — потомок своей Категории в DOM, а не портал. Отсюда сразу три
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
- const categories = categoryChildren(children);
208
- const firstEnabled = categories.find((category) => !category.props.disabled)?.props.id ?? null;
209
- // Остановка Tab: где пользователь был последним, иначе открытая, иначе первая.
210
- const tabStop = focusedId ?? value ?? firstEnabled;
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
- // Прицелу нужна открытая Витрина; ищется по id, а не ref'ом: её рисует Категория.
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
- // Спуск возможен лишь в Категорию с Витриной: `value` с чужим ключом или
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
- Столбец обязан остаться остановкой 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
- });
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
- // У ссылки-Категории Enter — переход, Пробел — прокрутка страницы:
413
- // обе роли браузерные, отнимать их незачем.
414
- if (!hasShowcase || node.getAttribute("aria-disabled") === "true") return;
495
+ // Пробел посреди набранного — часть подписи («Home and garden»), а
496
+ // не открытие Категории.
497
+ if (typeahead.current.text === "") {
498
+ openFromKey();
499
+ return;
500
+ }
501
+ // Не открываем и без совпадения: кнопка сработала бы на отпускании.
415
502
  event.preventDefault();
416
- openCategory(id, true);
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
- 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);
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
- return;
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
- /** Содержимое Витрины: `MegaMenuGroup` и что угодно своё. Без детей Витрины нет. */
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
- tabIndex: menu.tabStop === id ? 0 : -1,
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}>{label}</span>
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
- // Скрыта, пока Витрина занимает её место: `aria-expanded` и
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
- /** Ссылки колонки — `MegaMenuLink`. Каждая уезжает в свой пункт списка. */
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
- {href != null ? (
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
- {Children.map(children, (child) => (child == null ? null : <li>{child}</li>))}
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
- * Текущую страницу кит не вычисляет — проп `current`, как `active` у
916
- * `NavItem`: правила сравнения адресов у каждого роутера свои (ADR-0018).
1060
+ * Текущую страницу кит не вычисляет — проп `active`, как у `NavItem`:
1061
+ * правила сравнения адресов у каждого роутера свои (ADR-0018).
917
1062
  */
918
1063
  export const MegaMenuLink = forwardRef<HTMLAnchorElement, MegaMenuLinkProps>(function MegaMenuLink(
919
- { current = false, icon, asChild = false, children, className, ...rest },
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
- Витрина, roving tabindex, Escape в Витрине — всё то же. Обёртка добавляет
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
- * Витрина — `absolute` от `nav`, рядом со Столбцом. `overflow: auto` на самом
972
- * оверлее обрезал бы её по его рамке, и от Витрины осталась бы полоска шириной
973
- * в зазор. Поэтому высотой ограничены `ul` Столбца и `section` Витрины — классом
1116
+ * Полка — `absolute` от `nav`, рядом со Столбцом. `overflow: auto` на самом
1117
+ * оверлее обрезал бы её по его рамке, и от Полки осталась бы полоска шириной
1118
+ * в зазор. Поэтому высотой ограничены `ul` Столбца и `section` Полки — классом
974
1119
  * `OVERLAY_SCROLL_CLASS`, который ставит сам `MegaMenu`. `ul` не позиционирован,
975
- * и содержащий блок Витрины — `nav` выше него, так что `overflow` на `ul`
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
- /** Поле ввода внутри Витрины Escape оставляет себе — иначе закрываем оверлей. */
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
- // закрыть Витрину (обработчик в `MegaMenuCategory`). Оверлей при
1276
+ // Escape в Полке — её собственный: вернуть фокус на Категорию и
1277
+ // закрыть Полку (обработчик в `MegaMenuCategory`). Оверлей при
1118
1278
  // этом остаётся. В поле ввода Escape оверлею отдаём: иначе в
1119
- // Витрине с поиском он не закрыл бы ничего.
1279
+ // Полке с поиском он не закрыл бы ничего.
1120
1280
  const target = event.target;
1121
1281
  if (
1122
1282
  target instanceof Element &&