@timmo001/groundwork-ui 0.5.0 → 0.7.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.
@@ -17,11 +17,22 @@ import { base } from "./styles.js";
17
17
  const paletteIcon =
18
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";
19
19
 
20
- const modes = [
21
- { value: "auto", label: "Auto" },
22
- { value: "light", label: "Light" },
23
- { value: "dark", label: "Dark" },
24
- { 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 },
25
36
  ] as const;
26
37
 
27
38
  /**
@@ -37,17 +48,28 @@ const modes = [
37
48
  * `gallery` swaps the theme select for `gw-theme-gallery`'s cards, which
38
49
  * preview each theme in the chosen mode.
39
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
+ *
40
58
  * @element gw-theme-picker
41
59
  * @summary A button opening the page's mode, theme and import.
42
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`.
43
63
  * @attr {"auto"|"light"|"dark"|"darker"} mode - The mode in use. Defaults to `auto`.
44
64
  * @attr {string} theme - The theme in use, by id.
45
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.
46
66
  * @attr {boolean} gallery - Shows the themes as preview cards rather than a select.
47
67
  * @attr {string} status - A line under the controls, such as an import's result.
48
- * @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.
49
69
  *
50
70
  * @csspart gallery - The theme gallery, with `gallery`.
71
+ * @csspart controls - The mode, theme and status.
72
+ * @csspart actions - Reset and Import, with `inline`.
51
73
  *
52
74
  * @fires {CustomEvent<{ mode: string }>} mode - When a mode is picked.
53
75
  * @fires {CustomEvent<{ id: string }>} theme - When a theme is picked.
@@ -62,6 +84,8 @@ export class GwThemePicker extends GwElement {
62
84
  gallery: { type: "boolean", reflect: true },
63
85
  status: {},
64
86
  label: {},
87
+ layout: { reflect: true },
88
+ modes: {},
65
89
  } as const;
66
90
 
67
91
  static override styles = [
@@ -95,6 +119,28 @@ export class GwThemePicker extends GwElement {
95
119
  font-size: var(--gw-font-size-s);
96
120
  color: var(--gw-color-text-secondary);
97
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
+ }
98
144
  `,
99
145
  ];
100
146
 
@@ -104,6 +150,10 @@ export class GwThemePicker extends GwElement {
104
150
  declare gallery: boolean;
105
151
  declare status: string;
106
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;
107
157
 
108
158
  readonly #button = h("gw-button", {
109
159
  variant: "neutral",
@@ -129,34 +179,27 @@ export class GwThemePicker extends GwElement {
129
179
  hidden: true,
130
180
  });
131
181
 
132
- readonly #popover = h(
133
- "gw-popover",
134
- {},
135
- h(
136
- "div",
137
- { class: "controls" },
138
- this.#mode,
139
- this.#theme,
140
- this.#gallery,
141
- this.#status,
142
- ),
143
- h(
144
- "gw-button",
145
- {
146
- slot: "actions",
147
- variant: "neutral",
148
- appearance: "plain",
149
- class: "reset",
150
- },
151
- "Reset",
152
- ),
153
- h(
154
- "gw-button",
155
- { slot: "actions", appearance: "outlined", class: "import" },
156
- "Import",
157
- ),
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",
158
195
  );
159
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
+
160
203
  constructor() {
161
204
  super();
162
205
  this.mode = "auto";
@@ -165,22 +208,65 @@ export class GwThemePicker extends GwElement {
165
208
  this.gallery = false;
166
209
  this.status = "";
167
210
  this.label = "Theme";
211
+ this.layout = "popover";
212
+ this.modes = "auto light dark darker";
168
213
  }
169
214
 
170
- /** Opens the popover under the button. */
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;
221
+ }
222
+
223
+ /** Opens the popover under the button, or with `cycle`, steps to the next mode. */
171
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
+
172
240
  this.#popover.show(this.#button);
173
241
 
174
242
  // A custom theme's CSS may have changed since the cards were drawn
175
243
  if (this.gallery) this.#gallery.refresh();
176
244
  }
177
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
+ }
263
+ }
264
+
178
265
  #detail<Detail>(type: string, detail: Detail) {
179
266
  return new CustomEvent(type, { detail, bubbles: true, composed: true });
180
267
  }
181
268
 
182
269
  protected override create(root: ShadowRoot) {
183
- this.#mode.options = modes;
184
270
  this.#button.addEventListener("click", () => {
185
271
  this.show();
186
272
  });
@@ -199,10 +285,10 @@ export class GwThemePicker extends GwElement {
199
285
  this.theme = this.#gallery.theme;
200
286
  this.dispatchEvent(this.#detail("theme", { id: this.theme }));
201
287
  });
202
- this.#popover.querySelector(".import")?.addEventListener("click", () => {
288
+ this.#import.addEventListener("click", () => {
203
289
  this.#file.click();
204
290
  });
205
- this.#popover.querySelector(".reset")?.addEventListener("click", () => {
291
+ this.#reset.addEventListener("click", () => {
206
292
  this.dispatchEvent(new Event("reset", { bubbles: true, composed: true }));
207
293
  });
208
294
  this.#file.addEventListener("change", async () => {
@@ -217,14 +303,33 @@ export class GwThemePicker extends GwElement {
217
303
  this.dispatchEvent(this.#detail("import", { text, name: file.name }));
218
304
  });
219
305
 
220
- root.append(this.#button, this.#popover, this.#file);
306
+ root.append(this.#file);
307
+ this.#place(root);
221
308
  }
222
309
 
223
- protected override update() {
224
- this.#button.label = this.label;
225
- 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
+
226
330
  this.#popover.heading = this.label;
227
331
  this.#mode.label = "Mode";
332
+ this.#mode.options = offered.map(({ value, label }) => ({ value, label }));
228
333
  this.#mode.value = this.mode;
229
334
  this.#theme.options = Array.isArray(this.themes) ? this.themes : [];
230
335
  this.#theme.value = this.theme;
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/styles.ts CHANGED
@@ -7,6 +7,31 @@ import { css } from "./element.js";
7
7
 
8
8
  const svgNamespace = "http://www.w3.org/2000/svg";
9
9
 
10
+ /**
11
+ * The `xs` to `xl` presets on a `size` attribute, as `--gw-<name>-scale`
12
+ * around 1 for medium, so an app can also set any size between or beyond
13
+ * them. A control multiplies its height and icon by `--gw-control-scale`
14
+ * too, which follows the theme's density, and its text only by its own
15
+ * scale, since spacing and type already follow density on their own.
16
+ */
17
+ export const controlSizes = (name: string) => css`
18
+ :host([size="xs"]) {
19
+ --gw-${name}-scale: 0.6;
20
+ }
21
+
22
+ :host([size="small"]) {
23
+ --gw-${name}-scale: 0.8;
24
+ }
25
+
26
+ :host([size="large"]) {
27
+ --gw-${name}-scale: 1.2;
28
+ }
29
+
30
+ :host([size="xl"]) {
31
+ --gw-${name}-scale: 1.4;
32
+ }
33
+ `;
34
+
10
35
  /**
11
36
  * What every component shares: box sizing, the focus ring, icons and the
12
37
  * tones that set `--gw-tone` from a `tone` attribute.