@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,287 @@
1
+ /**
2
+ * DetailHeader — the item's name, its state, and every way out.
3
+ *
4
+ * `docs/design-briefs/tasks-item-detail-full.md` §2, left to right: mark
5
+ * complete · title · "Updated ‹relative›" · prev / next · reminder · favourite
6
+ * · ellipsis · close.
7
+ *
8
+ * IT RENDERS SLOTS; THE LENS SUPPLIES BEHAVIOUR. Nothing here writes to a
9
+ * server, moves an item between columns or knows what a board is. The two
10
+ * controls §7 says cannot be honest yet — the reminder, and anything else the
11
+ * host has no backing system for — are `ReactNode` slots that render NOTHING
12
+ * when the host passes nothing. That is how an un-backed control stays off the
13
+ * screen instead of shipping dead: the header cannot draw a button the lens
14
+ * did not hand it.
15
+ *
16
+ * The same rule shapes the rest. `onCompleteChange` absent → no completion
17
+ * toggle. `onFavouriteChange` absent → no star. `onTitleChange` absent → the
18
+ * title is a heading, not a field. Prev / next render as a pair once EITHER
19
+ * handler is present, and the one without a handler is disabled — that is the
20
+ * "disabled at the ends" state, and it comes from the host passing `undefined`
21
+ * rather than from this component knowing anything about board order.
22
+ *
23
+ * Every control is an icon PLUS an accessible name (§8). The three that are
24
+ * icon-only carry it as `aria-label`; mark-complete carries it as visible text
25
+ * because its label is also the state readout ("the label reflects state").
26
+ */
27
+
28
+ import { useId, useRef, type ReactNode } from 'react'
29
+ import {
30
+ Check,
31
+ ChevronDown,
32
+ ChevronUp,
33
+ Circle,
34
+ Star,
35
+ X,
36
+ } from 'lucide-react'
37
+ import { Button } from '../button'
38
+ import { cn } from '../lib/utils'
39
+ import { RelativeTime } from '../thread/relative-time'
40
+ import { useNowTick } from '../thread/use-now'
41
+
42
+ export interface DetailHeaderProps {
43
+ /** The item's name. */
44
+ title: string
45
+ /** Omit and the title renders as a heading rather than a field. */
46
+ onTitleChange?: ((next: string) => void) | undefined
47
+ /** Enter and blur both land here. Escape reverts without calling it. */
48
+ onTitleCommit?: (() => void) | undefined
49
+ /** Accessible name for the title field. Default "Item title". */
50
+ titleLabel?: string | undefined
51
+ titleMaxLength?: number | undefined
52
+
53
+ /** Completion. Omit `onCompleteChange` and no toggle renders. */
54
+ complete?: boolean | undefined
55
+ onCompleteChange?: ((next: boolean) => void) | undefined
56
+ /** Default "Mark complete" / "Completed" — sentence case, names the state. */
57
+ completeLabel?: string | undefined
58
+ completedLabel?: string | undefined
59
+
60
+ /** Epoch milliseconds. Omit and no "Updated" reads out. */
61
+ updatedAt?: number | undefined
62
+ /** Frozen clock for tests. Omit and the shared 60s ticker drives it. */
63
+ now?: number | undefined
64
+ locale?: string | undefined
65
+
66
+ /** Prev / next through the board's visible items. Omit BOTH for no pair. */
67
+ onPrev?: (() => void) | undefined
68
+ onNext?: (() => void) | undefined
69
+ prevLabel?: string | undefined
70
+ nextLabel?: string | undefined
71
+
72
+ /**
73
+ * §7 — reminders have no notification system behind them in this workspace,
74
+ * and a reminder that silently never fires is worse than no reminder. So
75
+ * there is no built-in control: a host that HAS a backing system passes one
76
+ * here, and a host that does not passes nothing and nothing is drawn.
77
+ */
78
+ reminder?: ReactNode | undefined
79
+
80
+ /** Favourite. Omit `onFavouriteChange` and no star renders. */
81
+ favourite?: boolean | undefined
82
+ onFavouriteChange?: ((next: boolean) => void) | undefined
83
+ favouriteLabel?: string | undefined
84
+ favouritedLabel?: string | undefined
85
+
86
+ /** The ellipsis — a `<DetailMenu>`. Nothing renders when omitted. */
87
+ menu?: ReactNode | undefined
88
+
89
+ onClose: () => void
90
+ closeLabel?: string | undefined
91
+ className?: string | undefined
92
+ }
93
+
94
+ export function DetailHeader({
95
+ title,
96
+ onTitleChange,
97
+ onTitleCommit,
98
+ titleLabel = 'Item title',
99
+ titleMaxLength = 200,
100
+ complete,
101
+ onCompleteChange,
102
+ completeLabel = 'Mark complete',
103
+ completedLabel = 'Completed',
104
+ updatedAt,
105
+ now,
106
+ locale,
107
+ onPrev,
108
+ onNext,
109
+ prevLabel = 'Previous item',
110
+ nextLabel = 'Next item',
111
+ reminder,
112
+ favourite,
113
+ onFavouriteChange,
114
+ favouriteLabel = 'Add to favourites',
115
+ favouritedLabel = 'Remove from favourites',
116
+ menu,
117
+ onClose,
118
+ closeLabel = 'Close item',
119
+ className,
120
+ }: DetailHeaderProps) {
121
+ const fieldId = useId()
122
+ // The shared 60s ticker, or a frozen clock. `useNowTick(false)` subscribes
123
+ // to nothing and starts no timer, so a test that passes `now` is
124
+ // deterministic without fake-timer plumbing.
125
+ const ticked = useNowTick(now === undefined)
126
+ const clock = now ?? ticked
127
+ // Enter commits and then leaves the field, which fires a blur that would
128
+ // commit a second time. One gesture, one write.
129
+ const committedRef = useRef(false)
130
+ const isComplete = complete === true
131
+ const isFavourite = favourite === true
132
+ const showSteps = onPrev !== undefined || onNext !== undefined
133
+
134
+ return (
135
+ <header
136
+ data-slot="detail-header"
137
+ className={cn('flex shrink-0 items-center border-b', className)}
138
+ style={{
139
+ gap: 'var(--space-2)',
140
+ paddingInline: 'var(--space-4)',
141
+ // A step under the horizontal, per the optical rule (§8).
142
+ paddingBlock: 'var(--space-3)',
143
+ borderColor: 'rgb(var(--border))',
144
+ }}
145
+ >
146
+ {onCompleteChange === undefined ? null : (
147
+ <Button
148
+ variant="secondary"
149
+ size="sm"
150
+ aria-pressed={isComplete}
151
+ onClick={() => onCompleteChange(!isComplete)}
152
+ className="shrink-0"
153
+ leadingIcon={
154
+ isComplete ? (
155
+ <Check
156
+ size={14}
157
+ strokeWidth={2}
158
+ aria-hidden="true"
159
+ style={{ color: 'rgb(var(--success))' }}
160
+ />
161
+ ) : (
162
+ <Circle size={14} strokeWidth={1.5} aria-hidden="true" />
163
+ )
164
+ }
165
+ >
166
+ {isComplete ? completedLabel : completeLabel}
167
+ </Button>
168
+ )}
169
+
170
+ <div className="flex min-w-0 flex-1 items-center">
171
+ {onTitleChange === undefined ? (
172
+ <h2
173
+ className="min-w-0 truncate text-[17px] font-semibold leading-[1.3] tracking-[-0.01em]"
174
+ style={{ color: 'rgb(var(--foreground))', margin: 0 }}
175
+ >
176
+ {title}
177
+ </h2>
178
+ ) : (
179
+ <>
180
+ <label htmlFor={`${fieldId}-title`} className="sr-only">
181
+ {titleLabel}
182
+ </label>
183
+ <input
184
+ id={`${fieldId}-title`}
185
+ value={title}
186
+ maxLength={titleMaxLength}
187
+ onChange={(event) => onTitleChange(event.target.value)}
188
+ onBlur={() => {
189
+ if (committedRef.current) {
190
+ committedRef.current = false
191
+ return
192
+ }
193
+ onTitleCommit?.()
194
+ }}
195
+ onKeyDown={(event) => {
196
+ if (event.key !== 'Enter') return
197
+ event.preventDefault()
198
+ committedRef.current = true
199
+ onTitleCommit?.()
200
+ event.currentTarget.blur()
201
+ }}
202
+ // Reads as a heading until you reach for it. The transparent 1px
203
+ // border is always there, so revealing the field on hover cannot
204
+ // move the title by a pixel.
205
+ className="w-full truncate rounded-[var(--radius-sm)] border border-solid border-transparent bg-transparent text-[17px] font-semibold leading-[1.3] tracking-[-0.01em] hover:border-[rgb(var(--border))] hover:bg-[rgb(var(--surface-hover))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]"
206
+ style={{
207
+ color: 'rgb(var(--foreground))',
208
+ paddingInline: 'var(--space-2)',
209
+ paddingBlock: 'var(--space-1)',
210
+ marginInlineStart: 'calc(var(--space-2) * -1)',
211
+ }}
212
+ />
213
+ </>
214
+ )}
215
+ </div>
216
+
217
+ {updatedAt === undefined ? null : (
218
+ <span
219
+ data-slot="detail-updated"
220
+ className="shrink-0 whitespace-nowrap text-[11.5px] tabular-nums"
221
+ style={{ color: 'rgb(var(--text-tertiary))' }}
222
+ >
223
+ Updated <RelativeTime value={updatedAt} now={clock} locale={locale} />
224
+ </span>
225
+ )}
226
+
227
+ {!showSteps ? null : (
228
+ <span className="flex shrink-0 items-center" style={{ gap: 'var(--space-1)' }}>
229
+ <Button
230
+ variant="icon"
231
+ size="sm"
232
+ aria-label={prevLabel}
233
+ disabled={onPrev === undefined}
234
+ onClick={onPrev}
235
+ >
236
+ <ChevronUp size={15} strokeWidth={2} aria-hidden="true" />
237
+ </Button>
238
+ <Button
239
+ variant="icon"
240
+ size="sm"
241
+ aria-label={nextLabel}
242
+ disabled={onNext === undefined}
243
+ onClick={onNext}
244
+ >
245
+ <ChevronDown size={15} strokeWidth={2} aria-hidden="true" />
246
+ </Button>
247
+ </span>
248
+ )}
249
+
250
+ {reminder === undefined ? null : (
251
+ <span data-slot="detail-reminder" className="flex shrink-0 items-center">
252
+ {reminder}
253
+ </span>
254
+ )}
255
+
256
+ {onFavouriteChange === undefined ? null : (
257
+ <Button
258
+ variant="icon"
259
+ size="sm"
260
+ aria-label={isFavourite ? favouritedLabel : favouriteLabel}
261
+ aria-pressed={isFavourite}
262
+ onClick={() => onFavouriteChange(!isFavourite)}
263
+ >
264
+ {/* Not colour alone: the star FILLS when active, and the control is
265
+ `aria-pressed` (interface standards §"Color & emphasis"). */}
266
+ <Star
267
+ size={15}
268
+ strokeWidth={isFavourite ? 2 : 1.5}
269
+ aria-hidden="true"
270
+ fill={isFavourite ? 'currentColor' : 'none'}
271
+ style={isFavourite ? { color: 'rgb(var(--accent-ink))' } : undefined}
272
+ />
273
+ </Button>
274
+ )}
275
+
276
+ {menu === undefined ? null : (
277
+ <span data-slot="detail-menu-slot" className="flex shrink-0 items-center">
278
+ {menu}
279
+ </span>
280
+ )}
281
+
282
+ <Button variant="icon" size="sm" aria-label={closeLabel} onClick={onClose}>
283
+ <X size={15} strokeWidth={2} aria-hidden="true" />
284
+ </Button>
285
+ </header>
286
+ )
287
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * DetailMenu — the ellipsis.
3
+ *
4
+ * `docs/design-briefs/tasks-item-detail-full.md` §3, top to bottom: the three
5
+ * mode icons in a row · Move · Copy · Copy URL · Repeat · Print · Archive ·
6
+ * Delete (destructive, confirmed) · the record id with a copy affordance.
7
+ *
8
+ * THE HOST SUPPLIES THE ITEMS, AND AN ITEM IT DOES NOT SUPPLY IS NOT DRAWN.
9
+ * That is the whole point of the `actions` record rather than a fixed list of
10
+ * callbacks: §7 names three things — reminders, repeat, attachments — that
11
+ * have no backing system yet, and the owner's standing rule is that a rendered
12
+ * control does what it says. A menu built from a fixed list can only express
13
+ * "missing" as a disabled row, which is a dead control with an excuse. Here,
14
+ * `repeat` absent means there is no Repeat line at all, and the day the
15
+ * scheduling engine lands the lens adds one key to one object.
16
+ *
17
+ * ORDER IS FIXED HERE, not by the caller's key order, so two lenses cannot
18
+ * ship the same menu in two different sequences.
19
+ *
20
+ * The mode row is a real selected state: an opaque step UP (`--surface-card`
21
+ * on a `--surface-inset` track, accent border, `--shadow-sm`), never an alpha
22
+ * tint on a translucent track — CLAUDE.md §2's hard rule, and the same shape
23
+ * the thread's sort pills use.
24
+ */
25
+
26
+ import { useEffect, useState, type ReactNode } from 'react'
27
+ import {
28
+ Archive,
29
+ Copy,
30
+ CornerUpRight,
31
+ Link2,
32
+ Maximize2,
33
+ MoreHorizontal,
34
+ PanelRight,
35
+ Printer,
36
+ Repeat2,
37
+ SquareDashed,
38
+ Trash2,
39
+ } from 'lucide-react'
40
+ import {
41
+ DropdownMenu,
42
+ DropdownMenuContent,
43
+ DropdownMenuItem,
44
+ DropdownMenuSeparator,
45
+ DropdownMenuTrigger,
46
+ } from '../dropdown-menu'
47
+ import { Kbd } from '../kbd'
48
+ import { cn } from '../lib/utils'
49
+ import { copyText } from '../lib/clipboard'
50
+ import { SegmentedChoice } from './segmented-choice'
51
+ import { DETAIL_MODES, DETAIL_MODE_LABELS, type DetailMode } from './types'
52
+
53
+ export type DetailMenuAction =
54
+ | 'move'
55
+ | 'copy'
56
+ | 'copyUrl'
57
+ | 'repeat'
58
+ | 'print'
59
+ | 'archive'
60
+ | 'delete'
61
+
62
+ export interface DetailMenuItem {
63
+ onSelect: () => void
64
+ /** Overrides the default label. Sentence case. */
65
+ label?: string | undefined
66
+ /** Keyboard hint glyph. Defaults to the reference's (M · C · L · R · P). */
67
+ hint?: string | undefined
68
+ disabled?: boolean | undefined
69
+ }
70
+
71
+ /** Fixed order and default copy. Two lenses cannot ship two sequences. */
72
+ const ACTION_ORDER: readonly DetailMenuAction[] = [
73
+ 'move',
74
+ 'copy',
75
+ 'copyUrl',
76
+ 'repeat',
77
+ 'print',
78
+ 'archive',
79
+ 'delete',
80
+ ]
81
+
82
+ const ACTION_LABEL: Readonly<Record<DetailMenuAction, string>> = {
83
+ move: 'Move',
84
+ copy: 'Copy',
85
+ copyUrl: 'Copy URL',
86
+ repeat: 'Repeat',
87
+ print: 'Print',
88
+ archive: 'Archive',
89
+ delete: 'Delete',
90
+ }
91
+
92
+ const ACTION_HINT: Readonly<Partial<Record<DetailMenuAction, string>>> = {
93
+ move: 'M',
94
+ copy: 'C',
95
+ copyUrl: 'L',
96
+ repeat: 'R',
97
+ print: 'P',
98
+ }
99
+
100
+ const ACTION_ICON: Readonly<Record<DetailMenuAction, ReactNode>> = {
101
+ move: <CornerUpRight size={15} strokeWidth={1.5} aria-hidden="true" />,
102
+ copy: <Copy size={15} strokeWidth={1.5} aria-hidden="true" />,
103
+ copyUrl: <Link2 size={15} strokeWidth={1.5} aria-hidden="true" />,
104
+ repeat: <Repeat2 size={15} strokeWidth={1.5} aria-hidden="true" />,
105
+ print: <Printer size={15} strokeWidth={1.5} aria-hidden="true" />,
106
+ archive: <Archive size={15} strokeWidth={1.5} aria-hidden="true" />,
107
+ delete: <Trash2 size={15} strokeWidth={1.5} aria-hidden="true" />,
108
+ }
109
+
110
+ const MODE_ICON: Readonly<Record<DetailMode, ReactNode>> = {
111
+ full: <Maximize2 size={15} strokeWidth={1.5} aria-hidden="true" />,
112
+ modal: <SquareDashed size={15} strokeWidth={1.5} aria-hidden="true" />,
113
+ panel: <PanelRight size={15} strokeWidth={1.5} aria-hidden="true" />,
114
+ }
115
+
116
+ export interface DetailMenuProps {
117
+ mode: DetailMode
118
+ onModeChange: (next: DetailMode) => void
119
+ /** Only the actions present here are rendered, in the order §3 sets. */
120
+ actions?: Partial<Record<DetailMenuAction, DetailMenuItem>> | undefined
121
+ /** The record id, muted, at the foot. Omit for no id row. */
122
+ recordId?: string | undefined
123
+ /** Defaults to writing the id to the clipboard with a confirmation toast. */
124
+ onCopyRecordId?: ((recordId: string) => void) | undefined
125
+ /** Controlled open state. Omit for internal. */
126
+ open?: boolean | undefined
127
+ onOpenChange?: ((next: boolean) => void) | undefined
128
+ /** Accessible name for the ellipsis. Default "More actions". */
129
+ triggerLabel?: string | undefined
130
+ /** Names the mode group. Default "Open items in". */
131
+ modeGroupLabel?: string | undefined
132
+ className?: string | undefined
133
+ }
134
+
135
+ export function DetailMenu({
136
+ mode,
137
+ onModeChange,
138
+ actions,
139
+ recordId,
140
+ onCopyRecordId,
141
+ open,
142
+ onOpenChange,
143
+ triggerLabel = 'More actions',
144
+ modeGroupLabel = 'Open items in',
145
+ className,
146
+ }: DetailMenuProps) {
147
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
148
+ const isOpen = open ?? uncontrolledOpen
149
+ // Two-step delete. The confirm lives here rather than in the host so every
150
+ // lens gets the same guard; it resets whenever the menu closes, so a menu
151
+ // reopened later never starts one keystroke from destroying a record.
152
+ const [confirmingDelete, setConfirmingDelete] = useState(false)
153
+ useEffect(() => {
154
+ if (!isOpen) setConfirmingDelete(false)
155
+ }, [isOpen])
156
+
157
+ const present = ACTION_ORDER.filter((action) => actions?.[action] !== undefined)
158
+
159
+ return (
160
+ <DropdownMenu
161
+ open={isOpen}
162
+ onOpenChange={(next) => {
163
+ setUncontrolledOpen(next)
164
+ onOpenChange?.(next)
165
+ }}
166
+ >
167
+ <DropdownMenuTrigger
168
+ unstyled
169
+ aria-label={triggerLabel}
170
+ className={cn('btn btn-icon size-sm', className)}
171
+ >
172
+ <MoreHorizontal size={15} strokeWidth={2} aria-hidden="true" />
173
+ </DropdownMenuTrigger>
174
+
175
+ <DropdownMenuContent align="end" className="min-w-[15rem]">
176
+ {/* 1. The three modes. */}
177
+ <SegmentedChoice
178
+ ariaLabel={modeGroupLabel}
179
+ stretch
180
+ className="w-full"
181
+ value={mode}
182
+ onChange={onModeChange}
183
+ items={DETAIL_MODES.map((candidate) => ({
184
+ id: candidate,
185
+ label: DETAIL_MODE_LABELS[candidate],
186
+ icon: MODE_ICON[candidate],
187
+ iconOnly: true,
188
+ }))}
189
+ />
190
+
191
+ {present.length === 0 ? null : <DropdownMenuSeparator />}
192
+
193
+ {/* 2-8. Only what the host supplied, in §3's order. */}
194
+ {present.map((action) => {
195
+ const item = actions?.[action]
196
+ if (item === undefined) return null
197
+ const destructive = action === 'delete'
198
+ const confirming = destructive && confirmingDelete
199
+ const label = confirming
200
+ ? 'Confirm delete'
201
+ : (item.label ?? ACTION_LABEL[action])
202
+ const hint = item.hint ?? ACTION_HINT[action]
203
+ return (
204
+ <DropdownMenuItem
205
+ key={action}
206
+ data-action={action}
207
+ variant={destructive ? 'destructive' : 'default'}
208
+ disabled={item.disabled ?? false}
209
+ onClick={(event) => {
210
+ if (destructive && !confirmingDelete) {
211
+ // Keep the menu open for the second press: the primitive
212
+ // closes on click unless the handler prevents it.
213
+ event.preventDefault()
214
+ setConfirmingDelete(true)
215
+ return
216
+ }
217
+ item.onSelect()
218
+ }}
219
+ >
220
+ {ACTION_ICON[action]}
221
+ <span className="flex-1">{label}</span>
222
+ {hint === undefined || confirming ? null : (
223
+ <Kbd size="sm">{hint}</Kbd>
224
+ )}
225
+ </DropdownMenuItem>
226
+ )
227
+ })}
228
+
229
+ {/* 9. The record id. Muted, and copyable. */}
230
+ {recordId === undefined ? null : (
231
+ <>
232
+ <DropdownMenuSeparator />
233
+ {/* A `menuitem`, not a bare button: everything inside a
234
+ `role="menu"` has to be one, and copying the id IS an action.
235
+ Muted and tabular so it reads as a record stamp rather than as
236
+ a seventh command. */}
237
+ <DropdownMenuItem
238
+ data-slot="detail-record-id"
239
+ aria-label={`Copy record id ${recordId}`}
240
+ onClick={() => {
241
+ if (onCopyRecordId) onCopyRecordId(recordId)
242
+ else void copyText(recordId, 'record id')
243
+ }}
244
+ className="text-[11.5px] font-normal tabular-nums text-[rgb(var(--text-tertiary))]"
245
+ >
246
+ <span className="min-w-0 flex-1 truncate">{recordId}</span>
247
+ <Copy size={13} strokeWidth={1.5} aria-hidden="true" />
248
+ </DropdownMenuItem>
249
+ </>
250
+ )}
251
+ </DropdownMenuContent>
252
+ </DropdownMenu>
253
+ )
254
+ }