@gntik-ai/templates 0.5.0 → 0.7.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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { StepStatus, WizardLayoutProps } from '@gntik-ai/ui';
|
|
3
|
+
|
|
4
|
+
export type CreateWizardFieldErrors<V> = Partial<Record<keyof V, string>>;
|
|
5
|
+
export interface CreateWizardFlowStep<V> {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Render fixed values as text. Field errors are ignored on a read-only step. */
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
render: (context: {
|
|
11
|
+
values: V;
|
|
12
|
+
setValue: <K extends keyof V>(key: K, value: V[K]) => void;
|
|
13
|
+
errors: CreateWizardFieldErrors<V>;
|
|
14
|
+
}) => ReactNode;
|
|
15
|
+
/** Pure validation, evaluated on every render, including caller re-renders. */
|
|
16
|
+
validate?: (values: V) => { fieldErrors?: CreateWizardFieldErrors<V>; blocking?: string };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreateWizardFlowLabels {
|
|
20
|
+
title: string;
|
|
21
|
+
exit: string;
|
|
22
|
+
cancel: string;
|
|
23
|
+
back: string;
|
|
24
|
+
next: string;
|
|
25
|
+
finish: string;
|
|
26
|
+
pending: string;
|
|
27
|
+
review: string;
|
|
28
|
+
reviewTitle: string;
|
|
29
|
+
edit: (stepLabel: string) => string;
|
|
30
|
+
emptyValue: string;
|
|
31
|
+
success: (result: unknown) => string;
|
|
32
|
+
errorFallback: string;
|
|
33
|
+
openResource: string;
|
|
34
|
+
exitTitle: string;
|
|
35
|
+
exitDescription: string;
|
|
36
|
+
exitConfirm: string;
|
|
37
|
+
exitCancel: string;
|
|
38
|
+
steps: string;
|
|
39
|
+
stepsSummary: string;
|
|
40
|
+
stepStatusText: Record<StepStatus, string>;
|
|
41
|
+
formatStepCount: (position: number, total: number) => string;
|
|
42
|
+
stepAnnouncement: (step: { position: number; total: number; label: ReactNode }) => string;
|
|
43
|
+
skipLink: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type CreateWizardSubmitState =
|
|
47
|
+
| { status: 'idle' }
|
|
48
|
+
| { status: 'submitting' }
|
|
49
|
+
| { status: 'success'; result?: unknown }
|
|
50
|
+
| { status: 'error'; error?: unknown; message?: string };
|
|
51
|
+
|
|
52
|
+
export interface CreateWizardFlowProps<V extends object> extends Pick<
|
|
53
|
+
WizardLayoutProps,
|
|
54
|
+
| 'mode'
|
|
55
|
+
| 'size'
|
|
56
|
+
| 'open'
|
|
57
|
+
| 'onOpenChange'
|
|
58
|
+
| 'description'
|
|
59
|
+
| 'returnFocusRef'
|
|
60
|
+
| 'closeOnInteractOutside'
|
|
61
|
+
| 'confirmExit'
|
|
62
|
+
> {
|
|
63
|
+
flowSteps: readonly CreateWizardFlowStep<V>[];
|
|
64
|
+
initialValues: V;
|
|
65
|
+
summary: (values: V) => readonly { stepId: string; label: string; value?: string | null }[];
|
|
66
|
+
labels: CreateWizardFlowLabels;
|
|
67
|
+
onFinish: (values: V, context: { signal: AbortSignal }) => void | Promise<unknown>;
|
|
68
|
+
onExit: () => void;
|
|
69
|
+
onComplete?: (result: unknown) => void;
|
|
70
|
+
/** Mode is fixed at mount. Controlled callers discard aborted completions and reset to idle on exit. */
|
|
71
|
+
submitState?: CreateWizardSubmitState;
|
|
72
|
+
resourceHref?: (result: unknown) => string | undefined;
|
|
73
|
+
errorMessage?: (error: unknown) => string | undefined;
|
|
74
|
+
/** Legacy-only props cannot mix with flow mode. */
|
|
75
|
+
copy?: never;
|
|
76
|
+
steps?: never;
|
|
77
|
+
sources?: never;
|
|
78
|
+
regions?: never;
|
|
79
|
+
title?: never;
|
|
80
|
+
onStepChange?: never;
|
|
81
|
+
stepFeedback?: never;
|
|
82
|
+
pendingLabel?: never;
|
|
83
|
+
stepAnnouncement?: never;
|
|
84
|
+
}
|
|
@@ -1,12 +1,71 @@
|
|
|
1
1
|
import type { TemplateMeta } from '../meta';
|
|
2
2
|
|
|
3
|
-
export const meta
|
|
3
|
+
export const meta = {
|
|
4
4
|
name: 'Create wizard',
|
|
5
5
|
family: 'Resources',
|
|
6
6
|
priority: 'P2',
|
|
7
7
|
status: 'beta',
|
|
8
|
-
description:
|
|
9
|
-
|
|
8
|
+
description: `Focus-mode create flow on WizardLayout. Without flowSteps, the existing details, source, configuration and editable review flow, CreateWizardProps, copy and defaults are unchanged. Page mode is the default; mode="overlay" keeps the parent list mounted without a route change. Overlay size defaults to fullscreen; size="dialog" uses a popup (fullscreen on mobile). Legacy closing preserves answers; confirming exit in flow mode resets answers and the current step.
|
|
9
|
+
|
|
10
|
+
## Caller-defined flow mode
|
|
11
|
+
|
|
12
|
+
Import CreateWizardPage (or CreateWizardFlow) and CreateWizardFlowProps<V> from @gntik-ai/templates. flowSteps selects the additive generic variant; legacy-only props (steps, copy, sources, regions, title, stepFeedback, onStepChange) cannot mix with it. V is the caller's object type. The kit owns current step, values and validation visibility. The display-only Stepper follows the supplied order and appends review. Zero caller steps renders review directly.
|
|
13
|
+
|
|
14
|
+
| Prop | Type / behaviour |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| flowSteps | readonly { id: string; label: string; readOnly?: boolean; render({ values, setValue, errors }): ReactNode; validate?(values): { fieldErrors?, blocking? } }[]; ids must be unique and stable |
|
|
17
|
+
| initialValues | V; used at mount and restored on confirmed exit |
|
|
18
|
+
| summary | (values: V) => readonly { stepId: string; label: string; value?: string or null }[] |
|
|
19
|
+
| labels | Required CreateWizardFlowLabels; no template-owned visible or accessible copy has a fallback |
|
|
20
|
+
| onFinish | (values: V, { signal: AbortSignal }) => void or Promise<unknown>; one signal per attempt |
|
|
21
|
+
| onExit | Called once after confirmed exit, after reset and abort; Stay calls nothing |
|
|
22
|
+
| onComplete | Uncontrolled only; (result: unknown) => void, once after success is committed and announced |
|
|
23
|
+
| submitState | Controlled when provided at mount: {status:'idle'}, {status:'submitting'}, {status:'success', result?:unknown}, or {status:'error', error?:unknown, message?:string} |
|
|
24
|
+
| resourceHref | (result: unknown) => string or undefined; only called with a non-null success result |
|
|
25
|
+
| errorMessage | (error: unknown) => string or undefined; empty text uses labels.errorFallback |
|
|
26
|
+
| confirmExit | Defaults to true; Exit, Escape and overlay Cancel open confirmation. false exits directly |
|
|
27
|
+
| mode, size, open, onOpenChange, description, returnFocusRef, closeOnInteractOutside | WizardLayout presentation props; closing itself is not confirmation or reset |
|
|
28
|
+
|
|
29
|
+
labels supplies title, exit, cancel, back, next, finish, pending, review (Stepper label), reviewTitle (heading), edit(stepLabel), emptyValue, success(result), errorFallback, openResource, exitTitle, exitDescription, exitConfirm, exitCancel, steps (Stepper name), stepsSummary, skipLink, stepStatusText (complete/current/upcoming/error), formatStepCount(position,total) and stepAnnouncement({position,total,label}). Positions are one-based. Step labels, validation messages, summary labels/values and rendered body content also belong to the caller's namespace. Every DescriptionListCard receives the owning step label as its explicit title, labels.edit(stepLabel), explicit items and an empty description. All steps get a card, even with no matching rows; empty values use labels.emptyValue.
|
|
30
|
+
|
|
31
|
+
## Validation and navigation
|
|
32
|
+
|
|
33
|
+
validate runs on every render, including caller re-renders (for example members.count quota changes). A non-empty blocking message disables Next immediately. It is visible as ordinary text and announced politely once per new value, including entry and one frame after overlay opening. Clearing blocking re-enables Next without a press. fieldErrors is Partial<Record<keyof V,string>>. Errors become visible after the first Next press on their step and remain live thereafter. With errors, Next does not advance and focuses the first aria-invalid="true" field. Render fields using Field invalid, FieldLabel, Input and FieldError to associate errors and controls. Blocking does not hide visible field errors. A readOnly step must render its fixed value as text, without editable controls; only its blocking result gates Next and its fieldErrors are ignored. Back, Next and review Edit preserve values. Edit returns to the owning step and focuses its heading. No direct Stepper jumps are available.
|
|
34
|
+
|
|
35
|
+
## Submit ownership and aborts
|
|
36
|
+
|
|
37
|
+
In uncontrolled mode, Finish shows labels.pending in both presentations. Resolve commits success, announces labels.success(result) politely, then calls onComplete(result) once. Reject keeps values and review, renders errorMessage(error) or labels.errorFallback, announces assertively and re-enables Finish. Identical repeated failures each announce; changing settled error text announces once, unchanged text does not. Visible success, error and blocking text is never a live region. All announcements use useAnnounce from the nearest LiveAnnouncer; the template provides one only when needed. Put LiveAnnouncer at page level when onComplete closes/unmounts the flow, so the message survives.
|
|
38
|
+
|
|
39
|
+
Controlled mode renders submitState only. Each enabled Finish press calls the existing onFinish once; its returned result never changes kit state and onComplete is never called. The caller must pass through submitting for every attempt, discard completions when signal.aborted, and reset submitState to idle in onExit before reopening. Switching between controlled and uncontrolled after mount is unsupported. AbortSignal is a cancellation contract, not a transport: callers must honor it. Confirmed exit and unmount abort the current attempt. Uncontrolled aborted settlements cannot update state, announce or complete, including an older attempt settling after reopening.
|
|
40
|
+
|
|
41
|
+
While submitting or after success, Back has backDisabled (aria-disabled but focusable), review cards have no Edit, and Finish is loading or disabled. Exit remains enabled. Pending Finish and locked Back retain focus. A focused control becoming disabled or removed sends focus on success to the resource link, or to the success heading (tabIndex=-1) if no result/href exists; in all other cases it goes to the current heading. Focus never moves to a live region. Page Back/Next preserve focus unless the pressed control becomes disabled. Overlay step changes focus the new heading and announce via LiveAnnouncer. Escape in a portalled Select or Menu stays local.
|
|
42
|
+
|
|
43
|
+
## Examples
|
|
44
|
+
|
|
45
|
+
examples/Invite.tsx is a generic single-recipient invite with a read-only workspace context, one email checked against /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/, a members.count quota, exactly Owner/Admin/Member roles, and an optional message with maxLength=500. Its copy is one English page namespace. examples/labels.ts lists every required label. UncontrolledPending, UncontrolledSuccess and UncontrolledError cover a never-settling attempt, a success resource link and a failure fallback. ControlledIdle, ControlledSubmitting, ControlledSuccess and ControlledError show each caller-owned state. Every example has a create-wizard--<kebab-name> preview entry; Invite uses create-wizard--invite. The three-theme browser axe loop visits each step, review, submit state and exit confirmation. examples/Overlay.tsx retains the original list-to-overlay project flow with step-local error and empty surfaces.
|
|
46
|
+
|
|
47
|
+
A controlled caller's onFinish can use this pattern (with its own transport, values and state): setSubmitState({status:'submitting'}); try { const result = await save(values, {signal}); if (!signal.aborted) setSubmitState({status:'success',result}); } catch (error) { if (!signal.aborted) setSubmitState({status:'error',error}); }. onExit resets to idle before reopening. Keep the page-level LiveAnnouncer mounted when closing.
|
|
48
|
+
|
|
49
|
+
All flow surfaces reuse kit components and existing tokens; no new CSS variables. Themes: dark, light and high_contrast.`,
|
|
10
50
|
layout: 'WizardLayout',
|
|
11
51
|
blocks: ['description-list-card', 'error-panel', 'empty-states'],
|
|
12
|
-
|
|
52
|
+
pattern:
|
|
53
|
+
'Caller-defined steps and review with display-only progress; explicit labels; field-error associations; one LiveAnnouncer announcement mechanism; abortable submit lifecycle.',
|
|
54
|
+
keyboard: [
|
|
55
|
+
['Tab / Shift+Tab', 'Move through Exit, fields, review Edit and footer controls; the Stepper is display-only.'],
|
|
56
|
+
['Enter / Space on Next', 'Validate; focus the first invalid field, or advance. Blocking disables Next.'],
|
|
57
|
+
[
|
|
58
|
+
'Enter / Space on Back',
|
|
59
|
+
'Return one step; disabled on the first page step and locked while submitting or after success.',
|
|
60
|
+
],
|
|
61
|
+
['Enter on Edit', 'Return to the owning step and focus its heading, preserving answers.'],
|
|
62
|
+
[
|
|
63
|
+
'Escape / Exit',
|
|
64
|
+
'Open exit confirmation; Stay does nothing, Leave resets answers and aborts the attempt. Portalled popups handle their own Escape.',
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
'Finish',
|
|
68
|
+
'Call onFinish once when enabled; pending keeps focus, success hands focus to a resource link or the success heading if Finish becomes disabled.',
|
|
69
|
+
],
|
|
70
|
+
],
|
|
71
|
+
} satisfies TemplateMeta & { pattern: string; keyboard: [string, string][] };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useAnnounce } from '@gntik-ai/ui';
|
|
2
|
+
import { useEffect, useEffectEvent, useRef, useState } from 'react';
|
|
3
|
+
import type { CreateWizardFlowProps, CreateWizardSubmitState } from './flow-types';
|
|
4
|
+
|
|
5
|
+
const IDLE: CreateWizardSubmitState = { status: 'idle' };
|
|
6
|
+
|
|
7
|
+
/** Owns attempts, never transport; controlled callers own all settled state. */
|
|
8
|
+
export function useSubmitState<V extends object>(props: CreateWizardFlowProps<V>) {
|
|
9
|
+
const [controlled] = useState(() => props.submitState !== undefined);
|
|
10
|
+
const [internal, setInternal] = useState<CreateWizardSubmitState>({ status: 'idle' });
|
|
11
|
+
const attempt = useRef<AbortController | null>(null);
|
|
12
|
+
const state = controlled ? (props.submitState ?? IDLE) : internal;
|
|
13
|
+
const announce = useAnnounce();
|
|
14
|
+
const previous = useRef(state.status);
|
|
15
|
+
const lastError = useRef('');
|
|
16
|
+
const complete = useEffectEvent((result: unknown) => props.onComplete?.(result));
|
|
17
|
+
const errorText =
|
|
18
|
+
state.status === 'error' ? state.message || props.errorMessage?.(state.error) || props.labels.errorFallback : '';
|
|
19
|
+
const successText = state.status === 'success' ? props.labels.success(state.result) : '';
|
|
20
|
+
|
|
21
|
+
useEffect(
|
|
22
|
+
() => () => {
|
|
23
|
+
attempt.current?.abort();
|
|
24
|
+
},
|
|
25
|
+
[],
|
|
26
|
+
);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const was = previous.current;
|
|
29
|
+
previous.current = state.status;
|
|
30
|
+
if (state.status === 'error' && (was === 'submitting' || (was === 'error' && lastError.current !== errorText)))
|
|
31
|
+
announce(errorText, 'assertive');
|
|
32
|
+
lastError.current = errorText;
|
|
33
|
+
if (state.status !== 'success' || was !== 'submitting') return;
|
|
34
|
+
const controller = attempt.current;
|
|
35
|
+
announce(successText);
|
|
36
|
+
if (!controlled && controller) {
|
|
37
|
+
// The page-level region commits before a closing callback can unmount the wizard.
|
|
38
|
+
requestAnimationFrame(() => {
|
|
39
|
+
if (!controller.signal.aborted && attempt.current === controller) complete(state.result);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}, [state, successText, errorText, announce, controlled]);
|
|
43
|
+
|
|
44
|
+
const finish = async (values: V) => {
|
|
45
|
+
if (state.status === 'submitting' || state.status === 'success') return;
|
|
46
|
+
attempt.current?.abort();
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
attempt.current = controller;
|
|
49
|
+
if (!controlled) {
|
|
50
|
+
previous.current = 'submitting';
|
|
51
|
+
setInternal({ status: 'submitting' });
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const result = await props.onFinish(values, { signal: controller.signal });
|
|
55
|
+
if (!controlled && !controller.signal.aborted) setInternal({ status: 'success', result });
|
|
56
|
+
} catch (error: unknown) {
|
|
57
|
+
if (!controlled && !controller.signal.aborted) setInternal({ status: 'error', error });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const reset = () => {
|
|
61
|
+
attempt.current?.abort();
|
|
62
|
+
attempt.current = null;
|
|
63
|
+
previous.current = 'idle';
|
|
64
|
+
if (!controlled) setInternal({ status: 'idle' });
|
|
65
|
+
};
|
|
66
|
+
return { state, finish, reset, successText, errorText };
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,14 @@ export { meta as auditLogTemplateMeta } from './audit-log/template.meta';
|
|
|
13
13
|
export type { AuditLogProps } from './audit-log/Page';
|
|
14
14
|
export type { AuditEvent, AuditResult } from './audit-log/data';
|
|
15
15
|
export { default as CreateWizardPage } from './create-wizard/Page';
|
|
16
|
+
export { CreateWizardFlow } from './create-wizard/Flow';
|
|
17
|
+
export type {
|
|
18
|
+
CreateWizardFlowProps,
|
|
19
|
+
CreateWizardFlowLabels,
|
|
20
|
+
CreateWizardFlowStep,
|
|
21
|
+
CreateWizardFieldErrors,
|
|
22
|
+
CreateWizardSubmitState,
|
|
23
|
+
} from './create-wizard/Flow';
|
|
16
24
|
export { meta as createWizardTemplateMeta } from './create-wizard/template.meta';
|
|
17
25
|
export type { CreateWizardProps } from './create-wizard/Page';
|
|
18
26
|
export type { CreateWizardCopy, SourceOption, WizardRegion, WizardValues } from './create-wizard/data';
|