@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.
package/src/gw-card.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `gw-card`: a surface for one group of content.
3
+ * Docs: https://groundwork.timmo.dev/components/display/#card
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h, setAttribute } from "./element.js";
8
+ import "./gw-skeleton.js";
9
+ import { animateHeight, fadeIn } from "./motion.js";
10
+ import { base } from "./styles.js";
11
+
12
+ /**
13
+ * A surface for one group of content, with an optional heading and actions.
14
+ * With `href` the whole card is a link. While `loading`, lines of skeleton
15
+ * stand in for the content, and for the heading until it's known. The card
16
+ * glides to its new height and the content fades in once it's loaded.
17
+ *
18
+ * @element gw-card
19
+ * @summary A surface for one group of content, or a link to more.
20
+ *
21
+ * @attr {string} heading - The card's heading.
22
+ * @attr {string} href - Makes the whole card a link to this address.
23
+ * @attr {boolean} flush - Drops the padding, for content that runs to the edges.
24
+ * @attr {boolean} loading - Shows skeleton lines for the content and any unknown heading.
25
+ * @attr {number} lines - How many skeleton lines while loading. Defaults to 3.
26
+ *
27
+ * @slot - The content.
28
+ * @slot actions - Buttons beside the heading.
29
+ *
30
+ * @csspart card - The card's surface.
31
+ */
32
+ export class GwCard extends GwElement {
33
+ static override properties = {
34
+ heading: {},
35
+ href: {},
36
+ flush: { type: "boolean", reflect: true },
37
+ loading: { type: "boolean", reflect: true },
38
+ lines: { type: "number" },
39
+ } as const;
40
+
41
+ static override styles = [
42
+ base,
43
+ css`
44
+ :host {
45
+ display: block;
46
+ }
47
+
48
+ .card {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: var(--gw-space-3);
52
+ height: 100%;
53
+ padding: var(--gw-space-4);
54
+ color: inherit;
55
+ text-decoration: none;
56
+ background: var(--gw-color-surface-raised);
57
+ border: var(--gw-border-width) solid var(--gw-color-border-normal);
58
+ border-radius: var(--gw-radius-panel);
59
+ box-shadow: var(--gw-shadow-s);
60
+ }
61
+
62
+ :host([flush]) .card {
63
+ padding: 0;
64
+ overflow: hidden;
65
+ }
66
+
67
+ a.card {
68
+ transition: border-color var(--gw-motion-fast) var(--gw-motion-easing);
69
+ }
70
+
71
+ a.card:hover {
72
+ border-color: var(--gw-color-border-accent);
73
+ }
74
+
75
+ header {
76
+ display: flex;
77
+ gap: var(--gw-space-2);
78
+ align-items: center;
79
+ }
80
+
81
+ h2 {
82
+ flex: 1;
83
+ margin: 0;
84
+ font-size: var(--gw-font-size-m);
85
+ font-weight: var(--gw-font-weight-strong);
86
+ color: var(--gw-color-text-strong);
87
+ }
88
+
89
+ .body:not([hidden]) {
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: inherit;
93
+ }
94
+ `,
95
+ ];
96
+
97
+ declare heading: string;
98
+ declare href: string;
99
+ declare flush: boolean;
100
+ declare loading: boolean;
101
+ /** How many lines of skeleton stand in for the content while loading. */
102
+ declare lines: number;
103
+
104
+ readonly #title = h("span");
105
+
106
+ readonly #titleSkeleton = h("gw-skeleton-text");
107
+
108
+ readonly #header = h(
109
+ "header",
110
+ {},
111
+ h("h2", {}, this.#title, this.#titleSkeleton),
112
+ h("slot", { name: "actions" }),
113
+ );
114
+
115
+ readonly #skeleton = h("gw-skeleton-lines");
116
+
117
+ readonly #body = h("div", { class: "body" }, h("slot"));
118
+
119
+ #card: HTMLElement = h("section", { class: "card", part: "card" });
120
+
121
+ #wasLoading = false;
122
+
123
+ #rendered = false;
124
+
125
+ constructor() {
126
+ super();
127
+ this.heading = "";
128
+ this.href = "";
129
+ this.flush = false;
130
+ this.loading = false;
131
+ this.lines = 3;
132
+ }
133
+
134
+ protected override create(root: ShadowRoot) {
135
+ this.#card.append(this.#header, this.#skeleton, this.#body);
136
+ root.append(this.#card);
137
+ }
138
+
139
+ protected override update(changed: ReadonlySet<string>) {
140
+ const animate =
141
+ changed.has("loading") &&
142
+ this.#rendered &&
143
+ this.#wasLoading !== this.loading;
144
+
145
+ const before = animate ? this.offsetHeight : 0;
146
+
147
+ if (
148
+ changed.has("href") &&
149
+ (this.#card.tagName === "A") !== Boolean(this.href)
150
+ ) {
151
+ const next = this.href
152
+ ? h("a", { class: "card", part: "card" })
153
+ : h("section", { class: "card", part: "card" });
154
+
155
+ next.append(...this.#card.childNodes);
156
+ this.#card.replaceWith(next);
157
+ this.#card = next;
158
+ }
159
+
160
+ if (this.#card instanceof HTMLAnchorElement) this.#card.href = this.href;
161
+
162
+ setAttribute(this.#card, "aria-busy", this.loading ? "true" : null);
163
+ this.#title.textContent = this.heading;
164
+ this.#titleSkeleton.hidden = this.heading !== "" || !this.loading;
165
+ this.#header.hidden = !this.heading && !this.loading;
166
+ this.#skeleton.lines = this.lines;
167
+ this.#skeleton.hidden = !this.loading;
168
+ this.#body.hidden = this.loading;
169
+
170
+ if (animate) {
171
+ animateHeight(this, before);
172
+
173
+ if (!this.loading) fadeIn(this.#body);
174
+ }
175
+
176
+ this.#wasLoading = this.loading;
177
+ this.#rendered = true;
178
+ }
179
+ }
180
+
181
+ GwCard.define("gw-card");
182
+
183
+ declare global {
184
+ interface HTMLElementTagNameMap {
185
+ "gw-card": GwCard;
186
+ }
187
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `gw-checkbox` and `gw-switch`: labeled native checkboxes in light DOM.
3
+ * Docs: https://groundwork.timmo.dev/components/inputs/#checkbox-and-switch
4
+ *
5
+ * @module
6
+ */
7
+ import { h, setAttribute } from "./element.js";
8
+ import { LightElement, fieldId } from "./fields.js";
9
+
10
+ /**
11
+ * A labeled native checkbox. `gw-switch` draws the same control as a switch.
12
+ *
13
+ * @element gw-checkbox
14
+ * @summary A labeled checkbox in light DOM, so forms see it.
15
+ *
16
+ * @attr {string} label - The label.
17
+ * @attr {string} name - The form field name.
18
+ * @attr {string} value - The value submitted when checked. Defaults to `on`.
19
+ * @attr {string} hint - One line of help under it.
20
+ * @attr {boolean} checked - Whether it's checked.
21
+ * @attr {boolean} disabled - Disables it.
22
+ * @attr {boolean} label-hidden - Hides the label but keeps it for assistive technology.
23
+ */
24
+ export class GwCheckbox extends LightElement {
25
+ static override properties = {
26
+ label: {},
27
+ name: {},
28
+ value: {},
29
+ hint: {},
30
+ checked: { type: "boolean" },
31
+ disabled: { type: "boolean" },
32
+ labelHidden: { type: "boolean", attribute: "label-hidden" },
33
+ } as const;
34
+
35
+ declare label: string;
36
+ /** Keeps the label for assistive technology but hides it, such as in a setting row. */
37
+ declare labelHidden: boolean;
38
+ declare name: string;
39
+ declare value: string;
40
+ declare hint: string;
41
+ declare checked: boolean;
42
+ declare disabled: boolean;
43
+
44
+ readonly #id = fieldId();
45
+
46
+ readonly #input = h("input", { type: "checkbox" });
47
+
48
+ readonly #text = h("span");
49
+
50
+ readonly #hint = h("p", { class: "gw-hint", id: `${this.#id}-hint` });
51
+
52
+ /** `switch` for `gw-switch`, so assistive technology announces it as one. */
53
+ protected get controlRole(): string | undefined {
54
+ return undefined;
55
+ }
56
+
57
+ constructor() {
58
+ super();
59
+ this.label = "";
60
+ this.name = "";
61
+ this.value = "on";
62
+ this.hint = "";
63
+ this.checked = false;
64
+ this.disabled = false;
65
+ this.labelHidden = false;
66
+ }
67
+
68
+ protected override create(root: HTMLElement) {
69
+ setAttribute(this.#input, "role", this.controlRole);
70
+ this.#input.addEventListener("change", () => {
71
+ this.checked = this.#input.checked;
72
+ });
73
+ root.append(h("label", {}, this.#input, this.#text), this.#hint);
74
+ }
75
+
76
+ protected override update() {
77
+ const input = this.#input;
78
+
79
+ setAttribute(input, "name", this.name || null);
80
+ input.value = this.value;
81
+ setAttribute(input, "aria-describedby", this.hint ? this.#hint.id : null);
82
+ input.disabled = this.disabled;
83
+ input.checked = this.checked;
84
+ this.#text.textContent = this.label;
85
+ this.#text.className = this.labelHidden ? "gw-visually-hidden" : "";
86
+ this.#hint.textContent = this.hint;
87
+ this.#hint.hidden = !this.hint;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * A checkbox drawn as a switch, announced as a switch. It takes the same
93
+ * attributes as `gw-checkbox`.
94
+ *
95
+ * @element gw-switch
96
+ * @summary A labeled switch for a setting that applies at once.
97
+ */
98
+ export class GwSwitch extends GwCheckbox {
99
+ protected override get controlRole() {
100
+ return "switch";
101
+ }
102
+ }
103
+
104
+ GwCheckbox.define("gw-checkbox");
105
+
106
+ GwSwitch.define("gw-switch");
107
+
108
+ declare global {
109
+ interface HTMLElementTagNameMap {
110
+ "gw-checkbox": GwCheckbox;
111
+ "gw-switch": GwSwitch;
112
+ }
113
+ }
package/src/gw-chip.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `gw-chip`: a short status or category, tinted by its tone.
3
+ * Docs: https://groundwork.timmo.dev/components/display/#chip-and-dot
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, createIcon, setIconPath, type Tone } from "./styles.js";
11
+
12
+ /**
13
+ * A short status or category, tinted by its `tone`. While `loading`, a
14
+ * skeleton stands in for its text, and the text fades in once it's loaded.
15
+ *
16
+ * @element gw-chip
17
+ * @summary A short status or category, tinted by its tone.
18
+ *
19
+ * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The tint. Defaults to `neutral`.
20
+ * @attr {string} icon - SVG path data for an icon before the text.
21
+ * @attr {boolean} loading - Shows a skeleton for the text.
22
+ *
23
+ * @slot - The text.
24
+ * @slot icon - An icon before the text, in place of `icon`.
25
+ *
26
+ * @csspart chip - The chip.
27
+ */
28
+ export class GwChip extends GwElement {
29
+ static override properties = {
30
+ tone: { reflect: true },
31
+ icon: {},
32
+ loading: { type: "boolean", reflect: true },
33
+ } as const;
34
+
35
+ static override styles = [
36
+ base,
37
+ css`
38
+ :host {
39
+ display: inline-flex;
40
+ }
41
+
42
+ /* 32px, with 12px padding */
43
+ .chip {
44
+ display: inline-flex;
45
+ gap: var(--gw-space-2);
46
+ align-items: center;
47
+ box-sizing: border-box;
48
+ height: 2rem;
49
+ padding: 0 var(--gw-space-3);
50
+ font-size: var(--gw-font-size-xs);
51
+ font-weight: var(--gw-font-weight-strong);
52
+ color: var(--gw-color-text-strong);
53
+ background: var(--gw-tone-fill, var(--gw-color-fill-neutral-resting));
54
+ border: var(--gw-border-width) solid
55
+ var(--gw-tone-border, var(--gw-color-border-quiet));
56
+ border-radius: var(--gw-radius-chip);
57
+ --gw-icon-size: 1rem;
58
+ }
59
+
60
+ .content {
61
+ display: inline-flex;
62
+ gap: inherit;
63
+ align-items: center;
64
+ }
65
+ `,
66
+ ];
67
+
68
+ declare tone: Tone;
69
+ /** SVG path data for an icon before the text, instead of the `icon` slot. */
70
+ declare icon: string;
71
+ declare loading: boolean;
72
+
73
+ readonly #icon = createIcon("");
74
+
75
+ readonly #chip = h("span", { class: "chip", part: "chip" });
76
+
77
+ readonly #skeleton = h("gw-skeleton-text", {
78
+ style: "--gw-skeleton-width: 3.5rem",
79
+ });
80
+
81
+ readonly #content = h(
82
+ "span",
83
+ { class: "content" },
84
+ h("slot", { name: "icon" }, this.#icon),
85
+ h("slot"),
86
+ );
87
+
88
+ constructor() {
89
+ super();
90
+ this.tone = "neutral";
91
+ this.icon = "";
92
+ this.loading = false;
93
+ }
94
+
95
+ protected override create(root: ShadowRoot) {
96
+ this.#chip.append(this.#skeleton, this.#content);
97
+ root.append(this.#chip);
98
+ }
99
+
100
+ protected override update(changed: ReadonlySet<string>) {
101
+ const wasLoading = this.#content.hidden;
102
+
103
+ setIconPath(this.#icon, this.icon);
104
+ this.#icon.toggleAttribute("hidden", !this.icon);
105
+
106
+ setAttribute(this.#chip, "aria-busy", this.loading ? "true" : null);
107
+ this.#skeleton.hidden = !this.loading;
108
+ this.#content.hidden = this.loading;
109
+
110
+ if (changed.has("loading") && wasLoading && !this.loading) {
111
+ fadeIn(this.#content);
112
+ }
113
+ }
114
+ }
115
+
116
+ GwChip.define("gw-chip");
117
+
118
+ declare global {
119
+ interface HTMLElementTagNameMap {
120
+ "gw-chip": GwChip;
121
+ }
122
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * `gw-command-menu`: a searchable list of commands on Cmd+K or Ctrl+K.
3
+ * Docs: https://groundwork.timmo.dev/components/overlays/#command-menu
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import "./gw-option-list.js";
9
+ import type { OptionItem } from "./gw-option-list.js";
10
+ import { base } from "./styles.js";
11
+
12
+ /** Below this width the menu is a bottom sheet. */
13
+ const sheetBelow = 600;
14
+
15
+ /**
16
+ * A command menu on Cmd+K or Ctrl+K: a searchable list of commands in a
17
+ * modal near the top of the page, or a bottom sheet on phones. Picking one
18
+ * closes the menu and fires `pick` with its value. Esc or a click outside
19
+ * closes it. It won't open over another dialog, so only one overlay shows at
20
+ * a time. Set `shortcut="none"` to open it only with `show()`.
21
+ *
22
+ * @element gw-command-menu
23
+ * @summary A searchable list of commands on Cmd+K or Ctrl+K.
24
+ *
25
+ * @attr {OptionItem[]} options - The commands, as JSON.
26
+ * @attr {string} label - The accessible name. Defaults to `Commands`.
27
+ * @attr {string} placeholder - The search placeholder. Defaults to `Search commands`.
28
+ * @attr {string} shortcut - The key that opens it with Cmd or Ctrl, such as `mod+k`, or `none`. Defaults to `mod+k`.
29
+ * @attr {boolean} open - Whether it shows.
30
+ *
31
+ * @fires pick - When a command is picked, with its `value` in `detail`.
32
+ */
33
+ export class GwCommandMenu extends GwElement {
34
+ static override properties = {
35
+ options: { type: "json" },
36
+ label: {},
37
+ placeholder: {},
38
+ shortcut: {},
39
+ open: { type: "boolean", reflect: true },
40
+ } as const;
41
+
42
+ static override styles = [
43
+ base,
44
+ css`
45
+ dialog {
46
+ top: 12vh;
47
+ width: min(36rem, calc(100vw - 2 * var(--gw-space-3)));
48
+ max-height: 70vh;
49
+ padding: 0;
50
+ margin-block: 0;
51
+ overflow: hidden;
52
+ color: var(--gw-color-text-primary);
53
+ background: var(--gw-color-surface-overlay);
54
+ border: max(var(--gw-border-width), 1px) solid
55
+ var(--gw-color-border-normal);
56
+ border-radius: var(--gw-radius-panel);
57
+ box-shadow: var(--gw-shadow-l);
58
+ --gw-option-list-height: min(24rem, 56vh);
59
+ }
60
+
61
+ dialog::backdrop {
62
+ background: rgb(0 0 0 / 50%);
63
+ }
64
+
65
+ dialog,
66
+ dialog::backdrop {
67
+ opacity: 0;
68
+ transition:
69
+ opacity var(--gw-motion-normal) var(--gw-motion-easing),
70
+ scale var(--gw-motion-normal) var(--gw-motion-easing),
71
+ translate var(--gw-motion-normal) var(--gw-motion-easing),
72
+ display var(--gw-motion-normal) allow-discrete,
73
+ overlay var(--gw-motion-normal) allow-discrete;
74
+ }
75
+
76
+ dialog {
77
+ scale: 0.96;
78
+ }
79
+
80
+ dialog[open],
81
+ dialog[open]::backdrop {
82
+ opacity: 1;
83
+ }
84
+
85
+ dialog[open] {
86
+ scale: 1;
87
+ }
88
+
89
+ @starting-style {
90
+ dialog[open],
91
+ dialog[open]::backdrop {
92
+ opacity: 0;
93
+ }
94
+
95
+ dialog[open] {
96
+ scale: 0.96;
97
+ }
98
+ }
99
+
100
+ dialog[data-placement="sheet"] {
101
+ inset: auto 0 0;
102
+ width: 100%;
103
+ max-width: none;
104
+ padding-block-end: env(safe-area-inset-bottom);
105
+ border-end-start-radius: 0;
106
+ border-end-end-radius: 0;
107
+ scale: 1;
108
+ translate: 0 100%;
109
+ --gw-option-list-height: 60vh;
110
+ }
111
+
112
+ dialog[data-placement="sheet"][open] {
113
+ opacity: 1;
114
+ translate: 0 0;
115
+ }
116
+
117
+ @starting-style {
118
+ dialog[data-placement="sheet"][open] {
119
+ translate: 0 100%;
120
+ }
121
+ }
122
+
123
+ footer {
124
+ display: flex;
125
+ gap: var(--gw-space-3);
126
+ padding: var(--gw-space-2) var(--gw-space-3);
127
+ font-size: var(--gw-font-size-xs);
128
+ color: var(--gw-color-text-secondary);
129
+ border-block-start: var(--gw-border-width) solid
130
+ var(--gw-color-border-quiet);
131
+ }
132
+
133
+ kbd {
134
+ padding: 0 var(--gw-space-1);
135
+ font: inherit;
136
+ border: 1px solid var(--gw-color-border-normal);
137
+ border-radius: var(--gw-radius-control);
138
+ }
139
+
140
+ dialog[data-placement="sheet"] footer {
141
+ display: none;
142
+ }
143
+ `,
144
+ ];
145
+
146
+ declare options: readonly OptionItem[];
147
+ declare label: string;
148
+ declare placeholder: string;
149
+ /** `mod+k` (the default) opens it on Cmd+K or Ctrl+K; `none` turns that off. */
150
+ declare shortcut: string;
151
+ declare open: boolean;
152
+
153
+ readonly #list = h("gw-option-list", { search: true });
154
+
155
+ readonly #dialog = h(
156
+ "dialog",
157
+ {},
158
+ this.#list,
159
+ h(
160
+ "footer",
161
+ { "aria-hidden": "true" },
162
+ h("span", {}, h("kbd", {}, "↑"), " ", h("kbd", {}, "↓"), " to move"),
163
+ h("span", {}, h("kbd", {}, "Enter"), " to pick"),
164
+ h("span", {}, h("kbd", {}, "Esc"), " to close"),
165
+ ),
166
+ );
167
+
168
+ constructor() {
169
+ super();
170
+ this.options = [];
171
+ this.label = "Commands";
172
+ this.placeholder = "Search commands";
173
+ this.shortcut = "mod+k";
174
+ this.open = false;
175
+ }
176
+
177
+ show() {
178
+ this.open = true;
179
+ }
180
+
181
+ close() {
182
+ this.open = false;
183
+ }
184
+
185
+ readonly #onShortcut = (event: KeyboardEvent) => {
186
+ if (
187
+ this.shortcut === "none" ||
188
+ event.defaultPrevented ||
189
+ event.key.toLowerCase() !== "k" ||
190
+ !(event.metaKey || event.ctrlKey) ||
191
+ event.altKey ||
192
+ event.shiftKey
193
+ ) {
194
+ return;
195
+ }
196
+
197
+ event.preventDefault();
198
+
199
+ if (this.open) {
200
+ this.close();
201
+
202
+ return;
203
+ }
204
+
205
+ // One overlay at a time: another open dialog keeps the page
206
+ if (
207
+ document.querySelector(
208
+ "dialog[open], gw-dialog[open], gw-command-menu[open]",
209
+ )
210
+ ) {
211
+ return;
212
+ }
213
+
214
+ this.show();
215
+ };
216
+
217
+ override connectedCallback() {
218
+ super.connectedCallback();
219
+ document.addEventListener("keydown", this.#onShortcut);
220
+ }
221
+
222
+ override disconnectedCallback() {
223
+ document.removeEventListener("keydown", this.#onShortcut);
224
+ super.disconnectedCallback();
225
+ }
226
+
227
+ protected override create(root: ShadowRoot) {
228
+ this.#dialog.addEventListener("close", () => {
229
+ this.open = false;
230
+ });
231
+ this.#dialog.addEventListener("click", (event) => {
232
+ if (event.target === this.#dialog) this.close();
233
+ });
234
+ this.#list.addEventListener("pick", (event) => {
235
+ event.stopPropagation();
236
+
237
+ const value =
238
+ event instanceof CustomEvent &&
239
+ event.detail !== null &&
240
+ "value" in event.detail
241
+ ? String(event.detail.value)
242
+ : "";
243
+
244
+ this.close();
245
+ this.dispatchEvent(
246
+ new CustomEvent("pick", {
247
+ detail: { value },
248
+ bubbles: true,
249
+ composed: true,
250
+ }),
251
+ );
252
+ });
253
+ root.append(this.#dialog);
254
+ }
255
+
256
+ protected override update() {
257
+ const list = this.#list;
258
+ const dialog = this.#dialog;
259
+
260
+ list.options = Array.isArray(this.options) ? this.options : [];
261
+ list.label = this.label;
262
+ list.placeholder = this.placeholder;
263
+ dialog.setAttribute("aria-label", this.label);
264
+
265
+ if (this.open && !dialog.open) {
266
+ dialog.dataset.placement =
267
+ window.innerWidth < sheetBelow ? "sheet" : "center";
268
+ dialog.showModal();
269
+ list.reset();
270
+ document.dispatchEvent(new Event("gw-dialog-open"));
271
+ }
272
+
273
+ if (!this.open && dialog.open) dialog.close();
274
+ }
275
+ }
276
+
277
+ GwCommandMenu.define("gw-command-menu");
278
+
279
+ declare global {
280
+ interface HTMLElementTagNameMap {
281
+ "gw-command-menu": GwCommandMenu;
282
+ }
283
+ }