@timmo001/groundwork-ui 0.0.0-stage → 0.0.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,386 @@
1
+ /**
2
+ * `gw-menu-button`: a button that opens a menu of items and actions.
3
+ * Docs: https://groundwork.timmo.dev/components/actions/#menu-button
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base, createIcon, setIconPath } from "./styles.js";
9
+
10
+ export interface MenuItem {
11
+ readonly value: string;
12
+ readonly label: string;
13
+ /** A second line, such as what the item does. */
14
+ readonly description?: string;
15
+ /** SVG path data for an icon before the label. */
16
+ readonly icon?: string;
17
+ readonly disabled?: boolean;
18
+ }
19
+
20
+ /**
21
+ * A button that opens a menu on the Popover API, so it shows in the top
22
+ * layer and closes on Esc or a click outside. Set `items`; arrow keys, Home
23
+ * and End move between them, and choosing one fires `select` with its
24
+ * `value` and closes the menu.
25
+ *
26
+ * Contextual actions, such as "Add a schedule", go in `actions`: they stay
27
+ * below the items as the list scrolls, so they're found without scrolling to
28
+ * the end, and with no items the menu says `empty` above them.
29
+ *
30
+ * Items render inside the component, so a page's own button styles can't
31
+ * reach them.
32
+ *
33
+ * @element gw-menu-button
34
+ * @summary A button that opens a menu of items and actions.
35
+ *
36
+ * @attr {string} label - The trigger's text.
37
+ * @attr {string} icon - SVG path data for an icon before the trigger's text.
38
+ * @attr {string} empty - What the menu says with no items. Defaults to `Nothing here yet`.
39
+ * @attr {MenuItem[]} items - The items, as JSON.
40
+ * @attr {MenuItem[]} actions - Actions pinned under the items, as JSON.
41
+ *
42
+ * @csspart trigger - The button that opens the menu.
43
+ *
44
+ * @fires select - When an item or action is chosen, with its `value` in `detail`.
45
+ */
46
+ export class GwMenuButton extends GwElement {
47
+ static override properties = {
48
+ label: {},
49
+ icon: {},
50
+ empty: {},
51
+ items: { type: "json" },
52
+ actions: { type: "json" },
53
+ } as const;
54
+
55
+ static override styles = [
56
+ base,
57
+ css`
58
+ :host {
59
+ position: relative;
60
+ display: inline-block;
61
+ anchor-scope: --gw-menu;
62
+ }
63
+
64
+ .trigger {
65
+ display: inline-flex;
66
+ gap: var(--gw-space-2);
67
+ align-items: center;
68
+ box-sizing: border-box;
69
+ height: 2.5rem;
70
+ padding: 0 var(--gw-space-4);
71
+ font: inherit;
72
+ font-size: var(--gw-font-size-s);
73
+ font-weight: var(--gw-font-weight-strong);
74
+ color: var(--gw-color-text-strong);
75
+ cursor: pointer;
76
+ background: var(--gw-color-fill-neutral-resting);
77
+ border: var(--gw-border-width) solid var(--gw-border-color);
78
+ border-radius: var(--gw-radius-control);
79
+ transition: background-color var(--gw-motion-fast)
80
+ var(--gw-motion-easing);
81
+ anchor-name: --gw-menu;
82
+ }
83
+
84
+ .trigger:hover {
85
+ background: var(--gw-color-fill-neutral-hovered);
86
+ }
87
+
88
+ .chevron {
89
+ width: 0.4rem;
90
+ height: 0.4rem;
91
+ margin-inline-start: var(--gw-space-1);
92
+ border-inline-end: 1.5px solid currentColor;
93
+ border-block-end: 1.5px solid currentColor;
94
+ rotate: 45deg;
95
+ translate: 0 -0.125rem;
96
+ transition:
97
+ rotate var(--gw-motion-normal) var(--gw-motion-easing),
98
+ translate var(--gw-motion-normal) var(--gw-motion-easing);
99
+ }
100
+
101
+ .trigger[aria-expanded="true"] .chevron {
102
+ rotate: 225deg;
103
+ translate: 0 0.125rem;
104
+ }
105
+
106
+ [popover] {
107
+ position-anchor: --gw-menu;
108
+ position-try-fallbacks: flip-block, flip-inline;
109
+ inset: auto;
110
+ top: anchor(bottom);
111
+ left: anchor(left);
112
+ box-sizing: border-box;
113
+ min-width: 15rem;
114
+ max-height: min(24rem, 60vh);
115
+ padding: 0;
116
+ margin: var(--gw-space-1) 0 0;
117
+ overflow: hidden;
118
+ color: var(--gw-color-text-primary);
119
+ background: var(--gw-color-surface-overlay);
120
+ border: max(var(--gw-border-width), 1px) solid
121
+ var(--gw-color-border-normal);
122
+ border-radius: var(--gw-radius-l);
123
+ box-shadow: var(--gw-shadow-m);
124
+ opacity: 0;
125
+ translate: 0 -0.25rem;
126
+ transition:
127
+ opacity var(--gw-motion-fast) var(--gw-motion-easing),
128
+ translate var(--gw-motion-normal) var(--gw-motion-easing),
129
+ display var(--gw-motion-normal) allow-discrete,
130
+ overlay var(--gw-motion-normal) allow-discrete;
131
+ }
132
+
133
+ [popover]:popover-open {
134
+ display: flex;
135
+ flex-direction: column;
136
+ opacity: 1;
137
+ translate: 0 0;
138
+ }
139
+
140
+ @starting-style {
141
+ [popover]:popover-open {
142
+ opacity: 0;
143
+ translate: 0 -0.25rem;
144
+ }
145
+ }
146
+
147
+ /* The items scroll; the actions stay put below them, so "Add" is always
148
+ in reach rather than at the end of a long list */
149
+ .items,
150
+ .actions {
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: var(--gw-space-0-5);
154
+ padding: var(--gw-space-1);
155
+ }
156
+
157
+ .items {
158
+ flex: 1;
159
+ min-height: 0;
160
+ overflow-y: auto;
161
+ }
162
+
163
+ .actions {
164
+ border-block-start: max(var(--gw-border-width), 1px) solid
165
+ var(--gw-color-border-normal);
166
+ }
167
+
168
+ .empty {
169
+ padding: var(--gw-space-3);
170
+ margin: 0;
171
+ font-size: var(--gw-font-size-s);
172
+ color: var(--gw-color-text-secondary);
173
+ }
174
+
175
+ .item {
176
+ display: flex;
177
+ gap: var(--gw-space-3);
178
+ align-items: center;
179
+ box-sizing: border-box;
180
+ min-height: 2.5rem;
181
+ padding: var(--gw-space-2) var(--gw-space-3);
182
+ font: inherit;
183
+ font-size: var(--gw-font-size-s);
184
+ color: var(--gw-color-text-primary);
185
+ text-align: start;
186
+ cursor: pointer;
187
+ background: transparent;
188
+ border: 0;
189
+ border-radius: var(--gw-radius-m);
190
+ transition: background-color var(--gw-motion-fast)
191
+ var(--gw-motion-easing);
192
+ --gw-icon-size: 1.125rem;
193
+ }
194
+
195
+ .item:hover,
196
+ .item:focus-visible {
197
+ background: var(--gw-color-fill-neutral-hovered);
198
+ outline: none;
199
+ }
200
+
201
+ .item:focus-visible {
202
+ box-shadow: inset 0 0 0 var(--gw-focus-width) var(--gw-color-focus);
203
+ }
204
+
205
+ .item:disabled {
206
+ color: var(--gw-color-text-disabled);
207
+ cursor: not-allowed;
208
+ background: transparent;
209
+ }
210
+
211
+ .action {
212
+ font-weight: var(--gw-font-weight-strong);
213
+ color: var(--gw-color-accent);
214
+ }
215
+
216
+ .text {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: var(--gw-space-0-5);
220
+ }
221
+
222
+ .description {
223
+ font-size: var(--gw-font-size-xs);
224
+ color: var(--gw-color-text-secondary);
225
+ }
226
+ `,
227
+ ];
228
+
229
+ declare label: string;
230
+ /** SVG path data for an icon before the trigger's label. */
231
+ declare icon: string;
232
+ /** What the menu says when it has no items, such as "No backups yet". */
233
+ declare empty: string;
234
+ declare items: readonly MenuItem[];
235
+ declare actions: readonly MenuItem[];
236
+
237
+ readonly #trigger = h("button", {
238
+ class: "trigger",
239
+ part: "trigger",
240
+ popovertarget: "menu",
241
+ "aria-haspopup": "menu",
242
+ "aria-expanded": "false",
243
+ });
244
+
245
+ readonly #triggerText = document.createTextNode("");
246
+
247
+ readonly #triggerIcon = createIcon("");
248
+
249
+ readonly #items = h("div", { class: "items", role: "none" });
250
+
251
+ readonly #actions = h("div", { class: "actions", role: "none" });
252
+
253
+ readonly #popover = h(
254
+ "div",
255
+ { id: "menu", popover: "auto", role: "menu" },
256
+ this.#items,
257
+ this.#actions,
258
+ );
259
+
260
+ constructor() {
261
+ super();
262
+ this.label = "";
263
+ this.icon = "";
264
+ this.empty = "Nothing here yet";
265
+ this.items = [];
266
+ this.actions = [];
267
+ }
268
+
269
+ /** The enabled items then actions, in the order arrow keys move through them. */
270
+ get #buttons() {
271
+ return [...this.#popover.querySelectorAll("button")].filter(
272
+ (button) => !button.disabled,
273
+ );
274
+ }
275
+
276
+ readonly #onToggle = (event: ToggleEvent) => {
277
+ const expanded = event.newState === "open";
278
+
279
+ this.#trigger.setAttribute("aria-expanded", String(expanded));
280
+
281
+ if (expanded) this.#buttons[0]?.focus();
282
+ };
283
+
284
+ readonly #onKeydown = (event: KeyboardEvent) => {
285
+ const buttons = this.#buttons;
286
+
287
+ const index = buttons.findIndex(
288
+ (button) => button === this.shadowRoot?.activeElement,
289
+ );
290
+
291
+ const next = {
292
+ ArrowDown: (index + 1) % buttons.length,
293
+ ArrowUp: (index - 1 + buttons.length) % buttons.length,
294
+ Home: 0,
295
+ End: buttons.length - 1,
296
+ }[event.key];
297
+
298
+ if (next === undefined) return;
299
+
300
+ event.preventDefault();
301
+ buttons[next]?.focus();
302
+ };
303
+
304
+ #choose(value: string) {
305
+ this.dispatchEvent(
306
+ new CustomEvent("select", {
307
+ detail: { value },
308
+ bubbles: true,
309
+ composed: true,
310
+ }),
311
+ );
312
+
313
+ this.#popover.hidePopover();
314
+ this.#trigger.focus();
315
+ }
316
+
317
+ #renderItem(item: MenuItem, kind: "item" | "action") {
318
+ const button = h(
319
+ "button",
320
+ {
321
+ class: kind === "action" ? "item action" : "item",
322
+ role: "menuitem",
323
+ value: item.value,
324
+ disabled: item.disabled === true,
325
+ },
326
+ item.icon ? createIcon(item.icon) : null,
327
+ h(
328
+ "span",
329
+ { class: "text" },
330
+ h("span", {}, item.label),
331
+ item.description
332
+ ? h("span", { class: "description" }, item.description)
333
+ : null,
334
+ ),
335
+ );
336
+
337
+ button.addEventListener("click", () => this.#choose(item.value));
338
+
339
+ return button;
340
+ }
341
+
342
+ protected override create(root: ShadowRoot) {
343
+ this.#trigger.append(
344
+ this.#triggerIcon,
345
+ this.#triggerText,
346
+ h("span", { class: "chevron", "aria-hidden": "true" }),
347
+ );
348
+ this.#popover.addEventListener("toggle", this.#onToggle);
349
+ this.#popover.addEventListener("keydown", this.#onKeydown);
350
+ root.append(this.#trigger, this.#popover);
351
+ }
352
+
353
+ protected override update(changed: ReadonlySet<string>) {
354
+ this.#triggerText.data = this.label;
355
+ setIconPath(this.#triggerIcon, this.icon);
356
+ this.#triggerIcon.toggleAttribute("hidden", !this.icon);
357
+ this.#popover.setAttribute("aria-label", this.label);
358
+
359
+ if (changed.has("items") || changed.has("empty")) {
360
+ const items = Array.isArray(this.items) ? this.items : [];
361
+
362
+ this.#items.replaceChildren(
363
+ ...(items.length === 0
364
+ ? [h("p", { class: "empty", role: "none" }, this.empty)]
365
+ : items.map((item) => this.#renderItem(item, "item"))),
366
+ );
367
+ }
368
+
369
+ if (changed.has("actions")) {
370
+ const actions = Array.isArray(this.actions) ? this.actions : [];
371
+
372
+ this.#actions.replaceChildren(
373
+ ...actions.map((item) => this.#renderItem(item, "action")),
374
+ );
375
+ this.#actions.hidden = actions.length === 0;
376
+ }
377
+ }
378
+ }
379
+
380
+ GwMenuButton.define("gw-menu-button");
381
+
382
+ declare global {
383
+ interface HTMLElementTagNameMap {
384
+ "gw-menu-button": GwMenuButton;
385
+ }
386
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `gw-meter`: a labeled bar for a value in a range.
3
+ * Docs: https://groundwork.timmo.dev/components/display/#meter
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h, setAttribute } from "./element.js";
8
+ import "./gw-skeleton.js";
9
+ import { fadeIn } from "./motion.js";
10
+ import { base, type Tone } from "./styles.js";
11
+
12
+ /**
13
+ * A labeled bar for a value in a range, such as disk use. Past `high` it
14
+ * turns warning, and past `critical` danger, unless a `tone` is set. While
15
+ * `loading`, the track pulses and a skeleton stands in for the value.
16
+ *
17
+ * @element gw-meter
18
+ * @summary A labeled bar for a value in a range.
19
+ *
20
+ * @attr {string} label - What it measures.
21
+ * @attr {number} value - The value. Defaults to 0.
22
+ * @attr {number} min - The bottom of the range. Defaults to 0.
23
+ * @attr {number} max - The top of the range. Defaults to 100.
24
+ * @attr {number} high - Past this it turns warning.
25
+ * @attr {number} critical - Past this it turns danger.
26
+ * @attr {string} display - The value as shown, such as `42 GB`. Defaults to a percentage.
27
+ * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - A fixed color, overriding `high` and `critical`.
28
+ * @attr {boolean} loading - Pulses the track and shows a skeleton for the value.
29
+ */
30
+ export class GwMeter extends GwElement {
31
+ static override properties = {
32
+ label: {},
33
+ value: { type: "number" },
34
+ min: { type: "number" },
35
+ max: { type: "number" },
36
+ high: { type: "number" },
37
+ critical: { type: "number" },
38
+ display: {},
39
+ tone: { reflect: true },
40
+ loading: { type: "boolean", reflect: true },
41
+ } as const;
42
+
43
+ static override styles = [
44
+ base,
45
+ css`
46
+ :host {
47
+ display: block;
48
+ }
49
+
50
+ .top {
51
+ display: flex;
52
+ gap: var(--gw-space-2);
53
+ justify-content: space-between;
54
+ margin-block-end: var(--gw-space-1);
55
+ font-size: var(--gw-font-size-s);
56
+ }
57
+
58
+ .value {
59
+ font-variant-numeric: var(--gw-font-figures);
60
+ color: var(--gw-color-text-strong);
61
+ }
62
+
63
+ .track {
64
+ height: 0.5rem;
65
+ overflow: hidden;
66
+ background: var(--gw-color-surface-lower);
67
+ border: var(--gw-border-width) solid var(--gw-color-border-quiet);
68
+ border-radius: var(--gw-radius-pill);
69
+ }
70
+
71
+ .fill {
72
+ height: 100%;
73
+ background: var(--gw-meter-color, var(--gw-color-fill-accent-resting));
74
+ border-radius: inherit;
75
+ transition: width var(--gw-motion-slow) var(--gw-motion-easing);
76
+ }
77
+
78
+ .bar {
79
+ min-height: 0;
80
+ height: 0.5rem;
81
+ --gw-skeleton-radius: var(--gw-radius-pill);
82
+ }
83
+ `,
84
+ ];
85
+
86
+ declare label: string;
87
+ declare value: number;
88
+ declare min: number;
89
+ declare max: number;
90
+ declare high: number | undefined;
91
+ declare critical: number | undefined;
92
+ declare display: string;
93
+ declare tone: Tone | undefined;
94
+ declare loading: boolean;
95
+
96
+ readonly #top = h("div", { class: "top" });
97
+
98
+ readonly #label = h("span");
99
+
100
+ readonly #labelSkeleton = h("gw-skeleton-text", {
101
+ style: "--gw-skeleton-width: 6rem",
102
+ });
103
+
104
+ readonly #value = h("span", { class: "value", "aria-hidden": "true" });
105
+
106
+ readonly #valueSkeleton = h("gw-skeleton-text", {
107
+ style: "--gw-skeleton-width: 2.5rem",
108
+ });
109
+
110
+ readonly #fill = h("div", { class: "fill" });
111
+
112
+ readonly #track = h("div", { class: "track", role: "meter" }, this.#fill);
113
+
114
+ readonly #bar = h("gw-skeleton", { class: "bar" });
115
+
116
+ #rendered = false;
117
+
118
+ constructor() {
119
+ super();
120
+ this.label = "";
121
+ this.value = 0;
122
+ this.min = 0;
123
+ this.max = 100;
124
+ this.high = undefined;
125
+ this.critical = undefined;
126
+ this.display = "";
127
+ this.tone = undefined;
128
+ this.loading = false;
129
+ }
130
+
131
+ get #color() {
132
+ if (this.tone !== undefined) return "var(--gw-tone)";
133
+
134
+ if (this.critical !== undefined && this.value >= this.critical) {
135
+ return "var(--gw-color-danger)";
136
+ }
137
+
138
+ if (this.high !== undefined && this.value >= this.high) {
139
+ return "var(--gw-color-warning)";
140
+ }
141
+
142
+ return "var(--gw-color-fill-accent-resting)";
143
+ }
144
+
145
+ protected override create(root: ShadowRoot) {
146
+ this.#top.append(
147
+ h("span", {}, this.#label, this.#labelSkeleton),
148
+ this.#value,
149
+ this.#valueSkeleton,
150
+ );
151
+ root.append(this.#top, this.#track, this.#bar);
152
+ }
153
+
154
+ protected override update(changed: ReadonlySet<string>) {
155
+ const span = this.max - this.min || 1;
156
+
157
+ const percent = Math.min(
158
+ 100,
159
+ Math.max(0, ((this.value - this.min) / span) * 100),
160
+ );
161
+
162
+ const text = this.display || `${Math.round(percent)}%`;
163
+
164
+ setAttribute(this.#top, "aria-busy", this.loading ? "true" : null);
165
+ this.#label.textContent = this.label;
166
+ this.#labelSkeleton.hidden = !this.loading || this.label !== "";
167
+ this.#value.textContent = text;
168
+ this.#value.hidden = this.loading;
169
+ this.#valueSkeleton.hidden = !this.loading;
170
+ this.#track.hidden = this.loading;
171
+ this.#bar.hidden = !this.loading;
172
+ setAttribute(this.#track, "aria-label", this.label || null);
173
+ setAttribute(this.#track, "aria-valuemin", this.min);
174
+ setAttribute(this.#track, "aria-valuemax", this.max);
175
+ setAttribute(this.#track, "aria-valuenow", this.value);
176
+ setAttribute(this.#track, "aria-valuetext", text);
177
+ this.#fill.style.width = `${percent}%`;
178
+ this.#fill.style.setProperty("--gw-meter-color", this.#color);
179
+
180
+ if (this.#rendered && changed.has("loading") && !this.loading) {
181
+ fadeIn(this.#value);
182
+ }
183
+
184
+ this.#rendered = true;
185
+ }
186
+ }
187
+
188
+ GwMeter.define("gw-meter");
189
+
190
+ declare global {
191
+ interface HTMLElementTagNameMap {
192
+ "gw-meter": GwMeter;
193
+ }
194
+ }