@timmo001/groundwork-ui 0.3.1 → 0.4.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/src/gw-select.ts CHANGED
@@ -36,9 +36,14 @@ const chevronIcon =
36
36
  * with Enter. Below 600px wide the list is a bottom sheet.
37
37
  *
38
38
  * Pass `options`, or write `<option>` children (in `<optgroup>`s for
39
- * groups, with `data-icon` and `data-description`), which it reads once when
40
- * it first connects. A hidden input carries the value in forms, and it fires
41
- * `change` when the value changes.
39
+ * groups, with `data-icon`, `data-swatch` and `data-description`), which it
40
+ * reads once when it first connects. A hidden input carries the value in
41
+ * forms, and it fires `change` when the value changes.
42
+ *
43
+ * With `multiple` it picks several: each pick toggles an option in `values`
44
+ * and the list stays open, the trigger shows the first choice and how many
45
+ * more, and a Clear button under the list empties it. A hidden input per
46
+ * value carries them in forms.
42
47
  *
43
48
  * @element gw-select
44
49
  * @summary A labeled picker with search, a bottom sheet on phones.
@@ -46,6 +51,8 @@ const chevronIcon =
46
51
  * @attr {string} label - The field's label.
47
52
  * @attr {string} name - The form field name.
48
53
  * @attr {string} value - The chosen option's value.
54
+ * @attr {boolean} multiple - Picks several options, kept in `values`.
55
+ * @attr {string[]} values - The chosen options' values with `multiple`, as JSON.
49
56
  * @attr {string} hint - One line of help under the field.
50
57
  * @attr {string} info - Longer help, opened from an (i) button by the label.
51
58
  * @attr {string} placeholder - What the trigger shows with nothing chosen. Defaults to `Choose`.
@@ -56,13 +63,15 @@ const chevronIcon =
56
63
  * @attr {boolean} label-hidden - Hides the label but keeps it for assistive technology.
57
64
  * @attr {boolean} inline-label - Shows the label inside the trigger, before the value, for toolbars and filters.
58
65
  *
59
- * @fires change - When the value changes.
66
+ * @fires {Event} change - When the value, or `values` with `multiple`, changes.
60
67
  */
61
68
  export class GwSelect extends LightElement {
62
69
  static override properties = {
63
70
  label: {},
64
71
  name: {},
65
72
  value: {},
73
+ multiple: { type: "boolean", reflect: true },
74
+ values: { type: "json" },
66
75
  hint: {},
67
76
  info: {},
68
77
  placeholder: {},
@@ -77,6 +86,8 @@ export class GwSelect extends LightElement {
77
86
  declare label: string;
78
87
  declare name: string;
79
88
  declare value: string;
89
+ declare multiple: boolean;
90
+ declare values: readonly string[];
80
91
  declare hint: string;
81
92
  /** Longer help, opened from an (i) button by the label. */
82
93
  declare info: string;
@@ -126,14 +137,23 @@ export class GwSelect extends LightElement {
126
137
 
127
138
  readonly #input = h("input", { type: "hidden" });
128
139
 
140
+ readonly #inputs = h("span", { hidden: true });
141
+
129
142
  readonly #hint = h("p", { class: "gw-hint", id: `${this.#id}-hint` });
130
143
 
131
144
  readonly #list = h("gw-option-list");
132
145
 
146
+ readonly #clear = h(
147
+ "button",
148
+ { type: "button", class: "gw-select-clear" },
149
+ "Clear",
150
+ );
151
+
133
152
  readonly #popup = h(
134
153
  "div",
135
154
  { class: "gw-select-popup", popover: "manual" },
136
155
  this.#list,
156
+ this.#clear,
137
157
  );
138
158
 
139
159
  constructor() {
@@ -141,6 +161,8 @@ export class GwSelect extends LightElement {
141
161
  this.label = "";
142
162
  this.name = "";
143
163
  this.value = "";
164
+ this.multiple = false;
165
+ this.values = [];
144
166
  this.hint = "";
145
167
  this.info = "";
146
168
  this.placeholder = "Choose";
@@ -179,6 +201,10 @@ export class GwSelect extends LightElement {
179
201
 
180
202
  if (option.dataset.icon !== undefined) item.icon = option.dataset.icon;
181
203
 
204
+ if (option.dataset.swatch !== undefined) {
205
+ item.swatch = option.dataset.swatch;
206
+ }
207
+
182
208
  if (option.dataset.description !== undefined) {
183
209
  item.description = option.dataset.description;
184
210
  }
@@ -188,9 +214,15 @@ export class GwSelect extends LightElement {
188
214
  return item;
189
215
  });
190
216
 
191
- const picked = children.find((option) => option.selected);
217
+ const picked = children.filter((option) => option.selected);
192
218
 
193
- if (this.value === "" && picked !== undefined) this.value = picked.value;
219
+ if (this.multiple) {
220
+ if (this.values.length === 0 && picked.length > 0) {
221
+ this.values = picked.map((option) => option.value);
222
+ }
223
+ } else if (this.value === "" && picked[0] !== undefined) {
224
+ this.value = picked[0].value;
225
+ }
194
226
  }
195
227
 
196
228
  for (const element of this.querySelectorAll("option, optgroup")) {
@@ -294,6 +326,17 @@ export class GwSelect extends LightElement {
294
326
  ? String(event.detail.value)
295
327
  : "";
296
328
 
329
+ if (this.multiple) {
330
+ const chosen = Array.isArray(this.values) ? this.values : [];
331
+
332
+ this.values = chosen.includes(value)
333
+ ? chosen.filter((item) => item !== value)
334
+ : [...chosen, value];
335
+ this.dispatchEvent(new Event("change", { bubbles: true }));
336
+
337
+ return;
338
+ }
339
+
297
340
  this.close();
298
341
  this.#trigger.focus();
299
342
 
@@ -302,19 +345,44 @@ export class GwSelect extends LightElement {
302
345
  this.dispatchEvent(new Event("change", { bubbles: true }));
303
346
  }
304
347
  });
348
+ this.#clear.addEventListener("click", () => {
349
+ this.values = [];
350
+ this.close();
351
+ this.#trigger.focus();
352
+ this.dispatchEvent(new Event("change", { bubbles: true }));
353
+ });
305
354
 
306
355
  root.append(
307
356
  this.#info.heading,
308
357
  this.#trigger,
309
358
  this.#input,
359
+ this.#inputs,
310
360
  this.#hint,
311
361
  this.#popup,
312
362
  );
313
363
  }
314
364
 
365
+ /** What the trigger says: the choice, or with `multiple` the first and how many more. */
366
+ #summary(options: readonly OptionItem[]) {
367
+ if (!this.multiple) {
368
+ return options.find((option) => option.value === this.value);
369
+ }
370
+
371
+ const values = Array.isArray(this.values) ? this.values : [];
372
+ const chosen = options.filter((option) => values.includes(option.value));
373
+ const [first] = chosen;
374
+
375
+ if (first === undefined) return undefined;
376
+
377
+ return chosen.length === 1
378
+ ? first
379
+ : { ...first, label: `${first.label} +${chosen.length - 1}` };
380
+ }
381
+
315
382
  protected override update() {
316
383
  const options = Array.isArray(this.options) ? this.options : [];
317
- const selected = options.find((option) => option.value === this.value);
384
+ const selected = this.#summary(options);
385
+ const values = Array.isArray(this.values) ? this.values : [];
318
386
 
319
387
  this.#label.textContent = this.label;
320
388
  this.#info.update(this.label, this.info);
@@ -335,13 +403,23 @@ export class GwSelect extends LightElement {
335
403
  setAttribute(trigger, "aria-expanded", this.open ? "true" : "false");
336
404
  setAttribute(trigger, "aria-describedby", this.hint ? this.#hint.id : null);
337
405
 
338
- setAttribute(this.#input, "name", this.name || null);
339
- this.#input.value = this.value;
406
+ setAttribute(this.#input, "name", this.multiple ? null : this.name || null);
407
+ this.#input.value = this.multiple ? "" : this.value;
408
+ this.#inputs.replaceChildren(
409
+ ...(this.multiple && this.name
410
+ ? values.map((value) =>
411
+ h("input", { type: "hidden", name: this.name, value }),
412
+ )
413
+ : []),
414
+ );
415
+ this.#clear.hidden = !this.multiple || values.length === 0;
340
416
 
341
417
  const list = this.#list;
342
418
 
343
419
  list.options = options;
344
420
  list.value = this.value;
421
+ list.multiple = this.multiple;
422
+ list.values = values;
345
423
  list.label = this.label;
346
424
  list.search =
347
425
  this.search === "always" ||
@@ -0,0 +1,200 @@
1
+ /**
2
+ * `gw-theme-picker`: a button opening the page's mode, theme and import.
3
+ * Docs: https://groundwork.timmo.dev/components/actions/#theme-picker
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import "./gw-button.js";
9
+ import type { OptionItem } from "./gw-option-list.js";
10
+ import "./gw-popover.js";
11
+ import "./gw-segmented.js";
12
+ import "./gw-select.js";
13
+ import { base } from "./styles.js";
14
+
15
+ // MDI's palette
16
+ const paletteIcon =
17
+ "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
+ const modes = [
20
+ { value: "auto", label: "Auto" },
21
+ { value: "light", label: "Light" },
22
+ { value: "dark", label: "Dark" },
23
+ { value: "darker", label: "Darker" },
24
+ ] as const;
25
+
26
+ /**
27
+ * A palette button for a page's look. It opens a popover with the mode
28
+ * (auto, light, dark or Darker), the theme, and buttons to import a theme
29
+ * file (a Groundwork theme's `.json` or an Omarchy `colors.toml`) or reset.
30
+ *
31
+ * It only asks: it fires `mode`, `theme`, `import` and `reset`, and the page
32
+ * applies them and sets `mode`, `theme` and `themes` back. `pageTheme` from
33
+ * `@timmo001/groundwork-tokens/page` does all of that. Show an import's
34
+ * result, such as contrast misses, in `status`.
35
+ *
36
+ * @element gw-theme-picker
37
+ * @summary A button opening the page's mode, theme and import.
38
+ *
39
+ * @attr {"auto"|"light"|"dark"|"darker"} mode - The mode in use. Defaults to `auto`.
40
+ * @attr {string} theme - The theme in use, by id.
41
+ * @attr {OptionItem[]} themes - The themes to offer, as JSON.
42
+ * @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`.
44
+ *
45
+ * @fires {CustomEvent<{ mode: string }>} mode - When a mode is picked.
46
+ * @fires {CustomEvent<{ id: string }>} theme - When a theme is picked.
47
+ * @fires {CustomEvent<{ text: string, name: string }>} import - When a theme file is chosen, with its text and file name.
48
+ * @fires {Event} reset - When Reset is pressed.
49
+ */
50
+ export class GwThemePicker extends GwElement {
51
+ static override properties = {
52
+ mode: { reflect: true },
53
+ theme: { reflect: true },
54
+ themes: { type: "json" },
55
+ status: {},
56
+ label: {},
57
+ } as const;
58
+
59
+ static override styles = [
60
+ base,
61
+ css`
62
+ :host {
63
+ display: inline-block;
64
+ }
65
+
66
+ .controls {
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: var(--gw-space-4);
70
+ margin-block-start: var(--gw-space-2);
71
+ }
72
+
73
+ .status {
74
+ margin: 0;
75
+ font-size: var(--gw-font-size-s);
76
+ color: var(--gw-color-text-secondary);
77
+ }
78
+ `,
79
+ ];
80
+
81
+ declare mode: string;
82
+ declare theme: string;
83
+ declare themes: readonly OptionItem[];
84
+ declare status: string;
85
+ declare label: string;
86
+
87
+ readonly #button = h("gw-button", {
88
+ variant: "neutral",
89
+ appearance: "plain",
90
+ icon: paletteIcon,
91
+ labels: "hide",
92
+ });
93
+
94
+ readonly #mode = h("gw-segmented");
95
+
96
+ readonly #theme = h("gw-select", { label: "Theme" });
97
+
98
+ readonly #status = h("p", { class: "status", role: "status" });
99
+
100
+ readonly #file = h("input", {
101
+ type: "file",
102
+ accept: ".json,.toml,application/json",
103
+ hidden: true,
104
+ });
105
+
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
+ ),
125
+ );
126
+
127
+ constructor() {
128
+ super();
129
+ this.mode = "auto";
130
+ this.theme = "";
131
+ this.themes = [];
132
+ this.status = "";
133
+ this.label = "Theme";
134
+ }
135
+
136
+ /** Opens the popover under the button. */
137
+ show() {
138
+ this.#popover.show(this.#button);
139
+ }
140
+
141
+ #detail<Detail>(type: string, detail: Detail) {
142
+ return new CustomEvent(type, { detail, bubbles: true, composed: true });
143
+ }
144
+
145
+ protected override create(root: ShadowRoot) {
146
+ this.#mode.options = modes;
147
+ this.#button.addEventListener("click", () => {
148
+ this.show();
149
+ });
150
+ this.#mode.addEventListener("change", (event) => {
151
+ event.stopPropagation();
152
+ this.mode = this.#mode.value;
153
+ this.dispatchEvent(this.#detail("mode", { mode: this.mode }));
154
+ });
155
+ this.#theme.addEventListener("change", (event) => {
156
+ event.stopPropagation();
157
+ this.theme = this.#theme.value;
158
+ this.dispatchEvent(this.#detail("theme", { id: this.theme }));
159
+ });
160
+ this.#popover.querySelector(".import")?.addEventListener("click", () => {
161
+ this.#file.click();
162
+ });
163
+ this.#popover.querySelector(".reset")?.addEventListener("click", () => {
164
+ this.dispatchEvent(new Event("reset", { bubbles: true, composed: true }));
165
+ });
166
+ this.#file.addEventListener("change", async () => {
167
+ const file = this.#file.files?.[0];
168
+
169
+ this.#file.value = "";
170
+
171
+ if (file === undefined) return;
172
+
173
+ const text = await file.text();
174
+
175
+ this.dispatchEvent(this.#detail("import", { text, name: file.name }));
176
+ });
177
+
178
+ root.append(this.#button, this.#popover, this.#file);
179
+ }
180
+
181
+ protected override update() {
182
+ this.#button.label = this.label;
183
+ this.#button.textContent = this.label;
184
+ this.#popover.heading = this.label;
185
+ this.#mode.label = "Mode";
186
+ this.#mode.value = this.mode;
187
+ this.#theme.options = Array.isArray(this.themes) ? this.themes : [];
188
+ this.#theme.value = this.theme;
189
+ this.#status.textContent = this.status;
190
+ this.#status.hidden = !this.status;
191
+ }
192
+ }
193
+
194
+ GwThemePicker.define("gw-theme-picker");
195
+
196
+ declare global {
197
+ interface HTMLElementTagNameMap {
198
+ "gw-theme-picker": GwThemePicker;
199
+ }
200
+ }
package/src/index.ts CHANGED
@@ -15,8 +15,12 @@ export * from "./fields.js";
15
15
 
16
16
  export * from "./layout.js";
17
17
 
18
+ export * from "./on.js";
19
+
18
20
  export * from "./gw-alert.js";
19
21
 
22
+ export * from "./gw-app-bar.js";
23
+
20
24
  export * from "./gw-badge.js";
21
25
 
22
26
  export * from "./gw-banner.js";
@@ -73,4 +77,6 @@ export * from "./gw-skeleton.js";
73
77
 
74
78
  export * from "./gw-spinner.js";
75
79
 
80
+ export * from "./gw-theme-picker.js";
81
+
76
82
  export * from "./gw-toast.js";
package/src/on.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `on()`: listens for a `gw-` element's own events, typed from the
3
+ * generated `GwEventMap`, so a handler gets `CustomEvent<{ value: string }>`
4
+ * for `pick` with no cast.
5
+ * Docs: https://groundwork.timmo.dev/components/#using-with-lit
6
+ *
7
+ * @module
8
+ */
9
+ import type { GwEventMap } from "./events.js";
10
+
11
+ export type { GwEventMap } from "./events.js";
12
+
13
+ type EventTag = keyof GwEventMap;
14
+
15
+ /** A `gw-` element that fires events of its own. */
16
+ export type GwEventTarget = HTMLElementTagNameMap[EventTag];
17
+
18
+ /** The events an element fires, found from the tag its class is defined for. */
19
+ export type GwEventsOf<E extends GwEventTarget> = {
20
+ [T in EventTag]: E extends HTMLElementTagNameMap[T] ? GwEventMap[T] : never;
21
+ }[EventTag];
22
+
23
+ /**
24
+ * A handler for one event. Declared as a method so it's checked bivariantly
25
+ * and can be handed to `addEventListener`, which only knows it as an `Event`.
26
+ */
27
+ type Listener<T extends Event> = {
28
+ handle(event: T): void;
29
+ }["handle"];
30
+
31
+ /**
32
+ * Adds a listener for one of the element's own events, typed from its
33
+ * `@fires` docs, and returns a function that removes it.
34
+ *
35
+ * @example
36
+ * on(menu, "pick", (event) => open(event.detail.value));
37
+ */
38
+ export function on<
39
+ E extends GwEventTarget,
40
+ K extends keyof GwEventsOf<E> & string,
41
+ >(
42
+ element: E,
43
+ type: K,
44
+ listener: Listener<GwEventsOf<E>[K] & Event>,
45
+ options?: AddEventListenerOptions,
46
+ ): () => void {
47
+ const handler: Listener<Event> = listener;
48
+
49
+ element.addEventListener(type, handler, options);
50
+
51
+ return () => element.removeEventListener(type, handler, options);
52
+ }