@proteos/ui 0.57.0 → 0.58.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 +562 -193
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +341 -23
- package/dist/index.d.ts +341 -23
- package/dist/index.js +550 -195
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/badge-multi-select/badge-multi-select.tsx +1 -1
- package/src/components/checkbox/checkbox-group.tsx +4 -1
- package/src/components/color-input/color-input.tsx +1 -1
- package/src/components/combobox/combobox.tsx +1 -1
- package/src/components/command/command-hint.tsx +1 -4
- package/src/components/command/command.stories.tsx +2 -2
- package/src/components/command/command.test.tsx +65 -3
- package/src/components/command/command.tsx +52 -7
- package/src/components/command/index.ts +16 -8
- package/src/components/currency-input/currency-input.tsx +1 -1
- package/src/components/date-picker/date-picker.tsx +1 -1
- package/src/components/date-time-picker/date-time-picker.tsx +1 -1
- package/src/components/field/field.tsx +6 -0
- package/src/components/icon-picker/icon-picker.tsx +1 -1
- package/src/components/input/input.tsx +1 -1
- package/src/components/modal/modal.test.tsx +9 -0
- package/src/components/modal/modal.tsx +13 -1
- package/src/components/multi-select/multi-select.tsx +1 -1
- package/src/components/progress-bar/progress-bar.stories.tsx +3 -1
- package/src/components/progress-bar/progress-bar.tsx +2 -0
- package/src/components/progress-ring/index.ts +2 -0
- package/src/components/progress-ring/progress-ring.stories.tsx +82 -0
- package/src/components/progress-ring/progress-ring.test.tsx +47 -0
- package/src/components/progress-ring/progress-ring.tsx +112 -0
- package/src/components/radio-group/radio-group.tsx +4 -1
- package/src/components/record-update/index.ts +17 -0
- package/src/components/record-update/record-update.stories.tsx +141 -0
- package/src/components/record-update/record-update.test.tsx +114 -0
- package/src/components/record-update/record-update.tsx +259 -0
- package/src/components/select/select.tsx +1 -1
- package/src/components/switch/switch.stories.tsx +22 -0
- package/src/components/switch/switch.test.tsx +6 -0
- package/src/components/switch/switch.tsx +40 -3
- package/src/components/tag-input/tag-input.tsx +1 -1
- package/src/components/text-shimmer/index.ts +2 -0
- package/src/components/text-shimmer/text-shimmer.stories.tsx +35 -0
- package/src/components/text-shimmer/text-shimmer.test.tsx +16 -0
- package/src/components/text-shimmer/text-shimmer.tsx +43 -0
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/time-field/time-field.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/lib/commands/index.ts +6 -2
- package/src/lib/commands/registry.test.ts +54 -1
- package/src/lib/commands/registry.ts +89 -4
- package/src/lib/commands/use-active-commands.test.tsx +56 -0
- package/src/lib/commands/use-active-commands.ts +16 -0
- package/src/lib/commands/use-command.ts +16 -5
- package/src/styles.css +14 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ProgressBar as AriaProgressBar,
|
|
3
|
+
type ProgressBarProps as AriaProgressBarProps,
|
|
4
|
+
} from 'react-aria-components'
|
|
5
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/cn'
|
|
8
|
+
|
|
9
|
+
/* =========================================================================
|
|
10
|
+
ProgressRing — the circular sibling of ProgressBar: RAC's headless
|
|
11
|
+
ProgressBar (state + ARIA) around an SVG track + arc, with the formatted
|
|
12
|
+
value (RAC's `valueText`, percent by default) set beside or inside the
|
|
13
|
+
ring. For the compact "how sure / how far" read beside a title — a
|
|
14
|
+
confidence, a quota, a completion — where a full-width bar would
|
|
15
|
+
dominate the row.
|
|
16
|
+
|
|
17
|
+
Sizes: `xs` is a 14px glyph with the value in mono BESIDE it (digits
|
|
18
|
+
cannot live inside a ring that small); `sm` (28px) and `md` (40px) centre
|
|
19
|
+
the value inside. Determinate (`value`) draws the arc clockwise from
|
|
20
|
+
12 o'clock; both circles carry `pathLength={100}` so the dash length IS
|
|
21
|
+
the percentage. Indeterminate (`isIndeterminate`) spins a quarter arc
|
|
22
|
+
and hides the value. `aria-label` (or `aria-labelledby`) is required —
|
|
23
|
+
the ring has no visible label of its own.
|
|
24
|
+
========================================================================= */
|
|
25
|
+
|
|
26
|
+
export const progressRingStyles = tv({
|
|
27
|
+
slots: {
|
|
28
|
+
root: 'shrink-0 align-middle',
|
|
29
|
+
svg: 'block shrink-0 -rotate-90',
|
|
30
|
+
track: 'fill-none',
|
|
31
|
+
arc: 'fill-none stroke-accent transition-[stroke-dasharray] duration-300 ease-out',
|
|
32
|
+
value: 'font-mono tabular-nums leading-none',
|
|
33
|
+
},
|
|
34
|
+
variants: {
|
|
35
|
+
size: {
|
|
36
|
+
xs: {
|
|
37
|
+
root: 'inline-flex items-center gap-1.5',
|
|
38
|
+
svg: 'size-3.5',
|
|
39
|
+
// A hair darker than the larger rings' track: a 2px hairline on a
|
|
40
|
+
// tinted card vanishes at surface-2.
|
|
41
|
+
track: 'stroke-surface-3',
|
|
42
|
+
value: 'text-[11px] text-ink-4',
|
|
43
|
+
},
|
|
44
|
+
sm: {
|
|
45
|
+
root: 'relative inline-grid place-items-center size-7',
|
|
46
|
+
svg: 'col-start-1 row-start-1 size-full',
|
|
47
|
+
track: 'stroke-surface-2',
|
|
48
|
+
value: 'col-start-1 row-start-1 text-[8.5px] text-ink-2',
|
|
49
|
+
},
|
|
50
|
+
md: {
|
|
51
|
+
root: 'relative inline-grid place-items-center size-10',
|
|
52
|
+
svg: 'col-start-1 row-start-1 size-full',
|
|
53
|
+
track: 'stroke-surface-2',
|
|
54
|
+
value: 'col-start-1 row-start-1 text-[10px] text-ink-2',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
defaultVariants: { size: 'sm' },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** Pixel geometry per size — tv() carries classes, the SVG needs numbers. */
|
|
62
|
+
const GEOMETRY = {
|
|
63
|
+
xs: { box: 14, stroke: 2 },
|
|
64
|
+
sm: { box: 28, stroke: 2.5 },
|
|
65
|
+
md: { box: 40, stroke: 3 },
|
|
66
|
+
} as const
|
|
67
|
+
|
|
68
|
+
export type ProgressRingVariants = VariantProps<typeof progressRingStyles>
|
|
69
|
+
export type ProgressRingProps = Omit<AriaProgressBarProps, 'children' | 'className'> &
|
|
70
|
+
ProgressRingVariants & {
|
|
71
|
+
className?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function ProgressRing({ size, className, ...props }: ProgressRingProps) {
|
|
75
|
+
const resolvedSize = size ?? 'sm'
|
|
76
|
+
const { root, svg, track, arc, value } = progressRingStyles({ size: resolvedSize })
|
|
77
|
+
const { box, stroke } = GEOMETRY[resolvedSize]
|
|
78
|
+
const center = box / 2
|
|
79
|
+
const radius = (box - stroke) / 2
|
|
80
|
+
return (
|
|
81
|
+
<AriaProgressBar {...props} className={cn(root(), className)}>
|
|
82
|
+
{({ percentage, valueText, isIndeterminate }) => {
|
|
83
|
+
const shown = isIndeterminate ? 25 : Math.max(0, Math.min(100, percentage ?? 0))
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<svg
|
|
87
|
+
aria-hidden
|
|
88
|
+
viewBox={`0 0 ${box} ${box}`}
|
|
89
|
+
className={cn(svg(), isIndeterminate && 'animate-spin')}
|
|
90
|
+
>
|
|
91
|
+
<circle cx={center} cy={center} r={radius} strokeWidth={stroke} className={track()} />
|
|
92
|
+
{/* A zero-length dash with round caps would still paint a dot. */}
|
|
93
|
+
{shown > 0 ? (
|
|
94
|
+
<circle
|
|
95
|
+
cx={center}
|
|
96
|
+
cy={center}
|
|
97
|
+
r={radius}
|
|
98
|
+
strokeWidth={stroke}
|
|
99
|
+
strokeLinecap="round"
|
|
100
|
+
pathLength={100}
|
|
101
|
+
strokeDasharray={`${shown} 100`}
|
|
102
|
+
className={arc()}
|
|
103
|
+
/>
|
|
104
|
+
) : null}
|
|
105
|
+
</svg>
|
|
106
|
+
{isIndeterminate ? null : <span className={value()}>{valueText}</span>}
|
|
107
|
+
</>
|
|
108
|
+
)
|
|
109
|
+
}}
|
|
110
|
+
</AriaProgressBar>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -39,7 +39,10 @@ export function RadioGroup({
|
|
|
39
39
|
{...props}
|
|
40
40
|
orientation={orientation}
|
|
41
41
|
className={(renderProps) =>
|
|
42
|
-
cn(
|
|
42
|
+
cn(
|
|
43
|
+
'grid gap-2 content-start',
|
|
44
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
45
|
+
)
|
|
43
46
|
}
|
|
44
47
|
>
|
|
45
48
|
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
RecordUpdateErrorsProps,
|
|
3
|
+
RecordUpdateProps,
|
|
4
|
+
RecordUpdateRowProps,
|
|
5
|
+
RecordUpdateRowState,
|
|
6
|
+
RecordUpdateRowVariants,
|
|
7
|
+
} from './record-update'
|
|
8
|
+
export {
|
|
9
|
+
RecordUpdate,
|
|
10
|
+
RecordUpdateErrors,
|
|
11
|
+
RecordUpdateFooter,
|
|
12
|
+
RecordUpdateRow,
|
|
13
|
+
RecordUpdateRows,
|
|
14
|
+
RecordUpdateSummary,
|
|
15
|
+
recordUpdateRowStyles,
|
|
16
|
+
recordUpdateStyles,
|
|
17
|
+
} from './record-update'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { type ReactNode, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { Badge } from '../badge/badge'
|
|
5
|
+
import { Button } from '../button/button'
|
|
6
|
+
import { Input } from '../input/input'
|
|
7
|
+
import { Select } from '../select/select'
|
|
8
|
+
import {
|
|
9
|
+
RecordUpdate,
|
|
10
|
+
RecordUpdateErrors,
|
|
11
|
+
RecordUpdateFooter,
|
|
12
|
+
RecordUpdateRow,
|
|
13
|
+
type RecordUpdateRowState,
|
|
14
|
+
RecordUpdateRows,
|
|
15
|
+
RecordUpdateSummary,
|
|
16
|
+
} from './record-update'
|
|
17
|
+
|
|
18
|
+
const meta: Meta<typeof RecordUpdateRow> = {
|
|
19
|
+
title: 'Components/RecordUpdate',
|
|
20
|
+
component: RecordUpdateRow,
|
|
21
|
+
tags: ['autodocs'],
|
|
22
|
+
argTypes: {
|
|
23
|
+
state: { control: 'radio', options: ['changed', 'unresolved', 'rejected', 'removed'] },
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof RecordUpdateRow>
|
|
29
|
+
|
|
30
|
+
const STATUS_OPTIONS = [
|
|
31
|
+
{ id: 'open', label: 'Open' },
|
|
32
|
+
{ id: 'won', label: 'Won' },
|
|
33
|
+
{ id: 'churned', label: 'Churned' },
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
function ReadOnly({ children }: { children: ReactNode }) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex items-center min-h-9 bg-surface-2 rounded-md px-3 py-2 text-[13.5px] text-ink">
|
|
39
|
+
{children}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Row: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
label: 'Status',
|
|
47
|
+
from: <ReadOnly>Open</ReadOnly>,
|
|
48
|
+
to: <ReadOnly>Churned</ReadOnly>,
|
|
49
|
+
state: 'changed',
|
|
50
|
+
onRemove: () => {},
|
|
51
|
+
onRestore: () => {},
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const Review: Story = {
|
|
56
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
57
|
+
render: () => {
|
|
58
|
+
const [status, setStatus] = useState('churned')
|
|
59
|
+
const [amount, setAmount] = useState('13200')
|
|
60
|
+
const [removed, setRemoved] = useState<Record<string, boolean>>({})
|
|
61
|
+
const stateFor = (key: string, fallback: RecordUpdateRowState = 'changed') =>
|
|
62
|
+
removed[key] ? 'removed' : fallback
|
|
63
|
+
const toggle = (key: string, value: boolean) =>
|
|
64
|
+
setRemoved((prev) => ({ ...prev, [key]: value }))
|
|
65
|
+
return (
|
|
66
|
+
<div className="w-[600px] border border-rule rounded-lg bg-surface">
|
|
67
|
+
<RecordUpdate>
|
|
68
|
+
<RecordUpdateSummary>
|
|
69
|
+
Marks the company as churned and raises the amount by 10%.
|
|
70
|
+
</RecordUpdateSummary>
|
|
71
|
+
<RecordUpdateRows>
|
|
72
|
+
<RecordUpdateRow
|
|
73
|
+
label="Status"
|
|
74
|
+
state={stateFor('status')}
|
|
75
|
+
from={
|
|
76
|
+
<ReadOnly>
|
|
77
|
+
<Badge>Open</Badge>
|
|
78
|
+
</ReadOnly>
|
|
79
|
+
}
|
|
80
|
+
to={
|
|
81
|
+
<Select
|
|
82
|
+
aria-label="Status"
|
|
83
|
+
options={STATUS_OPTIONS}
|
|
84
|
+
selectedKey={status}
|
|
85
|
+
onSelectionChange={(key) => setStatus(String(key))}
|
|
86
|
+
/>
|
|
87
|
+
}
|
|
88
|
+
onRemove={() => toggle('status', true)}
|
|
89
|
+
onRestore={() => toggle('status', false)}
|
|
90
|
+
/>
|
|
91
|
+
<RecordUpdateRow
|
|
92
|
+
label="Amount"
|
|
93
|
+
state={stateFor('amount')}
|
|
94
|
+
from={<ReadOnly>12,000</ReadOnly>}
|
|
95
|
+
to={<Input aria-label="Amount" value={amount} onChange={setAmount} />}
|
|
96
|
+
onRemove={() => toggle('amount', true)}
|
|
97
|
+
onRestore={() => toggle('amount', false)}
|
|
98
|
+
/>
|
|
99
|
+
<RecordUpdateRow
|
|
100
|
+
label="Owner"
|
|
101
|
+
state={stateFor('owner', 'unresolved')}
|
|
102
|
+
from={<ReadOnly>Tonio Knuth</ReadOnly>}
|
|
103
|
+
to={<Input aria-label="Owner" placeholder="Pick a user…" />}
|
|
104
|
+
hint="“Julia” is a name, not an id — pick the user."
|
|
105
|
+
onRemove={() => toggle('owner', true)}
|
|
106
|
+
onRestore={() => toggle('owner', false)}
|
|
107
|
+
/>
|
|
108
|
+
<RecordUpdateRow
|
|
109
|
+
label="Tier"
|
|
110
|
+
state="rejected"
|
|
111
|
+
from={
|
|
112
|
+
<ReadOnly>
|
|
113
|
+
<Badge>Silver</Badge>
|
|
114
|
+
</ReadOnly>
|
|
115
|
+
}
|
|
116
|
+
to={
|
|
117
|
+
<span className="text-[13px] text-danger">
|
|
118
|
+
“platinum” is not one of the options (silver, gold).
|
|
119
|
+
</span>
|
|
120
|
+
}
|
|
121
|
+
/>
|
|
122
|
+
<RecordUpdateRow
|
|
123
|
+
label="Website"
|
|
124
|
+
from={<ReadOnly>—</ReadOnly>}
|
|
125
|
+
to={<Input aria-label="Website" value="acme" onChange={() => {}} isInvalid />}
|
|
126
|
+
errorMessage="Must be a valid URL"
|
|
127
|
+
onRemove={() => {}}
|
|
128
|
+
/>
|
|
129
|
+
</RecordUpdateRows>
|
|
130
|
+
<RecordUpdateErrors
|
|
131
|
+
errors={['“Created at” is managed by the platform and cannot be changed.']}
|
|
132
|
+
/>
|
|
133
|
+
<RecordUpdateFooter>
|
|
134
|
+
<Button variant="ghost">Cancel</Button>
|
|
135
|
+
<Button variant="cta">Apply 2 changes</Button>
|
|
136
|
+
</RecordUpdateFooter>
|
|
137
|
+
</RecordUpdate>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
},
|
|
141
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
RecordUpdate,
|
|
7
|
+
RecordUpdateErrors,
|
|
8
|
+
RecordUpdateRow,
|
|
9
|
+
RecordUpdateRows,
|
|
10
|
+
RecordUpdateSummary,
|
|
11
|
+
} from './record-update'
|
|
12
|
+
|
|
13
|
+
describe('RecordUpdateRow', () => {
|
|
14
|
+
it('names the group after the label and shows both cells', () => {
|
|
15
|
+
render(<RecordUpdateRow label="Status" from={<span>Open</span>} to={<span>Won</span>} />)
|
|
16
|
+
expect(screen.getByRole('group')).toHaveAttribute('data-state', 'changed')
|
|
17
|
+
expect(screen.getByText('Status')).toBeInTheDocument()
|
|
18
|
+
expect(screen.getByText('Open')).toBeInTheDocument()
|
|
19
|
+
expect(screen.getByText('Won')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('offers remove while changed and restore once removed', async () => {
|
|
23
|
+
const user = userEvent.setup()
|
|
24
|
+
const onRemove = vi.fn()
|
|
25
|
+
const onRestore = vi.fn()
|
|
26
|
+
const { rerender } = render(
|
|
27
|
+
<RecordUpdateRow
|
|
28
|
+
label="Status"
|
|
29
|
+
from="Open"
|
|
30
|
+
to="Won"
|
|
31
|
+
onRemove={onRemove}
|
|
32
|
+
onRestore={onRestore}
|
|
33
|
+
/>,
|
|
34
|
+
)
|
|
35
|
+
await user.click(screen.getByRole('button', { name: 'Remove change' }))
|
|
36
|
+
expect(onRemove).toHaveBeenCalledTimes(1)
|
|
37
|
+
expect(screen.queryByRole('button', { name: 'Restore change' })).not.toBeInTheDocument()
|
|
38
|
+
|
|
39
|
+
rerender(
|
|
40
|
+
<RecordUpdateRow
|
|
41
|
+
label="Status"
|
|
42
|
+
from="Open"
|
|
43
|
+
to="Won"
|
|
44
|
+
state="removed"
|
|
45
|
+
onRemove={onRemove}
|
|
46
|
+
onRestore={onRestore}
|
|
47
|
+
/>,
|
|
48
|
+
)
|
|
49
|
+
expect(screen.queryByText('Won')).not.toBeInTheDocument()
|
|
50
|
+
expect(screen.getByText('Kept as it is')).toBeInTheDocument()
|
|
51
|
+
await user.click(screen.getByRole('button', { name: 'Restore change' }))
|
|
52
|
+
expect(onRestore).toHaveBeenCalledTimes(1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders the proposed value alone when there is no current value', () => {
|
|
56
|
+
const { container } = render(<RecordUpdateRow label="Name" to={<span>Julia</span>} />)
|
|
57
|
+
expect(screen.getByText('Julia')).toBeInTheDocument()
|
|
58
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('tags a rejected row and keeps its cells (the explanation rides in `to`)', () => {
|
|
62
|
+
render(
|
|
63
|
+
<RecordUpdateRow
|
|
64
|
+
label="Created at"
|
|
65
|
+
from="2026-09-01"
|
|
66
|
+
to={<span>Read-only field</span>}
|
|
67
|
+
state="rejected"
|
|
68
|
+
/>,
|
|
69
|
+
)
|
|
70
|
+
expect(screen.getByRole('group')).toHaveAttribute('data-state', 'rejected')
|
|
71
|
+
expect(screen.getByText('not possible')).toBeInTheDocument()
|
|
72
|
+
expect(screen.getByText('Read-only field')).toBeInTheDocument()
|
|
73
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('tags an unresolved row and shows the hint; an error is an alert', () => {
|
|
77
|
+
render(
|
|
78
|
+
<RecordUpdateRow
|
|
79
|
+
label="Owner"
|
|
80
|
+
from="Tonio"
|
|
81
|
+
to={<input aria-label="Owner" />}
|
|
82
|
+
state="unresolved"
|
|
83
|
+
hint="Pick the user."
|
|
84
|
+
errorMessage="Required"
|
|
85
|
+
/>,
|
|
86
|
+
)
|
|
87
|
+
expect(screen.getByRole('group')).toHaveAttribute('data-state', 'unresolved')
|
|
88
|
+
expect(screen.getByText('needs a value')).toBeInTheDocument()
|
|
89
|
+
expect(screen.getByText('Pick the user.')).toBeInTheDocument()
|
|
90
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Required')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('RecordUpdate', () => {
|
|
95
|
+
it('composes summary, rows and the general errors alert', () => {
|
|
96
|
+
render(
|
|
97
|
+
<RecordUpdate>
|
|
98
|
+
<RecordUpdateSummary>Two changes.</RecordUpdateSummary>
|
|
99
|
+
<RecordUpdateRows>
|
|
100
|
+
<RecordUpdateRow label="Status" from="Open" to="Won" />
|
|
101
|
+
</RecordUpdateRows>
|
|
102
|
+
<RecordUpdateErrors errors={['Created at cannot be changed.']} />
|
|
103
|
+
</RecordUpdate>,
|
|
104
|
+
)
|
|
105
|
+
expect(screen.getByText('Two changes.')).toBeInTheDocument()
|
|
106
|
+
expect(screen.getByRole('group', { name: '' })).toBeInTheDocument()
|
|
107
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Created at cannot be changed.')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('renders no errors block when the list is empty', () => {
|
|
111
|
+
render(<RecordUpdateErrors errors={[]} />)
|
|
112
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { AlertCircle, ArrowRight, Undo2, X } from 'lucide-react'
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../lib/cn'
|
|
6
|
+
import { Button } from '../button/button'
|
|
7
|
+
|
|
8
|
+
/* =========================================================================
|
|
9
|
+
RecordUpdate — a proposed set of field changes to ONE record, laid out
|
|
10
|
+
as a mini page: a summary line, one row per field (`from → to`), the
|
|
11
|
+
problems the proposer ran into, and a footer with the decision buttons.
|
|
12
|
+
Chrome only — the consumer renders the two cells of every row (a
|
|
13
|
+
read-only display on the left, an editor on the right), so the same
|
|
14
|
+
surface reviews an AI-prepared record update in the command palette, in
|
|
15
|
+
a chat client tool, or in a prepared-action popover.
|
|
16
|
+
|
|
17
|
+
Slot composition (Card pattern): the root carries no padding; each slot
|
|
18
|
+
owns its own spacing so they compose freely.
|
|
19
|
+
|
|
20
|
+
<RecordUpdate>
|
|
21
|
+
<RecordUpdateSummary>…</RecordUpdateSummary>
|
|
22
|
+
<RecordUpdateRows>
|
|
23
|
+
<RecordUpdateRow label from to state … />
|
|
24
|
+
</RecordUpdateRows>
|
|
25
|
+
<RecordUpdateErrors errors={[…]} />
|
|
26
|
+
<RecordUpdateFooter>…buttons…</RecordUpdateFooter>
|
|
27
|
+
</RecordUpdate>
|
|
28
|
+
|
|
29
|
+
Row states:
|
|
30
|
+
changed — a value the reviewer can still edit or drop.
|
|
31
|
+
unresolved — the proposer knew WHICH field but not the value; the
|
|
32
|
+
right cell is expected to be an empty editor and `hint`
|
|
33
|
+
says why. Consumers block "apply" while any row is here.
|
|
34
|
+
rejected — the proposer could NOT do what was asked for this field
|
|
35
|
+
(read-only, no such option …); the right cell carries the
|
|
36
|
+
explanation and nothing is applied for the row.
|
|
37
|
+
removed — dropped from the change set; collapses to one line with a
|
|
38
|
+
restore button so the decision stays reversible.
|
|
39
|
+
========================================================================= */
|
|
40
|
+
|
|
41
|
+
export const recordUpdateStyles = tv({
|
|
42
|
+
slots: {
|
|
43
|
+
root: 'flex flex-col min-w-0 text-ink',
|
|
44
|
+
summary: 'px-4 pt-3 pb-1 text-[13px] text-ink-3',
|
|
45
|
+
rows: 'flex flex-col px-4 divide-y divide-rule',
|
|
46
|
+
errors: [
|
|
47
|
+
'mx-4 my-3 flex gap-2 rounded-md px-3 py-2 text-[13px] text-danger',
|
|
48
|
+
'bg-[color-mix(in_oklab,var(--color-danger)_8%,var(--color-surface))]',
|
|
49
|
+
],
|
|
50
|
+
footer: 'flex items-center justify-end gap-2 px-4 py-3',
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export const recordUpdateRowStyles = tv({
|
|
55
|
+
slots: {
|
|
56
|
+
// fieldset resets (UA border / padding / min-content width) included.
|
|
57
|
+
root: 'flex flex-col gap-1.5 m-0 border-0 px-0 py-2.5 min-w-0',
|
|
58
|
+
labelRow: [
|
|
59
|
+
'flex items-center gap-2 min-h-7',
|
|
60
|
+
'font-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-4',
|
|
61
|
+
],
|
|
62
|
+
tag: 'normal-case tracking-normal text-[10px] rounded-full px-1.5 py-px',
|
|
63
|
+
cells:
|
|
64
|
+
'grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 sm:gap-3',
|
|
65
|
+
from: 'min-w-0 opacity-70',
|
|
66
|
+
arrow: 'size-3.5 shrink-0 text-ink-4 rotate-90 sm:rotate-0 justify-self-center',
|
|
67
|
+
to: 'min-w-0',
|
|
68
|
+
hint: 'text-[12px] text-ink-4',
|
|
69
|
+
error: 'text-[12px] text-danger',
|
|
70
|
+
removed: 'text-[13px] italic text-ink-4',
|
|
71
|
+
},
|
|
72
|
+
variants: {
|
|
73
|
+
state: {
|
|
74
|
+
changed: {},
|
|
75
|
+
unresolved: {
|
|
76
|
+
tag: 'text-warning bg-[color-mix(in_oklab,var(--color-warning)_12%,var(--color-surface))]',
|
|
77
|
+
},
|
|
78
|
+
rejected: {
|
|
79
|
+
tag: 'text-danger bg-[color-mix(in_oklab,var(--color-danger)_10%,var(--color-surface))]',
|
|
80
|
+
},
|
|
81
|
+
removed: { labelRow: 'line-through decoration-ink-4/60' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
defaultVariants: { state: 'changed' },
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
export type RecordUpdateRowVariants = VariantProps<typeof recordUpdateRowStyles>
|
|
88
|
+
export type RecordUpdateRowState = NonNullable<RecordUpdateRowVariants['state']>
|
|
89
|
+
|
|
90
|
+
/* ---------- root + slots ---------- */
|
|
91
|
+
|
|
92
|
+
export type RecordUpdateProps = HTMLAttributes<HTMLDivElement>
|
|
93
|
+
|
|
94
|
+
export function RecordUpdate({ className, children, ...props }: RecordUpdateProps) {
|
|
95
|
+
return (
|
|
96
|
+
<div {...props} className={cn(recordUpdateStyles().root(), className)}>
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function RecordUpdateSummary({
|
|
103
|
+
className,
|
|
104
|
+
children,
|
|
105
|
+
...props
|
|
106
|
+
}: HTMLAttributes<HTMLElement>) {
|
|
107
|
+
return (
|
|
108
|
+
<p {...props} className={cn(recordUpdateStyles().summary(), className)}>
|
|
109
|
+
{children}
|
|
110
|
+
</p>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function RecordUpdateRows({
|
|
115
|
+
className,
|
|
116
|
+
children,
|
|
117
|
+
...props
|
|
118
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
119
|
+
return (
|
|
120
|
+
<div {...props} className={cn(recordUpdateStyles().rows(), className)}>
|
|
121
|
+
{children}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RecordUpdateErrorsProps extends HTMLAttributes<HTMLDivElement> {
|
|
127
|
+
/** Problems that are not tied to one field. Renders nothing when empty. */
|
|
128
|
+
errors: ReadonlyArray<string>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function RecordUpdateErrors({ errors, className, ...props }: RecordUpdateErrorsProps) {
|
|
132
|
+
if (errors.length === 0) return null
|
|
133
|
+
return (
|
|
134
|
+
<div {...props} role="alert" className={cn(recordUpdateStyles().errors(), className)}>
|
|
135
|
+
<AlertCircle className="size-4 shrink-0 mt-px" aria-hidden />
|
|
136
|
+
<ul className="flex flex-col gap-1 min-w-0">
|
|
137
|
+
{errors.map((error) => (
|
|
138
|
+
<li key={error}>{error}</li>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function RecordUpdateFooter({
|
|
146
|
+
className,
|
|
147
|
+
children,
|
|
148
|
+
...props
|
|
149
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
150
|
+
return (
|
|
151
|
+
<div {...props} className={cn(recordUpdateStyles().footer(), className)}>
|
|
152
|
+
{children}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ---------- row ---------- */
|
|
158
|
+
|
|
159
|
+
export interface RecordUpdateRowProps
|
|
160
|
+
extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'children' | 'className'> {
|
|
161
|
+
/** The field's label — rendered in the page-layout field-label style. */
|
|
162
|
+
label: ReactNode
|
|
163
|
+
/** Current value cell (typically a read-only display). Omit for a value
|
|
164
|
+
* that has no "before" — a record being created — and the row shows
|
|
165
|
+
* `to` alone, without the arrow. */
|
|
166
|
+
from?: ReactNode
|
|
167
|
+
/** Proposed value cell (an editor bound to the change, or the rejection text). */
|
|
168
|
+
to: ReactNode
|
|
169
|
+
state?: RecordUpdateRowState
|
|
170
|
+
/** Why the value is unresolved, or any other one-line note under the cells. */
|
|
171
|
+
hint?: ReactNode
|
|
172
|
+
/** Validation error for the proposed value. */
|
|
173
|
+
errorMessage?: string
|
|
174
|
+
/** Drop this change. Renders the remove button when given (not in `removed`). */
|
|
175
|
+
onRemove?: () => void
|
|
176
|
+
/** Bring a removed change back. Renders the restore button in `removed`. */
|
|
177
|
+
onRestore?: () => void
|
|
178
|
+
/** Accessible names for the two buttons. */
|
|
179
|
+
removeLabel?: string
|
|
180
|
+
restoreLabel?: string
|
|
181
|
+
/** Text of the `unresolved` / `rejected` tags next to the label. */
|
|
182
|
+
unresolvedLabel?: string
|
|
183
|
+
rejectedLabel?: string
|
|
184
|
+
/** Text shown in place of the cells while `removed`. */
|
|
185
|
+
removedText?: string
|
|
186
|
+
className?: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function RecordUpdateRow({
|
|
190
|
+
label,
|
|
191
|
+
from,
|
|
192
|
+
to,
|
|
193
|
+
state = 'changed',
|
|
194
|
+
hint,
|
|
195
|
+
errorMessage,
|
|
196
|
+
onRemove,
|
|
197
|
+
onRestore,
|
|
198
|
+
removeLabel = 'Remove change',
|
|
199
|
+
restoreLabel = 'Restore change',
|
|
200
|
+
unresolvedLabel = 'needs a value',
|
|
201
|
+
rejectedLabel = 'not possible',
|
|
202
|
+
removedText = 'Kept as it is',
|
|
203
|
+
className,
|
|
204
|
+
...props
|
|
205
|
+
}: RecordUpdateRowProps) {
|
|
206
|
+
const styles = recordUpdateRowStyles({ state })
|
|
207
|
+
const isRemoved = state === 'removed'
|
|
208
|
+
// A fieldset: the row IS a group of one field's cells (implicit `group`
|
|
209
|
+
// role), reset from the UA's border / padding / min-content width.
|
|
210
|
+
return (
|
|
211
|
+
<fieldset
|
|
212
|
+
{...props}
|
|
213
|
+
data-state={state}
|
|
214
|
+
{...(errorMessage ? { 'aria-invalid': true } : {})}
|
|
215
|
+
className={cn(styles.root(), className)}
|
|
216
|
+
>
|
|
217
|
+
<div className={styles.labelRow()}>
|
|
218
|
+
<span className="truncate">{label}</span>
|
|
219
|
+
{state === 'unresolved' ? <span className={styles.tag()}>{unresolvedLabel}</span> : null}
|
|
220
|
+
{state === 'rejected' ? <span className={styles.tag()}>{rejectedLabel}</span> : null}
|
|
221
|
+
<span className="ml-auto flex items-center">
|
|
222
|
+
{isRemoved
|
|
223
|
+
? onRestore && (
|
|
224
|
+
<Button
|
|
225
|
+
variant="ghost"
|
|
226
|
+
size="icon-sm"
|
|
227
|
+
aria-label={restoreLabel}
|
|
228
|
+
onPress={onRestore}
|
|
229
|
+
>
|
|
230
|
+
<Undo2 />
|
|
231
|
+
</Button>
|
|
232
|
+
)
|
|
233
|
+
: onRemove && (
|
|
234
|
+
<Button variant="ghost" size="icon-sm" aria-label={removeLabel} onPress={onRemove}>
|
|
235
|
+
<X />
|
|
236
|
+
</Button>
|
|
237
|
+
)}
|
|
238
|
+
</span>
|
|
239
|
+
</div>
|
|
240
|
+
{isRemoved ? (
|
|
241
|
+
<div className={styles.removed()}>{removedText}</div>
|
|
242
|
+
) : from === undefined ? (
|
|
243
|
+
<div className={styles.to()}>{to}</div>
|
|
244
|
+
) : (
|
|
245
|
+
<div className={styles.cells()}>
|
|
246
|
+
<div className={styles.from()}>{from}</div>
|
|
247
|
+
<ArrowRight className={styles.arrow()} aria-hidden />
|
|
248
|
+
<div className={styles.to()}>{to}</div>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
{!isRemoved && hint ? <div className={styles.hint()}>{hint}</div> : null}
|
|
252
|
+
{!isRemoved && errorMessage ? (
|
|
253
|
+
<div className={styles.error()} role="alert">
|
|
254
|
+
{errorMessage}
|
|
255
|
+
</div>
|
|
256
|
+
) : null}
|
|
257
|
+
</fieldset>
|
|
258
|
+
)
|
|
259
|
+
}
|