@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.
@@ -10,17 +10,29 @@ import type { OptionItem } from "./gw-option-list.js";
10
10
  import "./gw-popover.js";
11
11
  import "./gw-segmented.js";
12
12
  import "./gw-select.js";
13
+ import "./gw-theme-gallery.js";
13
14
  import { base } from "./styles.js";
14
15
 
15
16
  // MDI's palette
16
17
  const paletteIcon =
17
18
  "M17.5,12A1.5,1.5 0 0,1 16,10.5A1.5,1.5 0 0,1 17.5,9A1.5,1.5 0 0,1 19,10.5A1.5,1.5 0 0,1 17.5,12M14.5,8A1.5,1.5 0 0,1 13,6.5A1.5,1.5 0 0,1 14.5,5A1.5,1.5 0 0,1 16,6.5A1.5,1.5 0 0,1 14.5,8M9.5,8A1.5,1.5 0 0,1 8,6.5A1.5,1.5 0 0,1 9.5,5A1.5,1.5 0 0,1 11,6.5A1.5,1.5 0 0,1 9.5,8M6.5,12A1.5,1.5 0 0,1 5,10.5A1.5,1.5 0 0,1 6.5,9A1.5,1.5 0 0,1 8,10.5A1.5,1.5 0 0,1 6.5,12M12,3A9,9 0 0,0 3,12A9,9 0 0,0 12,21A1.5,1.5 0 0,0 13.5,19.5C13.5,19.11 13.35,18.76 13.11,18.5C12.88,18.23 12.73,17.88 12.73,17.5A1.5,1.5 0 0,1 14.23,16H16A5,5 0 0,0 21,11C21,6.58 16.97,3 12,3Z";
18
19
 
19
- const modes = [
20
- { value: "auto", label: "Auto" },
21
- { value: "light", label: "Light" },
22
- { value: "dark", label: "Dark" },
23
- { value: "darker", label: "Darker" },
20
+ // MDI's sunny, moon and night (a moon with stars), for cycle's button. Auto
21
+ // keeps the palette
22
+ const modeIcons = {
23
+ auto: paletteIcon,
24
+ light:
25
+ "M12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,2L14.39,5.42C13.65,5.15 12.84,5 12,5C11.16,5 10.35,5.15 9.61,5.42L12,2M3.34,7L7.5,6.65C6.9,7.16 6.36,7.78 5.94,8.5C5.5,9.24 5.25,10 5.11,10.79L3.34,7M3.36,17L5.12,13.23C5.26,14 5.53,14.78 5.95,15.5C6.37,16.24 6.91,16.86 7.5,17.37L3.36,17M20.65,7L18.88,10.79C18.74,10 18.47,9.23 18.05,8.5C17.63,7.78 17.1,7.15 16.5,6.64L20.65,7M20.64,17L16.5,17.36C17.09,16.85 17.62,16.22 18.04,15.5C18.46,14.77 18.73,14 18.87,13.21L20.64,17M12,22L9.59,18.56C10.33,18.83 11.14,19 12,19C12.82,19 13.63,18.83 14.37,18.56L12,22Z",
26
+ dark: "M2 12A10 10 0 0 0 15 21.54A10 10 0 0 1 15 2.46A10 10 0 0 0 2 12Z",
27
+ darker:
28
+ "M17.75,4.09L15.22,6.03L16.13,9.09L13.5,7.28L10.87,9.09L11.78,6.03L9.25,4.09L12.44,4L13.5,1L14.56,4L17.75,4.09M21.25,11L19.61,12.25L20.2,14.23L18.5,13.06L16.8,14.23L17.39,12.25L15.75,11L17.81,10.95L18.5,9L19.19,10.95L21.25,11M18.97,15.95C19.8,15.87 20.69,17.05 20.16,17.8C19.84,18.25 19.5,18.67 19.08,19.07C15.17,23 8.84,23 4.94,19.07C1.03,15.17 1.03,8.83 4.94,4.93C5.34,4.53 5.76,4.17 6.21,3.85C6.96,3.32 8.14,4.21 8.06,5.04C7.79,7.9 8.75,10.87 10.95,13.06C13.14,15.26 16.1,16.22 18.97,15.95M17.33,17.97C14.5,17.81 11.7,16.64 9.53,14.5C7.36,12.31 6.2,9.5 6.04,6.68C3.23,9.82 3.34,14.64 6.35,17.66C9.37,20.67 14.19,20.78 17.33,17.97Z",
29
+ };
30
+
31
+ const allModes = [
32
+ { value: "auto", label: "Auto", icon: modeIcons.auto },
33
+ { value: "light", label: "Light", icon: modeIcons.light },
34
+ { value: "dark", label: "Dark", icon: modeIcons.dark },
35
+ { value: "darker", label: "Darker", icon: modeIcons.darker },
24
36
  ] as const;
25
37
 
26
38
  /**
@@ -33,14 +45,31 @@ const modes = [
33
45
  * `@timmo001/groundwork-tokens/page` does all of that. Show an import's
34
46
  * result, such as contrast misses, in `status`.
35
47
  *
48
+ * `gallery` swaps the theme select for `gw-theme-gallery`'s cards, which
49
+ * preview each theme in the chosen mode.
50
+ *
51
+ * `layout` sets how it shows. `popover`, the default, is the palette button
52
+ * and its popover. `cycle` is one button showing the mode in use, which steps
53
+ * to the next mode on each press, for an app bar. `inline` lays the controls
54
+ * out in place, in one wrapping row with Reset and Import, for a footer or a
55
+ * settings page; set `justify-content` on it to align the row. `modes` picks which modes it offers, such as `light dark` without
56
+ * Auto.
57
+ *
36
58
  * @element gw-theme-picker
37
59
  * @summary A button opening the page's mode, theme and import.
38
60
  *
61
+ * @attr {"popover"|"cycle"|"inline"} layout - How it shows: a button and popover, a button cycling the modes, or the controls in place. Defaults to `popover`.
62
+ * @attr {string} modes - The modes to offer, in order and separated by spaces. Defaults to `auto light dark darker`.
39
63
  * @attr {"auto"|"light"|"dark"|"darker"} mode - The mode in use. Defaults to `auto`.
40
64
  * @attr {string} theme - The theme in use, by id.
41
- * @attr {OptionItem[]} themes - The themes to offer, as JSON.
65
+ * @attr {OptionItem[]} themes - The themes to offer, as JSON. The select hides with fewer than two. With `gallery`, a `group` gathers a family's themes.
66
+ * @attr {boolean} gallery - Shows the themes as preview cards rather than a select.
42
67
  * @attr {string} status - A line under the controls, such as an import's result.
43
- * @attr {string} label - The button's accessible name and the popover's heading. Defaults to `Theme`.
68
+ * @attr {string} label - The button's accessible name and the popover's heading. Defaults to `Theme`. With `cycle`, the button's name also gives the mode in use.
69
+ *
70
+ * @csspart gallery - The theme gallery, with `gallery`.
71
+ * @csspart controls - The mode, theme and status.
72
+ * @csspart actions - Reset and Import, with `inline`.
44
73
  *
45
74
  * @fires {CustomEvent<{ mode: string }>} mode - When a mode is picked.
46
75
  * @fires {CustomEvent<{ id: string }>} theme - When a theme is picked.
@@ -52,8 +81,11 @@ export class GwThemePicker extends GwElement {
52
81
  mode: { reflect: true },
53
82
  theme: { reflect: true },
54
83
  themes: { type: "json" },
84
+ gallery: { type: "boolean", reflect: true },
55
85
  status: {},
56
86
  label: {},
87
+ layout: { reflect: true },
88
+ modes: {},
57
89
  } as const;
58
90
 
59
91
  static override styles = [
@@ -63,6 +95,18 @@ export class GwThemePicker extends GwElement {
63
95
  display: inline-block;
64
96
  }
65
97
 
98
+ :host([gallery]) gw-popover {
99
+ --gw-popover-width: 34rem;
100
+ }
101
+
102
+ .gallery {
103
+ max-height: min(24rem, 50vh);
104
+ overflow: auto;
105
+ /* Room for the cards' focus rings */
106
+ padding: var(--gw-space-1);
107
+ margin: calc(var(--gw-space-1) * -1);
108
+ }
109
+
66
110
  .controls {
67
111
  display: flex;
68
112
  flex-direction: column;
@@ -75,14 +119,41 @@ export class GwThemePicker extends GwElement {
75
119
  font-size: var(--gw-font-size-s);
76
120
  color: var(--gw-color-text-secondary);
77
121
  }
122
+
123
+ /* One wrapping row: the controls, then Reset and Import. The host is
124
+ the flex container, so a page can set its justify-content */
125
+ :host([layout="inline"]) {
126
+ display: flex;
127
+ flex-wrap: wrap;
128
+ gap: var(--gw-space-4);
129
+ align-items: center;
130
+ }
131
+
132
+ :host([layout="inline"]) .controls {
133
+ flex-flow: row wrap;
134
+ align-items: center;
135
+ margin-block-start: 0;
136
+ }
137
+
138
+ .actions {
139
+ display: flex;
140
+ flex-wrap: wrap;
141
+ gap: var(--gw-space-2);
142
+ align-items: center;
143
+ }
78
144
  `,
79
145
  ];
80
146
 
81
147
  declare mode: string;
82
148
  declare theme: string;
83
149
  declare themes: readonly OptionItem[];
150
+ declare gallery: boolean;
84
151
  declare status: string;
85
152
  declare label: string;
153
+ /** How it shows: a button and popover, a button cycling the modes, or the controls in place. */
154
+ declare layout: "popover" | "cycle" | "inline";
155
+ /** The modes to offer, in order and separated by spaces. */
156
+ declare modes: string;
86
157
 
87
158
  readonly #button = h("gw-button", {
88
159
  variant: "neutral",
@@ -95,6 +166,11 @@ export class GwThemePicker extends GwElement {
95
166
 
96
167
  readonly #theme = h("gw-select", { label: "Theme" });
97
168
 
169
+ readonly #gallery = h("gw-theme-gallery", {
170
+ class: "gallery",
171
+ part: "gallery",
172
+ });
173
+
98
174
  readonly #status = h("p", { class: "status", role: "status" });
99
175
 
100
176
  readonly #file = h("input", {
@@ -103,39 +179,87 @@ export class GwThemePicker extends GwElement {
103
179
  hidden: true,
104
180
  });
105
181
 
106
- readonly #popover = h(
107
- "gw-popover",
108
- {},
109
- h("div", { class: "controls" }, this.#mode, this.#theme, this.#status),
110
- h(
111
- "gw-button",
112
- {
113
- slot: "actions",
114
- variant: "neutral",
115
- appearance: "plain",
116
- class: "reset",
117
- },
118
- "Reset",
119
- ),
120
- h(
121
- "gw-button",
122
- { slot: "actions", appearance: "outlined", class: "import" },
123
- "Import",
124
- ),
182
+ readonly #controls = h(
183
+ "div",
184
+ { class: "controls", part: "controls" },
185
+ this.#mode,
186
+ this.#theme,
187
+ this.#gallery,
188
+ this.#status,
189
+ );
190
+
191
+ readonly #reset = h(
192
+ "gw-button",
193
+ { variant: "neutral", appearance: "plain" },
194
+ "Reset",
125
195
  );
126
196
 
197
+ readonly #import = h("gw-button", { appearance: "outlined" }, "Import");
198
+
199
+ readonly #popover = h("gw-popover");
200
+
201
+ readonly #actions = h("div", { class: "actions", part: "actions" });
202
+
127
203
  constructor() {
128
204
  super();
129
205
  this.mode = "auto";
130
206
  this.theme = "";
131
207
  this.themes = [];
208
+ this.gallery = false;
132
209
  this.status = "";
133
210
  this.label = "Theme";
211
+ this.layout = "popover";
212
+ this.modes = "auto light dark darker";
213
+ }
214
+
215
+ /** The modes on offer, in the order `modes` gives them. */
216
+ #offered() {
217
+ const wanted = String(this.modes).split(/\s+/);
218
+ const offered = allModes.filter(({ value }) => wanted.includes(value));
219
+
220
+ return offered.length > 0 ? offered : allModes;
134
221
  }
135
222
 
136
- /** Opens the popover under the button. */
223
+ /** Opens the popover under the button, or with `cycle`, steps to the next mode. */
137
224
  show() {
225
+ if (this.layout === "inline") return;
226
+
227
+ if (this.layout === "cycle") {
228
+ const offered = this.#offered();
229
+ const at = offered.findIndex(({ value }) => value === this.mode);
230
+ const next = offered[(at + 1) % offered.length];
231
+
232
+ if (next !== undefined) {
233
+ this.mode = next.value;
234
+ this.dispatchEvent(this.#detail("mode", { mode: this.mode }));
235
+ }
236
+
237
+ return;
238
+ }
239
+
138
240
  this.#popover.show(this.#button);
241
+
242
+ // A custom theme's CSS may have changed since the cards were drawn
243
+ if (this.gallery) this.#gallery.refresh();
244
+ }
245
+
246
+ /** Puts the controls and their buttons in the popover, or in place for `inline`. */
247
+ #place(root: ShadowRoot) {
248
+ const inline = this.layout === "inline";
249
+
250
+ this.#reset.slot = inline ? "" : "actions";
251
+ this.#import.slot = inline ? "" : "actions";
252
+
253
+ if (inline) {
254
+ this.#actions.append(this.#reset, this.#import);
255
+ root.append(this.#controls, this.#actions);
256
+ this.#button.remove();
257
+ this.#popover.remove();
258
+ } else {
259
+ this.#popover.append(this.#controls, this.#reset, this.#import);
260
+ root.prepend(this.#button, this.#popover);
261
+ this.#actions.remove();
262
+ }
139
263
  }
140
264
 
141
265
  #detail<Detail>(type: string, detail: Detail) {
@@ -143,7 +267,6 @@ export class GwThemePicker extends GwElement {
143
267
  }
144
268
 
145
269
  protected override create(root: ShadowRoot) {
146
- this.#mode.options = modes;
147
270
  this.#button.addEventListener("click", () => {
148
271
  this.show();
149
272
  });
@@ -157,10 +280,15 @@ export class GwThemePicker extends GwElement {
157
280
  this.theme = this.#theme.value;
158
281
  this.dispatchEvent(this.#detail("theme", { id: this.theme }));
159
282
  });
160
- this.#popover.querySelector(".import")?.addEventListener("click", () => {
283
+ this.#gallery.addEventListener("theme", (event) => {
284
+ event.stopPropagation();
285
+ this.theme = this.#gallery.theme;
286
+ this.dispatchEvent(this.#detail("theme", { id: this.theme }));
287
+ });
288
+ this.#import.addEventListener("click", () => {
161
289
  this.#file.click();
162
290
  });
163
- this.#popover.querySelector(".reset")?.addEventListener("click", () => {
291
+ this.#reset.addEventListener("click", () => {
164
292
  this.dispatchEvent(new Event("reset", { bubbles: true, composed: true }));
165
293
  });
166
294
  this.#file.addEventListener("change", async () => {
@@ -175,17 +303,42 @@ export class GwThemePicker extends GwElement {
175
303
  this.dispatchEvent(this.#detail("import", { text, name: file.name }));
176
304
  });
177
305
 
178
- root.append(this.#button, this.#popover, this.#file);
306
+ root.append(this.#file);
307
+ this.#place(root);
179
308
  }
180
309
 
181
- protected override update() {
182
- this.#button.label = this.label;
183
- this.#button.textContent = this.label;
310
+ protected override update(changed: ReadonlySet<string>) {
311
+ const root = this.shadowRoot;
312
+
313
+ if (changed.has("layout") && root !== null) this.#place(root);
314
+
315
+ const offered = this.#offered();
316
+ const current = offered.find(({ value }) => value === this.mode);
317
+
318
+ if (this.layout === "cycle") {
319
+ const name = `${this.label}: ${current?.label ?? this.mode}`;
320
+
321
+ this.#button.label = name;
322
+ this.#button.textContent = name;
323
+ this.#button.icon = current?.icon ?? paletteIcon;
324
+ } else {
325
+ this.#button.label = this.label;
326
+ this.#button.textContent = this.label;
327
+ this.#button.icon = paletteIcon;
328
+ }
329
+
184
330
  this.#popover.heading = this.label;
185
331
  this.#mode.label = "Mode";
332
+ this.#mode.options = offered.map(({ value, label }) => ({ value, label }));
186
333
  this.#mode.value = this.mode;
187
334
  this.#theme.options = Array.isArray(this.themes) ? this.themes : [];
188
335
  this.#theme.value = this.theme;
336
+ this.#theme.hidden = this.gallery || this.#theme.options.length < 2;
337
+ this.#gallery.hidden = !this.gallery;
338
+ this.#gallery.themes = Array.isArray(this.themes) ? this.themes : [];
339
+ this.#gallery.theme = this.theme;
340
+ // Auto leaves it to follow the page, or the themed container around it
341
+ this.#gallery.mode = this.mode === "auto" ? "" : this.mode;
189
342
  this.#status.textContent = this.status;
190
343
  this.#status.hidden = !this.status;
191
344
  }
package/src/gw-toast.ts CHANGED
@@ -22,6 +22,8 @@ import { base, type Tone } from "./styles.js";
22
22
  * @summary Brief, non-interactive toasts in the bottom end corner.
23
23
  *
24
24
  * @attr {number} duration - Milliseconds each toast shows for. Defaults to 5000.
25
+ *
26
+ * @csspart toast - Each toast.
25
27
  */
26
28
  export class GwToaster extends GwElement {
27
29
  static override properties = {
@@ -111,7 +113,7 @@ export class GwToaster extends GwElement {
111
113
  }
112
114
 
113
115
  show(message: string, tone: Tone = "neutral") {
114
- const toast = h("div", { class: "toast" }, message);
116
+ const toast = h("div", { class: "toast", part: "toast" }, message);
115
117
 
116
118
  toast.style.setProperty(
117
119
  "--gw-toast-tone",
package/src/index.ts CHANGED
@@ -77,6 +77,8 @@ export * from "./gw-skeleton.js";
77
77
 
78
78
  export * from "./gw-spinner.js";
79
79
 
80
+ export * from "./gw-theme-gallery.js";
81
+
80
82
  export * from "./gw-theme-picker.js";
81
83
 
82
84
  export * from "./gw-toast.js";