@lovett/ui 0.0.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) 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 +5574 -57
  4. package/dist/index.js +21650 -11206
  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 +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -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
- * Focus stays on the trigger when the menu opens (menus do not steal
33
- * focus here; items are reached with Tab). Escape closes and refocuses the
34
- * trigger. Roving arrow-key focus is a follow-up.
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
- () => ({ open, setOpen, triggerRef, contentId }),
130
- [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],
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, setOpen, triggerRef, contentId } =
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 (!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
+ }
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, setOpen, triggerRef, contentId } =
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([triggerRef, contentRef], () => setOpen(false), {
252
- enabled: open,
253
- layer,
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 { setOpen } = useDropdownMenuCtx('DropdownMenuItem')
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
- 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()
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
+ })