@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
@@ -0,0 +1,487 @@
1
+ /**
2
+ * DisplayPopover + its row primitives — the "how should this view look" panel.
3
+ *
4
+ * `docs/design-briefs/tasks-board-header-primitives.md` §4: the source app's
5
+ * display popover is copy-pasted JSX, and the ONE abstraction in the whole
6
+ * repo is an eight-line `OptionRow` that its two call sites already disagree
7
+ * about (`text-sm`/`h-8` in one, `text-xs`/`h-7` in the other). So the
8
+ * boundary is drawn here rather than ported:
9
+ *
10
+ * OptionRow label on the left, control on the right
11
+ * SelectRow OptionRow + <Select>
12
+ * ToggleRow OptionRow + <Checkbox>, owning the id/htmlFor pair
13
+ * ChipToggleGroup the display-property chips
14
+ * SectionHeading plain + muted
15
+ * PopoverFooterActions the reset row
16
+ * DisplayPopover the shell: fixed width, no padding of its own,
17
+ * self-padding sections separated by a hairline
18
+ *
19
+ * Two things the source gets wrong and this cannot:
20
+ *
21
+ * 1. `ToggleRow` MINTS the id and wires `htmlFor` itself, so a caller
22
+ * cannot ship a checkbox whose label does not click it. Theirs is
23
+ * hand-written at three call sites.
24
+ * 2. `PopoverFooterActions` renders its primary action ONLY when one is
25
+ * supplied. Theirs has a "Set default for everyone" `<button>` with no
26
+ * `onClick` in three files — a dead control that cannot happen here
27
+ * because the absent prop renders nothing.
28
+ *
29
+ * Chips carry `type="button"` and `aria-pressed`; theirs carry neither, so
30
+ * inside a form they submit it and to a screen reader they are unlabelled
31
+ * toggles. A pressed chip steps up to an OPAQUE surface with an accent border
32
+ * rather than being marked by a translucent tint (CLAUDE.md §2: "if the state
33
+ * is conveyed only by alpha, it is wrong").
34
+ *
35
+ * Focus: `Popover` is non-modal — it focuses in and hands focus back, but Tab
36
+ * walks out of it. A display panel is a self-contained set of controls, so the
37
+ * shell wraps Tab inside itself with `handleTabTrap`. Escape and focus-return
38
+ * come from `Popover` unchanged.
39
+ */
40
+
41
+ import {
42
+ useId,
43
+ useRef,
44
+ type KeyboardEvent as ReactKeyboardEvent,
45
+ type ReactNode,
46
+ } from 'react'
47
+ import { Check } from 'lucide-react'
48
+ import { cn } from './lib/utils'
49
+ import { handleTabTrap } from './lib/focus'
50
+ import { Popover } from './popover'
51
+ import { Checkbox } from './checkbox'
52
+ import { Select, type SelectOption } from './select'
53
+
54
+ /* ------------------------------------------------------------------ */
55
+ /* SectionHeading */
56
+ /* ------------------------------------------------------------------ */
57
+
58
+ export interface SectionHeadingProps {
59
+ children: ReactNode
60
+ /** The quieter treatment, for a secondary block inside the same panel. */
61
+ muted?: boolean
62
+ className?: string
63
+ }
64
+
65
+ /** Small caps get positive tracking (interface standards §"Type"). */
66
+ export function SectionHeading({ children, muted, className }: SectionHeadingProps) {
67
+ return (
68
+ <div
69
+ className={cn(
70
+ 'text-[10.5px] font-semibold uppercase tracking-[0.06em]',
71
+ className,
72
+ )}
73
+ style={{ color: `rgb(var(--text-${muted ? 'muted' : 'tertiary'}))` }}
74
+ >
75
+ {children}
76
+ </div>
77
+ )
78
+ }
79
+
80
+ /* ------------------------------------------------------------------ */
81
+ /* OptionRow */
82
+ /* ------------------------------------------------------------------ */
83
+
84
+ export interface OptionRowProps {
85
+ /** A Lucide icon, sized by the caller. Optional — most rows have none. */
86
+ icon?: ReactNode
87
+ label: ReactNode
88
+ /** Secondary line under the label. */
89
+ hint?: ReactNode
90
+ /** Nest this row under the one above it. */
91
+ indent?: boolean
92
+ disabled?: boolean
93
+ /** The control. Sits at the inline end of the row. */
94
+ children?: ReactNode
95
+ /**
96
+ * Renders the label as a `<label>` for this control id. `ToggleRow` sets
97
+ * it; a caller building a bespoke row can too.
98
+ */
99
+ htmlFor?: string
100
+ className?: string
101
+ }
102
+
103
+ /**
104
+ * The label/control row. `min-h` rather than a fixed height, so a row whose
105
+ * label wraps grows instead of clipping (interface standards §3, "no fixed
106
+ * widths on any text container").
107
+ */
108
+ export function OptionRow({
109
+ icon,
110
+ label,
111
+ hint,
112
+ indent,
113
+ disabled,
114
+ children,
115
+ htmlFor,
116
+ className,
117
+ }: OptionRowProps) {
118
+ const text = (
119
+ <>
120
+ <span className="text-[12.5px] font-medium leading-tight">{label}</span>
121
+ {hint ? (
122
+ <span
123
+ className="text-[11.5px] leading-tight"
124
+ style={{ color: 'rgb(var(--text-tertiary))' }}
125
+ >
126
+ {hint}
127
+ </span>
128
+ ) : null}
129
+ </>
130
+ )
131
+
132
+ return (
133
+ <div
134
+ data-slot="option-row"
135
+ data-disabled={disabled ? 'true' : undefined}
136
+ className={cn(
137
+ 'flex min-h-[var(--ctrl-sm)] items-center justify-between gap-3',
138
+ 'rounded-[var(--radius-sm)]',
139
+ indent && 'ps-5',
140
+ disabled && 'opacity-50',
141
+ className,
142
+ )}
143
+ style={{ color: 'rgb(var(--foreground))' }}
144
+ >
145
+ {htmlFor ? (
146
+ <label
147
+ htmlFor={htmlFor}
148
+ className="flex flex-1 cursor-pointer select-none items-center gap-2 py-1"
149
+ >
150
+ {icon ? <span className="flex shrink-0 items-center">{icon}</span> : null}
151
+ <span className="flex flex-col gap-0.5">{text}</span>
152
+ </label>
153
+ ) : (
154
+ <span className="flex flex-1 items-center gap-2 py-1">
155
+ {icon ? <span className="flex shrink-0 items-center">{icon}</span> : null}
156
+ <span className="flex flex-col gap-0.5">{text}</span>
157
+ </span>
158
+ )}
159
+ {children ? <span className="flex shrink-0 items-center">{children}</span> : null}
160
+ </div>
161
+ )
162
+ }
163
+
164
+ /* ------------------------------------------------------------------ */
165
+ /* SelectRow */
166
+ /* ------------------------------------------------------------------ */
167
+
168
+ export interface SelectRowProps<T extends string = string>
169
+ extends Omit<OptionRowProps, 'children' | 'htmlFor'> {
170
+ value: T
171
+ onValueChange: (next: T) => void
172
+ options: ReadonlyArray<SelectOption<T>>
173
+ /** Accessible name for the control. Defaults to the row's label if it is a string. */
174
+ controlLabel?: string
175
+ /** Width of the select. Defaults to a slot wide enough for the longest option. */
176
+ selectClassName?: string
177
+ }
178
+
179
+ export function SelectRow<T extends string = string>({
180
+ value,
181
+ onValueChange,
182
+ options,
183
+ controlLabel,
184
+ selectClassName,
185
+ label,
186
+ ...row
187
+ }: SelectRowProps<T>) {
188
+ const name =
189
+ controlLabel ?? (typeof label === 'string' ? label : undefined) ?? 'Option'
190
+ return (
191
+ <OptionRow label={label} {...row}>
192
+ <Select
193
+ size="sm"
194
+ aria-label={name}
195
+ value={value}
196
+ onValueChange={onValueChange}
197
+ options={options}
198
+ disabled={row.disabled ?? false}
199
+ // A reserved slot, so swapping "Due date" for "Manual" cannot resize
200
+ // the row (interface standards §"Layout stability").
201
+ className={cn('min-w-[9.5rem]', selectClassName)}
202
+ />
203
+ </OptionRow>
204
+ )
205
+ }
206
+
207
+ /* ------------------------------------------------------------------ */
208
+ /* ToggleRow */
209
+ /* ------------------------------------------------------------------ */
210
+
211
+ export interface ToggleRowProps extends Omit<OptionRowProps, 'children' | 'htmlFor'> {
212
+ checked: boolean
213
+ onCheckedChange: (next: boolean) => void
214
+ /** Override the minted id. Rarely needed. */
215
+ id?: string
216
+ }
217
+
218
+ /**
219
+ * The id/htmlFor pairing lives HERE, not at the call site — a caller cannot
220
+ * forget it, which is the single most common way a settings checkbox ships
221
+ * with a label that does not click it.
222
+ */
223
+ export function ToggleRow({
224
+ checked,
225
+ onCheckedChange,
226
+ id,
227
+ ...row
228
+ }: ToggleRowProps) {
229
+ const generated = useId()
230
+ const controlId = id ?? `toggle-row-${generated.replace(/:/g, '')}`
231
+ return (
232
+ <OptionRow
233
+ {...row}
234
+ htmlFor={controlId}
235
+ className={cn(
236
+ // The checkbox itself is 16px; the row is the hit area, and the ring
237
+ // has to be visible on the row because the input is opacity-0.
238
+ //
239
+ // `:has(:focus-visible)`, not `:focus-within`. `focus-within` fires on
240
+ // ANY focus, including the focus a plain mouse click puts on the
241
+ // checkbox — so clicking a toggle painted a keyboard focus ring on the
242
+ // row and left it there. `:focus-visible` is the browser's own
243
+ // "this focus came from the keyboard" heuristic, and `:has()` lifts it
244
+ // from the invisible input to the row that can actually show it.
245
+ // Forced-colors gets the same treatment as a system outline in
246
+ // styles.css, since this ring is a box-shadow.
247
+ 'has-[:focus-visible]:[box-shadow:var(--ring-focus)]',
248
+ row.className,
249
+ )}
250
+ >
251
+ <Checkbox
252
+ id={controlId}
253
+ checked={checked}
254
+ onCheckedChange={onCheckedChange}
255
+ disabled={row.disabled ?? false}
256
+ />
257
+ </OptionRow>
258
+ )
259
+ }
260
+
261
+ /* ------------------------------------------------------------------ */
262
+ /* ChipToggleGroup */
263
+ /* ------------------------------------------------------------------ */
264
+
265
+ export interface ChipToggleItem {
266
+ readonly key: string
267
+ readonly label: string
268
+ readonly icon?: ReactNode
269
+ }
270
+
271
+ export interface ChipToggleGroupProps {
272
+ items: readonly ChipToggleItem[]
273
+ /** key -> pressed. A key missing from the record reads as unpressed. */
274
+ value: Readonly<Record<string, boolean>>
275
+ onToggle: (key: string, next: boolean) => void
276
+ /** Names the group for assistive tech. */
277
+ 'aria-label': string
278
+ className?: string
279
+ }
280
+
281
+ /**
282
+ * A row of on/off chips. Every chip is `type="button"` with `aria-pressed`,
283
+ * which the source's is not — inside a form theirs submits it, and a screen
284
+ * reader is told nothing about which are on.
285
+ */
286
+ export function ChipToggleGroup({
287
+ items,
288
+ value,
289
+ onToggle,
290
+ 'aria-label': ariaLabel,
291
+ className,
292
+ }: ChipToggleGroupProps) {
293
+ return (
294
+ <div
295
+ role="group"
296
+ aria-label={ariaLabel}
297
+ className={cn('flex flex-wrap gap-1.5', className)}
298
+ >
299
+ {items.map((item) => {
300
+ const pressed = value[item.key] ?? false
301
+ return (
302
+ <button
303
+ key={item.key}
304
+ type="button"
305
+ aria-pressed={pressed}
306
+ onClick={() => onToggle(item.key, !pressed)}
307
+ className={cn(
308
+ 'inline-flex min-h-6 items-center gap-1.5 px-2 py-1',
309
+ 'rounded-[var(--radius-sm)] text-[11.5px] font-medium',
310
+ 'transition-[background-color,border-color,color,box-shadow]',
311
+ 'duration-[var(--dur-fast)] ease-[var(--ease-out)]',
312
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
313
+ )}
314
+ style={{
315
+ // A pressed chip STEPS UP to an OPAQUE surface, out of the
316
+ // recessed track: never an alpha tint alone (CLAUDE.md §2, "if
317
+ // the state is conveyed only by alpha, it is wrong").
318
+ //
319
+ // The step-up is neutral — border weight, text weight and
320
+ // shadow — and deliberately NOT the accent. This is a
321
+ // multi-toggle whose DEFAULT is every chip on, so an accent
322
+ // border here paints five accents into a view that already
323
+ // spends its one on the active view tab. The accent earns its
324
+ // impact from scarcity; depth does this job without it.
325
+ background: pressed
326
+ ? 'rgb(var(--surface-card))'
327
+ : 'rgb(var(--surface-frame))',
328
+ border: `1px solid rgb(var(${pressed ? '--border-strong' : '--border'}))`,
329
+ color: `rgb(var(${pressed ? '--foreground' : '--text-muted'}))`,
330
+ boxShadow: pressed ? 'var(--shadow-sm)' : 'none',
331
+ }}
332
+ >
333
+ {/* A SHAPE cue, not only a surface one. In dark mode
334
+ `--surface-card` and `--surface-frame` sit close enough that
335
+ the step-up alone is not a reliable on/off read, and a state
336
+ carried by colour alone fails the accessibility floor either
337
+ way. The slot is always rendered so toggling cannot resize the
338
+ chip and reflow the row. */}
339
+ <Check
340
+ aria-hidden="true"
341
+ className={cn('h-3 w-3 shrink-0', !pressed && 'invisible')}
342
+ strokeWidth={2.5}
343
+ />
344
+ {item.icon ? (
345
+ <span className="flex shrink-0 items-center">{item.icon}</span>
346
+ ) : null}
347
+ {item.label}
348
+ </button>
349
+ )
350
+ })}
351
+ </div>
352
+ )
353
+ }
354
+
355
+ /* ------------------------------------------------------------------ */
356
+ /* PopoverFooterActions */
357
+ /* ------------------------------------------------------------------ */
358
+
359
+ export interface PopoverFooterAction {
360
+ label: string
361
+ onClick: () => void
362
+ disabled?: boolean
363
+ }
364
+
365
+ export interface PopoverFooterActionsProps {
366
+ onReset: () => void
367
+ resetLabel?: string
368
+ /** Absent renders NOTHING — the reason a dead primary cannot ship here. */
369
+ primaryAction?: PopoverFooterAction
370
+ /** Disables the reset when the panel is already at its defaults. */
371
+ resetDisabled?: boolean
372
+ }
373
+
374
+ export function PopoverFooterActions({
375
+ onReset,
376
+ resetLabel = 'Reset to defaults',
377
+ primaryAction,
378
+ resetDisabled,
379
+ }: PopoverFooterActionsProps) {
380
+ return (
381
+ <div className="flex items-center justify-between gap-2">
382
+ <button
383
+ type="button"
384
+ onClick={onReset}
385
+ disabled={resetDisabled ?? false}
386
+ className={cn(
387
+ 'inline-flex min-h-6 items-center rounded-[var(--radius-sm)] px-1.5',
388
+ 'text-[11.5px] font-medium underline-offset-2 hover:underline',
389
+ 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:no-underline',
390
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
391
+ )}
392
+ style={{ color: 'rgb(var(--text-secondary))' }}
393
+ >
394
+ {resetLabel}
395
+ </button>
396
+ {primaryAction ? (
397
+ <button
398
+ type="button"
399
+ onClick={primaryAction.onClick}
400
+ disabled={primaryAction.disabled ?? false}
401
+ className="btn btn-secondary size-sm"
402
+ >
403
+ {primaryAction.label}
404
+ </button>
405
+ ) : null}
406
+ </div>
407
+ )
408
+ }
409
+
410
+ /* ------------------------------------------------------------------ */
411
+ /* DisplayPopover */
412
+ /* ------------------------------------------------------------------ */
413
+
414
+ export interface DisplayPopoverProps {
415
+ /** The trigger element. Rendered through `Popover.Trigger asChild`. */
416
+ trigger: ReactNode
417
+ /** Accessible name for the panel. */
418
+ label: string
419
+ open?: boolean
420
+ onOpenChange?: (open: boolean) => void
421
+ /** `DisplayPopover.Section` children. */
422
+ children: ReactNode
423
+ /** Panel width. Default `w-80`. */
424
+ className?: string
425
+ }
426
+
427
+ function DisplayPopoverRoot({
428
+ trigger,
429
+ label,
430
+ open,
431
+ onOpenChange,
432
+ children,
433
+ className,
434
+ }: DisplayPopoverProps) {
435
+ const contentRef = useRef<HTMLDivElement | null>(null)
436
+
437
+ return (
438
+ <Popover
439
+ {...(open === undefined ? {} : { open })}
440
+ {...(onOpenChange ? { onOpenChange } : {})}
441
+ >
442
+ <Popover.Trigger asChild>{trigger}</Popover.Trigger>
443
+ <Popover.Content
444
+ ref={contentRef}
445
+ aria-label={label}
446
+ align="end"
447
+ // Padding is stripped here and paid by each section, so the hairline
448
+ // between sections reaches both edges of the panel.
449
+ className={cn('w-80 p-0', className)}
450
+ onKeyDown={(event: ReactKeyboardEvent<HTMLDivElement>) =>
451
+ handleTabTrap(event, contentRef.current)
452
+ }
453
+ >
454
+ {children}
455
+ </Popover.Content>
456
+ </Popover>
457
+ )
458
+ }
459
+
460
+ export interface DisplayPopoverSectionProps {
461
+ children: ReactNode
462
+ className?: string
463
+ }
464
+
465
+ /**
466
+ * A self-padding block. Every section but the first draws a hairline above
467
+ * it — the divider is the LAST resort elsewhere, but here it is the panel's
468
+ * only structure and the sections are genuinely different subjects.
469
+ */
470
+ function DisplayPopoverSection({ children, className }: DisplayPopoverSectionProps) {
471
+ return (
472
+ <div
473
+ className={cn(
474
+ 'flex flex-col gap-1.5 p-3',
475
+ '[&:not(:first-child)]:border-t [&:not(:first-child)]:border-solid',
476
+ className,
477
+ )}
478
+ style={{ borderColor: 'rgb(var(--border))' }}
479
+ >
480
+ {children}
481
+ </div>
482
+ )
483
+ }
484
+
485
+ export const DisplayPopover = Object.assign(DisplayPopoverRoot, {
486
+ Section: DisplayPopoverSection,
487
+ })