@runtypelabs/persona 4.7.0 → 4.8.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 (117) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +877 -172
  16. package/dist/index.d.ts +877 -172
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +91 -68
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +186 -1
  56. package/src/client.ts +92 -9
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/session.ts +76 -22
  80. package/src/styles/widget.css +773 -26
  81. package/src/theme-editor/preview.ts +2 -0
  82. package/src/theme-editor/sections.test.ts +26 -1
  83. package/src/theme-editor/sections.ts +10 -2
  84. package/src/theme-reference.ts +2 -2
  85. package/src/tool-call-display-defaults.test.ts +1 -0
  86. package/src/types/theme.ts +77 -0
  87. package/src/types.ts +516 -17
  88. package/src/ui.artifact-pane-gating.test.ts +636 -0
  89. package/src/ui.component-directive.test.ts +104 -0
  90. package/src/ui.composer-bar.test.ts +60 -2
  91. package/src/ui.detached-panel.test.ts +1049 -0
  92. package/src/ui.tool-display.test.ts +51 -0
  93. package/src/ui.ts +698 -109
  94. package/src/utils/artifact-custom-actions.ts +128 -0
  95. package/src/utils/artifact-display.test.ts +42 -0
  96. package/src/utils/artifact-display.ts +84 -0
  97. package/src/utils/artifact-file.test.ts +116 -0
  98. package/src/utils/artifact-file.ts +117 -0
  99. package/src/utils/artifact-gate.test.ts +112 -5
  100. package/src/utils/artifact-gate.ts +39 -14
  101. package/src/utils/artifact-loading-status.ts +55 -0
  102. package/src/utils/artifact-status-label.ts +190 -0
  103. package/src/utils/buttons.ts +7 -1
  104. package/src/utils/code-highlight.test.ts +186 -0
  105. package/src/utils/code-highlight.ts +400 -0
  106. package/src/utils/icons.ts +2 -0
  107. package/src/utils/roving-tablist.test.ts +152 -0
  108. package/src/utils/roving-tablist.ts +111 -0
  109. package/src/utils/spinner.ts +45 -0
  110. package/src/utils/theme.test.ts +48 -0
  111. package/src/utils/theme.ts +7 -0
  112. package/src/utils/tokens.ts +91 -0
  113. package/src/utils/tool-loading-animation.test.ts +32 -0
  114. package/src/utils/tool-loading-animation.ts +24 -0
  115. package/dist/chunk-DFBSCFYN.js +0 -1
  116. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  117. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Tiny hand-rolled syntax highlighter for the artifact source view.
3
+ *
4
+ * Zero dependencies by design: the IIFE bundle is built with `--splitting
5
+ * false`, so any highlight.js / prism / shiki style library would be inlined
6
+ * and inflate the critical bundle. This module is a small regex tokenizer
7
+ * (html / css / js|ts / json) plus a DOM builder that emits one
8
+ * `span.persona-code-line` per source line for the CSS-counter gutter.
9
+ *
10
+ * Safety by construction: the DOM is built ONLY with `createElement` +
11
+ * `textContent` + `createTextNode` — never `innerHTML` — so the output is safe
12
+ * regardless of the widget's `sanitize` config, even when the source itself is
13
+ * hostile markup (e.g. `<img src=x onerror=…>`).
14
+ *
15
+ * Verbatim-reconstruction invariant: the built fragment's `textContent` equals
16
+ * the original `source` exactly. Every source newline becomes a literal `"\n"`
17
+ * text node placed *between* line spans; token spans never contain a newline
18
+ * (multi-line tokens such as block comments and template literals are split at
19
+ * newline boundaries into per-line spans of the same class). A trailing source
20
+ * newline is preserved as a `"\n"` text node without a phantom numbered empty
21
+ * final line.
22
+ */
23
+ import { createElement, createFragment } from "./dom";
24
+
25
+ /** Token classification. `plain`/`punctuation` render as bare text (no span). */
26
+ export type CodeTokenType =
27
+ | "keyword"
28
+ | "string"
29
+ | "comment"
30
+ | "number"
31
+ | "tag"
32
+ | "attr"
33
+ | "property"
34
+ | "punctuation"
35
+ | "plain";
36
+
37
+ export interface CodeToken {
38
+ type: CodeTokenType;
39
+ value: string;
40
+ }
41
+
42
+ /** Canonical tokenizer keys. `ts`/`jsx`/`tsx` share the `js` tokenizer. */
43
+ export type HighlightLanguage = "html" | "css" | "js" | "json";
44
+
45
+ /** Token type → CSS class. Types absent here render as a bare text node. */
46
+ const TOKEN_CLASS: Partial<Record<CodeTokenType, string>> = {
47
+ keyword: "persona-code-token-keyword",
48
+ string: "persona-code-token-string",
49
+ comment: "persona-code-token-comment",
50
+ number: "persona-code-token-number",
51
+ tag: "persona-code-token-tag",
52
+ attr: "persona-code-token-attr",
53
+ property: "persona-code-token-property",
54
+ };
55
+
56
+ /**
57
+ * Streaming perf guard: skip tokenization above this many characters and fall
58
+ * back to plain line-numbered text. `renderPre` runs on every streaming delta,
59
+ * so keep the tokenizer off huge sources.
60
+ */
61
+ export const MAX_HIGHLIGHT_LENGTH = 150_000;
62
+
63
+ /** Extension / short-name → canonical tokenizer key. */
64
+ const LANGUAGE_ALIASES: Record<string, HighlightLanguage> = {
65
+ html: "html",
66
+ htm: "html",
67
+ xhtml: "html",
68
+ xml: "html",
69
+ svg: "html",
70
+ css: "css",
71
+ js: "js",
72
+ jsx: "js",
73
+ mjs: "js",
74
+ cjs: "js",
75
+ ts: "js",
76
+ tsx: "js",
77
+ mts: "js",
78
+ cts: "js",
79
+ javascript: "js",
80
+ typescript: "js",
81
+ ecmascript: "js",
82
+ json: "json",
83
+ jsonc: "json",
84
+ json5: "json",
85
+ };
86
+
87
+ /**
88
+ * Resolve a canonical tokenizer from an explicit language or a file path
89
+ * extension. Returns `null` for anything unmapped (plain-text fallback).
90
+ */
91
+ export function resolveHighlightLanguage(
92
+ language?: string,
93
+ path?: string
94
+ ): HighlightLanguage | null {
95
+ const lang = (language || "").trim().toLowerCase();
96
+ if (lang && LANGUAGE_ALIASES[lang]) return LANGUAGE_ALIASES[lang];
97
+
98
+ if (path) {
99
+ const dot = path.lastIndexOf(".");
100
+ if (dot >= 0) {
101
+ const ext = path.slice(dot + 1).toLowerCase();
102
+ if (LANGUAGE_ALIASES[ext]) return LANGUAGE_ALIASES[ext];
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ interface Rule {
109
+ type: CodeTokenType;
110
+ /** Sticky (`y`) regex so a match only counts when it starts at `lastIndex`. */
111
+ re: RegExp;
112
+ /** Optional reclassifier (e.g. identifier → keyword when in a keyword set). */
113
+ map?: (text: string) => CodeTokenType;
114
+ }
115
+
116
+ /**
117
+ * Left-to-right rule scanner. At each index the first rule that matches at that
118
+ * exact position wins; unmatched characters accrete into a single `plain` run.
119
+ */
120
+ function runRules(source: string, rules: Rule[]): CodeToken[] {
121
+ const tokens: CodeToken[] = [];
122
+ let i = 0;
123
+ let plainStart = 0;
124
+ const flushPlain = (end: number) => {
125
+ if (end > plainStart) {
126
+ tokens.push({ type: "plain", value: source.slice(plainStart, end) });
127
+ }
128
+ };
129
+
130
+ while (i < source.length) {
131
+ let matched = false;
132
+ for (const rule of rules) {
133
+ rule.re.lastIndex = i;
134
+ const m = rule.re.exec(source);
135
+ if (m && m.index === i && m[0].length > 0) {
136
+ flushPlain(i);
137
+ const type = rule.map ? rule.map(m[0]) : rule.type;
138
+ tokens.push({ type, value: m[0] });
139
+ i += m[0].length;
140
+ plainStart = i;
141
+ matched = true;
142
+ break;
143
+ }
144
+ }
145
+ if (!matched) i += 1;
146
+ }
147
+ flushPlain(source.length);
148
+ return tokens;
149
+ }
150
+
151
+ const JS_KEYWORDS = new Set([
152
+ "abstract", "any", "as", "async", "await", "boolean", "break", "case",
153
+ "catch", "class", "const", "continue", "debugger", "declare", "default",
154
+ "delete", "do", "else", "enum", "export", "extends", "false", "finally",
155
+ "for", "from", "function", "get", "if", "implements", "import", "in",
156
+ "instanceof", "interface", "keyof", "let", "namespace", "never", "new",
157
+ "null", "number", "object", "of", "private", "protected", "public",
158
+ "readonly", "return", "satisfies", "set", "static", "string", "super",
159
+ "switch", "symbol", "this", "throw", "true", "try", "type", "typeof",
160
+ "undefined", "unknown", "var", "void", "while", "yield",
161
+ ]);
162
+
163
+ const JS_RULES: Rule[] = [
164
+ { type: "comment", re: /\/\/[^\n]*/y },
165
+ { type: "comment", re: /\/\*[\s\S]*?\*\//y },
166
+ { type: "string", re: /`(?:\\[\s\S]|[^\\`])*`/y },
167
+ { type: "string", re: /"(?:\\.|[^"\\\n])*"/y },
168
+ { type: "string", re: /'(?:\\.|[^'\\\n])*'/y },
169
+ {
170
+ type: "number",
171
+ re: /0[xX][0-9a-fA-F]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y,
172
+ },
173
+ {
174
+ type: "plain",
175
+ re: /[A-Za-z_$][\w$]*/y,
176
+ map: (t) => (JS_KEYWORDS.has(t) ? "keyword" : "plain"),
177
+ },
178
+ ];
179
+
180
+ function tokenizeJs(source: string): CodeToken[] {
181
+ return runRules(source, JS_RULES);
182
+ }
183
+
184
+ const JSON_RULES: Rule[] = [
185
+ { type: "string", re: /"(?:\\.|[^"\\])*"/y },
186
+ { type: "number", re: /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y },
187
+ {
188
+ type: "plain",
189
+ re: /[A-Za-z_]\w*/y,
190
+ map: (t) =>
191
+ t === "true" || t === "false" || t === "null" ? "keyword" : "plain",
192
+ },
193
+ ];
194
+
195
+ function tokenizeJson(source: string): CodeToken[] {
196
+ const tokens = runRules(source, JSON_RULES);
197
+ // Reclassify object keys: a string whose next significant token opens with
198
+ // `:` is a property, not a value string.
199
+ for (let idx = 0; idx < tokens.length; idx += 1) {
200
+ if (tokens[idx].type !== "string") continue;
201
+ let j = idx + 1;
202
+ while (j < tokens.length && tokens[j].value.trim() === "") j += 1;
203
+ const next = tokens[j];
204
+ if (next && next.value.replace(/^\s*/, "").startsWith(":")) {
205
+ tokens[idx].type = "property";
206
+ }
207
+ }
208
+ return tokens;
209
+ }
210
+
211
+ const CSS_RULES: Rule[] = [
212
+ { type: "comment", re: /\/\*[\s\S]*?\*\//y },
213
+ { type: "string", re: /"(?:\\.|[^"\\\n])*"/y },
214
+ { type: "string", re: /'(?:\\.|[^'\\\n])*'/y },
215
+ { type: "keyword", re: /@[A-Za-z-]+/y },
216
+ { type: "number", re: /#[0-9a-fA-F]{3,8}\b/y },
217
+ { type: "number", re: /-?(?:\d+\.?\d*|\.\d+)(?:[a-z%]+)?/y },
218
+ { type: "plain", re: /[A-Za-z_-][\w-]*/y },
219
+ ];
220
+
221
+ function tokenizeCss(source: string): CodeToken[] {
222
+ const tokens = runRules(source, CSS_RULES);
223
+ // Property detection: an identifier immediately followed by `:` counts as a
224
+ // declaration property only when the previous significant character opens a
225
+ // declaration (`{`, `}`, `;`, `,`, or start of input). This keeps selectors
226
+ // and pseudo-classes (`a:hover`) plain in the common cases.
227
+ let lastSignificant = "";
228
+ for (let idx = 0; idx < tokens.length; idx += 1) {
229
+ const tok = tokens[idx];
230
+ if (tok.type === "plain" && /^[A-Za-z_-][\w-]*$/.test(tok.value)) {
231
+ let j = idx + 1;
232
+ while (j < tokens.length && tokens[j].value.trim() === "") j += 1;
233
+ const next = tokens[j];
234
+ if (
235
+ next &&
236
+ next.value.replace(/^\s*/, "").startsWith(":") &&
237
+ (lastSignificant === "" ||
238
+ lastSignificant === "{" ||
239
+ lastSignificant === "}" ||
240
+ lastSignificant === ";" ||
241
+ lastSignificant === ",")
242
+ ) {
243
+ tok.type = "property";
244
+ }
245
+ }
246
+ const trimmed = tok.value.replace(/\s+$/, "");
247
+ if (trimmed) lastSignificant = trimmed[trimmed.length - 1];
248
+ }
249
+ return tokens;
250
+ }
251
+
252
+ const HTML_TAG_RULES: Rule[] = [
253
+ { type: "tag", re: /<\/?\s*[A-Za-z][\w:-]*/y },
254
+ { type: "string", re: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y },
255
+ { type: "attr", re: /[A-Za-z_:@][\w:.-]*(?=\s*=)/y },
256
+ { type: "attr", re: /[A-Za-z_:@][\w:.-]*/y },
257
+ { type: "tag", re: /\/?>/y },
258
+ ];
259
+
260
+ function tokenizeHtml(source: string): CodeToken[] {
261
+ const tokens: CodeToken[] = [];
262
+ const push = (type: CodeTokenType, value: string) => {
263
+ if (value) tokens.push({ type, value });
264
+ };
265
+ const n = source.length;
266
+ let i = 0;
267
+
268
+ while (i < n) {
269
+ // Comments.
270
+ if (source.startsWith("<!--", i)) {
271
+ const end = source.indexOf("-->", i + 4);
272
+ const stop = end === -1 ? n : end + 3;
273
+ push("comment", source.slice(i, stop));
274
+ i = stop;
275
+ continue;
276
+ }
277
+ // Doctype (and other `<!…>` declarations).
278
+ if (source[i] === "<" && source[i + 1] === "!") {
279
+ const end = source.indexOf(">", i);
280
+ const stop = end === -1 ? n : end + 1;
281
+ push("tag", source.slice(i, stop));
282
+ i = stop;
283
+ continue;
284
+ }
285
+ // Element tag.
286
+ if (source[i] === "<" && /[A-Za-z/]/.test(source[i + 1] || "")) {
287
+ const tagEnd = source.indexOf(">", i);
288
+ const stop = tagEnd === -1 ? n : tagEnd + 1;
289
+ const tagText = source.slice(i, stop);
290
+ for (const t of runRules(tagText, HTML_TAG_RULES)) tokens.push(t);
291
+ i = stop;
292
+
293
+ // Embedded <script>/<style> bodies: tokenize with the js/css tokenizer.
294
+ const embed = /^<\s*(script|style)\b/i.exec(tagText);
295
+ if (embed && !/\/>\s*$/.test(tagText)) {
296
+ const tag = embed[1].toLowerCase();
297
+ const closeRe = new RegExp("</\\s*" + tag + "\\s*>", "i");
298
+ const cm = closeRe.exec(source.slice(i));
299
+ const innerEnd = cm ? i + cm.index : n;
300
+ const inner = source.slice(i, innerEnd);
301
+ const innerTokens =
302
+ tag === "script" ? tokenizeJs(inner) : tokenizeCss(inner);
303
+ for (const t of innerTokens) tokens.push(t);
304
+ i = innerEnd;
305
+ }
306
+ continue;
307
+ }
308
+ // Text content up to the next `<` (or a lone `<` that is not a tag).
309
+ const lt = source.indexOf("<", i);
310
+ if (lt === i) {
311
+ push("plain", source[i]);
312
+ i += 1;
313
+ } else {
314
+ const stop = lt === -1 ? n : lt;
315
+ push("plain", source.slice(i, stop));
316
+ i = stop;
317
+ }
318
+ }
319
+ return tokens;
320
+ }
321
+
322
+ function tokenizeByLanguage(
323
+ source: string,
324
+ lang: HighlightLanguage
325
+ ): CodeToken[] {
326
+ switch (lang) {
327
+ case "html":
328
+ return tokenizeHtml(source);
329
+ case "css":
330
+ return tokenizeCss(source);
331
+ case "js":
332
+ return tokenizeJs(source);
333
+ case "json":
334
+ return tokenizeJson(source);
335
+ }
336
+ }
337
+
338
+ /** Build one `span.persona-code-line` per source line from a token stream. */
339
+ function buildLines(tokens: CodeToken[]): DocumentFragment {
340
+ const frag = createFragment();
341
+ let line = createElement("span", "persona-code-line");
342
+
343
+ const appendSegment = (type: CodeTokenType, text: string) => {
344
+ if (!text) return;
345
+ const cls = TOKEN_CLASS[type];
346
+ if (cls) {
347
+ const span = createElement("span", cls);
348
+ span.textContent = text;
349
+ line.appendChild(span);
350
+ } else {
351
+ line.appendChild(document.createTextNode(text));
352
+ }
353
+ };
354
+
355
+ for (const tok of tokens) {
356
+ const parts = tok.value.split("\n");
357
+ for (let p = 0; p < parts.length; p += 1) {
358
+ if (p > 0) {
359
+ // Close the current line, then emit the literal newline separator that
360
+ // keeps `textContent` verbatim. `.persona-code` sets white-space:normal
361
+ // so this whitespace-only node collapses visually between block lines.
362
+ frag.appendChild(line);
363
+ frag.appendChild(document.createTextNode("\n"));
364
+ line = createElement("span", "persona-code-line");
365
+ }
366
+ appendSegment(tok.type, parts[p]);
367
+ }
368
+ }
369
+
370
+ if (line.childNodes.length > 0) {
371
+ frag.appendChild(line);
372
+ } else if (!frag.lastChild) {
373
+ // Wholly empty source: keep the single empty line so the gutter shows "1".
374
+ frag.appendChild(line);
375
+ }
376
+ // Otherwise the source ended with a newline: the trailing "\n" separator is
377
+ // already in the fragment, so drop the empty phantom final line + number.
378
+ return frag;
379
+ }
380
+
381
+ /**
382
+ * Tokenize `source` and return a DocumentFragment of line spans (+ newline
383
+ * separator text nodes). Prefers the explicit `language`, then the `path`
384
+ * extension, else plain text. Sources longer than {@link MAX_HIGHLIGHT_LENGTH}
385
+ * are returned as plain line-numbered text (still gutter-numbered, no colors).
386
+ */
387
+ export function highlightCode(
388
+ source: string,
389
+ language?: string,
390
+ path?: string
391
+ ): DocumentFragment {
392
+ const lang =
393
+ source.length <= MAX_HIGHLIGHT_LENGTH
394
+ ? resolveHighlightLanguage(language, path)
395
+ : null;
396
+ const tokens: CodeToken[] = lang
397
+ ? tokenizeByLanguage(source, lang)
398
+ : [{ type: "plain", value: source }];
399
+ return buildLines(tokens);
400
+ }
@@ -13,6 +13,7 @@ import {
13
13
  Check,
14
14
  Clipboard,
15
15
  ClipboardCopy,
16
+ CodeXml,
16
17
  Copy,
17
18
  File as FileIcon,
18
19
  FileCode,
@@ -162,6 +163,7 @@ const LUCIDE_ICONS = {
162
163
  "check": Check,
163
164
  "clipboard": Clipboard,
164
165
  "clipboard-copy": ClipboardCopy,
166
+ "code-xml": CodeXml,
165
167
  "copy": Copy,
166
168
  "file": FileIcon,
167
169
  "file-code": FileCode,
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { createRovingTablist } from "./roving-tablist";
6
+
7
+ const makeTabs = (container: HTMLElement, count: number): HTMLElement[] => {
8
+ const tabs: HTMLElement[] = [];
9
+ for (let i = 0; i < count; i += 1) {
10
+ const b = document.createElement("button");
11
+ b.type = "button";
12
+ b.textContent = `tab-${i}`;
13
+ container.appendChild(b);
14
+ tabs.push(b);
15
+ }
16
+ return tabs;
17
+ };
18
+
19
+ afterEach(() => {
20
+ document.body.innerHTML = "";
21
+ });
22
+
23
+ describe("createRovingTablist", () => {
24
+ it("applies role=tablist/tab, aria-selected, and a single roving tabindex", () => {
25
+ const container = document.createElement("div");
26
+ const tabs = makeTabs(container, 3);
27
+ const controller = createRovingTablist(container, { onSelect: () => {} });
28
+ controller.render(tabs, 1);
29
+
30
+ expect(container.getAttribute("role")).toBe("tablist");
31
+ expect(tabs.every((t) => t.getAttribute("role") === "tab")).toBe(true);
32
+ expect(tabs.map((t) => t.getAttribute("aria-selected"))).toEqual([
33
+ "false",
34
+ "true",
35
+ "false",
36
+ ]);
37
+ // Only the selected tab is the tab stop.
38
+ expect(tabs.filter((t) => t.tabIndex === 0).length).toBe(1);
39
+ expect(tabs[1].tabIndex).toBe(0);
40
+ expect(tabs[0].tabIndex).toBe(-1);
41
+ expect(tabs[2].tabIndex).toBe(-1);
42
+ });
43
+
44
+ it("makes the first tab the stop when nothing is selected", () => {
45
+ const container = document.createElement("div");
46
+ const tabs = makeTabs(container, 3);
47
+ const controller = createRovingTablist(container, { onSelect: () => {} });
48
+ controller.render(tabs, -1);
49
+ expect(tabs[0].tabIndex).toBe(0);
50
+ expect(tabs[1].tabIndex).toBe(-1);
51
+ expect(tabs[2].tabIndex).toBe(-1);
52
+ });
53
+
54
+ it("moves selection with Arrow/Home/End (clamped) via onSelect", () => {
55
+ const container = document.createElement("div");
56
+ document.body.appendChild(container);
57
+ const tabs = makeTabs(container, 3);
58
+ const picks: number[] = [];
59
+ const controller = createRovingTablist(container, {
60
+ onSelect: (i) => picks.push(i),
61
+ });
62
+ controller.render(tabs, 0);
63
+
64
+ tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
65
+ expect(picks[picks.length - 1]).toBe(1);
66
+
67
+ // Clamp at the start: ArrowLeft from index 0 stays put (no onSelect).
68
+ const before = picks.length;
69
+ tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
70
+ expect(picks.length).toBe(before);
71
+
72
+ tabs[2].dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
73
+ expect(picks[picks.length - 1]).toBe(0);
74
+
75
+ tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
76
+ expect(picks[picks.length - 1]).toBe(2);
77
+ });
78
+
79
+ it("uses Up/Down for vertical orientation", () => {
80
+ const container = document.createElement("div");
81
+ document.body.appendChild(container);
82
+ const tabs = makeTabs(container, 3);
83
+ const picks: number[] = [];
84
+ const controller = createRovingTablist(container, {
85
+ onSelect: (i) => picks.push(i),
86
+ orientation: "vertical",
87
+ });
88
+ controller.render(tabs, 1);
89
+
90
+ tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
91
+ expect(picks[picks.length - 1]).toBe(2);
92
+ tabs[2].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
93
+ expect(picks[picks.length - 1]).toBe(1);
94
+ // Horizontal keys are ignored in vertical mode.
95
+ const before = picks.length;
96
+ tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
97
+ expect(picks.length).toBe(before);
98
+ });
99
+
100
+ it("restores focus to the selected tab across a rebuild", () => {
101
+ const container = document.createElement("div");
102
+ document.body.appendChild(container);
103
+ const controller = createRovingTablist(container, { onSelect: () => {} });
104
+
105
+ const first = makeTabs(container, 3);
106
+ controller.render(first, 0);
107
+ first[0].focus();
108
+ expect(document.activeElement).toBe(first[0]);
109
+
110
+ // Rebuild: capture focus, swap the tab DOM, re-render selecting index 1.
111
+ controller.beforeRender();
112
+ container.replaceChildren();
113
+ const next = makeTabs(container, 3);
114
+ controller.render(next, 1);
115
+
116
+ expect(document.activeElement).toBe(next[1]);
117
+ });
118
+
119
+ it("does not restore focus when beforeRender saw no focus inside the strip", () => {
120
+ const container = document.createElement("div");
121
+ document.body.appendChild(container);
122
+ const controller = createRovingTablist(container, { onSelect: () => {} });
123
+
124
+ const first = makeTabs(container, 2);
125
+ controller.render(first, 0);
126
+ // Focus lives outside the strip.
127
+ const outside = document.createElement("button");
128
+ document.body.appendChild(outside);
129
+ outside.focus();
130
+
131
+ controller.beforeRender();
132
+ container.replaceChildren();
133
+ const next = makeTabs(container, 2);
134
+ controller.render(next, 1);
135
+
136
+ expect(document.activeElement).toBe(outside);
137
+ });
138
+
139
+ it("stops routing keys after destroy", () => {
140
+ const container = document.createElement("div");
141
+ document.body.appendChild(container);
142
+ const tabs = makeTabs(container, 2);
143
+ const picks: number[] = [];
144
+ const controller = createRovingTablist(container, {
145
+ onSelect: (i) => picks.push(i),
146
+ });
147
+ controller.render(tabs, 0);
148
+ controller.destroy();
149
+ tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
150
+ expect(picks.length).toBe(0);
151
+ });
152
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Roving-tabindex tablist controller (WAI-ARIA APG tabs pattern). Owns the tab
3
+ * roles, aria-selected, roving tabindex, Arrow/Home/End keyboard nav routed
4
+ * through an onSelect callback, focus reveal via scrollIntoView, and focus
5
+ * survival across a tab-DOM rebuild. Extracted so custom tab bars stay
6
+ * accessible without re-implementing the pattern.
7
+ */
8
+
9
+ export interface RovingTablistOptions {
10
+ onSelect: (index: number) => void;
11
+ /** Arrow-key axis. @default "horizontal" */
12
+ orientation?: "horizontal" | "vertical";
13
+ }
14
+
15
+ export interface RovingTablistController {
16
+ /** Snapshot whether focus is inside the strip. Call before replacing tab DOM. */
17
+ beforeRender(): void;
18
+ /**
19
+ * Apply role=tablist/tab, aria-selected, roving tabindex, and keyboard nav to
20
+ * the given tab elements (in order). Restores focus to the selected tab when
21
+ * the last beforeRender() saw focus inside the strip. Call after (re)building
22
+ * tabs.
23
+ */
24
+ render(tabs: HTMLElement[], selectedIndex: number): void;
25
+ destroy(): void;
26
+ }
27
+
28
+ export function createRovingTablist(
29
+ container: HTMLElement,
30
+ options: RovingTablistOptions
31
+ ): RovingTablistController {
32
+ const orientation = options.orientation ?? "horizontal";
33
+ const prevKey = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
34
+ const nextKey = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
35
+ container.setAttribute("role", "tablist");
36
+
37
+ let tabs: HTMLElement[] = [];
38
+ // Whether the last beforeRender() saw focus inside the strip; consumed by the
39
+ // next render() to restore the roving stop after a rebuild.
40
+ let restoreFocus = false;
41
+
42
+ const revealTab = (tab: HTMLElement) => {
43
+ if (typeof tab.scrollIntoView === "function") {
44
+ tab.scrollIntoView({ block: "nearest", inline: "nearest" });
45
+ }
46
+ };
47
+
48
+ const indexOfTarget = (target: EventTarget | null): number => {
49
+ const node = target as HTMLElement | null;
50
+ return tabs.findIndex((t) => t === node || t.contains(node));
51
+ };
52
+
53
+ // One delegated keydown listener so it survives child replacement. Arrow moves
54
+ // selection one step (clamped); Home/End jump to the edges; selection routes
55
+ // through onSelect so the host owns state.
56
+ const onKeydown = (e: KeyboardEvent) => {
57
+ const index = indexOfTarget(e.target);
58
+ if (index < 0) return;
59
+ let next = index;
60
+ if (e.key === nextKey) next = Math.min(index + 1, tabs.length - 1);
61
+ else if (e.key === prevKey) next = Math.max(index - 1, 0);
62
+ else if (e.key === "Home") next = 0;
63
+ else if (e.key === "End") next = tabs.length - 1;
64
+ else return;
65
+ e.preventDefault();
66
+ if (next === index) return;
67
+ options.onSelect(next);
68
+ };
69
+
70
+ // Delegated focus reveal: bring a focused edge tab out from under the fade.
71
+ const onFocusin = (e: FocusEvent) => {
72
+ const index = indexOfTarget(e.target);
73
+ if (index >= 0) revealTab(tabs[index]);
74
+ };
75
+
76
+ container.addEventListener("keydown", onKeydown);
77
+ container.addEventListener("focusin", onFocusin);
78
+
79
+ return {
80
+ beforeRender() {
81
+ restoreFocus =
82
+ typeof document !== "undefined" &&
83
+ container.contains(document.activeElement);
84
+ },
85
+ render(nextTabs: HTMLElement[], selectedIndex: number) {
86
+ tabs = nextTabs;
87
+ nextTabs.forEach((tab, i) => {
88
+ tab.setAttribute("role", "tab");
89
+ const selected = i === selectedIndex;
90
+ tab.setAttribute("aria-selected", selected ? "true" : "false");
91
+ // Roving tabindex: the selected tab is the only stop, or the first tab
92
+ // when nothing is selected.
93
+ tab.tabIndex = selected || (selectedIndex < 0 && i === 0) ? 0 : -1;
94
+ });
95
+ // Restore focus to the roving stop after the rebuild so arrow nav keeps
96
+ // working across the selection re-render.
97
+ if (restoreFocus) {
98
+ const stop =
99
+ (selectedIndex >= 0 ? nextTabs[selectedIndex] : undefined) ?? nextTabs[0];
100
+ if (stop && typeof stop.focus === "function") {
101
+ revealTab(stop);
102
+ stop.focus();
103
+ }
104
+ }
105
+ },
106
+ destroy() {
107
+ container.removeEventListener("keydown", onKeydown);
108
+ container.removeEventListener("focusin", onFocusin);
109
+ },
110
+ };
111
+ }