@johnmorrisdotca/kazoku-ui 0.14.0 → 0.15.1

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.
@@ -0,0 +1,684 @@
1
+ // <kazoku-embed-panel>: the family's one "Put it in your page" panel. A package fills it with its own choices and its own code: the panel shows a compact picker for the thing
2
+ // being embedded, a size choice, a tab for each way a page is made (HTML, ES module, React, Vue, Angular, Svelte, iframe and any more the package adds, such as an image), the code
3
+ // with a Copy button that says what it does, the picture the code makes (built from the same choices, and always fitting its box), a link to the embed page, a link to the docs and
4
+ // the credit line. It draws nothing of the package's: the page gives the code (`generate`) and the picture (`preview`), and calls `update()` when its own choices change.
5
+ import { applyParams, createAddressWriter, readParam } from "../address-state/address.js";
6
+ import { adoptStyles } from "../shared/adopt.js";
7
+ import { watchScheme } from "../shared/scheme.js";
8
+ import { DEFAULT_KINDS, DEFAULT_SIZE, filterItems, groupsOf, KIND_ADDRESS_KEY, nearestSize, parseKinds, parseSizes, SEARCH_FROM, SIZE_ADDRESS_KEY } from "./choices.js";
9
+ import { EVENT, TAG } from "./index.js";
10
+ import { STYLES } from "./styles.js";
11
+ import { kindLabel, languageOf, wordsFor } from "./words.js";
12
+ export { EVENT, TAG };
13
+ const SVG = "http://www.w3.org/2000/svg";
14
+ function icon(doc, path) {
15
+ const svg = doc.createElementNS(SVG, "svg");
16
+ svg.setAttribute("viewBox", "0 0 24 24");
17
+ svg.setAttribute("aria-hidden", "true");
18
+ const shape = doc.createElementNS(SVG, "path");
19
+ shape.setAttribute("d", path);
20
+ svg.append(shape);
21
+ return svg;
22
+ }
23
+ /**
24
+ * Attributes: `kinds` (the tabs, `html module react vue angular svelte iframe` by default; add your own such as `image`), `kind` (the tab to start on), `sizes` (`160 240 320 480`) and
25
+ * `size` (the start, 320), `no-size` (no size choice), `value` (the picker's choice), `open-href` (the embed page), `docs-href` (the docs), `aspect` (the picture's height over its width, 1),
26
+ * `no-preview` (no picture), `heading` (show the shared heading), `search` / `no-search` (force the picker's search box on or off), `pick-label` (the picker's name), `address` (keep the tab and
27
+ * the size in the address under `kind-key`, `embed`, and `size-key`, `size`; the starts are left off it), `lang` (`en` or `ja`), `theme` (`light` or `dark`).
28
+ * Slots: `lede` (a line under the heading), `picker` (your own picker in place of the built-in one), `options` (your own choices between the size and the tabs), `actions` (more buttons
29
+ * beside Copy: Save SVG, Download). Parts: `panel`, `title`, `picker`, `size`, `tabs`, `tab`, `preview`, `note`, `code`, `copy`, `open`, `docs`, `credits`.
30
+ * Custom properties: `--kazoku-preview-bg` (behind the picture, the page's surface by default), `--kazoku-preview-width` (the picture's column, 260px).
31
+ */
32
+ export class EmbedPanelElement extends HTMLElement {
33
+ static observedAttributes = ["kinds", "kind", "sizes", "size", "value", "open-href", "docs-href", "aspect", "lang", "heading", "no-size", "no-preview", "pick-label", "search", "no-search"];
34
+ #root;
35
+ #kinds = [...DEFAULT_KINDS];
36
+ #sizes = [];
37
+ #kind = "html";
38
+ #size = DEFAULT_SIZE;
39
+ #startKind = "html";
40
+ #startSize = DEFAULT_SIZE;
41
+ #value = "";
42
+ #items = [];
43
+ #query = "";
44
+ #group = "";
45
+ #generate;
46
+ #preview;
47
+ #credits = "";
48
+ #note = "";
49
+ #labels = {};
50
+ #words = {};
51
+ #renderItem;
52
+ #code = "";
53
+ #ready = false;
54
+ #built = false;
55
+ #styled = false;
56
+ #previewKey = "";
57
+ #writer = createAddressWriter({ delay: 0 });
58
+ #stop;
59
+ #sizeObserver;
60
+ #langObserver;
61
+ #timer;
62
+ #reflecting = false;
63
+ #frame = 0;
64
+ #el = {};
65
+ constructor() {
66
+ super();
67
+ this.#root = this.attachShadow({ mode: "open" });
68
+ }
69
+ /** The tab now: one of `kinds`. Setting one that is not there is ignored. */
70
+ get kind() {
71
+ return this.#kind;
72
+ }
73
+ set kind(kind) {
74
+ if (this.#kinds.includes(kind))
75
+ this.#choose({ kind }, "set");
76
+ }
77
+ /** The size now, in pixels across: one of `sizes`. Setting a number that is not one takes the nearest. */
78
+ get size() {
79
+ return this.#size;
80
+ }
81
+ set size(size) {
82
+ if (this.#sizes.length > 0)
83
+ this.#choose({ size: nearestSize(size, this.#sizes) }, "set");
84
+ }
85
+ /** The tabs, in order. Setting a list (or a text such as `html react iframe`) draws them again. */
86
+ get kinds() {
87
+ return this.#kinds;
88
+ }
89
+ set kinds(list) {
90
+ this.#aimKinds(parseKinds(typeof list === "string" ? list : list.join(" ")));
91
+ }
92
+ /** The sizes the buttons offer, smallest first. */
93
+ get sizes() {
94
+ return this.#sizes;
95
+ }
96
+ set sizes(list) {
97
+ this.#aimSizes(parseSizes(typeof list === "string" ? list : list.join(" ")));
98
+ }
99
+ /** The picker's choice: an item's `id`. Setting one that is not in `items` is ignored. */
100
+ get value() {
101
+ return this.#value;
102
+ }
103
+ set value(id) {
104
+ if (this.#items.some((item) => item.id === id))
105
+ this.#choose({ value: id }, "set");
106
+ }
107
+ /** The things the built-in picker offers. Leave it empty and give the `picker` slot for a picker of your own. */
108
+ get items() {
109
+ return this.#items;
110
+ }
111
+ set items(items) {
112
+ this.#items = items;
113
+ if (!items.some((item) => item.id === this.#value))
114
+ this.#value = items[0]?.id ?? "";
115
+ this.#query = "";
116
+ this.#group = "";
117
+ this.#renderPicker(true);
118
+ }
119
+ /** How a picker button looks inside: a node (a small picture) or a text. The label by default. */
120
+ set renderItem(render) {
121
+ this.#renderItem = render;
122
+ this.#renderPicker(true);
123
+ }
124
+ /** The text of a tab for the choices as they are. Required: without it the code box is empty. Call `update()` after the page's own choices change. */
125
+ set generate(generate) {
126
+ this.#generate = generate;
127
+ this.update();
128
+ }
129
+ /** The picture the code makes, as a node (or nothing). It is called when the picture's choices change; the iframe tab draws the iframe itself when this returns nothing for it. */
130
+ set preview(preview) {
131
+ this.#preview = preview;
132
+ this.#previewKey = "";
133
+ this.update();
134
+ }
135
+ /** The credit line shown under the code (and read by a screen reader): a text, or a function of the choices. */
136
+ set credits(credits) {
137
+ this.#credits = credits;
138
+ this.update();
139
+ }
140
+ /** A line above the code for the tab shown ("The embed page is for a site that cannot run script."): a text, or a function of the choices. */
141
+ set note(note) {
142
+ this.#note = note;
143
+ this.update();
144
+ }
145
+ /** The names of the tabs the family has no word for: `{ image: "Image address" }`. */
146
+ set labels(labels) {
147
+ this.#labels = labels;
148
+ this.#render();
149
+ }
150
+ /** Your own words over the language's. */
151
+ set words(words) {
152
+ this.#words = words;
153
+ this.#render();
154
+ }
155
+ /** Where "Open the embed page" goes. Set it again when the choices change the address. */
156
+ get openHref() {
157
+ return this.getAttribute("open-href") ?? "";
158
+ }
159
+ set openHref(href) {
160
+ this.#attr("open-href", href);
161
+ this.#links();
162
+ }
163
+ /** Where "Read the docs" goes. */
164
+ get docsHref() {
165
+ return this.getAttribute("docs-href") ?? "";
166
+ }
167
+ set docsHref(href) {
168
+ this.#attr("docs-href", href);
169
+ this.#links();
170
+ }
171
+ /** The text of the tab shown: what Copy copies. */
172
+ get code() {
173
+ return this.#code;
174
+ }
175
+ /** Reads the page's choices again: the code, the credit, the note, the links, and the picture if its choices changed. Call it whenever the page's own choices change. It tells no one. */
176
+ update() {
177
+ if (!this.#built)
178
+ return;
179
+ this.#refresh();
180
+ }
181
+ connectedCallback() {
182
+ if (!this.#styled) {
183
+ adoptStyles(this.#root, STYLES, this.nonce || "");
184
+ this.#styled = true;
185
+ }
186
+ if (!this.#built)
187
+ this.#build();
188
+ this.#stop = watchScheme(this);
189
+ this.#kinds = parseKinds(this.getAttribute("kinds"));
190
+ this.#sizes = parseSizes(this.getAttribute("sizes"));
191
+ this.#startKind = this.#kinds.includes(this.getAttribute("kind") ?? "") ? this.getAttribute("kind") : (this.#kinds[0] ?? "html");
192
+ this.#startSize = nearestSize(this.getAttribute("size"), this.#sizes);
193
+ const keptKind = this.hasAttribute("address") ? readParam(this.#key("kind"), globalThis.location?.search ?? "") : null;
194
+ const keptSize = this.hasAttribute("address") ? readParam(this.#key("size"), globalThis.location?.search ?? "") : null;
195
+ this.#kind = keptKind !== null && this.#kinds.includes(keptKind) ? keptKind : this.#startKind;
196
+ this.#size = keptSize !== null ? nearestSize(keptSize, this.#sizes) : this.#startSize;
197
+ this.#upgrade();
198
+ const wished = this.getAttribute("value");
199
+ if (wished !== null && this.#items.some((item) => item.id === wished))
200
+ this.#value = wished;
201
+ this.#render();
202
+ this.#ready = true;
203
+ if (typeof ResizeObserver === "function") {
204
+ this.#sizeObserver = new ResizeObserver(() => this.#fit());
205
+ this.#sizeObserver.observe(this.#el["preview"]);
206
+ }
207
+ if (typeof MutationObserver === "function") {
208
+ this.#langObserver = new MutationObserver(() => this.#render());
209
+ this.#langObserver.observe(this.ownerDocument.documentElement, { attributes: true, attributeFilter: ["lang"] });
210
+ }
211
+ queueMicrotask(() => {
212
+ if (this.isConnected)
213
+ this.#dispatch("init");
214
+ });
215
+ }
216
+ disconnectedCallback() {
217
+ this.#stop?.();
218
+ this.#stop = undefined;
219
+ this.#sizeObserver?.disconnect();
220
+ this.#langObserver?.disconnect();
221
+ this.#writer.cancel();
222
+ if (this.#timer !== undefined)
223
+ clearTimeout(this.#timer);
224
+ if (this.#frame !== 0)
225
+ cancelAnimationFrame(this.#frame);
226
+ this.#frame = 0;
227
+ this.#ready = false;
228
+ }
229
+ attributeChangedCallback(name, _old, value) {
230
+ if (!this.#ready || this.#reflecting)
231
+ return;
232
+ if (name === "kinds")
233
+ this.#aimKinds(parseKinds(value));
234
+ else if (name === "sizes")
235
+ this.#aimSizes(parseSizes(value));
236
+ else if (name === "kind") {
237
+ if (value !== null && this.#kinds.includes(value))
238
+ this.#choose({ kind: value }, "set");
239
+ }
240
+ else if (name === "size") {
241
+ if (value !== null)
242
+ this.#choose({ size: nearestSize(value, this.#sizes) }, "set");
243
+ }
244
+ else if (name === "value") {
245
+ if (value !== null && this.#items.some((item) => item.id === value))
246
+ this.#choose({ value }, "set");
247
+ }
248
+ else if (name === "open-href" || name === "docs-href")
249
+ this.#links();
250
+ else if (name === "aspect")
251
+ this.#fit();
252
+ else
253
+ this.#render();
254
+ }
255
+ /** A property set before the tag was defined sits on the element itself and hides the setter: take it off and set it again. */
256
+ #upgrade() {
257
+ const self = this;
258
+ for (const name of ["kinds", "sizes", "items", "renderItem", "labels", "words", "generate", "preview", "credits", "note", "kind", "size", "value"]) {
259
+ if (Object.prototype.hasOwnProperty.call(this, name)) {
260
+ const value = self[name];
261
+ delete self[name];
262
+ self[name] = value;
263
+ }
264
+ }
265
+ }
266
+ #key(which) {
267
+ return which === "kind" ? this.getAttribute("kind-key") || KIND_ADDRESS_KEY : this.getAttribute("size-key") || SIZE_ADDRESS_KEY;
268
+ }
269
+ #attr(name, value) {
270
+ this.#reflecting = true;
271
+ if (value === "")
272
+ this.removeAttribute(name);
273
+ else
274
+ this.setAttribute(name, value);
275
+ this.#reflecting = false;
276
+ }
277
+ #language() {
278
+ return languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
279
+ }
280
+ #words_() {
281
+ return wordsFor(this.#language(), this.#words);
282
+ }
283
+ #aimKinds(kinds) {
284
+ this.#kinds = kinds;
285
+ this.#startKind = kinds.includes(this.#startKind) ? this.#startKind : (kinds[0] ?? "html");
286
+ if (!kinds.includes(this.#kind))
287
+ this.#kind = this.#startKind;
288
+ this.#render();
289
+ }
290
+ #aimSizes(sizes) {
291
+ this.#sizes = sizes;
292
+ this.#startSize = nearestSize(this.#startSize, sizes);
293
+ this.#size = nearestSize(this.#size, sizes);
294
+ this.#render();
295
+ }
296
+ #choose(next, reason) {
297
+ const kind = next.kind ?? this.#kind;
298
+ const size = next.size ?? this.#size;
299
+ const value = next.value ?? this.#value;
300
+ if (kind === this.#kind && size === this.#size && value === this.#value)
301
+ return;
302
+ this.#kind = kind;
303
+ this.#size = size;
304
+ this.#value = value;
305
+ this.#refresh();
306
+ this.#sync();
307
+ if (this.hasAttribute("address")) {
308
+ const [kindKey, sizeKey] = [this.#key("kind"), this.#key("size")];
309
+ this.#writer.write((search) => applyParams(search, { [kindKey]: kind, [sizeKey]: size }, { [kindKey]: this.#startKind, [sizeKey]: String(this.#startSize) }));
310
+ }
311
+ this.#dispatch(reason);
312
+ }
313
+ /** The `value` attribute follows the picker, so a framework that watches attributes sees it. */
314
+ #sync() {
315
+ if (this.#items.length > 0 && this.getAttribute("value") !== this.#value)
316
+ this.#attr("value", this.#value);
317
+ }
318
+ #text(text, preview = false) {
319
+ return typeof text === "function" ? text({ kind: this.#kind, size: this.#size, preview }) : text;
320
+ }
321
+ #build() {
322
+ const doc = this.ownerDocument;
323
+ const make = (tag, className = "", part = "") => {
324
+ const node = doc.createElement(tag);
325
+ if (className !== "")
326
+ node.className = className;
327
+ if (part !== "")
328
+ node.setAttribute("part", part);
329
+ return node;
330
+ };
331
+ const slot = (name) => {
332
+ const node = doc.createElement("slot");
333
+ node.name = name;
334
+ return node;
335
+ };
336
+ const panel = make("section", "panel", "panel");
337
+ const title = make("h2", "title", "title");
338
+ const pick = make("div", "pick", "picker");
339
+ const builtin = make("div", "builtin");
340
+ pick.append(slot("picker"), builtin);
341
+ const sizeRow = make("div", "row");
342
+ const sizeLabel = make("span", "lbl");
343
+ sizeLabel.setAttribute("aria-hidden", "true");
344
+ const sizeGroup = make("div", "group", "size");
345
+ sizeGroup.setAttribute("role", "group");
346
+ sizeRow.append(sizeLabel, sizeGroup);
347
+ const tabs = make("div", "tabs", "tabs");
348
+ tabs.setAttribute("role", "tablist");
349
+ const grid = make("div", "grid");
350
+ grid.setAttribute("role", "tabpanel");
351
+ const preview = make("div", "preview", "preview");
352
+ const frame = make("div", "frame");
353
+ const fit = make("div", "fit");
354
+ frame.append(fit);
355
+ preview.append(frame);
356
+ const codecol = make("div", "codecol");
357
+ const note = make("p", "note", "note");
358
+ const codebox = make("pre", "codebox", "code");
359
+ codebox.tabIndex = 0;
360
+ const code = make("code");
361
+ codebox.append(code);
362
+ const actions = make("div", "actions");
363
+ const copy = make("button", "primary", "copy");
364
+ copy.type = "button";
365
+ const copyIcon = icon(doc, "M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z");
366
+ const copyText = make("span");
367
+ copy.append(copyIcon, copyText);
368
+ const open = make("a", "btn", "open");
369
+ open.target = "_blank";
370
+ open.rel = "noopener";
371
+ const docs = make("a", "btn", "docs");
372
+ const status = make("span", "status");
373
+ status.setAttribute("role", "status");
374
+ status.setAttribute("aria-live", "polite");
375
+ actions.append(copy, open, docs, slot("actions"), status);
376
+ const credits = make("p", "credits", "credits");
377
+ codecol.append(note, codebox, actions, credits);
378
+ grid.append(preview, codecol);
379
+ panel.append(title, slot("lede"), pick, sizeRow, slot("options"), tabs, grid);
380
+ this.#root.append(panel);
381
+ this.#el = { title, builtin, pick, sizeRow, sizeLabel, sizeGroup, tabs, grid, preview, frame, fit, note, codebox, code, copy, copyText, open, docs, status, credits };
382
+ tabs.addEventListener("click", (event) => {
383
+ const tab = event.target.closest("button[data-kind]");
384
+ if (tab !== null)
385
+ this.#choose({ kind: tab.dataset["kind"] }, "kind");
386
+ });
387
+ tabs.addEventListener("keydown", (event) => {
388
+ const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
389
+ const edge = event.key === "Home" ? 0 : event.key === "End" ? this.#kinds.length - 1 : -1;
390
+ if (step === 0 && edge < 0)
391
+ return;
392
+ const at = this.#kinds.indexOf(this.#kind);
393
+ const next = this.#kinds[edge >= 0 ? edge : (at + step + this.#kinds.length) % this.#kinds.length];
394
+ if (next === undefined)
395
+ return;
396
+ event.preventDefault();
397
+ this.#choose({ kind: next }, "kind");
398
+ tabs.querySelector(`[data-kind="${next}"]`)?.focus();
399
+ });
400
+ sizeGroup.addEventListener("click", (event) => {
401
+ const button = event.target.closest("button[data-size]");
402
+ if (button !== null)
403
+ this.#choose({ size: Number(button.dataset["size"]) }, "size");
404
+ });
405
+ builtin.addEventListener("click", (event) => {
406
+ const target = event.target;
407
+ const item = target.closest("button[data-id]");
408
+ if (item !== null) {
409
+ this.#choose({ value: item.dataset["id"] }, "pick");
410
+ return;
411
+ }
412
+ const chip = target.closest("button[data-group]");
413
+ if (chip !== null) {
414
+ this.#group = chip.dataset["group"] ?? "";
415
+ this.#renderPicker(false);
416
+ }
417
+ });
418
+ builtin.addEventListener("input", (event) => {
419
+ if (event.target.matches("input")) {
420
+ this.#query = event.target.value;
421
+ this.#renderPicker(false);
422
+ }
423
+ });
424
+ copy.addEventListener("click", () => void this.#copy());
425
+ this.#built = true;
426
+ }
427
+ async #copy() {
428
+ const words = this.#words_();
429
+ const status = this.#el["status"];
430
+ try {
431
+ await navigator.clipboard.writeText(this.#code);
432
+ status.textContent = words.copied;
433
+ }
434
+ catch {
435
+ const range = this.ownerDocument.createRange();
436
+ range.selectNodeContents(this.#el["code"]);
437
+ const selection = this.ownerDocument.defaultView?.getSelection();
438
+ selection?.removeAllRanges();
439
+ selection?.addRange(range);
440
+ status.textContent = words.copyFail;
441
+ }
442
+ this.dispatchEvent(new CustomEvent("kazoku-embedcopy", { detail: { kind: this.#kind, size: this.#size, code: this.#code }, bubbles: true, composed: true }));
443
+ if (this.#timer !== undefined)
444
+ clearTimeout(this.#timer);
445
+ this.#timer = setTimeout(() => (status.textContent = ""), 2500);
446
+ }
447
+ /** Everything the words and the lists decide: the headings, the buttons, the tabs, the picker, then the code. */
448
+ #render() {
449
+ if (!this.#built)
450
+ return;
451
+ const words = this.#words_();
452
+ const language = this.#language();
453
+ const doc = this.ownerDocument;
454
+ const el = this.#el;
455
+ el["title"].textContent = words.title;
456
+ el["title"].hidden = !this.hasAttribute("heading");
457
+ el["copyText"].textContent = words.copy;
458
+ el["open"].textContent = words.open;
459
+ el["docs"].textContent = words.docs;
460
+ el["sizeLabel"].textContent = words.size;
461
+ el["sizeGroup"].setAttribute("aria-label", words.size);
462
+ el["sizeRow"].hidden = this.hasAttribute("no-size") || this.#sizes.length < 2;
463
+ el["tabs"].setAttribute("aria-label", words.kinds);
464
+ el["preview"].setAttribute("aria-label", words.preview);
465
+ el["preview"].hidden = this.hasAttribute("no-preview");
466
+ el["codebox"].setAttribute("role", "region");
467
+ el["codebox"].setAttribute("aria-label", words.code);
468
+ const sized = [...el["sizeGroup"].querySelectorAll("button")];
469
+ if (sized.length !== this.#sizes.length || sized.some((button, i) => button.dataset["size"] !== String(this.#sizes[i]))) {
470
+ el["sizeGroup"].replaceChildren(...this.#sizes.map((size) => {
471
+ const button = doc.createElement("button");
472
+ button.type = "button";
473
+ button.dataset["size"] = String(size);
474
+ button.textContent = String(size);
475
+ button.setAttribute("part", "size-button");
476
+ return button;
477
+ }));
478
+ }
479
+ const tabbed = [...el["tabs"].querySelectorAll("button")];
480
+ if (tabbed.length !== this.#kinds.length || tabbed.some((tab, i) => tab.dataset["kind"] !== this.#kinds[i])) {
481
+ el["tabs"].replaceChildren(...this.#kinds.map((kind) => {
482
+ const tab = doc.createElement("button");
483
+ tab.type = "button";
484
+ tab.setAttribute("role", "tab");
485
+ tab.setAttribute("part", "tab");
486
+ tab.dataset["kind"] = kind;
487
+ return tab;
488
+ }));
489
+ }
490
+ for (const tab of el["tabs"].querySelectorAll("button")) {
491
+ const label = kindLabel(tab.dataset["kind"], language, this.#labels, this.#words);
492
+ if (tab.textContent !== label)
493
+ tab.textContent = label;
494
+ }
495
+ this.#renderPicker(true);
496
+ this.#refresh();
497
+ }
498
+ /** The pressed states, the code and everything that follows the page's choices. */
499
+ #refresh() {
500
+ if (!this.#built)
501
+ return;
502
+ const el = this.#el;
503
+ const words = this.#words_();
504
+ for (const button of el["sizeGroup"].querySelectorAll("button"))
505
+ button.setAttribute("aria-pressed", String(Number(button.dataset["size"]) === this.#size));
506
+ for (const tab of el["tabs"].querySelectorAll("button")) {
507
+ const on = tab.dataset["kind"] === this.#kind;
508
+ tab.setAttribute("aria-selected", String(on));
509
+ tab.tabIndex = on ? 0 : -1;
510
+ if (on)
511
+ el["grid"].setAttribute("aria-label", tab.textContent ?? "");
512
+ }
513
+ for (const button of el["builtin"].querySelectorAll("button[data-id]"))
514
+ button.setAttribute("aria-pressed", String(button.dataset["id"] === this.#value));
515
+ this.#code = this.#generate?.({ kind: this.#kind, size: this.#size, preview: false }) ?? "";
516
+ if (el["code"].textContent !== this.#code)
517
+ el["code"].textContent = this.#code;
518
+ const note = this.#text(this.#note);
519
+ el["note"].textContent = note;
520
+ el["note"].hidden = note === "";
521
+ const credits = this.#text(this.#credits);
522
+ el["credits"].textContent = credits === "" ? "" : `${words.credits}: ${credits}`;
523
+ el["credits"].hidden = credits === "";
524
+ this.#links();
525
+ this.#picture();
526
+ }
527
+ #links() {
528
+ if (!this.#built)
529
+ return;
530
+ const el = this.#el;
531
+ for (const [node, name] of [[el["open"], "open-href"], [el["docs"], "docs-href"]]) {
532
+ const href = this.getAttribute(name) ?? "";
533
+ if (href === "")
534
+ node.removeAttribute("href");
535
+ else
536
+ node.setAttribute("href", href);
537
+ node.hidden = href === "";
538
+ }
539
+ el["docs"].target = "_blank";
540
+ el["docs"].rel = "noopener";
541
+ }
542
+ /** The picture: rebuilt only when what it shows changes (the iframe tab, the image tab, or the choices the live picture is made from). */
543
+ #picture() {
544
+ if (this.hasAttribute("no-preview"))
545
+ return;
546
+ const el = this.#el;
547
+ const shows = this.#kind === "iframe" || this.#kind === "image" ? this.#kind : "live";
548
+ const asKind = shows === "live" ? (this.#kinds.find((kind) => kind !== "iframe" && kind !== "image") ?? "html") : shows;
549
+ const code = this.#generate?.({ kind: asKind, size: this.#size, preview: true }) ?? "";
550
+ const key = JSON.stringify([shows, this.#size, code, this.#language()]);
551
+ if (key === this.#previewKey) {
552
+ this.#fit();
553
+ return;
554
+ }
555
+ this.#previewKey = key;
556
+ let node = this.#preview?.({ kind: asKind, size: this.#size, preview: true });
557
+ if (node === undefined && shows === "image")
558
+ node = this.#preview?.({ kind: this.#kinds.find((kind) => kind !== "iframe" && kind !== "image") ?? "html", size: this.#size, preview: true });
559
+ if (node === undefined && asKind === "iframe")
560
+ node = this.#iframeOf(code);
561
+ el["fit"].replaceChildren(...(node === undefined ? [] : [node]));
562
+ this.#fit();
563
+ }
564
+ /** The iframe of the iframe tab's text, as a live element. */
565
+ #iframeOf(code) {
566
+ const doc = this.ownerDocument;
567
+ const holder = doc.createElement("template");
568
+ holder.innerHTML = code;
569
+ const found = holder.content.querySelector("iframe");
570
+ if (found === null)
571
+ return undefined;
572
+ const frame = doc.createElement("iframe");
573
+ for (const name of found.getAttributeNames())
574
+ if (name !== "style")
575
+ frame.setAttribute(name, found.getAttribute(name));
576
+ frame.setAttribute("loading", "lazy");
577
+ return frame;
578
+ }
579
+ /** Scales the picture to its box. The picture is laid out at its own size and shrunk to fit the width it has, so it is never cut off, at any size on any screen. */
580
+ #fit() {
581
+ if (!this.#built || this.hasAttribute("no-preview"))
582
+ return;
583
+ // One frame at a time, never cancelled: a frame already asked for reads the choices as they are when it runs.
584
+ if (this.#frame !== 0)
585
+ return;
586
+ this.#frame = requestAnimationFrame(() => {
587
+ this.#frame = 0;
588
+ const el = this.#el;
589
+ const aspect = Number(this.getAttribute("aspect"));
590
+ const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
591
+ const fit = el["fit"];
592
+ fit.style.width = `${this.#size}px`;
593
+ fit.style.height = `${Math.round(this.#size * ratio)}px`;
594
+ fit.style.transform = "none";
595
+ const width = Math.max(this.#size, fit.scrollWidth);
596
+ const height = Math.max(Math.round(this.#size * ratio), fit.scrollHeight);
597
+ fit.style.width = `${width}px`;
598
+ fit.style.height = `${height}px`;
599
+ const preview = el["preview"];
600
+ const room = preview.clientWidth - 16;
601
+ const scale = room > 0 ? Math.min(1, room / width) : 1;
602
+ fit.style.transform = scale < 1 ? `scale(${scale})` : "none";
603
+ el["frame"].style.width = `${Math.floor(width * scale)}px`;
604
+ el["frame"].style.height = `${Math.ceil(height * scale)}px`;
605
+ });
606
+ }
607
+ #renderPicker(rebuild) {
608
+ if (!this.#built)
609
+ return;
610
+ const box = this.#el["builtin"];
611
+ const words = this.#words_();
612
+ const doc = this.ownerDocument;
613
+ box.hidden = this.#items.length === 0;
614
+ if (this.#items.length === 0) {
615
+ box.replaceChildren();
616
+ return;
617
+ }
618
+ const label = this.getAttribute("pick-label") || words.pick;
619
+ const groups = groupsOf(this.#items);
620
+ const search = this.hasAttribute("search") || (!this.hasAttribute("no-search") && this.#items.length >= SEARCH_FROM);
621
+ if (rebuild || box.querySelector(".items") === null) {
622
+ const children = [];
623
+ if (groups.length > 1) {
624
+ const chips = doc.createElement("div");
625
+ chips.className = "chips";
626
+ chips.setAttribute("role", "group");
627
+ chips.setAttribute("aria-label", label);
628
+ for (const group of ["", ...groups]) {
629
+ const chip = doc.createElement("button");
630
+ chip.type = "button";
631
+ chip.dataset["group"] = group;
632
+ chip.textContent = group === "" ? words.all : group;
633
+ chip.setAttribute("part", "chip");
634
+ chips.append(chip);
635
+ }
636
+ children.push(chips);
637
+ }
638
+ if (search) {
639
+ const input = doc.createElement("input");
640
+ input.type = "search";
641
+ input.className = "search";
642
+ input.placeholder = words.search;
643
+ input.setAttribute("aria-label", words.search);
644
+ input.setAttribute("autocomplete", "off");
645
+ input.setAttribute("part", "search");
646
+ input.value = this.#query;
647
+ children.push(input);
648
+ }
649
+ const list = doc.createElement("div");
650
+ list.className = "items";
651
+ list.setAttribute("role", "group");
652
+ list.setAttribute("aria-label", label);
653
+ list.setAttribute("part", "items");
654
+ children.push(list);
655
+ const none = doc.createElement("p");
656
+ none.className = "none";
657
+ none.setAttribute("role", "status");
658
+ children.push(none);
659
+ box.replaceChildren(...children);
660
+ }
661
+ for (const chip of box.querySelectorAll("button[data-group]"))
662
+ chip.setAttribute("aria-pressed", String(chip.dataset["group"] === this.#group));
663
+ const shown = filterItems(this.#items, this.#query, this.#group);
664
+ const list = box.querySelector(".items");
665
+ list.replaceChildren(...shown.map((item) => {
666
+ const button = doc.createElement("button");
667
+ button.type = "button";
668
+ button.dataset["id"] = item.id;
669
+ button.setAttribute("part", "item");
670
+ button.setAttribute("aria-pressed", String(item.id === this.#value));
671
+ const inside = this.#renderItem?.(item) ?? item.label;
672
+ if (typeof inside === "string")
673
+ button.textContent = inside;
674
+ else
675
+ button.append(inside);
676
+ return button;
677
+ }));
678
+ box.querySelector(".none").textContent = shown.length === 0 ? words.none : "";
679
+ list.querySelector('[aria-pressed="true"]')?.scrollIntoView?.({ block: "nearest" });
680
+ }
681
+ #dispatch(reason) {
682
+ this.dispatchEvent(new CustomEvent(EVENT, { detail: { kind: this.#kind, size: this.#size, value: this.#value, code: this.#code, reason }, bubbles: true, composed: true }));
683
+ }
684
+ }