@gntik-ai/templates 0.6.0 → 0.8.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.d.ts +118 -5
- package/dist/index.js +322 -5
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/create-wizard/Flow.test.tsx +592 -0
- package/src/create-wizard/Flow.tsx +199 -0
- package/src/create-wizard/Page.tsx +33 -2
- package/src/create-wizard/Page.types.test.tsx +52 -0
- package/src/create-wizard/examples/ControlledError.tsx +6 -0
- package/src/create-wizard/examples/ControlledIdle.tsx +6 -0
- package/src/create-wizard/examples/ControlledSubmitting.tsx +6 -0
- package/src/create-wizard/examples/ControlledSuccess.tsx +6 -0
- package/src/create-wizard/examples/FlowExample.tsx +60 -0
- package/src/create-wizard/examples/FlowExamples.test.tsx +57 -0
- package/src/create-wizard/examples/Invite.test.tsx +47 -0
- package/src/create-wizard/examples/Invite.tsx +114 -0
- package/src/create-wizard/examples/UncontrolledError.tsx +5 -0
- package/src/create-wizard/examples/UncontrolledPending.tsx +5 -0
- package/src/create-wizard/examples/UncontrolledSuccess.tsx +5 -0
- package/src/create-wizard/examples/labels.ts +29 -0
- package/src/create-wizard/flow-types.ts +84 -0
- package/src/create-wizard/template.meta.ts +63 -4
- package/src/create-wizard/useSubmitState.ts +67 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gntik-ai/templates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "gntik-ui page templates: complete product pages (auth, dashboard, resources, settings, system) on the kit layouts and blocks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
"react-dom": "^19.0.0"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@gntik-ai/blocks": "0.
|
|
38
|
+
"@gntik-ai/blocks": "0.8.0",
|
|
39
39
|
"@gntik-ai/charts": "0.5.0",
|
|
40
|
-
"@gntik-ai/chat": "0.
|
|
40
|
+
"@gntik-ai/chat": "0.7.0",
|
|
41
41
|
"@gntik-ai/editor": "0.5.0",
|
|
42
42
|
"@gntik-ai/flow": "0.5.0",
|
|
43
43
|
"@gntik-ai/icons": "0.5.0",
|
|
44
44
|
"@gntik-ai/tokens": "0.2.1",
|
|
45
|
-
"@gntik-ai/ui": "0.
|
|
45
|
+
"@gntik-ai/ui": "0.7.0",
|
|
46
46
|
"lucide-react": "^1.48.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { CreateWizardSubmitState } from './Flow';
|
|
5
|
+
import { Field, FieldError, FieldLabel, Input, LiveAnnouncer, SimpleSelect } from '@gntik-ai/ui';
|
|
6
|
+
import CreateWizardPage from './Page';
|
|
7
|
+
import type { CreateWizardFlowProps, CreateWizardFlowLabels } from './Flow';
|
|
8
|
+
|
|
9
|
+
export const labels: CreateWizardFlowLabels = {
|
|
10
|
+
title: 'Caller flow',
|
|
11
|
+
exit: 'Caller exit',
|
|
12
|
+
back: 'Caller back',
|
|
13
|
+
next: 'Caller next',
|
|
14
|
+
finish: 'Caller finish',
|
|
15
|
+
pending: 'Caller pending',
|
|
16
|
+
cancel: 'Caller cancel',
|
|
17
|
+
review: 'Caller review',
|
|
18
|
+
reviewTitle: 'Caller review heading',
|
|
19
|
+
edit: (label) => `Caller edit ${label}`,
|
|
20
|
+
emptyValue: 'Caller empty',
|
|
21
|
+
success: () => 'Caller success',
|
|
22
|
+
errorFallback: 'Caller failure',
|
|
23
|
+
openResource: 'Caller resource',
|
|
24
|
+
exitTitle: 'Caller leave?',
|
|
25
|
+
exitDescription: 'Caller discard?',
|
|
26
|
+
exitConfirm: 'Caller leave',
|
|
27
|
+
exitCancel: 'Caller stay',
|
|
28
|
+
steps: 'Caller progress',
|
|
29
|
+
stepsSummary: 'Caller progress summary',
|
|
30
|
+
skipLink: 'Caller skip',
|
|
31
|
+
stepStatusText: {
|
|
32
|
+
complete: 'Caller complete',
|
|
33
|
+
current: 'Caller current',
|
|
34
|
+
upcoming: 'Caller upcoming',
|
|
35
|
+
error: 'Caller error',
|
|
36
|
+
},
|
|
37
|
+
formatStepCount: (position, total) => `Caller position ${position}/${total}`,
|
|
38
|
+
stepAnnouncement: ({ label, position, total }) => `Caller step ${label} ${position}/${total}`,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type Values = { name: string; context: string };
|
|
42
|
+
export function props(overrides: Partial<CreateWizardFlowProps<Values>> = {}): CreateWizardFlowProps<Values> {
|
|
43
|
+
return {
|
|
44
|
+
labels,
|
|
45
|
+
initialValues: { name: '', context: 'Caller context value' },
|
|
46
|
+
onFinish: vi.fn(),
|
|
47
|
+
onExit: vi.fn(),
|
|
48
|
+
flowSteps: [
|
|
49
|
+
{
|
|
50
|
+
id: 'context',
|
|
51
|
+
label: 'Caller context',
|
|
52
|
+
readOnly: true,
|
|
53
|
+
render: ({ values }) => <p>{values.context}</p>,
|
|
54
|
+
validate: () => ({ fieldErrors: { context: 'Ignored read-only error' } }),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'name',
|
|
58
|
+
label: 'Caller name step',
|
|
59
|
+
render: ({ values, setValue, errors }) => (
|
|
60
|
+
<Field invalid={Boolean(errors.name)}>
|
|
61
|
+
<FieldLabel>Caller name</FieldLabel>
|
|
62
|
+
<Input value={values.name} onValueChange={(value) => setValue('name', value)} />
|
|
63
|
+
<FieldError match={Boolean(errors.name)}>{errors.name}</FieldError>
|
|
64
|
+
</Field>
|
|
65
|
+
),
|
|
66
|
+
validate: (values) => ({ fieldErrors: values.name ? {} : { name: 'Caller required' } }),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
summary: (values) => [
|
|
70
|
+
{ stepId: 'context', label: 'Caller context summary', value: values.context },
|
|
71
|
+
{ stepId: 'name', label: 'Caller name summary', value: values.name },
|
|
72
|
+
],
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('renders caller steps, validates on Next and keeps errors live; review edit keeps values and focuses the heading', async () => {
|
|
78
|
+
const user = userEvent.setup();
|
|
79
|
+
render(<CreateWizardPage {...props()} />);
|
|
80
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
81
|
+
expect(screen.getByText('Caller context value')).toBeInTheDocument();
|
|
82
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
83
|
+
expect(screen.queryByText('Caller required')).not.toBeInTheDocument();
|
|
84
|
+
expect(within(screen.getByRole('navigation', { name: labels.steps })).queryByRole('button')).not.toBeInTheDocument();
|
|
85
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
86
|
+
expect(screen.getByText('Caller required')).toBeInTheDocument();
|
|
87
|
+
const name = screen.getByRole('textbox', { name: 'Caller name' });
|
|
88
|
+
expect(name).toHaveFocus();
|
|
89
|
+
expect(name).toHaveAttribute('aria-invalid', 'true');
|
|
90
|
+
await user.type(name, 'A value');
|
|
91
|
+
expect(screen.queryByText('Caller required')).not.toBeInTheDocument();
|
|
92
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
93
|
+
expect(screen.getByRole('heading', { name: labels.reviewTitle })).toBeInTheDocument();
|
|
94
|
+
await user.click(screen.getByRole('button', { name: 'Caller edit Caller name step' }));
|
|
95
|
+
expect(screen.getByRole('heading', { name: 'Caller name step' })).toHaveFocus();
|
|
96
|
+
expect(screen.getByRole('textbox')).toHaveValue('A value');
|
|
97
|
+
await user.click(screen.getByRole('button', { name: labels.back }));
|
|
98
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
99
|
+
expect(screen.getByRole('textbox')).toHaveValue('A value');
|
|
100
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
101
|
+
expect(screen.getByText('A value')).toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('revalidates blocking on entry and caller rerender, announcing each new value once and rescuing disabled Next focus', async () => {
|
|
105
|
+
const user = userEvent.setup();
|
|
106
|
+
const base = props();
|
|
107
|
+
const flowSteps = base.flowSteps.slice(1);
|
|
108
|
+
const view = (blocking?: string) => (
|
|
109
|
+
<CreateWizardPage
|
|
110
|
+
{...base}
|
|
111
|
+
flowSteps={flowSteps.map((step) => ({
|
|
112
|
+
...step,
|
|
113
|
+
validate: (values: Values) => ({ ...step.validate?.(values), blocking }),
|
|
114
|
+
}))}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
const { container, rerender } = render(view('Caller quota'));
|
|
118
|
+
expect(screen.getByRole('button', { name: labels.next })).toBeDisabled();
|
|
119
|
+
const region = container.querySelector('[data-live-announcer="polite"]')!;
|
|
120
|
+
await waitFor(() => expect(region).toHaveTextContent('Caller quota'));
|
|
121
|
+
let changes = 0;
|
|
122
|
+
const observer = new MutationObserver(() => changes++);
|
|
123
|
+
observer.observe(region, { subtree: true, characterData: true, childList: true });
|
|
124
|
+
rerender(view('Caller quota'));
|
|
125
|
+
await act(() => new Promise((resolve) => requestAnimationFrame(resolve)));
|
|
126
|
+
expect(changes).toBe(0);
|
|
127
|
+
rerender(view());
|
|
128
|
+
const next = screen.getByRole('button', { name: labels.next });
|
|
129
|
+
expect(next).toBeEnabled();
|
|
130
|
+
await user.click(next);
|
|
131
|
+
expect(screen.getByText('Caller required')).toBeInTheDocument();
|
|
132
|
+
act(() => next.focus());
|
|
133
|
+
rerender(view('Caller quota changed'));
|
|
134
|
+
expect(next).toBeDisabled();
|
|
135
|
+
expect(screen.getByText('Caller required')).toBeInTheDocument();
|
|
136
|
+
expect(screen.getByRole('heading', { name: 'Caller name step' })).toHaveFocus();
|
|
137
|
+
await waitFor(() => expect(region).toHaveTextContent('Caller quota changed'));
|
|
138
|
+
expect(changes).toBe(1);
|
|
139
|
+
expect(
|
|
140
|
+
within(screen.getByRole('region', { name: 'Caller name step' })).getByText('Caller quota changed'),
|
|
141
|
+
).not.toHaveAttribute('aria-live');
|
|
142
|
+
observer.disconnect();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('commits immediate success, announces once before onComplete closes the flow, preserving the page region', async () => {
|
|
146
|
+
const user = userEvent.setup();
|
|
147
|
+
const onComplete = vi.fn(() => {
|
|
148
|
+
expect(document.querySelector('[data-live-announcer="polite"]')).toHaveTextContent('Caller success');
|
|
149
|
+
rerender(<LiveAnnouncer />);
|
|
150
|
+
});
|
|
151
|
+
const onFinish = vi.fn(async () => ({ id: 'created' }));
|
|
152
|
+
const { rerender, container } = render(
|
|
153
|
+
<LiveAnnouncer>
|
|
154
|
+
<CreateWizardPage {...props({ flowSteps: [], onFinish, onComplete })} />
|
|
155
|
+
</LiveAnnouncer>,
|
|
156
|
+
);
|
|
157
|
+
let changes = 0;
|
|
158
|
+
const observer = new MutationObserver(() => changes++);
|
|
159
|
+
observer.observe(container.querySelector('[data-live-announcer="polite"]')!, {
|
|
160
|
+
subtree: true,
|
|
161
|
+
childList: true,
|
|
162
|
+
characterData: true,
|
|
163
|
+
});
|
|
164
|
+
await user.click(screen.getByRole('button', { name: labels.finish }));
|
|
165
|
+
await waitFor(() => expect(onComplete).toHaveBeenCalledExactlyOnceWith({ id: 'created' }));
|
|
166
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
167
|
+
expect(onFinish).toHaveBeenCalledWith(
|
|
168
|
+
{ name: '', context: 'Caller context value' },
|
|
169
|
+
{ signal: expect.any(AbortSignal) },
|
|
170
|
+
);
|
|
171
|
+
expect(container.querySelector('[data-live-announcer="polite"]')).toHaveTextContent('Caller success');
|
|
172
|
+
expect(changes).toBe(1);
|
|
173
|
+
observer.disconnect();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('keeps values after failures and announces identical failures on each attempt', async () => {
|
|
177
|
+
const user = userEvent.setup();
|
|
178
|
+
const onComplete = vi.fn();
|
|
179
|
+
const onFinish = vi.fn().mockRejectedValue(new Error('transport'));
|
|
180
|
+
const base = props();
|
|
181
|
+
const { container } = render(
|
|
182
|
+
<CreateWizardPage
|
|
183
|
+
{...base}
|
|
184
|
+
initialValues={{ name: 'Kept', context: 'Context' }}
|
|
185
|
+
onFinish={onFinish}
|
|
186
|
+
onComplete={onComplete}
|
|
187
|
+
errorMessage={() => 'Caller retry'}
|
|
188
|
+
/>,
|
|
189
|
+
);
|
|
190
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
191
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
192
|
+
const region = container.querySelector('[data-live-announcer="assertive"]')!;
|
|
193
|
+
let changes = 0;
|
|
194
|
+
const observer = new MutationObserver(() => changes++);
|
|
195
|
+
observer.observe(region, { subtree: true, childList: true, characterData: true });
|
|
196
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
197
|
+
await user.click(screen.getByRole('button', { name: labels.finish }));
|
|
198
|
+
await waitFor(() => expect(region).toHaveTextContent('Caller retry'));
|
|
199
|
+
await waitFor(() => expect(changes).toBe(attempt));
|
|
200
|
+
expect(screen.getByRole('button', { name: labels.finish })).toBeEnabled();
|
|
201
|
+
expect(
|
|
202
|
+
within(screen.getByRole('region', { name: labels.reviewTitle })).getByText('Caller retry'),
|
|
203
|
+
).not.toHaveAttribute('role', 'alert');
|
|
204
|
+
expect(screen.getByText('Kept')).toBeInTheDocument();
|
|
205
|
+
}
|
|
206
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
207
|
+
observer.disconnect();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
function deferred<T>() {
|
|
211
|
+
let resolve!: (value: T) => void;
|
|
212
|
+
let reject!: (error: unknown) => void;
|
|
213
|
+
const promise = new Promise<T>((yes, no) => {
|
|
214
|
+
resolve = yes;
|
|
215
|
+
reject = no;
|
|
216
|
+
});
|
|
217
|
+
return { promise, resolve, reject };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
it.each([false, true])(
|
|
221
|
+
'locks pending and success navigation and hands Finish focus to a resource link (controlled=%s)',
|
|
222
|
+
async (controlled) => {
|
|
223
|
+
const user = userEvent.setup();
|
|
224
|
+
const attempt = deferred<unknown>();
|
|
225
|
+
const onFinish = vi.fn(() => attempt.promise);
|
|
226
|
+
const onComplete = vi.fn();
|
|
227
|
+
const base = props({
|
|
228
|
+
initialValues: { name: 'Kept', context: 'Context' },
|
|
229
|
+
onFinish,
|
|
230
|
+
onComplete,
|
|
231
|
+
resourceHref: () => '/created',
|
|
232
|
+
});
|
|
233
|
+
const { rerender } = render(
|
|
234
|
+
<CreateWizardPage {...base} submitState={controlled ? { status: 'idle' } : undefined} />,
|
|
235
|
+
);
|
|
236
|
+
for (let index = 0; index < 2; index++) await user.click(screen.getByRole('button', { name: labels.next }));
|
|
237
|
+
const finish = screen.getByRole('button', { name: labels.finish });
|
|
238
|
+
await user.click(finish);
|
|
239
|
+
if (controlled) rerender(<CreateWizardPage {...base} submitState={{ status: 'submitting' }} />);
|
|
240
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
241
|
+
expect(finish).toHaveTextContent(labels.pending);
|
|
242
|
+
expect(finish).toHaveAttribute('aria-disabled', 'true');
|
|
243
|
+
expect(finish).toHaveFocus();
|
|
244
|
+
const back = screen.getByRole('button', { name: labels.back });
|
|
245
|
+
expect(back).toHaveAttribute('aria-disabled', 'true');
|
|
246
|
+
expect(back).not.toBeDisabled();
|
|
247
|
+
expect(screen.queryByRole('button', { name: /^Caller edit/ })).not.toBeInTheDocument();
|
|
248
|
+
expect(screen.getByRole('button', { name: labels.exit })).toBeEnabled();
|
|
249
|
+
await user.click(back);
|
|
250
|
+
expect(screen.getByRole('heading', { name: labels.reviewTitle })).toBeInTheDocument();
|
|
251
|
+
act(() => finish.focus());
|
|
252
|
+
await act(async () => attempt.resolve({ id: 'created' }));
|
|
253
|
+
if (controlled)
|
|
254
|
+
rerender(<CreateWizardPage {...base} submitState={{ status: 'success', result: { id: 'created' } }} />);
|
|
255
|
+
expect(await screen.findByRole('link', { name: labels.openResource })).toHaveFocus();
|
|
256
|
+
expect(finish).toBeDisabled();
|
|
257
|
+
expect(back).toHaveAttribute('aria-disabled', 'true');
|
|
258
|
+
expect(screen.queryByRole('button', { name: /^Caller edit/ })).not.toBeInTheDocument();
|
|
259
|
+
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(controlled ? 0 : 1));
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
it.each([false, true])(
|
|
264
|
+
'aborts confirmed exit, resets answers and drops the first attempt settling last (controlled=%s)',
|
|
265
|
+
async (controlled) => {
|
|
266
|
+
const user = userEvent.setup();
|
|
267
|
+
const first = deferred<unknown>();
|
|
268
|
+
const second = deferred<unknown>();
|
|
269
|
+
const signals: AbortSignal[] = [];
|
|
270
|
+
const onExit = vi.fn();
|
|
271
|
+
const onComplete = vi.fn();
|
|
272
|
+
function Host() {
|
|
273
|
+
const [open, setOpen] = useState(true);
|
|
274
|
+
const [state, setState] = useState<CreateWizardSubmitState>({ status: 'idle' });
|
|
275
|
+
return (
|
|
276
|
+
<LiveAnnouncer>
|
|
277
|
+
<main>
|
|
278
|
+
<h1>Caller list</h1>
|
|
279
|
+
<button onClick={() => setOpen(true)}>Caller open</button>
|
|
280
|
+
</main>
|
|
281
|
+
<CreateWizardPage
|
|
282
|
+
{...props()}
|
|
283
|
+
mode="overlay"
|
|
284
|
+
open={open}
|
|
285
|
+
onOpenChange={setOpen}
|
|
286
|
+
description="Caller description"
|
|
287
|
+
submitState={controlled ? state : undefined}
|
|
288
|
+
onComplete={onComplete}
|
|
289
|
+
onExit={() => {
|
|
290
|
+
onExit();
|
|
291
|
+
setState({ status: 'idle' });
|
|
292
|
+
}}
|
|
293
|
+
onFinish={async (_values, { signal }) => {
|
|
294
|
+
signals.push(signal);
|
|
295
|
+
if (controlled) setState({ status: 'submitting' });
|
|
296
|
+
const result = await (signals.length === 1 ? first.promise : second.promise);
|
|
297
|
+
if (controlled && !signal.aborted) setState({ status: 'success', result });
|
|
298
|
+
return result;
|
|
299
|
+
}}
|
|
300
|
+
/>
|
|
301
|
+
</LiveAnnouncer>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
render(<Host />);
|
|
305
|
+
for (const name of ['First answer', 'Second answer']) {
|
|
306
|
+
await user.click(await screen.findByRole('button', { name: labels.next }));
|
|
307
|
+
expect(screen.getByRole('textbox', { name: 'Caller name' })).toHaveValue('');
|
|
308
|
+
await user.type(screen.getByRole('textbox', { name: 'Caller name' }), name);
|
|
309
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
310
|
+
await user.click(screen.getByRole('button', { name: labels.finish }));
|
|
311
|
+
if (name === 'First answer') {
|
|
312
|
+
await user.click(screen.getByRole('button', { name: labels.exit }));
|
|
313
|
+
await user.click(
|
|
314
|
+
within(await screen.findByRole('alertdialog', { name: labels.exitTitle })).getByRole('button', {
|
|
315
|
+
name: labels.exitCancel,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
|
|
319
|
+
expect(onExit).not.toHaveBeenCalled();
|
|
320
|
+
expect(signals[0]?.aborted).toBe(false);
|
|
321
|
+
await user.keyboard('{Escape}');
|
|
322
|
+
await user.click(
|
|
323
|
+
within(await screen.findByRole('alertdialog')).getByRole('button', { name: labels.exitConfirm }),
|
|
324
|
+
);
|
|
325
|
+
expect(onExit).toHaveBeenCalledTimes(1);
|
|
326
|
+
expect(signals[0]?.aborted).toBe(true);
|
|
327
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
328
|
+
await user.click(screen.getByRole('button', { name: 'Caller open' }));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
await act(async () => second.resolve('Second result'));
|
|
332
|
+
await screen.findByRole('heading', { name: 'Caller success' });
|
|
333
|
+
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(controlled ? 0 : 1));
|
|
334
|
+
const polite = document.querySelector('[data-live-announcer="polite"]')!;
|
|
335
|
+
const text = polite.textContent;
|
|
336
|
+
await act(async () => first.resolve('First result'));
|
|
337
|
+
expect(polite.textContent).toBe(text);
|
|
338
|
+
expect(screen.getByText('Second answer')).toBeInTheDocument();
|
|
339
|
+
expect(onComplete).toHaveBeenCalledTimes(controlled ? 0 : 1);
|
|
340
|
+
if (!controlled) expect(onComplete).toHaveBeenCalledWith('Second result');
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
it.each(['idle', 'submitting', 'success', 'error'] as const)(
|
|
345
|
+
'renders controlled %s only and never completes on the kit promise',
|
|
346
|
+
async (status) => {
|
|
347
|
+
const user = userEvent.setup();
|
|
348
|
+
const onComplete = vi.fn();
|
|
349
|
+
const onFinish = vi.fn(async () => 'Ignored result');
|
|
350
|
+
const state: CreateWizardSubmitState =
|
|
351
|
+
status === 'error' ? { status, message: 'Caller controlled error' } : { status };
|
|
352
|
+
render(
|
|
353
|
+
<CreateWizardPage
|
|
354
|
+
{...props({
|
|
355
|
+
onFinish,
|
|
356
|
+
onComplete,
|
|
357
|
+
submitState: state,
|
|
358
|
+
initialValues: { context: 'Caller context value', name: 'Caller value' },
|
|
359
|
+
})}
|
|
360
|
+
/>,
|
|
361
|
+
);
|
|
362
|
+
if (status === 'idle') {
|
|
363
|
+
for (let index = 0; index < 2; index++) await user.click(screen.getByRole('button', { name: labels.next }));
|
|
364
|
+
for (let index = 0; index < 2; index++) await user.click(screen.getByRole('button', { name: labels.finish }));
|
|
365
|
+
expect(onFinish).toHaveBeenCalledTimes(2);
|
|
366
|
+
expect(screen.queryByRole('heading', { name: 'Caller success' })).not.toBeInTheDocument();
|
|
367
|
+
} else {
|
|
368
|
+
expect(screen.getByRole('heading', { name: labels.reviewTitle })).toBeInTheDocument();
|
|
369
|
+
if (status === 'submitting')
|
|
370
|
+
expect(screen.getByRole('button', { name: labels.pending })).toHaveAttribute('aria-disabled', 'true');
|
|
371
|
+
if (status === 'success') expect(screen.getByRole('heading', { name: 'Caller success' })).toBeInTheDocument();
|
|
372
|
+
if (status === 'error')
|
|
373
|
+
expect(
|
|
374
|
+
within(screen.getByRole('region', { name: labels.reviewTitle })).getByText('Caller controlled error'),
|
|
375
|
+
).toBeInTheDocument();
|
|
376
|
+
}
|
|
377
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
it.each([
|
|
382
|
+
{ result: undefined, href: '/unused' },
|
|
383
|
+
{ result: 'Created', href: undefined },
|
|
384
|
+
])('hands focus to the success heading without a usable resource ($result, $href)', async ({ result, href }) => {
|
|
385
|
+
const user = userEvent.setup();
|
|
386
|
+
const resourceHref = vi.fn(() => href);
|
|
387
|
+
const onComplete = vi.fn();
|
|
388
|
+
const { container } = render(
|
|
389
|
+
<CreateWizardPage {...props({ flowSteps: [], onFinish: async () => result, resourceHref, onComplete })} />,
|
|
390
|
+
);
|
|
391
|
+
let changes = 0;
|
|
392
|
+
const observer = new MutationObserver(() => changes++);
|
|
393
|
+
observer.observe(container.querySelector('[data-live-announcer="polite"]')!, {
|
|
394
|
+
subtree: true,
|
|
395
|
+
characterData: true,
|
|
396
|
+
childList: true,
|
|
397
|
+
});
|
|
398
|
+
await user.click(screen.getByRole('button', { name: labels.finish }));
|
|
399
|
+
const heading = await screen.findByRole('heading', { name: 'Caller success' });
|
|
400
|
+
expect(heading).toHaveAttribute('tabindex', '-1');
|
|
401
|
+
expect(heading).toHaveFocus();
|
|
402
|
+
expect(screen.queryByRole('link', { name: labels.openResource })).not.toBeInTheDocument();
|
|
403
|
+
if (result === undefined) expect(resourceHref).not.toHaveBeenCalled();
|
|
404
|
+
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1));
|
|
405
|
+
expect(changes).toBe(1);
|
|
406
|
+
observer.disconnect();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('announces controlled transitions once, changed settled errors once and repeated failures after submitting', async () => {
|
|
410
|
+
const base = props({ flowSteps: [], submitState: { status: 'idle' } });
|
|
411
|
+
const { container, rerender } = render(<CreateWizardPage {...base} />);
|
|
412
|
+
const region = container.querySelector('[data-live-announcer="assertive"]')!;
|
|
413
|
+
let changes = 0;
|
|
414
|
+
const observer = new MutationObserver(() => changes++);
|
|
415
|
+
observer.observe(region, { subtree: true, childList: true, characterData: true });
|
|
416
|
+
const show = async (submitState: CreateWizardSubmitState) => {
|
|
417
|
+
rerender(<CreateWizardPage {...base} submitState={submitState} />);
|
|
418
|
+
await act(() => Promise.resolve());
|
|
419
|
+
};
|
|
420
|
+
await show({ status: 'submitting' });
|
|
421
|
+
await show({ status: 'error', message: 'Caller error one' });
|
|
422
|
+
expect(changes).toBe(1);
|
|
423
|
+
await show({ status: 'error', message: 'Caller error one' });
|
|
424
|
+
expect(changes).toBe(1);
|
|
425
|
+
await show({ status: 'error', message: 'Caller error two' });
|
|
426
|
+
expect(changes).toBe(2);
|
|
427
|
+
await show({ status: 'submitting' });
|
|
428
|
+
await show({ status: 'error', message: 'Caller error two' });
|
|
429
|
+
expect(changes).toBe(3);
|
|
430
|
+
observer.disconnect();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('aborts on unmount and drops a rejected settlement without announcing or completing', async () => {
|
|
434
|
+
const user = userEvent.setup();
|
|
435
|
+
const attempt = deferred<unknown>();
|
|
436
|
+
let signal: AbortSignal | undefined;
|
|
437
|
+
const onComplete = vi.fn();
|
|
438
|
+
const base = props({
|
|
439
|
+
flowSteps: [],
|
|
440
|
+
onComplete,
|
|
441
|
+
onFinish: (_values, context) => {
|
|
442
|
+
signal = context.signal;
|
|
443
|
+
return attempt.promise;
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
const { container, rerender } = render(
|
|
447
|
+
<LiveAnnouncer>
|
|
448
|
+
<CreateWizardPage {...base} />
|
|
449
|
+
</LiveAnnouncer>,
|
|
450
|
+
);
|
|
451
|
+
await user.click(screen.getByRole('button', { name: labels.finish }));
|
|
452
|
+
expect(signal?.aborted).toBe(false);
|
|
453
|
+
rerender(<LiveAnnouncer />);
|
|
454
|
+
expect(signal?.aborted).toBe(true);
|
|
455
|
+
await act(async () => attempt.reject(new Error('Late error')));
|
|
456
|
+
expect(container.querySelector('[data-live-announcer="assertive"]')).toBeEmptyDOMElement();
|
|
457
|
+
expect(container.querySelector('[data-live-announcer="polite"]')).toBeEmptyDOMElement();
|
|
458
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('keeps page navigation focus unless Back becomes disabled, and moves removed Edit focus to the heading', async () => {
|
|
462
|
+
const user = userEvent.setup();
|
|
463
|
+
const base = props({
|
|
464
|
+
initialValues: { name: 'Caller value', context: 'Caller context value' },
|
|
465
|
+
submitState: { status: 'idle' },
|
|
466
|
+
});
|
|
467
|
+
const { rerender } = render(<CreateWizardPage {...base} />);
|
|
468
|
+
const next = screen.getByRole('button', { name: labels.next });
|
|
469
|
+
await user.click(next);
|
|
470
|
+
expect(next).toHaveFocus();
|
|
471
|
+
const back = screen.getByRole('button', { name: labels.back });
|
|
472
|
+
await user.click(back);
|
|
473
|
+
expect(back).toBeDisabled();
|
|
474
|
+
expect(screen.getByRole('heading', { name: 'Caller context' })).toHaveFocus();
|
|
475
|
+
for (let index = 0; index < 2; index++) await user.click(screen.getByRole('button', { name: labels.next }));
|
|
476
|
+
act(() => screen.getByRole('button', { name: 'Caller edit Caller name step' }).focus());
|
|
477
|
+
rerender(<CreateWizardPage {...base} submitState={{ status: 'submitting' }} />);
|
|
478
|
+
expect(screen.getByRole('heading', { name: labels.reviewTitle })).toHaveFocus();
|
|
479
|
+
act(() => back.focus());
|
|
480
|
+
rerender(<CreateWizardPage {...base} submitState={{ status: 'success' }} />);
|
|
481
|
+
expect(back).toHaveFocus();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('uses only caller text and names, including cards, dialog, pending and fallback; portal Escape stays local', async () => {
|
|
485
|
+
const user = userEvent.setup();
|
|
486
|
+
const first = deferred<unknown>();
|
|
487
|
+
const base = props({
|
|
488
|
+
mode: 'overlay',
|
|
489
|
+
open: true,
|
|
490
|
+
description: 'Caller description',
|
|
491
|
+
initialValues: { name: '', context: 'Caller context value' },
|
|
492
|
+
flowSteps: [
|
|
493
|
+
{
|
|
494
|
+
id: 'context',
|
|
495
|
+
label: 'Caller context',
|
|
496
|
+
render: ({ values, setValue }) => (
|
|
497
|
+
<SimpleSelect
|
|
498
|
+
placeholder="Caller placeholder"
|
|
499
|
+
label="Caller select"
|
|
500
|
+
items={[
|
|
501
|
+
{ value: 'first', label: 'Caller first' },
|
|
502
|
+
{ value: 'second', label: 'Caller second' },
|
|
503
|
+
]}
|
|
504
|
+
value={values.context}
|
|
505
|
+
onValueChange={(value) => value && setValue('context', value)}
|
|
506
|
+
/>
|
|
507
|
+
),
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
onFinish: () => first.promise,
|
|
511
|
+
summary: () => [{ stepId: 'context', label: 'Caller empty row', value: '' }],
|
|
512
|
+
});
|
|
513
|
+
const assertNamespace = () => {
|
|
514
|
+
const nodes = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
515
|
+
while (nodes.nextNode()) {
|
|
516
|
+
const text = nodes.currentNode.textContent?.trim().replace(/^[\d\s,/.]+/, '');
|
|
517
|
+
if (text) expect(text).toMatch(/^Caller /);
|
|
518
|
+
}
|
|
519
|
+
for (const element of document.querySelectorAll('[aria-label]'))
|
|
520
|
+
expect(element.getAttribute('aria-label')).toMatch(/^Caller /);
|
|
521
|
+
};
|
|
522
|
+
render(
|
|
523
|
+
<LiveAnnouncer>
|
|
524
|
+
<CreateWizardPage {...base} />
|
|
525
|
+
</LiveAnnouncer>,
|
|
526
|
+
);
|
|
527
|
+
await screen.findByRole('dialog', { name: labels.title });
|
|
528
|
+
expect(document.querySelectorAll('[aria-live="polite"]')).toHaveLength(1);
|
|
529
|
+
await user.click(screen.getByRole('combobox', { name: 'Caller select' }));
|
|
530
|
+
await screen.findByRole('listbox');
|
|
531
|
+
await user.keyboard('{Escape}');
|
|
532
|
+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
|
533
|
+
await user.click(screen.getByRole('button', { name: labels.next }));
|
|
534
|
+
assertNamespace();
|
|
535
|
+
expect(screen.getByText(labels.emptyValue)).toBeInTheDocument();
|
|
536
|
+
await user.click(screen.getByRole('button', { name: labels.finish }));
|
|
537
|
+
expect(screen.getByRole('button', { name: labels.pending })).toHaveAttribute('aria-disabled', 'true');
|
|
538
|
+
assertNamespace();
|
|
539
|
+
await act(async () => first.reject(new Error('Non-localized transport detail')));
|
|
540
|
+
expect(
|
|
541
|
+
within(screen.getByRole('region', { name: labels.reviewTitle })).getByText(labels.errorFallback),
|
|
542
|
+
).toBeInTheDocument();
|
|
543
|
+
assertNamespace();
|
|
544
|
+
await user.keyboard('{Escape}');
|
|
545
|
+
const dialog = await screen.findByRole('alertdialog', { name: labels.exitTitle });
|
|
546
|
+
expect(within(dialog).getByRole('button', { name: labels.exitConfirm })).toBeInTheDocument();
|
|
547
|
+
expect(within(dialog).getByRole('button', { name: labels.exitCancel })).toBeInTheDocument();
|
|
548
|
+
assertNamespace();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('keeps validation live after a successful first Next, including after editing from review', async () => {
|
|
552
|
+
const user = userEvent.setup();
|
|
553
|
+
render(<CreateWizardPage {...props({ initialValues: { name: 'Valid', context: 'Context' } })} />);
|
|
554
|
+
for (let index = 0; index < 2; index++) await user.click(screen.getByRole('button', { name: labels.next }));
|
|
555
|
+
await user.click(screen.getByRole('button', { name: 'Caller edit Caller name step' }));
|
|
556
|
+
await user.clear(screen.getByRole('textbox', { name: 'Caller name' }));
|
|
557
|
+
expect(screen.getByText('Caller required')).toBeInTheDocument();
|
|
558
|
+
expect(screen.getByRole('textbox', { name: 'Caller name' })).toHaveAttribute('aria-invalid', 'true');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('does not repeat step announcements when a success callback rerenders the overlay with equivalent labels', async () => {
|
|
562
|
+
const user = userEvent.setup();
|
|
563
|
+
function Host() {
|
|
564
|
+
const [completed, setCompleted] = useState(false);
|
|
565
|
+
return (
|
|
566
|
+
<LiveAnnouncer>
|
|
567
|
+
<CreateWizardPage
|
|
568
|
+
{...props({ flowSteps: [] })}
|
|
569
|
+
labels={{ ...labels }}
|
|
570
|
+
mode="overlay"
|
|
571
|
+
open
|
|
572
|
+
description="Caller description"
|
|
573
|
+
onFinish={async () => undefined}
|
|
574
|
+
onComplete={() => setCompleted(true)}
|
|
575
|
+
/>
|
|
576
|
+
{completed && <span>Caller completed</span>}
|
|
577
|
+
</LiveAnnouncer>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
render(<Host />);
|
|
581
|
+
const region = document.querySelector('[data-live-announcer="polite"]')!;
|
|
582
|
+
await waitFor(() => expect(region).toHaveTextContent('Caller step'));
|
|
583
|
+
let changes = 0;
|
|
584
|
+
const observer = new MutationObserver(() => changes++);
|
|
585
|
+
observer.observe(region, { subtree: true, childList: true, characterData: true });
|
|
586
|
+
await user.click(screen.getByRole('button', { name: labels.finish }));
|
|
587
|
+
await screen.findByText('Caller completed');
|
|
588
|
+
await act(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
589
|
+
expect(region).toHaveTextContent('Caller success');
|
|
590
|
+
expect(changes).toBe(1);
|
|
591
|
+
observer.disconnect();
|
|
592
|
+
});
|