@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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
|
+
})
|