@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/custom-elements.json +691 -100
- package/package.json +2 -2
- package/src/events.ts +3 -0
- package/src/gw-button.ts +91 -12
- package/src/gw-popover.ts +3 -1
- package/src/gw-theme-gallery.ts +493 -0
- package/src/gw-theme-picker.ts +50 -2
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timmo001/groundwork-ui",
|
|
3
|
-
"version": "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": "
|
|
17
|
+
"customElements": "custom-elements.json",
|
|
18
18
|
"sideEffects": true,
|
|
19
19
|
"publishConfig": {
|
|
20
20
|
"access": "public",
|
package/src/events.ts
CHANGED
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(
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
396
|
-
:
|
|
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
|
+
}
|