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