@tangle-network/ui 11.35.0 → 11.37.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/README.md +1 -1
- package/dist/code-presentation.d.ts +2 -0
- package/dist/code-presentation.js +2 -0
- package/dist/index.d.ts +8 -5
- package/dist/index.js +6 -3
- package/dist/lib/code-presentation.d.ts +92 -0
- package/dist/lib/code-presentation.js +437 -0
- package/dist/lib/control-presentation.d.ts +34 -1
- package/dist/lib/control-presentation.js +1 -1
- package/dist/lib/focus.d.ts +2 -2
- package/dist/lib/focus.js +3 -2
- package/dist/lib/motion.d.ts +4 -0
- package/dist/lib/motion.js +7 -0
- package/dist/lib/surface-motion.js +105 -0
- 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/presentation.d.ts +88 -0
- package/dist/presentation.js +183 -0
- package/dist/primitives/alert-dialog.js +1 -1
- package/dist/primitives/alert.d.ts +1 -2
- package/dist/primitives/button.d.ts +2 -5
- package/dist/primitives/button.js +5 -31
- package/dist/primitives/card.js +3 -7
- package/dist/primitives/code-block.d.ts +1 -1
- package/dist/primitives/date-picker.d.ts +1 -1
- package/dist/primitives/dialog.js +29 -16
- package/dist/primitives/empty-state.js +6 -5
- package/dist/primitives/heading.d.ts +1 -0
- package/dist/primitives/heading.js +3 -2
- package/dist/primitives/help-text.js +2 -1
- package/dist/primitives/input.d.ts +1 -4
- package/dist/primitives/input.js +3 -24
- package/dist/primitives/label.js +1 -2
- package/dist/primitives/metric-strip.js +7 -12
- package/dist/primitives/page-header.d.ts +1 -0
- package/dist/primitives/page-header.js +7 -6
- package/dist/primitives/page-shell.d.ts +8 -2
- package/dist/primitives/page-shell.js +21 -2
- package/dist/primitives/pagination.js +1 -1
- package/dist/primitives/popover.js +11 -3
- package/dist/primitives/sheet.js +29 -19
- package/dist/primitives/skeleton.js +1 -1
- package/dist/primitives/status-pill.d.ts +3 -1
- package/dist/primitives/status-pill.js +12 -3
- package/dist/primitives/table.js +10 -9
- package/dist/primitives/tabs.js +3 -12
- package/dist/primitives/tooltip.js +11 -3
- package/dist/primitives.d.ts +3 -2
- package/dist/primitives.js +2 -1
- package/dist/utils.d.ts +1 -1
- package/package.json +20 -5
- package/src/code-presentation.ts +2 -0
- package/src/lib/code-presentation.ts +257 -0
- package/src/lib/control-presentation.ts +2 -4
- package/src/lib/focus.ts +3 -2
- package/src/lib/motion.ts +7 -0
- package/src/lib/surface-motion.tsx +85 -0
- 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/presentation.ts +205 -0
- package/src/primitives/alert.tsx +1 -2
- package/src/primitives/button.tsx +6 -41
- package/src/primitives/card.tsx +5 -10
- package/src/primitives/checkbox-separator-sheet-tooltip.test.tsx +2 -1
- package/src/primitives/code-block.tsx +1 -1
- package/src/primitives/control-presentation.test.tsx +2 -7
- package/src/primitives/dialog.tsx +19 -18
- package/src/primitives/empty-state.tsx +6 -5
- package/src/primitives/heading.tsx +4 -2
- package/src/primitives/help-text.tsx +3 -2
- package/src/primitives/input.tsx +3 -31
- package/src/primitives/label.tsx +2 -4
- package/src/primitives/metric-strip.tsx +8 -17
- package/src/primitives/page-header.tsx +8 -5
- package/src/primitives/page-shell.tsx +13 -2
- package/src/primitives/popover.tsx +6 -8
- package/src/primitives/sheet.tsx +16 -11
- package/src/primitives/skeleton.tsx +1 -1
- package/src/primitives/status-pill.test.tsx +1 -0
- package/src/primitives/status-pill.tsx +10 -4
- package/src/primitives/table.tsx +10 -9
- package/src/primitives/tabs.tsx +5 -17
- package/src/primitives/tooltip.tsx +6 -5
- package/src/styles/compat.css +12 -0
- package/src/styles/presentation.css +38 -0
- package/src/primitives/page-shell.test.tsx +0 -24
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { tabsPresentation } from "../presentation";
|
|
2
|
+
import type { HighlighterCore, LanguageRegistration, ThemedToken } from "shiki/core";
|
|
3
|
+
// Leave variables unresolved: the browser resolves them on each token span,
|
|
4
|
+
// including nested themes, inline overrides and CSS/media changes. No document
|
|
5
|
+
// sampling, observers, layout reads or React re-render are needed to recolor code.
|
|
6
|
+
export const SYNTAX = {
|
|
7
|
+
comment: "var(--syntax-comment, currentColor)",
|
|
8
|
+
keyword: "var(--syntax-keyword, currentColor)",
|
|
9
|
+
string: "var(--syntax-string, currentColor)",
|
|
10
|
+
function: "var(--syntax-function, currentColor)",
|
|
11
|
+
number: "var(--syntax-number, currentColor)",
|
|
12
|
+
variable: "var(--syntax-variable, currentColor)",
|
|
13
|
+
foreground: "var(--syntax-foreground, currentColor)",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
const SHIKI_THEME = "tangle-syntax";
|
|
17
|
+
// Shiki's CSS-variables theme names token roles; each maps onto a Brand syntax token.
|
|
18
|
+
const BRAND_COLOR_FOR_SHIKI: Record<string, string> = {
|
|
19
|
+
"var(--shiki-foreground)": SYNTAX.foreground,
|
|
20
|
+
"var(--shiki-token-comment)": SYNTAX.comment,
|
|
21
|
+
"var(--shiki-token-keyword)": SYNTAX.keyword,
|
|
22
|
+
"var(--shiki-token-string)": SYNTAX.string,
|
|
23
|
+
"var(--shiki-token-string-expression)": SYNTAX.string,
|
|
24
|
+
"var(--shiki-token-function)": SYNTAX.function,
|
|
25
|
+
"var(--shiki-token-constant)": SYNTAX.number,
|
|
26
|
+
"var(--shiki-token-link)": SYNTAX.number,
|
|
27
|
+
"var(--shiki-token-parameter)": SYNTAX.variable,
|
|
28
|
+
"var(--shiki-token-punctuation)": SYNTAX.foreground,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type GrammarModule = { default: LanguageRegistration[] };
|
|
32
|
+
const GRAMMARS: Record<string, () => Promise<GrammarModule>> = {
|
|
33
|
+
typescript: () => import("shiki/langs/typescript.mjs"),
|
|
34
|
+
tsx: () => import("shiki/langs/tsx.mjs"),
|
|
35
|
+
javascript: () => import("shiki/langs/javascript.mjs"),
|
|
36
|
+
jsx: () => import("shiki/langs/jsx.mjs"),
|
|
37
|
+
json: () => import("shiki/langs/json.mjs"),
|
|
38
|
+
jsonc: () => import("shiki/langs/jsonc.mjs"),
|
|
39
|
+
bash: () => import("shiki/langs/bash.mjs"),
|
|
40
|
+
python: () => import("shiki/langs/python.mjs"),
|
|
41
|
+
html: () => import("shiki/langs/html.mjs"),
|
|
42
|
+
css: () => import("shiki/langs/css.mjs"),
|
|
43
|
+
scss: () => import("shiki/langs/scss.mjs"),
|
|
44
|
+
markdown: () => import("shiki/langs/markdown.mjs"),
|
|
45
|
+
mdx: () => import("shiki/langs/mdx.mjs"),
|
|
46
|
+
yaml: () => import("shiki/langs/yaml.mjs"),
|
|
47
|
+
toml: () => import("shiki/langs/toml.mjs"),
|
|
48
|
+
sql: () => import("shiki/langs/sql.mjs"),
|
|
49
|
+
diff: () => import("shiki/langs/diff.mjs"),
|
|
50
|
+
go: () => import("shiki/langs/go.mjs"),
|
|
51
|
+
rust: () => import("shiki/langs/rust.mjs"),
|
|
52
|
+
solidity: () => import("shiki/langs/solidity.mjs"),
|
|
53
|
+
ruby: () => import("shiki/langs/ruby.mjs"),
|
|
54
|
+
java: () => import("shiki/langs/java.mjs"),
|
|
55
|
+
kotlin: () => import("shiki/langs/kotlin.mjs"),
|
|
56
|
+
swift: () => import("shiki/langs/swift.mjs"),
|
|
57
|
+
php: () => import("shiki/langs/php.mjs"),
|
|
58
|
+
c: () => import("shiki/langs/c.mjs"),
|
|
59
|
+
cpp: () => import("shiki/langs/cpp.mjs"),
|
|
60
|
+
csharp: () => import("shiki/langs/csharp.mjs"),
|
|
61
|
+
graphql: () => import("shiki/langs/graphql.mjs"),
|
|
62
|
+
xml: () => import("shiki/langs/xml.mjs"),
|
|
63
|
+
dockerfile: () => import("shiki/langs/dockerfile.mjs"),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
67
|
+
ts: "typescript", mts: "typescript", cts: "typescript", js: "javascript", mjs: "javascript",
|
|
68
|
+
cjs: "javascript", node: "javascript", sh: "bash", shell: "bash", zsh: "bash", console: "bash",
|
|
69
|
+
shellscript: "bash", py: "python", python3: "python", yml: "yaml", md: "markdown", rs: "rust",
|
|
70
|
+
sol: "solidity", rb: "ruby", kt: "kotlin", cs: "csharp", "c#": "csharp", "c++": "cpp",
|
|
71
|
+
gql: "graphql", svg: "xml", docker: "dockerfile", patch: "diff", htm: "html",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const LANGUAGE_NAMES: Record<string, string> = {
|
|
75
|
+
typescript: "TypeScript", tsx: "TSX", javascript: "JavaScript", jsx: "JSX", json: "JSON",
|
|
76
|
+
jsonc: "JSONC", bash: "Bash", python: "Python", html: "HTML", css: "CSS", scss: "SCSS",
|
|
77
|
+
markdown: "Markdown", mdx: "MDX", yaml: "YAML", toml: "TOML", sql: "SQL", diff: "Diff",
|
|
78
|
+
go: "Go", rust: "Rust", solidity: "Solidity", ruby: "Ruby", java: "Java", kotlin: "Kotlin",
|
|
79
|
+
swift: "Swift", php: "PHP", c: "C", cpp: "C++", csharp: "C#", graphql: "GraphQL", xml: "XML",
|
|
80
|
+
dockerfile: "Dockerfile",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The Shiki grammar for a fence language, or undefined for plain text. */
|
|
84
|
+
export const PLAIN_TEXT = new Set(["text", "txt", "plain", "plaintext"]);
|
|
85
|
+
|
|
86
|
+
export function codeBlockGrammar(language: string | undefined): string | undefined {
|
|
87
|
+
if (!language) return undefined;
|
|
88
|
+
const id = language.toLowerCase();
|
|
89
|
+
const resolved = LANGUAGE_ALIASES[id] ?? id;
|
|
90
|
+
return GRAMMARS[resolved] ? resolved : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let highlighterPromise: Promise<HighlighterCore> | undefined;
|
|
94
|
+
const grammarLoads = new Map<string, Promise<void>>();
|
|
95
|
+
|
|
96
|
+
function loadHighlighter(): Promise<HighlighterCore> {
|
|
97
|
+
// The JavaScript regex engine needs no WebAssembly, so it runs under a strict CSP.
|
|
98
|
+
return highlighterPromise ??= Promise.all([import("shiki/core"), import("shiki/engine/javascript")])
|
|
99
|
+
.then(([core, engine]) => core.createHighlighterCore({
|
|
100
|
+
themes: [core.createCssVariablesTheme({ name: SHIKI_THEME, variablePrefix: "--shiki-", fontStyle: true })],
|
|
101
|
+
langs: [],
|
|
102
|
+
engine: engine.createJavaScriptRegexEngine(),
|
|
103
|
+
}))
|
|
104
|
+
.catch((error: unknown) => {
|
|
105
|
+
highlighterPromise = undefined;
|
|
106
|
+
throw error;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function tokenize(code: string, grammar: string): Promise<ThemedToken[][]> {
|
|
111
|
+
const highlighter = await loadHighlighter();
|
|
112
|
+
let load = grammarLoads.get(grammar);
|
|
113
|
+
if (!load) {
|
|
114
|
+
load = GRAMMARS[grammar]!().then((module) => highlighter.loadLanguage(module.default));
|
|
115
|
+
load.catch(() => grammarLoads.delete(grammar));
|
|
116
|
+
grammarLoads.set(grammar, load);
|
|
117
|
+
}
|
|
118
|
+
await load;
|
|
119
|
+
return highlighter.codeToTokensBase(code, { lang: grammar, theme: SHIKI_THEME });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function tokenStyle(token: ThemedToken): TokenStyle | undefined {
|
|
123
|
+
const color = token.color ? BRAND_COLOR_FOR_SHIKI[token.color] ?? SYNTAX.foreground : undefined;
|
|
124
|
+
const style = token.fontStyle ?? 0;
|
|
125
|
+
if (!color && !style) return undefined;
|
|
126
|
+
return {
|
|
127
|
+
...(color ? { color } : {}),
|
|
128
|
+
...(style & 1 ? { fontStyle: "italic" } : {}),
|
|
129
|
+
...(style & 2 ? { fontWeight: "bold" } : {}),
|
|
130
|
+
...(style & 4 ? { textDecoration: "underline" } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface TokenStyle {color?: string; fontStyle?: "italic"; fontWeight?: "bold"; textDecoration?: "underline";}
|
|
135
|
+
export const codePreStyle = {
|
|
136
|
+
margin: 0, padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
|
|
137
|
+
background: "transparent", color: SYNTAX.foreground, fontSize: "var(--code-font-size, 0.8125rem)",
|
|
138
|
+
lineHeight: "var(--code-line-height, 1.5)", overflowX: "auto",
|
|
139
|
+
} as const;
|
|
140
|
+
export const codeTextStyle = {fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)",whiteSpace:"pre"} as const;
|
|
141
|
+
export const codePresentation = {
|
|
142
|
+
tabs: `tangle-code-tabs ${tabsPresentation.list.underline}`,
|
|
143
|
+
tab: tabsPresentation.trigger.underline,
|
|
144
|
+
root: "group relative min-w-0 overflow-hidden rounded-lg border font-mono bg-card border-border",
|
|
145
|
+
header: "flex flex-wrap items-center justify-between gap-3 border-b border-border px-3 py-1.5 bg-[var(--code-header-bg,hsl(var(--background)))]",
|
|
146
|
+
copy: "flex items-center justify-center min-w-8 min-h-8 pointer-coarse:min-w-11 pointer-coarse:min-h-11 rounded-md bg-muted border border-border hover:border-[var(--border-strong)] transition-colors data-[copy-loading=true]:after:content-['…'] data-[copy-loading=true]:gap-1",
|
|
147
|
+
copyStatus: "sr-only data-[copy-long-work=true]:not-sr-only data-[copy-long-work=true]:text-xs data-[copy-long-work=true]:text-muted-foreground",
|
|
148
|
+
};
|
|
149
|
+
export interface CodeExample {label:string;code:string;language?:string;filename?:string;}
|
|
150
|
+
export interface ApiMetadata {kind:"request"|"response"|"error";method?:string;status?:number;contentType?:string;}
|
|
151
|
+
export const apiMetadataText = (meta:ApiMetadata) => [meta.kind,meta.method,meta.status,meta.contentType].filter(v=>v!==undefined).join(" · ");
|
|
152
|
+
|
|
153
|
+
/** Same lazy grammar/token model for React, Hono SSR and static renderers. */
|
|
154
|
+
export async function highlightCode(code:string,language?:string) {
|
|
155
|
+
const grammar=codeBlockGrammar(language);
|
|
156
|
+
if(!grammar)return code.split("\n").map(content=>[{content,style:undefined as TokenStyle|undefined}]);
|
|
157
|
+
return (await tokenize(code,grammar)).map(line=>line.map(token=>({content:token.content,style:tokenStyle(token)})));
|
|
158
|
+
}
|
|
159
|
+
/** Legacy fallback is used only when the modern clipboard API is absent. */
|
|
160
|
+
export async function writeClipboard(text:string):Promise<void> {
|
|
161
|
+
if(navigator.clipboard?.writeText){await navigator.clipboard.writeText(text);return;}
|
|
162
|
+
const active=document.activeElement as HTMLElement|null,selection=document.getSelection();
|
|
163
|
+
const ranges=selection?Array.from({length:selection.rangeCount},(_,i)=>selection.getRangeAt(i).cloneRange()):[];
|
|
164
|
+
const field=document.createElement("textarea");field.value=text;field.readOnly=true;field.tabIndex=-1;
|
|
165
|
+
field.setAttribute("aria-hidden","true");field.style.position="fixed";field.style.left="-9999px";
|
|
166
|
+
document.body.append(field);
|
|
167
|
+
try {field.select();if(!document.execCommand?.("copy"))throw Error("Clipboard unavailable");}
|
|
168
|
+
finally {field.remove();active?.focus({preventScroll:true});if(selection){selection.removeAllRanges();for(const range of ranges)selection.addRange(range);}}
|
|
169
|
+
}
|
|
170
|
+
const COPY_FEEDBACK_MS=2000;
|
|
171
|
+
export interface InteractionBudgets { feedback?:number; pending?:number; longWork?:number; mutation?:number; }
|
|
172
|
+
/** Resolve scoped Brand times, including calc(), without duplicating token values. */
|
|
173
|
+
export function interactionBudgets(scope:HTMLElement):InteractionBudgets {
|
|
174
|
+
const view=scope.ownerDocument.defaultView;if(!view)return {};
|
|
175
|
+
const probe=scope.ownerDocument.createElement("i");probe.hidden=true;scope.append(probe);
|
|
176
|
+
const read=(role:string)=>{
|
|
177
|
+
const name=`--interaction-${role}`;
|
|
178
|
+
if(!view.getComputedStyle(scope).getPropertyValue(name).trim())return undefined;
|
|
179
|
+
probe.style.animationDuration=`var(${name})`;
|
|
180
|
+
const time=/^(\d+(?:\.\d+)?)(ms|s)$/.exec(view.getComputedStyle(probe).animationDuration);
|
|
181
|
+
return time?Number(time[1])*(time[2]==="s"?1000:1):undefined;
|
|
182
|
+
};
|
|
183
|
+
try{return {feedback:read("feedback-budget"),pending:read("pending-delay"),longWork:read("long-work-delay"),mutation:read("mutation-budget")};}
|
|
184
|
+
finally{probe.remove();}
|
|
185
|
+
}
|
|
186
|
+
export interface CopyFeedback {
|
|
187
|
+
copied:boolean;status:string;pending:boolean;loading?:boolean;longRunning?:boolean;elapsedMs?:number;
|
|
188
|
+
measurement?:{elapsedMs:number;feedbackMs:number;budgets:InteractionBudgets;mutationBudgetExceeded:boolean|undefined};
|
|
189
|
+
}
|
|
190
|
+
/** Owns one accepted write, scoped delays and cleanup for both renderers. */
|
|
191
|
+
export function createCopyFeedback(change:(state:CopyFeedback)=>void,write:(text:string)=>Promise<void>=writeClipboard,scope?:HTMLElement) {
|
|
192
|
+
let disposed=false,pending=false,timer:ReturnType<typeof setTimeout>|undefined;
|
|
193
|
+
let delays:Array<ReturnType<typeof setTimeout>>=[],elapsed:ReturnType<typeof setInterval>|undefined;
|
|
194
|
+
const clearPending=()=>{for(const delay of delays)clearTimeout(delay);delays=[];if(elapsed)clearInterval(elapsed);elapsed=undefined;};
|
|
195
|
+
return {
|
|
196
|
+
async copy(text:string) {
|
|
197
|
+
if(disposed||pending)return;
|
|
198
|
+
const started=performance.now(),budgets=scope?interactionBudgets(scope):{};
|
|
199
|
+
pending=true;if(timer)clearTimeout(timer);
|
|
200
|
+
let loading=budgets.pending===undefined,longRunning=false;
|
|
201
|
+
const announce=()=>{
|
|
202
|
+
const elapsedMs=performance.now()-started;
|
|
203
|
+
change({copied:false,pending:true,loading,longRunning,elapsedMs,status:longRunning
|
|
204
|
+
?`Copying · ${Math.floor(elapsedMs/1000)}s elapsed. Time remaining unknown. Cannot cancel this clipboard write; waiting for confirmation.`
|
|
205
|
+
:"Copying to clipboard"});
|
|
206
|
+
};
|
|
207
|
+
announce();const feedbackMs=performance.now()-started;
|
|
208
|
+
if(budgets.pending!==undefined)delays.push(setTimeout(()=>{loading=true;announce();},budgets.pending));
|
|
209
|
+
if(budgets.longWork!==undefined)delays.push(setTimeout(()=>{longRunning=true;announce();elapsed=setInterval(announce,1000);},budgets.longWork));
|
|
210
|
+
let copied:boolean;
|
|
211
|
+
try {await write(text);copied=true;}
|
|
212
|
+
catch {copied=false;}
|
|
213
|
+
pending=false;clearPending();if(disposed)return;
|
|
214
|
+
const elapsedMs=performance.now()-started;
|
|
215
|
+
change({copied,status:copied?"Copied to clipboard":"Copy failed",pending:false,measurement:{elapsedMs,feedbackMs,budgets,
|
|
216
|
+
mutationBudgetExceeded:budgets.mutation===undefined?undefined:elapsedMs>budgets.mutation}});
|
|
217
|
+
timer=setTimeout(()=>{if(!disposed)change({copied:false,status:"",pending:false});},COPY_FEEDBACK_MS);
|
|
218
|
+
},
|
|
219
|
+
dispose(){disposed=true;clearPending();if(timer)clearTimeout(timer);},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export function bindCopyButton(button:HTMLButtonElement,status:HTMLElement,text:()=>string) {
|
|
223
|
+
const previous={label:button.getAttribute("aria-label"),status:status.textContent,disabled:button.getAttribute("aria-disabled"),busy:button.getAttribute("aria-busy"),loading:button.getAttribute("data-copy-loading"),long:status.getAttribute("data-copy-long-work")};
|
|
224
|
+
const restore=(element:HTMLElement,name:string,value:string|null)=>{if(value===null)element.removeAttribute(name);else element.setAttribute(name,value);};
|
|
225
|
+
const feedback=createCopyFeedback(state=>{
|
|
226
|
+
status.textContent=state.status;status.setAttribute("data-copy-long-work",String(!!state.longRunning));
|
|
227
|
+
button.setAttribute("data-copy-loading",String(!!state.loading));
|
|
228
|
+
button.setAttribute("aria-label",state.pending?"Copying to clipboard":state.copied?"Copied":"Copy to clipboard");
|
|
229
|
+
if(state.pending){button.setAttribute("aria-disabled","true");button.setAttribute("aria-busy","true");}
|
|
230
|
+
else {restore(button,"aria-disabled",previous.disabled);restore(button,"aria-busy",previous.busy);}
|
|
231
|
+
},writeClipboard,button);
|
|
232
|
+
const click=()=>{if(!button.disabled&&button.getAttribute("aria-disabled")!=="true")void feedback.copy(text());};button.addEventListener("click",click);
|
|
233
|
+
return ()=>{button.removeEventListener("click",click);feedback.dispose();restore(button,"aria-label",previous.label);status.textContent=previous.status;restore(button,"aria-disabled",previous.disabled);restore(button,"aria-busy",previous.busy);restore(button,"data-copy-loading",previous.loading);restore(status,"data-copy-long-work",previous.long);};
|
|
234
|
+
}
|
|
235
|
+
/** Native tabs adapter. The consumer owns labels, IDs and escaped panel content. */
|
|
236
|
+
export function bindCodeTabs(list:HTMLElement,onSelect?:(index:number)=>void) {
|
|
237
|
+
const buttons=()=>[...list.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
|
|
238
|
+
const select=(index:number,focus:boolean)=>{
|
|
239
|
+
const tabs=buttons(),tab=tabs[index];if(!tab)return;
|
|
240
|
+
for(const panel of new Set(tabs.map(t=>document.getElementById(t.getAttribute("aria-controls")??""))))if(panel)panel.hidden=true;
|
|
241
|
+
tabs.forEach((t,i)=>{t.setAttribute("aria-selected",String(i===index));t.dataset.state=i===index?"active":"inactive";t.tabIndex=i===index?0:-1;});
|
|
242
|
+
const panel=document.getElementById(tab.getAttribute("aria-controls")??"");if(panel){panel.hidden=false;panel.setAttribute("aria-labelledby",tab.id);}
|
|
243
|
+
if(focus)tab.focus();onSelect?.(index);
|
|
244
|
+
};
|
|
245
|
+
const click=(event:MouseEvent)=>{const tab=(event.target as Element).closest('[role="tab"]');const index=buttons().findIndex(t=>t===tab);if(index>=0)select(index,false);};
|
|
246
|
+
const key=(event:KeyboardEvent)=>{
|
|
247
|
+
const tabs=buttons(),current=tabs.findIndex(t=>t===document.activeElement);if(current<0)return;
|
|
248
|
+
const next=event.key==="Home"?0:event.key==="End"?tabs.length-1:event.key==="ArrowRight"?(current+1)%tabs.length:event.key==="ArrowLeft"?(current+tabs.length-1)%tabs.length:undefined;
|
|
249
|
+
if(next!==undefined){event.preventDefault();select(next,true);}
|
|
250
|
+
};
|
|
251
|
+
list.addEventListener("click",click);list.addEventListener("keydown",key);
|
|
252
|
+
return ()=>{list.removeEventListener("click",click);list.removeEventListener("keydown",key);};
|
|
253
|
+
}
|
|
254
|
+
export interface ApiField {name:string;type:string;description?:string;required?:boolean;children?:readonly ApiField[];}
|
|
255
|
+
export function schemaRows(fields:readonly ApiField[],parent="",depth=0):Array<ApiField&{path:string;depth:number}> {
|
|
256
|
+
return fields.flatMap(field=>{const path=parent?`${parent}.${field.name}`:field.name;return [{...field,path,depth},...schemaRows(field.children??[],path,depth+1)];});
|
|
257
|
+
}
|
|
@@ -2,10 +2,8 @@ import { focusField } from "./focus";
|
|
|
2
2
|
import { ixField } from "./interaction";
|
|
3
3
|
import { cn } from "./utils";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export const controlMotion =
|
|
8
|
-
"duration-[var(--duration-fast)] ease-[var(--ease-standard)] motion-reduce:transition-none motion-reduce:duration-0";
|
|
5
|
+
import { controlMotion } from "./motion";
|
|
6
|
+
export { controlMotion } from "./motion";
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
9
|
* The control scale. Button, Input, Textarea and SelectTrigger take the same
|
package/src/lib/focus.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { controlMotion } from "./motion";
|
|
1
2
|
import { ixField } from "./interaction";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -30,7 +31,7 @@ import { ixField } from "./interaction";
|
|
|
30
31
|
* Tailwind emits `focus:` after `hover:`.
|
|
31
32
|
*/
|
|
32
33
|
export const focusField =
|
|
33
|
-
|
|
34
|
+
`border-border ${controlMotion} hover:border-[var(--border-strong)] focus:outline-hidden focus:border-[var(--focus-border)] focus:ring-3 focus:ring-[var(--focus-halo)]`;
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* The field treatment for a container that wraps a borderless field, such as a
|
|
@@ -50,7 +51,7 @@ export const focusField =
|
|
|
50
51
|
* borderless field inside stays transparent.
|
|
51
52
|
*/
|
|
52
53
|
export const focusFieldWithin =
|
|
53
|
-
`focus-field-within
|
|
54
|
+
`focus-field-within ${controlMotion} ${ixField} has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:border-[var(--focus-border)] has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-3 has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-[var(--focus-halo)]`;
|
|
54
55
|
|
|
55
56
|
/** Error state for a field: a danger hairline and the same focus shape in danger. Compose after `focusField`. */
|
|
56
57
|
export const focusFieldInvalid =
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Brand motion roles contain duration AND easing; consume complete shorthands. */
|
|
2
|
+
export const controlMotion =
|
|
3
|
+
"[transition:color_var(--motion-control),background-color_var(--motion-control),border-color_var(--motion-control),box-shadow_var(--motion-control),transform_var(--motion-control),scale_var(--motion-control),opacity_var(--motion-control)] motion-reduce:transition-none";
|
|
4
|
+
|
|
5
|
+
/** Radix presence markers carry the scoped role; SurfaceMotion owns interruptible geometry. */
|
|
6
|
+
export const surfaceMotion =
|
|
7
|
+
"data-[state=open]:[animation:tangle-surface-present_var(--motion-surface)_both] data-[state=delayed-open]:[animation:tangle-surface-present_var(--motion-surface)_both] data-[state=instant-open]:[animation:tangle-surface-present_var(--motion-surface)_both] data-[state=closed]:[animation:tangle-surface-absent_var(--motion-dismiss)_both] motion-reduce:animate-none!";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
5
|
+
import { animate, motionValue, styleEffect, type MotionValue } from "motion";
|
|
6
|
+
|
|
7
|
+
type Side = "top" | "right" | "bottom" | "left";
|
|
8
|
+
type SurfaceProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
9
|
+
kind: "dialog" | "sheet" | "overlay" | "tooltip" | "popover";
|
|
10
|
+
side?: Side;
|
|
11
|
+
asChild?: boolean;
|
|
12
|
+
"data-state"?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Radix owns state, focus and presence; Motion only retargets the visible object. */
|
|
16
|
+
export const SurfaceMotion = React.forwardRef<HTMLDivElement, SurfaceProps>(
|
|
17
|
+
({ kind, side = "right", asChild, "data-state": state, ...props }, forwardedRef) => {
|
|
18
|
+
const element = React.useRef<HTMLDivElement | null>(null);
|
|
19
|
+
const values = React.useRef<Record<"opacity" | "scale" | "x" | "y", MotionValue<number>> | null>(null);
|
|
20
|
+
const controls = React.useRef<Array<ReturnType<typeof animate>>>([]);
|
|
21
|
+
const ref = React.useCallback<React.RefCallback<HTMLDivElement>>((node) => {
|
|
22
|
+
element.current = node;
|
|
23
|
+
if (typeof forwardedRef === "function") {
|
|
24
|
+
const cleanup: unknown = forwardedRef(node);
|
|
25
|
+
if (typeof cleanup === "function") return () => { element.current = null; cleanup(); };
|
|
26
|
+
} else if (forwardedRef) forwardedRef.current = node;
|
|
27
|
+
}, [forwardedRef]);
|
|
28
|
+
|
|
29
|
+
React.useLayoutEffect(() => {
|
|
30
|
+
const node = element.current;
|
|
31
|
+
if (!node) return;
|
|
32
|
+
const preference = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : undefined;
|
|
33
|
+
let cancelRender: (() => void) | undefined;
|
|
34
|
+
const run = () => {
|
|
35
|
+
const closed = state === "closed";
|
|
36
|
+
const bounds = node.getBoundingClientRect();
|
|
37
|
+
const hidden = {
|
|
38
|
+
opacity: kind === "sheet" ? 1 : 0,
|
|
39
|
+
scale: kind === "dialog" || kind === "tooltip" || kind === "popover" ? 0.98 : 1,
|
|
40
|
+
x: kind === "sheet" ? side === "left" ? -bounds.width : side === "right" ? bounds.width : 0 : 0,
|
|
41
|
+
y: kind === "sheet" ? side === "top" ? -bounds.height : side === "bottom" ? bounds.height : 0 : 0,
|
|
42
|
+
};
|
|
43
|
+
const target = closed ? hidden : { opacity: 1, scale: 1, x: 0, y: 0 };
|
|
44
|
+
const style = getComputedStyle(node);
|
|
45
|
+
// The nonvisual marker resolves the full role, including scoped calc().
|
|
46
|
+
const time = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(style.animationDuration.split(",")[0]!.trim());
|
|
47
|
+
const seconds = time ? Number(time[1]) / (time[2] === "ms" ? 1000 : 1) : 0;
|
|
48
|
+
const place = (value: typeof target) => {
|
|
49
|
+
node.style.opacity = String(value.opacity);
|
|
50
|
+
node.style.transform = `translateX(${value.x}px) translateY(${value.y}px) scale(${value.scale})`;
|
|
51
|
+
};
|
|
52
|
+
// Missing styles and reduced motion settle synchronously. No semantic
|
|
53
|
+
// transition waits for a JavaScript completion callback.
|
|
54
|
+
if (preference?.matches || seconds <= 0 || style.animationName === "none") {
|
|
55
|
+
controls.current.forEach(control => control.stop());
|
|
56
|
+
if (values.current) for (const key of ["opacity", "scale", "x", "y"] as const) values.current[key].jump(target[key]);
|
|
57
|
+
place(target);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!values.current) {
|
|
61
|
+
place(hidden);
|
|
62
|
+
values.current = { opacity: motionValue(hidden.opacity), scale: motionValue(hidden.scale), x: motionValue(hidden.x), y: motionValue(hidden.y) };
|
|
63
|
+
}
|
|
64
|
+
cancelRender ??= styleEffect(node, values.current);
|
|
65
|
+
// Critically damped response: <0.01% residual at the role's deadline.
|
|
66
|
+
// Explicit values keep every channel on the same frame clock, including
|
|
67
|
+
// opacity, without resampling a compositor animation on reversal.
|
|
68
|
+
const frequency = 12 / seconds;
|
|
69
|
+
controls.current = (["opacity", "scale", "x", "y"] as const).map(key => animate(values.current![key], target[key], {
|
|
70
|
+
type: "spring", stiffness: frequency * frequency, damping: 2 * frequency,
|
|
71
|
+
restDelta: 0.001, restSpeed: 0.01,
|
|
72
|
+
}));
|
|
73
|
+
};
|
|
74
|
+
run();
|
|
75
|
+
preference?.addEventListener("change", run);
|
|
76
|
+
return () => { preference?.removeEventListener("change", run); cancelRender?.(); };
|
|
77
|
+
// Radix Popper temporarily suppresses animation until placement resolves.
|
|
78
|
+
// Start the same retained values when that inline override clears.
|
|
79
|
+
}, [state, kind, side, props.style?.animation]);
|
|
80
|
+
React.useEffect(() => () => controls.current.forEach(control => control.stop()), []);
|
|
81
|
+
const Component = asChild ? Slot : "div";
|
|
82
|
+
return <Component {...props} data-state={state} ref={ref} />;
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
SurfaceMotion.displayName = "SurfaceMotion";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fireEvent,render,screen } from "@testing-library/react";
|
|
2
|
+
import { describe,it,expect,vi } from "vitest";
|
|
3
|
+
import { CodeBlock } from "./code-block";
|
|
4
|
+
import { ApiSchema } from "./api-schema";
|
|
5
|
+
import { bindCopyButton,createCopyFeedback,highlightCode } from "../code-presentation";
|
|
6
|
+
describe("shared API presentation",()=>{
|
|
7
|
+
it("renders field paths without turning schema text into HTML",()=>{
|
|
8
|
+
render(<ApiSchema fields={[{name:"messages",type:"array",children:[{name:"content",type:"string",description:"<script>bad()</script>"}]}]}/>);
|
|
9
|
+
expect(screen.getByText("messages.content")).toBeInTheDocument();expect(document.querySelector("script")).toBeNull();
|
|
10
|
+
});
|
|
11
|
+
it("keeps stale clipboard completions and disposed feedback silent",async()=>{
|
|
12
|
+
const updates=vi.fn();let complete!:()=>void;const feedback=createCopyFeedback(updates,()=>new Promise<void>(resolve=>{complete=resolve;}));
|
|
13
|
+
const pending=feedback.copy("bytes");feedback.dispose();complete();await pending;expect(updates).toHaveBeenCalledTimes(1);
|
|
14
|
+
});
|
|
15
|
+
it("blocks duplicate writes until the accepted operation settles",async()=>{
|
|
16
|
+
const updates=vi.fn();let complete!:()=>void;
|
|
17
|
+
const write=vi.fn(()=>new Promise<void>(resolve=>{complete=resolve;}));
|
|
18
|
+
const feedback=createCopyFeedback(updates,write);
|
|
19
|
+
const first=feedback.copy("first");await feedback.copy("duplicate");
|
|
20
|
+
expect(write.mock.calls).toEqual([["first"]]);
|
|
21
|
+
expect(updates).toHaveBeenLastCalledWith(expect.objectContaining({copied:false,status:"Copying to clipboard",pending:true}));
|
|
22
|
+
complete();await first;
|
|
23
|
+
expect(updates).toHaveBeenLastCalledWith(expect.objectContaining({copied:true,status:"Copied to clipboard",pending:false}));
|
|
24
|
+
const next=feedback.copy("next");expect(write).toHaveBeenCalledTimes(2);complete();await next;
|
|
25
|
+
feedback.dispose();await feedback.copy("disposed");expect(write).toHaveBeenCalledTimes(2);
|
|
26
|
+
});
|
|
27
|
+
it("retains exact newlines and plain binary/SSE text",async()=>{
|
|
28
|
+
const text='data: {"delta":"<x>"}\n\ndata: [DONE]\n';const lines=await highlightCode(text,"text");expect(lines.map(line=>line.map(t=>t.content).join("")).join("\n")).toBe(text);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("copy binding cleanup",()=>{
|
|
33
|
+
it.each(["pending","success","failure"])("restores retained DOM after %s",async phase=>{
|
|
34
|
+
const descriptor=Object.getOwnPropertyDescriptor(navigator,"clipboard");
|
|
35
|
+
let resolve!:()=>void,reject!:(error:Error)=>void;
|
|
36
|
+
const writeText=vi.fn(()=>new Promise<void>((yes,no)=>{resolve=yes;reject=no;}));
|
|
37
|
+
Object.defineProperty(navigator,"clipboard",{configurable:true,value:{writeText}});
|
|
38
|
+
const button=document.createElement("button"),status=document.createElement("span");
|
|
39
|
+
button.setAttribute("aria-label","Copy request");status.textContent="Ready";
|
|
40
|
+
const cleanup=bindCopyButton(button,status,()=>"exact bytes");
|
|
41
|
+
try {
|
|
42
|
+
button.click();expect(button.getAttribute("aria-label")).toBe("Copying to clipboard");
|
|
43
|
+
if(phase!=="pending"){
|
|
44
|
+
if(phase==="failure")reject(new Error("Denied"));else resolve();
|
|
45
|
+
await vi.waitFor(()=>expect(button.getAttribute("aria-label")).toBe(phase==="failure"?"Copy to clipboard":"Copied"));
|
|
46
|
+
}
|
|
47
|
+
cleanup();
|
|
48
|
+
expect(button.getAttribute("aria-label")).toBe("Copy request");expect(status.textContent).toBe("Ready");
|
|
49
|
+
resolve();await Promise.resolve();await Promise.resolve();
|
|
50
|
+
expect(button.getAttribute("aria-label")).toBe("Copy request");expect(status.textContent).toBe("Ready");
|
|
51
|
+
}finally{
|
|
52
|
+
cleanup();if(descriptor)Object.defineProperty(navigator,"clipboard",descriptor);else delete (navigator as {clipboard?:unknown}).clipboard;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it("restores an originally absent accessible label",()=>{
|
|
56
|
+
const button=document.createElement("button"),status=document.createElement("span");
|
|
57
|
+
const cleanup=bindCopyButton(button,status,()=>"bytes");button.setAttribute("aria-label","Copied");cleanup();expect(button.hasAttribute("aria-label")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it("gives a single code example no orphaned tab relationship",()=>{
|
|
60
|
+
const {container,unmount}=render(<CodeBlock code="fallback" examples={[{label:"Plain",language:"text",code:"one"}]}/>);
|
|
61
|
+
try {expect(container.querySelector('[role="tab"]')).toBeNull();expect(container.querySelector('[role="tabpanel"]')).toBeNull();expect(container.querySelector('[aria-labelledby]')).toBeNull();expect(container.querySelector("pre")?.textContent).toBe("one");}finally{unmount();}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -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
|
+
}
|