@openvoxproject/voxblocks 0.11.0 → 0.12.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 (41) hide show
  1. package/README.md +1 -1
  2. package/dist/cdn/voxblocks.css +1 -1
  3. package/dist/cdn/voxblocks.js +2011 -1475
  4. package/dist/types/components/accordion/vox-accordion.d.ts +0 -2
  5. package/dist/types/components/code-block/languages.d.ts +28 -0
  6. package/dist/types/components/code-block/vox-code-block.d.ts +34 -0
  7. package/dist/types/components/disclosure/vox-disclosure.d.ts +0 -2
  8. package/dist/types/components/dropdown/vox-dropdown.d.ts +0 -3
  9. package/dist/types/components/header/vox-header.d.ts +0 -2
  10. package/dist/types/components/pagination/vox-pagination.d.ts +4 -6
  11. package/dist/types/components/sidenav/vox-sidenav.d.ts +0 -4
  12. package/dist/types/index.d.ts +2 -0
  13. package/dist/types/internal/field.d.ts +0 -4
  14. package/dist/voxblocks.css +1 -1
  15. package/dist/voxblocks.js +1491 -955
  16. package/package.json +1 -1
  17. package/src/components/accordion/vox-accordion.ts +1 -6
  18. package/src/components/alert/vox-alert.ts +1 -5
  19. package/src/components/avatar/vox-avatar.ts +2 -9
  20. package/src/components/card/vox-card.ts +1 -1
  21. package/src/components/checkbox/vox-checkbox.ts +3 -4
  22. package/src/components/code-block/languages.ts +259 -0
  23. package/src/components/code-block/vox-code-block.ts +302 -0
  24. package/src/components/dialog/vox-dialog.ts +2 -2
  25. package/src/components/disclosure/vox-disclosure.ts +1 -6
  26. package/src/components/dropdown/vox-dropdown.ts +8 -32
  27. package/src/components/empty-state/vox-empty-state.ts +1 -1
  28. package/src/components/file-input/vox-file-input.ts +1 -4
  29. package/src/components/header/vox-header.ts +0 -10
  30. package/src/components/input/vox-input.ts +0 -2
  31. package/src/components/link-hub/vox-link-hub.ts +1 -1
  32. package/src/components/pagination/vox-pagination.ts +6 -9
  33. package/src/components/radio/vox-radio-group.ts +6 -17
  34. package/src/components/select/vox-select.ts +0 -3
  35. package/src/components/sidenav/vox-sidenav.ts +1 -16
  36. package/src/components/step-indicator/vox-step-indicator.ts +2 -16
  37. package/src/components/tabs/vox-tabs.ts +2 -26
  38. package/src/components/textarea/vox-textarea.ts +0 -2
  39. package/src/index.ts +2 -0
  40. package/src/internal/field.ts +2 -20
  41. package/src/tokens/tokens.css +24 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvoxproject/voxblocks",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Web components for OpenVox community web sites and apps",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -48,13 +48,9 @@ export class VoxAccordion extends LitElement {
48
48
  */
49
49
  @customElement('vox-accordion-item')
50
50
  export class VoxAccordionItem extends LitElement {
51
- private static nextId = 0;
52
-
53
51
  @property() heading = '';
54
52
  @property({ type: Boolean, reflect: true }) open = false;
55
53
 
56
- private readonly panelId = `vox-accordion-panel-${VoxAccordionItem.nextId++}`;
57
-
58
54
  static styles = css`
59
55
  :host {
60
56
  display: block;
@@ -131,7 +127,6 @@ export class VoxAccordionItem extends LitElement {
131
127
  <button
132
128
  class="trigger"
133
129
  aria-expanded=${this.open ? 'true' : 'false'}
134
- aria-controls=${this.panelId}
135
130
  @click=${this.toggle}
136
131
  >
137
132
  ${this.heading}
@@ -140,7 +135,7 @@ export class VoxAccordionItem extends LitElement {
140
135
  </svg>
141
136
  </button>
142
137
  </h3>
143
- <div id=${this.panelId} class="panel" ?hidden=${!this.open}>
138
+ <div class="panel" ?hidden=${!this.open}>
144
139
  <slot></slot>
145
140
  </div>
146
141
  `;
@@ -154,12 +154,8 @@ export class VoxAlert extends LitElement {
154
154
  }
155
155
 
156
156
  render() {
157
- const isUrgent = this.variant === 'danger' || this.variant === 'warning';
158
157
  return html`
159
- <div
160
- class="alert ${this.variant}"
161
- role=${isUrgent ? 'alert' : 'status'}
162
- >
158
+ <div class="alert ${this.variant}" role="alert">
163
159
  <svg
164
160
  class="icon"
165
161
  viewBox="0 0 48 48"
@@ -1,4 +1,4 @@
1
- import { LitElement, html, css, nothing } from 'lit';
1
+ import { LitElement, html, css } from 'lit';
2
2
  import { customElement, property } from 'lit/decorators.js';
3
3
 
4
4
  export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -63,15 +63,8 @@ export class VoxAvatar extends LitElement {
63
63
  `;
64
64
 
65
65
  render() {
66
- // Without `alt`, fall back to letting AT read the initials as plain text
67
- // rather than hiding them behind an empty aria-label via role="img".
68
- const labelled = !this.src && this.alt;
69
66
  return html`
70
- <span
71
- class="avatar"
72
- role=${labelled ? 'img' : nothing}
73
- aria-label=${labelled ? this.alt : nothing}
74
- >
67
+ <span class="avatar" role=${this.src ? 'presentation' : 'img'} aria-label=${this.alt}>
75
68
  ${this.src
76
69
  ? html`<img src=${this.src} alt=${this.alt} />`
77
70
  : this.initials}
@@ -129,7 +129,7 @@ export class VoxCard extends LitElement {
129
129
  <div class="badge ${this.hasBadge ? 'has-badge' : ''}">
130
130
  <slot name="badge" @slotchange=${this.handleBadgeSlotChange}></slot>
131
131
  </div>
132
- <div class="icon ${this.hasIcon ? 'has-icon' : ''}" aria-hidden="true">
132
+ <div class="icon ${this.hasIcon ? 'has-icon' : ''}">
133
133
  <slot name="icon" @slotchange=${this.handleIconSlotChange}></slot>
134
134
  </div>
135
135
  <h3 class="heading">${this.heading}</h3>
@@ -60,9 +60,10 @@ export class VoxCheckbox extends VoxFieldElement {
60
60
 
61
61
  updated() {
62
62
  this.internals.setFormValue(this.checked ? this.value : null);
63
- this.invalid = this.required && !this.checked;
64
63
  this.internals.setValidity(
65
- this.invalid ? { valueMissing: true } : {},
64
+ this.required && !this.checked
65
+ ? { valueMissing: true }
66
+ : {},
66
67
  'Please check this box.',
67
68
  this.renderRoot.querySelector('input') ?? undefined,
68
69
  );
@@ -80,8 +81,6 @@ export class VoxCheckbox extends VoxFieldElement {
80
81
  type="checkbox"
81
82
  .checked=${this.checked}
82
83
  ?disabled=${this.disabled}
83
- ?required=${this.required}
84
- aria-invalid=${this.invalid ? 'true' : 'false'}
85
84
  @change=${this.handleChange}
86
85
  />
87
86
  <span class="box" aria-hidden="true">
@@ -0,0 +1,259 @@
1
+ /**
2
+ * A small, dependency-free syntax tokenizer. It trades completeness for
3
+ * being self-contained (no Prism/highlight.js/Shiki) and covers the
4
+ * languages that actually show up in OpenVox docs: shell, Puppet
5
+ * manifests, Hiera/YAML data, JSON, Ruby, plus the web basics.
6
+ *
7
+ * Each language is an ordered list of rules; the first rule whose regex
8
+ * matches at the current position wins, so more specific rules (e.g. "a
9
+ * quoted key before a colon") must be listed before the general-purpose
10
+ * ones they'd otherwise be shadowed by.
11
+ */
12
+
13
+ export type TokenType =
14
+ | 'plain'
15
+ | 'comment'
16
+ | 'keyword'
17
+ | 'string'
18
+ | 'number'
19
+ | 'function'
20
+ | 'property'
21
+ | 'tag';
22
+
23
+ export interface Token {
24
+ type: TokenType;
25
+ text: string;
26
+ }
27
+
28
+ type TypeResolver = (matchText: string, code: string, matchEnd: number) => TokenType;
29
+
30
+ interface Rule {
31
+ re: RegExp;
32
+ type: TokenType | TypeResolver;
33
+ }
34
+
35
+ function skipInlineSpace(code: string, from: number): number {
36
+ let j = from;
37
+ while (j < code.length && (code[j] === ' ' || code[j] === '\t')) j++;
38
+ return j;
39
+ }
40
+
41
+ /** Matches an identifier; resolves to `keyword` if it's in `keywords`, `function` if followed by `(`, else `plain`. */
42
+ function identifierRule(re: RegExp, keywords: Set<string>): Rule {
43
+ return {
44
+ re,
45
+ type: (text, code, end) => {
46
+ if (keywords.has(text)) return 'keyword';
47
+ return code[skipInlineSpace(code, end)] === '(' ? 'function' : 'plain';
48
+ },
49
+ };
50
+ }
51
+
52
+ /** Matches a quoted string; resolves to `property` if it's a key (followed by `:`), else `string`. */
53
+ function quotedPropertyRule(re: RegExp): Rule {
54
+ return {
55
+ re,
56
+ type: (_text, code, end) => (code[skipInlineSpace(code, end)] === ':' ? 'property' : 'string'),
57
+ };
58
+ }
59
+
60
+ const NUMBER = /-?\b\d+\.?\d*(?:[eE][+-]?\d+)?\b/y;
61
+ const DQ_STRING = /"(?:\\.|[^"\\])*"/y;
62
+ const SQ_STRING = /'(?:\\.|[^'\\])*'/y;
63
+ const HASH_COMMENT = /#.*/y;
64
+ const SLASH_COMMENT = /\/\/.*/y;
65
+ const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//y;
66
+
67
+ function pushToken(tokens: Token[], type: TokenType, text: string): void {
68
+ const last = tokens[tokens.length - 1];
69
+ if (last && last.type === type) {
70
+ last.text += text;
71
+ } else {
72
+ tokens.push({ type, text });
73
+ }
74
+ }
75
+
76
+ /** Runs `rules` over `code`, falling back to a single plain-text token for anything unmatched. */
77
+ export function tokenize(code: string, rules: Rule[]): Token[] {
78
+ const tokens: Token[] = [];
79
+ let i = 0;
80
+ while (i < code.length) {
81
+ let matched = false;
82
+ for (const rule of rules) {
83
+ rule.re.lastIndex = i;
84
+ const m = rule.re.exec(code);
85
+ if (m && m.index === i && m[0].length > 0) {
86
+ const text = m[0];
87
+ const type = typeof rule.type === 'function' ? rule.type(text, code, i + text.length) : rule.type;
88
+ pushToken(tokens, type, text);
89
+ i += text.length;
90
+ matched = true;
91
+ break;
92
+ }
93
+ }
94
+ if (!matched) {
95
+ pushToken(tokens, 'plain', code[i]);
96
+ i++;
97
+ }
98
+ }
99
+ return tokens;
100
+ }
101
+
102
+ const BASH_KEYWORDS = new Set([
103
+ 'if', 'then', 'elif', 'else', 'fi', 'for', 'while', 'until', 'do', 'done',
104
+ 'case', 'esac', 'in', 'function', 'select', 'time', 'return', 'exit',
105
+ 'break', 'continue', 'local', 'export', 'readonly', 'declare', 'unset',
106
+ 'shift', 'eval', 'exec', 'trap', 'set', 'source', 'alias', 'unalias', 'true', 'false',
107
+ ]);
108
+
109
+ const bash: Rule[] = [
110
+ { re: HASH_COMMENT, type: 'comment' },
111
+ { re: /\$\{[^}]*\}|\$[A-Za-z_]\w*|\$[0-9@#?$!*_-]/y, type: 'function' },
112
+ { re: DQ_STRING, type: 'string' },
113
+ { re: SQ_STRING, type: 'string' },
114
+ { re: NUMBER, type: 'number' },
115
+ identifierRule(/[A-Za-z_][\w-]*/y, BASH_KEYWORDS),
116
+ ];
117
+
118
+ const YAML_KEYWORDS = new Set([
119
+ 'true', 'false', 'yes', 'no', 'null', 'on', 'off',
120
+ 'True', 'False', 'Yes', 'No', 'Null', 'On', 'Off',
121
+ 'TRUE', 'FALSE', 'YES', 'NO', 'NULL', 'ON', 'OFF',
122
+ ]);
123
+
124
+ const yaml: Rule[] = [
125
+ { re: HASH_COMMENT, type: 'comment' },
126
+ quotedPropertyRule(DQ_STRING),
127
+ quotedPropertyRule(SQ_STRING),
128
+ { re: /[A-Za-z_][\w .-]*?(?=:(\s|$))/y, type: 'property' },
129
+ { re: /[&*!][A-Za-z_][\w:.]*/y, type: 'function' },
130
+ { re: NUMBER, type: 'number' },
131
+ identifierRule(/[A-Za-z_][\w-]*/y, YAML_KEYWORDS),
132
+ ];
133
+
134
+ const json: Rule[] = [
135
+ quotedPropertyRule(DQ_STRING),
136
+ { re: NUMBER, type: 'number' },
137
+ identifierRule(/[A-Za-z_]\w*/y, new Set(['true', 'false', 'null'])),
138
+ ];
139
+
140
+ const JS_KEYWORDS = new Set([
141
+ 'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
142
+ 'do', 'switch', 'case', 'default', 'break', 'continue', 'class', 'extends',
143
+ 'super', 'new', 'this', 'import', 'export', 'from', 'as', 'async', 'await',
144
+ 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of',
145
+ 'yield', 'static', 'get', 'set', 'void', 'delete', 'null', 'undefined',
146
+ 'true', 'false', 'public', 'private', 'protected', 'readonly', 'interface',
147
+ 'type', 'enum', 'implements', 'namespace', 'declare', 'abstract', 'keyof',
148
+ 'satisfies',
149
+ ]);
150
+
151
+ const javascript: Rule[] = [
152
+ { re: SLASH_COMMENT, type: 'comment' },
153
+ { re: BLOCK_COMMENT, type: 'comment' },
154
+ { re: DQ_STRING, type: 'string' },
155
+ { re: SQ_STRING, type: 'string' },
156
+ { re: /`(?:\\.|[^`\\])*`/y, type: 'string' },
157
+ { re: /-?\b0[xXbBoO][0-9a-fA-F]+\b|-?\b\d+\.?\d*(?:[eE][+-]?\d+)?\b/y, type: 'number' },
158
+ identifierRule(/[A-Za-z_$][\w$]*/y, JS_KEYWORDS),
159
+ ];
160
+
161
+ const css: Rule[] = [
162
+ { re: BLOCK_COMMENT, type: 'comment' },
163
+ { re: DQ_STRING, type: 'string' },
164
+ { re: SQ_STRING, type: 'string' },
165
+ { re: /@[\w-]+/y, type: 'keyword' },
166
+ { re: /!important/y, type: 'keyword' },
167
+ { re: /#[0-9a-fA-F]{3,8}\b/y, type: 'number' },
168
+ { re: /-?\b\d+\.?\d*[a-zA-Z%]*\b/y, type: 'number' },
169
+ { re: /[a-zA-Z-]+(?=\s*:)/y, type: 'property' },
170
+ identifierRule(/[a-zA-Z-]+/y, new Set()),
171
+ ];
172
+
173
+ const htmlLang: Rule[] = [
174
+ { re: /<!--[\s\S]*?-->/y, type: 'comment' },
175
+ { re: /<\/?[a-zA-Z][\w:-]*/y, type: 'tag' },
176
+ { re: /[a-zA-Z-][\w-]*(?=\s*=)/y, type: 'property' },
177
+ { re: DQ_STRING, type: 'string' },
178
+ { re: SQ_STRING, type: 'string' },
179
+ { re: /&[\w#]+;/y, type: 'keyword' },
180
+ ];
181
+
182
+ const RUBY_KEYWORDS = new Set([
183
+ 'def', 'end', 'if', 'elsif', 'else', 'unless', 'while', 'until', 'for',
184
+ 'in', 'do', 'class', 'module', 'begin', 'rescue', 'ensure', 'raise',
185
+ 'return', 'yield', 'break', 'next', 'redo', 'retry', 'case', 'when',
186
+ 'then', 'and', 'or', 'not', 'nil', 'true', 'false', 'self', 'super',
187
+ 'require', 'require_relative', 'include', 'extend', 'attr_accessor',
188
+ 'attr_reader', 'attr_writer', 'private', 'protected', 'public', 'lambda',
189
+ 'proc', 'new',
190
+ ]);
191
+
192
+ const ruby: Rule[] = [
193
+ { re: HASH_COMMENT, type: 'comment' },
194
+ { re: /:[A-Za-z_]\w*[?!]?/y, type: 'string' },
195
+ { re: /@{1,2}[A-Za-z_]\w*|\$[A-Za-z_]\w*/y, type: 'function' },
196
+ { re: DQ_STRING, type: 'string' },
197
+ { re: SQ_STRING, type: 'string' },
198
+ { re: NUMBER, type: 'number' },
199
+ { re: /[A-Z]\w*/y, type: 'property' },
200
+ identifierRule(/[a-z_]\w*[?!]?/y, RUBY_KEYWORDS),
201
+ ];
202
+
203
+ const PUPPET_KEYWORDS = new Set([
204
+ 'class', 'define', 'node', 'inherits', 'if', 'elsif', 'else', 'unless',
205
+ 'case', 'and', 'or', 'in', 'undef', 'true', 'false', 'default', 'import',
206
+ 'include', 'require', 'contain', 'function', 'type', 'application',
207
+ 'produces', 'consumes', 'private', 'return', 'break', 'next', 'each',
208
+ 'map', 'filter', 'reduce', 'with',
209
+ 'String', 'Integer', 'Boolean', 'Array', 'Hash', 'Optional', 'Enum',
210
+ 'Variant', 'Numeric', 'Float', 'Undef', 'Any', 'Pattern', 'Regexp',
211
+ 'Sensitive', 'Struct', 'Tuple', 'Type', 'Callable', 'Data', 'Scalar',
212
+ ]);
213
+
214
+ const puppet: Rule[] = [
215
+ { re: HASH_COMMENT, type: 'comment' },
216
+ { re: DQ_STRING, type: 'string' },
217
+ { re: SQ_STRING, type: 'string' },
218
+ { re: /\$[\w:]+/y, type: 'function' },
219
+ { re: /[a-zA-Z_][\w:]*(?=\s*\{)/y, type: 'tag' },
220
+ { re: /[a-z_]\w*(?=\s*=>)/y, type: 'property' },
221
+ { re: NUMBER, type: 'number' },
222
+ identifierRule(/[A-Za-z_][\w:]*/y, PUPPET_KEYWORDS),
223
+ ];
224
+
225
+ const GRAMMARS: Record<string, Rule[]> = {
226
+ bash, sh: bash, shell: bash, zsh: bash,
227
+ yaml, yml: yaml,
228
+ json,
229
+ javascript, js: javascript,
230
+ typescript: javascript, ts: javascript,
231
+ css,
232
+ html: htmlLang, xml: htmlLang,
233
+ ruby, rb: ruby,
234
+ puppet, pp: puppet,
235
+ };
236
+
237
+ export const LANGUAGE_LABELS: Record<string, string> = {
238
+ bash: 'Bash', sh: 'Shell', shell: 'Shell', zsh: 'Zsh',
239
+ yaml: 'YAML', yml: 'YAML',
240
+ json: 'JSON',
241
+ javascript: 'JavaScript', js: 'JavaScript',
242
+ typescript: 'TypeScript', ts: 'TypeScript',
243
+ css: 'CSS',
244
+ html: 'HTML', xml: 'XML',
245
+ ruby: 'Ruby', rb: 'Ruby',
246
+ puppet: 'Puppet', pp: 'Puppet',
247
+ plaintext: 'Plain Text', text: 'Plain Text',
248
+ };
249
+
250
+ export type CodeBlockLanguage =
251
+ | 'plaintext' | 'bash' | 'sh' | 'shell' | 'zsh' | 'yaml' | 'yml' | 'json'
252
+ | 'javascript' | 'js' | 'typescript' | 'ts' | 'css' | 'html' | 'xml'
253
+ | 'ruby' | 'rb' | 'puppet' | 'pp';
254
+
255
+ /** Tokenizes `code` for `language`; unrecognized languages render as plain, unhighlighted text. */
256
+ export function highlight(code: string, language: string): Token[] {
257
+ const rules = GRAMMARS[language];
258
+ return rules ? tokenize(code, rules) : [{ type: 'plain', text: code }];
259
+ }
@@ -0,0 +1,302 @@
1
+ import { LitElement, html, css, nothing } from 'lit';
2
+ import { customElement, property, state } from 'lit/decorators.js';
3
+ import { ICON_PATHS } from '../icon/icon-paths.js';
4
+ import { highlight, LANGUAGE_LABELS, type Token } from './languages.js';
5
+
6
+ export type { CodeBlockLanguage } from './languages.js';
7
+
8
+ /** Strips a shared leading indent and surrounding blank lines, so authored (and thus HTML-indented) code renders flush left. */
9
+ function dedent(text: string): string {
10
+ const lines = text.split('\n');
11
+ while (lines.length && lines[0].trim() === '') lines.shift();
12
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
13
+ const indents = lines.filter((l) => l.trim() !== '').map((l) => l.match(/^[ \t]*/)?.[0].length ?? 0);
14
+ const minIndent = indents.length ? Math.min(...indents) : 0;
15
+ return lines.map((l) => l.slice(minIndent)).join('\n');
16
+ }
17
+
18
+ /** Splits tokens on embedded newlines into one array of tokens per source line. */
19
+ function toLines(tokens: Token[]): Token[][] {
20
+ const lines: Token[][] = [[]];
21
+ for (const token of tokens) {
22
+ token.text.split('\n').forEach((part, i) => {
23
+ if (i > 0) lines.push([]);
24
+ if (part) lines[lines.length - 1].push({ type: token.type, text: part });
25
+ });
26
+ }
27
+ return lines;
28
+ }
29
+
30
+ /**
31
+ * A syntax-highlighted code block with a copy button, no external
32
+ * highlighter required. Covers shell, Puppet, YAML, JSON, Ruby, JS/TS,
33
+ * CSS, and HTML; unrecognized languages render as plain text.
34
+ *
35
+ * @slot - The code, as plain text (escape `<` and `&` as you would in
36
+ * any HTML source, e.g. inside a `<code>` child).
37
+ */
38
+ @customElement('vox-code-block')
39
+ export class VoxCodeBlock extends LitElement {
40
+ @property() language = '';
41
+
42
+ /** Shown in the header instead of/alongside the language label. */
43
+ @property() filename = '';
44
+
45
+ @property({ type: Boolean, attribute: 'line-numbers', reflect: true }) lineNumbers = false;
46
+
47
+ @property({ type: Boolean, attribute: 'no-copy' }) noCopy = false;
48
+
49
+ /** Hides the whole header bar (filename, language label, and copy button) — for dense contexts like a table cell. */
50
+ @property({ type: Boolean, attribute: 'no-header' }) noHeader = false;
51
+
52
+ /** Drops the outer border — for dropping into a surface (e.g. a table cell) that already has its own edge. */
53
+ @property({ type: Boolean, attribute: 'no-border', reflect: true }) noBorder = false;
54
+
55
+ @state() private _code = '';
56
+ @state() private _copied = false;
57
+
58
+ private _copyResetTimer?: ReturnType<typeof setTimeout>;
59
+
60
+ static styles = css`
61
+ :host {
62
+ display: block;
63
+ font-family: var(--vox-font-family-base);
64
+ }
65
+
66
+ .block {
67
+ border: 1px solid var(--vox-color-border);
68
+ border-radius: var(--vox-radius-md);
69
+ background-color: var(--vox-color-bg-alt);
70
+ overflow: hidden;
71
+ }
72
+
73
+ :host([no-border]) .block {
74
+ border: none;
75
+ }
76
+
77
+ .header {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: space-between;
81
+ gap: var(--vox-space-3);
82
+ padding: var(--vox-space-2) var(--vox-space-2) var(--vox-space-2) var(--vox-space-4);
83
+ border-bottom: 1px solid var(--vox-color-divider);
84
+ font-size: 12px;
85
+ }
86
+
87
+ .meta {
88
+ display: flex;
89
+ align-items: baseline;
90
+ gap: var(--vox-space-3);
91
+ min-width: 0;
92
+ overflow: hidden;
93
+ color: var(--vox-color-text-2);
94
+ }
95
+
96
+ .filename {
97
+ color: var(--vox-color-text-1);
98
+ font-family: var(--vox-font-family-mono);
99
+ font-weight: 600;
100
+ white-space: nowrap;
101
+ overflow: hidden;
102
+ text-overflow: ellipsis;
103
+ }
104
+
105
+ .lang {
106
+ flex: none;
107
+ text-transform: uppercase;
108
+ letter-spacing: 0.04em;
109
+ }
110
+
111
+ .copy {
112
+ flex: none;
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ width: 28px;
117
+ height: 28px;
118
+ padding: 0;
119
+ border: none;
120
+ border-radius: var(--vox-radius-sm);
121
+ background: none;
122
+ color: var(--vox-color-text-2);
123
+ cursor: pointer;
124
+ }
125
+
126
+ .copy:hover {
127
+ background-color: var(--vox-color-brand-soft);
128
+ color: var(--vox-color-brand-1);
129
+ }
130
+
131
+ .copy:focus-visible {
132
+ outline: 2px solid var(--vox-color-brand-1);
133
+ outline-offset: 2px;
134
+ }
135
+
136
+ .copy svg {
137
+ width: 15px;
138
+ height: 15px;
139
+ }
140
+
141
+ pre {
142
+ margin: 0;
143
+ padding: var(--vox-space-4);
144
+ overflow-x: auto;
145
+ white-space: pre;
146
+ tab-size: 2;
147
+ }
148
+
149
+ pre:focus-visible {
150
+ outline: 2px solid var(--vox-color-brand-1);
151
+ outline-offset: -2px;
152
+ }
153
+
154
+ code {
155
+ font-family: var(--vox-font-family-mono);
156
+ font-size: 13px;
157
+ line-height: 1.7;
158
+ color: var(--vox-color-text-1);
159
+ }
160
+
161
+ .line {
162
+ display: block;
163
+ }
164
+
165
+ :host([line-numbers]) code {
166
+ counter-reset: line;
167
+ }
168
+
169
+ :host([line-numbers]) .line {
170
+ padding-left: 3.5ch;
171
+ position: relative;
172
+ }
173
+
174
+ :host([line-numbers]) .line::before {
175
+ counter-increment: line;
176
+ content: counter(line);
177
+ position: absolute;
178
+ left: 0;
179
+ width: 2.5ch;
180
+ text-align: right;
181
+ color: var(--vox-color-text-3);
182
+ user-select: none;
183
+ }
184
+
185
+ .tok-comment {
186
+ color: var(--vox-code-comment);
187
+ font-style: italic;
188
+ }
189
+ .tok-keyword {
190
+ color: var(--vox-code-keyword);
191
+ }
192
+ .tok-string {
193
+ color: var(--vox-code-string);
194
+ }
195
+ .tok-number {
196
+ color: var(--vox-code-number);
197
+ }
198
+ .tok-function {
199
+ color: var(--vox-code-function);
200
+ }
201
+ .tok-property {
202
+ color: var(--vox-code-property);
203
+ }
204
+ .tok-tag {
205
+ color: var(--vox-code-tag);
206
+ }
207
+
208
+ slot {
209
+ display: none;
210
+ }
211
+
212
+ .visually-hidden {
213
+ position: absolute;
214
+ width: 1px;
215
+ height: 1px;
216
+ overflow: hidden;
217
+ clip: rect(0 0 0 0);
218
+ white-space: nowrap;
219
+ }
220
+ `;
221
+
222
+ disconnectedCallback() {
223
+ super.disconnectedCallback();
224
+ clearTimeout(this._copyResetTimer);
225
+ }
226
+
227
+ private _handleSlotChange(event: Event) {
228
+ const slot = event.target as HTMLSlotElement;
229
+ const text = slot
230
+ .assignedNodes({ flatten: true })
231
+ .map((node) => node.textContent ?? '')
232
+ .join('');
233
+ this._code = dedent(text);
234
+ }
235
+
236
+ private async _copy() {
237
+ try {
238
+ await navigator.clipboard.writeText(this._code);
239
+ } catch {
240
+ const textarea = document.createElement('textarea');
241
+ textarea.value = this._code;
242
+ textarea.style.position = 'fixed';
243
+ textarea.style.opacity = '0';
244
+ this.shadowRoot?.appendChild(textarea);
245
+ textarea.select();
246
+ document.execCommand('copy');
247
+ textarea.remove();
248
+ }
249
+ this._copied = true;
250
+ clearTimeout(this._copyResetTimer);
251
+ this._copyResetTimer = setTimeout(() => {
252
+ this._copied = false;
253
+ }, 1500);
254
+ }
255
+
256
+ render() {
257
+ const label = LANGUAGE_LABELS[this.language] ?? this.language;
258
+ const lines = toLines(highlight(this._code, this.language));
259
+
260
+ return html`
261
+ <div class="block">
262
+ ${this.noHeader
263
+ ? nothing
264
+ : html`
265
+ <div class="header">
266
+ <span class="meta">
267
+ ${this.filename ? html`<span class="filename">${this.filename}</span>` : nothing}
268
+ ${label ? html`<span class="lang">${label}</span>` : nothing}
269
+ </span>
270
+ ${this.noCopy
271
+ ? nothing
272
+ : html`
273
+ <button
274
+ class="copy"
275
+ type="button"
276
+ @click=${this._copy}
277
+ aria-label=${this._copied ? 'Copied' : 'Copy code'}
278
+ >
279
+ <svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
280
+ ${ICON_PATHS[this._copied ? 'check' : 'copy']}
281
+ </svg>
282
+ </button>
283
+ `}
284
+ </div>
285
+ `}
286
+ <pre tabindex="0"><code>${lines.map(
287
+ (line) => html`<span class="line">${line.map((token) =>
288
+ token.type === 'plain' ? token.text : html`<span class="tok-${token.type}">${token.text}</span>`,
289
+ )}</span>`,
290
+ )}</code></pre>
291
+ <span class="visually-hidden" aria-live="polite">${this._copied ? 'Copied to clipboard' : ''}</span>
292
+ </div>
293
+ <slot @slotchange=${this._handleSlotChange}></slot>
294
+ `;
295
+ }
296
+ }
297
+
298
+ declare global {
299
+ interface HTMLElementTagNameMap {
300
+ 'vox-code-block': VoxCodeBlock;
301
+ }
302
+ }
@@ -148,12 +148,12 @@ export class VoxDialog extends LitElement {
148
148
  render() {
149
149
  return html`
150
150
  <dialog
151
- aria-labelledby=${this.heading ? 'heading' : nothing}
151
+ aria-label=${this.heading || nothing}
152
152
  @close=${this.handleNativeClose}
153
153
  @click=${this.handleClick}
154
154
  >
155
155
  <div class="header">
156
- <h2 class="heading" id="heading">${this.heading}</h2>
156
+ <h2 class="heading">${this.heading}</h2>
157
157
  <button class="close" aria-label="Close dialog" @click=${this.close}>
158
158
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
159
159
  <path d="M18 6 6 18M6 6l12 12" />