@timmo001/groundwork-ui 0.4.1 → 0.6.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.
@@ -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
+ }