@timmo001/groundwork-ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Groundwork's framework-free web components, styled by the --gw-* tokens",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "README.md",
15
15
  "LICENSE"
16
16
  ],
17
- "customElements": ".manifest-check/custom-elements.json",
17
+ "customElements": "custom-elements.json",
18
18
  "sideEffects": true,
19
19
  "publishConfig": {
20
20
  "access": "public",
package/src/events.ts CHANGED
@@ -35,6 +35,9 @@ export interface GwEventMap {
35
35
  "gw-select": {
36
36
  change: Event;
37
37
  };
38
+ "gw-theme-gallery": {
39
+ theme: CustomEvent<{ id: string }>;
40
+ };
38
41
  "gw-theme-picker": {
39
42
  reset: Event;
40
43
  mode: CustomEvent<{ mode: string }>;
package/src/gw-button.ts CHANGED
@@ -12,6 +12,11 @@ import { base, createIcon, setIconPath } from "./styles.js";
12
12
  * theme and mode around it. With `href` it renders a link that looks the
13
13
  * same. Put an icon in the `icon` slot.
14
14
  *
15
+ * For a link the page's HTML has to carry, such as one with `rel="me"`, a
16
+ * `target` or `aria-current="page"`, put a plain `<a>` inside instead of
17
+ * `href`. The button styles it, a click anywhere on the pill follows it, and
18
+ * `aria-current` on it gets a current style.
19
+ *
15
20
  * `variant` picks the color and `appearance` how it's applied, from most to
16
21
  * least important: `accent` (a solid fill), `filled` (a soft tint),
17
22
  * `outlined` (a stroke) or `plain` (text only). Every button is a pill.
@@ -35,10 +40,10 @@ import { base, createIcon, setIconPath } from "./styles.js";
35
40
  * @attr {string} icon - SVG path data for an icon before the text.
36
41
  * @attr {"show"|"hide"|"auto"} labels - Whether the text shows beside an icon: always, never, or not on narrow screens. Defaults to `show`.
37
42
  *
38
- * @slot - The text.
43
+ * @slot - The text, or an `<a>` holding it, in place of `href`.
39
44
  * @slot icon - An icon before the text, in place of `icon`.
40
45
  *
41
- * @csspart button - The native button or link.
46
+ * @csspart button - The native button or link, or the pill around a slotted link.
42
47
  *
43
48
  * @cssprop --gw-button-scale - Scales every dimension, around 1 for medium.
44
49
  * @cssprop --gw-button-tone - The color, set by `variant`.
@@ -204,6 +209,27 @@ export class GwButton extends GwElement {
204
209
  border-color: var(--gw-button-tone);
205
210
  }
206
211
 
212
+ /* A slotted link carries the text; the pill draws its focus ring */
213
+ ::slotted(a) {
214
+ color: inherit;
215
+ text-decoration: none;
216
+ outline: none;
217
+ }
218
+
219
+ .button.focused {
220
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
221
+ outline-offset: var(--gw-focus-offset);
222
+ }
223
+
224
+ :host([appearance="plain"]) .button.current,
225
+ :host([appearance="outlined"]) .button.current {
226
+ background: color-mix(in oklch, var(--gw-button-tone) 16%, transparent);
227
+ }
228
+
229
+ :host([appearance="filled"]) .button.current {
230
+ background: color-mix(in oklch, var(--gw-button-tone) 32%, transparent);
231
+ }
232
+
207
233
  .button:disabled,
208
234
  .button[aria-disabled="true"] {
209
235
  color: var(--gw-color-text-disabled);
@@ -350,10 +376,18 @@ export class GwButton extends GwElement {
350
376
 
351
377
  readonly #over = h("span", { class: "ring over" });
352
378
 
353
- #control: HTMLButtonElement | HTMLAnchorElement = h("button", {
354
- class: "button",
355
- part: "button",
356
- });
379
+ #control: HTMLButtonElement | HTMLAnchorElement | HTMLSpanElement = h(
380
+ "button",
381
+ {
382
+ class: "button",
383
+ part: "button",
384
+ },
385
+ );
386
+
387
+ /** A plain `<a>` the page put inside, which the button wraps instead of `href`. */
388
+ #link(): HTMLAnchorElement | null {
389
+ return this.querySelector(":scope > a:not([slot])");
390
+ }
357
391
 
358
392
  constructor() {
359
393
  super();
@@ -373,12 +407,43 @@ export class GwButton extends GwElement {
373
407
  if (this.loading) {
374
408
  event.preventDefault();
375
409
  event.stopImmediatePropagation();
410
+
411
+ return;
412
+ }
413
+
414
+ // A click on the pill around a slotted link follows the link
415
+ const link = this.#link();
416
+
417
+ if (link !== null && !event.composedPath().includes(link)) {
418
+ link.click();
376
419
  }
377
420
  });
378
421
 
422
+ const focus = () => {
423
+ this.#control.classList.toggle(
424
+ "focused",
425
+ this.#link()?.matches(":focus-visible") === true,
426
+ );
427
+ };
428
+
429
+ this.addEventListener("focusin", focus);
430
+ this.addEventListener("focusout", focus);
431
+
379
432
  this.#iconSlot.addEventListener("slotchange", () => {
380
433
  this.requestUpdate("icon");
381
434
  });
435
+
436
+ this.#text.addEventListener("slotchange", () => {
437
+ this.requestUpdate("href");
438
+ });
439
+
440
+ new MutationObserver(() => {
441
+ this.requestUpdate("href");
442
+ }).observe(this, {
443
+ subtree: true,
444
+ attributes: true,
445
+ attributeFilter: ["aria-current"],
446
+ });
382
447
  }
383
448
 
384
449
  protected override create(root: ShadowRoot) {
@@ -387,13 +452,16 @@ export class GwButton extends GwElement {
387
452
  }
388
453
 
389
454
  protected override update(changed: ReadonlySet<string>) {
455
+ const link = this.#link();
456
+
390
457
  if (changed.has("href")) {
391
- const wanted = this.href ? "A" : "BUTTON";
458
+ const wanted = link ? "SPAN" : this.href ? "A" : "BUTTON";
392
459
 
393
460
  if (this.#control.tagName !== wanted) {
394
- const next = this.href
395
- ? h("a", { class: "button", part: "button" })
396
- : h("button", { class: "button", part: "button" });
461
+ const next = h(link ? "span" : this.href ? "a" : "button", {
462
+ class: "button",
463
+ part: "button",
464
+ });
397
465
 
398
466
  next.append(...this.#control.childNodes);
399
467
  this.#control.replaceWith(next);
@@ -407,6 +475,11 @@ export class GwButton extends GwElement {
407
475
  this.icon !== "" || this.querySelector("[slot='icon']") !== null;
408
476
 
409
477
  control.classList.toggle("has-icon", hasIcon);
478
+ control.classList.toggle(
479
+ "current",
480
+ link !== null &&
481
+ (link.getAttribute("aria-current") ?? "false") !== "false",
482
+ );
410
483
  setIconPath(this.#icon, this.icon);
411
484
  this.#icon.style.display = this.icon ? "" : "none";
412
485
  setAttribute(
@@ -416,13 +489,19 @@ export class GwButton extends GwElement {
416
489
  ? this.textContent.trim() || null
417
490
  : null,
418
491
  );
419
- setAttribute(control, "aria-label", this.label || null);
420
492
  setAttribute(control, "aria-busy", this.loading ? "true" : null);
421
493
 
494
+ if (control instanceof HTMLSpanElement) {
495
+ // The slotted link is the control, so it carries the name
496
+ setAttribute(control, "aria-label", null);
497
+ } else {
498
+ setAttribute(control, "aria-label", this.label || null);
499
+ }
500
+
422
501
  if (control instanceof HTMLAnchorElement) {
423
502
  setAttribute(control, "href", this.disabled ? null : this.href);
424
503
  setAttribute(control, "aria-disabled", this.disabled ? "true" : null);
425
- } else {
504
+ } else if (control instanceof HTMLButtonElement) {
426
505
  control.type = this.type;
427
506
  control.disabled = this.disabled;
428
507
  }
package/src/gw-popover.ts CHANGED
@@ -38,6 +38,8 @@ const sheetBelow = 600;
38
38
  * @slot - The content.
39
39
  * @slot actions - Buttons; one with a `value` closes it with that value.
40
40
  *
41
+ * @cssprop --gw-popover-width - Its width beside a button. Defaults to `20rem`.
42
+ *
41
43
  * @fires {Event} close - When it closes; `returnValue` holds the button's value.
42
44
  */
43
45
  export class GwPopover extends GwElement {
@@ -54,7 +56,7 @@ export class GwPopover extends GwElement {
54
56
  position: fixed;
55
57
  inset: unset;
56
58
  box-sizing: border-box;
57
- width: min(20rem, calc(100vw - 2 * ${gap}px));
59
+ width: min(var(--gw-popover-width, 20rem), calc(100vw - 2 * ${gap}px));
58
60
  padding: var(--gw-space-4);
59
61
  margin: 0;
60
62
  color: var(--gw-color-text-primary);
@@ -0,0 +1,493 @@
1
+ /**
2
+ * `gw-theme-gallery`: picks a theme from cards that preview each one.
3
+ * Docs: https://groundwork.timmo.dev/components/actions/#theme-gallery
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import type { OptionItem } from "./gw-option-list.js";
9
+ import { base, createIcon } from "./styles.js";
10
+
11
+ // MDI's check
12
+ const checkIcon = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z";
13
+
14
+ const other = "Other";
15
+
16
+ /** The roles a preview is drawn from, by the variable each one sets. */
17
+ const roles = {
18
+ "--p-bg": "--gw-color-surface-lower",
19
+ "--p-surface": "--gw-color-surface-raised",
20
+ "--p-border": "--gw-color-border-normal",
21
+ "--p-text": "--gw-color-text-primary",
22
+ "--p-muted": "--gw-color-text-secondary",
23
+ "--p-accent": "--gw-color-accent",
24
+ "--p-info": "--gw-color-info",
25
+ "--p-success": "--gw-color-success",
26
+ "--p-warning": "--gw-color-warning",
27
+ "--p-danger": "--gw-color-danger",
28
+ "--p-important": "--gw-color-important",
29
+ "--p-radius": "--gw-radius-panel",
30
+ } as const;
31
+
32
+ const tones = ["info", "success", "warning", "danger", "important"] as const;
33
+
34
+ /**
35
+ * A theme's preview colors in a mode. Theme CSS is written for the page, so
36
+ * it can't reach into a shadow root: a hidden probe on the page reads them.
37
+ */
38
+ const probe = (id: string, mode: string) => {
39
+ const element = h("div", {
40
+ "data-gw-theme": id,
41
+ "data-gw-mode": mode === "auto" ? null : mode,
42
+ hidden: true,
43
+ });
44
+
45
+ document.body.append(element);
46
+
47
+ const style = getComputedStyle(element);
48
+
49
+ const colors = Object.entries(roles).map(
50
+ ([name, role]) => [name, style.getPropertyValue(role).trim()] as const,
51
+ );
52
+
53
+ element.remove();
54
+
55
+ return colors;
56
+ };
57
+
58
+ /**
59
+ * The closest element at or above `start` with `attribute`, crossing shadow
60
+ * roots, or `<html>`.
61
+ */
62
+ const closestWith = (start: Element, attribute: string) => {
63
+ let node: Node | null = start;
64
+
65
+ while (node !== null) {
66
+ if (node instanceof Element && node.hasAttribute(attribute)) return node;
67
+
68
+ node = node instanceof ShadowRoot ? node.host : node.parentNode;
69
+ }
70
+
71
+ return document.documentElement;
72
+ };
73
+
74
+ /**
75
+ * Themes in groups, in the order they come. A group with one theme joins the
76
+ * others like it under "Other", last. With no groups, one unnamed group.
77
+ */
78
+ const grouped = (themes: readonly OptionItem[]) => {
79
+ if (!themes.some((theme) => theme.group)) return [["", themes] as const];
80
+
81
+ const groups = new Map<string, OptionItem[]>();
82
+
83
+ for (const theme of themes) {
84
+ const name = theme.group ?? other;
85
+
86
+ groups.set(name, [...(groups.get(name) ?? []), theme]);
87
+ }
88
+
89
+ const own = [...groups].filter(
90
+ ([name, members]) => name !== other && members.length > 1,
91
+ );
92
+
93
+ const rest = themes.filter(
94
+ (theme) => !own.some(([name]) => name === theme.group),
95
+ );
96
+
97
+ return rest.length === 0 ? own : [...own, [other, rest] as const];
98
+ };
99
+
100
+ /**
101
+ * Picks a theme from a grid of cards, each a small picture of an app in that
102
+ * theme's colors: a rail, a panel and the status tones. For a settings page,
103
+ * or inside `gw-theme-picker` with `gallery`.
104
+ *
105
+ * Themes with a `group` sit together under its name, and lone ones under
106
+ * "Other", so a family's light and dark variants line up. Previews draw in
107
+ * `mode`, or the mode of the page or themed container it sits in, from the
108
+ * theme CSS already on the page, including a custom theme `pageTheme`
109
+ * imported. Call `refresh()` after that CSS changes.
110
+ *
111
+ * It's a `<fieldset>` of native radio buttons, so arrow keys move the choice.
112
+ * It fires `theme`.
113
+ *
114
+ * @element gw-theme-gallery
115
+ * @summary Picks a theme from cards that preview each one.
116
+ *
117
+ * @attr {string} label - The group's accessible name. Defaults to `Theme`.
118
+ * @attr {string} theme - The chosen theme, by id.
119
+ * @attr {OptionItem[]} themes - The themes, as JSON: `value` is the id, `label` the name, and `group` the family.
120
+ * @attr {"auto"|"light"|"dark"|"darker"} mode - The mode the previews show. Defaults to the mode it sits in.
121
+ *
122
+ * @csspart card - Each theme's card.
123
+ * @csspart preview - The picture of an app in the theme.
124
+ * @csspart name - The theme's name under it.
125
+ * @csspart group - Each group's heading.
126
+ *
127
+ * @cssprop --gw-theme-gallery-min - The narrowest a card gets. Defaults to `9rem`.
128
+ *
129
+ * @fires {CustomEvent<{ id: string }>} theme - When a theme is picked.
130
+ */
131
+ export class GwThemeGallery extends GwElement {
132
+ static override properties = {
133
+ label: {},
134
+ theme: { reflect: true },
135
+ themes: { type: "json" },
136
+ mode: { reflect: true },
137
+ } as const;
138
+
139
+ static override styles = [
140
+ base,
141
+ css`
142
+ :host {
143
+ display: block;
144
+ }
145
+
146
+ fieldset {
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: var(--gw-space-4);
150
+ min-width: 0;
151
+ padding: 0;
152
+ margin: 0;
153
+ border: 0;
154
+ }
155
+
156
+ legend {
157
+ position: absolute;
158
+ width: 1px;
159
+ height: 1px;
160
+ overflow: hidden;
161
+ clip-path: inset(50%);
162
+ white-space: nowrap;
163
+ }
164
+
165
+ .group {
166
+ display: grid;
167
+ grid-template-columns: repeat(
168
+ auto-fill,
169
+ minmax(var(--gw-theme-gallery-min, 9rem), 1fr)
170
+ );
171
+ gap: var(--gw-space-3);
172
+ }
173
+
174
+ .group-name {
175
+ grid-column: 1 / -1;
176
+ margin: 0;
177
+ font-size: var(--gw-font-size-s);
178
+ font-weight: var(--gw-font-weight-strong);
179
+ color: var(--gw-color-text-secondary);
180
+ text-transform: var(--gw-label-transform);
181
+ letter-spacing: var(--gw-label-tracking);
182
+ }
183
+
184
+ label {
185
+ position: relative;
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: var(--gw-space-2);
189
+ padding: var(--gw-space-2);
190
+ cursor: pointer;
191
+ background: var(--gw-color-surface-raised);
192
+ border: 2px solid var(--gw-color-border-normal);
193
+ border-radius: var(--gw-radius-panel);
194
+ transition:
195
+ border-color var(--gw-motion-fast) var(--gw-motion-easing),
196
+ background-color var(--gw-motion-fast) var(--gw-motion-easing);
197
+ }
198
+
199
+ label:hover {
200
+ border-color: var(--gw-color-border-accent);
201
+ }
202
+
203
+ label:has(input:checked) {
204
+ background: var(--gw-color-fill-accent-quiet);
205
+ border-color: var(--gw-color-accent);
206
+ }
207
+
208
+ label:has(input:focus-visible) {
209
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
210
+ outline-offset: var(--gw-focus-offset);
211
+ }
212
+
213
+ input {
214
+ position: absolute;
215
+ inset: 0;
216
+ margin: 0;
217
+ cursor: pointer;
218
+ opacity: 0;
219
+ }
220
+
221
+ /* A small app in the theme's own colors */
222
+ .preview {
223
+ display: grid;
224
+ grid-template-columns: 22% 1fr;
225
+ gap: 6px;
226
+ height: 5.5rem;
227
+ padding: 6px;
228
+ background: var(--p-bg);
229
+ border-radius: calc(var(--gw-radius-panel) - 2px);
230
+ }
231
+
232
+ .rail {
233
+ display: flex;
234
+ flex-direction: column;
235
+ gap: 4px;
236
+ padding: 4px;
237
+ }
238
+
239
+ .rail i {
240
+ height: 5px;
241
+ background: var(--p-muted);
242
+ border-radius: 3px;
243
+ opacity: 0.5;
244
+ }
245
+
246
+ .rail i:first-child {
247
+ background: var(--p-accent);
248
+ opacity: 1;
249
+ }
250
+
251
+ .panel {
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: 5px;
255
+ min-width: 0;
256
+ padding: 6px;
257
+ background: var(--p-surface);
258
+ border: 1px solid var(--p-border);
259
+ border-radius: min(var(--p-radius), 6px);
260
+ }
261
+
262
+ .line {
263
+ width: 60%;
264
+ height: 5px;
265
+ background: var(--p-text);
266
+ border-radius: 3px;
267
+ }
268
+
269
+ .line.short {
270
+ width: 40%;
271
+ background: var(--p-muted);
272
+ }
273
+
274
+ .bars {
275
+ display: flex;
276
+ flex: 1;
277
+ gap: 3px;
278
+ align-items: flex-end;
279
+ }
280
+
281
+ .bars i {
282
+ flex: 1;
283
+ border-radius: 2px 2px 0 0;
284
+ }
285
+
286
+ .name {
287
+ display: flex;
288
+ flex-direction: column;
289
+ padding: 0 var(--gw-space-1) var(--gw-space-1);
290
+ font-size: var(--gw-font-size-s);
291
+ font-weight: var(--gw-font-weight-strong);
292
+ color: var(--gw-color-text-strong);
293
+ }
294
+
295
+ .name small {
296
+ font-size: var(--gw-font-size-xs);
297
+ font-weight: normal;
298
+ color: var(--gw-color-text-secondary);
299
+ }
300
+
301
+ label:has(input:checked) .name {
302
+ color: var(--gw-color-accent);
303
+ }
304
+
305
+ .check {
306
+ position: absolute;
307
+ inset-block-start: var(--gw-space-3);
308
+ inset-inline-end: var(--gw-space-3);
309
+ display: none;
310
+ place-items: center;
311
+ width: 1.5rem;
312
+ height: 1.5rem;
313
+ color: var(--gw-color-on-accent);
314
+ background: var(--gw-color-fill-accent-resting);
315
+ border-radius: 50%;
316
+ --gw-icon-size: 1rem;
317
+ }
318
+
319
+ label:has(input:checked) .check {
320
+ display: grid;
321
+ }
322
+ `,
323
+ ];
324
+
325
+ declare label: string;
326
+ declare theme: string;
327
+ declare themes: readonly OptionItem[];
328
+ declare mode: string;
329
+
330
+ readonly #legend = h("legend");
331
+
332
+ readonly #fieldset = h("fieldset", {}, this.#legend);
333
+
334
+ readonly #name = `gw-theme-gallery-${Math.random().toString(36).slice(2)}`;
335
+
336
+ #cards: {
337
+ input: HTMLInputElement;
338
+ preview: HTMLElement;
339
+ id: string;
340
+ }[] = [];
341
+
342
+ readonly #scheme = matchMedia("(prefers-color-scheme: dark)");
343
+
344
+ readonly #onScheme = () => {
345
+ if (this.#mode() === "auto") this.refresh();
346
+ };
347
+
348
+ // Follows the surrounding mode while `mode` is unset
349
+ readonly #modeRoot = new MutationObserver(() => {
350
+ if (!this.mode) this.refresh();
351
+ });
352
+
353
+ constructor() {
354
+ super();
355
+ this.label = "Theme";
356
+ this.theme = "";
357
+ this.themes = [];
358
+ this.mode = "";
359
+ }
360
+
361
+ /** `mode`, or the mode of the page or themed container around it. */
362
+ #mode() {
363
+ if (this.mode) return this.mode;
364
+
365
+ return (
366
+ closestWith(this, "data-gw-mode").getAttribute("data-gw-mode") ?? "auto"
367
+ );
368
+ }
369
+
370
+ /** Redraws the previews from the page's theme CSS, such as after an import. */
371
+ refresh() {
372
+ const mode = this.#mode();
373
+
374
+ for (const { preview, id } of this.#cards) {
375
+ for (const [name, value] of probe(id, mode)) {
376
+ preview.style.setProperty(name, value);
377
+ }
378
+ }
379
+ }
380
+
381
+ override connectedCallback() {
382
+ super.connectedCallback();
383
+ this.#scheme.addEventListener("change", this.#onScheme);
384
+ this.#modeRoot.observe(closestWith(this, "data-gw-theme"), {
385
+ attributeFilter: ["data-gw-mode"],
386
+ });
387
+ this.refresh();
388
+ }
389
+
390
+ override disconnectedCallback() {
391
+ super.disconnectedCallback();
392
+ this.#scheme.removeEventListener("change", this.#onScheme);
393
+ this.#modeRoot.disconnect();
394
+ }
395
+
396
+ #card(theme: OptionItem, inOther: boolean) {
397
+ const input = h("input", {
398
+ type: "radio",
399
+ name: this.#name,
400
+ value: theme.value,
401
+ });
402
+
403
+ input.addEventListener("change", () => {
404
+ this.theme = theme.value;
405
+ this.dispatchEvent(
406
+ new CustomEvent("theme", {
407
+ detail: { id: theme.value },
408
+ bubbles: true,
409
+ composed: true,
410
+ }),
411
+ );
412
+ });
413
+
414
+ const preview = h(
415
+ "span",
416
+ { class: "preview", part: "preview", "aria-hidden": "true" },
417
+ h("span", { class: "rail" }, h("i"), h("i"), h("i"), h("i")),
418
+ h(
419
+ "span",
420
+ { class: "panel" },
421
+ h("span", { class: "line" }),
422
+ h("span", { class: "line short" }),
423
+ h("span", { class: "bars" }),
424
+ ),
425
+ );
426
+
427
+ preview.querySelector(".bars")?.append(
428
+ ...tones.map((tone, index) => {
429
+ const bar = h("i");
430
+
431
+ bar.style.height = `${35 + ((index * 37) % 65)}%`;
432
+ bar.style.background = `var(--p-${tone})`;
433
+
434
+ return bar;
435
+ }),
436
+ );
437
+
438
+ const label = h(
439
+ "label",
440
+ { part: "card" },
441
+ input,
442
+ preview,
443
+ h(
444
+ "span",
445
+ { class: "name", part: "name" },
446
+ theme.label,
447
+ inOther && theme.group ? h("small", {}, theme.group) : null,
448
+ ),
449
+ h("span", { class: "check" }, createIcon(checkIcon)),
450
+ );
451
+
452
+ this.#cards.push({ input, preview, id: theme.value });
453
+
454
+ return label;
455
+ }
456
+
457
+ protected override create(root: ShadowRoot) {
458
+ root.append(this.#fieldset);
459
+ }
460
+
461
+ protected override update(changed: ReadonlySet<string>) {
462
+ this.#legend.textContent = this.label;
463
+
464
+ if (changed.has("themes")) {
465
+ const themes = Array.isArray(this.themes) ? this.themes : [];
466
+
467
+ this.#cards = [];
468
+ this.#fieldset.replaceChildren(
469
+ this.#legend,
470
+ ...grouped(themes).map(([name, members]) =>
471
+ h(
472
+ "div",
473
+ { class: "group" },
474
+ name ? h("h3", { class: "group-name", part: "group" }, name) : null,
475
+ ...members.map((theme) => this.#card(theme, name === other)),
476
+ ),
477
+ ),
478
+ );
479
+ }
480
+
481
+ for (const { input, id } of this.#cards) input.checked = id === this.theme;
482
+
483
+ if (changed.has("themes") || changed.has("mode")) this.refresh();
484
+ }
485
+ }
486
+
487
+ GwThemeGallery.define("gw-theme-gallery");
488
+
489
+ declare global {
490
+ interface HTMLElementTagNameMap {
491
+ "gw-theme-gallery": GwThemeGallery;
492
+ }
493
+ }