@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/code-presentation.d.ts +2 -0
  3. package/dist/code-presentation.js +2 -0
  4. package/dist/index.d.ts +5 -2
  5. package/dist/index.js +5 -2
  6. package/dist/lib/code-presentation.d.ts +92 -0
  7. package/dist/lib/code-presentation.js +437 -0
  8. package/dist/lib/surface-motion.js +34 -13
  9. package/dist/markdown/api-schema.d.ts +9 -0
  10. package/dist/markdown/api-schema.js +20 -0
  11. package/dist/markdown/code-block.d.ts +15 -7
  12. package/dist/markdown/code-block.js +120 -268
  13. package/dist/markdown.d.ts +4 -2
  14. package/dist/markdown.js +4 -2
  15. package/dist/primitives/button.js +2 -1
  16. package/dist/primitives/code-block.d.ts +1 -1
  17. package/dist/primitives/popover.js +11 -3
  18. package/dist/primitives/wizard.d.ts +30 -0
  19. package/dist/primitives/wizard.js +158 -0
  20. package/dist/primitives.d.ts +2 -1
  21. package/dist/primitives.js +2 -1
  22. package/package.json +7 -2
  23. package/src/code-presentation.ts +2 -0
  24. package/src/lib/code-presentation.ts +257 -0
  25. package/src/lib/surface-motion.tsx +22 -15
  26. package/src/markdown/api-presentation.test.tsx +63 -0
  27. package/src/markdown/api-schema.tsx +9 -0
  28. package/src/markdown/code-block.tsx +54 -185
  29. package/src/markdown/index.ts +2 -1
  30. package/src/primitives/button.tsx +2 -1
  31. package/src/primitives/code-block.tsx +1 -1
  32. package/src/primitives/index.ts +3 -0
  33. package/src/primitives/popover.tsx +6 -8
  34. package/src/primitives/wizard.stories.tsx +51 -0
  35. package/src/primitives/wizard.test.tsx +106 -0
  36. package/src/primitives/wizard.tsx +212 -0
  37. package/src/styles/presentation.css +8 -0
@@ -0,0 +1,9 @@
1
+ import { schemaRows, type ApiField } from "../lib/code-presentation";
2
+ export interface ApiSchemaProps {fields:readonly ApiField[];label?:string;}
3
+ /** Presentation only: the consuming API owns all fields and constraints. */
4
+ export function ApiSchema({fields,label="Schema"}:ApiSchemaProps) {
5
+ return <dl aria-label={label} className="tangle-api-schema">{schemaRows(fields).map((field,index)=><div key={`${field.path}-${index}`} style={{paddingInlineStart:`${field.depth}rem`}}>
6
+ <dt><code>{field.path}</code> <span>{field.type}{field.required?" · required":""}</span></dt>
7
+ {field.description&&<dd>{field.description}</dd>}
8
+ </div>)}</dl>;
9
+ }
@@ -1,151 +1,27 @@
1
1
  import {
2
2
  memo,
3
- useCallback,
3
+ useId,
4
4
  useEffect,
5
5
  useRef,
6
6
  useState,
7
7
  type CSSProperties,
8
+ type ButtonHTMLAttributes,
9
+ type MouseEvent,
8
10
  type HTMLAttributes,
9
11
  type ReactNode,
10
12
  } from "react";
11
- import type { HighlighterCore, LanguageRegistration, ThemedToken } from "shiki/core";
13
+ import type { ThemedToken } from "shiki/core";
12
14
  import { Check, Copy, FileCode2 } from "lucide-react";
13
15
  import { focusRing, focusRingInset } from "../lib/focus";
14
16
  import { cn } from "../lib/utils";
17
+ import { SYNTAX, LANGUAGE_NAMES, PLAIN_TEXT, codeBlockGrammar, tokenize, tokenStyle, codePreStyle, codeTextStyle, codePresentation, createCopyFeedback, bindCodeTabs, apiMetadataText, type CopyFeedback, type CodeExample, type ApiMetadata } from "../lib/code-presentation";
18
+ export { codeBlockGrammar } from "../lib/code-presentation";
15
19
 
16
- // Leave variables unresolved: the browser resolves them on each token span,
17
- // including nested themes, inline overrides and CSS/media changes. No document
18
- // sampling, observers, layout reads or React re-render are needed to recolor code.
19
- const SYNTAX = {
20
- comment: "var(--syntax-comment, currentColor)",
21
- keyword: "var(--syntax-keyword, currentColor)",
22
- string: "var(--syntax-string, currentColor)",
23
- function: "var(--syntax-function, currentColor)",
24
- number: "var(--syntax-number, currentColor)",
25
- variable: "var(--syntax-variable, currentColor)",
26
- foreground: "var(--syntax-foreground, currentColor)",
27
- } as const;
28
-
29
- const SHIKI_THEME = "tangle-syntax";
30
- // Shiki's CSS-variables theme names token roles; each maps onto a Brand syntax token.
31
- const BRAND_COLOR_FOR_SHIKI: Record<string, string> = {
32
- "var(--shiki-foreground)": SYNTAX.foreground,
33
- "var(--shiki-token-comment)": SYNTAX.comment,
34
- "var(--shiki-token-keyword)": SYNTAX.keyword,
35
- "var(--shiki-token-string)": SYNTAX.string,
36
- "var(--shiki-token-string-expression)": SYNTAX.string,
37
- "var(--shiki-token-function)": SYNTAX.function,
38
- "var(--shiki-token-constant)": SYNTAX.number,
39
- "var(--shiki-token-link)": SYNTAX.number,
40
- "var(--shiki-token-parameter)": SYNTAX.variable,
41
- "var(--shiki-token-punctuation)": SYNTAX.foreground,
42
- };
43
-
44
- type GrammarModule = { default: LanguageRegistration[] };
45
- const GRAMMARS: Record<string, () => Promise<GrammarModule>> = {
46
- typescript: () => import("shiki/langs/typescript.mjs"),
47
- tsx: () => import("shiki/langs/tsx.mjs"),
48
- javascript: () => import("shiki/langs/javascript.mjs"),
49
- jsx: () => import("shiki/langs/jsx.mjs"),
50
- json: () => import("shiki/langs/json.mjs"),
51
- jsonc: () => import("shiki/langs/jsonc.mjs"),
52
- bash: () => import("shiki/langs/bash.mjs"),
53
- python: () => import("shiki/langs/python.mjs"),
54
- html: () => import("shiki/langs/html.mjs"),
55
- css: () => import("shiki/langs/css.mjs"),
56
- scss: () => import("shiki/langs/scss.mjs"),
57
- markdown: () => import("shiki/langs/markdown.mjs"),
58
- mdx: () => import("shiki/langs/mdx.mjs"),
59
- yaml: () => import("shiki/langs/yaml.mjs"),
60
- toml: () => import("shiki/langs/toml.mjs"),
61
- sql: () => import("shiki/langs/sql.mjs"),
62
- diff: () => import("shiki/langs/diff.mjs"),
63
- go: () => import("shiki/langs/go.mjs"),
64
- rust: () => import("shiki/langs/rust.mjs"),
65
- solidity: () => import("shiki/langs/solidity.mjs"),
66
- ruby: () => import("shiki/langs/ruby.mjs"),
67
- java: () => import("shiki/langs/java.mjs"),
68
- kotlin: () => import("shiki/langs/kotlin.mjs"),
69
- swift: () => import("shiki/langs/swift.mjs"),
70
- php: () => import("shiki/langs/php.mjs"),
71
- c: () => import("shiki/langs/c.mjs"),
72
- cpp: () => import("shiki/langs/cpp.mjs"),
73
- csharp: () => import("shiki/langs/csharp.mjs"),
74
- graphql: () => import("shiki/langs/graphql.mjs"),
75
- xml: () => import("shiki/langs/xml.mjs"),
76
- dockerfile: () => import("shiki/langs/dockerfile.mjs"),
77
- };
78
-
79
- const LANGUAGE_ALIASES: Record<string, string> = {
80
- ts: "typescript", mts: "typescript", cts: "typescript", js: "javascript", mjs: "javascript",
81
- cjs: "javascript", node: "javascript", sh: "bash", shell: "bash", zsh: "bash", console: "bash",
82
- shellscript: "bash", py: "python", python3: "python", yml: "yaml", md: "markdown", rs: "rust",
83
- sol: "solidity", rb: "ruby", kt: "kotlin", cs: "csharp", "c#": "csharp", "c++": "cpp",
84
- gql: "graphql", svg: "xml", docker: "dockerfile", patch: "diff", htm: "html",
85
- };
86
-
87
- const LANGUAGE_NAMES: Record<string, string> = {
88
- typescript: "TypeScript", tsx: "TSX", javascript: "JavaScript", jsx: "JSX", json: "JSON",
89
- jsonc: "JSONC", bash: "Bash", python: "Python", html: "HTML", css: "CSS", scss: "SCSS",
90
- markdown: "Markdown", mdx: "MDX", yaml: "YAML", toml: "TOML", sql: "SQL", diff: "Diff",
91
- go: "Go", rust: "Rust", solidity: "Solidity", ruby: "Ruby", java: "Java", kotlin: "Kotlin",
92
- swift: "Swift", php: "PHP", c: "C", cpp: "C++", csharp: "C#", graphql: "GraphQL", xml: "XML",
93
- dockerfile: "Dockerfile",
94
- };
95
-
96
- /** The Shiki grammar for a fence language, or undefined for plain text. */
97
- const PLAIN_TEXT = new Set(["text", "txt", "plain", "plaintext"]);
98
-
99
- export function codeBlockGrammar(language: string | undefined): string | undefined {
100
- if (!language) return undefined;
101
- const id = language.toLowerCase();
102
- const resolved = LANGUAGE_ALIASES[id] ?? id;
103
- return GRAMMARS[resolved] ? resolved : undefined;
104
- }
105
-
106
- let highlighterPromise: Promise<HighlighterCore> | undefined;
107
- const grammarLoads = new Map<string, Promise<void>>();
108
-
109
- function loadHighlighter(): Promise<HighlighterCore> {
110
- // The JavaScript regex engine needs no WebAssembly, so it runs under a strict CSP.
111
- return highlighterPromise ??= Promise.all([import("shiki/core"), import("shiki/engine/javascript")])
112
- .then(([core, engine]) => core.createHighlighterCore({
113
- themes: [core.createCssVariablesTheme({ name: SHIKI_THEME, variablePrefix: "--shiki-", fontStyle: true })],
114
- langs: [],
115
- engine: engine.createJavaScriptRegexEngine(),
116
- }))
117
- .catch((error: unknown) => {
118
- highlighterPromise = undefined;
119
- throw error;
120
- });
121
- }
122
-
123
- async function tokenize(code: string, grammar: string): Promise<ThemedToken[][]> {
124
- const highlighter = await loadHighlighter();
125
- let load = grammarLoads.get(grammar);
126
- if (!load) {
127
- load = GRAMMARS[grammar]!().then((module) => highlighter.loadLanguage(module.default));
128
- load.catch(() => grammarLoads.delete(grammar));
129
- grammarLoads.set(grammar, load);
130
- }
131
- await load;
132
- return highlighter.codeToTokensBase(code, { lang: grammar, theme: SHIKI_THEME });
133
- }
134
-
135
- function tokenStyle(token: ThemedToken): CSSProperties | undefined {
136
- const color = token.color ? BRAND_COLOR_FOR_SHIKI[token.color] ?? SYNTAX.foreground : undefined;
137
- const style = token.fontStyle ?? 0;
138
- if (!color && !style) return undefined;
139
- return {
140
- ...(color ? { color } : {}),
141
- ...(style & 1 ? { fontStyle: "italic" } : {}),
142
- ...(style & 2 ? { fontWeight: "bold" } : {}),
143
- ...(style & 4 ? { textDecoration: "underline" } : {}),
144
- };
145
- }
146
20
 
147
21
  export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
148
22
  code: string;
23
+ examples?: readonly CodeExample[];
24
+ metadata?: ApiMetadata;
149
25
  language?: string;
150
26
  /**
151
27
  * Header text. Defaults to the language's name. Set this when the display
@@ -165,7 +41,13 @@ export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
165
41
  * plain code first (and on the server), then highlights once its grammar loads.
166
42
  */
167
43
  export const CodeBlock = memo(
168
- ({ code, language, label, filename, showLineNumbers = false, light, className, children, ...props }: CodeBlockProps) => {
44
+ ({ code: defaultCode, language: defaultLanguage, label, filename: defaultFilename, examples, metadata, showLineNumbers = false, light, className, children, ...props }: CodeBlockProps) => {
45
+ const [selected,setSelected]=useState(0);
46
+ const tabId=useId(),tabList=useRef<HTMLDivElement>(null);
47
+ const selectedIndex=Math.min(selected,Math.max(0,(examples?.length??1)-1));
48
+ const example=examples?.[selectedIndex];
49
+ const code=example?.code??defaultCode,language=example?.language??defaultLanguage,filename=example?.filename??defaultFilename;
50
+ useEffect(()=>{if(tabList.current)return bindCodeTabs(tabList.current,setSelected);},[examples?.length]);
169
51
  const grammar = codeBlockGrammar(language);
170
52
  const [highlighted, setHighlighted] = useState<{ code: string; grammar: string; lines: ThemedToken[][] } | null>(null);
171
53
  useEffect(() => {
@@ -182,21 +64,8 @@ export const CodeBlock = memo(
182
64
  // A plain-text fence names no language worth showing; it keeps its copy control.
183
65
  const languageName = grammar ? LANGUAGE_NAMES[grammar] : PLAIN_TEXT.has(language?.toLowerCase() ?? "") ? undefined : language;
184
66
  const headerLabel = label ?? (filename ? undefined : languageName);
185
- const hasHeader = Boolean(filename || headerLabel);
67
+ const hasHeader = Boolean(filename || headerLabel || metadata || examples?.length);
186
68
  const mode = light === undefined ? {} : { "data-theme": light ? "light" : "dark" };
187
- const preStyle: CSSProperties = {
188
- margin: 0,
189
- padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
190
- background: "transparent",
191
- color: SYNTAX.foreground,
192
- fontSize: "var(--code-font-size, 0.8125rem)",
193
- lineHeight: "var(--code-line-height, 1.5)",
194
- overflowX: "auto",
195
- };
196
- const codeStyle: CSSProperties = {
197
- fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)",
198
- whiteSpace: "pre",
199
- };
200
69
  const isHighlighted = Boolean(highlighted && highlighted.code === code && highlighted.grammar === grammar);
201
70
  const lines: Array<Array<{ content: string; style?: CSSProperties }>> = isHighlighted
202
71
  ? highlighted!.lines.map((line) => line.map((token) => ({ content: token.content, style: tokenStyle(token) })))
@@ -205,13 +74,14 @@ export const CodeBlock = memo(
205
74
 
206
75
  return (
207
76
  <div
208
- className={cn("group relative overflow-hidden rounded-lg border font-mono", "bg-card border-border", className)}
77
+ className={cn(codePresentation.root, className)}
209
78
  data-highlighted={isHighlighted ? "true" : undefined}
210
79
  {...props}
211
80
  {...mode}
212
81
  >
213
82
  {hasHeader && (
214
- <div className="flex items-center justify-between gap-3 border-b border-border px-3 py-1.5 bg-[var(--code-header-bg,hsl(var(--background)))]">
83
+ <div className={codePresentation.header}>
84
+ {metadata && <span className="tangle-api-meta">{apiMetadataText(metadata)}</span>}
215
85
  {filename ? (
216
86
  <span className="flex min-w-0 items-center gap-2 font-sans text-sm">
217
87
  <FileCode2 aria-hidden className="size-4 shrink-0 text-muted-foreground" />
@@ -222,6 +92,7 @@ export const CodeBlock = memo(
222
92
  <span className="font-sans text-sm font-medium text-muted-foreground">{headerLabel}</span>
223
93
  )}
224
94
  {children}
95
+ {examples?.length ? <CopyButton text={code}/> : null}
225
96
  </div>
226
97
  )}
227
98
  {!hasHeader && children && (
@@ -229,6 +100,10 @@ export const CodeBlock = memo(
229
100
  {children}
230
101
  </div>
231
102
  )}
103
+ {examples && examples.length > 1 && <div role="tablist" aria-label="Code examples" ref={tabList} className={codePresentation.tabs}>
104
+ {examples.map((item,index)=><button key={index} id={`${tabId}-tab-${index}`} type="button" role="tab" className={codePresentation.tab} data-state={index===selectedIndex?"active":"inactive"} aria-selected={index===selectedIndex} tabIndex={index===selectedIndex?0:-1} aria-controls={`${tabId}-panel`}>{item.label}</button>)}
105
+ </div>}
106
+ <div id={`${tabId}-panel`} role={examples && examples.length > 1 ? "tabpanel" : undefined} aria-labelledby={examples && examples.length > 1 ? `${tabId}-tab-${selectedIndex}` : undefined}>
232
107
  {/* A long line scrolls the block sideways, which a keyboard reaches only
233
108
  if the block takes focus; a label on a bare <pre> is prohibited ARIA,
234
109
  so the block is a named region. Inset: the rounded card clips an outer ring. */}
@@ -237,9 +112,9 @@ export const CodeBlock = memo(
237
112
  role="region"
238
113
  aria-label={regionName ? `${regionName} code` : "Code"}
239
114
  className={focusRingInset}
240
- style={preStyle}
115
+ style={codePreStyle}
241
116
  >
242
- <code style={codeStyle}>
117
+ <code style={codeTextStyle}>
243
118
  {/* Plain code without line numbers stays one text node, as the server renders it. */}
244
119
  {!isHighlighted && !showLineNumbers ? code : lines.map((line, index) => (
245
120
  <span key={index}>
@@ -261,53 +136,46 @@ export const CodeBlock = memo(
261
136
  ))}
262
137
  </code>
263
138
  </pre>
139
+ </div>
264
140
  </div>
265
141
  );
266
142
  },
267
143
  );
268
144
  CodeBlock.displayName = "CodeBlock";
269
145
 
270
- /** Copy-to-clipboard button for use inside CodeBlock. */
271
- export const CopyButton = memo(({ text }: { text: string }) => {
272
- const [copied, setCopied] = useState(false);
273
- // Status text for the live region. Cleared before each attempt so a repeat
274
- // copy or a failure is announced again.
275
- const [status, setStatus] = useState("");
276
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
277
-
278
- useEffect(() => {
279
- return () => { if (timerRef.current) clearTimeout(timerRef.current); };
280
- }, []);
281
-
282
- const handleCopy = useCallback(async () => {
283
- setStatus("");
284
- if (timerRef.current) clearTimeout(timerRef.current);
285
- try {
286
- await navigator.clipboard.writeText(text);
287
- setCopied(true);
288
- setStatus("Copied to clipboard");
289
- } catch (err) {
290
- console.warn("Clipboard write failed:", err);
291
- setCopied(false);
292
- setStatus("Copy failed");
293
- }
294
- timerRef.current = setTimeout(() => {
295
- setCopied(false);
296
- setStatus("");
297
- }, 2000);
298
- }, [text]);
299
-
300
- const label = copied ? "Copied" : "Copy to clipboard";
146
+ export interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>,"children"> {
147
+ text:string;
148
+ /** Supply for a visible action label; omitted preserves the icon-only control. */
149
+ label?:string;
150
+ copiedLabel?:string;
151
+ failedLabel?:string;
152
+ }
153
+ /** Copy-to-clipboard button for use inside CodeBlock or other exact-text actions. */
154
+ export const CopyButton = memo(({ text, label: actionLabel, copiedLabel="Copied", failedLabel="Copy failed", className, onClick, ...props }: CopyButtonProps) => {
155
+ const [{copied,status,pending,loading,longRunning},setFeedback] = useState<CopyFeedback>({copied:false,status:"",pending:false});
156
+ const button=useRef<HTMLButtonElement>(null);
157
+ const feedback=useRef<ReturnType<typeof createCopyFeedback>|null>(null);
158
+ useEffect(()=>{const controller=createCopyFeedback(setFeedback,undefined,button.current??undefined);feedback.current=controller;return()=>controller.dispose();},[]);
159
+ const handleCopy=(event:MouseEvent<HTMLButtonElement>)=>{if(pending||props["aria-disabled"]===true||props["aria-disabled"]==="true")return;onClick?.(event);if(!event.defaultPrevented)void feedback.current?.copy(text);};
160
+
161
+ const label = copied ? copiedLabel : actionLabel && status === "Copy failed" ? failedLabel : actionLabel ?? "Copy to clipboard";
301
162
  return (
302
163
  <>
303
164
  <button
165
+ {...props}
166
+ ref={button}
167
+ data-copy-loading={!!loading}
304
168
  type="button"
305
169
  onClick={handleCopy}
306
170
  className={cn(
307
- "flex items-center justify-center w-6 h-6 rounded-md bg-muted border border-border hover:border-[var(--border-strong)] transition-colors",
171
+ codePresentation.copy,
172
+ actionLabel && "gap-2 px-3",
308
173
  focusRing,
174
+ className,
309
175
  )}
310
- aria-label={label}
176
+ aria-label={pending?"Copying to clipboard":label}
177
+ aria-busy={pending||props["aria-busy"]}
178
+ aria-disabled={pending||props["aria-disabled"]}
311
179
  title={label}
312
180
  >
313
181
  {copied ? (
@@ -315,9 +183,10 @@ export const CopyButton = memo(({ text }: { text: string }) => {
315
183
  ) : (
316
184
  <Copy aria-hidden="true" className="w-3.5 h-3.5 text-muted-foreground" />
317
185
  )}
186
+ {actionLabel && <span>{label}</span>}
318
187
  </button>
319
188
  {/* Announce the result; the icon swap alone is silent to screen readers. */}
320
- <span className="sr-only" role="status" aria-live="polite">
189
+ <span className={codePresentation.copyStatus} data-copy-long-work={!!longRunning} role="status" aria-live="polite">
321
190
  {status}
322
191
  </span>
323
192
  </>
@@ -5,6 +5,7 @@ export {
5
5
  type MarkdownLinkChipResolver,
6
6
  type MarkdownProps,
7
7
  } from "./markdown";
8
- export { CodeBlock, CopyButton, codeBlockGrammar, type CodeBlockProps } from "./code-block";
8
+ export { CodeBlock, CopyButton, codeBlockGrammar, type CopyButtonProps, type CodeBlockProps } from "./code-block";
9
9
  export { fenceInfo } from "./code-meta";
10
10
  export { numericColumns, tableCsv } from "./markdown-table";
11
+ export { ApiSchema, type ApiSchemaProps } from "./api-schema";
@@ -1,6 +1,7 @@
1
1
  import { Slot } from "@radix-ui/react-slot";
2
2
  import { buttonVariants } from "../presentation";
3
3
  import * as React from "react";
4
+ import { controlMotion } from "../lib/motion";
4
5
  import { focusRing } from "../lib/focus";
5
6
  import { ixControl, ixControlSoft, ixSelected } from "../lib/interaction";
6
7
  import { cn } from "../lib/utils";
@@ -18,7 +19,7 @@ import { cn } from "../lib/utils";
18
19
  // every surface, size and type class. It is for pressable rows, tiles and pills
19
20
  // that draw their own layout; anything that reads as a button uses a styled
20
21
  // variant and a size.
21
- const bareButton = `${focusRing} ${ixControlSoft} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50`;
22
+ const bareButton = `${focusRing} ${controlMotion} ${ixControlSoft} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50`;
22
23
 
23
24
  /** One Button render's classes: variant and size, the toggle states, then the caller's. */
24
25
  function buttonClassName(
@@ -1,7 +1,7 @@
1
1
  // Re-exports the canonical themed CodeBlock from the markdown package.
2
2
  // Kept here for backwards compatibility with openui and other importers.
3
3
  export { CodeBlock, CopyButton } from "../markdown/code-block";
4
- export type { CodeBlockProps } from "../markdown/code-block";
4
+ export type { CodeBlockProps, CopyButtonProps } from "../markdown/code-block";
5
5
 
6
6
  import { cn } from "../lib/utils";
7
7
 
@@ -217,6 +217,9 @@ export type { TagProps } from "./tag";
217
217
  export { Chip } from "./chip";
218
218
  export type { ChipProps } from "./chip";
219
219
 
220
+ export { Wizard, WizardStep } from "./wizard";
221
+ export type { WizardProps, WizardStepProps } from "./wizard";
222
+
220
223
  export { IconTile, initialsOf } from "./icon-tile";
221
224
  export type { IconTileProps } from "./icon-tile";
222
225
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  import * as PopoverPrimitive from "@radix-ui/react-popover";
4
4
  import * as React from "react";
5
+ import { SurfaceMotion } from "../lib/surface-motion";
6
+ import { surfaceMotion } from "../lib/motion";
5
7
  import { cn } from "../lib/utils";
6
8
 
7
9
  const Popover = PopoverPrimitive.Root;
@@ -20,7 +22,7 @@ const PopoverClose = PopoverPrimitive.Close;
20
22
  const PopoverContent = React.forwardRef<
21
23
  React.ElementRef<typeof PopoverPrimitive.Content>,
22
24
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
23
- >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
25
+ >(({ className, align = "center", sideOffset = 4, asChild, children, ...props }, ref) => (
24
26
  <PopoverPrimitive.Portal>
25
27
  <PopoverPrimitive.Content
26
28
  ref={ref}
@@ -28,16 +30,12 @@ const PopoverContent = React.forwardRef<
28
30
  sideOffset={sideOffset}
29
31
  className={cn(
30
32
  "z-50 w-72 max-w-[calc(100vw-2rem)] origin-(--radix-popover-content-transform-origin) rounded-xl border border-border bg-card p-4 text-foreground shadow-[var(--shadow-card)] outline-hidden",
31
- "data-[state=closed]:animate-out data-[state=open]:animate-in",
32
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
33
- "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
34
- "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
35
- "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
36
- "motion-reduce:animate-none",
33
+ surfaceMotion,
37
34
  className,
38
35
  )}
39
36
  {...props}
40
- />
37
+ asChild
38
+ ><SurfaceMotion kind="popover" asChild={asChild}>{children}</SurfaceMotion></PopoverPrimitive.Content>
41
39
  </PopoverPrimitive.Portal>
42
40
  ));
43
41
  PopoverContent.displayName = PopoverPrimitive.Content.displayName;
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import * as React from "react";
3
+ import { Button } from "./button";
4
+ import { Input } from "./input";
5
+ import { Wizard } from "./wizard";
6
+
7
+ const meta: Meta<typeof Wizard> = {
8
+ title: "Primitives/Wizard",
9
+ component: Wizard,
10
+ };
11
+ export default meta;
12
+
13
+ /** The intended shape: linear steps, one primary action each, async gate in the middle. */
14
+ export const ConnectNumber: StoryObj<typeof Wizard> = {
15
+ render: () => (
16
+ <div className="mx-auto max-w-lg rounded-xl border border-border bg-card p-6">
17
+ <Wizard onFinished={() => alert("connected")}>
18
+ <Wizard.Step title="Number" nextLabel="Continue">
19
+ <label className="grid gap-2 text-sm">
20
+ Your business number
21
+ <Input placeholder="+1 618 297 8214" />
22
+ </label>
23
+ </Wizard.Step>
24
+ <Wizard.Step title="Verify" nextLabel="I replied" onNext={() => new Promise(resolve => setTimeout(resolve, 800))}>
25
+ <p className="text-sm text-muted-foreground">
26
+ We sent a code to your phone. Reply to the message, then continue.
27
+ </p>
28
+ </Wizard.Step>
29
+ <Wizard.Step title="Done" nextLabel="Finish">
30
+ <p className="text-sm">Your number is live. Guests can text it now.</p>
31
+ </Wizard.Step>
32
+ </Wizard>
33
+ </div>
34
+ ),
35
+ };
36
+
37
+ /** A step whose content owns the actions entirely: no footer, no default chrome. */
38
+ export const CustomActions: StoryObj<typeof Wizard> = {
39
+ render: () => (
40
+ <div className="mx-auto max-w-lg rounded-xl border border-border bg-card p-6">
41
+ <Wizard>
42
+ <Wizard.Step title="Choice">
43
+ <div className="grid gap-3">
44
+ <Button onClick={() => undefined}>Use my own number</Button>
45
+ <Button variant="outline" onClick={() => undefined}>Give me a Tangle number</Button>
46
+ </div>
47
+ </Wizard.Step>
48
+ </Wizard>
49
+ </div>
50
+ ),
51
+ };
@@ -0,0 +1,106 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import * as React from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { Wizard } from "./wizard";
6
+
7
+ function Flow(props: Partial<React.ComponentProps<typeof Wizard>> = {}) {
8
+ return (
9
+ <Wizard {...props}>
10
+ <Wizard.Step title="Number" nextLabel="Continue">pick a number</Wizard.Step>
11
+ <Wizard.Step title="Verify" nextLabel="I replied">verify panel</Wizard.Step>
12
+ <Wizard.Step title="Done" nextLabel="Finish">done panel</Wizard.Step>
13
+ </Wizard>
14
+ );
15
+ }
16
+
17
+ describe("Wizard", () => {
18
+ it("renders the first step and the rail, marks the active step, hides future panels", () => {
19
+ render(<Flow />);
20
+ expect(screen.getByText("pick a number")).toBeVisible();
21
+ expect(screen.queryByText("verify panel")).toBeNull();
22
+ expect(screen.getByRole("list", { name: "Steps" })).toBeVisible();
23
+ expect(screen.getAllByText("Number").length).toBeGreaterThan(0);
24
+ });
25
+
26
+ it("advances on the primary action and mirrors onStepChange", async () => {
27
+ const onStepChange = vi.fn();
28
+ render(<Flow onStepChange={onStepChange} />);
29
+ await userEvent.click(screen.getByRole("button", { name: "Continue" }));
30
+ expect(screen.getByText("verify panel")).toBeVisible();
31
+ expect(onStepChange).toHaveBeenCalledWith(1);
32
+ });
33
+
34
+ it("back returns to the previous step; the first step has no back", async () => {
35
+ render(<Flow />);
36
+ await userEvent.click(screen.getByRole("button", { name: "Continue" }));
37
+ await userEvent.click(screen.getByRole("button", { name: "Back" }));
38
+ expect(screen.getByText("pick a number")).toBeVisible();
39
+ expect(screen.queryByRole("button", { name: "Back" })).toBeNull();
40
+ });
41
+
42
+ it("future steps are not clickable; past steps are", async () => {
43
+ render(<Flow />);
44
+ fireEvent.click(screen.getByText("Verify")); // future: a span, not a button
45
+ expect(screen.getByText("pick a number")).toBeVisible();
46
+ await userEvent.click(screen.getByRole("button", { name: "Continue" }));
47
+ await userEvent.click(screen.getAllByRole("button", { name: /Number/ })[0]!); // past: clickable
48
+ expect(screen.getByText("pick a number")).toBeVisible();
49
+ });
50
+
51
+ it("an async onNext advances only on resolve; returning false keeps the step", async () => {
52
+ let resolveCheck: (value: unknown) => void = () => {};
53
+ const onNext = vi.fn(() => new Promise(resolve => { resolveCheck = resolve; }));
54
+ render(
55
+ <Wizard>
56
+ <Wizard.Step title="Check" nextLabel="Check" onNext={onNext}>checking</Wizard.Step>
57
+ <Wizard.Step title="Next">next panel</Wizard.Step>
58
+ </Wizard>,
59
+ );
60
+ await userEvent.click(screen.getByRole("button", { name: "Check" }));
61
+ expect(screen.getByText("checking")).toBeVisible(); // still here while pending
62
+ resolveCheck(true);
63
+ await waitFor(() => expect(screen.getByText("next panel")).toBeVisible());
64
+ expect(onNext).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it("a rejected onNext stays on the step without crashing", async () => {
68
+ const onNext = vi.fn(() => Promise.reject(new Error("no")));
69
+ render(
70
+ <Wizard>
71
+ <Wizard.Step title="Check" nextLabel="Check" onNext={onNext}>checking</Wizard.Step>
72
+ <Wizard.Step title="Next">next panel</Wizard.Step>
73
+ </Wizard>,
74
+ );
75
+ await userEvent.click(screen.getByRole("button", { name: "Check" }));
76
+ await waitFor(() => expect(screen.getByRole("button", { name: "Check" })).toBeEnabled());
77
+ expect(screen.getByText("checking")).toBeVisible();
78
+ });
79
+
80
+ it("the last step fires onFinished instead of advancing", async () => {
81
+ const onFinished = vi.fn();
82
+ render(<Flow onFinished={onFinished} defaultStep={2} />);
83
+ await userEvent.click(screen.getByRole("button", { name: "Finish" }));
84
+ expect(onFinished).toHaveBeenCalledTimes(1);
85
+ expect(screen.getByText("done panel")).toBeVisible();
86
+ });
87
+
88
+ it("step can drive externally (controlled) and omit the default footer action", () => {
89
+ render(
90
+ <Wizard step={1}>
91
+ <Wizard.Step title="One">one</Wizard.Step>
92
+ <Wizard.Step title="Two"><button type="button">custom</button></Wizard.Step>
93
+ </Wizard>,
94
+ );
95
+ expect(screen.getAllByText("Two").length).toBeGreaterThan(0);
96
+ expect(screen.getByText("custom")).toBeVisible();
97
+ expect(screen.queryByRole("button", { name: "Continue" })).toBeNull();
98
+ });
99
+
100
+ it("throws when a step is used outside a wizard", () => {
101
+ // Silence the expected console.error from React's error boundary path.
102
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
103
+ expect(() => render(<Wizard.Step title="Alone">x</Wizard.Step>)).toThrow(/must be used inside/);
104
+ spy.mockRestore();
105
+ });
106
+ });