@lovett/ui 0.0.10 → 0.1.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 (158) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +6064 -80
  4. package/dist/index.js +21561 -9696
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -8,46 +8,172 @@
8
8
  * BUILD_FRESH per ADR-025 DEVIATION-3: source ships on
9
9
  * `@base-ui/react/menu`, which is NOT in CLAUDE.md §1 allowed deps.
10
10
  * The compound-component surface (Root / Trigger / Content / Item /
11
- * CheckboxItem / Separator / Label / Group) carries over; the
12
- * implementation is a lightweight React Context + Portal + outside-
13
- * click + Escape handler.
11
+ * CheckboxItem / Separator / Label / Group) carries over.
14
12
  *
15
- * Trigger styling per ADR-026 D4 (DQ-4 LIFT): the default trigger
16
- * inherits the secondary Button visual (rounded-xl, `--bg-card` fill,
17
- * 1px `--border`). Consumers can pass `asChild`-equivalent custom
18
- * trigger content; the wrapper applies the button-style classes
19
- * unless `unstyled` is set.
13
+ * Rebuilt on the shared anchored-positioning kit per ADR-0030 Decision G
14
+ * (meta-ads-audit-dashboard task manager; the workspace app's 137 call
15
+ * sites are the other consumer). Same public API, same DOM roles and
16
+ * `data-slot`s; what changed underneath:
17
+ * Positioning is `useAnchoredPosition` (lib/anchor.ts) — the menu now
18
+ * FLIPS to the other side when it cannot fit, instead of being clamped
19
+ * over its own trigger; `side` accepts `left` / `right` too.
20
+ * • Escape goes through the shared dismiss-layer stack (lib/layer-stack.ts)
21
+ * that Modal uses: a menu opened inside a Modal now closes on its own,
22
+ * leaving the Modal open. Before, both closed on one keypress.
23
+ * • Outside-click is `useOutsideClick`, layer-aware — a Select or Popover
24
+ * opened from a menu item is not "outside".
25
+ * • Trigger, Content, Item and CheckboxItem forward refs.
26
+ *
27
+ * Trigger styling per ADR-026 D4 (DQ-4 LIFT): the default trigger inherits
28
+ * the secondary Button visual (rounded-xl, `--bg-card` fill, 1px
29
+ * `--border`). Consumers can pass custom trigger content; the wrapper
30
+ * applies the button-style classes unless `unstyled` is set.
31
+ *
32
+ * Keyboard model (ARIA APG "menu button", added 2026-09-06 — before this
33
+ * the menu had NO keyboard model at all: opening it left focus on the
34
+ * trigger, ArrowDown did nothing, and Tab walked off into the page behind
35
+ * while the menu stayed open):
36
+ * • Opening moves focus INTO the menu — the first item, or the last when
37
+ * the menu was opened with ArrowUp. A menu whose content is a small
38
+ * form rather than a list of items (the keywords range-filter chip)
39
+ * focuses its first focusable field instead, and the bare surface when
40
+ * it has none.
41
+ * • Up / Down / Home / End move between items on a ROVING TABINDEX — the
42
+ * focused item is `0`, every other item `-1`, so the menu is one tab
43
+ * stop. Movement wraps at both ends.
44
+ * • Tab from an item closes the menu and hands focus back to the trigger,
45
+ * so the browser's own sequential navigation continues from there.
46
+ * Tab inside a form-shaped menu is left alone.
47
+ * • Escape, item activation and outside-click all CLOSE and RESTORE focus
48
+ * to the trigger (outside-click only when focus was still inside the
49
+ * menu — otherwise the element you clicked keeps it). Dropping focus to
50
+ * <body> here also broke Modal, which captures `document.activeElement`
51
+ * as its restore target; a dialog opened from a menu item now restores
52
+ * correctly because the trigger is focused before it mounts.
53
+ * • Typeahead (jump to the item starting with a typed letter) is NOT
54
+ * implemented — printable keys are left to the content, because menus
55
+ * here legitimately host text inputs.
56
+ * Arrow keys are yielded to any target that owns them: text-entry fields
57
+ * and dnd-kit sortable handles (`aria-roledescription="sortable"`, the
58
+ * DataGrid column reorder handles).
59
+ *
60
+ * Token discipline: surface chrome is `POPOVER_SURFACE_STYLE` (shared with
61
+ * Popover — internal `--popover` tokens are allowed inside packages/ui);
62
+ * item hover / focus use public `--surface-overlay`; motion is
63
+ * `.ds-enter-pop`, gated on `positioned` so it never plays from the parked
64
+ * off-screen frame.
20
65
  *
21
66
  * Workspace ADR: ADR-025 (port) + ADR-026 D4 (trigger spec)
22
- * Ported 2026-05-18
67
+ * Ported 2026-05-18 · Rebuilt on lib/anchor 2026-09-05
23
68
  */
24
69
 
25
70
  import {
26
71
  createContext,
72
+ forwardRef,
27
73
  useCallback,
28
74
  useContext,
29
75
  useEffect,
30
76
  useId,
31
- useLayoutEffect,
32
77
  useMemo,
33
78
  useRef,
34
79
  useState,
35
80
  type ButtonHTMLAttributes,
81
+ type FocusEvent as ReactFocusEvent,
36
82
  type HTMLAttributes,
83
+ type KeyboardEvent as ReactKeyboardEvent,
37
84
  type ReactNode,
85
+ type RefObject,
38
86
  } from 'react'
39
87
  import { createPortal } from 'react-dom'
40
88
  import { Check } from 'lucide-react'
41
89
  import { cn } from './lib/utils'
90
+ import { composeRefs } from './lib/refs'
91
+ import { useLayer } from './lib/layer-stack'
92
+ import {
93
+ useAnchoredPosition,
94
+ useOutsideClick,
95
+ type AnchorAlign,
96
+ type AnchorSide,
97
+ } from './lib/anchor'
98
+ import { POPOVER_SURFACE_STYLE } from './popover'
99
+
100
+ type Align = AnchorAlign
101
+ type Side = AnchorSide
42
102
 
43
- type Align = 'start' | 'center' | 'end'
44
- type Side = 'top' | 'bottom'
103
+ /** Where focus lands when the menu opens. */
104
+ type InitialFocus = 'first' | 'last'
45
105
 
46
106
  interface DropdownMenuContextValue {
47
107
  open: boolean
48
108
  setOpen: (next: boolean) => void
49
- triggerRef: React.RefObject<HTMLButtonElement | null>
109
+ /** Open (or, when already open, move focus into) the menu. */
110
+ openMenu: (initialFocus: InitialFocus) => void
111
+ /** Close, restoring focus to the trigger unless told not to. */
112
+ closeMenu: (options?: { restoreFocus?: boolean }) => void
113
+ triggerRef: RefObject<HTMLButtonElement | null>
50
114
  contentId: string
115
+ initialFocusRef: RefObject<InitialFocus>
116
+ /** Content registers its edge-focus fn here so the trigger can reach it. */
117
+ focusEdgeRef: RefObject<((where: InitialFocus) => void) | null>
118
+ }
119
+
120
+ /* --------------------------------------------------------------------- *
121
+ * Roving-tabindex helpers. DOM-driven rather than a registration context:
122
+ * items arrive through Groups, through consumer wrappers (the DataGrid's
123
+ * sortable column rows), and through `children` this file never sees, so
124
+ * querying the live subtree is the only ordering that is always right.
125
+ * --------------------------------------------------------------------- */
126
+
127
+ const MENU_ITEM_SELECTOR =
128
+ '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]'
129
+
130
+ const FOCUSABLE_SELECTOR = [
131
+ 'a[href]',
132
+ 'button:not([disabled])',
133
+ 'input:not([disabled])',
134
+ 'select:not([disabled])',
135
+ 'textarea:not([disabled])',
136
+ '[tabindex]:not([tabindex="-1"])',
137
+ ].join(',')
138
+
139
+ function isEnabledItem(el: HTMLElement): boolean {
140
+ return (
141
+ !el.hasAttribute('disabled') &&
142
+ el.getAttribute('aria-disabled') !== 'true' &&
143
+ !el.hidden
144
+ )
145
+ }
146
+
147
+ function menuItemsIn(container: HTMLElement | null): HTMLElement[] {
148
+ if (!container) return []
149
+ return Array.from(
150
+ container.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
151
+ ).filter(isEnabledItem)
152
+ }
153
+
154
+ /** Active item is the ONE tab stop; the rest are -1. Never a positive value. */
155
+ function applyRoving(items: readonly HTMLElement[], activeIndex: number): void {
156
+ items.forEach((item, index) => {
157
+ item.tabIndex = index === activeIndex ? 0 : -1
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Targets that own the arrow keys themselves — text entry (a menu is
163
+ * allowed to host a small form) and dnd-kit sortable handles, whose
164
+ * keyboard drag IS arrow keys.
165
+ */
166
+ function ownsArrowKeys(target: EventTarget | null): boolean {
167
+ if (!(target instanceof HTMLElement)) return false
168
+ if (target.closest('[aria-roledescription="sortable"]')) return true
169
+ if (target.isContentEditable) return true
170
+ const tag = target.tagName
171
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true
172
+ if (tag === 'INPUT') {
173
+ const type = (target as HTMLInputElement).type
174
+ return type !== 'checkbox' && type !== 'radio' && type !== 'button'
175
+ }
176
+ return false
51
177
  }
52
178
 
53
179
  const DropdownMenuCtx = createContext<DropdownMenuContextValue | null>(null)
@@ -87,6 +213,9 @@ export function DropdownMenu({
87
213
  const generatedId = useId()
88
214
  const contentId = `dropdown-${generatedId.replace(/:/g, '')}`
89
215
 
216
+ const initialFocusRef = useRef<InitialFocus>('first')
217
+ const focusEdgeRef = useRef<((where: InitialFocus) => void) | null>(null)
218
+
90
219
  const setOpen = useCallback(
91
220
  (next: boolean) => {
92
221
  if (!isControlled) setUncontrolledOpen(next)
@@ -95,9 +224,40 @@ export function DropdownMenu({
95
224
  [isControlled, onOpenChange],
96
225
  )
97
226
 
227
+ const openMenu = useCallback(
228
+ (initialFocus: InitialFocus) => {
229
+ initialFocusRef.current = initialFocus
230
+ // Already open (ArrowDown on the trigger of an open menu): jump
231
+ // straight in, since the open effect will not fire again.
232
+ if (open) {
233
+ focusEdgeRef.current?.(initialFocus)
234
+ return
235
+ }
236
+ setOpen(true)
237
+ },
238
+ [open, setOpen],
239
+ )
240
+
241
+ const closeMenu = useCallback(
242
+ (options?: { restoreFocus?: boolean }) => {
243
+ setOpen(false)
244
+ if (options?.restoreFocus !== false) triggerRef.current?.focus()
245
+ },
246
+ [setOpen],
247
+ )
248
+
98
249
  const value = useMemo<DropdownMenuContextValue>(
99
- () => ({ open, setOpen, triggerRef, contentId }),
100
- [open, setOpen, contentId],
250
+ () => ({
251
+ open,
252
+ setOpen,
253
+ openMenu,
254
+ closeMenu,
255
+ triggerRef,
256
+ contentId,
257
+ initialFocusRef,
258
+ focusEdgeRef,
259
+ }),
260
+ [open, setOpen, openMenu, closeMenu, contentId],
101
261
  )
102
262
 
103
263
  return (
@@ -119,37 +279,56 @@ export interface DropdownMenuTriggerProps
119
279
  * secondary Button visual (rounded-xl + `--bg-card` fill + 1px
120
280
  * `--border`). Set `unstyled` to wrap your own button shape.
121
281
  */
122
- export function DropdownMenuTrigger({
123
- unstyled,
124
- className,
125
- onClick,
126
- children,
127
- ...rest
128
- }: DropdownMenuTriggerProps) {
129
- const { open, setOpen, triggerRef, contentId } =
282
+ export const DropdownMenuTrigger = forwardRef<
283
+ HTMLButtonElement,
284
+ DropdownMenuTriggerProps
285
+ >(function DropdownMenuTrigger(
286
+ { unstyled, className, onClick, onKeyDown, children, ...rest },
287
+ forwardedRef,
288
+ ) {
289
+ const { open, openMenu, closeMenu, triggerRef, contentId } =
130
290
  useDropdownMenuCtx('DropdownMenuTrigger')
291
+ const ref = useMemo(
292
+ () => composeRefs<HTMLButtonElement>(forwardedRef, triggerRef),
293
+ [forwardedRef, triggerRef],
294
+ )
131
295
 
132
296
  return (
133
297
  <button
134
- ref={triggerRef}
298
+ ref={ref}
135
299
  type="button"
136
300
  aria-haspopup="menu"
137
301
  aria-expanded={open}
138
302
  aria-controls={open ? contentId : undefined}
303
+ data-state={open ? 'open' : 'closed'}
139
304
  className={cn(
140
305
  !unstyled && 'btn btn-secondary',
141
306
  className,
142
307
  )}
143
308
  onClick={(event) => {
144
309
  onClick?.(event)
145
- if (!event.defaultPrevented) setOpen(!open)
310
+ if (event.defaultPrevented) return
311
+ if (open) closeMenu()
312
+ else openMenu('first')
313
+ }}
314
+ onKeyDown={(event) => {
315
+ onKeyDown?.(event)
316
+ if (event.defaultPrevented) return
317
+ // APG menu button: Down opens onto the first item, Up onto the last.
318
+ if (event.key === 'ArrowDown') {
319
+ event.preventDefault()
320
+ openMenu('first')
321
+ } else if (event.key === 'ArrowUp') {
322
+ event.preventDefault()
323
+ openMenu('last')
324
+ }
146
325
  }}
147
326
  {...rest}
148
327
  >
149
328
  {children}
150
329
  </button>
151
330
  )
152
- }
331
+ })
153
332
 
154
333
  export interface DropdownMenuContentProps
155
334
  extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
@@ -158,142 +337,222 @@ export interface DropdownMenuContentProps
158
337
  align?: Align
159
338
  /** Pixel offset along the alignment axis. Default 0. */
160
339
  alignOffset?: number
161
- /** Place above (`top`) or below (`bottom`) the trigger. Default `bottom`. */
340
+ /**
341
+ * Preferred side of the trigger. Default `bottom`. Flips to the opposite
342
+ * side when it cannot fit (before the 2026-09-05 rebuild it was clamped
343
+ * over its own trigger instead; only that no-room case differs).
344
+ */
162
345
  side?: Side
163
346
  /** Pixel offset along the side axis. Default 6. */
164
347
  sideOffset?: number
165
348
  }
166
349
 
167
- export function DropdownMenuContent({
168
- children,
169
- align = 'start',
170
- alignOffset = 0,
171
- side = 'bottom',
172
- sideOffset = 6,
173
- className,
174
- style,
175
- ...rest
176
- }: DropdownMenuContentProps) {
177
- const { open, setOpen, triggerRef, contentId } =
350
+ export const DropdownMenuContent = forwardRef<
351
+ HTMLDivElement,
352
+ DropdownMenuContentProps
353
+ >(function DropdownMenuContent(
354
+ {
355
+ children,
356
+ align = 'start',
357
+ alignOffset = 0,
358
+ side = 'bottom',
359
+ sideOffset = 6,
360
+ className,
361
+ style,
362
+ onKeyDown,
363
+ onFocus,
364
+ ...rest
365
+ },
366
+ forwardedRef,
367
+ ) {
368
+ const { open, closeMenu, triggerRef, contentId, initialFocusRef, focusEdgeRef } =
178
369
  useDropdownMenuCtx('DropdownMenuContent')
179
370
  const contentRef = useRef<HTMLDivElement | null>(null)
180
- const [position, setPosition] = useState<{ left: number; top: number } | null>(
181
- null,
371
+
372
+ const {
373
+ ref: positionRef,
374
+ style: positionStyle,
375
+ placement,
376
+ positioned,
377
+ } = useAnchoredPosition<HTMLDivElement>({
378
+ anchorRef: triggerRef,
379
+ side,
380
+ align,
381
+ offset: sideOffset,
382
+ alignOffset,
383
+ enabled: open,
384
+ })
385
+
386
+ // Topmost-wins Escape, shared with Modal and Popover.
387
+ const layer = useLayer({
388
+ enabled: open,
389
+ kind: 'popover',
390
+ elementRef: contentRef,
391
+ onEscape: () => closeMenu(),
392
+ })
393
+
394
+ useOutsideClick(
395
+ [triggerRef, contentRef],
396
+ () => {
397
+ // Restore only when focus is still ours to give back. If the pointer
398
+ // landed on another focusable control, the browser's own mousedown
399
+ // focus (which runs after this pointerdown) must win.
400
+ const active = document.activeElement
401
+ const inside =
402
+ active instanceof HTMLElement && Boolean(contentRef.current?.contains(active))
403
+ closeMenu({ restoreFocus: inside })
404
+ },
405
+ { enabled: open, layer },
182
406
  )
183
407
 
184
- // Position relative to the trigger viewport rect. We measure on
185
- // open + on layout changes (scroll / resize) so the panel tracks.
186
- useLayoutEffect(() => {
187
- if (!open) {
188
- setPosition(null)
189
- return
190
- }
191
- const measure = () => {
192
- const triggerEl = triggerRef.current
193
- const contentEl = contentRef.current
194
- if (!triggerEl || !contentEl) return
195
- const triggerRect = triggerEl.getBoundingClientRect()
196
- const contentRect = contentEl.getBoundingClientRect()
197
-
198
- let left = triggerRect.left + alignOffset
199
- if (align === 'end') left = triggerRect.right - contentRect.width - alignOffset
200
- else if (align === 'center')
201
- left =
202
- triggerRect.left +
203
- triggerRect.width / 2 -
204
- contentRect.width / 2 +
205
- alignOffset
206
-
207
- let top =
208
- side === 'bottom'
209
- ? triggerRect.bottom + sideOffset
210
- : triggerRect.top - contentRect.height - sideOffset
211
-
212
- // Keep on-screen — clamp to viewport with an 8px gutter.
213
- const gutter = 8
214
- const maxLeft = window.innerWidth - contentRect.width - gutter
215
- const maxTop = window.innerHeight - contentRect.height - gutter
216
- left = Math.max(gutter, Math.min(left, maxLeft))
217
- top = Math.max(gutter, Math.min(top, maxTop))
218
-
219
- setPosition({ left, top })
220
- }
408
+ const ref = useMemo(
409
+ () => composeRefs<HTMLDivElement>(forwardedRef, contentRef, positionRef),
410
+ [forwardedRef, positionRef],
411
+ )
412
+
413
+ /** Focus item `index`, wrapping, and rove the tab stop onto it. */
414
+ const focusIndex = useCallback((index: number) => {
415
+ const items = menuItemsIn(contentRef.current)
416
+ if (items.length === 0) return
417
+ const wrapped = ((index % items.length) + items.length) % items.length
418
+ applyRoving(items, wrapped)
419
+ items[wrapped]?.focus()
420
+ }, [])
421
+
422
+ /**
423
+ * Entry point for "put focus in the menu". Items first; a menu that is
424
+ * really a small form (range-filter chip) falls back to its first field,
425
+ * and a menu with nothing focusable to the surface itself — anything but
426
+ * leaving focus behind on the trigger with the menu open.
427
+ */
428
+ const focusEdge = useCallback(
429
+ (where: InitialFocus) => {
430
+ const content = contentRef.current
431
+ if (!content) return
432
+ const items = menuItemsIn(content)
433
+ if (items.length > 0) {
434
+ focusIndex(where === 'last' ? items.length - 1 : 0)
435
+ return
436
+ }
437
+ const focusable = Array.from(
438
+ content.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
439
+ )
440
+ const fallback =
441
+ where === 'last' ? focusable[focusable.length - 1] : focusable[0]
442
+ ;(fallback ?? content).focus()
443
+ },
444
+ [focusIndex],
445
+ )
221
446
 
222
- measure()
223
- window.addEventListener('resize', measure)
224
- window.addEventListener('scroll', measure, true)
447
+ // The trigger reaches back in here for ArrowDown on an already-open menu.
448
+ useEffect(() => {
449
+ focusEdgeRef.current = focusEdge
225
450
  return () => {
226
- window.removeEventListener('resize', measure)
227
- window.removeEventListener('scroll', measure, true)
451
+ if (focusEdgeRef.current === focusEdge) focusEdgeRef.current = null
228
452
  }
229
- }, [open, align, alignOffset, side, sideOffset, triggerRef])
453
+ }, [focusEdge, focusEdgeRef])
230
454
 
231
- // Outside-click + Escape dismissal.
455
+ // Focus on open — gated on `positioned` because the first paint is parked
456
+ // off-screen with `visibility: hidden`, and a hidden element cannot take
457
+ // focus. Once per open.
458
+ const didFocusRef = useRef(false)
232
459
  useEffect(() => {
233
- if (!open) return
234
- const onPointerDown = (event: PointerEvent) => {
235
- const target = event.target as Node | null
236
- if (!target) return
237
- if (contentRef.current?.contains(target)) return
238
- if (triggerRef.current?.contains(target)) return
239
- setOpen(false)
460
+ if (!open) {
461
+ didFocusRef.current = false
462
+ return
240
463
  }
241
- const onKeyDown = (event: KeyboardEvent) => {
242
- if (event.key === 'Escape') {
243
- event.stopPropagation()
244
- setOpen(false)
245
- triggerRef.current?.focus()
246
- }
464
+ if (!positioned || didFocusRef.current) return
465
+ didFocusRef.current = true
466
+ focusEdge(initialFocusRef.current)
467
+ }, [open, positioned, focusEdge, initialFocusRef])
468
+
469
+ const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
470
+ onKeyDown?.(event)
471
+ if (event.defaultPrevented) return
472
+ const { key } = event
473
+ if (
474
+ key !== 'ArrowDown' &&
475
+ key !== 'ArrowUp' &&
476
+ key !== 'Home' &&
477
+ key !== 'End' &&
478
+ key !== 'Tab'
479
+ ) {
480
+ return
247
481
  }
248
- document.addEventListener('pointerdown', onPointerDown)
249
- document.addEventListener('keydown', onKeyDown)
250
- return () => {
251
- document.removeEventListener('pointerdown', onPointerDown)
252
- document.removeEventListener('keydown', onKeyDown)
482
+ const target = event.target
483
+ const current =
484
+ target instanceof HTMLElement
485
+ ? target.closest<HTMLElement>(MENU_ITEM_SELECTOR)
486
+ : null
487
+
488
+ if (key === 'Tab') {
489
+ // APG: Tab closes the menu. Only from an item — a form-shaped menu
490
+ // must keep ordinary tabbing between its own fields.
491
+ if (!current) return
492
+ // Land on the trigger rather than letting the default action continue:
493
+ // the content is portaled to the END of <body>, so the "next element
494
+ // after it" is nothing at all — the tab sequence falls off the
495
+ // document. One extra Tab from the trigger then moves on normally.
496
+ event.preventDefault()
497
+ closeMenu()
498
+ return
253
499
  }
254
- }, [open, setOpen, triggerRef])
255
500
 
256
- if (!open || typeof document === 'undefined') return null
501
+ if (ownsArrowKeys(target)) return
502
+ const items = menuItemsIn(contentRef.current)
503
+ if (items.length === 0) return
504
+ event.preventDefault()
505
+ const index = current ? items.indexOf(current) : -1
506
+ if (key === 'Home') focusIndex(0)
507
+ else if (key === 'End') focusIndex(items.length - 1)
508
+ else if (key === 'ArrowDown') focusIndex(index + 1)
509
+ else focusIndex(index <= 0 ? items.length - 1 : index - 1)
510
+ }
257
511
 
258
- // Two-phase render: first paint measures the content invisibly at
259
- // (0,0) with opacity:0, useLayoutEffect computes the real position,
260
- // and the second paint applies the correct coords + entrance animation.
261
- // The animation classes are gated behind `isPositioned` so the
262
- // entrance never fires from the unpositioned location — that's what
263
- // caused the "flash at top of viewport then jump under the trigger"
264
- // glitch.
265
- const isPositioned = position !== null
512
+ const handleFocus = (event: ReactFocusEvent<HTMLDivElement>) => {
513
+ onFocus?.(event)
514
+ // Keep the single tab stop on whatever actually holds focus, including
515
+ // focus arriving by pointer.
516
+ const item = event.target.closest<HTMLElement>(MENU_ITEM_SELECTOR)
517
+ if (!item) return
518
+ const items = menuItemsIn(contentRef.current)
519
+ const index = items.indexOf(item)
520
+ if (index >= 0) applyRoving(items, index)
521
+ }
266
522
 
523
+ if (!open || typeof document === 'undefined') return null
524
+
525
+ // Two-phase render (see lib/anchor.ts): the hook parks the first paint
526
+ // off-screen and hidden, measures, then applies real coordinates. The
527
+ // entrance animation is gated on `positioned` so it never fires from the
528
+ // parked location — that was the "flash at top of viewport then jump
529
+ // under the trigger" glitch.
267
530
  return createPortal(
268
531
  <div
269
- ref={contentRef}
532
+ ref={ref}
270
533
  id={contentId}
271
534
  role="menu"
535
+ aria-orientation="vertical"
536
+ // Focusable surface, so a menu with nothing focusable inside still
537
+ // has somewhere to put focus instead of dropping it to <body>.
538
+ tabIndex={-1}
539
+ onKeyDown={handleKeyDown}
540
+ onFocus={handleFocus}
272
541
  data-slot="dropdown-menu-content"
273
542
  data-state="open"
274
- data-positioned={isPositioned ? 'true' : 'false'}
543
+ data-side={placement?.side}
544
+ data-align={placement?.align}
545
+ data-positioned={positioned ? 'true' : 'false'}
275
546
  className={cn(
276
547
  'fixed z-[100] min-w-48 max-w-[min(28rem,calc(100vw-16px))]',
277
548
  'p-1.5 overflow-y-auto',
278
549
  'rounded-[var(--radius-lg)]',
279
- isPositioned && 'ds-enter-pop',
550
+ positioned && 'ds-enter-pop',
280
551
  className,
281
552
  )}
282
553
  style={{
283
- // Anchor the unpositioned phase WAY off-screen as a defense
284
- // against any browser/renderer that doesn't fully respect
285
- // visibility:hidden during a same-frame double-render. If the
286
- // first paint slips through, it's at (-99999, -99999), not at
287
- // viewport (0, 0) where a left-edge flash would be visible.
288
- left: position?.left ?? -99999,
289
- top: position?.top ?? -99999,
290
- opacity: isPositioned ? undefined : 0,
291
- pointerEvents: isPositioned ? undefined : 'none',
292
- visibility: isPositioned ? 'visible' : 'hidden',
293
- background: 'rgb(var(--popover))',
294
- color: 'rgb(var(--popover-foreground))',
295
- border: '1px solid rgb(var(--border))',
296
- boxShadow: 'var(--shadow-lg, 0 10px 32px rgba(0,0,0,0.18))',
554
+ ...positionStyle,
555
+ ...POPOVER_SURFACE_STYLE,
297
556
  ...style,
298
557
  }}
299
558
  {...rest}
@@ -302,7 +561,7 @@ export function DropdownMenuContent({
302
561
  </div>,
303
562
  document.body,
304
563
  )
305
- }
564
+ })
306
565
 
307
566
  export interface DropdownMenuItemProps
308
567
  extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -312,17 +571,17 @@ export interface DropdownMenuItemProps
312
571
  inset?: boolean
313
572
  }
314
573
 
315
- export function DropdownMenuItem({
316
- variant = 'default',
317
- inset,
318
- className,
319
- onClick,
320
- children,
321
- ...rest
322
- }: DropdownMenuItemProps) {
323
- const { setOpen } = useDropdownMenuCtx('DropdownMenuItem')
574
+ export const DropdownMenuItem = forwardRef<
575
+ HTMLButtonElement,
576
+ DropdownMenuItemProps
577
+ >(function DropdownMenuItem(
578
+ { variant = 'default', inset, className, onClick, children, ...rest },
579
+ forwardedRef,
580
+ ) {
581
+ const { closeMenu } = useDropdownMenuCtx('DropdownMenuItem')
324
582
  return (
325
583
  <button
584
+ ref={forwardedRef}
326
585
  role="menuitem"
327
586
  type="button"
328
587
  data-slot="dropdown-menu-item"
@@ -341,14 +600,17 @@ export function DropdownMenuItem({
341
600
  )}
342
601
  onClick={(event) => {
343
602
  onClick?.(event)
344
- if (!event.defaultPrevented) setOpen(false)
603
+ // Activation closes AND hands focus back to the trigger. Without the
604
+ // restore, a dialog opened from here has no `activeElement` to
605
+ // capture and its own close drops focus to <body>.
606
+ if (!event.defaultPrevented) closeMenu()
345
607
  }}
346
608
  {...rest}
347
609
  >
348
610
  {children}
349
611
  </button>
350
612
  )
351
- }
613
+ })
352
614
 
353
615
  export interface DropdownMenuCheckboxItemProps
354
616
  extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
@@ -356,17 +618,16 @@ export interface DropdownMenuCheckboxItemProps
356
618
  onCheckedChange?: (checked: boolean) => void
357
619
  }
358
620
 
359
- export function DropdownMenuCheckboxItem({
360
- checked,
361
- onCheckedChange,
362
- className,
363
- children,
364
- onClick,
365
- disabled,
366
- ...rest
367
- }: DropdownMenuCheckboxItemProps) {
621
+ export const DropdownMenuCheckboxItem = forwardRef<
622
+ HTMLButtonElement,
623
+ DropdownMenuCheckboxItemProps
624
+ >(function DropdownMenuCheckboxItem(
625
+ { checked, onCheckedChange, className, children, onClick, disabled, ...rest },
626
+ forwardedRef,
627
+ ) {
368
628
  return (
369
629
  <button
630
+ ref={forwardedRef}
370
631
  role="menuitemcheckbox"
371
632
  type="button"
372
633
  data-slot="dropdown-menu-checkbox-item"
@@ -399,7 +660,7 @@ export function DropdownMenuCheckboxItem({
399
660
  ) : null}
400
661
  </button>
401
662
  )
402
- }
663
+ })
403
664
 
404
665
  export function DropdownMenuSeparator({
405
666
  className,