@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
|
@@ -1,151 +1,27 @@
|
|
|
1
1
|
import {
|
|
2
2
|
memo,
|
|
3
|
-
|
|
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 {
|
|
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(
|
|
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=
|
|
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={
|
|
115
|
+
style={codePreStyle}
|
|
241
116
|
>
|
|
242
|
-
<code style={
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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=
|
|
189
|
+
<span className={codePresentation.copyStatus} data-copy-long-work={!!longRunning} role="status" aria-live="polite">
|
|
321
190
|
{status}
|
|
322
191
|
</span>
|
|
323
192
|
</>
|
package/src/markdown/index.ts
CHANGED
|
@@ -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";
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/** React-free presentation. Consumers scan this published source with Tailwind. */
|
|
2
|
+
import { surfaceMotion } from "./lib/motion";
|
|
3
|
+
import { cva } from "class-variance-authority";
|
|
4
|
+
import { controlHeight, controlMotion, controlRadius, controlSizes, controlSquare, controlText, fieldPresentation, fieldSizes, fieldTouchText } from "./lib/control-presentation";
|
|
5
|
+
import { focusRing, focusFieldInvalid } from "./lib/focus";
|
|
6
|
+
import { ixControl, ixControlSoft, ixSelected, ixTabUnderline } from "./lib/interaction";
|
|
7
|
+
import { cn } from "./lib/utils";
|
|
8
|
+
export { controlHeight, controlMotion, controlRadius, controlSizes, controlText, fieldPresentation, fieldSizes, fieldTouchText } from "./lib/control-presentation";
|
|
9
|
+
|
|
10
|
+
export const buttonVariants = cva(
|
|
11
|
+
`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
|
|
12
|
+
{
|
|
13
|
+
variants: {
|
|
14
|
+
variant: {
|
|
15
|
+
default:
|
|
16
|
+
`bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97] ${ixControl}`,
|
|
17
|
+
destructive:
|
|
18
|
+
`bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97] ${ixControl}`,
|
|
19
|
+
outline:
|
|
20
|
+
`border border-border bg-card hover:bg-muted active:scale-[0.97] text-foreground ${ixControl}`,
|
|
21
|
+
secondary:
|
|
22
|
+
`bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97] ${ixControl}`,
|
|
23
|
+
ghost:
|
|
24
|
+
`hover:text-foreground text-muted-foreground border border-transparent ${ixControlSoft}`,
|
|
25
|
+
link: `text-[var(--accent-text)] underline-offset-4 hover:underline ${ixControlSoft}`,
|
|
26
|
+
sandbox:
|
|
27
|
+
`bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97] ${ixControl}`,
|
|
28
|
+
},
|
|
29
|
+
size: {
|
|
30
|
+
default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
31
|
+
md: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
32
|
+
sm: `${controlHeight.sm} ${controlText.sm} ${controlRadius.sm} px-3`,
|
|
33
|
+
lg: `${controlHeight.lg} ${controlText.lg} ${controlRadius.lg} px-7`,
|
|
34
|
+
display: "tangle-control-display",
|
|
35
|
+
xl: "h-13 rounded-xl px-9 text-base",
|
|
36
|
+
icon: `${controlSquare.md} ${controlText.md} ${controlRadius.md}`,
|
|
37
|
+
"icon-sm": `${controlSquare.sm} ${controlText.sm} ${controlRadius.sm}`,
|
|
38
|
+
"icon-lg": `${controlSquare.lg} ${controlText.lg} ${controlRadius.lg}`,
|
|
39
|
+
...controlSizes,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
defaultVariants: {
|
|
43
|
+
variant: "default",
|
|
44
|
+
size: "default",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export const inputVariants = cva(
|
|
51
|
+
cn(
|
|
52
|
+
"flex w-full border py-1 placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium",
|
|
53
|
+
fieldPresentation,
|
|
54
|
+
fieldTouchText,
|
|
55
|
+
),
|
|
56
|
+
{
|
|
57
|
+
variants: {
|
|
58
|
+
// `default` and `sandbox` render the same field; both names stay valid.
|
|
59
|
+
variant: {
|
|
60
|
+
default: "",
|
|
61
|
+
sandbox: "",
|
|
62
|
+
error: focusFieldInvalid,
|
|
63
|
+
},
|
|
64
|
+
size: {
|
|
65
|
+
default: fieldSizes.md,
|
|
66
|
+
md: fieldSizes.md,
|
|
67
|
+
sm: fieldSizes.sm,
|
|
68
|
+
lg: fieldSizes.lg,
|
|
69
|
+
compact: cn(fieldSizes.md, controlSizes.compact),
|
|
70
|
+
touch: cn(fieldSizes.md, controlSizes.touch),
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
defaultVariants: {
|
|
74
|
+
variant: "default",
|
|
75
|
+
size: "default",
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
export const tablePresentation = {
|
|
82
|
+
root: "w-full caption-bottom text-sm",
|
|
83
|
+
wrapper: "relative w-full overflow-auto",
|
|
84
|
+
head: "[&_tr]:border-b",
|
|
85
|
+
body: "[&_tr:last-child]:border-0",
|
|
86
|
+
footer: "border-t bg-card font-medium [&>tr]:last:border-b-0",
|
|
87
|
+
row: `border-border border-b ${controlMotion} hover:bg-muted/50 data-[state=selected]:bg-muted/50`,
|
|
88
|
+
columnHeader: "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
|
89
|
+
cell: "p-4 align-middle [&:has([role=checkbox])]:pr-0",
|
|
90
|
+
caption: "mt-4 text-muted-foreground text-sm",
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
export const emptyStatePresentation = {
|
|
94
|
+
root: "flex flex-col items-center justify-center px-4 py-16 text-center",
|
|
95
|
+
icon: "mb-4 rounded-full bg-muted p-4 text-muted-foreground",
|
|
96
|
+
title: "font-semibold text-lg",
|
|
97
|
+
description: "mt-2 max-w-sm text-muted-foreground text-sm",
|
|
98
|
+
action: "mt-6",
|
|
99
|
+
} as const;
|
|
100
|
+
|
|
101
|
+
export const metricPresentation = {
|
|
102
|
+
strip: "grid min-w-0 rounded-[var(--radius-lg)] border border-border bg-card shadow-[var(--shadow-card)]",
|
|
103
|
+
item: "min-w-0 border-border p-4 sm:p-5",
|
|
104
|
+
value: "mt-1 whitespace-normal [overflow-wrap:anywhere] font-semibold text-xl tabular-nums tracking-tight lg:text-2xl",
|
|
105
|
+
label: "flex min-w-0 flex-wrap items-center gap-2 text-muted-foreground text-sm",
|
|
106
|
+
labelText: "min-w-0 whitespace-normal [overflow-wrap:anywhere]",
|
|
107
|
+
hint: "mt-0.5 whitespace-normal [overflow-wrap:anywhere] text-[var(--text-dim)] text-xs",
|
|
108
|
+
} as const;
|
|
109
|
+
|
|
110
|
+
export const metricLayouts = {
|
|
111
|
+
3: "grid-cols-2 sm:grid-cols-3 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(3n+1))]:border-l",
|
|
112
|
+
4: "grid-cols-2 sm:grid-cols-4 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(4n+1))]:border-l",
|
|
113
|
+
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(5n+1))]:border-l",
|
|
114
|
+
6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(6n+1))]:border-l",
|
|
115
|
+
} as const;
|
|
116
|
+
|
|
117
|
+
export const dialogPresentation = {
|
|
118
|
+
overlay: "fixed inset-0 z-50 bg-black/40",
|
|
119
|
+
transition: surfaceMotion,
|
|
120
|
+
position: "fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]",
|
|
121
|
+
surface: "gap-4 rounded-lg border bg-card p-6 shadow-lg",
|
|
122
|
+
close: `absolute top-4 right-4 rounded-sm opacity-70 ${controlMotion} hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground`,
|
|
123
|
+
header: "flex flex-col space-y-1.5 text-center sm:text-left",
|
|
124
|
+
footer: "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
125
|
+
title: "font-semibold text-lg leading-none tracking-tight",
|
|
126
|
+
description: "text-muted-foreground text-sm",
|
|
127
|
+
} as const;
|
|
128
|
+
|
|
129
|
+
/** Native-dialog adapter for Hono/static markup. Radix remains the React Dialog/Sheet owner. */
|
|
130
|
+
export function bindDialog(dialog: HTMLDialogElement, trigger: HTMLElement): () => void {
|
|
131
|
+
const sync = () => trigger.setAttribute("aria-expanded", String(dialog.open));
|
|
132
|
+
const show = () => { if (!dialog.open) dialog.showModal(); sync(); };
|
|
133
|
+
const close = () => { sync(); trigger.focus(); };
|
|
134
|
+
const click = (event: MouseEvent) => {
|
|
135
|
+
const target = event.target;
|
|
136
|
+
if (target instanceof Element && target.closest("[data-dialog-close]")) dialog.close();
|
|
137
|
+
};
|
|
138
|
+
// Native modal inertness blocks page controls, but Tab can still visit browser
|
|
139
|
+
// chrome at a boundary. Keep this reading surface's keyboard cycle local.
|
|
140
|
+
const key = (event: KeyboardEvent) => {
|
|
141
|
+
if (event.key !== "Tab" || !dialog.open) return;
|
|
142
|
+
const candidates = [...dialog.querySelectorAll<HTMLElement>("*")]
|
|
143
|
+
.filter(element => (element.tabIndex >= 0 || (element.isContentEditable && !element.hasAttribute("tabindex")))
|
|
144
|
+
&& !element.matches(":disabled") && !element.closest("[inert]")
|
|
145
|
+
&& element.getClientRects().length > 0 && getComputedStyle(element).visibility === "visible");
|
|
146
|
+
const radio = (element: Element): element is HTMLInputElement => element instanceof HTMLInputElement && element.type === "radio" && !!element.name;
|
|
147
|
+
const sameRadioGroup = (a: Element, b: Element) => radio(a) && radio(b) && a.name === b.name && a.form === b.form && a.getRootNode() === b.getRootNode();
|
|
148
|
+
const stops = candidates.filter(element => {
|
|
149
|
+
if (!radio(element)) return true;
|
|
150
|
+
const group = candidates.filter(candidate => sameRadioGroup(element, candidate));
|
|
151
|
+
const checked = group.find(candidate => (candidate as HTMLInputElement).checked);
|
|
152
|
+
return element === (checked ?? (event.shiftKey ? group.at(-1) : group[0]));
|
|
153
|
+
}).sort((a, b) => (a.tabIndex > 0 ? a.tabIndex : Infinity) - (b.tabIndex > 0 ? b.tabIndex : Infinity));
|
|
154
|
+
const first = stops[0], last = stops.at(-1), active = document.activeElement;
|
|
155
|
+
if (!first) { event.preventDefault(); dialog.focus(); return; }
|
|
156
|
+
const atBoundary = (boundary: HTMLElement) => active === boundary || (active instanceof Element && sameRadioGroup(active, boundary));
|
|
157
|
+
if (active === dialog || (event.shiftKey ? atBoundary(first) : atBoundary(last!))) {
|
|
158
|
+
event.preventDefault(); (event.shiftKey ? last! : first).focus();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
trigger.setAttribute("aria-haspopup", "dialog");
|
|
162
|
+
if (dialog.id) trigger.setAttribute("aria-controls", dialog.id);
|
|
163
|
+
sync();trigger.addEventListener("click",show);dialog.addEventListener("close",close);dialog.addEventListener("click",click);dialog.addEventListener("keydown",key);
|
|
164
|
+
return () => {trigger.removeEventListener("click",show);dialog.removeEventListener("close",close);dialog.removeEventListener("click",click);dialog.removeEventListener("keydown",key);};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const labelVariants = cva(
|
|
168
|
+
"font-medium text-[length:var(--font-size-label,0.875rem)] leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
export const helpPresentation = {
|
|
172
|
+
root: "text-[length:var(--font-size-help,0.75rem)] leading-4",
|
|
173
|
+
error: "font-medium text-[var(--surface-danger-text)]",
|
|
174
|
+
hint: "text-[var(--text-dim)]",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const cardPresentation = {
|
|
178
|
+
root: `rounded-[var(--radius-lg)] border text-card-foreground ${controlMotion}`,
|
|
179
|
+
padding: "p-4",
|
|
180
|
+
variants: {
|
|
181
|
+
default: "bg-card border-border shadow-[var(--edge-highlight),var(--shadow-card)]",
|
|
182
|
+
elevated: "bg-muted/50 border-border shadow-[var(--shadow-card)]",
|
|
183
|
+
glass: "bg-card/80 backdrop-blur-xl border-border shadow-[var(--shadow-card)]",
|
|
184
|
+
sandbox: "bg-muted/50 border-primary/20 shadow-[var(--shadow-accent)]",
|
|
185
|
+
},
|
|
186
|
+
} as const;
|
|
187
|
+
|
|
188
|
+
/** Canonical segmented, pill and underline navigation presentation. */
|
|
189
|
+
export const tabsPresentation = {
|
|
190
|
+
list: {
|
|
191
|
+
default:
|
|
192
|
+
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
|
193
|
+
pills: "inline-flex items-center gap-2",
|
|
194
|
+
underline: "inline-flex items-center gap-4 border-b border-border",
|
|
195
|
+
},
|
|
196
|
+
trigger: {
|
|
197
|
+
default:
|
|
198
|
+
`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 ${ixControlSoft} ${ixSelected}`,
|
|
199
|
+
pills:
|
|
200
|
+
`inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground ${ixControlSoft} ${ixSelected}`,
|
|
201
|
+
underline:
|
|
202
|
+
`inline-flex items-center justify-center whitespace-nowrap rounded-t-md pb-3 text-sm font-medium ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent data-[state=inactive]:text-muted-foreground ${ixTabUnderline}`,
|
|
203
|
+
}
|
|
204
|
+
} as const;
|
|
205
|
+
export { ixControlSoft, ixSelected, ixTabUnderline } from "./lib/interaction";
|
package/src/primitives/alert.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
|
-
import type { StatusTone } from "./status-pill";
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* An inline message that stays on the page: a policy note above a form, a
|
|
@@ -14,7 +13,7 @@ import type { StatusTone } from "./status-pill";
|
|
|
14
13
|
* `danger` and `warning` announce themselves (`role="alert"`); the quieter
|
|
15
14
|
* tones are `role="status"` so a screen reader is not interrupted by a note.
|
|
16
15
|
*/
|
|
17
|
-
export type AlertTone =
|
|
16
|
+
export type AlertTone = "neutral" | "info" | "success" | "warning" | "danger";
|
|
18
17
|
|
|
19
18
|
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
20
19
|
tone?: AlertTone;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Slot } from "@radix-ui/react-slot";
|
|
2
|
-
import {
|
|
2
|
+
import { buttonVariants } from "../presentation";
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { controlMotion } from "../lib/motion";
|
|
5
5
|
import { focusRing } from "../lib/focus";
|
|
6
6
|
import { ixControl, ixControlSoft, ixSelected } from "../lib/interaction";
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
@@ -14,49 +14,12 @@ import { cn } from "../lib/utils";
|
|
|
14
14
|
// `ixControl` (green edge and glow on hover, press and after a click); ghost,
|
|
15
15
|
// link and bare take `ixControlSoft`, a wash that never glows. A toggle button
|
|
16
16
|
// (any `aria-pressed`) adds `ixControl` + `ixSelected` whatever its variant.
|
|
17
|
-
const buttonVariants = cva(
|
|
18
|
-
`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-[color,background-color,border-color,box-shadow,transform,scale] ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
|
|
19
|
-
{
|
|
20
|
-
variants: {
|
|
21
|
-
variant: {
|
|
22
|
-
default:
|
|
23
|
-
`bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97] ${ixControl}`,
|
|
24
|
-
destructive:
|
|
25
|
-
`bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97] ${ixControl}`,
|
|
26
|
-
outline:
|
|
27
|
-
`border border-border bg-card hover:bg-muted active:scale-[0.97] text-foreground ${ixControl}`,
|
|
28
|
-
secondary:
|
|
29
|
-
`bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97] ${ixControl}`,
|
|
30
|
-
ghost:
|
|
31
|
-
`hover:text-foreground text-muted-foreground border border-transparent ${ixControlSoft}`,
|
|
32
|
-
link: `text-[var(--accent-text)] underline-offset-4 hover:underline ${ixControlSoft}`,
|
|
33
|
-
sandbox:
|
|
34
|
-
`bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97] ${ixControl}`,
|
|
35
|
-
},
|
|
36
|
-
size: {
|
|
37
|
-
default: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
38
|
-
md: `${controlHeight.md} ${controlText.md} ${controlRadius.md} px-4 py-2`,
|
|
39
|
-
sm: `${controlHeight.sm} ${controlText.sm} ${controlRadius.sm} px-3`,
|
|
40
|
-
lg: `${controlHeight.lg} ${controlText.lg} ${controlRadius.lg} px-7`,
|
|
41
|
-
xl: "h-13 rounded-xl px-9 text-base",
|
|
42
|
-
icon: `${controlSquare.md} ${controlText.md} ${controlRadius.md}`,
|
|
43
|
-
"icon-sm": `${controlSquare.sm} ${controlText.sm} ${controlRadius.sm}`,
|
|
44
|
-
"icon-lg": `${controlSquare.lg} ${controlText.lg} ${controlRadius.lg}`,
|
|
45
|
-
...controlSizes,
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
defaultVariants: {
|
|
49
|
-
variant: "default",
|
|
50
|
-
size: "default",
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
);
|
|
54
17
|
|
|
55
18
|
// `bare` keeps the shared focus ring, disabled and loading behavior and drops
|
|
56
19
|
// every surface, size and type class. It is for pressable rows, tiles and pills
|
|
57
20
|
// that draw their own layout; anything that reads as a button uses a styled
|
|
58
21
|
// variant and a size.
|
|
59
|
-
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`;
|
|
60
23
|
|
|
61
24
|
/** One Button render's classes: variant and size, the toggle states, then the caller's. */
|
|
62
25
|
function buttonClassName(
|
|
@@ -75,7 +38,7 @@ export interface ButtonProps
|
|
|
75
38
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
76
39
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | "bare" | null;
|
|
77
40
|
/** sm 32px, default/md 36px (`--control-height`), lg 44px; icon sizes are squares on the same heights. */
|
|
78
|
-
size?: "default" | "md" | "sm" | "lg" | "xl" | "icon" | "icon-sm" | "icon-lg" | "compact" | "touch" | null;
|
|
41
|
+
size?: "default" | "md" | "sm" | "lg" | "xl" | "display" | "icon" | "icon-sm" | "icon-lg" | "compact" | "touch" | null;
|
|
79
42
|
asChild?: boolean;
|
|
80
43
|
loading?: boolean;
|
|
81
44
|
children?: React.ReactNode;
|
|
@@ -136,6 +99,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
136
99
|
<Slot
|
|
137
100
|
className={buttonClassName(variant, size, props["aria-pressed"], className)}
|
|
138
101
|
ref={ref}
|
|
102
|
+
data-variant={size === "display" ? variant ?? "default" : undefined}
|
|
139
103
|
{...props}
|
|
140
104
|
{...(isDisabled ? disabledHandlers(props) : {})}
|
|
141
105
|
>
|
|
@@ -161,6 +125,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
161
125
|
<button
|
|
162
126
|
className={buttonClassName(variant, size, props["aria-pressed"], className)}
|
|
163
127
|
ref={ref}
|
|
128
|
+
data-variant={size === "display" ? variant ?? "default" : undefined}
|
|
164
129
|
{...props}
|
|
165
130
|
disabled={disabled || loading}
|
|
166
131
|
aria-busy={loading || props["aria-busy"]}
|