@proteos/ui 0.79.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.
@@ -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 gap-2.5 cursor-pointer',
27
- 'font-sans text-[14px] text-ink-2 select-none',
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-[18px] rounded-[4px] border bg-surface',
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="block h-px w-2.5 bg-surface" />
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-3 text-surface transition-opacity duration-150',
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