@tangle-network/ui 11.36.0 → 11.38.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/CHANGELOG.md +20 -0
- package/dist/code-presentation.d.ts +2 -0
- package/dist/code-presentation.js +2 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/lib/code-presentation.d.ts +92 -0
- package/dist/lib/code-presentation.js +437 -0
- package/dist/lib/surface-motion.js +34 -13
- package/dist/markdown/api-schema.d.ts +9 -0
- package/dist/markdown/api-schema.js +20 -0
- package/dist/markdown/code-block.d.ts +15 -7
- package/dist/markdown/code-block.js +120 -268
- package/dist/markdown.d.ts +4 -2
- package/dist/markdown.js +4 -2
- package/dist/primitives/button.js +2 -1
- package/dist/primitives/code-block.d.ts +1 -1
- package/dist/primitives/popover.js +11 -3
- package/dist/primitives/wizard.d.ts +30 -0
- package/dist/primitives/wizard.js +158 -0
- package/dist/primitives.d.ts +2 -1
- package/dist/primitives.js +2 -1
- package/package.json +7 -2
- package/src/code-presentation.ts +2 -0
- package/src/lib/code-presentation.ts +257 -0
- package/src/lib/surface-motion.tsx +22 -15
- package/src/markdown/api-presentation.test.tsx +63 -0
- package/src/markdown/api-schema.tsx +9 -0
- package/src/markdown/code-block.tsx +54 -185
- package/src/markdown/index.ts +2 -1
- package/src/primitives/button.tsx +2 -1
- package/src/primitives/code-block.tsx +1 -1
- package/src/primitives/index.ts +3 -0
- package/src/primitives/popover.tsx +6 -8
- package/src/primitives/wizard.stories.tsx +51 -0
- package/src/primitives/wizard.test.tsx +106 -0
- package/src/primitives/wizard.tsx +212 -0
- package/src/styles/presentation.css +8 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Check, ChevronLeft } from "lucide-react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A multi-step flow — onboarding, connection setup, anything where a person
|
|
8
|
+
* should see one decision at a time.
|
|
9
|
+
*
|
|
10
|
+
* The library owns the chrome (step rail, progress, footer); each step owns
|
|
11
|
+
* its content and its primary action. That separation is the point: products
|
|
12
|
+
* stop rebuilding stepper markup with drifting styles, and a person gets the
|
|
13
|
+
* same rhythm — one screen, one obvious action — in every Tangle app.
|
|
14
|
+
*
|
|
15
|
+
* Composition (DOM order is the step order):
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <Wizard onFinished={() => navigate("/done")}>
|
|
19
|
+
* <Wizard.Step title="Number" nextLabel="Continue">…</Wizard.Step>
|
|
20
|
+
* <Wizard.Step title="Verify" nextLabel="I replied" onNext={() => check()}>…</Wizard.Step>
|
|
21
|
+
* <Wizard.Step title="Done" nextLabel="Finish">…</Wizard.Step>
|
|
22
|
+
* </Wizard>
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* - **Uncontrolled by default**; pass `step` to drive it (a background poll
|
|
26
|
+
* can jump straight to Done). `onStepChange` mirrors every move.
|
|
27
|
+
* - **The step names its action.** `nextLabel` renders the footer's primary
|
|
28
|
+
* button; an async `onNext` shows `loading` and advances only on resolve,
|
|
29
|
+
* so failures keep the person on the step with the app's inline error.
|
|
30
|
+
* Omit `nextLabel` for steps whose content renders its own actions.
|
|
31
|
+
* - **Forward is earned, backward is free.** Future steps are not clickable;
|
|
32
|
+
* past steps are (people re-check earlier choices mid-flow).
|
|
33
|
+
* - **Mobile is a first-class shape**, not a squeezed desktop: below `sm` the
|
|
34
|
+
* rail collapses to "Step 2 of 3 · Verify" and a thin progress bar.
|
|
35
|
+
*
|
|
36
|
+
* Wizard renders no card — compose it inside a `Card` when the surface needs
|
|
37
|
+
* one, so containers-within-containers stay a choice, not a default.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
interface WizardContextValue {
|
|
41
|
+
current: number;
|
|
42
|
+
total: number;
|
|
43
|
+
goTo: (step: number) => void;
|
|
44
|
+
next: () => void;
|
|
45
|
+
back: () => void;
|
|
46
|
+
/** Set by Wizard: fires onFinished when the final step's action resolves. */
|
|
47
|
+
finish: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const WizardContext = React.createContext<WizardContextValue | null>(null);
|
|
51
|
+
|
|
52
|
+
function useWizardContext(component: string): WizardContextValue {
|
|
53
|
+
const context = React.useContext(WizardContext);
|
|
54
|
+
if (!context) throw new Error(`<${component}> must be used inside <Wizard>`);
|
|
55
|
+
return context;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WizardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
59
|
+
children: React.ReactNode;
|
|
60
|
+
/** Drives the active step; omit for uncontrolled with `defaultStep`. */
|
|
61
|
+
step?: number;
|
|
62
|
+
defaultStep?: number;
|
|
63
|
+
onStepChange?: (step: number) => void;
|
|
64
|
+
/** Fires when the last step's primary action resolves. */
|
|
65
|
+
onFinished?: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function Wizard({ children, step, defaultStep = 0, onStepChange, onFinished, className, ...rest }: WizardProps) {
|
|
69
|
+
const steps = React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement<WizardStepProps>[];
|
|
70
|
+
const [internal, setInternal] = React.useState(defaultStep);
|
|
71
|
+
const current = step !== undefined ? step : internal;
|
|
72
|
+
const total = steps.length;
|
|
73
|
+
|
|
74
|
+
const goTo = React.useCallback((target: number) => {
|
|
75
|
+
const bounded = Math.max(0, Math.min(target, total - 1));
|
|
76
|
+
if (bounded === current) return;
|
|
77
|
+
onStepChange?.(bounded);
|
|
78
|
+
if (step === undefined) setInternal(bounded);
|
|
79
|
+
}, [current, onStepChange, step, total]);
|
|
80
|
+
|
|
81
|
+
const context = React.useMemo<WizardContextValue>(() => ({
|
|
82
|
+
current,
|
|
83
|
+
total,
|
|
84
|
+
goTo,
|
|
85
|
+
next: () => goTo(current + 1),
|
|
86
|
+
back: () => goTo(current - 1),
|
|
87
|
+
finish: () => onFinished?.(),
|
|
88
|
+
}), [current, goTo, onFinished, total]);
|
|
89
|
+
|
|
90
|
+
const titles = steps.map(child => child.props.title);
|
|
91
|
+
const activeTitle = titles[current] ?? "";
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<WizardContext.Provider value={context}>
|
|
95
|
+
<div className={cn("w-full", className)} {...rest}>
|
|
96
|
+
{/* Desktop rail. */}
|
|
97
|
+
<ol aria-label="Steps" className="hidden items-center gap-2 sm:flex">
|
|
98
|
+
{titles.map((title, index) => {
|
|
99
|
+
const state = index < current ? "done" : index === current ? "active" : "future";
|
|
100
|
+
const marker = (
|
|
101
|
+
<>
|
|
102
|
+
<span className={cn(
|
|
103
|
+
"flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium",
|
|
104
|
+
state === "active" && "bg-primary text-primary-foreground",
|
|
105
|
+
state === "done" && "bg-primary/15 text-primary",
|
|
106
|
+
state === "future" && "bg-muted text-muted-foreground",
|
|
107
|
+
)}>
|
|
108
|
+
{state === "done" ? <Check className="size-3.5" aria-hidden /> : index + 1}
|
|
109
|
+
</span>
|
|
110
|
+
<span className={cn(
|
|
111
|
+
"text-sm",
|
|
112
|
+
state === "active" && "font-medium text-foreground",
|
|
113
|
+
state === "done" && "text-foreground",
|
|
114
|
+
state === "future" && "text-muted-foreground",
|
|
115
|
+
)}>{title}</span>
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
return (
|
|
119
|
+
<React.Fragment key={`${index}-${title}`}>
|
|
120
|
+
{index > 0 && <li aria-hidden className="h-px flex-1 bg-border" />}
|
|
121
|
+
<li>
|
|
122
|
+
{state === "future"
|
|
123
|
+
? <span aria-disabled="true" className="flex items-center gap-2 p-1 opacity-70">{marker}</span>
|
|
124
|
+
: <button type="button" onClick={() => goTo(index)}
|
|
125
|
+
className="-m-1 flex items-center gap-2 rounded-md p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
126
|
+
{marker}
|
|
127
|
+
</button>}
|
|
128
|
+
</li>
|
|
129
|
+
</React.Fragment>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
</ol>
|
|
133
|
+
{/* Mobile: position + progress, not a squeezed rail. */}
|
|
134
|
+
<div className="sm:hidden">
|
|
135
|
+
<p className="text-xs text-muted-foreground">
|
|
136
|
+
Step {current + 1} of {total} · <span className="font-medium text-foreground">{activeTitle}</span>
|
|
137
|
+
</p>
|
|
138
|
+
<div className="mt-2 h-1 overflow-hidden rounded-full bg-muted" role="progressbar"
|
|
139
|
+
aria-valuemin={1} aria-valuemax={total} aria-valuenow={current + 1} aria-label={`Step ${current + 1} of ${total}`}>
|
|
140
|
+
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${((current + 1) / total) * 100}%` }} />
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
{steps.map((child, index) => index === current
|
|
144
|
+
? <React.Fragment key={index}>{child}</React.Fragment>
|
|
145
|
+
: null)}
|
|
146
|
+
</div>
|
|
147
|
+
</WizardContext.Provider>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface WizardStepProps {
|
|
152
|
+
/** Shown in the rail (and the mobile position line). Steps read at a glance; keep it one or two words. */
|
|
153
|
+
title: string;
|
|
154
|
+
children: React.ReactNode;
|
|
155
|
+
/** The footer's primary action label. Omit when the step renders its own actions. */
|
|
156
|
+
nextLabel?: string;
|
|
157
|
+
/** Runs before advancing. Resolve (or return a non-false value) to advance; return false or throw to stay — the step's own content shows why. */
|
|
158
|
+
onNext?: () => Promise<unknown> | unknown;
|
|
159
|
+
/** Disables the primary action (a required choice is still missing). */
|
|
160
|
+
nextDisabled?: boolean;
|
|
161
|
+
/** Hides the Back button for steps where going back makes no sense (a completed handshake). */
|
|
162
|
+
hideBack?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function WizardStep({ title, children, nextLabel, onNext, nextDisabled, hideBack }: WizardStepProps) {
|
|
166
|
+
const { current, total, goTo, back, finish } = useWizardContext("Wizard.Step");
|
|
167
|
+
const [busy, setBusy] = React.useState(false);
|
|
168
|
+
const isLast = current === total - 1;
|
|
169
|
+
|
|
170
|
+
async function runNext() {
|
|
171
|
+
if (!onNext) {
|
|
172
|
+
if (isLast) finish(); else goTo(current + 1);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
setBusy(true);
|
|
176
|
+
try {
|
|
177
|
+
const result = await onNext();
|
|
178
|
+
if (result === false) return;
|
|
179
|
+
if (isLast) finish(); else goTo(current + 1);
|
|
180
|
+
} catch {
|
|
181
|
+
// Stay on the step; its content owns the error presentation. A rejected
|
|
182
|
+
// promise here must not surface as an unhandled flow crash.
|
|
183
|
+
} finally {
|
|
184
|
+
setBusy(false);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<section aria-label={title} className="pt-6">
|
|
190
|
+
{children}
|
|
191
|
+
{(nextLabel || !hideBack) && (
|
|
192
|
+
<div className="mt-6 flex items-center justify-between gap-2 border-t border-border pt-4">
|
|
193
|
+
{!hideBack && current > 0
|
|
194
|
+
? <Button type="button" variant="ghost" onClick={back}><ChevronLeft className="size-4" aria-hidden /> Back</Button>
|
|
195
|
+
: <span />}
|
|
196
|
+
{nextLabel && (
|
|
197
|
+
<Button type="button" className="w-full sm:w-auto" onClick={() => void runNext()} disabled={nextDisabled} loading={busy}>
|
|
198
|
+
{nextLabel}
|
|
199
|
+
</Button>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</section>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Dot-notation composition (`<Wizard.Step>`), attached after both definitions
|
|
208
|
+
// so each keeps a plain, tree-shakeable named export.
|
|
209
|
+
export interface Wizard extends React.FC<WizardProps> {
|
|
210
|
+
Step: typeof WizardStep;
|
|
211
|
+
}
|
|
212
|
+
(Wizard as Wizard).Step = WizardStep;
|
|
@@ -27,4 +27,12 @@
|
|
|
27
27
|
.tangle-native-dialog { max-width: min(32rem,calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow: auto; margin: auto; padding: 1.5rem; border: 1px solid hsl(var(--border)); border-radius: var(--radius-lg); background: hsl(var(--card)); color: hsl(var(--card-foreground)); }
|
|
28
28
|
.tangle-native-dialog::backdrop { background: var(--scrim); }
|
|
29
29
|
|
|
30
|
+
.tangle-api-meta { font-family: var(--font-mono); font-size: var(--font-size-sm); color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; }
|
|
31
|
+
.tangle-api-schema { margin: 1rem 0; }
|
|
32
|
+
.tangle-api-schema > div { padding-block: 0.75rem; border-bottom: 1px solid hsl(var(--border)); overflow-wrap: anywhere; }
|
|
33
|
+
.tangle-api-schema dt { font-family: var(--font-mono); font-size: var(--font-size-label); }
|
|
34
|
+
.tangle-api-schema dt span, .tangle-api-schema dd { color: hsl(var(--muted-foreground)); }
|
|
35
|
+
.tangle-api-schema dd { margin: 0.25rem 0 0; font-size: var(--font-size-label); }
|
|
30
36
|
.tangle-page-description { margin-top: 0.25rem; max-width: 65ch; color: hsl(var(--muted-foreground)); font-size: var(--font-size-label); overflow-wrap: anywhere; }
|
|
37
|
+
.tangle-code-tabs { overflow-x: auto; max-width: 100%; }
|
|
38
|
+
.tangle-code-tabs [role="tab"] { flex: none; min-height: var(--control-height-lg); }
|