@lovett/ui 0.0.11 → 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 +5402 -71
- package/dist/index.js +21992 -11618
- 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 +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- 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__/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 +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -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/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- 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/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 +290 -19
- 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 +296 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- 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/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- 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 +112 -0
package/src/dropdown-menu.tsx
CHANGED
|
@@ -29,9 +29,33 @@
|
|
|
29
29
|
* `--border`). Consumers can pass custom trigger content; the wrapper
|
|
30
30
|
* applies the button-style classes unless `unstyled` is set.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* trigger
|
|
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).
|
|
35
59
|
*
|
|
36
60
|
* Token discipline: surface chrome is `POPOVER_SURFACE_STYLE` (shared with
|
|
37
61
|
* Popover — internal `--popover` tokens are allowed inside packages/ui);
|
|
@@ -48,12 +72,15 @@ import {
|
|
|
48
72
|
forwardRef,
|
|
49
73
|
useCallback,
|
|
50
74
|
useContext,
|
|
75
|
+
useEffect,
|
|
51
76
|
useId,
|
|
52
77
|
useMemo,
|
|
53
78
|
useRef,
|
|
54
79
|
useState,
|
|
55
80
|
type ButtonHTMLAttributes,
|
|
81
|
+
type FocusEvent as ReactFocusEvent,
|
|
56
82
|
type HTMLAttributes,
|
|
83
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
57
84
|
type ReactNode,
|
|
58
85
|
type RefObject,
|
|
59
86
|
} from 'react'
|
|
@@ -73,11 +100,80 @@ import { POPOVER_SURFACE_STYLE } from './popover'
|
|
|
73
100
|
type Align = AnchorAlign
|
|
74
101
|
type Side = AnchorSide
|
|
75
102
|
|
|
103
|
+
/** Where focus lands when the menu opens. */
|
|
104
|
+
type InitialFocus = 'first' | 'last'
|
|
105
|
+
|
|
76
106
|
interface DropdownMenuContextValue {
|
|
77
107
|
open: boolean
|
|
78
108
|
setOpen: (next: boolean) => void
|
|
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
|
|
79
113
|
triggerRef: RefObject<HTMLButtonElement | null>
|
|
80
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
|
|
81
177
|
}
|
|
82
178
|
|
|
83
179
|
const DropdownMenuCtx = createContext<DropdownMenuContextValue | null>(null)
|
|
@@ -117,6 +213,9 @@ export function DropdownMenu({
|
|
|
117
213
|
const generatedId = useId()
|
|
118
214
|
const contentId = `dropdown-${generatedId.replace(/:/g, '')}`
|
|
119
215
|
|
|
216
|
+
const initialFocusRef = useRef<InitialFocus>('first')
|
|
217
|
+
const focusEdgeRef = useRef<((where: InitialFocus) => void) | null>(null)
|
|
218
|
+
|
|
120
219
|
const setOpen = useCallback(
|
|
121
220
|
(next: boolean) => {
|
|
122
221
|
if (!isControlled) setUncontrolledOpen(next)
|
|
@@ -125,9 +224,40 @@ export function DropdownMenu({
|
|
|
125
224
|
[isControlled, onOpenChange],
|
|
126
225
|
)
|
|
127
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
|
+
|
|
128
249
|
const value = useMemo<DropdownMenuContextValue>(
|
|
129
|
-
() => ({
|
|
130
|
-
|
|
250
|
+
() => ({
|
|
251
|
+
open,
|
|
252
|
+
setOpen,
|
|
253
|
+
openMenu,
|
|
254
|
+
closeMenu,
|
|
255
|
+
triggerRef,
|
|
256
|
+
contentId,
|
|
257
|
+
initialFocusRef,
|
|
258
|
+
focusEdgeRef,
|
|
259
|
+
}),
|
|
260
|
+
[open, setOpen, openMenu, closeMenu, contentId],
|
|
131
261
|
)
|
|
132
262
|
|
|
133
263
|
return (
|
|
@@ -153,10 +283,10 @@ export const DropdownMenuTrigger = forwardRef<
|
|
|
153
283
|
HTMLButtonElement,
|
|
154
284
|
DropdownMenuTriggerProps
|
|
155
285
|
>(function DropdownMenuTrigger(
|
|
156
|
-
{ unstyled, className, onClick, children, ...rest },
|
|
286
|
+
{ unstyled, className, onClick, onKeyDown, children, ...rest },
|
|
157
287
|
forwardedRef,
|
|
158
288
|
) {
|
|
159
|
-
const { open,
|
|
289
|
+
const { open, openMenu, closeMenu, triggerRef, contentId } =
|
|
160
290
|
useDropdownMenuCtx('DropdownMenuTrigger')
|
|
161
291
|
const ref = useMemo(
|
|
162
292
|
() => composeRefs<HTMLButtonElement>(forwardedRef, triggerRef),
|
|
@@ -177,7 +307,21 @@ export const DropdownMenuTrigger = forwardRef<
|
|
|
177
307
|
)}
|
|
178
308
|
onClick={(event) => {
|
|
179
309
|
onClick?.(event)
|
|
180
|
-
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
|
+
}
|
|
181
325
|
}}
|
|
182
326
|
{...rest}
|
|
183
327
|
>
|
|
@@ -215,11 +359,13 @@ export const DropdownMenuContent = forwardRef<
|
|
|
215
359
|
sideOffset = 6,
|
|
216
360
|
className,
|
|
217
361
|
style,
|
|
362
|
+
onKeyDown,
|
|
363
|
+
onFocus,
|
|
218
364
|
...rest
|
|
219
365
|
},
|
|
220
366
|
forwardedRef,
|
|
221
367
|
) {
|
|
222
|
-
const { open,
|
|
368
|
+
const { open, closeMenu, triggerRef, contentId, initialFocusRef, focusEdgeRef } =
|
|
223
369
|
useDropdownMenuCtx('DropdownMenuContent')
|
|
224
370
|
const contentRef = useRef<HTMLDivElement | null>(null)
|
|
225
371
|
|
|
@@ -242,22 +388,138 @@ export const DropdownMenuContent = forwardRef<
|
|
|
242
388
|
enabled: open,
|
|
243
389
|
kind: 'popover',
|
|
244
390
|
elementRef: contentRef,
|
|
245
|
-
onEscape: () =>
|
|
246
|
-
setOpen(false)
|
|
247
|
-
triggerRef.current?.focus()
|
|
248
|
-
},
|
|
391
|
+
onEscape: () => closeMenu(),
|
|
249
392
|
})
|
|
250
393
|
|
|
251
|
-
useOutsideClick(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 },
|
|
406
|
+
)
|
|
255
407
|
|
|
256
408
|
const ref = useMemo(
|
|
257
409
|
() => composeRefs<HTMLDivElement>(forwardedRef, contentRef, positionRef),
|
|
258
410
|
[forwardedRef, positionRef],
|
|
259
411
|
)
|
|
260
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
|
+
)
|
|
446
|
+
|
|
447
|
+
// The trigger reaches back in here for ArrowDown on an already-open menu.
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
focusEdgeRef.current = focusEdge
|
|
450
|
+
return () => {
|
|
451
|
+
if (focusEdgeRef.current === focusEdge) focusEdgeRef.current = null
|
|
452
|
+
}
|
|
453
|
+
}, [focusEdge, focusEdgeRef])
|
|
454
|
+
|
|
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)
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
if (!open) {
|
|
461
|
+
didFocusRef.current = false
|
|
462
|
+
return
|
|
463
|
+
}
|
|
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
|
|
481
|
+
}
|
|
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
|
|
499
|
+
}
|
|
500
|
+
|
|
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
|
+
}
|
|
511
|
+
|
|
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
|
+
}
|
|
522
|
+
|
|
261
523
|
if (!open || typeof document === 'undefined') return null
|
|
262
524
|
|
|
263
525
|
// Two-phase render (see lib/anchor.ts): the hook parks the first paint
|
|
@@ -270,6 +532,12 @@ export const DropdownMenuContent = forwardRef<
|
|
|
270
532
|
ref={ref}
|
|
271
533
|
id={contentId}
|
|
272
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}
|
|
273
541
|
data-slot="dropdown-menu-content"
|
|
274
542
|
data-state="open"
|
|
275
543
|
data-side={placement?.side}
|
|
@@ -310,7 +578,7 @@ export const DropdownMenuItem = forwardRef<
|
|
|
310
578
|
{ variant = 'default', inset, className, onClick, children, ...rest },
|
|
311
579
|
forwardedRef,
|
|
312
580
|
) {
|
|
313
|
-
const {
|
|
581
|
+
const { closeMenu } = useDropdownMenuCtx('DropdownMenuItem')
|
|
314
582
|
return (
|
|
315
583
|
<button
|
|
316
584
|
ref={forwardedRef}
|
|
@@ -332,7 +600,10 @@ export const DropdownMenuItem = forwardRef<
|
|
|
332
600
|
)}
|
|
333
601
|
onClick={(event) => {
|
|
334
602
|
onClick?.(event)
|
|
335
|
-
|
|
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()
|
|
336
607
|
}}
|
|
337
608
|
{...rest}
|
|
338
609
|
>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# filter-core — export wiring
|
|
2
|
+
|
|
3
|
+
`packages/ui/src/filter-core/**` is self-contained and currently reachable
|
|
4
|
+
only by its own path. Nothing outside this directory was edited.
|
|
5
|
+
|
|
6
|
+
## The one line to add
|
|
7
|
+
|
|
8
|
+
Append to `packages/ui/src/index.ts`:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// Board filter model — pure TypeScript, no React. See src/filter-core/index.ts.
|
|
12
|
+
export * from './filter-core'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`filter-core/index.ts` is an explicit barrel (no wildcard re-exports of its
|
|
16
|
+
own), so `export *` here surfaces exactly the list below and nothing else.
|
|
17
|
+
Every type is exported with `export type`, so `verbatimModuleSyntax` is
|
|
18
|
+
satisfied and none of it survives into the JS bundle.
|
|
19
|
+
|
|
20
|
+
### If you would rather namespace it
|
|
21
|
+
|
|
22
|
+
The alternative, if `@lovett/ui`'s top level should stay component-only:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
// packages/ui/package.json — "exports"
|
|
26
|
+
"./filter-core": "./src/filter-core/index.ts"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
…plus the matching `publishConfig.exports` entry and a second `tsup` entry
|
|
30
|
+
(`entry: { index: 'src/index.ts', 'filter-core': 'src/filter-core/index.ts' }`).
|
|
31
|
+
That is three edits instead of one; the flat `export *` is the cheaper default
|
|
32
|
+
and there are no name collisions (checked against the current `src/index.ts`).
|
|
33
|
+
|
|
34
|
+
## What that line surfaces
|
|
35
|
+
|
|
36
|
+
**Values**
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
COLUMN_DATA_TYPES
|
|
40
|
+
DEFAULT_OPERATORS, FILTER_OPERATOR_DETAILS
|
|
41
|
+
textFilterOperators, numberFilterOperators, dateFilterOperators,
|
|
42
|
+
optionFilterOperators, multiOptionFilterOperators
|
|
43
|
+
operatorsForType, operatorDetails, operatorValueArity, relatedOperators,
|
|
44
|
+
isOperatorOf, negateOperator, determineNewOperator
|
|
45
|
+
defineColumns, indexColumns, isColumnOfType
|
|
46
|
+
textFilterFn, numberFilterFn, dateFilterFn, optionFilterFn, multiOptionFilterFn
|
|
47
|
+
rowMatchesFilter, rowMatchesFilters, pruneFilters, filterRows
|
|
48
|
+
facetedCounts, facetedRowsFor, facetedMinMax
|
|
49
|
+
getFilter, upsertFilter, removeFilter, clearFilters, setFilterOperator
|
|
50
|
+
setTextFilter, setNumberFilter, setDateFilter, setOptionFilter,
|
|
51
|
+
setMultiOptionFilter, toggleOptionValue, toggleMultiOptionValue
|
|
52
|
+
FILTERS_SEARCH_PARAM, serializeFilters, parseFilters
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Types**
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Nullable, ElementType
|
|
59
|
+
ColumnDataType, OptionBasedColumnDataType, ColumnDataNativeMap, ColumnValueMap
|
|
60
|
+
ColumnOption, ColumnAccessor, ColumnConfig, AnyColumnConfig
|
|
61
|
+
FilterValues, FilterModel, FiltersState
|
|
62
|
+
FilterOperators, FilterOperatorTarget, FilterOperatorArity
|
|
63
|
+
FilterOperatorDetails, FilterOperatorDetailsShape, FilterDetails,
|
|
64
|
+
FilterTypeOperatorDetails
|
|
65
|
+
TextFilterOperator, NumberFilterOperator, DateFilterOperator,
|
|
66
|
+
OptionFilterOperator, MultiOptionFilterOperator
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## What it deliberately does NOT surface
|
|
70
|
+
|
|
71
|
+
`./examples/task-filter-columns` — the Tasks-board column set is illustration,
|
|
72
|
+
not API. The lens declares its own columns over its own row type. Import it
|
|
73
|
+
only from a test or while reading.
|
|
74
|
+
|
|
75
|
+
## Notes for whoever wires this
|
|
76
|
+
|
|
77
|
+
- **No new dependency.** Nothing was added to `package.json` and nothing was
|
|
78
|
+
installed. `date-fns` (four helpers) is inlined in `date-utils.ts`.
|
|
79
|
+
- **`zod` was asked for and is NOT used**, because zod is not a dependency of
|
|
80
|
+
`@lovett/ui` and adding one to the design system is an ADR-level call plus a
|
|
81
|
+
`package.json` edit — outside this change's scope. `url.ts` hand-validates to
|
|
82
|
+
the same standard (every field, every bound, fresh object literals) and is
|
|
83
|
+
funnelled through a single `parseEntry` so a zod schema can replace it in one
|
|
84
|
+
place if you want it later.
|
|
85
|
+
- **CSS: none.** Nothing to add to `styles.css`.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-time validation.
|
|
3
|
+
*
|
|
4
|
+
* BUG 2, the other half: a column type with no predicate must FAIL LOUDLY
|
|
5
|
+
* where the columns are declared. In the source, declaring a date column was
|
|
6
|
+
* accepted and then silently filtered to everything.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import { defineColumns, indexColumns, isColumnOfType } from '../index'
|
|
10
|
+
import type { AnyColumnConfig } from '../index'
|
|
11
|
+
|
|
12
|
+
interface Row {
|
|
13
|
+
readonly id: string
|
|
14
|
+
readonly name: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a config the type system would reject, to exercise the RUNTIME gate.
|
|
19
|
+
* The double assertion is confined to this helper and is the only way to
|
|
20
|
+
* reach a validator whose whole job is catching what types cannot.
|
|
21
|
+
*/
|
|
22
|
+
function illegal(config: unknown): AnyColumnConfig<Row> {
|
|
23
|
+
return config as AnyColumnConfig<Row>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ok = { id: 'name', displayName: 'Name', accessor: (r: Row) => r.name }
|
|
27
|
+
|
|
28
|
+
describe('defineColumns', () => {
|
|
29
|
+
it('returns the columns it was given', () => {
|
|
30
|
+
const columns = defineColumns<Row>([{ ...ok, type: 'text' }])
|
|
31
|
+
expect(columns).toHaveLength(1)
|
|
32
|
+
expect(columns[0]?.id).toBe('name')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('throws on an unsupported column type', () => {
|
|
36
|
+
expect(() =>
|
|
37
|
+
defineColumns<Row>([illegal({ ...ok, type: 'boolean' })]),
|
|
38
|
+
).toThrow(/unsupported type "boolean"/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('throws on a missing type', () => {
|
|
42
|
+
expect(() => defineColumns<Row>([illegal({ ...ok })])).toThrow(
|
|
43
|
+
/unsupported type/,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('throws on a blank id', () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
defineColumns<Row>([illegal({ ...ok, id: ' ', type: 'text' })]),
|
|
50
|
+
).toThrow(/non-empty string id/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('throws on a reserved id', () => {
|
|
54
|
+
expect(() =>
|
|
55
|
+
defineColumns<Row>([illegal({ ...ok, id: '__proto__', type: 'text' })]),
|
|
56
|
+
).toThrow(/reserved/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('throws on duplicate ids', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
defineColumns<Row>([
|
|
62
|
+
{ ...ok, type: 'text' },
|
|
63
|
+
{ ...ok, type: 'text' },
|
|
64
|
+
]),
|
|
65
|
+
).toThrow(/duplicate column id/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws on a missing accessor or displayName', () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
defineColumns<Row>([illegal({ id: 'x', displayName: 'X', type: 'text' })]),
|
|
71
|
+
).toThrow(/accessor function/)
|
|
72
|
+
expect(() =>
|
|
73
|
+
defineColumns<Row>([illegal({ ...ok, displayName: '', type: 'text' })]),
|
|
74
|
+
).toThrow(/displayName/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('throws on a malformed option', () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
defineColumns<Row>([
|
|
80
|
+
illegal({ ...ok, type: 'option', options: [{ label: 'No value' }] }),
|
|
81
|
+
]),
|
|
82
|
+
).toThrow(/malformed option/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('throws on duplicate option values', () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
defineColumns<Row>([
|
|
88
|
+
{
|
|
89
|
+
...ok,
|
|
90
|
+
type: 'option',
|
|
91
|
+
options: [
|
|
92
|
+
{ value: 'a', label: 'A' },
|
|
93
|
+
{ value: 'a', label: 'Also A' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
]),
|
|
97
|
+
).toThrow(/duplicate option value/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('throws when a number column has min above max', () => {
|
|
101
|
+
expect(() =>
|
|
102
|
+
defineColumns<Row>([
|
|
103
|
+
illegal({ ...ok, type: 'number', min: 10, max: 1 }),
|
|
104
|
+
]),
|
|
105
|
+
).toThrow(/min greater than max/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('accepts every supported type', () => {
|
|
109
|
+
const columns = defineColumns<Row>([
|
|
110
|
+
{ id: 'a', displayName: 'A', type: 'text', accessor: (r) => r.name },
|
|
111
|
+
{ id: 'b', displayName: 'B', type: 'number', accessor: () => 1, min: 0, max: 5 },
|
|
112
|
+
{ id: 'c', displayName: 'C', type: 'date', accessor: () => new Date() },
|
|
113
|
+
{
|
|
114
|
+
id: 'd',
|
|
115
|
+
displayName: 'D',
|
|
116
|
+
type: 'option',
|
|
117
|
+
accessor: (r) => r.name,
|
|
118
|
+
options: [{ value: 'x', label: 'X' }],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'e',
|
|
122
|
+
displayName: 'E',
|
|
123
|
+
type: 'multiOption',
|
|
124
|
+
accessor: () => ['x'],
|
|
125
|
+
options: [{ value: 'x', label: 'X' }],
|
|
126
|
+
},
|
|
127
|
+
])
|
|
128
|
+
expect(columns).toHaveLength(5)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('indexColumns / isColumnOfType', () => {
|
|
133
|
+
const columns = defineColumns<Row>([
|
|
134
|
+
{ id: 'a', displayName: 'A', type: 'text', accessor: (r) => r.name },
|
|
135
|
+
{
|
|
136
|
+
id: 'b',
|
|
137
|
+
displayName: 'B',
|
|
138
|
+
type: 'option',
|
|
139
|
+
accessor: (r) => r.name,
|
|
140
|
+
options: [{ value: 'x', label: 'X' }],
|
|
141
|
+
},
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
it('indexes by id', () => {
|
|
145
|
+
const index = indexColumns(columns)
|
|
146
|
+
expect(index.get('a')?.type).toBe('text')
|
|
147
|
+
expect(index.get('missing')).toBeUndefined()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('narrows on the discriminant', () => {
|
|
151
|
+
const column = columns[1]
|
|
152
|
+
if (!column) throw new Error('fixture missing')
|
|
153
|
+
expect(isColumnOfType(column, 'option')).toBe(true)
|
|
154
|
+
expect(isColumnOfType(column, 'text')).toBe(false)
|
|
155
|
+
if (isColumnOfType(column, 'option')) {
|
|
156
|
+
expect(column.options?.[0]?.value).toBe('x')
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
})
|