@proteos/ui 0.78.0 → 0.80.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/index.cjs +316 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +197 -23
- package/dist/index.d.ts +197 -23
- package/dist/index.js +303 -61
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ava-action-review/ava-action-review.stories.tsx +313 -0
- package/src/components/{record-update/record-update.test.tsx → ava-action-review/ava-action-review.test.tsx} +52 -23
- package/src/components/ava-action-review/ava-action-review.tsx +612 -0
- package/src/components/ava-action-review/index.ts +30 -0
- package/src/components/checkbox/checkbox.tsx +12 -6
- package/src/components/radio-group/radio-group.tsx +11 -6
- package/src/components/switch/switch.tsx +15 -8
- package/src/index.ts +1 -1
- package/src/components/record-update/index.ts +0 -17
- package/src/components/record-update/record-update.stories.tsx +0 -141
- package/src/components/record-update/record-update.tsx +0 -259
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlertCircle,
|
|
3
|
+
ArrowRight,
|
|
4
|
+
CornerDownRight,
|
|
5
|
+
Plus,
|
|
6
|
+
Search,
|
|
7
|
+
TriangleAlert,
|
|
8
|
+
Undo2,
|
|
9
|
+
X,
|
|
10
|
+
} from 'lucide-react'
|
|
11
|
+
import { type HTMLAttributes, type ReactNode, useLayoutEffect, useRef, useState } from 'react'
|
|
12
|
+
import {
|
|
13
|
+
Dialog as AriaDialog,
|
|
14
|
+
Input as AriaInput,
|
|
15
|
+
Popover as AriaPopover,
|
|
16
|
+
Autocomplete,
|
|
17
|
+
DialogTrigger,
|
|
18
|
+
Header,
|
|
19
|
+
MenuSection,
|
|
20
|
+
SearchField,
|
|
21
|
+
useFilter,
|
|
22
|
+
} from 'react-aria-components'
|
|
23
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
24
|
+
|
|
25
|
+
import { cn } from '../../lib/cn'
|
|
26
|
+
import { Button } from '../button/button'
|
|
27
|
+
import { Menu, MenuItem } from '../menu/menu'
|
|
28
|
+
|
|
29
|
+
/* =========================================================================
|
|
30
|
+
AvaActionReview — what Ava prepared, for the person to adjust and decide:
|
|
31
|
+
a fixed head, a body that alone scrolls, and a foot that keeps the buttons
|
|
32
|
+
at the bottom right of whatever hosts it. The same shell reviews a record
|
|
33
|
+
proposal and an action's prepared inputs, in hosts from a 240px column to
|
|
34
|
+
a 768px palette, so the layout answers to its CONTAINER, never the viewport.
|
|
35
|
+
|
|
36
|
+
Slot composition (Card pattern): the root carries no padding; each slot
|
|
37
|
+
owns its own spacing, 12px below the container breakpoint and 16px above.
|
|
38
|
+
|
|
39
|
+
<AvaActionReview>
|
|
40
|
+
<AvaActionReviewHead>
|
|
41
|
+
<AvaActionReviewTitle eyebrow="Update" action={…}>Acme</AvaActionReviewTitle>
|
|
42
|
+
<AvaActionReviewReason>…</AvaActionReviewReason>
|
|
43
|
+
<AvaActionReviewSummary meta="5 suggested">…</AvaActionReviewSummary>
|
|
44
|
+
<AvaActionReviewProblem>…</AvaActionReviewProblem>
|
|
45
|
+
</AvaActionReviewHead>
|
|
46
|
+
<AvaActionReviewBody>
|
|
47
|
+
<AvaActionReviewErrors errors={[…]} />
|
|
48
|
+
<AvaActionReviewRows>
|
|
49
|
+
<AvaActionReviewRow label from to state … />
|
|
50
|
+
</AvaActionReviewRows>
|
|
51
|
+
</AvaActionReviewBody>
|
|
52
|
+
<AvaActionReviewFooter>
|
|
53
|
+
<AvaActionReviewAddField options={…} onPick={…} />
|
|
54
|
+
<AvaActionReviewActions>…buttons…</AvaActionReviewActions>
|
|
55
|
+
</AvaActionReviewFooter>
|
|
56
|
+
</AvaActionReview>
|
|
57
|
+
|
|
58
|
+
Row states:
|
|
59
|
+
changed — a value the reviewer can still edit or drop.
|
|
60
|
+
added — the reviewer brought this field in; nothing to write until
|
|
61
|
+
they edit it.
|
|
62
|
+
unresolved — the proposer knew WHICH field but not the value; the right
|
|
63
|
+
cell is expected to be an empty editor and `hint` says why.
|
|
64
|
+
Consumers block "apply" while any row is here.
|
|
65
|
+
rejected — the proposer could NOT do what was asked for this field
|
|
66
|
+
(read-only, no such option …); `to` carries the explanation
|
|
67
|
+
and nothing is applied for the row.
|
|
68
|
+
removed — dropped from the change set; collapses to one line with a
|
|
69
|
+
restore button so the decision stays reversible.
|
|
70
|
+
========================================================================= */
|
|
71
|
+
|
|
72
|
+
export const avaActionReviewStyles = tv({
|
|
73
|
+
slots: {
|
|
74
|
+
root: '@container flex h-full min-h-0 min-w-0 flex-1 flex-col text-ink',
|
|
75
|
+
// In a short host the head gives way and scrolls before the body would
|
|
76
|
+
// collapse and push the foot out of the box (a long summary, many problems).
|
|
77
|
+
head: [
|
|
78
|
+
'flex min-h-0 flex-col gap-1.5 overflow-y-auto [scrollbar-width:thin] [&>*]:shrink-0',
|
|
79
|
+
'border-b border-rule-2 px-3 pt-3 pb-2.5 @md:px-4',
|
|
80
|
+
],
|
|
81
|
+
title: 'flex min-w-0 items-baseline gap-2',
|
|
82
|
+
eyebrow: 'shrink-0 font-mono text-[10.5px] font-medium uppercase tracking-[0.08em] text-ink-4',
|
|
83
|
+
titleText: 'min-w-0 flex-1 truncate text-[13.5px] font-medium',
|
|
84
|
+
titleAction: 'shrink-0 self-center',
|
|
85
|
+
reason: 'text-[12px] leading-[1.4] text-ink-3',
|
|
86
|
+
summary: 'text-[13px] leading-[1.45] text-ink-2',
|
|
87
|
+
summaryMore: 'flex flex-wrap items-baseline gap-2.5',
|
|
88
|
+
summaryMeta: 'font-mono text-[11px] leading-[1.4] text-ink-3',
|
|
89
|
+
problem: [
|
|
90
|
+
'flex gap-1.5 rounded-sm px-2 py-1 text-[12.5px] leading-[1.4] text-warning',
|
|
91
|
+
'bg-[color-mix(in_oklab,var(--color-warning)_10%,var(--color-surface))]',
|
|
92
|
+
],
|
|
93
|
+
body: 'min-h-20 flex-1 overflow-y-auto [scrollbar-width:thin]',
|
|
94
|
+
rows: 'flex flex-col px-3 @md:px-4',
|
|
95
|
+
errors: [
|
|
96
|
+
'mx-3 mt-2.5 flex gap-2 rounded-md px-3 py-2 text-[13px] text-danger @md:mx-4',
|
|
97
|
+
'bg-[color-mix(in_oklab,var(--color-danger)_8%,var(--color-surface))]',
|
|
98
|
+
],
|
|
99
|
+
footer:
|
|
100
|
+
'flex shrink-0 flex-wrap items-center justify-between gap-1.5 border-t border-rule-2 px-3 py-2 @md:px-4',
|
|
101
|
+
actions: 'ml-auto flex items-center gap-1',
|
|
102
|
+
hint: 'basis-full text-[12px] leading-[1.35] text-ink-3',
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
export const avaActionReviewRowStyles = tv({
|
|
107
|
+
slots: {
|
|
108
|
+
// fieldset resets (UA border / padding / min-content width) included. The
|
|
109
|
+
// hairline comes after `border-0`: a row's own reset would otherwise beat
|
|
110
|
+
// a divider set on its parent.
|
|
111
|
+
root: 'm-0 flex min-w-0 flex-col gap-1.5 border-0 border-t border-rule-2 px-0 pt-2 pb-2.5 first:border-t-0',
|
|
112
|
+
labelRow: [
|
|
113
|
+
'flex min-h-7 items-center gap-2',
|
|
114
|
+
'font-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-4',
|
|
115
|
+
],
|
|
116
|
+
tag: 'rounded-full px-1.5 py-px text-[10px] normal-case tracking-normal',
|
|
117
|
+
cells: 'grid grid-cols-[16px_minmax(0,1fr)] items-start gap-x-1.5 gap-y-1',
|
|
118
|
+
from: 'col-span-full min-w-0',
|
|
119
|
+
arrow: 'flex h-8 items-center justify-center text-ink-4 [&_svg]:size-3.5',
|
|
120
|
+
arrowStacked: '',
|
|
121
|
+
arrowBeside: 'hidden',
|
|
122
|
+
to: 'min-w-0',
|
|
123
|
+
hint: 'text-[12px] text-ink-3',
|
|
124
|
+
error: 'text-[12px] text-danger',
|
|
125
|
+
removed: 'whitespace-nowrap font-sans text-[12.5px] italic normal-case tracking-normal',
|
|
126
|
+
},
|
|
127
|
+
variants: {
|
|
128
|
+
state: {
|
|
129
|
+
changed: {},
|
|
130
|
+
added: {
|
|
131
|
+
tag: 'bg-surface-2 text-ink-3',
|
|
132
|
+
},
|
|
133
|
+
unresolved: {
|
|
134
|
+
tag: 'bg-[color-mix(in_oklab,var(--color-warning)_12%,var(--color-surface))] text-warning',
|
|
135
|
+
},
|
|
136
|
+
rejected: {
|
|
137
|
+
tag: 'bg-[color-mix(in_oklab,var(--color-danger)_10%,var(--color-surface))] text-danger',
|
|
138
|
+
},
|
|
139
|
+
removed: { root: 'pt-0.5 pb-0.5' },
|
|
140
|
+
},
|
|
141
|
+
/** Long values read better one above the other at any width. */
|
|
142
|
+
isLong: {
|
|
143
|
+
false: {
|
|
144
|
+
cells: '@md:grid-cols-[minmax(0,1fr)_14px_minmax(0,1fr)] @md:gap-x-2.5 @md:gap-y-0',
|
|
145
|
+
from: '@md:col-auto',
|
|
146
|
+
arrowStacked: '@md:hidden',
|
|
147
|
+
arrowBeside: '@md:block',
|
|
148
|
+
},
|
|
149
|
+
true: {},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
defaultVariants: { state: 'changed', isLong: false },
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
export type AvaActionReviewRowVariants = VariantProps<typeof avaActionReviewRowStyles>
|
|
156
|
+
export type AvaActionReviewRowState = NonNullable<AvaActionReviewRowVariants['state']>
|
|
157
|
+
|
|
158
|
+
/* ---------- root + slots ---------- */
|
|
159
|
+
|
|
160
|
+
export type AvaActionReviewProps = HTMLAttributes<HTMLDivElement>
|
|
161
|
+
|
|
162
|
+
export function AvaActionReview({ className, children, ...props }: AvaActionReviewProps) {
|
|
163
|
+
return (
|
|
164
|
+
<div {...props} className={cn(avaActionReviewStyles().root(), className)}>
|
|
165
|
+
{children}
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function AvaActionReviewHead({
|
|
171
|
+
className,
|
|
172
|
+
children,
|
|
173
|
+
...props
|
|
174
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
175
|
+
return (
|
|
176
|
+
<div {...props} className={cn(avaActionReviewStyles().head(), className)}>
|
|
177
|
+
{children}
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface AvaActionReviewTitleProps extends HTMLAttributes<HTMLDivElement> {
|
|
183
|
+
/** What Ava is about to do, in one word ("Update", "Create"). */
|
|
184
|
+
eyebrow?: ReactNode
|
|
185
|
+
/** Sits at the line's end (the upsert's Change). */
|
|
186
|
+
action?: ReactNode
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function AvaActionReviewTitle({
|
|
190
|
+
eyebrow,
|
|
191
|
+
action,
|
|
192
|
+
className,
|
|
193
|
+
children,
|
|
194
|
+
...props
|
|
195
|
+
}: AvaActionReviewTitleProps) {
|
|
196
|
+
const styles = avaActionReviewStyles()
|
|
197
|
+
return (
|
|
198
|
+
<div {...props} className={cn(styles.title(), className)}>
|
|
199
|
+
{eyebrow ? <span className={styles.eyebrow()}>{eyebrow}</span> : null}
|
|
200
|
+
<span className={styles.titleText()}>{children}</span>
|
|
201
|
+
{action ? <span className={styles.titleAction()}>{action}</span> : null}
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function AvaActionReviewReason({
|
|
207
|
+
className,
|
|
208
|
+
children,
|
|
209
|
+
...props
|
|
210
|
+
}: HTMLAttributes<HTMLParagraphElement>) {
|
|
211
|
+
return (
|
|
212
|
+
<p {...props} className={cn(avaActionReviewStyles().reason(), className)}>
|
|
213
|
+
{children}
|
|
214
|
+
</p>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface AvaActionReviewSummaryProps extends HTMLAttributes<HTMLDivElement> {
|
|
219
|
+
/** A short machine fact beside More ("5 suggested"). */
|
|
220
|
+
meta?: ReactNode
|
|
221
|
+
moreLabel?: string
|
|
222
|
+
lessLabel?: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Ava's own words, clamped to two lines; More appears only when they run longer. */
|
|
226
|
+
export function AvaActionReviewSummary({
|
|
227
|
+
meta,
|
|
228
|
+
moreLabel = 'More',
|
|
229
|
+
lessLabel = 'Less',
|
|
230
|
+
className,
|
|
231
|
+
children,
|
|
232
|
+
...props
|
|
233
|
+
}: AvaActionReviewSummaryProps) {
|
|
234
|
+
const styles = avaActionReviewStyles()
|
|
235
|
+
const textRef = useRef<HTMLParagraphElement>(null)
|
|
236
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
237
|
+
const [isClamped, setIsClamped] = useState(false)
|
|
238
|
+
|
|
239
|
+
// Whether the text overflows depends on the host's width, which can change
|
|
240
|
+
// (a resizable column), so it is measured rather than guessed from length.
|
|
241
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `children` changes what overflows.
|
|
242
|
+
useLayoutEffect(() => {
|
|
243
|
+
const text = textRef.current
|
|
244
|
+
if (!text || isOpen) return
|
|
245
|
+
const measure = () => setIsClamped(text.scrollHeight > text.clientHeight + 1)
|
|
246
|
+
measure()
|
|
247
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
248
|
+
const observer = new ResizeObserver(measure)
|
|
249
|
+
observer.observe(text)
|
|
250
|
+
return () => observer.disconnect()
|
|
251
|
+
}, [isOpen, children])
|
|
252
|
+
|
|
253
|
+
const canToggle = isOpen || isClamped
|
|
254
|
+
return (
|
|
255
|
+
<div {...props} className={cn('flex flex-col gap-1', className)}>
|
|
256
|
+
{children ? (
|
|
257
|
+
<p ref={textRef} className={cn(styles.summary(), !isOpen && 'line-clamp-2')}>
|
|
258
|
+
{children}
|
|
259
|
+
</p>
|
|
260
|
+
) : null}
|
|
261
|
+
{canToggle || meta ? (
|
|
262
|
+
<div className={styles.summaryMore()}>
|
|
263
|
+
{canToggle ? (
|
|
264
|
+
<Button
|
|
265
|
+
variant="link"
|
|
266
|
+
size="sm"
|
|
267
|
+
className="h-auto py-0 text-[12.5px]"
|
|
268
|
+
aria-expanded={isOpen}
|
|
269
|
+
onPress={() => setIsOpen((open) => !open)}
|
|
270
|
+
>
|
|
271
|
+
{isOpen ? lessLabel : moreLabel}
|
|
272
|
+
</Button>
|
|
273
|
+
) : null}
|
|
274
|
+
{meta ? <span className={styles.summaryMeta()}>{meta}</span> : null}
|
|
275
|
+
</div>
|
|
276
|
+
) : null}
|
|
277
|
+
</div>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Something Ava could not do, kept in sight rather than folded away. */
|
|
282
|
+
export function AvaActionReviewProblem({
|
|
283
|
+
className,
|
|
284
|
+
children,
|
|
285
|
+
...props
|
|
286
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
287
|
+
return (
|
|
288
|
+
<div {...props} className={cn(avaActionReviewStyles().problem(), className)}>
|
|
289
|
+
<TriangleAlert className="mt-[3px] size-3 shrink-0" aria-hidden />
|
|
290
|
+
<span className="min-w-0">{children}</span>
|
|
291
|
+
</div>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function AvaActionReviewBody({
|
|
296
|
+
className,
|
|
297
|
+
children,
|
|
298
|
+
...props
|
|
299
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
300
|
+
return (
|
|
301
|
+
<div {...props} className={cn(avaActionReviewStyles().body(), className)}>
|
|
302
|
+
{children}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function AvaActionReviewRows({
|
|
308
|
+
className,
|
|
309
|
+
children,
|
|
310
|
+
...props
|
|
311
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
312
|
+
return (
|
|
313
|
+
<div {...props} className={cn(avaActionReviewStyles().rows(), className)}>
|
|
314
|
+
{children}
|
|
315
|
+
</div>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface AvaActionReviewErrorsProps extends HTMLAttributes<HTMLDivElement> {
|
|
320
|
+
/** Problems that are not tied to one field. Renders nothing when empty. */
|
|
321
|
+
errors: ReadonlyArray<string>
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function AvaActionReviewErrors({ errors, className, ...props }: AvaActionReviewErrorsProps) {
|
|
325
|
+
if (errors.length === 0) return null
|
|
326
|
+
return (
|
|
327
|
+
<div {...props} role="alert" className={cn(avaActionReviewStyles().errors(), className)}>
|
|
328
|
+
<AlertCircle className="mt-px size-4 shrink-0" aria-hidden />
|
|
329
|
+
<ul className="flex min-w-0 flex-col gap-1">
|
|
330
|
+
{errors.map((error) => (
|
|
331
|
+
<li key={error}>{error}</li>
|
|
332
|
+
))}
|
|
333
|
+
</ul>
|
|
334
|
+
</div>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function AvaActionReviewFooter({
|
|
339
|
+
className,
|
|
340
|
+
children,
|
|
341
|
+
...props
|
|
342
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
343
|
+
return (
|
|
344
|
+
<div {...props} className={cn(avaActionReviewStyles().footer(), className)}>
|
|
345
|
+
{children}
|
|
346
|
+
</div>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The decision buttons, kept together at the right when the foot wraps. */
|
|
351
|
+
export function AvaActionReviewActions({
|
|
352
|
+
className,
|
|
353
|
+
children,
|
|
354
|
+
...props
|
|
355
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
356
|
+
return (
|
|
357
|
+
<div {...props} className={cn(avaActionReviewStyles().actions(), className)}>
|
|
358
|
+
{children}
|
|
359
|
+
</div>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A full-width line above the foot's buttons (why Apply is blocked). */
|
|
364
|
+
export function AvaActionReviewHint({
|
|
365
|
+
className,
|
|
366
|
+
children,
|
|
367
|
+
...props
|
|
368
|
+
}: HTMLAttributes<HTMLSpanElement>) {
|
|
369
|
+
return (
|
|
370
|
+
<span {...props} className={cn(avaActionReviewStyles().hint(), className)}>
|
|
371
|
+
{children}
|
|
372
|
+
</span>
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* ---------- add a field ---------- */
|
|
377
|
+
|
|
378
|
+
export interface AvaActionReviewAddFieldOption {
|
|
379
|
+
id: string
|
|
380
|
+
label: string
|
|
381
|
+
icon?: ReactNode
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface AvaActionReviewAddFieldGroup {
|
|
385
|
+
/** Shown above the group; omit for a single, unlabelled list. */
|
|
386
|
+
label?: string
|
|
387
|
+
options: ReadonlyArray<AvaActionReviewAddFieldOption>
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface AvaActionReviewAddFieldProps {
|
|
391
|
+
groups: ReadonlyArray<AvaActionReviewAddFieldGroup>
|
|
392
|
+
onPick: (id: string) => void
|
|
393
|
+
/** Identifies the trigger, so a host can hand focus back to it. */
|
|
394
|
+
id?: string
|
|
395
|
+
isDisabled?: boolean
|
|
396
|
+
label?: string
|
|
397
|
+
searchLabel?: string
|
|
398
|
+
emptyText?: string
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The foot's "Add field": a filterable list of what the proposer left out. */
|
|
402
|
+
export function AvaActionReviewAddField({
|
|
403
|
+
groups,
|
|
404
|
+
onPick,
|
|
405
|
+
id,
|
|
406
|
+
isDisabled = false,
|
|
407
|
+
label = 'Add field',
|
|
408
|
+
searchLabel = 'Find a field',
|
|
409
|
+
emptyText = 'No other fields match.',
|
|
410
|
+
}: AvaActionReviewAddFieldProps) {
|
|
411
|
+
const { contains } = useFilter({ sensitivity: 'base' })
|
|
412
|
+
return (
|
|
413
|
+
<DialogTrigger>
|
|
414
|
+
<Button
|
|
415
|
+
{...(id !== undefined ? { id } : {})}
|
|
416
|
+
variant="ghost"
|
|
417
|
+
size="sm"
|
|
418
|
+
className="px-1.5"
|
|
419
|
+
isDisabled={isDisabled}
|
|
420
|
+
>
|
|
421
|
+
<Plus aria-hidden />
|
|
422
|
+
{label}
|
|
423
|
+
</Button>
|
|
424
|
+
<AriaPopover
|
|
425
|
+
placement="top start"
|
|
426
|
+
offset={6}
|
|
427
|
+
className={cn(
|
|
428
|
+
'w-[260px] max-w-[calc(100vw-2rem)] rounded-md border border-rule bg-surface p-1',
|
|
429
|
+
'shadow-[var(--shadow-float)] outline-none',
|
|
430
|
+
'entering:animate-enter-slide-up exiting:animate-exit-slide-up',
|
|
431
|
+
)}
|
|
432
|
+
>
|
|
433
|
+
<AriaDialog aria-label={label} className="flex max-h-[290px] flex-col outline-none">
|
|
434
|
+
{({ close }) => (
|
|
435
|
+
<Autocomplete filter={contains}>
|
|
436
|
+
<SearchField
|
|
437
|
+
aria-label={searchLabel}
|
|
438
|
+
autoFocus
|
|
439
|
+
className={cn(
|
|
440
|
+
'mb-1 flex shrink-0 items-center gap-1.5 rounded-sm border border-rule px-2 py-1',
|
|
441
|
+
'focus-within:border-accent',
|
|
442
|
+
)}
|
|
443
|
+
>
|
|
444
|
+
<Search className="size-3.5 shrink-0 text-ink-4" aria-hidden />
|
|
445
|
+
<AriaInput
|
|
446
|
+
placeholder={searchLabel}
|
|
447
|
+
className="w-full min-w-0 bg-transparent text-[13px] outline-none placeholder:text-ink-4"
|
|
448
|
+
/>
|
|
449
|
+
</SearchField>
|
|
450
|
+
<Menu
|
|
451
|
+
aria-label={label}
|
|
452
|
+
className="min-h-0 overflow-y-auto [scrollbar-width:thin]"
|
|
453
|
+
onAction={(key) => {
|
|
454
|
+
onPick(String(key))
|
|
455
|
+
close()
|
|
456
|
+
}}
|
|
457
|
+
renderEmptyState={() => (
|
|
458
|
+
<div className="px-2.5 py-2 text-[12.5px] text-ink-3">{emptyText}</div>
|
|
459
|
+
)}
|
|
460
|
+
>
|
|
461
|
+
{groups.map((group, index) => (
|
|
462
|
+
<MenuSection key={group.label ?? index}>
|
|
463
|
+
{group.label ? (
|
|
464
|
+
<Header className="px-2.5 pt-2 pb-1 font-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-4">
|
|
465
|
+
{group.label}
|
|
466
|
+
</Header>
|
|
467
|
+
) : null}
|
|
468
|
+
{group.options.map((option) => (
|
|
469
|
+
<MenuItem
|
|
470
|
+
key={option.id}
|
|
471
|
+
id={option.id}
|
|
472
|
+
textValue={option.label}
|
|
473
|
+
{...(option.icon ? { icon: option.icon } : {})}
|
|
474
|
+
>
|
|
475
|
+
{option.label}
|
|
476
|
+
</MenuItem>
|
|
477
|
+
))}
|
|
478
|
+
</MenuSection>
|
|
479
|
+
))}
|
|
480
|
+
</Menu>
|
|
481
|
+
</Autocomplete>
|
|
482
|
+
)}
|
|
483
|
+
</AriaDialog>
|
|
484
|
+
</AriaPopover>
|
|
485
|
+
</DialogTrigger>
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* ---------- row ---------- */
|
|
490
|
+
|
|
491
|
+
export interface AvaActionReviewRowProps
|
|
492
|
+
extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'children' | 'className'> {
|
|
493
|
+
/** The field's label — rendered in the page-layout field-label style. */
|
|
494
|
+
label: ReactNode
|
|
495
|
+
/** Current value cell (typically a read-only display). Omit for a value
|
|
496
|
+
* that has no "before" — a record being created — and the row shows
|
|
497
|
+
* `to` alone, without the arrow. */
|
|
498
|
+
from?: ReactNode
|
|
499
|
+
/** Proposed value cell (an editor bound to the change, or the rejection text). */
|
|
500
|
+
to: ReactNode
|
|
501
|
+
state?: AvaActionReviewRowState
|
|
502
|
+
/** Keep the two cells one above the other at every width. */
|
|
503
|
+
isLong?: boolean
|
|
504
|
+
/** Why the value is unresolved, or any other one-line note under the cells. */
|
|
505
|
+
hint?: ReactNode
|
|
506
|
+
/** Validation error for the proposed value. */
|
|
507
|
+
errorMessage?: string
|
|
508
|
+
/** Drop this change. Renders the remove button when given (not in `removed`). */
|
|
509
|
+
onRemove?: () => void
|
|
510
|
+
/** Bring a removed change back. Renders the restore button in `removed`. */
|
|
511
|
+
onRestore?: () => void
|
|
512
|
+
/** Accessible names for the two buttons. */
|
|
513
|
+
removeLabel?: string
|
|
514
|
+
restoreLabel?: string
|
|
515
|
+
/** Accessible names for the two cells. */
|
|
516
|
+
fromLabel?: string
|
|
517
|
+
toLabel?: string
|
|
518
|
+
/** Text of the `added` / `unresolved` / `rejected` tags next to the label. */
|
|
519
|
+
addedLabel?: string
|
|
520
|
+
unresolvedLabel?: string
|
|
521
|
+
rejectedLabel?: string
|
|
522
|
+
/** Text shown beside the label while `removed`. */
|
|
523
|
+
removedText?: string
|
|
524
|
+
className?: string
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function AvaActionReviewRow({
|
|
528
|
+
label,
|
|
529
|
+
from,
|
|
530
|
+
to,
|
|
531
|
+
state = 'changed',
|
|
532
|
+
isLong = false,
|
|
533
|
+
hint,
|
|
534
|
+
errorMessage,
|
|
535
|
+
onRemove,
|
|
536
|
+
onRestore,
|
|
537
|
+
removeLabel = 'Remove change',
|
|
538
|
+
restoreLabel = 'Restore change',
|
|
539
|
+
fromLabel = 'Current',
|
|
540
|
+
toLabel = 'New',
|
|
541
|
+
addedLabel = 'added',
|
|
542
|
+
unresolvedLabel = 'needs a value',
|
|
543
|
+
rejectedLabel = 'not possible',
|
|
544
|
+
removedText = 'Kept as it is',
|
|
545
|
+
className,
|
|
546
|
+
...props
|
|
547
|
+
}: AvaActionReviewRowProps) {
|
|
548
|
+
const styles = avaActionReviewRowStyles({ state, isLong })
|
|
549
|
+
const isRemoved = state === 'removed'
|
|
550
|
+
// A rejected row has nothing to compare: `to` is the explanation.
|
|
551
|
+
const hasCells = from !== undefined && state !== 'rejected'
|
|
552
|
+
// A fieldset: the row IS a group of one field's cells (implicit `group`
|
|
553
|
+
// role), reset from the UA's border / padding / min-content width.
|
|
554
|
+
return (
|
|
555
|
+
<fieldset
|
|
556
|
+
{...props}
|
|
557
|
+
data-state={state}
|
|
558
|
+
{...(errorMessage ? { 'aria-invalid': true } : {})}
|
|
559
|
+
className={cn(styles.root(), className)}
|
|
560
|
+
>
|
|
561
|
+
<div className={styles.labelRow()}>
|
|
562
|
+
<span className="truncate">{isRemoved ? <s>{label}</s> : label}</span>
|
|
563
|
+
{isRemoved ? <span className={styles.removed()}>{removedText}</span> : null}
|
|
564
|
+
{state === 'added' ? <span className={styles.tag()}>{addedLabel}</span> : null}
|
|
565
|
+
{state === 'unresolved' ? <span className={styles.tag()}>{unresolvedLabel}</span> : null}
|
|
566
|
+
{state === 'rejected' ? <span className={styles.tag()}>{rejectedLabel}</span> : null}
|
|
567
|
+
<span className="ml-auto flex items-center">
|
|
568
|
+
{isRemoved
|
|
569
|
+
? onRestore && (
|
|
570
|
+
<Button
|
|
571
|
+
variant="ghost"
|
|
572
|
+
size="icon-sm"
|
|
573
|
+
aria-label={restoreLabel}
|
|
574
|
+
onPress={onRestore}
|
|
575
|
+
>
|
|
576
|
+
<Undo2 />
|
|
577
|
+
</Button>
|
|
578
|
+
)
|
|
579
|
+
: onRemove && (
|
|
580
|
+
<Button variant="ghost" size="icon-sm" aria-label={removeLabel} onPress={onRemove}>
|
|
581
|
+
<X />
|
|
582
|
+
</Button>
|
|
583
|
+
)}
|
|
584
|
+
</span>
|
|
585
|
+
</div>
|
|
586
|
+
{isRemoved ? null : hasCells ? (
|
|
587
|
+
<div className={styles.cells()}>
|
|
588
|
+
<div className={styles.from()}>
|
|
589
|
+
<span className="sr-only">{fromLabel}</span>
|
|
590
|
+
{from}
|
|
591
|
+
</div>
|
|
592
|
+
<div className={styles.arrow()} aria-hidden>
|
|
593
|
+
<CornerDownRight className={styles.arrowStacked()} />
|
|
594
|
+
<ArrowRight className={styles.arrowBeside()} />
|
|
595
|
+
</div>
|
|
596
|
+
<div className={styles.to()}>
|
|
597
|
+
<span className="sr-only">{toLabel}</span>
|
|
598
|
+
{to}
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
) : (
|
|
602
|
+
<div className={styles.to()}>{to}</div>
|
|
603
|
+
)}
|
|
604
|
+
{!isRemoved && hint ? <div className={styles.hint()}>{hint}</div> : null}
|
|
605
|
+
{!isRemoved && errorMessage ? (
|
|
606
|
+
<div className={styles.error()} role="alert">
|
|
607
|
+
{errorMessage}
|
|
608
|
+
</div>
|
|
609
|
+
) : null}
|
|
610
|
+
</fieldset>
|
|
611
|
+
)
|
|
612
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AvaActionReviewAddFieldGroup,
|
|
3
|
+
AvaActionReviewAddFieldOption,
|
|
4
|
+
AvaActionReviewAddFieldProps,
|
|
5
|
+
AvaActionReviewErrorsProps,
|
|
6
|
+
AvaActionReviewProps,
|
|
7
|
+
AvaActionReviewRowProps,
|
|
8
|
+
AvaActionReviewRowState,
|
|
9
|
+
AvaActionReviewRowVariants,
|
|
10
|
+
AvaActionReviewSummaryProps,
|
|
11
|
+
AvaActionReviewTitleProps,
|
|
12
|
+
} from './ava-action-review'
|
|
13
|
+
export {
|
|
14
|
+
AvaActionReview,
|
|
15
|
+
AvaActionReviewActions,
|
|
16
|
+
AvaActionReviewAddField,
|
|
17
|
+
AvaActionReviewBody,
|
|
18
|
+
AvaActionReviewErrors,
|
|
19
|
+
AvaActionReviewFooter,
|
|
20
|
+
AvaActionReviewHead,
|
|
21
|
+
AvaActionReviewHint,
|
|
22
|
+
AvaActionReviewProblem,
|
|
23
|
+
AvaActionReviewReason,
|
|
24
|
+
AvaActionReviewRow,
|
|
25
|
+
AvaActionReviewRows,
|
|
26
|
+
AvaActionReviewSummary,
|
|
27
|
+
AvaActionReviewTitle,
|
|
28
|
+
avaActionReviewRowStyles,
|
|
29
|
+
avaActionReviewStyles,
|
|
30
|
+
} from './ava-action-review'
|
|
@@ -5,12 +5,15 @@ import {
|
|
|
5
5
|
} from 'react-aria-components'
|
|
6
6
|
|
|
7
7
|
import { cn } from '../../lib/cn'
|
|
8
|
+
import type { FieldSize } from '../field/field'
|
|
8
9
|
|
|
9
10
|
export type CheckboxProps = Omit<AriaCheckboxProps, 'children'> & {
|
|
10
11
|
children?: ReactNode
|
|
12
|
+
/** `sm` for dense rows. Defaults to `md`. */
|
|
13
|
+
size?: FieldSize
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
export function Checkbox({ children, className, ...props }: CheckboxProps) {
|
|
16
|
+
export function Checkbox({ children, className, size = 'md', ...props }: CheckboxProps) {
|
|
14
17
|
return (
|
|
15
18
|
<AriaCheckbox
|
|
16
19
|
{...props}
|
|
@@ -23,8 +26,9 @@ export function Checkbox({ children, className, ...props }: CheckboxProps) {
|
|
|
23
26
|
// scroll clipping, and focusing it makes the browser scroll a
|
|
24
27
|
// container the user cannot scroll back (the roles permission
|
|
25
28
|
// matrix, where toggling a row threw the page off-screen).
|
|
26
|
-
'group relative inline-flex items-center
|
|
27
|
-
'
|
|
29
|
+
'group relative inline-flex items-center cursor-pointer',
|
|
30
|
+
size === 'sm' ? 'gap-2 text-[13px]' : 'gap-2.5 text-[14px]',
|
|
31
|
+
'font-sans text-ink-2 select-none',
|
|
28
32
|
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
29
33
|
typeof className === 'function' ? className(renderProps) : className,
|
|
30
34
|
)
|
|
@@ -36,7 +40,8 @@ export function Checkbox({ children, className, ...props }: CheckboxProps) {
|
|
|
36
40
|
aria-hidden
|
|
37
41
|
className={cn(
|
|
38
42
|
'relative inline-flex items-center justify-center shrink-0',
|
|
39
|
-
'size-
|
|
43
|
+
size === 'sm' ? 'size-4' : 'size-[18px]',
|
|
44
|
+
'rounded-[4px] border bg-surface',
|
|
40
45
|
'border-ink-4',
|
|
41
46
|
'transition-[background-color,border-color,box-shadow] duration-150',
|
|
42
47
|
// hovered
|
|
@@ -49,12 +54,13 @@ export function Checkbox({ children, className, ...props }: CheckboxProps) {
|
|
|
49
54
|
)}
|
|
50
55
|
>
|
|
51
56
|
{renderProps.isIndeterminate ? (
|
|
52
|
-
<span className=
|
|
57
|
+
<span className={cn('block h-px bg-surface', size === 'sm' ? 'w-2' : 'w-2.5')} />
|
|
53
58
|
) : (
|
|
54
59
|
<svg
|
|
55
60
|
viewBox="0 0 12 12"
|
|
56
61
|
className={cn(
|
|
57
|
-
'size-
|
|
62
|
+
size === 'sm' ? 'size-2.5' : 'size-3',
|
|
63
|
+
'text-surface transition-opacity duration-150',
|
|
58
64
|
renderProps.isSelected ? 'opacity-100' : 'opacity-0',
|
|
59
65
|
)}
|
|
60
66
|
aria-hidden
|