@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.78.0",
3
+ "version": "0.80.0",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -57,7 +57,7 @@
57
57
  "tailwindcss": "^4.2.4",
58
58
  "thinking-orbs": "^0.3.1",
59
59
  "wavesurfer.js": "8.0.5",
60
- "@proteos/sdk": "0.78.0"
60
+ "@proteos/sdk": "0.80.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@biomejs/biome": "^2.4.13",
@@ -0,0 +1,313 @@
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 { GlowFrame } from '../glow-frame/glow-frame'
7
+ import { Input } from '../input/input'
8
+ import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle } from '../modal/modal'
9
+ import { Select } from '../select/select'
10
+ import { Textarea } from '../textarea/textarea'
11
+ import {
12
+ AvaActionReview,
13
+ AvaActionReviewActions,
14
+ AvaActionReviewAddField,
15
+ AvaActionReviewBody,
16
+ AvaActionReviewErrors,
17
+ AvaActionReviewFooter,
18
+ AvaActionReviewHead,
19
+ AvaActionReviewProblem,
20
+ AvaActionReviewReason,
21
+ AvaActionReviewRow,
22
+ type AvaActionReviewRowState,
23
+ AvaActionReviewRows,
24
+ AvaActionReviewSummary,
25
+ AvaActionReviewTitle,
26
+ } from './ava-action-review'
27
+
28
+ const meta: Meta<typeof AvaActionReviewRow> = {
29
+ title: 'Components/AvaActionReview',
30
+ component: AvaActionReviewRow,
31
+ tags: ['autodocs'],
32
+ argTypes: {
33
+ state: { control: 'radio', options: ['changed', 'added', 'unresolved', 'rejected', 'removed'] },
34
+ },
35
+ }
36
+
37
+ export default meta
38
+ type Story = StoryObj<typeof AvaActionReviewRow>
39
+
40
+ const STATUS_OPTIONS = [
41
+ { id: 'open', label: 'Open' },
42
+ { id: 'won', label: 'Won' },
43
+ { id: 'churned', label: 'Churned' },
44
+ ] as const
45
+
46
+ function ReadOnly({ children }: { children: ReactNode }) {
47
+ return (
48
+ <div className="flex min-h-8 items-center rounded-md bg-surface-2 px-2.5 py-1.5 text-[13px] text-ink">
49
+ {children}
50
+ </div>
51
+ )
52
+ }
53
+
54
+ export const Row: Story = {
55
+ args: {
56
+ label: 'Status',
57
+ from: <ReadOnly>Open</ReadOnly>,
58
+ to: <ReadOnly>Churned</ReadOnly>,
59
+ state: 'changed',
60
+ onRemove: () => {},
61
+ onRestore: () => {},
62
+ },
63
+ }
64
+
65
+ const LONG_FROM =
66
+ 'Waiting on legal to confirm the updated data-processing terms before the renewal goes to procurement.'
67
+ const LONG_TO =
68
+ 'Send the countersigned order form to procurement on Monday, then book the handover call with the customer success lead.'
69
+ const SUMMARY =
70
+ 'Marks the renewal as won and raises the amount by 10% to match the signed order form.'
71
+ const ADD_GROUPS = [
72
+ {
73
+ label: 'On this form',
74
+ options: [
75
+ { id: 'probability', label: 'Probability' },
76
+ { id: 'renewal', label: 'Renewal date' },
77
+ ],
78
+ },
79
+ { label: 'More fields', options: [{ id: 'competitor', label: 'Competitor' }] },
80
+ ]
81
+
82
+ function ReviewContent() {
83
+ const [status, setStatus] = useState('churned')
84
+ const [amount, setAmount] = useState('13200')
85
+ const [removed, setRemoved] = useState<Record<string, boolean>>({})
86
+ const stateFor = (key: string, fallback: AvaActionReviewRowState = 'changed') =>
87
+ removed[key] ? 'removed' : fallback
88
+ const toggle = (key: string, value: boolean) => setRemoved((prev) => ({ ...prev, [key]: value }))
89
+ return (
90
+ <AvaActionReview>
91
+ <AvaActionReviewHead>
92
+ <AvaActionReviewTitle eyebrow="Update">Renewal — Acme GmbH</AvaActionReviewTitle>
93
+ <AvaActionReviewSummary>{SUMMARY}</AvaActionReviewSummary>
94
+ </AvaActionReviewHead>
95
+ <AvaActionReviewBody>
96
+ <AvaActionReviewErrors
97
+ errors={['“Created at” is managed by the platform and cannot be changed.']}
98
+ />
99
+ <AvaActionReviewRows>
100
+ <AvaActionReviewRow
101
+ label="Status"
102
+ state={stateFor('status')}
103
+ from={
104
+ <ReadOnly>
105
+ <Badge>Open</Badge>
106
+ </ReadOnly>
107
+ }
108
+ to={
109
+ <Select
110
+ size="sm"
111
+ aria-label="Status"
112
+ options={STATUS_OPTIONS}
113
+ selectedKey={status}
114
+ onSelectionChange={(key) => setStatus(String(key))}
115
+ />
116
+ }
117
+ onRemove={() => toggle('status', true)}
118
+ onRestore={() => toggle('status', false)}
119
+ />
120
+ <AvaActionReviewRow
121
+ label="Amount"
122
+ state={stateFor('amount')}
123
+ from={<ReadOnly>12,000</ReadOnly>}
124
+ to={<Input size="sm" aria-label="Amount" value={amount} onChange={setAmount} />}
125
+ onRemove={() => toggle('amount', true)}
126
+ onRestore={() => toggle('amount', false)}
127
+ />
128
+ <AvaActionReviewRow
129
+ label="Owner"
130
+ state={stateFor('owner', 'unresolved')}
131
+ from={<ReadOnly>Lena Hartmann</ReadOnly>}
132
+ to={<Input size="sm" aria-label="Owner" placeholder="Pick a user…" />}
133
+ hint="“Julia” matches two people. Pick the user."
134
+ onRemove={() => toggle('owner', true)}
135
+ onRestore={() => toggle('owner', false)}
136
+ />
137
+ <AvaActionReviewRow
138
+ label="Tier"
139
+ state="rejected"
140
+ to={
141
+ <span className="text-[12.5px] text-danger">
142
+ “Platinum” is not an option. Choose Silver or Gold.
143
+ </span>
144
+ }
145
+ />
146
+ <AvaActionReviewRow
147
+ label="Next step"
148
+ isLong
149
+ state={stateFor('next')}
150
+ from={<ReadOnly>{LONG_FROM}</ReadOnly>}
151
+ to={<Textarea size="sm" aria-label="Next step" rows={3} defaultValue={LONG_TO} />}
152
+ onRemove={() => toggle('next', true)}
153
+ onRestore={() => toggle('next', false)}
154
+ />
155
+ <AvaActionReviewRow
156
+ label="Probability"
157
+ state="added"
158
+ from={<ReadOnly>60</ReadOnly>}
159
+ to={<Input size="sm" aria-label="Probability" defaultValue="60" />}
160
+ onRemove={() => {}}
161
+ />
162
+ </AvaActionReviewRows>
163
+ </AvaActionReviewBody>
164
+ <AvaActionReviewFooter>
165
+ <AvaActionReviewAddField groups={ADD_GROUPS} onPick={() => {}} />
166
+ <AvaActionReviewActions>
167
+ <Button variant="ghost" size="sm">
168
+ Cancel
169
+ </Button>
170
+ <Button variant="cta" size="sm">
171
+ Apply 3 changes
172
+ </Button>
173
+ </AvaActionReviewActions>
174
+ </AvaActionReviewFooter>
175
+ </AvaActionReview>
176
+ )
177
+ }
178
+
179
+ /** Drag the corner: below 448px of width the cells stack. */
180
+ export const Review: Story = {
181
+ parameters: { controls: { hideNoControlsWarning: true } },
182
+ render: () => (
183
+ <div className="h-[560px] w-[320px] min-w-[240px] max-w-[768px] resize-x overflow-hidden rounded-md border border-rule bg-surface">
184
+ <ReviewContent />
185
+ </div>
186
+ ),
187
+ }
188
+
189
+ export const Wide: Story = {
190
+ parameters: { controls: { hideNoControlsWarning: true } },
191
+ render: () => (
192
+ <div className="h-[640px] w-[672px] overflow-hidden rounded-md border border-rule bg-surface">
193
+ <ReviewContent />
194
+ </div>
195
+ ),
196
+ }
197
+
198
+ /** The foot stays at the bottom right when the rows are few. */
199
+ export const ShortInTallHost: Story = {
200
+ parameters: { controls: { hideNoControlsWarning: true } },
201
+ render: () => (
202
+ <div className="h-[560px] w-[320px] overflow-hidden rounded-md border border-rule bg-surface">
203
+ <AvaActionReview>
204
+ <AvaActionReviewHead>
205
+ <AvaActionReviewTitle
206
+ eyebrow="Update"
207
+ action={
208
+ <Button variant="link" size="sm" className="h-auto py-0 text-[12.5px]">
209
+ Change
210
+ </Button>
211
+ }
212
+ >
213
+ Renewal — Acme GmbH
214
+ </AvaActionReviewTitle>
215
+ <AvaActionReviewReason>
216
+ Same customer and an open stage. The only match.
217
+ </AvaActionReviewReason>
218
+ <AvaActionReviewSummary meta="1 suggested">{SUMMARY}</AvaActionReviewSummary>
219
+ <AvaActionReviewProblem>
220
+ No price book exists for 2027. The amount uses the current list price.
221
+ </AvaActionReviewProblem>
222
+ </AvaActionReviewHead>
223
+ <AvaActionReviewBody>
224
+ <AvaActionReviewRows>
225
+ <AvaActionReviewRow
226
+ label="Amount"
227
+ from={<ReadOnly>12,000</ReadOnly>}
228
+ to={<Input size="sm" aria-label="Amount" defaultValue="13200" />}
229
+ onRemove={() => {}}
230
+ />
231
+ </AvaActionReviewRows>
232
+ </AvaActionReviewBody>
233
+ <AvaActionReviewFooter>
234
+ <AvaActionReviewAddField groups={ADD_GROUPS} onPick={() => {}} />
235
+ <AvaActionReviewActions>
236
+ <Button variant="ghost" size="sm">
237
+ Cancel
238
+ </Button>
239
+ <Button variant="cta" size="sm">
240
+ Apply 1 change
241
+ </Button>
242
+ </AvaActionReviewActions>
243
+ </AvaActionReviewFooter>
244
+ </AvaActionReview>
245
+ </div>
246
+ ),
247
+ }
248
+
249
+ /** The page hosts' modal: a long proposal scrolls in the body, the foot stays. */
250
+ export const InModal: Story = {
251
+ parameters: { controls: { hideNoControlsWarning: true } },
252
+ render: () => (
253
+ <Modal isOpen>
254
+ <ModalContent modalClassName="max-w-2xl">
255
+ <ModalHeader className="px-4 pt-4 pb-2">
256
+ <ModalTitle>Log renewal</ModalTitle>
257
+ </ModalHeader>
258
+ <ModalBody className="overflow-hidden p-0">
259
+ <div className="flex min-h-0 flex-col gap-2">
260
+ <GlowFrame status="ready" className="min-h-0 flex-1 rounded-md">
261
+ <div className="flex min-h-0 flex-1 flex-col">
262
+ <ReviewContent />
263
+ </div>
264
+ </GlowFrame>
265
+ </div>
266
+ </ModalBody>
267
+ </ModalContent>
268
+ </Modal>
269
+ ),
270
+ }
271
+
272
+ /** Press More: the head scrolls, the body keeps its room, the foot stays in the box. */
273
+ export const LongHeadInShortHost: Story = {
274
+ parameters: { controls: { hideNoControlsWarning: true } },
275
+ render: () => (
276
+ <div className="h-[300px] w-[320px] overflow-hidden rounded-md border border-rule bg-surface">
277
+ <AvaActionReview>
278
+ <AvaActionReviewHead>
279
+ <AvaActionReviewTitle eyebrow="Action">Create renewal quote</AvaActionReviewTitle>
280
+ <AvaActionReviewSummary meta="5 suggested">
281
+ {SUMMARY} {SUMMARY} {SUMMARY} {SUMMARY} {SUMMARY}
282
+ </AvaActionReviewSummary>
283
+ <AvaActionReviewProblem>
284
+ No price book exists for 2027. The amount uses the current list price.
285
+ </AvaActionReviewProblem>
286
+ <AvaActionReviewProblem>
287
+ The customer has no billing contact. Add one before you start.
288
+ </AvaActionReviewProblem>
289
+ </AvaActionReviewHead>
290
+ <AvaActionReviewBody>
291
+ <AvaActionReviewRows>
292
+ <AvaActionReviewRow
293
+ label="Amount"
294
+ from={<ReadOnly>12,000</ReadOnly>}
295
+ to={<Input size="sm" aria-label="Amount" defaultValue="13200" />}
296
+ onRemove={() => {}}
297
+ />
298
+ </AvaActionReviewRows>
299
+ </AvaActionReviewBody>
300
+ <AvaActionReviewFooter>
301
+ <AvaActionReviewActions>
302
+ <Button variant="ghost" size="sm">
303
+ Cancel
304
+ </Button>
305
+ <Button variant="cta" size="sm">
306
+ Apply 1 change
307
+ </Button>
308
+ </AvaActionReviewActions>
309
+ </AvaActionReviewFooter>
310
+ </AvaActionReview>
311
+ </div>
312
+ ),
313
+ }
@@ -3,16 +3,17 @@ import userEvent from '@testing-library/user-event'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
 
5
5
  import {
6
- RecordUpdate,
7
- RecordUpdateErrors,
8
- RecordUpdateRow,
9
- RecordUpdateRows,
10
- RecordUpdateSummary,
11
- } from './record-update'
6
+ AvaActionReview,
7
+ AvaActionReviewAddField,
8
+ AvaActionReviewErrors,
9
+ AvaActionReviewRow,
10
+ AvaActionReviewRows,
11
+ AvaActionReviewSummary,
12
+ } from './ava-action-review'
12
13
 
13
- describe('RecordUpdateRow', () => {
14
+ describe('AvaActionReviewRow', () => {
14
15
  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
+ render(<AvaActionReviewRow label="Status" from={<span>Open</span>} to={<span>Won</span>} />)
16
17
  expect(screen.getByRole('group')).toHaveAttribute('data-state', 'changed')
17
18
  expect(screen.getByText('Status')).toBeInTheDocument()
18
19
  expect(screen.getByText('Open')).toBeInTheDocument()
@@ -24,7 +25,7 @@ describe('RecordUpdateRow', () => {
24
25
  const onRemove = vi.fn()
25
26
  const onRestore = vi.fn()
26
27
  const { rerender } = render(
27
- <RecordUpdateRow
28
+ <AvaActionReviewRow
28
29
  label="Status"
29
30
  from="Open"
30
31
  to="Won"
@@ -37,7 +38,7 @@ describe('RecordUpdateRow', () => {
37
38
  expect(screen.queryByRole('button', { name: 'Restore change' })).not.toBeInTheDocument()
38
39
 
39
40
  rerender(
40
- <RecordUpdateRow
41
+ <AvaActionReviewRow
41
42
  label="Status"
42
43
  from="Open"
43
44
  to="Won"
@@ -53,14 +54,14 @@ describe('RecordUpdateRow', () => {
53
54
  })
54
55
 
55
56
  it('renders the proposed value alone when there is no current value', () => {
56
- const { container } = render(<RecordUpdateRow label="Name" to={<span>Julia</span>} />)
57
+ const { container } = render(<AvaActionReviewRow label="Name" to={<span>Julia</span>} />)
57
58
  expect(screen.getByText('Julia')).toBeInTheDocument()
58
59
  expect(container.querySelector('svg')).toBeNull()
59
60
  })
60
61
 
61
- it('tags a rejected row and keeps its cells (the explanation rides in `to`)', () => {
62
+ it('tags a rejected row and shows only the explanation', () => {
62
63
  render(
63
- <RecordUpdateRow
64
+ <AvaActionReviewRow
64
65
  label="Created at"
65
66
  from="2026-09-01"
66
67
  to={<span>Read-only field</span>}
@@ -70,12 +71,13 @@ describe('RecordUpdateRow', () => {
70
71
  expect(screen.getByRole('group')).toHaveAttribute('data-state', 'rejected')
71
72
  expect(screen.getByText('not possible')).toBeInTheDocument()
72
73
  expect(screen.getByText('Read-only field')).toBeInTheDocument()
74
+ expect(screen.queryByText('2026-09-01')).not.toBeInTheDocument()
73
75
  expect(screen.queryByRole('button')).not.toBeInTheDocument()
74
76
  })
75
77
 
76
78
  it('tags an unresolved row and shows the hint; an error is an alert', () => {
77
79
  render(
78
- <RecordUpdateRow
80
+ <AvaActionReviewRow
79
81
  label="Owner"
80
82
  from="Tonio"
81
83
  to={<input aria-label="Owner" />}
@@ -91,16 +93,16 @@ describe('RecordUpdateRow', () => {
91
93
  })
92
94
  })
93
95
 
94
- describe('RecordUpdate', () => {
96
+ describe('AvaActionReview', () => {
95
97
  it('composes summary, rows and the general errors alert', () => {
96
98
  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>,
99
+ <AvaActionReview>
100
+ <AvaActionReviewSummary>Two changes.</AvaActionReviewSummary>
101
+ <AvaActionReviewRows>
102
+ <AvaActionReviewRow label="Status" from="Open" to="Won" />
103
+ </AvaActionReviewRows>
104
+ <AvaActionReviewErrors errors={['Created at cannot be changed.']} />
105
+ </AvaActionReview>,
104
106
  )
105
107
  expect(screen.getByText('Two changes.')).toBeInTheDocument()
106
108
  expect(screen.getByRole('group', { name: '' })).toBeInTheDocument()
@@ -108,7 +110,34 @@ describe('RecordUpdate', () => {
108
110
  })
109
111
 
110
112
  it('renders no errors block when the list is empty', () => {
111
- render(<RecordUpdateErrors errors={[]} />)
113
+ render(<AvaActionReviewErrors errors={[]} />)
112
114
  expect(screen.queryByRole('alert')).not.toBeInTheDocument()
113
115
  })
114
116
  })
117
+
118
+ describe('AvaActionReviewRow labels', () => {
119
+ it('names the two cells for assistive tech and tags an added row', () => {
120
+ render(<AvaActionReviewRow label="Status" from="Open" to="Won" state="added" />)
121
+ expect(screen.getByText('Current')).toBeInTheDocument()
122
+ expect(screen.getByText('New')).toBeInTheDocument()
123
+ expect(screen.getByText('added')).toBeInTheDocument()
124
+ })
125
+ })
126
+
127
+ describe('AvaActionReviewAddField', () => {
128
+ const groups = [
129
+ { label: 'On this form', options: [{ id: 'probability', label: 'Probability' }] },
130
+ { label: 'More fields', options: [{ id: 'competitor', label: 'Competitor' }] },
131
+ ]
132
+
133
+ it('filters as you type and reports the pick', async () => {
134
+ const user = userEvent.setup()
135
+ const onPick = vi.fn()
136
+ render(<AvaActionReviewAddField groups={groups} onPick={onPick} />)
137
+ await user.click(screen.getByRole('button', { name: 'Add field' }))
138
+ await user.type(await screen.findByRole('searchbox', { name: 'Find a field' }), 'comp')
139
+ expect(screen.queryByRole('menuitem', { name: 'Probability' })).not.toBeInTheDocument()
140
+ await user.click(screen.getByRole('menuitem', { name: 'Competitor' }))
141
+ expect(onPick).toHaveBeenCalledWith('competitor')
142
+ })
143
+ })