@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,1131 @@
1
+ /**
2
+ * FieldRow — a property that reads as its own value, not as a form.
3
+ *
4
+ * `docs/design-briefs/tasks-item-detail-full.md` §4, and the owner's words in
5
+ * the brief above it:
6
+ *
7
+ * "Don't immediately reach for the dropdowns with the input fields — I think
8
+ * we should have some notion type field where it looks native to the field,
9
+ * not really an input either."
10
+ *
11
+ * So AT REST a row is `[icon] [label] [value]` and the value is plain text on
12
+ * the surface — no border, no shell, no chevron. Empty is a muted verb phrase
13
+ * ("Set due date", "Unassigned", "No tags"), never a blank box. The affordance
14
+ * is a hover tint and a focus ring, which is exactly as much as Notion gives
15
+ * it. ENGAGED, the value is replaced IN PLACE by the right editor for the
16
+ * type, and the row is the only thing on screen that changed.
17
+ *
18
+ * ## Every value starts on the same column
19
+ *
20
+ * The label column is one custom property declared by `<FieldList>`
21
+ * (`--field-label-inline-size`), so the values line up down the list and no
22
+ * row can drift by setting its own width.
23
+ *
24
+ * ## Commit semantics, and where they differ
25
+ *
26
+ * §4: "Escape reverts, Enter commits, blur commits." Exact for the rows that
27
+ * hold a DRAFT, with the two variations the controls themselves force:
28
+ *
29
+ * Text all three, verbatim. A MULTILINE text row is the one exception
30
+ * worth naming: Enter is a newline there, so the deliberate
31
+ * "I am done" gesture is Cmd/Ctrl+Enter. Escape and blur are
32
+ * unchanged.
33
+ * Tags all three. The draft is the chip array, so Enter adds a chip
34
+ * and leaving the row is what commits the set.
35
+ * Date all three, plus an explicit confirm (§4 asks for one). Escape
36
+ * discards the pending day; clicking away commits it.
37
+ * People a pending SET, because toggling is repeated: it commits when
38
+ * the picker closes, and Escape discards it.
39
+ * Select commits the moment you pick a row. A single choice closes the
40
+ * list as it is made, so there is no pending state left for an
41
+ * Escape to revert — the last press before a pick reverts by
42
+ * changing nothing.
43
+ *
44
+ * ## Focus comes back to the row (added 2026-09-06)
45
+ *
46
+ * Every editor here REPLACES the resting value, so committing or reverting
47
+ * unmounts the element that holds focus. Before this, the file contained no
48
+ * `.focus()` at all: Enter and Escape — the two most common gestures in the
49
+ * detail surface — dropped focus to `<body>`, and the next Tab restarted
50
+ * from the top of the document. `useRestingFocus` gives each row a ref to
51
+ * its resting `FieldValueButton` and puts focus back the moment the editor
52
+ * closes, for all five row types.
53
+ *
54
+ * It restores ONLY when focus was orphaned (`activeElement` is `<body>`).
55
+ * That one test is what separates "Enter / Escape / Save" — where nothing
56
+ * else claimed focus — from "the operator clicked another control", where
57
+ * blur commits and the thing they clicked must keep focus. It needs no
58
+ * knowledge of WHY the editor closed, which is exactly as much as the
59
+ * popover-based rows can tell us.
60
+ *
61
+ * Escape inside a plain-text editor calls `stopPropagation`. Without it the
62
+ * keypress reaches the shared layer stack's document listener and closes the
63
+ * whole detail — reverting a field would throw the operator out of the item.
64
+ * The popover-based editors deliberately do NOT stop it: there, Escape SHOULD
65
+ * reach the layer above and close the picker first.
66
+ *
67
+ * ## What the editors are
68
+ *
69
+ * The pickers are `Combobox` and the tag editor is `TagChipInput` — the
70
+ * primitives that already exist, not new ones. The rest state is the part that
71
+ * had to be built; a searchable multi-select with chips, keyboard handling and
72
+ * an empty message is a solved problem in this package and forking it to get a
73
+ * different resting appearance would be the drift CLAUDE.md §2 names.
74
+ */
75
+
76
+ import {
77
+ createContext,
78
+ forwardRef,
79
+ useContext,
80
+ useEffect,
81
+ useId,
82
+ useRef,
83
+ useState,
84
+ type ButtonHTMLAttributes,
85
+ type CSSProperties,
86
+ type ReactNode,
87
+ } from 'react'
88
+ import { Avatar, asAvatarTint } from '../avatar'
89
+ import { Button } from '../button'
90
+ import { Combobox } from '../combobox'
91
+ import { Popover } from '../popover'
92
+ import { TagChipInput } from '../tag-chip-input'
93
+ import { ToggleRow } from '../display-popover'
94
+ import { Input } from '../input'
95
+ import { cn } from '../lib/utils'
96
+ import {
97
+ MonthCalendar,
98
+ formatDateDisplay,
99
+ formatDateValue,
100
+ parseDateValue,
101
+ type DateParts,
102
+ } from './calendar'
103
+ import { useFieldRowVisible } from './field-list'
104
+ import type { FieldOption, FieldPerson } from './types'
105
+
106
+ /* ------------------------------------------------------------------ */
107
+ /* The row */
108
+ /* ------------------------------------------------------------------ */
109
+
110
+ interface FieldRowContextValue {
111
+ readonly labelId: string
112
+ }
113
+
114
+ const FieldRowCtx = createContext<FieldRowContextValue | null>(null)
115
+
116
+ /**
117
+ * Puts focus back on the resting value when an editor closes.
118
+ *
119
+ * `engaged` is whatever makes the editor exist — `editing` for the inline
120
+ * rows, `open` for the date popover. Attach the returned ref to the row's
121
+ * `FieldValueButton`.
122
+ */
123
+ function useRestingFocus(engaged: boolean) {
124
+ const restingRef = useRef<HTMLButtonElement | null>(null)
125
+ const wasEngaged = useRef(engaged)
126
+
127
+ useEffect(() => {
128
+ const justClosed = wasEngaged.current && !engaged
129
+ wasEngaged.current = engaged
130
+ if (!justClosed) return
131
+ // Only reclaim focus that nothing else took. A blur-commit caused by
132
+ // clicking another control must leave that control focused.
133
+ const active = document.activeElement
134
+ if (active !== null && active !== document.body) return
135
+ restingRef.current?.focus()
136
+ }, [engaged])
137
+
138
+ return restingRef
139
+ }
140
+
141
+ /**
142
+ * A value that is text rather than a control (a `readOnly` row, or a row
143
+ * given a plain `value`).
144
+ *
145
+ * `role="group"` + `aria-labelledby` is the association: a bare `<span>` is
146
+ * `role="generic"`, where a name is ignored, so the value used to reach a
147
+ * screen reader as a loose string with no tie to the label beside it — the
148
+ * interactive rows were named and the static ones were not.
149
+ */
150
+ function StaticFieldValue({
151
+ className,
152
+ style,
153
+ title,
154
+ children,
155
+ }: {
156
+ className?: string | undefined
157
+ style?: CSSProperties | undefined
158
+ title?: string | undefined
159
+ children: ReactNode
160
+ }) {
161
+ const row = useContext(FieldRowCtx)
162
+ return (
163
+ <span
164
+ role="group"
165
+ {...(row === null ? {} : { 'aria-labelledby': row.labelId })}
166
+ {...(title === undefined ? {} : { title })}
167
+ className={className}
168
+ style={style}
169
+ >
170
+ {children}
171
+ </span>
172
+ )
173
+ }
174
+
175
+ /**
176
+ * The description every INLINE-editing row points at. The five rows share
177
+ * one affordance but not one behaviour: Date opens a dialog, Select and
178
+ * People open a listbox, Text and Tags become an editor in place. The
179
+ * popup rows say so with `aria-haspopup` + `aria-expanded`; the inline rows
180
+ * say so with this. Referenced-but-hidden text is still used for the
181
+ * accessible description (accname §4.3), so it costs no layout.
182
+ */
183
+ function InlineEditHint({ id }: { id: string }) {
184
+ return (
185
+ <span id={id} hidden className="hidden">
186
+ Edits in place. Enter commits, Escape reverts.
187
+ </span>
188
+ )
189
+ }
190
+
191
+ export interface FieldRowProps {
192
+ /** A Lucide icon at 14px. Optional, but the rows read better with one. */
193
+ icon?: ReactNode | undefined
194
+ label: string
195
+ /** Whether the row holds a value. Drives the placeholder and the chips. */
196
+ filled: boolean
197
+ /** The muted verb phrase for empty. Default "Empty". */
198
+ placeholder?: string | undefined
199
+ /** A static value, when the row is not editable. Ignored if `children`. */
200
+ value?: ReactNode | undefined
201
+ /** The value slot — a `<FieldValueButton>`, an editor, a popover. */
202
+ children?: ReactNode | undefined
203
+ className?: string | undefined
204
+ }
205
+
206
+ function FieldRowRoot({
207
+ icon,
208
+ label,
209
+ filled,
210
+ placeholder = 'Empty',
211
+ value,
212
+ children,
213
+ className,
214
+ }: FieldRowProps) {
215
+ const labelId = useId()
216
+ // A filtered-out row is hidden, NOT unmounted: it keeps a half-finished
217
+ // edit through a keystroke in the field search. See field-list.tsx.
218
+ const visible = useFieldRowVisible(label, filled)
219
+
220
+ return (
221
+ <FieldRowCtx.Provider value={{ labelId }}>
222
+ <div
223
+ data-field-row=""
224
+ data-filled={filled ? 'true' : 'false'}
225
+ hidden={!visible}
226
+ className={cn('flex items-start', className)}
227
+ >
228
+ {/* The row's BOX — gap, label width, stacking — lives in styles.css,
229
+ not here. An inline style beats a container query, so anything the
230
+ narrow bands need to change cannot be written on the element. */}
231
+ <span
232
+ data-slot="field-label"
233
+ className="flex shrink-0 items-center"
234
+ style={{ color: 'rgb(var(--text-tertiary))' }}
235
+ >
236
+ {icon === undefined ? null : (
237
+ <span className="flex shrink-0 items-center">{icon}</span>
238
+ )}
239
+ <span id={labelId} className="min-w-0 truncate text-[12.5px] font-medium">
240
+ {label}
241
+ </span>
242
+ </span>
243
+
244
+ <div data-slot="field-value-col" className="min-w-0 flex-1">
245
+ {children ?? (
246
+ <span
247
+ role="group"
248
+ aria-labelledby={labelId}
249
+ className="block truncate text-[13px] leading-[1.5]"
250
+ {...(typeof value === 'string' || !filled
251
+ ? { title: filled ? String(value) : placeholder }
252
+ : {})}
253
+ style={{
254
+ paddingBlock: 'var(--space-2)',
255
+ color: filled
256
+ ? 'rgb(var(--foreground))'
257
+ : 'rgb(var(--text-tertiary))',
258
+ }}
259
+ >
260
+ {filled ? value : placeholder}
261
+ </span>
262
+ )}
263
+ </div>
264
+ </div>
265
+ </FieldRowCtx.Provider>
266
+ )
267
+ }
268
+
269
+ /* ------------------------------------------------------------------ */
270
+ /* The resting value */
271
+ /* ------------------------------------------------------------------ */
272
+
273
+ export interface FieldValueButtonProps
274
+ extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'children'> {
275
+ filled: boolean
276
+ /** Shown, muted, when `filled` is false. */
277
+ placeholder: string
278
+ /** The value at rest. */
279
+ children?: ReactNode | undefined
280
+ }
281
+
282
+ /**
283
+ * The resting value, and the whole affordance: text on the surface, a hover
284
+ * tint, a focus ring. Named by its row's label plus its own content
285
+ * ("Due date, Set due date"), so it announces which field it belongs to
286
+ * without the label being repeated on screen.
287
+ */
288
+ export const FieldValueButton = forwardRef<HTMLButtonElement, FieldValueButtonProps>(
289
+ function FieldValueButton(
290
+ { filled, placeholder, children, className, title, ...rest },
291
+ ref,
292
+ ) {
293
+ const ownId = useId()
294
+ const row = useContext(FieldRowCtx)
295
+ // Truncated text has to stay READABLE somewhere. `title` is the cheap,
296
+ // always-available channel; a caller with a longer value passes its own.
297
+ const hoverTitle = title ?? (filled ? undefined : placeholder)
298
+ return (
299
+ <button
300
+ ref={ref}
301
+ type="button"
302
+ id={ownId}
303
+ {...(hoverTitle === undefined ? {} : { title: hoverTitle })}
304
+ data-slot="field-value"
305
+ data-filled={filled ? 'true' : 'false'}
306
+ aria-labelledby={row === null ? undefined : `${row.labelId} ${ownId}`}
307
+ // `overflow-hidden` is the whole fix for the four-character column:
308
+ // without it the button is a flex line that wraps its own content, so
309
+ // a narrow pane broke "Aug 22, 2026" across three lines rather than
310
+ // ending it in an ellipsis. Rows that carry CHIPS opt back into
311
+ // wrapping with `flex-wrap` — chips are short and wrap cleanly.
312
+ className={cn(
313
+ 'flex w-full min-w-0 items-center gap-[var(--space-2)] overflow-hidden',
314
+ 'text-left text-[13px] leading-[1.5]',
315
+ 'rounded-[var(--radius-sm)] transition-colors duration-[var(--dur-fast)] ease-[var(--ease-out)]',
316
+ 'motion-reduce:transition-none',
317
+ 'hover:bg-[rgb(var(--surface-hover))]',
318
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
319
+ className,
320
+ )}
321
+ style={{
322
+ paddingInline: 'var(--space-2)',
323
+ paddingBlock: 'var(--space-1)',
324
+ minBlockSize: 'var(--ctrl-sm)',
325
+ color: filled ? 'rgb(var(--foreground))' : 'rgb(var(--text-tertiary))',
326
+ }}
327
+ {...rest}
328
+ >
329
+ {filled ? (
330
+ children
331
+ ) : (
332
+ // The empty state is a verb phrase — "Set start date", "Add a link" —
333
+ // and it is the string the screenshot showed wrapping to "Set star
334
+ // date". It truncates like a value, and the `title` on the button
335
+ // says it in full.
336
+ <span className="min-w-0 truncate">{placeholder}</span>
337
+ )}
338
+ </button>
339
+ )
340
+ },
341
+ )
342
+
343
+ /* ------------------------------------------------------------------ */
344
+ /* Chips — the resting shape for the multi-value rows */
345
+ /* ------------------------------------------------------------------ */
346
+
347
+ function ValueChip({ children }: { children: ReactNode }) {
348
+ return (
349
+ <span
350
+ className="inline-flex items-center gap-[var(--space-1)] text-[12px]"
351
+ style={{
352
+ // A flat OPAQUE grey. `--surface-overlay` here would wash out against
353
+ // whatever the row is sitting on (CLAUDE.md §2).
354
+ background: 'rgb(var(--muted))',
355
+ color: 'rgb(var(--foreground))',
356
+ borderRadius: 'var(--radius-xs)',
357
+ paddingInline: 'var(--space-2)',
358
+ paddingBlock: 'var(--space-1)',
359
+ }}
360
+ >
361
+ {children}
362
+ </span>
363
+ )
364
+ }
365
+
366
+ /* ------------------------------------------------------------------ */
367
+ /* Shared prop surface */
368
+ /* ------------------------------------------------------------------ */
369
+
370
+ interface FieldRowCommonProps {
371
+ icon?: ReactNode | undefined
372
+ label: string
373
+ placeholder?: string | undefined
374
+ /** Renders the value with no engage affordance at all. */
375
+ readOnly?: boolean | undefined
376
+ className?: string | undefined
377
+ }
378
+
379
+ /* ------------------------------------------------------------------ */
380
+ /* Text */
381
+ /* ------------------------------------------------------------------ */
382
+
383
+ export interface FieldRowTextProps extends FieldRowCommonProps {
384
+ value: string
385
+ /** Fires only when the committed text differs from `value`. */
386
+ onCommit: (next: string) => void
387
+ /** A textarea instead of an input. Enter then inserts a newline. */
388
+ multiline?: boolean | undefined
389
+ maxLength?: number | undefined
390
+ /** The editor's placeholder while typing. */
391
+ editPlaceholder?: string | undefined
392
+ }
393
+
394
+ function FieldRowText({
395
+ icon,
396
+ label,
397
+ value,
398
+ onCommit,
399
+ multiline = false,
400
+ maxLength = 2000,
401
+ placeholder = 'Empty',
402
+ editPlaceholder,
403
+ readOnly = false,
404
+ className,
405
+ }: FieldRowTextProps) {
406
+ const [editing, setEditing] = useState(false)
407
+ const [draft, setDraft] = useState(value)
408
+ const restingRef = useRestingFocus(editing)
409
+ const hintId = useId()
410
+
411
+ const commit = () => {
412
+ setEditing(false)
413
+ if (draft !== value) onCommit(draft)
414
+ }
415
+
416
+ const revert = () => {
417
+ setDraft(value)
418
+ setEditing(false)
419
+ }
420
+
421
+ const editorClass =
422
+ 'w-full rounded-[var(--radius-sm)] border bg-[rgb(var(--surface-inset))] text-[13px] leading-[1.5] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]'
423
+ const editorStyle = {
424
+ borderColor: 'rgb(var(--border))',
425
+ color: 'rgb(var(--foreground))',
426
+ paddingInline: 'var(--space-2)',
427
+ paddingBlock: 'var(--space-1)',
428
+ }
429
+
430
+ return (
431
+ <FieldRowRoot
432
+ icon={icon}
433
+ label={label}
434
+ filled={value.trim().length > 0}
435
+ placeholder={placeholder}
436
+ className={className}
437
+ >
438
+ {editing && !readOnly ? (
439
+ multiline ? (
440
+ <textarea
441
+ aria-label={label}
442
+ autoFocus
443
+ rows={4}
444
+ maxLength={maxLength}
445
+ value={draft}
446
+ placeholder={editPlaceholder}
447
+ onChange={(event) => setDraft(event.target.value)}
448
+ onBlur={commit}
449
+ onKeyDown={(event) => {
450
+ if (event.key === 'Escape') {
451
+ // Never let this reach the layer stack: reverting a field
452
+ // must not close the item.
453
+ event.stopPropagation()
454
+ event.preventDefault()
455
+ revert()
456
+ return
457
+ }
458
+ // Enter is a newline in a multiline field, so the deliberate
459
+ // "I am done" gesture is the modifier.
460
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
461
+ event.preventDefault()
462
+ commit()
463
+ }
464
+ }}
465
+ className={editorClass}
466
+ style={editorStyle}
467
+ />
468
+ ) : (
469
+ <input
470
+ aria-label={label}
471
+ autoFocus
472
+ maxLength={maxLength}
473
+ value={draft}
474
+ placeholder={editPlaceholder}
475
+ onChange={(event) => setDraft(event.target.value)}
476
+ onBlur={commit}
477
+ onKeyDown={(event) => {
478
+ if (event.key === 'Escape') {
479
+ event.stopPropagation()
480
+ event.preventDefault()
481
+ revert()
482
+ return
483
+ }
484
+ if (event.key === 'Enter') {
485
+ event.preventDefault()
486
+ commit()
487
+ }
488
+ }}
489
+ className={editorClass}
490
+ style={editorStyle}
491
+ />
492
+ )
493
+ ) : readOnly ? (
494
+ <StaticFieldValue
495
+ className={cn(
496
+ 'block text-[13px] leading-[1.5]',
497
+ multiline ? 'whitespace-pre-wrap' : 'truncate',
498
+ )}
499
+ title={value.trim().length > 0 ? value : placeholder}
500
+ style={{
501
+ paddingBlock: 'var(--space-2)',
502
+ color: value.trim().length > 0
503
+ ? 'rgb(var(--foreground))'
504
+ : 'rgb(var(--text-tertiary))',
505
+ }}
506
+ >
507
+ {value.trim().length > 0 ? value : placeholder}
508
+ </StaticFieldValue>
509
+ ) : (
510
+ <>
511
+ <InlineEditHint id={hintId} />
512
+ <FieldValueButton
513
+ ref={restingRef}
514
+ aria-describedby={hintId}
515
+ filled={value.trim().length > 0}
516
+ placeholder={placeholder}
517
+ {...(value.trim().length > 0 ? { title: value } : {})}
518
+ onClick={() => {
519
+ setDraft(value)
520
+ setEditing(true)
521
+ }}
522
+ >
523
+ {/* A multiline value WRAPS on purpose — it is a paragraph, and the
524
+ row's floor keeps the column wide enough to read. `line-clamp`
525
+ keeps it from becoming the whole pane. */}
526
+ <span
527
+ className={
528
+ multiline ? 'line-clamp-3 whitespace-pre-wrap' : 'min-w-0 truncate'
529
+ }
530
+ >
531
+ {value}
532
+ </span>
533
+ </FieldValueButton>
534
+ </>
535
+ )}
536
+ </FieldRowRoot>
537
+ )
538
+ }
539
+
540
+ /* ------------------------------------------------------------------ */
541
+ /* Date */
542
+ /* ------------------------------------------------------------------ */
543
+
544
+ export interface FieldRowDateProps extends FieldRowCommonProps {
545
+ /** `YYYY-MM-DD`, or `YYYY-MM-DDTHH:mm` with a time. Null is empty. */
546
+ value: string | null
547
+ onCommit: (next: string | null) => void
548
+ /** Offer the `Include time` toggle. Default true. */
549
+ allowTime?: boolean | undefined
550
+ locale?: string | undefined
551
+ /** Frozen clock, epoch ms — the calendar's "today". */
552
+ now?: number | undefined
553
+ confirmLabel?: string | undefined
554
+ clearLabel?: string | undefined
555
+ }
556
+
557
+ function FieldRowDate({
558
+ icon,
559
+ label,
560
+ value,
561
+ onCommit,
562
+ allowTime = true,
563
+ locale,
564
+ now,
565
+ placeholder = 'Set date',
566
+ confirmLabel = 'Save date',
567
+ clearLabel = 'Clear',
568
+ readOnly = false,
569
+ className,
570
+ }: FieldRowDateProps) {
571
+ const parsed = parseDateValue(value)
572
+ const [open, setOpen] = useState(false)
573
+ const restingRef = useRestingFocus(open)
574
+ const [pending, setPending] = useState<DateParts | null>(parsed)
575
+ const [includeTime, setIncludeTime] = useState(parsed !== null && parsed.time !== null)
576
+ // A REF, not state. Escape is handled here and then travels on to the layer
577
+ // stack, which closes the popover in the same native dispatch — before any
578
+ // queued state update has been applied. A flag that has to be read by the
579
+ // close handler therefore cannot be state.
580
+ const revertingRef = useRef(false)
581
+
582
+ const commitPending = () => {
583
+ const next = pending === null ? null : formatDateValue(pending)
584
+ if (next !== value) onCommit(next)
585
+ }
586
+
587
+ const openEditor = () => {
588
+ const seed = parseDateValue(value)
589
+ setPending(seed)
590
+ setIncludeTime(seed !== null && seed.time !== null)
591
+ setOpen(true)
592
+ }
593
+
594
+ const filled = parsed !== null
595
+
596
+ return (
597
+ <FieldRowRoot
598
+ icon={icon}
599
+ label={label}
600
+ filled={filled}
601
+ placeholder={placeholder}
602
+ className={className}
603
+ >
604
+ {readOnly ? (
605
+ <StaticFieldValue
606
+ className="block truncate text-[13px] leading-[1.5] tabular-nums"
607
+ title={filled ? formatDateDisplay(value, locale) : placeholder}
608
+ style={{
609
+ paddingBlock: 'var(--space-2)',
610
+ color: filled ? 'rgb(var(--foreground))' : 'rgb(var(--text-tertiary))',
611
+ }}
612
+ >
613
+ {filled ? formatDateDisplay(value, locale) : placeholder}
614
+ </StaticFieldValue>
615
+ ) : (
616
+ <Popover
617
+ open={open}
618
+ onOpenChange={(next) => {
619
+ // Closing any way other than Escape is a blur, and blur commits.
620
+ if (!next) {
621
+ if (revertingRef.current) {
622
+ revertingRef.current = false
623
+ setPending(parseDateValue(value))
624
+ } else {
625
+ commitPending()
626
+ }
627
+ }
628
+ setOpen(next)
629
+ }}
630
+ >
631
+ <Popover.Trigger asChild>
632
+ {/* Popover.Trigger `asChild` composes this ref with its own, and
633
+ supplies `aria-haspopup="dialog"` + `aria-expanded`. */}
634
+ <FieldValueButton
635
+ ref={restingRef}
636
+ filled={filled}
637
+ placeholder={placeholder}
638
+ className="tabular-nums"
639
+ {...(filled ? { title: formatDateDisplay(value, locale) } : {})}
640
+ onClick={() => {
641
+ if (!open) openEditor()
642
+ }}
643
+ >
644
+ {/* The row from the owner's screenshot: "Aug 22, 202". A bare
645
+ string child is a wrapping flex line; the span is what makes
646
+ it an ellipsis. */}
647
+ <span className="min-w-0 truncate">
648
+ {formatDateDisplay(value, locale)}
649
+ </span>
650
+ </FieldValueButton>
651
+ </Popover.Trigger>
652
+ <Popover.Content
653
+ aria-label={label}
654
+ align="start"
655
+ className="w-[17.5rem]"
656
+ onKeyDown={(event) => {
657
+ if (event.key !== 'Escape') return
658
+ // Mark the revert and let the keypress travel on: the popover's
659
+ // own layer closes it, and the close handler reads this flag
660
+ // instead of committing.
661
+ revertingRef.current = true
662
+ }}
663
+ >
664
+ <div className="flex flex-col" style={{ gap: 'var(--space-3)' }}>
665
+ <MonthCalendar
666
+ selected={pending}
667
+ onSelect={(day) =>
668
+ setPending((current) => ({
669
+ ...day,
670
+ time: current?.time ?? (includeTime ? '09:00' : null),
671
+ }))
672
+ }
673
+ locale={locale}
674
+ now={now}
675
+ />
676
+
677
+ {!allowTime ? null : (
678
+ <div
679
+ className="flex flex-col border-t"
680
+ style={{
681
+ gap: 'var(--space-2)',
682
+ borderColor: 'rgb(var(--border))',
683
+ paddingBlockStart: 'var(--space-2)',
684
+ }}
685
+ >
686
+ <ToggleRow
687
+ label="Include time"
688
+ checked={includeTime}
689
+ onCheckedChange={(next) => {
690
+ setIncludeTime(next)
691
+ setPending((current) =>
692
+ current === null
693
+ ? current
694
+ : { ...current, time: next ? (current.time ?? '09:00') : null },
695
+ )
696
+ }}
697
+ />
698
+ {!includeTime ? null : (
699
+ <Input
700
+ inputSize="sm"
701
+ type="time"
702
+ aria-label="Time"
703
+ className="tabular-nums"
704
+ value={pending?.time ?? '09:00'}
705
+ onChange={(event) =>
706
+ setPending((current) =>
707
+ current === null
708
+ ? current
709
+ : { ...current, time: event.target.value || null },
710
+ )
711
+ }
712
+ />
713
+ )}
714
+ </div>
715
+ )}
716
+
717
+ <div
718
+ className="flex items-center justify-end border-t"
719
+ style={{
720
+ gap: 'var(--space-2)',
721
+ borderColor: 'rgb(var(--border))',
722
+ paddingBlockStart: 'var(--space-2)',
723
+ }}
724
+ >
725
+ <Button
726
+ variant="secondary"
727
+ size="sm"
728
+ onClick={() => {
729
+ setPending(null)
730
+ if (value !== null) onCommit(null)
731
+ setOpen(false)
732
+ }}
733
+ >
734
+ {clearLabel}
735
+ </Button>
736
+ <Button
737
+ variant="primary"
738
+ size="sm"
739
+ onClick={() => {
740
+ commitPending()
741
+ setOpen(false)
742
+ }}
743
+ >
744
+ {confirmLabel}
745
+ </Button>
746
+ </div>
747
+ </div>
748
+ </Popover.Content>
749
+ </Popover>
750
+ )}
751
+ </FieldRowRoot>
752
+ )
753
+ }
754
+
755
+ /* ------------------------------------------------------------------ */
756
+ /* Select */
757
+ /* ------------------------------------------------------------------ */
758
+
759
+ export interface FieldRowSelectProps<T extends string = string>
760
+ extends FieldRowCommonProps {
761
+ value: T | null
762
+ options: readonly FieldOption<T>[]
763
+ onCommit: (next: T | null) => void
764
+ /** The picker's own placeholder. */
765
+ searchPlaceholder?: string | undefined
766
+ emptyMessage?: string | undefined
767
+ }
768
+
769
+ function FieldRowSelect<T extends string = string>({
770
+ icon,
771
+ label,
772
+ value,
773
+ options,
774
+ onCommit,
775
+ placeholder = 'Empty',
776
+ searchPlaceholder = 'Search',
777
+ emptyMessage = 'No matches',
778
+ readOnly = false,
779
+ className,
780
+ }: FieldRowSelectProps<T>) {
781
+ const [editing, setEditing] = useState(false)
782
+ const restingRef = useRestingFocus(editing)
783
+ const selected = options.find((option) => option.value === value) ?? null
784
+ const filled = selected !== null
785
+
786
+ return (
787
+ <FieldRowRoot
788
+ icon={icon}
789
+ label={label}
790
+ filled={filled}
791
+ placeholder={placeholder}
792
+ className={className}
793
+ >
794
+ {editing && !readOnly ? (
795
+ <Combobox<T>
796
+ aria-label={label}
797
+ autoFocus
798
+ open
799
+ placeholder={searchPlaceholder}
800
+ emptyMessage={emptyMessage}
801
+ size="sm"
802
+ options={options.map((option) => ({
803
+ value: option.value,
804
+ label: option.label,
805
+ ...(option.icon === undefined ? {} : { icon: option.icon }),
806
+ ...(option.disabled === undefined ? {} : { disabled: option.disabled }),
807
+ }))}
808
+ value={value}
809
+ onValueChange={(next) => {
810
+ if (next !== value) onCommit(next)
811
+ }}
812
+ onOpenChange={(next) => {
813
+ if (!next) setEditing(false)
814
+ }}
815
+ />
816
+ ) : readOnly ? (
817
+ <StaticFieldValue
818
+ className="flex min-w-0 items-center gap-[var(--space-2)] overflow-hidden text-[13px] leading-[1.5]"
819
+ title={filled && selected !== null ? selected.label : placeholder}
820
+ style={{
821
+ paddingBlock: 'var(--space-2)',
822
+ color: filled ? 'rgb(var(--foreground))' : 'rgb(var(--text-tertiary))',
823
+ }}
824
+ >
825
+ {filled && selected !== null ? (
826
+ <>
827
+ <span className="flex shrink-0 items-center">{selected.icon}</span>
828
+ <span className="min-w-0 truncate">{selected.label}</span>
829
+ </>
830
+ ) : (
831
+ <span className="min-w-0 truncate">{placeholder}</span>
832
+ )}
833
+ </StaticFieldValue>
834
+ ) : (
835
+ <FieldValueButton
836
+ ref={restingRef}
837
+ // What actually opens: a searchable list, not a dialog and not an
838
+ // inline editor. Same affordance as the other rows, honest promise.
839
+ aria-haspopup="listbox"
840
+ aria-expanded={editing}
841
+ filled={filled}
842
+ placeholder={placeholder}
843
+ {...(selected === null ? {} : { title: selected.label })}
844
+ onClick={() => setEditing(true)}
845
+ >
846
+ {selected === null ? null : (
847
+ <>
848
+ <span className="flex shrink-0 items-center">{selected.icon}</span>
849
+ <span className="min-w-0 truncate">{selected.label}</span>
850
+ </>
851
+ )}
852
+ </FieldValueButton>
853
+ )}
854
+ </FieldRowRoot>
855
+ )
856
+ }
857
+
858
+ /* ------------------------------------------------------------------ */
859
+ /* People */
860
+ /* ------------------------------------------------------------------ */
861
+
862
+ export interface FieldRowPeopleProps extends FieldRowCommonProps {
863
+ value: readonly string[]
864
+ people: readonly FieldPerson[]
865
+ onCommit: (next: string[]) => void
866
+ searchPlaceholder?: string | undefined
867
+ emptyMessage?: string | undefined
868
+ }
869
+
870
+ /** D5's rule: never fabricate a display name — fall back, do not invent. */
871
+ function personLabel(person: FieldPerson): string {
872
+ return person.name ?? person.id
873
+ }
874
+
875
+ /**
876
+ * One avatar. A component rather than an inline expression because
877
+ * `exactOptionalPropertyTypes` makes `tint={asAvatarTint(x)}` an error when
878
+ * the helper can return `undefined` — the tint has to be narrowed once and
879
+ * spread, and doing that inline twice reads worse than doing it here once.
880
+ */
881
+ function PersonAvatar({ person }: { person: FieldPerson }) {
882
+ const tint = asAvatarTint(person.tint)
883
+ return (
884
+ <Avatar
885
+ name={person.name}
886
+ size="sm"
887
+ seed={person.id}
888
+ src={person.avatarUrl ?? null}
889
+ {...(tint === undefined ? {} : { tint })}
890
+ />
891
+ )
892
+ }
893
+
894
+ function FieldRowPeople({
895
+ icon,
896
+ label,
897
+ value,
898
+ people,
899
+ onCommit,
900
+ placeholder = 'Unassigned',
901
+ searchPlaceholder = 'Search people',
902
+ emptyMessage = 'No people match',
903
+ readOnly = false,
904
+ className,
905
+ }: FieldRowPeopleProps) {
906
+ const [editing, setEditing] = useState(false)
907
+ const [pending, setPending] = useState<readonly string[]>(value)
908
+ const revertingRef = useRef(false)
909
+ const restingRef = useRestingFocus(editing)
910
+
911
+ const byId = new Map(people.map((person) => [person.id, person]))
912
+ const chosen = value.map((id) => byId.get(id)).filter((person): person is FieldPerson => person !== undefined)
913
+ const filled = value.length > 0
914
+
915
+ const finish = () => {
916
+ setEditing(false)
917
+ if (revertingRef.current) {
918
+ revertingRef.current = false
919
+ return
920
+ }
921
+ const next = [...pending]
922
+ const changed =
923
+ next.length !== value.length || next.some((id, index) => id !== value[index])
924
+ if (changed) onCommit(next)
925
+ }
926
+
927
+ return (
928
+ <FieldRowRoot
929
+ icon={icon}
930
+ label={label}
931
+ filled={filled}
932
+ placeholder={placeholder}
933
+ className={className}
934
+ >
935
+ {editing && !readOnly ? (
936
+ <div
937
+ onKeyDown={(event) => {
938
+ // Escape discards the pending selection, then travels on so the
939
+ // combobox's own layer closes it.
940
+ if (event.key === 'Escape') revertingRef.current = true
941
+ }}
942
+ >
943
+ <Combobox
944
+ multiple
945
+ aria-label={label}
946
+ autoFocus
947
+ open
948
+ size="sm"
949
+ placeholder={searchPlaceholder}
950
+ emptyMessage={emptyMessage}
951
+ options={people.map((person) => ({
952
+ value: person.id,
953
+ label: personLabel(person),
954
+ ...(person.hint === undefined ? {} : { description: person.hint }),
955
+ icon: <PersonAvatar person={person} />,
956
+ }))}
957
+ value={pending}
958
+ onValueChange={setPending}
959
+ onOpenChange={(next) => {
960
+ if (!next) finish()
961
+ }}
962
+ />
963
+ </div>
964
+ ) : readOnly ? (
965
+ <StaticFieldValue
966
+ className="block truncate text-[13px] leading-[1.5]"
967
+ title={filled ? chosen.map(personLabel).join(', ') : placeholder}
968
+ style={{
969
+ paddingBlock: 'var(--space-2)',
970
+ color: filled ? 'rgb(var(--foreground))' : 'rgb(var(--text-tertiary))',
971
+ }}
972
+ >
973
+ {filled ? chosen.map(personLabel).join(', ') : placeholder}
974
+ </StaticFieldValue>
975
+ ) : (
976
+ <FieldValueButton
977
+ ref={restingRef}
978
+ aria-haspopup="listbox"
979
+ aria-expanded={editing}
980
+ filled={filled}
981
+ placeholder={placeholder}
982
+ className="flex-wrap"
983
+ onClick={() => {
984
+ setPending(value)
985
+ setEditing(true)
986
+ }}
987
+ >
988
+ {chosen.map((person) => (
989
+ <span
990
+ key={person.id}
991
+ className="inline-flex items-center gap-[var(--space-1)]"
992
+ >
993
+ <PersonAvatar person={person} />
994
+ <span className="truncate">{personLabel(person)}</span>
995
+ </span>
996
+ ))}
997
+ </FieldValueButton>
998
+ )}
999
+ </FieldRowRoot>
1000
+ )
1001
+ }
1002
+
1003
+ /* ------------------------------------------------------------------ */
1004
+ /* Tags */
1005
+ /* ------------------------------------------------------------------ */
1006
+
1007
+ export interface FieldRowTagsProps extends FieldRowCommonProps {
1008
+ value: readonly string[]
1009
+ onCommit: (next: string[]) => void
1010
+ suggestions?: readonly string[] | undefined
1011
+ max?: number | undefined
1012
+ maxLength?: number | undefined
1013
+ editPlaceholder?: string | undefined
1014
+ }
1015
+
1016
+ function FieldRowTags({
1017
+ icon,
1018
+ label,
1019
+ value,
1020
+ onCommit,
1021
+ suggestions,
1022
+ max,
1023
+ maxLength,
1024
+ placeholder = 'No tags',
1025
+ editPlaceholder = 'Add a tag',
1026
+ readOnly = false,
1027
+ className,
1028
+ }: FieldRowTagsProps) {
1029
+ const [editing, setEditing] = useState(false)
1030
+ const [draft, setDraft] = useState<string[]>([...value])
1031
+ const restingRef = useRestingFocus(editing)
1032
+ const hintId = useId()
1033
+ const editorRef = useRef<HTMLDivElement | null>(null)
1034
+ const filled = value.length > 0
1035
+
1036
+ // The other four editors autofocus themselves; TagChipInput has no such
1037
+ // prop, so engaging this row by keyboard used to unmount the button and
1038
+ // leave focus on <body> — with nothing to press Escape on.
1039
+ useEffect(() => {
1040
+ if (!editing) return
1041
+ editorRef.current?.querySelector('input')?.focus()
1042
+ }, [editing])
1043
+
1044
+ const commit = () => {
1045
+ setEditing(false)
1046
+ const changed =
1047
+ draft.length !== value.length || draft.some((tag, index) => tag !== value[index])
1048
+ if (changed) onCommit(draft)
1049
+ }
1050
+
1051
+ return (
1052
+ <FieldRowRoot
1053
+ icon={icon}
1054
+ label={label}
1055
+ filled={filled}
1056
+ placeholder={placeholder}
1057
+ className={className}
1058
+ >
1059
+ {editing && !readOnly ? (
1060
+ <div
1061
+ ref={editorRef}
1062
+ onKeyDown={(event) => {
1063
+ if (event.key !== 'Escape') return
1064
+ event.stopPropagation()
1065
+ event.preventDefault()
1066
+ setDraft([...value])
1067
+ setEditing(false)
1068
+ }}
1069
+ onBlur={(event) => {
1070
+ // A click on the chip editor's own remove button is not a blur.
1071
+ if (event.currentTarget.contains(event.relatedTarget)) return
1072
+ commit()
1073
+ }}
1074
+ >
1075
+ <TagChipInput
1076
+ value={draft}
1077
+ onChange={setDraft}
1078
+ placeholder={editPlaceholder}
1079
+ {...(suggestions === undefined ? {} : { suggestions: [...suggestions] })}
1080
+ {...(max === undefined ? {} : { max })}
1081
+ {...(maxLength === undefined ? {} : { maxLength })}
1082
+ />
1083
+ </div>
1084
+ ) : readOnly ? (
1085
+ <StaticFieldValue
1086
+ className="flex flex-wrap items-center gap-[var(--space-1)] text-[13px] leading-[1.5]"
1087
+ style={{
1088
+ paddingBlock: 'var(--space-2)',
1089
+ color: filled ? 'rgb(var(--foreground))' : 'rgb(var(--text-tertiary))',
1090
+ }}
1091
+ >
1092
+ {filled ? value.map((tag) => <ValueChip key={tag}>{tag}</ValueChip>) : placeholder}
1093
+ </StaticFieldValue>
1094
+ ) : (
1095
+ <>
1096
+ <InlineEditHint id={hintId} />
1097
+ <FieldValueButton
1098
+ ref={restingRef}
1099
+ aria-describedby={hintId}
1100
+ filled={filled}
1101
+ placeholder={placeholder}
1102
+ className="flex-wrap"
1103
+ onClick={() => {
1104
+ setDraft([...value])
1105
+ setEditing(true)
1106
+ }}
1107
+ >
1108
+ {value.map((tag) => (
1109
+ <ValueChip key={tag}>{tag}</ValueChip>
1110
+ ))}
1111
+ </FieldValueButton>
1112
+ </>
1113
+ )}
1114
+ </FieldRowRoot>
1115
+ )
1116
+ }
1117
+
1118
+ /* ------------------------------------------------------------------ */
1119
+
1120
+ /**
1121
+ * The family. `Object.assign` rather than property assignment on a function
1122
+ * declaration — the same shape `DisplayPopover` and `Popover` use, and the
1123
+ * one that keeps `Select`'s type parameter intact.
1124
+ */
1125
+ export const FieldRow = Object.assign(FieldRowRoot, {
1126
+ Text: FieldRowText,
1127
+ Date: FieldRowDate,
1128
+ Select: FieldRowSelect,
1129
+ People: FieldRowPeople,
1130
+ Tags: FieldRowTags,
1131
+ })