@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,273 @@
1
+ ---
2
+ component: MegaMenu
3
+ exports: [MegaMenu, MegaMenuCategory, MegaMenuGroup, MegaMenuLink, MegaMenuTrigger, MegaMenuButton]
4
+ category: navigation
5
+ status: stable
6
+ source: src/components/navigation/MegaMenu.tsx
7
+ story: src/components/navigation/MegaMenu.stories.tsx
8
+ sourceHash: 8e166f4b1ab7
9
+ ---
10
+
11
+ # MegaMenu
12
+
13
+ ## Summary for AI
14
+
15
+ `MegaMenu` is an Amazon-style store catalog: a category column on the left and, next to the hovered
16
+ category, its showcase with columns of links and any content of your own. It is link navigation, not a command
17
+ menu: the markup follows the WAI-ARIA Disclosure Navigation pattern (`nav`, buttons with `aria-expanded`, real
18
+ `a` elements). Categories are `MegaMenuCategory` as direct children, columns are `MegaMenuGroup`, links are
19
+ `MegaMenuLink`. The same catalog opens as a dropdown overlay from a button: `MegaMenuTrigger` with
20
+ `MegaMenuButton` and an unchanged `MegaMenu`.
21
+
22
+ ## When to use
23
+
24
+ - A catalog with ten or more sections, each with subsections in columns: a store, a marketplace, a help portal.
25
+ - The sections should be visible at once and the subsections should appear on hover, without taking the user
26
+ off the page.
27
+ - The showcase needs content of its own next to the links: a promo card, an image, a selection.
28
+
29
+ ## When not to use
30
+
31
+ - For commands on an object ("Rename", "Delete"), use `Menu`: it holds actions, not navigation.
32
+ - For application navigation with a current section, use `SideNav`.
33
+ - For a deep hierarchy that expands in place, use `TreeView`.
34
+ - For two or three links with no subsections, use a plain list of links or `Tabs`.
35
+
36
+ ## Minimal example
37
+
38
+ ```tsx
39
+ import { MegaMenu, MegaMenuCategory, MegaMenuGroup, MegaMenuLink } from "@olddevs/ui";
40
+
41
+ <MegaMenu aria-label="Catalog">
42
+ <MegaMenuCategory id="computers" label="Computers" href="/computers">
43
+ <MegaMenuGroup title="Laptops" href="/computers/laptops">
44
+ <MegaMenuLink href="/computers/laptops/gaming">Gaming</MegaMenuLink>
45
+ <MegaMenuLink href="/computers/laptops/ultrabooks" current>
46
+ Ultrabooks
47
+ </MegaMenuLink>
48
+ </MegaMenuGroup>
49
+ </MegaMenuCategory>
50
+ <MegaMenuCategory id="deals" label="Deals of the day" href="/deals" />
51
+ </MegaMenu>;
52
+ ```
53
+
54
+ ## Props
55
+
56
+ `MegaMenu`:
57
+
58
+ | Prop | Default | Meaning |
59
+ | ------------------------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------- |
60
+ | `aria-label` | — | The accessible name of the navigation. Required: on a page with several `nav` elements, the name tells them apart |
61
+ | `value` / `defaultValue` / `onValueChange` | `null` | The open category (its `id`) or `null`, controlled or uncontrolled |
62
+ | `showcaseWidth` | `"40rem"` | The width of the showcase, shared by all categories so that the catalog does not jump between them |
63
+ | `className` | — | Classes for the `nav`; the default width of the category column is `w-64` |
64
+
65
+ `MegaMenuCategory`: `id` is the key for `value`; `label` is the text in the category column and the name of the
66
+ showcase; `icon`; `href` or `link` is the address of the whole section; `disabled`; the children are the content of
67
+ the showcase. For a category with a showcase, the address becomes a title link at the top of the showcase; for a
68
+ category without children, the row in the category column is itself the link. `link` is a router link passed as an
69
+ empty element (`link={<Link href="/x" />}`), and the label moves inside it. It is a separate prop rather than
70
+ `asChild` because the children of a category are its showcase.
71
+
72
+ `MegaMenuGroup`: `title` is the title of the column and the name of its list; `href` turns the title into a link;
73
+ the children are `MegaMenuLink` elements, and each one is placed in its own list item.
74
+
75
+ `MegaMenuLink`: the usual `a` props, `icon`, `current` and `asChild`. `current` sets `aria-current="page"` and the
76
+ look of the current link. The kit does not work out the current page itself, because every router compares
77
+ addresses by its own rules. `asChild` renders the link as a router element:
78
+ `<MegaMenuLink asChild><Link href="/x">Label</Link></MegaMenuLink>`.
79
+
80
+ ## Hover and the showcase
81
+
82
+ Hovering a category with the mouse opens its showcase at once, with no delay. Exactly one category is highlighted,
83
+ the one whose showcase is open: the highlight follows `aria-expanded`, not `:hover`. Hovering a category without a
84
+ showcase closes the open one, with the same caveat about aim as below.
85
+
86
+ The showcase closes 150 ms after the pointer has left both the category column and the showcase, so a brief
87
+ excursion past the edge does not reset the catalog. It also closes on a pointer press outside the catalog and when
88
+ focus leaves the catalog.
89
+
90
+ The content of a showcase is mounted only for the open category, so a catalog of a hundred links and images does
91
+ not load everything at once. The region itself is always there, hidden: the button's `aria-controls` points to it.
92
+
93
+ The showcase is a DOM descendant of its category, not a portal. That is why the Tab order runs "category, its
94
+ links, then on down the page", and why the showcase sits flush against the category column without any position
95
+ calculation. An ancestor with `overflow: hidden` clips it, so put the catalog where there is room beside it.
96
+
97
+ The showcase sits on the end side of the line: on the right, or on the left in right-to-left writing. If there is
98
+ not enough room there and there is enough on the other side, it moves to the other side. The kit measures the side
99
+ ahead of time and again when the window is resized, so the arrow key that points "toward the showcase" already
100
+ points to where the showcase will appear.
101
+
102
+ ## Touch and narrow screens
103
+
104
+ Touch has no hover, so the touch itself (`click`) opens a category, without aim. A pointer whose `pointerType` is
105
+ "touch" or "pen" neither opens a category on hover nor closes the showcase when it leaves: a pen behaves like a
106
+ finger. Touching an open category again neither closes it nor goes anywhere. The showcase closes on a touch outside
107
+ the catalog or on Escape.
108
+
109
+ The mouse behaves as before: hover, aim and closing after 150 ms all remain while the screen is wide.
110
+
111
+ On a narrow screen, under 640px (the same breakpoint as `Menu` submenus and Editor submenus), there is no room beside
112
+ the category column for the showcase: the edge of the window would cut it off on the right. So the catalog works as a
113
+ drill-down:
114
+
115
+ - The category column takes the full width, and category labels wrap instead of being truncated.
116
+ - An open category (by touch, click, Enter or Space) replaces the category column with its showcase. The showcase
117
+ starts with a "Back" row that shows the category name. If the category has a link of its own, the link shows the
118
+ name, on the row below, instead of the "Back" row. Below that, the columns of links follow, one under another.
119
+ - "Back" returns the category column and puts focus on the category the person came from. Escape inside the showcase
120
+ works like "Back".
121
+ - When you drill down, focus moves to "Back": the category button is hidden along with the category column, and focus
122
+ left on it would be lost. In the showcase on a narrow screen, the arrow key toward the category column does
123
+ nothing, because the column is not beside it.
124
+ - There is no aim on a narrow screen: hovering with the mouse does not open a category, and moving the mouse away does
125
+ not cancel the drill-down. Replacing the column under the cursor would pull it out from under the person's hand.
126
+ - The markup stays on the disclosure pattern: the category button keeps `aria-expanded` and `aria-controls` pointing
127
+ to the region on screen (the button itself is hidden during the drill-down), and the region is named after the
128
+ category.
129
+
130
+ The "Back" label comes from the message catalog: the key is `megaMenu.back`, with the default "Back"; the Russian
131
+ catalog supplies "Назад".
132
+
133
+ The kit reads the width through `matchMedia`, not through CSS, because the width determines the contents of the
134
+ markup (the category column is hidden), not just the look. Before hydration and on the server, the hook reports a wide
135
+ screen (ADR-0018): the server does not know the width, and the markup it produces is always the regular one. On a phone,
136
+ after hydration, a showcase opened by `defaultValue` is re-rendered once as a drill-down. On a phone the `nav` takes
137
+ the full width through the `max-sm:w-full` class; a `w-*` class from `className` applies on a wide screen.
138
+
139
+ ## Aim
140
+
141
+ On its way from a category to its showcase, the pointer cuts diagonally and brushes against the neighboring
142
+ categories. If hover opened them at once, the showcase would change under the person's hand, and the links would be
143
+ out of reach. Aim watches where the pointer is heading: while it moves toward the open showcase, a neighboring
144
+ category does not take over the hover.
145
+
146
+ - The direction of the pointer's last step is compared with the wedge from its previous position to the two corners
147
+ of the showcase's near edge: Kamens's triangle from "Where it comes from". A step into the wedge means the pointer
148
+ is aiming.
149
+ - One movement off course is forgiven; two in a row are not, and then the category under the pointer opens.
150
+ - If the pointer stops for 300 ms, the category under it opens even if it was aiming. The menu does not get stuck.
151
+ - When the pointer reaches the showcase, the deferred switch is canceled.
152
+ - Clicking a category opens it at once, bypassing aim. The keyboard knows nothing about aim.
153
+ - Mouse only, and only on a wide screen: touch and pen open a category at once, and a narrow screen has no aim at all.
154
+ - The first hover, while there is no showcase yet, opens the category with no delay at all: there is nothing to aim at.
155
+
156
+ The model is the same as for react-aria submenus (`Menu` with `SubmenuTrigger`), so that the two menus of the kit
157
+ behave alike. There are two differences. The category switch is deferred, not suppressed with `pointer-events: none`
158
+ on the category column, since that would lose the highlight under the pointer and the click on a neighboring category.
159
+ And the stop time is 300 ms, as in Kamens's plugin (more on it in "Where it comes from"), not a second, as in react-aria: its submenu is short, while the
160
+ category column of a catalog gets scrolled through.
161
+
162
+ ## Where it comes from
163
+
164
+ The technique comes from Amazon. By 2013 the "Shop by Department" menu on amazon.com already worked like this: a
165
+ column of departments on the left, a large panel on the right, and switching with no delay at all, while the pointer
166
+ could cut diagonally toward the panel without knocking it away. Ben Kamens worked out how it does that.
167
+
168
+ - **6 March 2013**: Ben Kamens, a developer at Khan Academy, publishes the analysis "Breaking down Amazon's mega
169
+ dropdown". He shows that Amazon does not slow the opening down with a delay, as most dropdown menus do, but
170
+ follows the direction of the pointer: while it stays inside the triangle between the pointer and the corners of the
171
+ panel, the submenu does not change. Khan Academy puts the technique into its new "Learn" menu.
172
+ - **March 2013**: Kamens publishes the `jQuery-menu-aim` plugin on GitHub: the triangle, a tolerance near the corners
173
+ and a 300 ms delay for a pointer that has stopped.
174
+ - **7 March 2013**: a retelling of the article appears on Habr, from which the technique spread across the Russian-speaking web.
175
+ - **After that**: the technique moves into libraries: the "safe polygon" `safePolygon()` in Floating UI, the "grace
176
+ area" of submenus in Radix, the "safe triangle" `useSafelyMouseToSubmenu` in react-aria. Submenus shipped in
177
+ react-aria as an alpha on 13 February 2024 (v1.1.0) and became stable on 1 May 2024 (v1.2.0).
178
+ - **2026**: `MegaMenu` in `@olddevs/ui`: the react-aria model and Kamens's pause.
179
+
180
+ Primary sources:
181
+ [Ben Kamens's article](https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown),
182
+ [the `jQuery-menu-aim` repository](https://github.com/kamens/jQuery-menu-aim),
183
+ [a retelling on Habr, in Russian](https://habr.com/ru/articles/171905/).
184
+
185
+ ## Keyboard and names
186
+
187
+ - The category column is a single Tab stop. The Up and Down arrows, Home and End move through the categories;
188
+ disabled ones are skipped. When the user comes back with Tab, they land on the category they were on.
189
+ - Enter, Space and the arrow toward the showcase open it and put focus on the first link. On a category that is a
190
+ link, Enter follows it.
191
+ - Inside the showcase, plain Tab works. The arrow toward the category column returns focus to the category, and so
192
+ does Escape, which also closes the showcase. Tab past the last link moves on down the page and closes the showcase.
193
+ - While the showcase is open, it follows the arrow-key focus the way it follows hover.
194
+ - The arrow "toward the showcase" is the one that points to where the showcase sits: in right-to-left writing and at
195
+ the edge of the screen, the arrows swap places.
196
+ - A screen reader hears a navigation with the name from `aria-label`, the categories as expand/collapse buttons, the
197
+ showcase as a region named after the category, and a column as a list named after its title.
198
+
199
+ ## Dropdown mode
200
+
201
+ A catalog in a page header is opened with a "Catalog" button rather than kept on the page. The markup of the
202
+ categories is the same as in the embedded mode; only the wrapper changes: put `MegaMenu` into `MegaMenuTrigger`
203
+ as the second child, after the `MegaMenuButton`.
204
+
205
+ ```tsx
206
+ import {
207
+ MegaMenu,
208
+ MegaMenuButton,
209
+ MegaMenuCategory,
210
+ MegaMenuGroup,
211
+ MegaMenuLink,
212
+ MegaMenuTrigger,
213
+ } from "@olddevs/ui";
214
+ import { ListIcon } from "@olddevs/ui/icons";
215
+
216
+ <MegaMenuTrigger>
217
+ <MegaMenuButton icon={<ListIcon size={16} />}>Catalog</MegaMenuButton>
218
+ <MegaMenu aria-label="Catalog">
219
+ <MegaMenuCategory id="computers" label="Computers" href="/computers">
220
+ <MegaMenuGroup title="Laptops">
221
+ <MegaMenuLink href="/computers/laptops/gaming">Gaming</MegaMenuLink>
222
+ </MegaMenuGroup>
223
+ </MegaMenuCategory>
224
+ </MegaMenu>
225
+ </MegaMenuTrigger>;
226
+ ```
227
+
228
+ `MegaMenuTrigger`: `open`, `defaultOpen` and `onOpenChange` control whether the overlay is open, controlled or
229
+ uncontrolled, as with other overlays of the kit. The first child is the button, and all the others go into the
230
+ overlay. Children are sorted by position, so you can wrap the button in a component of your own and move `MegaMenu`
231
+ into one. `MegaMenuButton` accepts the kit's button look (`variant`, `tone`, `size`, `iconOnly`, `selected`), `icon`,
232
+ `disabled`, `aria-label` and `className`; the look is the same as for `MenuButton`.
233
+
234
+ - **Opening.** Click, Enter, Space and the Down arrow on the button. Hover does not open the catalog: it should not
235
+ jump out at a stray mouse movement.
236
+ - **Focus.** On opening, focus goes to the first available category (or, if one is already open through `value` or
237
+ `defaultValue`, to that one), and the showcase stays closed until a category is chosen. After that the category column
238
+ and the showcase work as in the embedded mode.
239
+ - **Closing.** Escape on a category, a press outside the catalog, a second click on the button, following any link (a
240
+ category, a column title, a `MegaMenuLink`, router links included) and Tab past the last element.
241
+ The first Escape inside a showcase closes only the showcase and returns focus to its category; the next one closes
242
+ the overlay.
243
+ - **Returning focus.** Focus returns to the button on Escape and on a second click. After Tab past the last element,
244
+ focus moves on down the page, past the button; Shift+Tab before the first element returns it to the button. After a
245
+ press outside the catalog, focus stays where the user pressed.
246
+ - **Screen reader.** The button is a disclosure with `aria-expanded` and `aria-controls` and no `aria-haspopup`: the
247
+ overlay is neither a menu nor a dialog, but the same navigation with links. The overlay has no `dialog` role.
248
+ - **Layers.** The overlay sits on the `popover` layer: above the scrim of an open `Dialog`, below the confirmation.
249
+ Inside a dialog, the first Escape closes the catalog and the next one closes the dialog. If focus is in an open
250
+ showcase, the very first Escape closes only the showcase and returns focus to its category.
251
+ - **Height.** The overlay is no taller than the visible area from the button to the edge of the window. The category
252
+ column and the showcase each scroll within themselves (`overscroll-contain`), not the overlay as a whole: otherwise the
253
+ overlay's frame would clip the showcase, which stands beside the category column. On iOS the height is capped at
254
+ `min(24rem, 60dvh)`, like the `Menu` panel.
255
+ - **Appearance.** Smooth, on the kit's motion scale; with `prefers-reduced-motion`, no animation.
256
+ - **Server.** The server renders a closed button; the overlay is mounted in a portal after opening.
257
+
258
+ ## Related sources and tests
259
+
260
+ - Implementation: `src/components/navigation/MegaMenu.tsx`; aim is in `src/components/navigation/useMenuAim.ts`.
261
+ - Tests: `tests/mega-menu.test.tsx` covers roles and `aria-controls`, hover and mounting only the open
262
+ showcase, closing with a delay, roving tabindex, Enter, Escape and the arrows, Tab past the last link,
263
+ a category that is a link, a disabled category, `current`, `asChild` and `link`, the controlled value, a press
264
+ outside the catalog and server rendering; `tests/mega-menu-aim.test.tsx` covers aim on mocked geometry and fake
265
+ time, and the flip at the edge; `tests/mega-menu-touch.test.tsx` covers touch and pen, a mocked `matchMedia`,
266
+ the drill-down in place of the category column, "Back" and focus, Escape, labels from the message catalog and server
267
+ rendering; the browser test `tests-visual/mega-menu.spec.ts` uses a real mouse moving diagonally and down
268
+ the category column.
269
+ - Dropdown mode: `tests/mega-menu-dropdown.test.tsx` covers opening by click and keys with focus on the first
270
+ category, every way of closing and returning focus, Escape in the showcase, `open` / `defaultOpen` / `onOpenChange`, the
271
+ `popover` layer, the order of Escape inside a `Dialog` and server rendering.
272
+ - Story: `src/components/navigation/MegaMenu.stories.tsx`, including `DropdownInTopBar`, a mega menu in a top bar; at phone
273
+ width, `PhoneColumn` and `PhoneDrillDown`.
@@ -0,0 +1,258 @@
1
+ ---
2
+ component: MegaMenu
3
+ exports: [MegaMenu, MegaMenuCategory, MegaMenuGroup, MegaMenuLink, MegaMenuTrigger, MegaMenuButton]
4
+ category: navigation
5
+ status: stable
6
+ source: src/components/navigation/MegaMenu.tsx
7
+ story: src/components/navigation/MegaMenu.stories.tsx
8
+ ---
9
+
10
+ # MegaMenu
11
+
12
+ ## Кратко для AI
13
+
14
+ `MegaMenu` — каталог магазина в духе Amazon: слева Столбец категорий, справа от наведённой Категории — её
15
+ Витрина с колонками ссылок и любым своим содержимым. Это навигация по ссылкам, а не меню команд: разметка по
16
+ шаблону WAI-ARIA Disclosure Navigation (`nav`, кнопки с `aria-expanded`, настоящие `a`). Категории —
17
+ `MegaMenuCategory` прямыми детьми, колонки — `MegaMenuGroup`, ссылки — `MegaMenuLink`. Тот же каталог
18
+ открывается выпадающим оверлеем от кнопки: `MegaMenuTrigger` с `MegaMenuButton` и неизменным `MegaMenu`.
19
+
20
+ ## Когда использовать
21
+
22
+ - Каталог с десятком и больше разделов, у каждого — подразделы колонками: магазин, маркетплейс, справочный
23
+ портал.
24
+ - Разделы надо показать сразу, а подразделы — по наведению, не уводя пользователя со страницы.
25
+ - В Витрине рядом со ссылками нужно своё содержимое: промо-карточка, картинка, подборка.
26
+
27
+ ## Когда не использовать
28
+
29
+ - Для команд над объектом («Переименовать», «Удалить») — `Menu`: там действия, а не переходы.
30
+ - Для навигации приложения с текущим разделом — `SideNav`.
31
+ - Для глубокой иерархии, которую раскрывают на месте, — `TreeView`.
32
+ - Для двух-трёх ссылок без подразделов — обычный список ссылок или `Tabs`.
33
+
34
+ ## Минимальный пример
35
+
36
+ ```tsx
37
+ import { MegaMenu, MegaMenuCategory, MegaMenuGroup, MegaMenuLink } from "@olddevs/ui";
38
+
39
+ <MegaMenu aria-label="Каталог">
40
+ <MegaMenuCategory id="computers" label="Компьютеры" href="/computers">
41
+ <MegaMenuGroup title="Ноутбуки" href="/computers/laptops">
42
+ <MegaMenuLink href="/computers/laptops/gaming">Игровые</MegaMenuLink>
43
+ <MegaMenuLink href="/computers/laptops/ultrabooks" current>
44
+ Ультрабуки
45
+ </MegaMenuLink>
46
+ </MegaMenuGroup>
47
+ </MegaMenuCategory>
48
+ <MegaMenuCategory id="deals" label="Скидки дня" href="/deals" />
49
+ </MegaMenu>;
50
+ ```
51
+
52
+ ## Пропы
53
+
54
+ `MegaMenu`:
55
+
56
+ | Проп | По умолчанию | Смысл |
57
+ | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------ |
58
+ | `aria-label` | — | Доступное имя навигации, обязательно: на странице с несколькими `nav` их различают по нему |
59
+ | `value` / `defaultValue` / `onValueChange` | `null` | Открытая Категория (её `id`) или `null`, управляемо или нет |
60
+ | `showcaseWidth` | `"40rem"` | Ширина Витрины, одна на все Категории: каталог не прыгает при переходе между ними |
61
+ | `className` | — | Классы на `nav`; ширина Столбца по умолчанию — `w-64` |
62
+
63
+ `MegaMenuCategory`: `id` — ключ для `value`; `label` — подпись в Столбце и имя Витрины; `icon`; `href` или
64
+ `link` — адрес раздела целиком; `disabled`; дети — содержимое Витрины. У Категории с Витриной адрес становится
65
+ заголовком-ссылкой в начале Витрины, у Категории без детей сама строка Столбца — ссылка. `link` — ссылка роутера
66
+ пустым элементом (`link={<Link href="/x" />}`), подпись уезжает внутрь; это отдельный проп, а не `asChild`,
67
+ потому что дети Категории — её Витрина.
68
+
69
+ `MegaMenuGroup`: `title` — заголовок колонки, он же подпись списка; `href` делает заголовок ссылкой; дети —
70
+ `MegaMenuLink`, каждая уезжает в свой пункт списка.
71
+
72
+ `MegaMenuLink`: обычные пропы `a`, `icon`, `current` и `asChild`. `current` ставит `aria-current="page"` и вид
73
+ текущей: текущую страницу кит не вычисляет, правила сравнения адресов у каждого роутера свои. `asChild` выводит
74
+ ссылку элементом роутера: `<MegaMenuLink asChild><Link href="/x">Подпись</Link></MegaMenuLink>`.
75
+
76
+ ## Наведение и Витрина
77
+
78
+ Наведение мышью на Категорию открывает её Витрину сразу, без задержки. Подсвечена ровно та Категория, чья
79
+ Витрина открыта: подсветка идёт по `aria-expanded`, а не по `:hover`. Наведение на Категорию без Витрины
80
+ закрывает открытую — с той же оговоркой про Прицел, что ниже.
81
+
82
+ Витрина закрывается, когда курсор ушёл и со Столбца, и с Витрины, через 150 мс: короткий выход за край не
83
+ сбрасывает каталог. Закрывают её и нажатие указателем вне каталога, и уход фокуса со страницы каталога.
84
+
85
+ Содержимое Витрины монтируется только у открытой Категории: каталог из сотни ссылок и картинок не грузит всё
86
+ разом. Сам регион есть всегда, скрытым: на него указывает `aria-controls` кнопки.
87
+
88
+ Витрина — потомок своей Категории в DOM, а не портал. Поэтому порядок Tab идёт «Категория → её ссылки → дальше
89
+ по странице», и Витрина встаёт вплотную к Столбцу без расчёта позиции. Предок с `overflow: hidden` её обрежет:
90
+ ставьте каталог туда, где рядом есть место.
91
+
92
+ Витрина стоит со стороны конца строки — справа, а при письме справа налево слева. Если там не хватает места, а с
93
+ другой стороны хватает, она переходит на другую сторону; сторону кит меряет заранее и при смене размера окна,
94
+ поэтому стрелка «к Витрине» сразу смотрит туда, где Витрина окажется.
95
+
96
+ ## Касание и узкий экран
97
+
98
+ Касание наведения не знает, поэтому Категорию открывает само касание (`click`), без Прицела. Указатель с
99
+ `pointerType` «touch» или «pen» не открывает Категорию наведением и не закрывает Витрину уходом: перо
100
+ ведёт себя как палец. Повторное касание открытой Категории её не закрывает и никуда не ведёт: закрывает
101
+ Витрину касание вне каталога или Escape.
102
+
103
+ Мышь ведёт себя, как раньше: наведение, Прицел и закрытие через 150 мс остаются, пока экран широкий.
104
+
105
+ На узком экране — меньше 640px, граница та же, что у подменю `Menu` и подменю Редактора, — Витрине некуда
106
+ встать сбоку: справа от Столбца её обрезал бы край окна. Поэтому каталог идёт «спуском»:
107
+
108
+ - Столбец занимает всю ширину, подписи Категорий переносятся, а не обрезаются.
109
+ - Открытая Категория (касанием, щелчком, Enter или Пробелом) заменяет Столбец своей Витриной. Витрина
110
+ начинается строкой «Назад» с названием Категории; если у Категории есть своя ссылка, название показывает она,
111
+ строкой ниже, а не строка «Назад». Ниже — колонки ссылок, одна под другой.
112
+ - «Назад» возвращает Столбец и ставит фокус на Категорию, из которой человек пришёл. Escape внутри
113
+ Витрины работает как «Назад».
114
+ - Фокус при спуске переходит на «Назад»: кнопка Категории скрыта вместе со Столбцом, и фокус на ней
115
+ пропал бы. Стрелка в сторону Столбца в Витрине на узком экране ничего не делает: Столбца рядом нет.
116
+ - Прицела на узком экране нет, наведение мышью Категорию не открывает, а уход мыши не отменяет спуск:
117
+ заменять Столбец под курсором значило бы вырывать его из-под руки.
118
+ - Разметка остаётся по шаблону disclosure: кнопка Категории сохраняет `aria-expanded` и `aria-controls`
119
+ на показанный регион (сама кнопка при спуске скрыта), регион подписан именем Категории.
120
+
121
+ Подпись «Назад» берётся из каталога сообщений: ключ `megaMenu.back`, по умолчанию «Back»; русский каталог
122
+ отдаёт «Назад».
123
+
124
+ Ширину кит читает через `matchMedia`, а не через CSS: от неё зависит состав разметки (прячется Столбец), а
125
+ не только вид. До гидратации и на сервере хук возвращает «широкий» (ADR-0018): сервер ширины не знает, и
126
+ разметка с него всегда обычная. На телефоне после гидратации Витрина, открытая `defaultValue`, один раз
127
+ перерисовывается спуском. На телефоне `nav` занимает всю ширину классом `max-sm:w-full`; `w-*` из `className` действует на
128
+ широком экране.
129
+
130
+ ## Прицел
131
+
132
+ Курсор идёт от Категории к её Витрине наискосок и по пути задевает соседние Категории. Если бы наведение
133
+ открывало их сразу, Витрина менялась бы у человека под рукой, и до ссылки было бы не дотянуться. Прицел смотрит,
134
+ куда движется курсор: пока он идёт в сторону открытой Витрины, соседняя Категория наведение не перехватывает.
135
+
136
+ - Направление последнего шага курсора сравнивается с сектором от его прежней точки к двум углам ближнего края
137
+ Витрины — это и есть треугольник Каменса из раздела «Откуда это». Шаг в сектор — курсор целится.
138
+ - Одно движение мимо прощается, два подряд — нет: тогда открывается Категория под курсором.
139
+ - Курсор остановился на 300 мс — открывается Категория под ним, даже если он целился. Меню не «залипает».
140
+ - Курсор дошёл до Витрины — отложенная смена отменяется.
141
+ - Щелчок по Категории открывает её сразу, мимо Прицела. Клавиатура Прицела не знает вовсе.
142
+ - Только мышь, и только на широком экране: касание и перо открывают Категорию сразу, а на узком экране
143
+ Прицела нет вовсе.
144
+ - Первое наведение, пока Витрины нет, открывает Категорию без всякой задержки: целиться ещё не во что.
145
+
146
+ Модель та же, что у подменю react-aria (`Menu` с `SubmenuTrigger`), чтобы два меню кита вели себя одинаково.
147
+ Отличий два. Смена Категории откладывается, а не гасится `pointer-events: none` на Столбце: иначе пропала бы
148
+ подсветка под курсором и щелчок по соседней Категории. И время остановки — 300 мс, как в плагине Каменса (о нём — в «Откуда это»), а не секунда,
149
+ как у react-aria: его подменю короткое, а Столбец каталога листают.
150
+
151
+ ## Откуда это
152
+
153
+ Приём пришёл из Amazon. К 2013 году меню «Shop by Department» на amazon.com уже работало так: столбец отделов
154
+ слева, большая панель справа, и переключение без всякой задержки — при этом наискосок к панели курсор проходил,
155
+ не сбивая её. Как это устроено, разобрал Бен Каменс.
156
+
157
+ - **6 марта 2013** — Бен Каменс, разработчик Khan Academy, публикует разбор «Breaking down Amazon's mega
158
+ dropdown». Он показывает, что Amazon не тормозит открытие задержкой, как большинство выпадающих меню, а следит
159
+ за направлением курсора: пока тот внутри треугольника между курсором и углами панели, подменю не меняется.
160
+ Khan Academy ставит приём в своё новое меню «Learn».
161
+ - **Март 2013** — Каменс выкладывает плагин `jQuery-menu-aim` на GitHub: треугольник, допуск у углов и
162
+ задержка 300 мс для курсора, который остановился.
163
+ - **7 марта 2013** — пересказ статьи на Хабре, с которого приём разошёлся по рунету.
164
+ - **Дальше** — приём уходит в библиотеки: «безопасный многоугольник» `safePolygon()` в Floating UI, «grace
165
+ area» подменю в Radix, «безопасный треугольник» `useSafelyMouseToSubmenu` в react-aria. В react-aria подменю
166
+ вышли альфой 13 февраля 2024 года (v1.1.0) и стали стабильными 1 мая 2024 года (v1.2.0).
167
+ - **2026** — `MegaMenu` в `@olddevs/ui`: модель react-aria и пауза Каменса.
168
+
169
+ Первоисточники:
170
+ [статья Бена Каменса](https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown),
171
+ [репозиторий `jQuery-menu-aim`](https://github.com/kamens/jQuery-menu-aim),
172
+ [пересказ на Хабре](https://habr.com/ru/articles/171905/).
173
+
174
+ ## Клавиатура и имена
175
+
176
+ - Столбец — одна остановка Tab. Стрелки вверх и вниз, Home и End ходят по Категориям; выключенные
177
+ пропускаются. Вернувшись Tab'ом, пользователь попадает на ту Категорию, где был.
178
+ - Enter, Пробел и стрелка в сторону Витрины открывают её и ставят фокус на первую ссылку. У Категории-ссылки
179
+ Enter — переход.
180
+ - Внутри Витрины работает обычный Tab. Стрелка в сторону Столбца возвращает фокус на Категорию, Escape — тоже,
181
+ и закрывает Витрину. Tab за последней ссылкой уходит дальше по странице и закрывает Витрину.
182
+ - Пока Витрина открыта, она следует за фокусом стрелок, как следует за наведением.
183
+ - Стрелка «к Витрине» — та, что смотрит туда, где Витрина стоит: при письме справа налево и у края экрана
184
+ стрелки меняются местами.
185
+ - Скринридер слышит навигацию с именем из `aria-label`, Категории — кнопками «развёрнуто / свёрнуто», Витрину
186
+ — регионом с именем Категории, колонку — списком с именем заголовка.
187
+
188
+ ## Выпадающий вид
189
+
190
+ Каталог в шапке открывают кнопкой «Каталог», а не держат на странице. Разметка Категорий та же, что во встроенном
191
+ виде, меняется только обёртка: `MegaMenu` кладут в `MegaMenuTrigger` вторым после кнопки `MegaMenuButton`.
192
+
193
+ ```tsx
194
+ import {
195
+ MegaMenu,
196
+ MegaMenuButton,
197
+ MegaMenuCategory,
198
+ MegaMenuGroup,
199
+ MegaMenuLink,
200
+ MegaMenuTrigger,
201
+ } from "@olddevs/ui";
202
+ import { ListIcon } from "@olddevs/ui/icons";
203
+
204
+ <MegaMenuTrigger>
205
+ <MegaMenuButton icon={<ListIcon size={16} />}>Каталог</MegaMenuButton>
206
+ <MegaMenu aria-label="Каталог">
207
+ <MegaMenuCategory id="computers" label="Компьютеры" href="/computers">
208
+ <MegaMenuGroup title="Ноутбуки">
209
+ <MegaMenuLink href="/computers/laptops/gaming">Игровые</MegaMenuLink>
210
+ </MegaMenuGroup>
211
+ </MegaMenuCategory>
212
+ </MegaMenu>
213
+ </MegaMenuTrigger>;
214
+ ```
215
+
216
+ `MegaMenuTrigger`: `open`, `defaultOpen` и `onOpenChange` — открытость, управляемо или нет, как у других
217
+ оверлеев кита. Первый ребёнок — кнопка, все остальные уходят в оверлей; разбор идёт по позиции, поэтому кнопку
218
+ можно обернуть своим компонентом, а `MegaMenu` — вынести в свой. `MegaMenuButton` принимает вид кнопки кита
219
+ (`variant`, `tone`, `size`, `iconOnly`, `selected`), `icon`, `disabled`, `aria-label` и `className`; вид тот же, что у
220
+ `MenuButton`.
221
+
222
+ - **Открытие.** Щелчок, Enter, Пробел и стрелка вниз на кнопке. Наведение каталог не открывает: он не должен
223
+ выпрыгивать от случайного движения мыши.
224
+ - **Фокус.** При открытии фокус встаёт на первую доступную Категорию (если одна из них уже открыта через `value` или
225
+ `defaultValue` — на неё), Витрина закрыта до выбора. Дальше Столбец и Витрина работают как во встроенном виде.
226
+ - **Закрытие.** Escape на Категории, нажатие вне каталога, повторный щелчок по кнопке, переход по любой ссылке
227
+ (Категория, заголовок колонки, `MegaMenuLink`, в том числе ссылки роутера) и Tab за последним элементом.
228
+ Первый Escape внутри Витрины закрывает только Витрину и возвращает фокус на её Категорию; оверлей закрывает
229
+ следующий.
230
+ - **Возврат фокуса.** На кнопку фокус возвращается при Escape и при повторном щелчке. После Tab за последним
231
+ элементом фокус идёт дальше по странице, за кнопкой; Shift+Tab перед первым элементом возвращает на кнопку. После
232
+ нажатия вне каталога фокус остаётся там, куда нажал пользователь.
233
+ - **Скринридер.** Кнопка — раскрыватель с `aria-expanded` и `aria-controls`, без `aria-haspopup`: оверлей — не
234
+ меню и не диалог, а та же навигация с ссылками. Роли `dialog` у оверлея нет.
235
+ - **Слои.** Оверлей лежит на слое `popover`: выше затемнения открытого `Dialog`, ниже подтверждения. Внутри окна
236
+ первый Escape закрывает каталог, следующий — окно. Если фокус в открытой Витрине, самый первый Escape закрывает
237
+ только её и возвращает фокус на Категорию.
238
+ - **Высота.** Оверлей не выше видимой области от кнопки до края окна. Столбец и Витрина прокручиваются каждый
239
+ внутри себя (`overscroll-contain`), а не оверлей целиком: иначе рамка оверлея обрезала бы Витрину, которая
240
+ стоит рядом со Столбцом. На iOS высота ограничена `min(24rem, 60dvh)`, как у панели `Menu`.
241
+ - **Появление.** Плавное, по шкале движения кита; при `prefers-reduced-motion` без анимации.
242
+ - **Сервер.** На сервере рисуется закрытая кнопка; оверлей монтируется в портале после открытия.
243
+
244
+ ## Связанные исходники и проверка
245
+
246
+ - Реализация: `src/components/navigation/MegaMenu.tsx`, Прицел — `src/components/navigation/useMenuAim.ts`.
247
+ - Тесты: `tests/mega-menu.test.tsx` — роли и `aria-controls`, наведение и монтирование только открытой
248
+ Витрины, закрытие с задержкой, roving tabindex, Enter, Escape и стрелки, Tab за последней ссылкой,
249
+ Категория-ссылка, выключенная Категория, `current`, `asChild` и `link`, управляемое значение, нажатие вне
250
+ каталога, рендер на сервере; `tests/mega-menu-aim.test.tsx` — Прицел на подменённой геометрии и поддельном
251
+ времени, переворот у края; `tests/mega-menu-touch.test.tsx` — касание и перо, подмена `matchMedia`, спуск
252
+ вместо Столбца, «Назад» и фокус, Escape, подписи из каталога сообщений, рендер на сервере; браузерный тест `tests-visual/mega-menu.spec.ts` — настоящая мышь наискосок и вниз
253
+ по Столбцу.
254
+ - Выпадающий вид: `tests/mega-menu-dropdown.test.tsx` — открытие щелчком и клавишами с фокусом на первой
255
+ Категории, все пути закрытия и возврат фокуса, Escape в Витрине, `open` / `defaultOpen` / `onOpenChange`, слой
256
+ `popover`, порядок Escape внутри `Dialog`, рендер на сервере.
257
+ - История: `src/components/navigation/MegaMenu.stories.tsx` — в том числе `DropdownInTopBar`, мегаменю в шапке; на ширине
258
+ телефона — `PhoneColumn` и `PhoneDrillDown`.
@@ -5,7 +5,7 @@ category: overlays
5
5
  status: stable
6
6
  source: src/components/overlays/Menu.tsx
7
7
  story: src/components/overlays/Menu.stories.tsx
8
- sourceHash: cedb4db63593
8
+ sourceHash: 473c4ffa0e0a
9
9
  ---
10
10
 
11
11
  # Menu
@@ -15,7 +15,11 @@ sourceHash: cedb4db63593
15
15
  `Menu` is a compound action menu opened by `MenuTrigger`. Inside `MenuTrigger`,
16
16
  use the dedicated `MenuButton`, not a regular `Button`: only it reads react-aria's
17
17
  `ButtonContext`. The menu suits heterogeneous commands, sections, selected modes
18
- and submenus; it is not for choosing a field value.
18
+ and submenus; it is not for choosing a field value. A nested `Menu` inside
19
+ `SubmenuTrigger` opens beside its item on its own, and below it on a narrow
20
+ screen: you do not need to set `placement`. The "aim" (protection that keeps a
21
+ submenu from closing while the pointer travels toward it) comes from react-aria;
22
+ do not write your own.
19
23
 
20
24
  ## When to use
21
25
 
@@ -52,8 +56,9 @@ the props that set the button's look, `icon`, `children`, `disabled`, `aria-labe
52
56
  and `className`; `aria-label` is required if the button has no text, and with
53
57
  `iconOnly` it never does. `Menu` accepts the usual `AriaMenuProps`, plus `offset`,
54
58
  the gap between the panel and the button in pixels (default 6), and `placement`,
55
- the side where the panel opens (default `bottom start`; also `bottom end`,
56
- `top start`, `top end`, `right top` and `left top`).
59
+ the side where the panel opens (default for the root menu `bottom start`; also
60
+ `bottom end`, `top start`, `top end`, `right top` and `left top`; the submenu
61
+ default is described below).
57
62
 
58
63
  `MenuItem` accepts the `AriaMenuItem` props, `icon`, `shortcut`, `tone`,
59
64
  `disabled` and `className`; pass the action in `onAction`. `MenuSection` accepts
@@ -61,6 +66,19 @@ the side where the panel opens (default `bottom start`; also `bottom end`,
61
66
  `selectedKeys` and `onSelectionChange` on the section, not `checked` on the item.
62
67
  `MenuSeparator` separates groups.
63
68
 
69
+ A submenu, a `Menu` inside a `SubmenuTrigger`, opens to the right of its item by
70
+ default, with its top edge level with the item. When there is not enough room on
71
+ the right, react-aria flips it to the left. In a right-to-left layout everything is
72
+ mirrored: the submenu opens to the left of its item. On a narrow screen (up to 639px wide)
73
+ there is no room on either side, so the submenu opens below its item, over the
74
+ parent menu. An explicit `placement` on the nested `Menu` wins over the default,
75
+ phones included. The root menu opens as before, at `bottom start`. The Editor's
76
+ submenus follow the same rule.
77
+
78
+ Moving the pointer diagonally toward a submenu neither closes it nor switches to a
79
+ neighboring item: this "aim" is handled by react-aria's `SubmenuTrigger`. The kit
80
+ does not replace it and adds none of its own, so do not write one on top of it.
81
+
64
82
  react-aria sets the menu panel's height from the visible area. Items that do
65
83
  not fit scroll inside the panel (`overflow-y: auto`, the scroll does not leak to
66
84
  the page) instead of being rendered past its edge over the content. On iOS the panel