@lovett/ui 0.0.10 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +6064 -80
  4. package/dist/index.js +21561 -9696
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Popover — an anchored, non-modal floating surface with focus management.
3
+ *
4
+ * Compound: `<Popover>` (state) + `<Popover.Trigger>` + `<Popover.Content>`.
5
+ * Controlled (`open` + `onOpenChange`) or uncontrolled (`defaultOpen`).
6
+ * Content portals to `document.body`, positions through
7
+ * `useAnchoredPosition` (flip + clamp), enters with `.ds-enter-pop` once
8
+ * positioned, and registers on the shared dismiss-layer stack — so Escape
9
+ * closes ONLY the topmost surface (a Popover inside a Modal closes before
10
+ * the Modal does) and an outside pointerdown closes it unless the pointer
11
+ * landed in a layer stacked above (a Select opened from inside it).
12
+ *
13
+ * Focus: opening moves focus into the content — the `[data-autofocus]`
14
+ * control if one is marked, else the first tabbable, else the content
15
+ * itself (`tabIndex={-1}`) — once the content is POSITIONED, not on the
16
+ * first frame: that frame is parked off-screen with `visibility: hidden`,
17
+ * and no browser will focus into a hidden subtree (the call is a silent
18
+ * no-op). Closing returns focus to the trigger, EXCEPT
19
+ * after an outside click, where the user has already put focus where they
20
+ * want it and yanking it back to the trigger would be wrong.
21
+ *
22
+ * Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
23
+ * the row / card / context-menu / palette pickers all sit in a popover with
24
+ * search + live counts). Second consumer is the workspace app, which has
25
+ * three hand-rolled popovers today (sidebar user menu, group-settings
26
+ * colour and icon pickers, CalculatorShell's AssumptionsPopover) — each a
27
+ * migration target. DropdownMenu stays a sibling on the same hook and stack
28
+ * rather than a Popover child: a menu does NOT move focus into itself on
29
+ * open, and 137 call sites depend on that.
30
+ *
31
+ * Trigger is an unstyled `<button type="button">` by default — Popover is a
32
+ * generic surface, the caller chooses the trigger's look. Pass `asChild` to
33
+ * make the single child element the trigger instead (a `<Button>`, a table
34
+ * cell, a chip): it receives the ref, the click handler and the ARIA
35
+ * attributes, and the DOM stays one element. The child must be keyboard-
36
+ * operable ON ITS OWN: Trigger adds ARIA + click only, no role, tabIndex or
37
+ * key handler. A native `<button>` / `<a href>` already is; a `<td>` or
38
+ * `<span>` needs `role="button"`, `tabIndex={0}` and its own Enter / Space
39
+ * handling, or only mouse users can open the popover.
40
+ *
41
+ * Content is `role="dialog"` (non-modal — no `aria-modal`) and needs a name
42
+ * from the caller (`aria-label` / `aria-labelledby`). Pass `role="none"`
43
+ * when the content IS the widget (a listbox, a menu) and would otherwise
44
+ * nest roles.
45
+ *
46
+ * Token discipline: the surface reads the INTERNAL `--popover` /
47
+ * `--popover-foreground` (allowed in packages/ui, ESLint-enforced elsewhere)
48
+ * plus public `--border`, `--shadow-lg`, `--radius-lg`. Geometry comes from
49
+ * the hook; motion from `.ds-enter-pop` (`--dur-fast` / `--ease-out`, honours
50
+ * prefers-reduced-motion in styles.css). No hex, no raw px in classes.
51
+ *
52
+ * Usage:
53
+ *
54
+ * <Popover>
55
+ * <Popover.Trigger asChild>
56
+ * <Button variant="secondary">Assignee</Button>
57
+ * </Popover.Trigger>
58
+ * <Popover.Content aria-label="Pick an assignee" align="start">
59
+ * <input data-autofocus placeholder="Search people…" />
60
+ * …
61
+ * </Popover.Content>
62
+ * </Popover>
63
+ *
64
+ * // Virtual anchor (ContextMenu later): no Trigger, position at a point.
65
+ * <Popover open={!!point} onOpenChange={…} anchorRect={point}>
66
+ * <Popover.Content role="none">…</Popover.Content>
67
+ * </Popover>
68
+ */
69
+
70
+ import {
71
+ cloneElement,
72
+ createContext,
73
+ forwardRef,
74
+ isValidElement,
75
+ useCallback,
76
+ useContext,
77
+ useEffect,
78
+ useId,
79
+ useMemo,
80
+ useRef,
81
+ useState,
82
+ type ButtonHTMLAttributes,
83
+ type CSSProperties,
84
+ type HTMLAttributes,
85
+ type MouseEvent as ReactMouseEvent,
86
+ type ReactNode,
87
+ type Ref,
88
+ type RefObject,
89
+ } from 'react'
90
+ import { createPortal } from 'react-dom'
91
+ import { cn } from './lib/utils'
92
+ import { composeRefs } from './lib/refs'
93
+ import { tabbablesWithin } from './lib/focus'
94
+ import { useLayer } from './lib/layer-stack'
95
+ import {
96
+ useAnchoredPosition,
97
+ useOutsideClick,
98
+ type AnchorAlign,
99
+ type AnchorRect,
100
+ type AnchorSide,
101
+ } from './lib/anchor'
102
+
103
+ /**
104
+ * The popover surface chrome, shared with DropdownMenuContent so a menu and
105
+ * a popover read as the same family. Inline rather than a stylesheet class
106
+ * because the surface tokens are internal and the primitives already own
107
+ * them here (Kbd, DropdownMenu follow the same pattern).
108
+ */
109
+ export const POPOVER_SURFACE_STYLE: CSSProperties = {
110
+ background: 'rgb(var(--popover))',
111
+ color: 'rgb(var(--popover-foreground))',
112
+ border: '1px solid rgb(var(--border))',
113
+ boxShadow: 'var(--shadow-lg)',
114
+ }
115
+
116
+ interface PopoverContextValue {
117
+ open: boolean
118
+ setOpen: (next: boolean) => void
119
+ triggerRef: RefObject<HTMLElement | null>
120
+ contentId: string
121
+ anchorRect: AnchorRect | null
122
+ /** Set when the close came from an outside pointerdown; skips focus return. */
123
+ interactedOutsideRef: RefObject<boolean>
124
+ }
125
+
126
+ const PopoverCtx = createContext<PopoverContextValue | null>(null)
127
+
128
+ function usePopoverCtx(consumer: string): PopoverContextValue {
129
+ const ctx = useContext(PopoverCtx)
130
+ if (!ctx) {
131
+ throw new Error(
132
+ `${consumer} must be rendered inside <Popover>. ` +
133
+ `Wrap your trigger + content with the Popover root component.`,
134
+ )
135
+ }
136
+ return ctx
137
+ }
138
+
139
+ export interface PopoverProps {
140
+ /** Controlled open state. Omit for internal state. */
141
+ open?: boolean
142
+ defaultOpen?: boolean
143
+ /** Fires whenever open transitions. */
144
+ onOpenChange?: (open: boolean) => void
145
+ /**
146
+ * Virtual anchor. When non-null, Content positions against this rect
147
+ * instead of the Trigger — a right-click point, a caret, a cell.
148
+ */
149
+ anchorRect?: AnchorRect | null
150
+ children: ReactNode
151
+ }
152
+
153
+ function PopoverRoot({
154
+ open: controlledOpen,
155
+ defaultOpen,
156
+ onOpenChange,
157
+ anchorRect = null,
158
+ children,
159
+ }: PopoverProps) {
160
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(Boolean(defaultOpen))
161
+ const isControlled = controlledOpen !== undefined
162
+ const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen
163
+ const triggerRef = useRef<HTMLElement | null>(null)
164
+ const interactedOutsideRef = useRef(false)
165
+ const generatedId = useId()
166
+ const contentId = `popover-${generatedId.replace(/:/g, '')}`
167
+
168
+ const setOpen = useCallback(
169
+ (next: boolean) => {
170
+ if (!isControlled) setUncontrolledOpen(next)
171
+ onOpenChange?.(next)
172
+ },
173
+ [isControlled, onOpenChange],
174
+ )
175
+
176
+ const value = useMemo<PopoverContextValue>(
177
+ () => ({ open, setOpen, triggerRef, contentId, anchorRect, interactedOutsideRef }),
178
+ [open, setOpen, contentId, anchorRect],
179
+ )
180
+
181
+ return <PopoverCtx.Provider value={value}>{children}</PopoverCtx.Provider>
182
+ }
183
+
184
+ export interface PopoverTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
185
+ /**
186
+ * Render the single child element as the trigger instead of a `<button>`.
187
+ * The child receives the ref, click handler and ARIA attributes; the DOM
188
+ * stays one element. It must be focusable and keyboard-operable on its
189
+ * own (a `<button>`, an `<a href>`, or `role="button"` + `tabIndex={0}` +
190
+ * Enter / Space handling): Trigger adds no role, tabIndex or key handler.
191
+ */
192
+ asChild?: boolean
193
+ }
194
+
195
+ type TriggerChildProps = HTMLAttributes<HTMLElement> & {
196
+ ref?: Ref<HTMLElement>
197
+ 'data-state'?: string
198
+ }
199
+
200
+ const PopoverTrigger = forwardRef<HTMLButtonElement, PopoverTriggerProps>(
201
+ function PopoverTrigger({ asChild, onClick, children, ...rest }, forwardedRef) {
202
+ const { open, setOpen, triggerRef, contentId } = usePopoverCtx('Popover.Trigger')
203
+
204
+ const ariaProps = {
205
+ 'aria-haspopup': 'dialog',
206
+ 'aria-expanded': open,
207
+ 'aria-controls': open ? contentId : undefined,
208
+ 'data-state': open ? 'open' : 'closed',
209
+ } as const
210
+
211
+ if (asChild) {
212
+ if (!isValidElement<TriggerChildProps>(children)) {
213
+ throw new Error('Popover.Trigger with `asChild` expects exactly one element child.')
214
+ }
215
+ const child = children
216
+ const childOnClick = child.props.onClick
217
+ return cloneElement(child, {
218
+ ...rest,
219
+ ...ariaProps,
220
+ ref: composeRefs<HTMLElement>(child.props.ref, forwardedRef, triggerRef),
221
+ onClick: (event: ReactMouseEvent<HTMLElement>) => {
222
+ childOnClick?.(event)
223
+ onClick?.(event as ReactMouseEvent<HTMLButtonElement>)
224
+ if (!event.defaultPrevented) setOpen(!open)
225
+ },
226
+ })
227
+ }
228
+
229
+ return (
230
+ <button
231
+ ref={composeRefs<HTMLElement>(forwardedRef, triggerRef)}
232
+ type="button"
233
+ {...ariaProps}
234
+ onClick={(event) => {
235
+ onClick?.(event)
236
+ if (!event.defaultPrevented) setOpen(!open)
237
+ }}
238
+ {...rest}
239
+ >
240
+ {children}
241
+ </button>
242
+ )
243
+ },
244
+ )
245
+
246
+ export interface PopoverContentProps
247
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'role' | 'children'> {
248
+ children: ReactNode
249
+ /** Preferred side of the anchor. Default `bottom`. Flips when it cannot fit. */
250
+ side?: AnchorSide
251
+ /** Alignment along the anchor's cross axis. Default `start`. */
252
+ align?: AnchorAlign
253
+ /** Gap from the anchor, px. Default 6. */
254
+ offset?: number
255
+ /** Shift along the alignment axis, px. Default 0. */
256
+ alignOffset?: number
257
+ /** Default true. */
258
+ flip?: boolean
259
+ /** Default true. */
260
+ clampToViewport?: boolean
261
+ /**
262
+ * `dialog` (default) — a named, non-modal dialog; give it `aria-label` or
263
+ * `aria-labelledby`. `none` — no role attribute, for content that is
264
+ * itself the widget.
265
+ */
266
+ role?: 'dialog' | 'none'
267
+ /** Skip the surface chrome (background / border / shadow / radius / padding). */
268
+ unstyled?: boolean
269
+ }
270
+
271
+ const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
272
+ function PopoverContent(
273
+ {
274
+ children,
275
+ side = 'bottom',
276
+ align = 'start',
277
+ offset = 6,
278
+ alignOffset = 0,
279
+ flip = true,
280
+ clampToViewport = true,
281
+ role = 'dialog',
282
+ unstyled,
283
+ className,
284
+ style,
285
+ ...rest
286
+ },
287
+ forwardedRef,
288
+ ) {
289
+ const { open, setOpen, triggerRef, contentId, anchorRect, interactedOutsideRef } =
290
+ usePopoverCtx('Popover.Content')
291
+ const contentRef = useRef<HTMLDivElement | null>(null)
292
+
293
+ const {
294
+ ref: positionRef,
295
+ style: positionStyle,
296
+ placement,
297
+ positioned,
298
+ } = useAnchoredPosition<HTMLDivElement>({
299
+ anchorRef: triggerRef,
300
+ anchorRect,
301
+ side,
302
+ align,
303
+ offset,
304
+ alignOffset,
305
+ flip,
306
+ clampToViewport,
307
+ enabled: open,
308
+ })
309
+
310
+ const layer = useLayer({
311
+ enabled: open,
312
+ kind: 'popover',
313
+ elementRef: contentRef,
314
+ onEscape: () => setOpen(false),
315
+ })
316
+
317
+ useOutsideClick(
318
+ [triggerRef, contentRef],
319
+ () => {
320
+ interactedOutsideRef.current = true
321
+ setOpen(false)
322
+ },
323
+ { enabled: open, layer },
324
+ )
325
+
326
+ // Focus in once the content is POSITIONED, back to the trigger on close.
327
+ // Not on `open`: the first open frame is the parked one (off-screen,
328
+ // `visibility: hidden`), and Chromium, Firefox and WebKit all refuse to
329
+ // focus into a visibility:hidden subtree, so a focus() there is a silent
330
+ // no-op and focus stays on the trigger. `positioned` flips true in the
331
+ // re-render the measuring layout effect triggers, after the parked frame
332
+ // has already flushed its passive effects. jsdom does not enforce the
333
+ // rule, which is why the test records `data-positioned` at focus time.
334
+ // Keyed on state, not an inline callback, so `onOpenChange` identity
335
+ // churn never re-runs it mid-interaction.
336
+ useEffect(() => {
337
+ if (!open || !positioned) return
338
+ const node = contentRef.current
339
+ if (!node) return
340
+ interactedOutsideRef.current = false
341
+ // Captured now, not in the cleanup: the trigger does not change while
342
+ // the popover is open, and a remounted one would be disconnected.
343
+ const trigger = triggerRef.current
344
+ const previous =
345
+ document.activeElement instanceof HTMLElement &&
346
+ document.activeElement !== document.body
347
+ ? document.activeElement
348
+ : null
349
+ const initial =
350
+ node.querySelector<HTMLElement>('[data-autofocus]') ??
351
+ tabbablesWithin(node)[0] ??
352
+ node
353
+ initial.focus()
354
+
355
+ return () => {
356
+ if (interactedOutsideRef.current) return
357
+ // The content is gone by now, so a focus that was inside it has
358
+ // already fallen to <body>. Anything else means the user moved on.
359
+ const active = document.activeElement
360
+ if (active !== null && active !== document.body && !node.contains(active)) return
361
+ const target = trigger ?? previous
362
+ if (target?.isConnected) target.focus()
363
+ }
364
+ }, [open, positioned, triggerRef, interactedOutsideRef])
365
+
366
+ const ref = useMemo(
367
+ () => composeRefs<HTMLDivElement>(forwardedRef, contentRef, positionRef),
368
+ [forwardedRef, positionRef],
369
+ )
370
+
371
+ if (!open || typeof document === 'undefined') return null
372
+
373
+ return createPortal(
374
+ <div
375
+ ref={ref}
376
+ id={contentId}
377
+ role={role === 'none' ? undefined : role}
378
+ tabIndex={-1}
379
+ data-slot="popover-content"
380
+ data-state="open"
381
+ data-side={placement?.side}
382
+ data-align={placement?.align}
383
+ data-positioned={positioned ? 'true' : 'false'}
384
+ className={cn(
385
+ 'fixed z-[100] outline-none',
386
+ !unstyled && 'rounded-[var(--radius-lg)] p-3',
387
+ positioned && 'ds-enter-pop',
388
+ className,
389
+ )}
390
+ style={{
391
+ ...positionStyle,
392
+ ...(unstyled ? undefined : POPOVER_SURFACE_STYLE),
393
+ ...style,
394
+ }}
395
+ {...rest}
396
+ >
397
+ {children}
398
+ </div>,
399
+ document.body,
400
+ )
401
+ },
402
+ )
403
+
404
+ export const Popover = Object.assign(PopoverRoot, {
405
+ Trigger: PopoverTrigger,
406
+ Content: PopoverContent,
407
+ })
@@ -0,0 +1,304 @@
1
+ /**
2
+ * ProgressLedger — the per-task block bar.
3
+ *
4
+ * ADR-146's one piece of boldness, and the reason the port keeps a ledger
5
+ * rather than a percentage bar: it is COUNTABLE. You read 7 of 11 without
6
+ * reading a number, and each segment doubles as navigation into that group.
7
+ *
8
+ * Three rules it must not lose:
9
+ *
10
+ * 1. **Never colour-only.** The label row always carries the count AND the
11
+ * percentage as text (interface-standards §6), so completion is legible
12
+ * without perceiving hue.
13
+ * 2. **The track is grey structure, not a surface.** `--surface-inset` +
14
+ * 1px `--border`, exactly the "grey structure inside an opaque card" the
15
+ * data-display recipe requires (CLAUDE.md §2). Filled cells take the
16
+ * column hue off the tint ladder, so they composite opaque in both themes.
17
+ * 3. **A cell settles, it never pops.** Colour transitions over `--dur-base`
18
+ * (`.ds-ledger-cell` in styles.css) and `prefers-reduced-motion` jumps to
19
+ * the end state.
20
+ *
21
+ * Radius is the documented exception to the concentric ladder: cells are 3px
22
+ * because squareness is what makes them countable. The track is 5px so that
23
+ * 5 − 2px of padding = the cells' 3px and the chain still holds locally.
24
+ *
25
+ * The hue comes from an ancestor's `.hue hue-<key>` classes (see `hue.ts`);
26
+ * this primitive reads `--tint-fill` / `--tint-task` / `--h` and never names a
27
+ * colour of its own.
28
+ */
29
+
30
+ import type { CSSProperties, ReactNode } from 'react'
31
+ import { cn } from './lib/utils'
32
+ import { StatusRing } from './status'
33
+
34
+ /** One cell — a single task. */
35
+ export interface LedgerCell {
36
+ id: string
37
+ done: boolean
38
+ /** The task in flight. Rendered as an outlined cell, not a filled one. */
39
+ current?: boolean
40
+ /** Falls back to the cell id in the cell's title attribute. */
41
+ label?: string
42
+ }
43
+
44
+ /** One phase / group. A divider is drawn at every boundary between segments. */
45
+ export interface LedgerSegment {
46
+ id: string
47
+ label: string
48
+ cells: readonly LedgerCell[]
49
+ }
50
+
51
+ /** Done, total and the rounded percentage over a set of segments. */
52
+ export interface LedgerProgress {
53
+ done: number
54
+ total: number
55
+ /** 0–100, rounded. `0` when there is nothing to measure. */
56
+ percent: number
57
+ }
58
+
59
+ /**
60
+ * THE completion calculation — one function, every consumer.
61
+ *
62
+ * The ledger prints these numbers as text, the card's ring draws them, and a
63
+ * board column aggregates its lane's items through this same function. A
64
+ * second implementation anywhere is how a filled meter ends up disagreeing
65
+ * with the percentage printed beside it (header-primitives §1, last row).
66
+ */
67
+ export function ledgerProgress(segments: readonly LedgerSegment[]): LedgerProgress {
68
+ const cells = segments.flatMap((segment) => segment.cells)
69
+ const total = cells.length
70
+ const done = cells.filter((cell) => cell.done).length
71
+ return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
72
+ }
73
+
74
+ export interface ProgressLedgerProps {
75
+ segments: readonly LedgerSegment[]
76
+ /** Leading word in the label row. Sentence case. */
77
+ label?: string
78
+ /** Optional middle term — the phase the item is in. */
79
+ phase?: string
80
+ /** `lg` is the detail-drawer size; `sm` is the card size. */
81
+ size?: 'sm' | 'lg'
82
+ /**
83
+ * Emitted when a segment is activated. Interactivity is OPT-IN: without
84
+ * this, segments render as inert `<span>`s and add no tab stops.
85
+ *
86
+ * Supplying it is a commitment to a destination. A board that wires an empty
87
+ * handler here puts one dead 12px-tall tab stop on the page per segment per
88
+ * card — six of the Tasks board's twenty tab stops did exactly that — which
89
+ * is worse than the control being absent. When it IS supplied, the segment
90
+ * is forced to a 24px minimum hit area (interface-standards §6), so it
91
+ * cannot come back as a 12px target.
92
+ */
93
+ onSelectSegment?: (segmentId: string) => void
94
+ /**
95
+ * Draws a `<StatusRing>` at the head of the label row, showing the SAME
96
+ * percentage this ledger prints. It is decorative by construction — the
97
+ * number is already text two words away, and announcing it twice is the
98
+ * defect, not the feature.
99
+ */
100
+ showRing?: boolean
101
+ /** Names the ledger for assistive tech, e.g. the item title. */
102
+ ariaLabel?: string
103
+ className?: string
104
+ }
105
+
106
+ const TRACK_RADIUS = '5px'
107
+ const CELL_RADIUS = '3px'
108
+
109
+ export function ProgressLedger({
110
+ segments,
111
+ label = 'Progress',
112
+ phase,
113
+ size = 'sm',
114
+ onSelectSegment,
115
+ showRing = false,
116
+ ariaLabel,
117
+ className,
118
+ }: ProgressLedgerProps) {
119
+ const { done, total, percent } = ledgerProgress(segments)
120
+ const cellHeight = size === 'lg' ? 22 : 12
121
+ const cellMinWidth = size === 'lg' ? 12 : 5
122
+
123
+ return (
124
+ <div
125
+ className={cn('flex flex-col', className)}
126
+ style={{ gap: 'var(--space-1)' }}
127
+ role="group"
128
+ aria-label={
129
+ ariaLabel
130
+ ? `${label} for ${ariaLabel}: ${done} of ${total} done, ${percent} percent`
131
+ : `${label}: ${done} of ${total} done, ${percent} percent`
132
+ }
133
+ >
134
+ {/* Label row — `Progress · 7/11 · 64%`. The count is the reason the
135
+ ledger beats a bar, so it is never dropped for space. */}
136
+ <div
137
+ className="flex items-baseline text-[11px] leading-none"
138
+ style={{ gap: 'var(--space-2)', color: 'rgb(var(--text-muted))' }}
139
+ >
140
+ {showRing ? (
141
+ <StatusRing
142
+ percent={percent}
143
+ size={size === 'lg' ? 16 : 13}
144
+ strokeWidth={2}
145
+ decorative
146
+ // The 11px label row sits on its baseline; a ring has none, so it
147
+ // is nudged optically rather than mathematically.
148
+ style={{ alignSelf: 'center', marginBlockStart: '-1px' }}
149
+ />
150
+ ) : null}
151
+ <span>{label}</span>
152
+ {/* min-width slots: the card must not resize when a task is checked
153
+ and `9/11` becomes `10/11` (layout-stability rule). */}
154
+ <LedgerFigure style={{ minInlineSize: '3.5ch' }}>
155
+ {done}/{total}
156
+ </LedgerFigure>
157
+ {phase ? <span className="truncate">{phase}</span> : null}
158
+ <LedgerFigure
159
+ className="ms-auto font-semibold"
160
+ style={{
161
+ color: 'rgb(var(--muted-foreground))',
162
+ minInlineSize: '4ch',
163
+ textAlign: 'end',
164
+ }}
165
+ >
166
+ {percent}%
167
+ </LedgerFigure>
168
+ </div>
169
+
170
+ <div
171
+ className="flex items-stretch"
172
+ style={{
173
+ gap: '2px',
174
+ padding: '2px',
175
+ background: 'rgb(var(--surface-inset))',
176
+ border: '1px solid rgb(var(--border))',
177
+ borderRadius: TRACK_RADIUS,
178
+ }}
179
+ >
180
+ {total === 0 ? (
181
+ <span
182
+ className="block w-full"
183
+ style={{ height: cellHeight, borderRadius: CELL_RADIUS }}
184
+ aria-hidden="true"
185
+ />
186
+ ) : null}
187
+ {segments.map((segment, index) => (
188
+ <SegmentRun
189
+ key={segment.id}
190
+ segment={segment}
191
+ showDivider={index > 0}
192
+ cellHeight={cellHeight}
193
+ cellMinWidth={cellMinWidth}
194
+ onSelect={onSelectSegment}
195
+ />
196
+ ))}
197
+ </div>
198
+ </div>
199
+ )
200
+ }
201
+
202
+ function SegmentRun({
203
+ segment,
204
+ showDivider,
205
+ cellHeight,
206
+ cellMinWidth,
207
+ onSelect,
208
+ }: {
209
+ segment: LedgerSegment
210
+ showDivider: boolean
211
+ cellHeight: number
212
+ cellMinWidth: number
213
+ onSelect?: ((segmentId: string) => void) | undefined
214
+ }) {
215
+ const doneCount = segment.cells.filter((cell) => cell.done).length
216
+ const cells = (
217
+ <>
218
+ {segment.cells.map((cell) => (
219
+ <span
220
+ key={cell.id}
221
+ className="ds-ledger-cell block"
222
+ title={cell.label ?? undefined}
223
+ style={{
224
+ flex: '1 1 0',
225
+ minWidth: cellMinWidth,
226
+ height: cellHeight,
227
+ borderRadius: CELL_RADIUS,
228
+ background: cell.done
229
+ ? 'var(--tint-fill)'
230
+ : cell.current
231
+ ? 'var(--tint-task)'
232
+ : 'rgb(var(--surface-card))',
233
+ boxShadow: cell.current && !cell.done ? 'inset 0 0 0 1px var(--h)' : undefined,
234
+ }}
235
+ />
236
+ ))}
237
+ </>
238
+ )
239
+
240
+ const runStyle = {
241
+ flex: `${Math.max(segment.cells.length, 1)} 1 0`,
242
+ display: 'flex',
243
+ gap: '2px',
244
+ minWidth: 0,
245
+ } as const
246
+
247
+ return (
248
+ <>
249
+ {/* Phase boundary. A 1px rule, not a gap — a gap at this scale reads as
250
+ a missing cell and would make the count wrong at a glance. */}
251
+ {showDivider ? (
252
+ <span
253
+ aria-hidden="true"
254
+ style={{
255
+ flex: 'none',
256
+ width: '1px',
257
+ background: 'rgb(var(--border-strong))',
258
+ marginBlock: '-2px',
259
+ marginInline: '2px',
260
+ }}
261
+ />
262
+ ) : null}
263
+ {onSelect ? (
264
+ <button
265
+ type="button"
266
+ onClick={() => onSelect(segment.id)}
267
+ aria-label={`${segment.label}: ${doneCount} of ${segment.cells.length} done`}
268
+ className="rounded-[3px] focus-visible:[box-shadow:var(--ring-focus)] focus-visible:outline-none"
269
+ style={{
270
+ ...runStyle,
271
+ // The accessibility floor, enforced in the primitive rather than
272
+ // asked of every caller: a 12px cell row is not a 24px target, so
273
+ // the run grows to 24px and centres its cells inside it.
274
+ alignItems: 'center',
275
+ minBlockSize: '24px',
276
+ background: 'transparent',
277
+ border: 'none',
278
+ padding: 0,
279
+ }}
280
+ >
281
+ {cells}
282
+ </button>
283
+ ) : (
284
+ <span style={runStyle}>{cells}</span>
285
+ )}
286
+ </>
287
+ )
288
+ }
289
+
290
+ function LedgerFigure({
291
+ children,
292
+ className,
293
+ style,
294
+ }: {
295
+ children: ReactNode
296
+ className?: string
297
+ style?: CSSProperties
298
+ }) {
299
+ return (
300
+ <span className={cn('tabular-nums', className)} style={style}>
301
+ {children}
302
+ </span>
303
+ )
304
+ }