@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +121 -0
- package/src/tooltip.tsx +297 -0
package/src/dropdown-menu.tsx
CHANGED
|
@@ -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
|
|
12
|
-
* implementation is a lightweight React Context + Portal + outside-
|
|
13
|
-
* click + Escape handler.
|
|
11
|
+
* CheckboxItem / Separator / Label / Group) carries over.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
44
|
-
type
|
|
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
|
-
|
|
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
|
-
() => ({
|
|
100
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
children,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const { open,
|
|
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={
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
447
|
+
// The trigger reaches back in here for ArrowDown on an already-open menu.
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
focusEdgeRef.current = focusEdge
|
|
225
450
|
return () => {
|
|
226
|
-
|
|
227
|
-
window.removeEventListener('scroll', measure, true)
|
|
451
|
+
if (focusEdgeRef.current === focusEdge) focusEdgeRef.current = null
|
|
228
452
|
}
|
|
229
|
-
}, [
|
|
453
|
+
}, [focusEdge, focusEdgeRef])
|
|
230
454
|
|
|
231
|
-
//
|
|
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)
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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={
|
|
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-
|
|
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
|
-
|
|
550
|
+
positioned && 'ds-enter-pop',
|
|
280
551
|
className,
|
|
281
552
|
)}
|
|
282
553
|
style={{
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
onClick,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
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
|
-
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
children,
|
|
364
|
-
|
|
365
|
-
|
|
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,
|