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