@raylang/elements 0.0.0-stage → 0.4.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 (95) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +12 -2
  4. package/base.css +111 -0
  5. package/components/accordion/accordion.js +197 -0
  6. package/components/alert/alert.js +112 -0
  7. package/components/app-bar/app-bar.js +168 -0
  8. package/components/app-shell/app-shell.js +198 -0
  9. package/components/aspect-ratio/aspect-ratio.js +59 -0
  10. package/components/attachment/attachment.js +333 -0
  11. package/components/avatar/avatar.js +96 -0
  12. package/components/badge/badge.js +63 -0
  13. package/components/blockquote/blockquote.js +128 -0
  14. package/components/bottom-nav/bottom-nav.js +309 -0
  15. package/components/breadcrumbs/breadcrumbs.js +175 -0
  16. package/components/button/button.js +283 -0
  17. package/components/button-group/button-group.js +72 -0
  18. package/components/card/card.js +73 -0
  19. package/components/carousel/carousel.js +435 -0
  20. package/components/center/center.js +26 -0
  21. package/components/checkbox/checkbox.js +152 -0
  22. package/components/checkbox-group/checkbox-group.js +152 -0
  23. package/components/code/code.js +574 -0
  24. package/components/color-picker/color-picker.js +775 -0
  25. package/components/combobox/combobox.js +563 -0
  26. package/components/command-palette/command-palette.js +510 -0
  27. package/components/container/container.js +41 -0
  28. package/components/date-picker/date-picker.js +594 -0
  29. package/components/dialog/dialog.js +329 -0
  30. package/components/drawer/drawer.js +139 -0
  31. package/components/empty-state/empty-state.js +66 -0
  32. package/components/file-upload/file-upload.js +589 -0
  33. package/components/grid/grid.js +96 -0
  34. package/components/group/group.js +40 -0
  35. package/components/icon/icon.js +58 -0
  36. package/components/icon-button/icon-button.js +30 -0
  37. package/components/indicator/indicator.js +112 -0
  38. package/components/input/input.js +85 -0
  39. package/components/kbd/kbd.js +60 -0
  40. package/components/menu/menu.js +296 -0
  41. package/components/nav-link/nav-link.js +159 -0
  42. package/components/notification/notification.js +591 -0
  43. package/components/number-input/number-input.js +241 -0
  44. package/components/pagination/pagination.js +194 -0
  45. package/components/password-input/password-input.js +85 -0
  46. package/components/path-input/path-input.js +137 -0
  47. package/components/popover/popover.js +85 -0
  48. package/components/progress/progress.js +109 -0
  49. package/components/radio/radio.js +253 -0
  50. package/components/scroll-area/scroll-area.js +158 -0
  51. package/components/search/search.js +157 -0
  52. package/components/segmented-control/segmented-control.js +190 -0
  53. package/components/select/select.js +506 -0
  54. package/components/separator/separator.js +58 -0
  55. package/components/sidebar/sidebar.js +252 -0
  56. package/components/skeleton/skeleton.js +90 -0
  57. package/components/slider/slider.js +155 -0
  58. package/components/spinner/spinner.js +39 -0
  59. package/components/split/split.js +221 -0
  60. package/components/stack/stack.js +46 -0
  61. package/components/stat/stat.js +79 -0
  62. package/components/statusbar/statusbar.js +110 -0
  63. package/components/stepper/stepper.js +239 -0
  64. package/components/switch/switch.js +45 -0
  65. package/components/table/table.js +328 -0
  66. package/components/tabs/tabs.js +224 -0
  67. package/components/textarea/textarea.js +105 -0
  68. package/components/theme-toggle/theme-toggle.js +78 -0
  69. package/components/timeline/timeline.js +150 -0
  70. package/components/toggle/toggle.js +130 -0
  71. package/components/toggle-group/toggle-group.js +216 -0
  72. package/components/tooltip/tooltip.js +101 -0
  73. package/core/charts.js +107 -0
  74. package/core/color.js +110 -0
  75. package/core/core.js +556 -0
  76. package/core/dismiss.js +69 -0
  77. package/core/floating.js +195 -0
  78. package/core/focus.js +70 -0
  79. package/core/form.js +235 -0
  80. package/core/host.js +200 -0
  81. package/core/hotkey.js +40 -0
  82. package/core/icons.js +104 -0
  83. package/core/motion.js +73 -0
  84. package/core/position.js +91 -0
  85. package/core/raylang.js +167 -0
  86. package/core/scroll.js +58 -0
  87. package/core/styles.js +211 -0
  88. package/core.js +16 -0
  89. package/custom-elements.json +18545 -0
  90. package/index.js +74 -0
  91. package/package.json +312 -4
  92. package/ray-elements.d.ts +2570 -0
  93. package/tokens.css +796 -0
  94. package/tokens.json +2083 -0
  95. package/vscode.html-custom-data.json +5499 -0
@@ -0,0 +1,574 @@
1
+ // <ray-code>: a block of source code — a scroll box with an optional header
2
+ // (file name, language), a copy button, line numbers, highlighted lines,
3
+ // diff lines, wrapping and a "show more" height.
4
+ //
5
+ // The code comes from the `code` property, a <script type="text/plain">
6
+ // child (raw text: anything but "</script>"), a <template> child (parsed as
7
+ // HTML, so best for markup: a "<" that is not a tag, as in Promise<void>,
8
+ // breaks it), or the element's text; common indentation and blank edges are
9
+ // removed.
10
+ //
11
+ // The language is `language`, not `lang`: lang is HTML's attribute for the
12
+ // human language of the text (a screen reader would switch its voice).
13
+ //
14
+ // Highlighting is pluggable. The built-in highlighter is small and generic
15
+ // (comments, strings, numbers, keywords, types, functions, tags, attributes,
16
+ // punctuation for js/ts, json, html/xml, css, shell, ray and diff) and
17
+ // builds text nodes only. setHighlighter(fn) hands every block to a library
18
+ // instead (Prism, highlight.js, Shiki…): its HTML is inserted as is, and its
19
+ // classes are painted with the --ray-code-* tokens, so the palette stays
20
+ // ray-elements's.
21
+
22
+ import { RayElement, css, define } from "../../core/core.js";
23
+
24
+ // --- the built-in highlighter -------------------------------------------------
25
+
26
+ const JS_KEYWORDS = "as async await break case catch class const continue debugger default delete do else enum export extends false finally for from function get if implements import in instanceof interface let new null of private protected public readonly return set static super switch this throw true try type typeof undefined var void while with yield";
27
+ const RAY_KEYWORDS = "as break const continue else enum false fn for from if impl import in let match pub return self spawn struct test trait true type var while";
28
+ const SH_KEYWORDS = "case do done elif else esac export fi for function if in local return then until while";
29
+ const words = (list) => new Set(list.split(" "));
30
+
31
+ /** Token rules per language: [type, sticky regex]; identifiers are classified after. */
32
+ const IDENT = /[A-Za-z_$][\w$]*/y;
33
+ const LANGS = {
34
+ js: {
35
+ keywords: words(JS_KEYWORDS),
36
+ rules: [
37
+ ["comment", /\/\/[^\n]*|\/\*[\s\S]*?\*\//y],
38
+ ["string", /`(?:\\[\s\S]|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/y],
39
+ ["number", /\b(?:0[xob][\da-f_]+|\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?n?)\b/iy],
40
+ ["punctuation", /=>|[{}()[\];,.:?!<>=+\-*/%&|^~@]+/y],
41
+ ],
42
+ },
43
+ ray: {
44
+ keywords: words(RAY_KEYWORDS),
45
+ rules: [
46
+ ["comment", /\/\/[^\n]*|\/\*[\s\S]*?\*\//y],
47
+ ["string", /`(?:\\[\s\S]|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/y],
48
+ ["attr", /@\w+/y],
49
+ ["number", /\b(?:0[xob][\da-f_]+|\d[\d_]*(?:\.\d+)?)\b/iy],
50
+ ["punctuation", /=>|->|\|>|[{}()[\];,.:?!<>=+\-*/%&|^~]+/y],
51
+ ],
52
+ },
53
+ json: {
54
+ keywords: words("true false null"),
55
+ rules: [
56
+ ["attr", /"(?:\\.|[^"\\\n])*"(?=\s*:)/y],
57
+ ["string", /"(?:\\.|[^"\\\n])*"/y],
58
+ ["number", /-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/iy],
59
+ ["punctuation", /[{}[\],:]/y],
60
+ ],
61
+ },
62
+ css: {
63
+ keywords: words("important inherit initial unset revert none auto"),
64
+ rules: [
65
+ ["comment", /\/\*[\s\S]*?\*\//y],
66
+ ["string", /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/y],
67
+ ["keyword", /@[\w-]+/y],
68
+ ["attr", /--?[A-Za-z][\w-]*(?=\s*:)|[a-z][\w-]*(?=\s*:(?!:))/y],
69
+ ["number", /#[\da-f]{3,8}\b|-?\b\d*\.?\d+(?:%|[a-z]+)?/iy],
70
+ ["function", /[\w-]+(?=\()/y],
71
+ ["tag", /[.#][A-Za-z][\w-]*|::?[\w-]+/y],
72
+ ["punctuation", /[{}();:,>+~*=[\]]/y],
73
+ ],
74
+ },
75
+ sh: {
76
+ keywords: words(SH_KEYWORDS),
77
+ rules: [
78
+ ["comment", /(?:^|(?<=\s))#[^\n]*/my],
79
+ ["string", /"(?:\\.|[^"\\])*"|'[^']*'/y],
80
+ ["attr", /\$\{[^}]*\}|\$\w+|--?[A-Za-z][\w-]*/y],
81
+ ["number", /\b\d+\b/y],
82
+ ["punctuation", /[|&;<>()]+/y],
83
+ ],
84
+ // The first word of a command is the program.
85
+ commands: true,
86
+ },
87
+ };
88
+ const ALIASES = { javascript: "js", ts: "js", typescript: "js", jsx: "js", tsx: "js", mjs: "js", raylang: "ray", bash: "sh", shell: "sh", zsh: "sh", console: "sh" };
89
+
90
+ /** HTML / XML / SVG / Vue: tags, attributes, values, comments. */
91
+ function tokenizeMarkup(src) {
92
+ const out = [];
93
+ const re = /<!--[\s\S]*?-->|<\/?[A-Za-z][\w:.-]*(?:\s+[^\s=/>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*\s*\/?>|[^<]+|</y;
94
+ let m;
95
+ while ((m = re.exec(src))) {
96
+ const t = m[0];
97
+ if (t.startsWith("<!--")) out.push(["comment", t]);
98
+ else if (t.startsWith("<") && t.length > 1) {
99
+ const tag = /^(<\/?)([A-Za-z][\w:.-]*)([\s\S]*?)(\/?>)$/.exec(t);
100
+ if (!tag) { out.push(["text", t]); continue; }
101
+ out.push(["punctuation", tag[1]], ["tag", tag[2]]);
102
+ const attrs = /(\s+)([^\s=/>]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s>]+))?/y;
103
+ let a;
104
+ let last = 0;
105
+ while ((a = attrs.exec(tag[3]))) {
106
+ out.push(["text", a[1]], ["attr", a[2]]);
107
+ if (a[3]) out.push(["punctuation", a[3]], ["string", a[4]]);
108
+ last = attrs.lastIndex;
109
+ }
110
+ if (last < tag[3].length) out.push(["text", tag[3].slice(last)]);
111
+ out.push(["punctuation", tag[4]]);
112
+ } else out.push(["text", t]);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function tokenizeCode(src, lang) {
118
+ const out = [];
119
+ const { keywords, rules, commands } = lang;
120
+ let i = 0;
121
+ let lineStart = true;
122
+ while (i < src.length) {
123
+ const ch = src[i];
124
+ if (ch === "\n" || ch === " " || ch === "\t") {
125
+ out.push(["text", ch]);
126
+ if (ch === "\n") lineStart = true;
127
+ i++;
128
+ continue;
129
+ }
130
+ let hit = null;
131
+ for (const [type, re] of rules) {
132
+ re.lastIndex = i;
133
+ const m = re.exec(src);
134
+ if (m && m[0]) { hit = [type, m[0]]; break; }
135
+ }
136
+ if (!hit) {
137
+ IDENT.lastIndex = i;
138
+ const m = IDENT.exec(src);
139
+ if (m) {
140
+ const w = m[0];
141
+ const next = src.slice(i + w.length).match(/^\s*(.)/)?.[1];
142
+ let type = "text";
143
+ if (keywords.has(w)) type = "keyword";
144
+ else if (commands && lineStart) type = "function";
145
+ else if (next === "(") type = "function";
146
+ else if (/^[A-Z][a-z0-9]/.test(w) && !commands) type = "type";
147
+ hit = [type, w];
148
+ } else hit = ["text", ch];
149
+ }
150
+ out.push(hit);
151
+ lineStart = false;
152
+ i += hit[1].length;
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * The built-in highlighter: code as [type, text] tokens. Types: keyword,
159
+ * string, number, comment, function, type, tag, attr, punctuation, text.
160
+ */
161
+ export function tokenize(code, lang = "") {
162
+ const l = String(lang).toLowerCase();
163
+ const key = ALIASES[l] ?? l;
164
+ if (["html", "xml", "svg", "vue"].includes(key)) return tokenizeMarkup(code);
165
+ if (LANGS[key]) return tokenizeCode(code, LANGS[key]);
166
+ return [["text", code]];
167
+ }
168
+
169
+ /** The built-in highlighter's output as nodes (text only, never parsed as HTML). */
170
+ function highlightBuiltIn(code, lang) {
171
+ const frag = document.createDocumentFragment();
172
+ for (const [type, text] of tokenize(code, lang)) {
173
+ if (type === "text") frag.append(text);
174
+ else {
175
+ const s = document.createElement("span");
176
+ s.className = `t-${type}`;
177
+ s.textContent = text;
178
+ frag.append(s);
179
+ }
180
+ }
181
+ return frag;
182
+ }
183
+
184
+ // --- pluggable highlighter --------------------------------------------------------
185
+
186
+ let highlighter = null;
187
+ const blocks = new Set();
188
+
189
+ /**
190
+ * Hands every ray-code to `fn(code, lang)`, which returns HTML (a string, a
191
+ * Node, or a Promise of either); null/undefined falls back to the built-in
192
+ * one for that block. setHighlighter(null) restores the built-in one.
193
+ * The HTML is inserted as is: pass only a trusted highlighter.
194
+ */
195
+ export function setHighlighter(fn) {
196
+ highlighter = typeof fn === "function" ? fn : null;
197
+ for (const b of blocks) b.requestUpdate();
198
+ }
199
+
200
+ // --- lines ------------------------------------------------------------------------
201
+
202
+ // Highlighted nodes split at the newlines into one fragment per line, each
203
+ // keeping its ancestors (a span across lines is cloned on each).
204
+ function splitLines(root) {
205
+ const lines = [document.createDocumentFragment()];
206
+ const place = (ancestors, node) => {
207
+ let parent = lines[lines.length - 1];
208
+ for (const a of ancestors) {
209
+ const last = parent.lastChild;
210
+ if (last && last.__rayFrom === a) {
211
+ parent = last;
212
+ } else {
213
+ const clone = a.cloneNode(false);
214
+ clone.__rayFrom = a;
215
+ parent.append(clone);
216
+ parent = clone;
217
+ }
218
+ }
219
+ parent.append(node);
220
+ };
221
+ const walk = (node, ancestors) => {
222
+ for (const child of [...node.childNodes]) {
223
+ if (child.nodeType === Node.TEXT_NODE) {
224
+ child.data.split("\n").forEach((part, i) => {
225
+ if (i > 0) lines.push(document.createDocumentFragment());
226
+ if (part) place(ancestors, document.createTextNode(part));
227
+ });
228
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
229
+ walk(child, [...ancestors, child]);
230
+ }
231
+ }
232
+ };
233
+ walk(root, []);
234
+ return lines;
235
+ }
236
+
237
+ // "3, 5-7" → Set {3, 5, 6, 7}.
238
+ function parseLines(spec) {
239
+ const set = new Set();
240
+ for (const part of String(spec ?? "").split(/[\s,]+/).filter(Boolean)) {
241
+ const m = /^(\d+)(?:-(\d+))?$/.exec(part);
242
+ if (!m) continue;
243
+ const a = Number(m[1]);
244
+ const b = Number(m[2] ?? m[1]);
245
+ for (let n = Math.min(a, b); n <= Math.max(a, b); n++) set.add(n);
246
+ }
247
+ return set;
248
+ }
249
+
250
+ // Common indentation and blank first/last lines removed.
251
+ function dedent(text) {
252
+ const lines = String(text).replace(/\r\n?/g, "\n").replace(/\t/g, " ").split("\n");
253
+ while (lines.length && !lines[0].trim()) lines.shift();
254
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
255
+ const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length), Infinity);
256
+ return lines.map((l) => l.slice(Number.isFinite(indent) ? indent : 0)).join("\n");
257
+ }
258
+
259
+ // --- the element ------------------------------------------------------------------
260
+
261
+ const LABELS = { js: "JavaScript", ts: "TypeScript", tsx: "TSX", jsx: "JSX", json: "JSON", html: "HTML", xml: "XML", svg: "SVG", vue: "Vue", css: "CSS", sh: "Shell", bash: "Bash", shell: "Shell", ray: "raylang", raylang: "raylang", diff: "Diff" };
262
+
263
+ const styles = css`
264
+ :host {
265
+ display: block;
266
+ --_lh: 1.65;
267
+ --_fs: var(--ray-font-size-sm);
268
+ /* Shiki's css-variables theme, painted with the ray-elements palette. */
269
+ --shiki-foreground: var(--ray-code-text);
270
+ --shiki-background: transparent;
271
+ --shiki-token-keyword: var(--ray-code-keyword);
272
+ --shiki-token-string: var(--ray-code-string);
273
+ --shiki-token-string-expression: var(--ray-code-string);
274
+ --shiki-token-constant: var(--ray-code-number);
275
+ --shiki-token-comment: var(--ray-code-comment);
276
+ --shiki-token-function: var(--ray-code-function);
277
+ --shiki-token-parameter: var(--ray-code-text);
278
+ --shiki-token-punctuation: var(--ray-code-punctuation);
279
+ --shiki-token-link: var(--ray-code-attr);
280
+ }
281
+ :host([size="xs"]) { --_fs: var(--ray-font-size-xs); }
282
+ :host([size="md"]) { --_fs: var(--ray-font-size-md); }
283
+ .frame {
284
+ position: relative;
285
+ overflow: hidden;
286
+ color: var(--ray-code-text);
287
+ background: var(--ray-code-background);
288
+ border: 1px solid var(--ray-border);
289
+ border-radius: var(--_radius, var(--ray-radius-md));
290
+ }
291
+ .header {
292
+ display: flex;
293
+ align-items: center;
294
+ gap: var(--ray-space-2);
295
+ min-height: 2.5rem;
296
+ padding: 0 var(--ray-space-2) 0 var(--ray-space-4);
297
+ font-size: var(--ray-font-size-xs);
298
+ color: var(--ray-muted-foreground);
299
+ border-bottom: 1px solid var(--ray-border);
300
+ }
301
+ .header[hidden] { display: none; }
302
+ .filename { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--ray-font-mono); color: var(--ray-foreground); }
303
+ .lang { font-weight: var(--ray-font-weight-medium); letter-spacing: 0.02em; }
304
+ .copy {
305
+ display: inline-flex;
306
+ align-items: center;
307
+ justify-content: center;
308
+ gap: var(--ray-space-1);
309
+ height: 1.75rem;
310
+ min-width: 1.75rem;
311
+ padding: 0 var(--ray-space-2);
312
+ font: inherit;
313
+ font-size: var(--ray-font-size-xs);
314
+ color: var(--ray-muted-foreground);
315
+ background: transparent;
316
+ border: 0;
317
+ border-radius: var(--ray-radius-sm);
318
+ cursor: pointer;
319
+ }
320
+ .copy:hover { color: var(--ray-foreground); background: var(--ray-default-hover); }
321
+ .copy svg { width: 0.95rem; height: 0.95rem; }
322
+ .copy .done { display: none; }
323
+ .copy[data-copied] .idle { display: none; }
324
+ .copy[data-copied] .done { display: inline-flex; color: var(--ray-code-added); }
325
+ .copy-label { display: none; }
326
+ .copy[data-copied] .copy-label { display: inline; }
327
+ /* Without a header, the button floats in the top-end corner. */
328
+ .copy.floating { position: absolute; z-index: 1; top: var(--ray-space-2); right: var(--ray-space-2); background: var(--ray-code-background); opacity: 0; transition: opacity var(--ray-duration-fast); }
329
+ .frame:hover .copy.floating, .copy.floating:focus-visible, .copy.floating[data-copied] { opacity: 1; }
330
+ @media (hover: none) { .copy.floating { opacity: 1; } }
331
+ .copy[hidden] { display: none; }
332
+
333
+ .scroller { overflow: auto; max-height: var(--_max, none); outline: none; }
334
+ .scroller:focus-visible { box-shadow: inset 0 0 0 2px var(--ray-ring); }
335
+ :host([expanded]) .scroller { max-height: none; }
336
+ pre { margin: 0; padding: var(--ray-space-3) 0; font-family: var(--ray-font-mono); font-size: var(--_fs); line-height: var(--_lh); tab-size: 2; }
337
+ code { display: block; min-width: max-content; font: inherit; counter-reset: line; }
338
+ :host([wrap]) code { min-width: 0; }
339
+ .line { display: block; padding: 0 var(--ray-space-4); white-space: pre; border-inline-start: 2px solid transparent; }
340
+ :host([wrap]) .line { white-space: pre-wrap; overflow-wrap: anywhere; }
341
+ .line:empty::after { content: " "; }
342
+ :host([line-numbers]) .line { counter-increment: line; padding-left: 0; }
343
+ :host([line-numbers]) .line::before {
344
+ content: counter(line);
345
+ display: inline-block;
346
+ width: calc(var(--_digits, 2) * 1ch + var(--ray-space-6));
347
+ padding-inline-end: var(--ray-space-4);
348
+ box-sizing: border-box;
349
+ text-align: right;
350
+ color: var(--ray-code-comment);
351
+ opacity: 0.7;
352
+ user-select: none;
353
+ }
354
+ :host([line-numbers][wrap]) .line { padding-left: calc(var(--_digits, 2) * 1ch + var(--ray-space-6)); text-indent: calc((var(--_digits, 2) * 1ch + var(--ray-space-6)) * -1); }
355
+ .line[data-highlight] { background: color-mix(in srgb, var(--ray-primary) 12%, transparent); border-inline-start-color: var(--ray-primary); }
356
+ .line[data-diff="add"] { color: var(--ray-code-added); background: color-mix(in srgb, var(--ray-code-added) 12%, transparent); }
357
+ .line[data-diff="remove"] { color: var(--ray-code-removed); background: color-mix(in srgb, var(--ray-code-removed) 12%, transparent); }
358
+ .line[data-diff="meta"] { color: var(--ray-code-comment); }
359
+
360
+ .more {
361
+ display: flex;
362
+ justify-content: center;
363
+ padding: var(--ray-space-1);
364
+ border-top: 1px solid var(--ray-border);
365
+ }
366
+ .more[hidden] { display: none; }
367
+ .more button {
368
+ padding: var(--ray-space-1) var(--ray-space-3);
369
+ font: inherit;
370
+ font-size: var(--ray-font-size-xs);
371
+ color: var(--ray-primary-light-color);
372
+ background: transparent;
373
+ border: 0;
374
+ border-radius: var(--ray-radius-sm);
375
+ cursor: pointer;
376
+ }
377
+ .more button:hover { background: var(--ray-default-hover); }
378
+
379
+ /* Token colors: the built-in classes, Prism's and highlight.js's. */
380
+ .t-keyword, .token.keyword, .token.atrule, .token.important, .hljs-keyword, .hljs-selector-tag, .hljs-meta .hljs-keyword { color: var(--ray-code-keyword); }
381
+ .t-string, .token.string, .token.attr-value, .token.char, .token.regex, .token.url, .hljs-string, .hljs-regexp, .hljs-symbol { color: var(--ray-code-string); }
382
+ .t-number, .token.number, .token.boolean, .token.constant, .hljs-number, .hljs-literal { color: var(--ray-code-number); }
383
+ .t-comment, .token.comment, .token.prolog, .token.doctype, .token.cdata, .hljs-comment, .hljs-quote { color: var(--ray-code-comment); font-style: italic; }
384
+ .t-function, .token.function, .hljs-title.function_, .hljs-title, .hljs-built_in { color: var(--ray-code-function); }
385
+ .t-type, .token.class-name, .token.builtin, .hljs-type, .hljs-title.class_ { color: var(--ray-code-type); }
386
+ .t-tag, .token.tag, .token.selector, .hljs-tag, .hljs-name, .hljs-selector-class, .hljs-selector-id { color: var(--ray-code-tag); }
387
+ .t-attr, .token.attr-name, .token.property, .token.variable, .hljs-attr, .hljs-attribute, .hljs-property, .hljs-variable, .hljs-params { color: var(--ray-code-attr); }
388
+ .t-punctuation, .token.punctuation, .token.operator, .hljs-punctuation, .hljs-operator { color: var(--ray-code-punctuation); }
389
+ .token.inserted, .hljs-addition { color: var(--ray-code-added); }
390
+ .token.deleted, .hljs-deletion { color: var(--ray-code-removed); }
391
+ `;
392
+
393
+ export class RayCode extends RayElement {
394
+ static properties = {
395
+ code: { type: String, attribute: false },
396
+ language: { type: String, reflect: true },
397
+ filename: { type: String },
398
+ lineNumbers: { type: Boolean, reflect: true },
399
+ highlight: { type: String },
400
+ wrap: { type: Boolean, reflect: true },
401
+ maxHeight: { type: String },
402
+ expanded: { type: Boolean, reflect: true },
403
+ hideCopy: { type: Boolean, reflect: true },
404
+ hideHeader: { type: Boolean, reflect: true },
405
+ size: { type: String, reflect: true, default: "sm" },
406
+ radius: { type: String, reflect: true },
407
+ copyLabel: { type: String, default: "Copy code" },
408
+ copiedLabel: { type: String, default: "Copied" },
409
+ };
410
+ static styles = [styles];
411
+ static template = `
412
+ <div class="frame" part="frame">
413
+ <div class="header" part="header"><span class="filename" part="filename"></span><span class="lang" part="language"></span></div>
414
+ <div class="scroller" part="scroller" tabindex="0" role="region"><pre part="pre"><code part="code"></code></pre></div>
415
+ <div class="more" part="more" hidden><button type="button"></button></div>
416
+ <span class="sr" aria-live="polite" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)"></span>
417
+ </div>
418
+ <slot hidden></slot>`;
419
+
420
+ #render = 0;
421
+ #source = "";
422
+
423
+ constructor() {
424
+ super();
425
+ this.copyButton = document.createElement("button");
426
+ const b = this.copyButton;
427
+ b.type = "button";
428
+ b.className = "copy focus-ring";
429
+ b.setAttribute("part", "copy");
430
+ b.innerHTML = `<span class="idle" data-ray-icon="copy" aria-hidden="true"></span><span class="done" data-ray-icon="check" aria-hidden="true"></span><span class="copy-label"></span>`;
431
+ b.addEventListener("click", () => this.copy());
432
+ this.$(".more button").addEventListener("click", () => { this.expanded = !this.expanded; });
433
+ this.$("slot").addEventListener("slotchange", () => this.requestUpdate());
434
+ this.#observer = new MutationObserver(() => this.requestUpdate());
435
+ }
436
+
437
+ #observer;
438
+
439
+ connectedCallback() {
440
+ super.connectedCallback();
441
+ blocks.add(this);
442
+ this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
443
+ }
444
+
445
+ disconnectedCallback() {
446
+ super.disconnectedCallback();
447
+ blocks.delete(this);
448
+ this.#observer.disconnect();
449
+ }
450
+
451
+ /** The code shown (dedented), whatever its source. */
452
+ get source() {
453
+ return this.#source;
454
+ }
455
+
456
+ #readSource() {
457
+ if (typeof this.code === "string") return dedent(this.code);
458
+ // A <template> is parsed as HTML: perfect for markup; its serialization
459
+ // escapes &, < and > in text, undone here.
460
+ const tpl = this.querySelector(":scope > template");
461
+ if (tpl) return dedent(tpl.innerHTML.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, "\u00a0").replace(/&amp;/g, "&"));
462
+ const script = this.querySelector(':scope > script[type="text/plain"]');
463
+ if (script) return dedent(script.textContent);
464
+ return dedent(this.textContent);
465
+ }
466
+
467
+ update() {
468
+ this.#source = this.#readSource();
469
+ const lang = (this.language ?? "").toLowerCase();
470
+ // Header: file name and language; the copy button goes in it or floats.
471
+ const header = this.$(".header");
472
+ const label = LABELS[lang] ?? (lang ? lang.toUpperCase() : "");
473
+ this.$(".filename").textContent = this.filename ?? "";
474
+ this.$(".lang").textContent = this.filename || !this.hideHeader ? label : "";
475
+ const showHeader = !this.hideHeader && Boolean(this.filename || label);
476
+ header.hidden = !showHeader;
477
+ const b = this.copyButton;
478
+ b.hidden = Boolean(this.hideCopy);
479
+ b.classList.toggle("floating", !showHeader);
480
+ if (showHeader) header.append(b);
481
+ else this.$(".frame").append(b);
482
+ b.setAttribute("aria-label", this.copyLabel);
483
+ b.title = this.copyLabel;
484
+ b.querySelector(".copy-label").textContent = this.copiedLabel;
485
+ this.paintIcons(b);
486
+ // The scroll box: a named region, its height and the "show more" button.
487
+ const scroller = this.$(".scroller");
488
+ scroller.setAttribute("aria-label", this.filename || (label ? `${label} code` : "Code"));
489
+ const max = this.maxHeight;
490
+ if (max) this.style.setProperty("--_max", /^\d+(\.\d+)?$/.test(max) ? `${max}px` : max);
491
+ else this.style.removeProperty("--_max");
492
+ const r = this.radius;
493
+ if (r) this.style.setProperty("--_radius", ["xs", "sm", "md", "lg", "xl"].includes(r) ? `var(--ray-radius-${r})` : r === "none" ? "0" : r);
494
+ else this.style.removeProperty("--_radius");
495
+ this.#paint(lang);
496
+ }
497
+
498
+ async #paint(lang) {
499
+ const run = ++this.#render;
500
+ const src = this.#source;
501
+ let html = null;
502
+ if (highlighter && lang !== "diff") {
503
+ try {
504
+ html = await highlighter(src, lang);
505
+ } catch {
506
+ html = null;
507
+ }
508
+ if (run !== this.#render) return;
509
+ }
510
+ const holder = document.createElement("div");
511
+ if (html === null || html === undefined) holder.append(highlightBuiltIn(src, lang === "diff" ? "" : lang));
512
+ else if (typeof html === "string") holder.innerHTML = html;
513
+ else holder.append(html);
514
+ // A library's <pre><code> wrapper: keep only what is inside.
515
+ const inner = holder.querySelector(":scope > pre code, :scope > pre, :scope > code");
516
+ const root = inner && holder.childElementCount === 1 ? inner : holder;
517
+ const marked = parseLines(this.highlight);
518
+ const code = this.$("code");
519
+ const lines = splitLines(root);
520
+ const srcLines = src.split("\n");
521
+ code.replaceChildren(...lines.map((frag, i) => {
522
+ const line = document.createElement("span");
523
+ line.className = "line";
524
+ line.append(frag);
525
+ if (marked.has(i + 1)) line.setAttribute("data-highlight", "");
526
+ if (lang === "diff") {
527
+ const first = srcLines[i]?.[0];
528
+ const kind = first === "+" ? "add" : first === "-" ? "remove" : first === "@" ? "meta" : "";
529
+ if (kind) line.dataset.diff = kind;
530
+ }
531
+ return line;
532
+ }));
533
+ this.style.setProperty("--_digits", String(String(lines.length).length));
534
+ // "Show more" when a max height cuts the code.
535
+ const more = this.$(".more");
536
+ const scroller = this.$(".scroller");
537
+ const cut = Boolean(this.maxHeight) && (this.expanded || scroller.scrollHeight > scroller.clientHeight + 1);
538
+ more.hidden = !cut;
539
+ more.firstElementChild.textContent = this.expanded ? "Show less" : `Show all ${lines.length} lines`;
540
+ more.firstElementChild.setAttribute("aria-expanded", String(Boolean(this.expanded)));
541
+ }
542
+
543
+ /** Copies the code to the clipboard; resolves to true when it worked. */
544
+ async copy() {
545
+ let ok = false;
546
+ try {
547
+ await navigator.clipboard.writeText(this.#source);
548
+ ok = true;
549
+ } catch {
550
+ // No clipboard API (or refused): select the code and copy it.
551
+ const sel = getSelection();
552
+ const range = document.createRange();
553
+ range.selectNodeContents(this.$("code"));
554
+ sel?.removeAllRanges();
555
+ sel?.addRange(range);
556
+ try { ok = document.execCommand("copy"); } catch { ok = false; }
557
+ sel?.removeAllRanges();
558
+ }
559
+ if (ok) {
560
+ const b = this.copyButton;
561
+ b.setAttribute("data-copied", "");
562
+ this.$(".sr").textContent = this.copiedLabel;
563
+ clearTimeout(this._copied);
564
+ this._copied = setTimeout(() => {
565
+ b.removeAttribute("data-copied");
566
+ this.$(".sr").textContent = "";
567
+ }, 1600);
568
+ this.emit("ray-copy", { code: this.#source });
569
+ }
570
+ return ok;
571
+ }
572
+ }
573
+
574
+ define("ray-code", RayCode);