@timmo001/groundwork-ui 0.0.0-stage → 0.3.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,301 @@
1
+ /**
2
+ * `gw-segmented`: picks one of a few options, as a row of radio buttons.
3
+ * Docs: https://groundwork.timmo.dev/components/actions/#segmented-control
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h, setAttribute } from "./element.js";
8
+ import { base, createIcon } from "./styles.js";
9
+
10
+ export interface Segment {
11
+ readonly value: string;
12
+ readonly label: string;
13
+ /** SVG path data for an icon beside, or instead of, the label. */
14
+ readonly icon?: string;
15
+ }
16
+
17
+ /**
18
+ * Picks one of a few options, as a row of native radio buttons, so arrow
19
+ * keys move between them and a form submits the choice. Fires `change`.
20
+ *
21
+ * Options can carry icons, as Home Assistant's toggles do. `labels` decides
22
+ * whether their text shows: `show` (the default), `hide` (icons only, with
23
+ * the text as a tooltip and accessible name) or `auto`, which shows the text
24
+ * on wide screens and only the icons on narrow ones.
25
+ *
26
+ * @element gw-segmented
27
+ * @summary Picks one of a few options, as a row of radio buttons.
28
+ *
29
+ * @attr {string} label - The group's accessible name.
30
+ * @attr {string} name - The form field name. Defaults to a unique name.
31
+ * @attr {string} value - The chosen option's value.
32
+ * @attr {"show"|"hide"|"auto"} labels - Whether option text shows beside icons. Defaults to `show`.
33
+ * @attr {Segment[]} options - The options, as JSON.
34
+ *
35
+ * @csspart indicator - The pill that slides to the chosen option.
36
+ *
37
+ * @fires change - When the chosen option changes.
38
+ */
39
+ export class GwSegmented extends GwElement {
40
+ static override properties = {
41
+ label: {},
42
+ name: {},
43
+ value: { reflect: true },
44
+ labels: { reflect: true },
45
+ options: { type: "json" },
46
+ } as const;
47
+
48
+ static override styles = [
49
+ base,
50
+ css`
51
+ :host {
52
+ display: inline-block;
53
+ }
54
+
55
+ /* A pill track with the picked option as a pill inside, as Home
56
+ Assistant's toggles are */
57
+ fieldset {
58
+ display: inline-flex;
59
+ gap: 2px;
60
+ padding: 3px;
61
+ margin: 0;
62
+ background: var(--gw-color-surface-lower);
63
+ border: max(var(--gw-border-width), 1px) solid
64
+ var(--gw-color-border-normal);
65
+ border-radius: var(--gw-radius-pill);
66
+ }
67
+
68
+ legend {
69
+ position: absolute;
70
+ width: 1px;
71
+ height: 1px;
72
+ overflow: hidden;
73
+ clip-path: inset(50%);
74
+ white-space: nowrap;
75
+ }
76
+
77
+ label {
78
+ position: relative;
79
+ display: inline-flex;
80
+ gap: var(--gw-space-2);
81
+ align-items: center;
82
+ box-sizing: border-box;
83
+ /* 32px options in a 40px track, matching fields and buttons */
84
+ height: 2rem;
85
+ padding: 0 var(--gw-space-3);
86
+ font-size: var(--gw-font-size-s);
87
+ color: var(--gw-color-text-primary);
88
+ cursor: pointer;
89
+ background: transparent;
90
+ border-radius: var(--gw-radius-pill);
91
+ transition: background-color var(--gw-motion-fast)
92
+ var(--gw-motion-easing);
93
+ --gw-icon-size: 1.125rem;
94
+ }
95
+
96
+ :host([labels="hide"]) label.has-icon {
97
+ padding-inline: var(--gw-space-2);
98
+ }
99
+
100
+ :host([labels="hide"]) label.has-icon .text {
101
+ position: absolute;
102
+ width: 1px;
103
+ height: 1px;
104
+ overflow: hidden;
105
+ clip-path: inset(50%);
106
+ white-space: nowrap;
107
+ }
108
+
109
+ @media (width < 40rem) {
110
+ :host([labels="auto"]) label.has-icon {
111
+ padding-inline: var(--gw-space-2);
112
+ }
113
+
114
+ :host([labels="auto"]) label.has-icon .text {
115
+ position: absolute;
116
+ width: 1px;
117
+ height: 1px;
118
+ overflow: hidden;
119
+ clip-path: inset(50%);
120
+ white-space: nowrap;
121
+ }
122
+ }
123
+
124
+ label:hover {
125
+ background: var(--gw-color-fill-neutral-hovered);
126
+ }
127
+
128
+ input {
129
+ position: absolute;
130
+ inset: 0;
131
+ margin: 0;
132
+ cursor: pointer;
133
+ opacity: 0;
134
+ }
135
+
136
+ /* The picked pill is one element that slides between options, under
137
+ their text, so the choice moves rather than jumps */
138
+ fieldset {
139
+ position: relative;
140
+ isolation: isolate;
141
+ }
142
+
143
+ .indicator {
144
+ position: absolute;
145
+ inset-block-start: 0;
146
+ inset-inline-start: 0;
147
+ z-index: -1;
148
+ background: var(--gw-color-fill-accent-resting);
149
+ border-radius: var(--gw-radius-pill);
150
+ box-shadow: var(--gw-shadow-s);
151
+ opacity: 0;
152
+ }
153
+
154
+ .indicator.placed {
155
+ opacity: 1;
156
+ }
157
+
158
+ .indicator.moving {
159
+ transition:
160
+ translate var(--gw-motion-normal) var(--gw-motion-easing),
161
+ width var(--gw-motion-normal) var(--gw-motion-easing),
162
+ height var(--gw-motion-normal) var(--gw-motion-easing);
163
+ }
164
+
165
+ label {
166
+ transition:
167
+ background-color var(--gw-motion-fast) var(--gw-motion-easing),
168
+ color var(--gw-motion-normal) var(--gw-motion-easing);
169
+ }
170
+
171
+ label:has(input:checked),
172
+ label:has(input:checked):hover {
173
+ color: var(--gw-color-on-accent);
174
+ background: transparent;
175
+ }
176
+
177
+ label:has(input:focus-visible) {
178
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
179
+ /* Inset, so the ring sits inside the track rather than on its border */
180
+ outline-offset: calc(var(--gw-focus-width) * -1);
181
+ }
182
+ `,
183
+ ];
184
+
185
+ declare label: string;
186
+ declare name: string;
187
+ declare value: string;
188
+ declare labels: "show" | "hide" | "auto";
189
+ declare options: readonly Segment[];
190
+
191
+ constructor() {
192
+ super();
193
+ this.label = "";
194
+ this.name = `gw-segmented-${Math.random().toString(36).slice(2)}`;
195
+ this.value = "";
196
+ this.labels = "show";
197
+ this.options = [];
198
+ }
199
+
200
+ readonly #legend = h("legend");
201
+
202
+ readonly #indicator = h("span", { class: "indicator", part: "indicator" });
203
+
204
+ readonly #fieldset = h("fieldset", {}, this.#legend, this.#indicator);
205
+
206
+ #segments: {
207
+ label: HTMLLabelElement;
208
+ input: HTMLInputElement;
209
+ option: Segment;
210
+ }[] = [];
211
+
212
+ readonly #resize = new ResizeObserver(() => this.#place(false));
213
+
214
+ #pick(value: string) {
215
+ this.value = value;
216
+ this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
217
+ }
218
+
219
+ /** Puts the pill under the picked option, sliding there unless it's the first placing. */
220
+ #place(animate: boolean) {
221
+ const indicator = this.#indicator;
222
+
223
+ const picked = this.#segments.find(({ input }) => input.checked)?.label;
224
+
225
+ if (picked === undefined) {
226
+ indicator.classList.remove("placed");
227
+
228
+ return;
229
+ }
230
+
231
+ indicator.classList.toggle(
232
+ "moving",
233
+ animate && indicator.classList.contains("placed"),
234
+ );
235
+ indicator.style.translate = `${picked.offsetLeft}px ${picked.offsetTop}px`;
236
+ indicator.style.width = `${picked.offsetWidth}px`;
237
+ indicator.style.height = `${picked.offsetHeight}px`;
238
+ indicator.classList.add("placed");
239
+ }
240
+
241
+ override connectedCallback() {
242
+ super.connectedCallback();
243
+ this.#resize.observe(this);
244
+ }
245
+
246
+ override disconnectedCallback() {
247
+ super.disconnectedCallback();
248
+ this.#resize.disconnect();
249
+ }
250
+
251
+ protected override create(root: ShadowRoot) {
252
+ root.append(this.#fieldset);
253
+ }
254
+
255
+ protected override update(changed: ReadonlySet<string>) {
256
+ this.#legend.textContent = this.label;
257
+
258
+ if (changed.has("options")) {
259
+ const options = Array.isArray(this.options) ? this.options : [];
260
+
261
+ for (const { label } of this.#segments) label.remove();
262
+
263
+ this.#segments = options.map((option) => {
264
+ const input = h("input", { type: "radio", value: option.value });
265
+
266
+ input.addEventListener("change", () => this.#pick(option.value));
267
+
268
+ const label = h(
269
+ "label",
270
+ { class: option.icon ? "has-icon" : null },
271
+ input,
272
+ option.icon ? createIcon(option.icon) : null,
273
+ h("span", { class: "text" }, option.label),
274
+ );
275
+
276
+ return { label, input, option };
277
+ });
278
+ this.#fieldset.append(...this.#segments.map(({ label }) => label));
279
+ }
280
+
281
+ for (const { label, input, option } of this.#segments) {
282
+ input.name = this.name;
283
+ input.checked = option.value === this.value;
284
+ setAttribute(
285
+ label,
286
+ "title",
287
+ option.icon && this.labels !== "show" ? option.label : null,
288
+ );
289
+ }
290
+
291
+ this.#place(true);
292
+ }
293
+ }
294
+
295
+ GwSegmented.define("gw-segmented");
296
+
297
+ declare global {
298
+ interface HTMLElementTagNameMap {
299
+ "gw-segmented": GwSegmented;
300
+ }
301
+ }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * `gw-select`: a labeled picker with search, a bottom sheet on phones.
3
+ * Docs: https://groundwork.timmo.dev/components/inputs/#select
4
+ *
5
+ * @module
6
+ */
7
+ import { h, setAttribute } from "./element.js";
8
+ import { FieldInfo } from "./field-info.js";
9
+ import { LightElement, fieldId } from "./fields.js";
10
+ import "./gw-option-list.js";
11
+ import type { OptionItem } from "./gw-option-list.js";
12
+ import { createIcon, setIconPath } from "./styles.js";
13
+
14
+ /** @deprecated Use `OptionItem`, which also takes an icon, description and group. */
15
+ export type SelectOption = OptionItem;
16
+
17
+ /** An option being read from markup, filled in field by field. */
18
+ type DraftOption = { -readonly [Key in keyof OptionItem]: OptionItem[Key] };
19
+
20
+ const gap = 6;
21
+
22
+ /** Below this width the list is a bottom sheet. */
23
+ const sheetBelow = 600;
24
+
25
+ /** From this many options a search field shows by default. */
26
+ const searchFrom = 7;
27
+
28
+ // MDI's chevron-down
29
+ const chevronIcon =
30
+ "M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z";
31
+
32
+ /**
33
+ * A labeled select whose list opens under it, after Home Assistant's
34
+ * pickers. Long lists get a search field, focused as the list opens, and
35
+ * only the options in view are rendered. Move with the arrow keys and pick
36
+ * with Enter. Below 600px wide the list is a bottom sheet.
37
+ *
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.
42
+ *
43
+ * @element gw-select
44
+ * @summary A labeled picker with search, a bottom sheet on phones.
45
+ *
46
+ * @attr {string} label - The field's label.
47
+ * @attr {string} name - The form field name.
48
+ * @attr {string} value - The chosen option's value.
49
+ * @attr {string} hint - One line of help under the field.
50
+ * @attr {string} info - Longer help, opened from an (i) button by the label.
51
+ * @attr {string} placeholder - What the trigger shows with nothing chosen. Defaults to `Choose`.
52
+ * @attr {"auto"|"always"|"never"} search - Whether the list has a search field; `auto` adds one from 7 options. Defaults to `auto`.
53
+ * @attr {boolean} disabled - Disables the field.
54
+ * @attr {boolean} open - Whether the list shows.
55
+ * @attr {OptionItem[]} options - The options, as JSON.
56
+ * @attr {boolean} label-hidden - Hides the label but keeps it for assistive technology.
57
+ * @attr {boolean} inline-label - Shows the label inside the trigger, before the value, for toolbars and filters.
58
+ *
59
+ * @fires change - When the value changes.
60
+ */
61
+ export class GwSelect extends LightElement {
62
+ static override properties = {
63
+ label: {},
64
+ name: {},
65
+ value: {},
66
+ hint: {},
67
+ info: {},
68
+ placeholder: {},
69
+ search: {},
70
+ disabled: { type: "boolean" },
71
+ open: { type: "boolean", reflect: true },
72
+ options: { type: "json" },
73
+ labelHidden: { type: "boolean", attribute: "label-hidden" },
74
+ inlineLabel: { type: "boolean", attribute: "inline-label", reflect: true },
75
+ } as const;
76
+
77
+ declare label: string;
78
+ declare name: string;
79
+ declare value: string;
80
+ declare hint: string;
81
+ /** Longer help, opened from an (i) button by the label. */
82
+ declare info: string;
83
+ declare placeholder: string;
84
+ /** `auto` shows a search field from seven options; `always` or `never` overrides that. */
85
+ declare search: "auto" | "always" | "never";
86
+ declare disabled: boolean;
87
+ declare open: boolean;
88
+ declare options: readonly OptionItem[];
89
+ declare labelHidden: boolean;
90
+ declare inlineLabel: boolean;
91
+
92
+ readonly #id = fieldId();
93
+
94
+ readonly #label = h("label", { id: `${this.#id}-label` });
95
+
96
+ readonly #info = new FieldInfo(this.#label);
97
+
98
+ readonly #icon = createIcon("");
99
+
100
+ // The trigger is already labelled by the real label, so this copy is hidden
101
+ // from assistive technology
102
+ readonly #inline = h("span", {
103
+ class: "gw-select-inline-label",
104
+ "aria-hidden": "true",
105
+ });
106
+
107
+ readonly #text = h("span", { class: "gw-select-value" });
108
+
109
+ readonly #trigger = h(
110
+ "button",
111
+ {
112
+ type: "button",
113
+ class: "gw-select-trigger",
114
+ id: this.#id,
115
+ "aria-haspopup": "listbox",
116
+ "aria-labelledby": `${this.#id}-label ${this.#id}`,
117
+ },
118
+ this.#icon,
119
+ this.#inline,
120
+ // Keeps the visible text "Theme Base" rather than "ThemeBase", matching
121
+ // the accessible name
122
+ " ",
123
+ this.#text,
124
+ createIcon(chevronIcon),
125
+ );
126
+
127
+ readonly #input = h("input", { type: "hidden" });
128
+
129
+ readonly #hint = h("p", { class: "gw-hint", id: `${this.#id}-hint` });
130
+
131
+ readonly #list = h("gw-option-list");
132
+
133
+ readonly #popup = h(
134
+ "div",
135
+ { class: "gw-select-popup", popover: "manual" },
136
+ this.#list,
137
+ );
138
+
139
+ constructor() {
140
+ super();
141
+ this.label = "";
142
+ this.name = "";
143
+ this.value = "";
144
+ this.hint = "";
145
+ this.info = "";
146
+ this.placeholder = "Choose";
147
+ this.search = "auto";
148
+ this.disabled = false;
149
+ this.open = false;
150
+ this.options = [];
151
+ this.labelHidden = false;
152
+ this.inlineLabel = false;
153
+ }
154
+
155
+ /** Opens the list, such as from a keyboard shortcut. */
156
+ show() {
157
+ if (this.disabled || this.#popup.matches(":popover-open")) return;
158
+
159
+ this.#popup.dataset.placement =
160
+ window.innerWidth < sheetBelow ? "sheet" : "below";
161
+ this.#popup.showPopover();
162
+ }
163
+
164
+ close() {
165
+ if (this.#popup.matches(":popover-open")) this.#popup.hidePopover();
166
+ }
167
+
168
+ #readChildren() {
169
+ const children = [...this.querySelectorAll("option")];
170
+
171
+ if (this.options.length === 0 && children.length > 0) {
172
+ this.options = children.map((option) => {
173
+ const group = option.parentElement;
174
+
175
+ const item: DraftOption = {
176
+ value: option.value,
177
+ label: option.textContent.trim(),
178
+ };
179
+
180
+ if (option.dataset.icon !== undefined) item.icon = option.dataset.icon;
181
+
182
+ if (option.dataset.description !== undefined) {
183
+ item.description = option.dataset.description;
184
+ }
185
+
186
+ if (group instanceof HTMLOptGroupElement) item.group = group.label;
187
+
188
+ return item;
189
+ });
190
+
191
+ const picked = children.find((option) => option.selected);
192
+
193
+ if (this.value === "" && picked !== undefined) this.value = picked.value;
194
+ }
195
+
196
+ for (const element of this.querySelectorAll("option, optgroup")) {
197
+ element.remove();
198
+ }
199
+ }
200
+
201
+ readonly #position = () => {
202
+ const popup = this.#popup;
203
+
204
+ if (popup.dataset.placement === "sheet") {
205
+ popup.style.top = "";
206
+ popup.style.left = "";
207
+ popup.style.width = "";
208
+
209
+ return;
210
+ }
211
+
212
+ const rect = this.#trigger.getBoundingClientRect();
213
+ const width = Math.max(rect.width, 224);
214
+
215
+ popup.style.width = `${Math.min(width, window.innerWidth - 2 * gap)}px`;
216
+
217
+ const below = window.innerHeight - rect.bottom - gap;
218
+ const fitsBelow = below >= popup.offsetHeight || below >= rect.top;
219
+
220
+ popup.dataset.placement = fitsBelow ? "below" : "above";
221
+ popup.style.top = `${fitsBelow ? rect.bottom + gap : Math.max(gap, rect.top - gap - popup.offsetHeight)}px`;
222
+ popup.style.left = `${Math.min(Math.max(gap, rect.left), window.innerWidth - popup.offsetWidth - gap)}px`;
223
+ };
224
+
225
+ readonly #onOutside = (event: PointerEvent) => {
226
+ if (!event.composedPath().includes(this)) this.close();
227
+ };
228
+
229
+ readonly #onToggle = (event: ToggleEvent) => {
230
+ this.open = event.newState === "open";
231
+
232
+ if (this.open) {
233
+ this.#position();
234
+ this.#list.reset();
235
+ window.addEventListener("resize", this.#position);
236
+ window.addEventListener("scroll", this.#position, true);
237
+ document.addEventListener("pointerdown", this.#onOutside, true);
238
+
239
+ return;
240
+ }
241
+
242
+ this.#stopListening();
243
+ };
244
+
245
+ #stopListening() {
246
+ window.removeEventListener("resize", this.#position);
247
+ window.removeEventListener("scroll", this.#position, true);
248
+ document.removeEventListener("pointerdown", this.#onOutside, true);
249
+ }
250
+
251
+ override disconnectedCallback() {
252
+ this.#stopListening();
253
+ super.disconnectedCallback();
254
+ }
255
+
256
+ protected override create(root: HTMLElement) {
257
+ this.#readChildren();
258
+
259
+ this.#label.addEventListener("click", () => {
260
+ this.#trigger.focus();
261
+ });
262
+ this.#trigger.addEventListener("click", () => {
263
+ if (this.open) {
264
+ this.close();
265
+ } else {
266
+ this.show();
267
+ }
268
+ });
269
+ this.#trigger.addEventListener("keydown", (event) => {
270
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
271
+ event.preventDefault();
272
+ this.show();
273
+ }
274
+ });
275
+ this.#popup.addEventListener("toggle", this.#onToggle);
276
+ this.#popup.addEventListener("keydown", (event) => {
277
+ if (event.key === "Escape") {
278
+ event.preventDefault();
279
+ event.stopPropagation();
280
+ this.close();
281
+ this.#trigger.focus();
282
+ } else if (event.key === "Tab") {
283
+ this.close();
284
+ this.#trigger.focus();
285
+ }
286
+ });
287
+ this.#list.addEventListener("pick", (event) => {
288
+ event.stopPropagation();
289
+
290
+ const value =
291
+ event instanceof CustomEvent &&
292
+ event.detail !== null &&
293
+ "value" in event.detail
294
+ ? String(event.detail.value)
295
+ : "";
296
+
297
+ this.close();
298
+ this.#trigger.focus();
299
+
300
+ if (value !== this.value) {
301
+ this.value = value;
302
+ this.dispatchEvent(new Event("change", { bubbles: true }));
303
+ }
304
+ });
305
+
306
+ root.append(
307
+ this.#info.heading,
308
+ this.#trigger,
309
+ this.#input,
310
+ this.#hint,
311
+ this.#popup,
312
+ );
313
+ }
314
+
315
+ protected override update() {
316
+ const options = Array.isArray(this.options) ? this.options : [];
317
+ const selected = options.find((option) => option.value === this.value);
318
+
319
+ this.#label.textContent = this.label;
320
+ this.#info.update(this.label, this.info);
321
+ this.#info.heading.classList.toggle(
322
+ "gw-visually-hidden",
323
+ this.labelHidden || this.inlineLabel,
324
+ );
325
+ this.#inline.textContent = this.label;
326
+ this.#inline.hidden = !this.inlineLabel;
327
+ this.#text.textContent = selected?.label ?? this.placeholder;
328
+ this.#text.toggleAttribute("data-placeholder", selected === undefined);
329
+ setIconPath(this.#icon, selected?.icon ?? "");
330
+ this.#icon.toggleAttribute("hidden", selected?.icon === undefined);
331
+
332
+ const trigger = this.#trigger;
333
+
334
+ trigger.disabled = this.disabled;
335
+ setAttribute(trigger, "aria-expanded", this.open ? "true" : "false");
336
+ setAttribute(trigger, "aria-describedby", this.hint ? this.#hint.id : null);
337
+
338
+ setAttribute(this.#input, "name", this.name || null);
339
+ this.#input.value = this.value;
340
+
341
+ const list = this.#list;
342
+
343
+ list.options = options;
344
+ list.value = this.value;
345
+ list.label = this.label;
346
+ list.search =
347
+ this.search === "always" ||
348
+ (this.search !== "never" && options.length >= searchFrom);
349
+
350
+ this.#hint.textContent = this.hint;
351
+ this.#hint.hidden = !this.hint;
352
+ }
353
+ }
354
+
355
+ GwSelect.define("gw-select");
356
+
357
+ declare global {
358
+ interface HTMLElementTagNameMap {
359
+ "gw-select": GwSelect;
360
+ }
361
+ }