@olddevs/ui 4.1.0 → 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 (141) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/components/actions/Toggle.d.ts.map +1 -1
  3. package/dist/components/actions/Toggle.js +1 -1
  4. package/dist/components/actions/Toggle.js.map +1 -1
  5. package/dist/components/actions/Truncate.d.ts.map +1 -1
  6. package/dist/components/actions/Truncate.js +2 -2
  7. package/dist/components/actions/Truncate.js.map +1 -1
  8. package/dist/components/app-shell/MobileTabBar.d.ts.map +1 -1
  9. package/dist/components/app-shell/MobileTabBar.js +1 -1
  10. package/dist/components/app-shell/MobileTabBar.js.map +1 -1
  11. package/dist/components/app-shell/NavItem.d.ts.map +1 -1
  12. package/dist/components/app-shell/NavItem.js +3 -1
  13. package/dist/components/app-shell/NavItem.js.map +1 -1
  14. package/dist/components/content/AvatarGroup.d.ts.map +1 -1
  15. package/dist/components/content/AvatarGroup.js +2 -1
  16. package/dist/components/content/AvatarGroup.js.map +1 -1
  17. package/dist/components/content/Table.d.ts.map +1 -1
  18. package/dist/components/content/Table.js +2 -1
  19. package/dist/components/content/Table.js.map +1 -1
  20. package/dist/components/content/VirtualGrid.d.ts.map +1 -1
  21. package/dist/components/content/VirtualGrid.js +4 -1
  22. package/dist/components/content/VirtualGrid.js.map +1 -1
  23. package/dist/components/inputs/Checkbox.d.ts.map +1 -1
  24. package/dist/components/inputs/Checkbox.js +3 -0
  25. package/dist/components/inputs/Checkbox.js.map +1 -1
  26. package/dist/components/inputs/CheckboxCard.d.ts.map +1 -1
  27. package/dist/components/inputs/CheckboxCard.js +2 -2
  28. package/dist/components/inputs/CheckboxCard.js.map +1 -1
  29. package/dist/components/inputs/RadioGroup.d.ts.map +1 -1
  30. package/dist/components/inputs/RadioGroup.js +5 -3
  31. package/dist/components/inputs/RadioGroup.js.map +1 -1
  32. package/dist/components/inputs/Select.d.ts.map +1 -1
  33. package/dist/components/inputs/Select.js +6 -6
  34. package/dist/components/inputs/Select.js.map +1 -1
  35. package/dist/components/inputs/Slider.d.ts.map +1 -1
  36. package/dist/components/inputs/Slider.js +2 -2
  37. package/dist/components/inputs/Slider.js.map +1 -1
  38. package/dist/components/navigation/Accordion.d.ts.map +1 -1
  39. package/dist/components/navigation/Accordion.js +1 -1
  40. package/dist/components/navigation/Accordion.js.map +1 -1
  41. package/dist/components/navigation/MegaMenu.d.ts +156 -0
  42. package/dist/components/navigation/MegaMenu.d.ts.map +1 -0
  43. package/dist/components/navigation/MegaMenu.js +512 -0
  44. package/dist/components/navigation/MegaMenu.js.map +1 -0
  45. package/dist/components/navigation/TreeView.d.ts.map +1 -1
  46. package/dist/components/navigation/TreeView.js +6 -3
  47. package/dist/components/navigation/TreeView.js.map +1 -1
  48. package/dist/components/navigation/useMenuAim.d.ts +41 -0
  49. package/dist/components/navigation/useMenuAim.d.ts.map +1 -0
  50. package/dist/components/navigation/useMenuAim.js +114 -0
  51. package/dist/components/navigation/useMenuAim.js.map +1 -0
  52. package/dist/components/overlays/Menu.d.ts +19 -1
  53. package/dist/components/overlays/Menu.d.ts.map +1 -1
  54. package/dist/components/overlays/Menu.js +16 -4
  55. package/dist/components/overlays/Menu.js.map +1 -1
  56. package/dist/editor/RichTextEditor.d.ts.map +1 -1
  57. package/dist/editor/RichTextEditor.js +4 -2
  58. package/dist/editor/RichTextEditor.js.map +1 -1
  59. package/dist/editor/features/table/TableMenuButton.js +1 -1
  60. package/dist/editor/features/table/TableMenuButton.js.map +1 -1
  61. package/dist/editor/styles/prose.d.ts +7 -0
  62. package/dist/editor/styles/prose.d.ts.map +1 -1
  63. package/dist/editor/styles/prose.js +1 -1
  64. package/dist/editor/styles/prose.js.map +1 -1
  65. package/dist/editor/ui/BlockHandle.js +1 -1
  66. package/dist/editor/ui/BlockHandle.js.map +1 -1
  67. package/dist/editor/ui/FormatMenus.d.ts.map +1 -1
  68. package/dist/editor/ui/FormatMenus.js +3 -2
  69. package/dist/editor/ui/FormatMenus.js.map +1 -1
  70. package/dist/{editor/hooks → hooks}/useSubmenuPlacement.d.ts +14 -1
  71. package/dist/hooks/useSubmenuPlacement.d.ts.map +1 -0
  72. package/dist/hooks/useSubmenuPlacement.js +10 -0
  73. package/dist/hooks/useSubmenuPlacement.js.map +1 -0
  74. package/dist/i18n/defaults.d.ts.map +1 -1
  75. package/dist/i18n/defaults.js +3 -0
  76. package/dist/i18n/defaults.js.map +1 -1
  77. package/dist/i18n/messages.d.ts +8 -0
  78. package/dist/i18n/messages.d.ts.map +1 -1
  79. package/dist/index.d.ts +2 -0
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +1 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/lib/choice-card.d.ts +13 -0
  84. package/dist/lib/choice-card.d.ts.map +1 -1
  85. package/dist/lib/choice-card.js +2 -0
  86. package/dist/lib/choice-card.js.map +1 -1
  87. package/dist/locales/ru.d.ts.map +1 -1
  88. package/dist/locales/ru.js +3 -0
  89. package/dist/locales/ru.js.map +1 -1
  90. package/docs/api/messages.md +6 -0
  91. package/docs/components/app-shell/mobile-tab-bar.en.md +3 -3
  92. package/docs/components/app-shell/mobile-tab-bar.md +2 -2
  93. package/docs/components/app-shell/side-nav.en.md +7 -3
  94. package/docs/components/app-shell/side-nav.md +6 -2
  95. package/docs/components/index.en.md +2 -1
  96. package/docs/components/index.md +1 -0
  97. package/docs/components/inputs/form.en.md +2 -2
  98. package/docs/components/inputs/form.md +1 -1
  99. package/docs/components/inputs/rich-text-editor.en.md +2 -2
  100. package/docs/components/inputs/rich-text-editor.md +1 -1
  101. package/docs/components/navigation/mega-menu.en.md +273 -0
  102. package/docs/components/navigation/mega-menu.md +258 -0
  103. package/docs/components/overlays/menu.en.md +22 -4
  104. package/docs/components/overlays/menu.md +20 -4
  105. package/docs/getting-started.en.md +5 -5
  106. package/docs/getting-started.md +4 -4
  107. package/docs/glossary.en.md +6 -1
  108. package/docs/glossary.md +5 -0
  109. package/llms.txt +1 -0
  110. package/package.json +1 -1
  111. package/src/components/actions/Toggle.tsx +10 -3
  112. package/src/components/actions/Truncate.tsx +5 -3
  113. package/src/components/app-shell/MobileTabBar.tsx +9 -1
  114. package/src/components/app-shell/NavItem.tsx +6 -1
  115. package/src/components/content/AvatarGroup.tsx +6 -2
  116. package/src/components/content/Table.tsx +15 -3
  117. package/src/components/content/VirtualGrid.tsx +11 -1
  118. package/src/components/inputs/Checkbox.tsx +5 -0
  119. package/src/components/inputs/CheckboxCard.tsx +2 -0
  120. package/src/components/inputs/RadioGroup.tsx +10 -1
  121. package/src/components/inputs/Select.tsx +113 -106
  122. package/src/components/inputs/Slider.tsx +6 -2
  123. package/src/components/navigation/Accordion.tsx +4 -1
  124. package/src/components/navigation/MegaMenu.tsx +1223 -0
  125. package/src/components/navigation/TreeView.tsx +70 -49
  126. package/src/components/navigation/useMenuAim.ts +202 -0
  127. package/src/components/overlays/Menu.tsx +47 -4
  128. package/src/editor/RichTextEditor.tsx +6 -3
  129. package/src/editor/features/table/TableMenuButton.tsx +1 -1
  130. package/src/editor/styles/prose.ts +1 -1
  131. package/src/editor/ui/BlockHandle.tsx +1 -1
  132. package/src/editor/ui/FormatMenus.tsx +17 -2
  133. package/src/{editor/hooks → hooks}/useSubmenuPlacement.ts +18 -15
  134. package/src/i18n/defaults.ts +4 -0
  135. package/src/i18n/messages.ts +9 -0
  136. package/src/index.ts +17 -0
  137. package/src/lib/choice-card.ts +17 -0
  138. package/src/locales/ru.ts +4 -0
  139. package/dist/editor/hooks/useSubmenuPlacement.d.ts.map +0 -1
  140. package/dist/editor/hooks/useSubmenuPlacement.js +0 -20
  141. package/dist/editor/hooks/useSubmenuPlacement.js.map +0 -1
@@ -165,57 +165,78 @@ export function TreeView({
165
165
  className="outline-none [--tree-indent:1.25rem]"
166
166
  >
167
167
  <TreeItemContent>
168
- {({ isExpanded, hasChildItems }) => (
169
- <div
170
- ref={(content) => markCurrent(content, current)}
171
- className={cn(
172
- "text-fg-1 flex items-center gap-2 rounded-xs py-1.5 pr-2 text-[13px] leading-5",
173
- "hover:bg-bg-2 selected:bg-accent-soft selected:text-fg-0",
174
- // Текущий узел — подложка и вес шрифта, как у `NavItem` с `active`.
175
- current && "bg-bg-3 text-fg-0 font-semibold",
176
- "focus-visible:outline-accent-strong focus-visible:outline-2 focus-visible:-outline-offset-2",
177
- "disabled:opacity-50"
178
- )}
179
- // Уровень вложенности берётся у react-aria: она кладёт его в
180
- // `--tree-item-level` на строке, и считать глубину самим не нужно
181
- // — иначе у нас была бы вторая копия того же числа.
182
- style={{
183
- paddingLeft: "calc((var(--tree-item-level) - 1) * var(--tree-indent) + 0.25rem)",
184
- }}
185
- >
186
- {hasChildItems ? (
187
- <Button
188
- slot="chevron"
189
- aria-label={isExpanded ? messages.treeView.collapse : messages.treeView.expand}
190
- className={cn(
191
- "text-fg-3 hover:text-fg-0 flex cursor-pointer items-center rounded-xs",
192
- "transition-colors",
193
- "focus-visible:outline-accent-strong focus-visible:outline-2 focus-visible:outline-offset-2"
194
- )}
195
- >
196
- <ChevronRightIcon
197
- size={14}
198
- aria-hidden
168
+ {({ isExpanded, hasChildItems, isSelected, isDisabled }) => {
169
+ // Гасятся шеврон, иконка и подпись по отдельности, а не весь `div`:
170
+ // флажок выбора получает блокировку от строки сам и гасится собственным
171
+ // `disabled:opacity-50`, так что приглушение всего ряда дало бы ему
172
+ // двойное — 25% вместо 50%.
173
+ const dim = isDisabled && "opacity-50";
174
+ return (
175
+ <div
176
+ ref={(content) => markCurrent(content, current)}
177
+ className={cn(
178
+ "text-fg-1 flex items-center gap-2 rounded-xs py-1.5 pr-2 text-[13px] leading-5",
179
+ // Выбор и блокировка берутся из рендер-пропа, а не вариантами
180
+ // `selected:` и `disabled:`: `data-selected` и `data-disabled`
181
+ // react-aria ставит на строку (`row`), а красится её содержимое —
182
+ // вариант на нём не срабатывал, и ни выбранный узел не
183
+ // подсвечивался, ни заблокированный не гас. Тот же случай, что
184
+ // шеврон в `Accordion`.
185
+ //
186
+ // Приглушение — не на всём `div`, а на частях (см. `dim` ниже).
187
+ //
188
+ // Текущий узел — подложка и вес шрифта, как у `NavItem` с `active`.
189
+ // Стоит ДО выбора: `cn` оставляет последнюю подложку, и у узла,
190
+ // который и текущий, и выбран, видна подложка выбора — как было,
191
+ // пока порядок решал каскад вариантов.
192
+ current && "bg-bg-3 text-fg-0 font-semibold",
193
+ isSelected ? "bg-accent-soft text-fg-0" : !isDisabled && "hover:bg-bg-2",
194
+ "focus-visible:outline-accent-strong focus-visible:outline-2 focus-visible:-outline-offset-2"
195
+ )}
196
+ // Уровень вложенности берётся у react-aria: она кладёт его в
197
+ // `--tree-item-level` на строке, и считать глубину самим не нужно
198
+ // — иначе у нас была бы вторая копия того же числа.
199
+ style={{
200
+ paddingLeft: "calc((var(--tree-item-level) - 1) * var(--tree-indent) + 0.25rem)",
201
+ }}
202
+ >
203
+ {hasChildItems ? (
204
+ <Button
205
+ slot="chevron"
206
+ aria-label={isExpanded ? messages.treeView.collapse : messages.treeView.expand}
199
207
  className={cn(
200
- "transition-transform motion-reduce:transition-none",
201
- isExpanded && "rotate-90"
208
+ "text-fg-3 hover:text-fg-0 flex cursor-pointer items-center rounded-xs",
209
+ "transition-colors",
210
+ dim,
211
+ "focus-visible:outline-accent-strong focus-visible:outline-2 focus-visible:outline-offset-2"
202
212
  )}
203
- />
204
- </Button>
205
- ) : (
206
- // Место раскрывателя занято всегда: без него подписи листьев
207
- // разъезжаются с подписями узлов на том же уровне.
208
- <span aria-hidden className="w-[14px] shrink-0" />
209
- )}
210
- {selectionMode ? (
211
- <Checkbox slot="selection" label={messages.treeView.select} />
212
- ) : null}
213
- {node.icon != null && (
214
- <span className="text-fg-3 flex shrink-0 items-center">{node.icon}</span>
215
- )}
216
- <span className="min-w-0 truncate">{node.label}</span>
217
- </div>
218
- )}
213
+ >
214
+ <ChevronRightIcon
215
+ size={14}
216
+ aria-hidden
217
+ className={cn(
218
+ "transition-transform motion-reduce:transition-none",
219
+ isExpanded && "rotate-90"
220
+ )}
221
+ />
222
+ </Button>
223
+ ) : (
224
+ // Место раскрывателя занято всегда: без него подписи листьев
225
+ // разъезжаются с подписями узлов на том же уровне.
226
+ <span aria-hidden className="w-[14px] shrink-0" />
227
+ )}
228
+ {selectionMode ? (
229
+ <Checkbox slot="selection" label={messages.treeView.select} />
230
+ ) : null}
231
+ {node.icon != null && (
232
+ <span className={cn("text-fg-3 flex shrink-0 items-center", dim)}>
233
+ {node.icon}
234
+ </span>
235
+ )}
236
+ <span className={cn("min-w-0 truncate", dim)}>{node.label}</span>
237
+ </div>
238
+ );
239
+ }}
219
240
  </TreeItemContent>
220
241
  {node.children != null && node.children.length > 0 ? (
221
242
  <Collection items={node.children}>{renderNode}</Collection>
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, type RefObject } from "react";
4
+
5
+ /*
6
+ ПРИЦЕЛ МЕГАМЕНЮ — приём Amazon, разобранный Беном Каменсом в 2013 году.
7
+
8
+ Курсор идёт от Категории к её Витрине наискосок и по пути задевает соседние
9
+ Категории. Наивное «наведение открывает» сменило бы Витрину у человека под
10
+ рукой. Прицел смотрит, КУДА движется курсор: пока он идёт в сторону открытой
11
+ Витрины, соседняя Категория наведение не перехватывает.
12
+
13
+ Модель — как у `useSafelyMouseToSubmenu` в react-aria, чтобы подменю `Menu` и
14
+ мегаменю вели себя одинаково: направление последнего шага сравнивается с
15
+ сектором от предыдущей точки к двум углам ближнего края Витрины; смена
16
+ направления прощается; обработка прорежена до кадра; только мышь.
17
+
18
+ Два осознанных расхождения с react-aria:
19
+
20
+ 1. Механизм — ОТЛОЖЕННАЯ СМЕНА Категории, а не `pointer-events: none` на
21
+ Столбце. Отключённые события гасят подсветку под курсором и съедают щелчок
22
+ по соседней Категории, а щелчок должен открывать сразу.
23
+ 2. Время сброса — 300 мс (значение Каменса), а не 1000 мс react-aria. Его
24
+ подменю узкое и короткое; Столбец каталога листают, и секунда ожидания на
25
+ каждой остановке ощущается как залипание.
26
+ */
27
+
28
+ /** Курсор стоит дольше — Категория под ним открывается, даже если он «целился». */
29
+ export const AIM_SETTLE_MS = 300;
30
+
31
+ /** Прореживание: один шаг на кадр. Чаще направление шумит пиксельной дрожью. */
32
+ const THROTTLE_MS = 16;
33
+
34
+ /**
35
+ * Сколько шагов «не туда» подряд прощается. Счётчик растёт на шаг к Витрине и
36
+ * падает на шаг мимо; целится курсор, пока счётчик выше нуля. Отсюда: одно
37
+ * случайное движение вбок прицел не сбрасывает, два подряд — сбрасывают.
38
+ */
39
+ const ALLOWED_INVALID_MOVES = 2;
40
+
41
+ /**
42
+ * Запас сектора по вертикали, пиксели. Без него курсор, идущий точно в угол
43
+ * Витрины, выпадал бы из сектора на дрожании руки.
44
+ */
45
+ const SECTOR_PADDING = 24;
46
+
47
+ interface Point {
48
+ x: number;
49
+ y: number;
50
+ }
51
+
52
+ /**
53
+ * Идёт ли шаг `from → to` к Витрине: направление шага лежит внутри сектора от
54
+ * `from` к двум углам её ближнего края.
55
+ *
56
+ * Векторное произведение, а не `atan2`: тот же сектор без разрыва угла на ±π.
57
+ * Ось y экрана направлена вниз, отсюда знаки.
58
+ */
59
+ export function isMovingToward(from: Point, to: Point, rect: DOMRect, side: "left" | "right") {
60
+ const dx = to.x - from.x;
61
+ const dy = to.y - from.y;
62
+ if (dx === 0 && dy === 0) return false;
63
+ const edge = side === "right" ? rect.left : rect.right;
64
+ // Курсор уже за ближним краем — он в Витрине, а не на пути к ней.
65
+ if (side === "right" ? from.x >= edge : from.x <= edge) return false;
66
+ // Шаг от Витрины по горизонтали не может к ней вести.
67
+ if (side === "right" ? dx <= 0 : dx >= 0) return false;
68
+ const top = { x: edge - from.x, y: rect.top - SECTOR_PADDING - from.y };
69
+ const bottom = { x: edge - from.x, y: rect.bottom + SECTOR_PADDING - from.y };
70
+ const cross = (a: Point, b: Point) => a.x * b.y - a.y * b.x;
71
+ const step = { x: dx, y: dy };
72
+ // Справа: шаг «ниже» луча к верхнему углу и «выше» луча к нижнему. Слева
73
+ // ориентация лучей зеркальна, и знаки меняются местами.
74
+ return side === "right"
75
+ ? cross(top, step) >= 0 && cross(step, bottom) >= 0
76
+ : cross(top, step) <= 0 && cross(step, bottom) <= 0;
77
+ }
78
+
79
+ const NONE = Symbol("none");
80
+
81
+ interface MenuAimOptions {
82
+ /** Открытая Категория; `null` — Витрины нет, и целиться не во что. */
83
+ value: string | null;
84
+ /** Открытая сейчас Витрина: её прямоугольник — цель. */
85
+ showcaseRef: RefObject<HTMLElement | null>;
86
+ /** Сменить Категорию (или закрыть Витрину — `null`). */
87
+ commit: (next: string | null) => void;
88
+ }
89
+
90
+ /**
91
+ * Прицел: решает, сменить Категорию под курсором сейчас или отложить.
92
+ *
93
+ * `request` зовётся из наведения на строку Столбца. Если Витрины нет или
94
+ * курсор не целится — смена сразу. Если целится — смена откладывается: курсор
95
+ * перестал целиться или стоит `AIM_SETTLE_MS` — открывается то, что под ним
96
+ * сейчас; дошёл до Витрины — смена отменяется.
97
+ *
98
+ * Прямоугольник Витрины берётся заново на каждом шаге, а не кешируется:
99
+ * прокрутка страницы и смена размера окна не оставляют его устаревшим, а цена
100
+ * — одно чтение раскладки на кадр, и только пока Витрина открыта.
101
+ */
102
+ export function useMenuAim({ value, showcaseRef, commit }: MenuAimOptions) {
103
+ const previous = useRef<Point | null>(null);
104
+ const lastStep = useRef(0);
105
+ const aim = useRef(0);
106
+ const pending = useRef<string | null | typeof NONE>(NONE);
107
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
108
+ const commitRef = useRef(commit);
109
+ commitRef.current = commit;
110
+
111
+ const clearTimer = () => {
112
+ if (timer.current) clearTimeout(timer.current);
113
+ timer.current = null;
114
+ };
115
+
116
+ const flush = useCallback(() => {
117
+ clearTimer();
118
+ const next = pending.current;
119
+ pending.current = NONE;
120
+ if (next !== NONE) commitRef.current(next);
121
+ }, []);
122
+
123
+ const settleLater = useCallback(() => {
124
+ clearTimer();
125
+ timer.current = setTimeout(flush, AIM_SETTLE_MS);
126
+ }, [flush]);
127
+
128
+ // Новая Витрина — новая цель: прежний счёт к ней отношения не имеет.
129
+ useEffect(() => {
130
+ aim.current = 0;
131
+ previous.current = null;
132
+ pending.current = NONE;
133
+ clearTimer();
134
+ }, [value]);
135
+
136
+ useEffect(() => clearTimer, []);
137
+
138
+ useEffect(() => {
139
+ if (value === null) return;
140
+ const onPointerMove = (event: PointerEvent) => {
141
+ if (event.pointerType === "touch" || event.pointerType === "pen") return;
142
+ const now = Date.now();
143
+ if (now - lastStep.current < THROTTLE_MS) return;
144
+ lastStep.current = now;
145
+ const point = { x: event.clientX, y: event.clientY };
146
+ const showcase = showcaseRef.current;
147
+ const from = previous.current;
148
+ previous.current = point;
149
+ if (!showcase || !from) return;
150
+
151
+ // Курсор дошёл до Витрины — отложенная смена больше не нужна.
152
+ if (event.target instanceof Node && showcase.contains(event.target)) {
153
+ pending.current = NONE;
154
+ clearTimer();
155
+ return;
156
+ }
157
+
158
+ const rect = showcase.getBoundingClientRect();
159
+ const side = rect.left >= from.x ? "right" : "left";
160
+ aim.current = isMovingToward(from, point, rect, side)
161
+ ? Math.min(aim.current + 1, ALLOWED_INVALID_MOVES)
162
+ : Math.max(aim.current - 1, 0);
163
+
164
+ if (pending.current === NONE) return;
165
+ // Перестал целиться — открывается то, над чем он сейчас; иначе ждём
166
+ // остановки заново: каждый шаг откладывает срок.
167
+ if (aim.current === 0) flush();
168
+ else settleLater();
169
+ };
170
+ document.addEventListener("pointermove", onPointerMove);
171
+ return () => document.removeEventListener("pointermove", onPointerMove);
172
+ }, [value, showcaseRef, flush, settleLater]);
173
+
174
+ /** Курсор наведён на строку Столбца: открыть её (`null` — закрыть Витрину). */
175
+ const request = useCallback(
176
+ (next: string | null) => {
177
+ if (next === value) {
178
+ // Вернулся на открытую Категорию — отложенная смена отменяется.
179
+ pending.current = NONE;
180
+ clearTimer();
181
+ return;
182
+ }
183
+ if (value === null || aim.current === 0) {
184
+ pending.current = NONE;
185
+ clearTimer();
186
+ commitRef.current(next);
187
+ return;
188
+ }
189
+ pending.current = next;
190
+ settleLater();
191
+ },
192
+ [value, settleLater]
193
+ );
194
+
195
+ /** Отменить отложенную смену: щелчок, клавиатура, уход из меню решают сами. */
196
+ const cancel = useCallback(() => {
197
+ pending.current = NONE;
198
+ clearTimer();
199
+ }, []);
200
+
201
+ return { request, cancel };
202
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { forwardRef, type ReactNode } from "react";
3
+ import { forwardRef, useContext, type ReactNode } from "react";
4
4
  import {
5
5
  Button as AriaButton,
6
6
  Menu as AriaMenu,
@@ -8,6 +8,7 @@ import {
8
8
  MenuSection as AriaMenuSection,
9
9
  Header,
10
10
  Popover,
11
+ PopoverContext,
11
12
  Separator,
12
13
  type MenuItemProps as AriaMenuItemProps,
13
14
  type MenuProps as AriaMenuProps,
@@ -15,6 +16,7 @@ import {
15
16
  } from "react-aria-components";
16
17
  import { CheckIcon, ChevronRightIcon } from "../../icons.js";
17
18
  import { buttonClasses, type ButtonLookOptions } from "../../lib/button.js";
19
+ import { useSubmenuPlacement } from "../../hooks/useSubmenuPlacement.js";
18
20
  import { cn } from "../../lib/cn.js";
19
21
  import {
20
22
  MENU_ITEM_CLASS,
@@ -83,7 +85,12 @@ export const MenuButton = forwardRef<HTMLButtonElement, MenuButtonProps>(functio
83
85
  export interface MenuProps<T extends object = object> extends AriaMenuProps<T> {
84
86
  /** Отступ панели от кнопки, в пикселях. */
85
87
  offset?: number;
86
- /** Сторона, с которой панель раскрывается. */
88
+ /**
89
+ * Сторона, с которой панель раскрывается. Корневое меню по умолчанию — под
90
+ * кнопкой слева (`bottom start`); вложенное, внутри `SubmenuTrigger`, —
91
+ * сбоку от своего пункта, а на узком экране под ним. Явное значение
92
+ * побеждает оба умолчания.
93
+ */
87
94
  placement?: "bottom start" | "bottom end" | "top start" | "top end" | "right top" | "left top";
88
95
  }
89
96
 
@@ -115,6 +122,19 @@ export interface MenuItemProps extends Omit<AriaMenuItemProps, "children" | "cla
115
122
  * меню руками. Это же делает работоспособным `SubmenuTrigger`: он ждёт вторым
116
123
  * ребёнком именно попоувер.
117
124
  *
125
+ * ПОДМЕНЮ раскрывается сбоку от своего пункта, и это умолчание нужно
126
+ * выставлять самим. `SubmenuTrigger` отдаёт вложенному поповеру место
127
+ * `end top` через контекст, но явный проп поповера важнее контекста, а корневое
128
+ * умолчание кита `bottom start` — как раз явный проп: вложенное меню без
129
+ * особого случая раскрывалось бы под пунктом. Поэтому `Menu` узнаёт подменю по
130
+ * контексту (`trigger: "SubmenuTrigger"`, публичное поле `PopoverContext`) и
131
+ * спрашивает общий хук `useSubmenuPlacement` — тот же, что в Редакторе: на
132
+ * телефоне подменю встаёт под пунктом, на широком экране кит своего места не
133
+ * передаёт, и действует `end top` react-aria — сбоку, с учётом направления
134
+ * письма. Переворот у края экрана
135
+ * (`shouldFlip`) и «Прицел» — движение курсора к подменю по диагонали не
136
+ * закрывает его — остаются у react-aria, кит их не трогает.
137
+ *
118
138
  * ПЕРЕКЛЮЧАТЕЛИ — через выбор на разделе, а не через `checked` у пункта:
119
139
  *
120
140
  * <MenuSection label="Вид" selectionMode="multiple"
@@ -128,11 +148,34 @@ export interface MenuItemProps extends Omit<AriaMenuItemProps, "children" | "cla
128
148
  * чужую работу и расходиться с ней при первом же обновлении.
129
149
  */
130
150
  export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
131
- { offset = 6, placement = "bottom start", className, ...rest },
151
+ { offset = 6, placement, className, ...rest },
132
152
  ref
133
153
  ) {
154
+ const popoverContext = useContext(PopoverContext);
155
+ // Контекст бывает пустым, со слотами или с самими пропами поповера; «подменю»
156
+ // говорит только последнее.
157
+ const isSubmenu =
158
+ popoverContext != null &&
159
+ "trigger" in popoverContext &&
160
+ popoverContext.trigger === "SubmenuTrigger";
161
+ const submenuPlacement = useSubmenuPlacement();
162
+ // Широкий экран — место не передаётся вовсе: тогда действует `end top` из
163
+ // контекста `SubmenuTrigger`. Оно логическое, и при письме справа налево
164
+ // подменю раскрывается слева, а `right top` хука уводило бы его вправо.
165
+ const resolvedPlacement =
166
+ placement ??
167
+ (isSubmenu
168
+ ? submenuPlacement === "bottom start"
169
+ ? submenuPlacement
170
+ : undefined
171
+ : "bottom start");
172
+
134
173
  return (
135
- <Popover offset={offset} placement={placement} className={cn(MENU_PANEL_CLASS, className)}>
174
+ <Popover
175
+ offset={offset}
176
+ placement={resolvedPlacement}
177
+ className={cn(MENU_PANEL_CLASS, className)}
178
+ >
136
179
  {/*
137
180
  `data-disable-focus-ring` — механизм пакета, тот же, что у `TextControl`.
138
181
  Глобальное правило `:focus-visible` в styles.css рисует контур вокруг
@@ -67,7 +67,7 @@ import {
67
67
  } from "./core/jsonDocument.js";
68
68
  import { markdownOf } from "./core/atomText.js";
69
69
  import { countCharacters, EXTERNAL_CHANGE } from "./core/maxLength.js";
70
- import { proseClass } from "./styles/prose.js";
70
+ import { PAPER_SHEET_CLASS, proseClass } from "./styles/prose.js";
71
71
  import { SELECTION_BUBBLE_KEY, SelectionBubble } from "./ui/SelectionBubble.js";
72
72
  import { useSlashMenu } from "./ui/SlashMenu.js";
73
73
  import { useTriggerMenu } from "./ui/TriggerMenu.js";
@@ -1543,8 +1543,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
1543
1543
  {...surfaceAttributes(surface)}
1544
1544
  className={cn(
1545
1545
  "grid",
1546
- surface === "paper" ? EDITOR_PAPER_TOKENS_CLASS : EDITOR_TOKENS_CLASS,
1547
- surface === "paper" ? "my-3" : "py-2.5"
1546
+ // На листе светлый лист остаётся под тёмным полем: смена режима
1547
+ // меняет содержимое листа, а не сам лист.
1548
+ surface === "paper"
1549
+ ? cn(EDITOR_PAPER_TOKENS_CLASS, PAPER_SHEET_CLASS, "my-3")
1550
+ : cn(EDITOR_TOKENS_CLASS, "py-2.5")
1548
1551
  )}
1549
1552
  style={{ minHeight: source.minHeight }}
1550
1553
  >
@@ -56,7 +56,7 @@ import {
56
56
  type TableAction,
57
57
  } from "./tableActions.js";
58
58
  import { TableSizeGrid } from "./TableSizeGrid.js";
59
- import { useSubmenuPlacement } from "../../hooks/useSubmenuPlacement.js";
59
+ import { useSubmenuPlacement } from "../../../hooks/useSubmenuPlacement.js";
60
60
  import { GLASS_MENU_CLASS } from "../../styles/glass.js";
61
61
 
62
62
  export interface TableMenuButtonProps {
@@ -415,7 +415,7 @@ export const PROSE_CLASS = EDITOR_TOKENS_CLASS + " " + PROSE_RULES;
415
415
  * видны на тёмной подложке так же, как на светлой. `scheme-light` — родной
416
416
  * флажок чек-листа и полосы прокрутки рисуются светлыми.
417
417
  */
418
- const PAPER_SHEET_CLASS =
418
+ export const PAPER_SHEET_CLASS =
419
419
  "rounded-(--r-sm) border border-(--line) bg-(--rte-background) px-4 py-3 " +
420
420
  "shadow-[var(--shadow-1)] scheme-light";
421
421
 
@@ -42,7 +42,7 @@ import { ariaShortcut, findShortcutKeys, isApple, shortcutLabel } from "../core/
42
42
  import { blockActionsOf, blockActionVisible, type ToolList } from "../core/toolDescriptor.js";
43
43
  import { WRAPPER_NODE } from "../features/wrapper/wrapper.js";
44
44
  import { EDITOR_WRAPPER_TONES, wrapperToneFrom } from "../features/wrapper/wrapperTones.js";
45
- import { useSubmenuPlacement } from "../hooks/useSubmenuPlacement.js";
45
+ import { useSubmenuPlacement } from "../../hooks/useSubmenuPlacement.js";
46
46
  import { GLASS_MENU_CLASS } from "../styles/glass.js";
47
47
 
48
48
  /** Размер ручки и зазор до текста, px: ручка `h-6 w-5` стоит в левом поле Документа. */
@@ -13,6 +13,7 @@ import {
13
13
  } from "../../components/overlays/Menu.js";
14
14
  import { Tooltip } from "../../components/overlays/Tooltip.js";
15
15
  import type { UiMessages } from "../../i18n/messages.js";
16
+ import { cn } from "../../lib/cn.js";
16
17
  import { useMessages, useUiLocale } from "../../i18n/useMessages.js";
17
18
  import {
18
19
  AlignCenterIcon,
@@ -44,7 +45,7 @@ import { DEFAULT_ALIGNMENT, EDITOR_ALIGNMENTS, type EditorAlignment } from "../s
44
45
  import { findShortcutKeys } from "../core/shortcuts.js";
45
46
  import type { ToolList } from "../core/toolDescriptor.js";
46
47
  import type { Tool } from "../core/tools.js";
47
- import { useSubmenuPlacement } from "../hooks/useSubmenuPlacement.js";
48
+ import { useSubmenuPlacement } from "../../hooks/useSubmenuPlacement.js";
48
49
  import { GLASS_MENU_CLASS } from "../styles/glass.js";
49
50
 
50
51
  type EditorMessages = UiMessages["editor"];
@@ -227,7 +228,21 @@ export function formatMenuSections(
227
228
  icon={ItemIcon ? <ItemIcon size={14} aria-hidden /> : undefined}
228
229
  shortcut={item.keys ? shortcut(item.keys) : undefined}
229
230
  >
230
- {item.swatch ? <span className={item.swatch}>{item.label}</span> : item.label}
231
+ {item.swatch ? (
232
+ /*
233
+ Образец не должен брать вид выбранной строки: её голубой текст
234
+ и подложка перекрашивали цвета тона. Текст задан образцу
235
+ самому, а непрозрачная основа под ним не даёт полупрозрачной
236
+ подложке тона смешаться с подложкой строки. Основа — только в
237
+ отмеченной строке: у остальных образец лежит на панели меню,
238
+ как обёртка в документе, и выглядит так же.
239
+ */
240
+ <span className="in-[[aria-checked=true]]:bg-surface-elevated rounded-xs">
241
+ <span className={cn(item.swatch, "text-fg-0")}>{item.label}</span>
242
+ </span>
243
+ ) : (
244
+ item.label
245
+ )}
231
246
  </MenuItem>
232
247
  );
233
248
  if (!item.submenu || item.disabled) return entry;
@@ -1,24 +1,25 @@
1
1
  "use client";
2
2
 
3
- import { useSyncExternalStore } from "react";
3
+ import { useMediaQuery } from "./useMediaQuery.js";
4
4
 
5
5
  /** Уже `sm` (640): на такой ширине подменю сбоку от пункта не помещается. */
6
- const QUERY = "(max-width: 639px)";
6
+ const NARROW_QUERY = "(max-width: 639px)";
7
7
 
8
- function subscribe(onChange: () => void): () => void {
9
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
10
- const list = window.matchMedia(QUERY);
11
- list.addEventListener("change", onChange);
12
- return () => list.removeEventListener("change", onChange);
13
- }
14
-
15
- function snapshot(): boolean {
16
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
17
- return window.matchMedia(QUERY).matches;
8
+ /**
9
+ * Узкий ли экран по правилу кита — граница одна на подменю и мегаменю.
10
+ *
11
+ * Внутренний хук, как и `useSubmenuPlacement`. До гидратации и без `matchMedia`
12
+ * — `false`, то есть широкий: серверной ширины нет, а угадывать значило бы
13
+ * получить расхождение разметки (ADR-0018, см. `useMediaQuery`).
14
+ */
15
+ export function useNarrowScreen(): boolean {
16
+ return useMediaQuery(NARROW_QUERY);
18
17
  }
19
18
 
20
19
  /**
21
- * Где открыть подменю Редактора: сбоку от пункта или под ним.
20
+ * Где открыть подменю: сбоку от пункта или под ним.
21
+ *
22
+ * Правило одно на весь кит: его применяет вложенный `Menu` и подменю Редактора.
22
23
  *
23
24
  * Сбоку — привычно на десктопе. На телефоне места нет ни справа, ни слева:
24
25
  * меню само занимает середину экрана, и react-aria, перевернув подменю на
@@ -33,8 +34,10 @@ function snapshot(): boolean {
33
34
  * JS, а не CSS: место подменю — проп поповера, а не класс. Подписка — экран
34
35
  * поворачивают на лету. На сервере и без `matchMedia` — сбоку: подменю
35
36
  * открывается только в браузере, по действию пользователя.
37
+ *
38
+ * Внутренний хук: наружу не экспортируется. Потребитель кита решает за себя
39
+ * проп `placement` у `Menu`, а умолчание для вложенного меню уже здесь.
36
40
  */
37
41
  export function useSubmenuPlacement(): "bottom start" | "right top" {
38
- const narrow = useSyncExternalStore(subscribe, snapshot, () => false);
39
- return narrow ? "bottom start" : "right top";
42
+ return useNarrowScreen() ? "bottom start" : "right top";
40
43
  }
@@ -347,6 +347,10 @@ export const DEFAULT_MESSAGES: UiMessages = {
347
347
  slide: ({ index, total }) => `${index} of ${total}`,
348
348
  },
349
349
 
350
+ megaMenu: {
351
+ back: "Back",
352
+ },
353
+
350
354
  shortcutsHelp: {
351
355
  title: "Keyboard shortcuts",
352
356
  list: "Shortcuts",
@@ -591,6 +591,15 @@ export interface UiMessages {
591
591
  slide: (vars: { index: number; total: number }) => string;
592
592
  };
593
593
 
594
+ megaMenu: {
595
+ /**
596
+ * Строка возврата на узком экране: открытая Категория заменяет Столбец, и
597
+ * эта кнопка возвращает его. Имя кнопки — одно слово без названия
598
+ * Категории: название стоит рядом текстом, а регион им уже подписан.
599
+ */
600
+ back: string;
601
+ };
602
+
594
603
  shortcutsHelp: {
595
604
  /** Заголовок окна по умолчанию. Он же доступное имя диалога. */
596
605
  title: string;
package/src/index.ts CHANGED
@@ -428,6 +428,23 @@ export type { PaginationProps } from "./components/navigation/Pagination.js";
428
428
  export { Stepper } from "./components/navigation/Stepper.js";
429
429
  export type { StepperProps, Step } from "./components/navigation/Stepper.js";
430
430
 
431
+ export {
432
+ MegaMenu,
433
+ MegaMenuButton,
434
+ MegaMenuCategory,
435
+ MegaMenuGroup,
436
+ MegaMenuLink,
437
+ MegaMenuTrigger,
438
+ } from "./components/navigation/MegaMenu.js";
439
+ export type {
440
+ MegaMenuProps,
441
+ MegaMenuButtonProps,
442
+ MegaMenuCategoryProps,
443
+ MegaMenuGroupProps,
444
+ MegaMenuLinkProps,
445
+ MegaMenuTriggerProps,
446
+ } from "./components/navigation/MegaMenu.js";
447
+
431
448
  export { ToggleGroup } from "./components/actions/ToggleGroup.js";
432
449
  export type { ToggleGroupProps, ToggleGroupOption } from "./components/actions/ToggleGroup.js";
433
450