@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,246 @@
1
+ /**
2
+ * `gw-dialog`: a modal on the native `<dialog>`, for what blocks the whole app.
3
+ * Docs: https://groundwork.timmo.dev/components/overlays/#dialog
4
+ *
5
+ * @module
6
+ */
7
+ import { Dismissal, dismissalStyles } from "./dismiss.js";
8
+ import { GwElement, css, h } from "./element.js";
9
+ import "./gw-button.js";
10
+ import { base } from "./styles.js";
11
+
12
+ // MDI's close
13
+ const closeIcon =
14
+ "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z";
15
+
16
+ /**
17
+ * A modal on the native `<dialog>`, so focus is trapped and returned, Esc
18
+ * closes it and the page behind is inert. Open it with `show()`. Put buttons
19
+ * in the `actions` slot; a click on one with a `value` closes the dialog with
20
+ * that value, and fires `close`.
21
+ *
22
+ * @element gw-dialog
23
+ * @summary A modal for what blocks the whole app.
24
+ *
25
+ * @attr {string} heading - The heading beside the close button.
26
+ * @attr {boolean} open - Whether it shows.
27
+ *
28
+ * @slot - The content.
29
+ * @slot actions - Buttons; one with a `value` closes it with that value.
30
+ *
31
+ * @fires close - When it closes; `returnValue` holds the button's value.
32
+ */
33
+ export class GwDialog extends GwElement {
34
+ static override properties = {
35
+ heading: {},
36
+ open: { type: "boolean", reflect: true },
37
+ } as const;
38
+
39
+ static override styles = [
40
+ base,
41
+ dismissalStyles,
42
+ css`
43
+ dialog {
44
+ width: min(32rem, calc(100vw - 2 * var(--gw-space-3)));
45
+ padding: 0;
46
+ color: var(--gw-color-text-primary);
47
+ background: var(--gw-color-surface-overlay);
48
+ border: var(--gw-border-width) solid var(--gw-color-border-normal);
49
+ border-radius: var(--gw-radius-panel);
50
+ box-shadow: var(--gw-shadow-l);
51
+ }
52
+
53
+ dialog::backdrop {
54
+ background: rgb(0 0 0 / 50%);
55
+ }
56
+
57
+ /* Fades and lifts in and out, the backdrop with it */
58
+ dialog,
59
+ dialog::backdrop {
60
+ transition:
61
+ opacity var(--gw-motion-normal) var(--gw-motion-easing),
62
+ scale var(--gw-motion-normal) var(--gw-motion-easing),
63
+ display var(--gw-motion-normal) allow-discrete,
64
+ overlay var(--gw-motion-normal) allow-discrete;
65
+ }
66
+
67
+ dialog,
68
+ dialog::backdrop {
69
+ opacity: 0;
70
+ }
71
+
72
+ dialog {
73
+ scale: 0.96;
74
+ }
75
+
76
+ dialog[open],
77
+ dialog[open]::backdrop {
78
+ opacity: 1;
79
+ }
80
+
81
+ dialog[open] {
82
+ scale: 1;
83
+ }
84
+
85
+ @starting-style {
86
+ dialog[open],
87
+ dialog[open]::backdrop {
88
+ opacity: 0;
89
+ }
90
+
91
+ dialog[open] {
92
+ scale: 0.96;
93
+ }
94
+ }
95
+
96
+ header {
97
+ display: flex;
98
+ gap: var(--gw-space-2);
99
+ align-items: center;
100
+ padding: var(--gw-space-3) var(--gw-space-3) 0 var(--gw-space-4);
101
+ }
102
+
103
+ h2 {
104
+ flex: 1;
105
+ margin: 0;
106
+ font-size: var(--gw-font-size-l);
107
+ font-weight: var(--gw-font-weight-strong);
108
+ color: var(--gw-color-text-strong);
109
+ }
110
+
111
+ .body {
112
+ padding: var(--gw-space-3) var(--gw-space-4);
113
+ font-size: var(--gw-font-size-s);
114
+ }
115
+
116
+ footer {
117
+ display: flex;
118
+ flex-wrap: wrap;
119
+ gap: var(--gw-space-1);
120
+ justify-content: flex-end;
121
+ padding: 0 var(--gw-space-4) var(--gw-space-4);
122
+ }
123
+
124
+ dialog > .discard {
125
+ padding: var(--gw-space-4);
126
+ }
127
+ `,
128
+ ];
129
+
130
+ declare heading: string;
131
+ declare open: boolean;
132
+
133
+ /** The value of the action that closed the dialog, or "" for Esc. */
134
+ returnValue = "";
135
+
136
+ constructor() {
137
+ super();
138
+ this.heading = "";
139
+ this.open = false;
140
+ }
141
+
142
+ readonly #heading = h("h2", { id: "heading" });
143
+
144
+ readonly #footer = h("footer", {}, h("slot", { name: "actions" }));
145
+
146
+ readonly #closeButton = h(
147
+ "gw-button",
148
+ {
149
+ class: "close",
150
+ variant: "neutral",
151
+ appearance: "plain",
152
+ size: "small",
153
+ labels: "hide",
154
+ icon: closeIcon,
155
+ },
156
+ "Close",
157
+ );
158
+
159
+ readonly #dialog = h(
160
+ "dialog",
161
+ { "aria-labelledby": "heading" },
162
+ h("header", {}, this.#heading, this.#closeButton),
163
+ h("div", { class: "body" }, h("slot")),
164
+ this.#footer,
165
+ );
166
+
167
+ readonly #dismissal = new Dismissal(this, this.#dialog, () => this.close());
168
+
169
+ /** Whether its content has changed since it opened, so closing it asks first. */
170
+ get dirty() {
171
+ return this.#dismissal.dirty;
172
+ }
173
+
174
+ set dirty(dirty: boolean) {
175
+ this.#dismissal.mark(dirty);
176
+ }
177
+
178
+ show() {
179
+ this.#dismissal.reset();
180
+ this.open = true;
181
+ }
182
+
183
+ close(value = "") {
184
+ this.returnValue = value;
185
+ this.open = false;
186
+ }
187
+
188
+ readonly #onAction = (event: Event) => {
189
+ const target = event.target;
190
+
191
+ if (target instanceof HTMLElement && target.hasAttribute("value")) {
192
+ this.close(target.getAttribute("value") ?? "");
193
+ }
194
+ };
195
+
196
+ readonly #onClose = () => {
197
+ this.open = false;
198
+ this.dispatchEvent(new Event("close", { bubbles: true, composed: true }));
199
+ };
200
+
201
+ // Esc and the close button close it at once while nothing has changed,
202
+ // and ask first once something has
203
+ readonly #onCancel = (event: Event) => {
204
+ event.preventDefault();
205
+ this.#dismissal.request();
206
+ };
207
+
208
+ // A click on the backdrop closes it too, but only while nothing has
209
+ // changed, so a stray click never throws work away
210
+ readonly #onBackdrop = (event: MouseEvent) => {
211
+ if (event.target === this.#dialog && !this.#dismissal.dirty) this.close();
212
+ };
213
+
214
+ protected override create(root: ShadowRoot) {
215
+ this.#dialog.addEventListener("close", this.#onClose);
216
+ this.#dialog.addEventListener("cancel", this.#onCancel);
217
+ this.#dialog.addEventListener("click", this.#onBackdrop);
218
+ this.#closeButton.addEventListener("click", () => {
219
+ this.#dismissal.request();
220
+ });
221
+ this.#footer.addEventListener("click", this.#onAction);
222
+ root.append(this.#dialog);
223
+ }
224
+
225
+ protected override update() {
226
+ const dialog = this.#dialog;
227
+
228
+ this.#heading.textContent = this.heading;
229
+
230
+ if (this.open && !dialog.open) {
231
+ dialog.showModal();
232
+ // Toasts rise back above it
233
+ document.dispatchEvent(new Event("gw-dialog-open"));
234
+ }
235
+
236
+ if (!this.open && dialog.open) dialog.close(this.returnValue);
237
+ }
238
+ }
239
+
240
+ GwDialog.define("gw-dialog");
241
+
242
+ declare global {
243
+ interface HTMLElementTagNameMap {
244
+ "gw-dialog": GwDialog;
245
+ }
246
+ }
package/src/gw-dot.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `gw-dot`: a small status dot.
3
+ * Docs: https://groundwork.timmo.dev/components/display/#chip-and-dot
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base, type Tone } from "./styles.js";
9
+
10
+ /**
11
+ * A small status dot. Give it a `label`, such as "Online", unless text
12
+ * beside it already says the same.
13
+ *
14
+ * @element gw-dot
15
+ * @summary A small status dot.
16
+ *
17
+ * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The color. Defaults to `neutral`.
18
+ * @attr {string} label - What it means, for assistive technology.
19
+ * @attr {boolean} pulse - Pulses, for something live.
20
+ */
21
+ export class GwDot extends GwElement {
22
+ static override properties = {
23
+ tone: { reflect: true },
24
+ label: {},
25
+ pulse: { type: "boolean", reflect: true },
26
+ } as const;
27
+
28
+ static override styles = [
29
+ base,
30
+ css`
31
+ :host {
32
+ display: inline-block;
33
+ flex: none;
34
+ width: 0.5rem;
35
+ height: 0.5rem;
36
+ background: var(--gw-tone, var(--gw-color-text-secondary));
37
+ border-radius: 50%;
38
+ }
39
+
40
+ :host([pulse]) {
41
+ animation: pulse 1.6s ease-in-out infinite;
42
+ }
43
+
44
+ @keyframes pulse {
45
+ 50% {
46
+ opacity: 0.4;
47
+ }
48
+ }
49
+
50
+ span {
51
+ position: absolute;
52
+ width: 1px;
53
+ height: 1px;
54
+ overflow: hidden;
55
+ clip-path: inset(50%);
56
+ }
57
+ `,
58
+ ];
59
+
60
+ declare tone: Tone;
61
+ declare label: string;
62
+ declare pulse: boolean;
63
+
64
+ readonly #label = h("span");
65
+
66
+ constructor() {
67
+ super();
68
+ this.tone = "neutral";
69
+ this.label = "";
70
+ this.pulse = false;
71
+ }
72
+
73
+ protected override create(root: ShadowRoot) {
74
+ root.append(this.#label);
75
+ }
76
+
77
+ protected override update() {
78
+ this.#label.textContent = this.label;
79
+ this.#label.hidden = !this.label;
80
+ }
81
+ }
82
+
83
+ GwDot.define("gw-dot");
84
+
85
+ declare global {
86
+ interface HTMLElementTagNameMap {
87
+ "gw-dot": GwDot;
88
+ }
89
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `gw-empty-state`: what a list or page shows when there's nothing in it.
3
+ * Docs: https://groundwork.timmo.dev/components/feedback/#empty-state
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base, createIcon, setIconPath } from "./styles.js";
9
+
10
+ /**
11
+ * What a list or page shows when there's nothing in it, or nothing to do:
12
+ * an icon, a heading, a line of text and an optional action.
13
+ *
14
+ * @element gw-empty-state
15
+ * @summary What a list or page shows when there's nothing in it.
16
+ *
17
+ * @attr {string} icon - SVG path data for the icon.
18
+ * @attr {string} heading - The heading.
19
+ *
20
+ * @slot - A line of text.
21
+ * @slot actions - An action, such as adding the first item.
22
+ *
23
+ * @csspart empty - The block.
24
+ */
25
+ export class GwEmptyState extends GwElement {
26
+ static override properties = {
27
+ icon: {},
28
+ heading: {},
29
+ } as const;
30
+
31
+ static override styles = [
32
+ base,
33
+ css`
34
+ :host {
35
+ display: block;
36
+ }
37
+
38
+ .empty {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: var(--gw-space-1);
42
+ align-items: center;
43
+ justify-content: center;
44
+ padding: var(--gw-space-12) var(--gw-space-3);
45
+ color: var(--gw-color-text-secondary);
46
+ text-align: center;
47
+ --gw-icon-size: 2.5rem;
48
+ }
49
+
50
+ h2 {
51
+ margin: 0;
52
+ font-size: var(--gw-font-size-m);
53
+ font-weight: var(--gw-font-weight-strong);
54
+ color: var(--gw-color-text-strong);
55
+ }
56
+
57
+ ::slotted(p) {
58
+ margin: 0;
59
+ }
60
+
61
+ ::slotted([slot="actions"]) {
62
+ margin-block-start: var(--gw-space-2);
63
+ }
64
+ `,
65
+ ];
66
+
67
+ declare icon: string;
68
+ declare heading: string;
69
+
70
+ readonly #icon = createIcon("");
71
+
72
+ readonly #heading = h("h2");
73
+
74
+ constructor() {
75
+ super();
76
+ this.icon = "";
77
+ this.heading = "";
78
+ }
79
+
80
+ protected override create(root: ShadowRoot) {
81
+ root.append(
82
+ h(
83
+ "div",
84
+ { class: "empty", part: "empty" },
85
+ this.#icon,
86
+ this.#heading,
87
+ h("slot"),
88
+ h("slot", { name: "actions" }),
89
+ ),
90
+ );
91
+ }
92
+
93
+ protected override update() {
94
+ setIconPath(this.#icon, this.icon);
95
+ this.#icon.toggleAttribute("hidden", !this.icon);
96
+ this.#heading.textContent = this.heading;
97
+ this.#heading.hidden = !this.heading;
98
+ }
99
+ }
100
+
101
+ GwEmptyState.define("gw-empty-state");
102
+
103
+ declare global {
104
+ interface HTMLElementTagNameMap {
105
+ "gw-empty-state": GwEmptyState;
106
+ }
107
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * `gw-expansion`: a heading that opens and closes the content under it.
3
+ * Docs: https://groundwork.timmo.dev/components/display/#expansion-panel
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base } from "./styles.js";
9
+
10
+ /**
11
+ * A heading that opens and closes the content under it, on a native
12
+ * `<details>`, so find-in-page and the keyboard work as they do anywhere. The
13
+ * content glides open and shut rather than jumping, and the chevron turns
14
+ * with it. `open` reflects its state and it fires `toggle`.
15
+ *
16
+ * @element gw-expansion
17
+ * @summary A heading that opens and closes the content under it.
18
+ *
19
+ * @attr {string} heading - The heading in the summary row.
20
+ * @attr {string} description - A line under the heading.
21
+ * @attr {boolean} open - Whether the content shows.
22
+ *
23
+ * @slot - The content.
24
+ * @slot icon - An icon before the heading.
25
+ *
26
+ * @csspart details - The native `<details>`.
27
+ * @csspart summary - The summary row.
28
+ * @csspart content - The content's wrapper.
29
+ *
30
+ * @fires toggle - When it opens or closes.
31
+ */
32
+ export class GwExpansion extends GwElement {
33
+ static override properties = {
34
+ heading: {},
35
+ description: {},
36
+ open: { type: "boolean", reflect: true },
37
+ } as const;
38
+
39
+ static override styles = [
40
+ base,
41
+ css`
42
+ :host {
43
+ display: block;
44
+ interpolate-size: allow-keywords;
45
+ }
46
+
47
+ details {
48
+ background: var(--gw-color-surface-raised);
49
+ border: var(--gw-border-width) solid var(--gw-color-border-normal);
50
+ border-radius: var(--gw-radius-panel);
51
+ }
52
+
53
+ details::details-content {
54
+ block-size: 0;
55
+ overflow: clip;
56
+ transition:
57
+ block-size var(--gw-motion-normal) var(--gw-motion-easing),
58
+ content-visibility var(--gw-motion-normal) allow-discrete;
59
+ }
60
+
61
+ details[open]::details-content {
62
+ block-size: auto;
63
+ }
64
+
65
+ summary {
66
+ display: flex;
67
+ gap: var(--gw-space-3);
68
+ align-items: center;
69
+ min-height: 3rem;
70
+ padding: var(--gw-space-2) var(--gw-space-4);
71
+ cursor: pointer;
72
+ list-style: none;
73
+ border-radius: calc(var(--gw-radius-panel) - var(--gw-border-width));
74
+ user-select: none;
75
+ transition:
76
+ background-color var(--gw-motion-fast) var(--gw-motion-easing),
77
+ border-radius var(--gw-motion-normal) var(--gw-motion-easing);
78
+ }
79
+
80
+ summary::-webkit-details-marker {
81
+ display: none;
82
+ }
83
+
84
+ details[open] summary {
85
+ border-end-start-radius: 0;
86
+ border-end-end-radius: 0;
87
+ }
88
+
89
+ summary:hover {
90
+ background: var(--gw-color-fill-neutral-hovered);
91
+ }
92
+
93
+ summary:active {
94
+ background: var(--gw-color-fill-neutral-pressed);
95
+ }
96
+
97
+ summary:focus-visible {
98
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
99
+ outline-offset: var(--gw-focus-offset);
100
+ }
101
+
102
+ .text {
103
+ display: flex;
104
+ flex: 1;
105
+ flex-direction: column;
106
+ gap: var(--gw-space-0-5);
107
+ }
108
+
109
+ .heading {
110
+ font-size: var(--gw-font-size-s);
111
+ font-weight: var(--gw-font-weight-strong);
112
+ color: var(--gw-color-text-strong);
113
+ }
114
+
115
+ .description {
116
+ font-size: var(--gw-font-size-xs);
117
+ color: var(--gw-color-text-secondary);
118
+ }
119
+
120
+ /* The chevron sits in a round neutral button, so the heading reads as
121
+ something to press */
122
+ .toggle {
123
+ display: grid;
124
+ flex: none;
125
+ place-items: center;
126
+ width: 2rem;
127
+ height: 2rem;
128
+ background: var(--gw-color-fill-neutral-resting);
129
+ border-radius: 50%;
130
+ transition: background-color var(--gw-motion-fast)
131
+ var(--gw-motion-easing);
132
+ }
133
+
134
+ summary:hover .toggle {
135
+ background: var(--gw-color-fill-neutral-pressed);
136
+ }
137
+
138
+ summary:active .toggle {
139
+ background: var(--gw-color-fill-neutral-selected);
140
+ }
141
+
142
+ .chevron {
143
+ width: 0.45rem;
144
+ height: 0.45rem;
145
+ border-inline-end: 1.5px solid var(--gw-color-text-primary);
146
+ border-block-end: 1.5px solid var(--gw-color-text-primary);
147
+ rotate: 45deg;
148
+ translate: 0 -0.125rem;
149
+ transition:
150
+ rotate var(--gw-motion-normal) var(--gw-motion-easing),
151
+ translate var(--gw-motion-normal) var(--gw-motion-easing);
152
+ }
153
+
154
+ details[open] .chevron {
155
+ rotate: 225deg;
156
+ translate: 0 0.125rem;
157
+ }
158
+
159
+ .content {
160
+ padding: var(--gw-space-3) var(--gw-space-4) var(--gw-space-4);
161
+ border-block-start: var(--gw-border-width) solid
162
+ var(--gw-color-border-quiet);
163
+ font-size: var(--gw-font-size-s);
164
+ }
165
+ `,
166
+ ];
167
+
168
+ declare heading: string;
169
+ declare description: string;
170
+ declare open: boolean;
171
+
172
+ readonly #heading = h("span", { class: "heading" });
173
+
174
+ readonly #description = h("span", { class: "description" });
175
+
176
+ readonly #details = h(
177
+ "details",
178
+ { part: "details" },
179
+ h(
180
+ "summary",
181
+ { part: "summary" },
182
+ h("slot", { name: "icon" }),
183
+ h("span", { class: "text" }, this.#heading, this.#description),
184
+ h(
185
+ "span",
186
+ { class: "toggle", "aria-hidden": "true" },
187
+ h("span", { class: "chevron" }),
188
+ ),
189
+ ),
190
+ h("div", { class: "content", part: "content" }, h("slot")),
191
+ );
192
+
193
+ constructor() {
194
+ super();
195
+ this.heading = "";
196
+ this.description = "";
197
+ this.open = false;
198
+ }
199
+
200
+ readonly #onToggle = () => {
201
+ this.open = this.#details.open;
202
+ this.dispatchEvent(new Event("toggle", { bubbles: true, composed: true }));
203
+ };
204
+
205
+ protected override create(root: ShadowRoot) {
206
+ this.#details.addEventListener("toggle", this.#onToggle);
207
+ root.append(this.#details);
208
+ }
209
+
210
+ protected override update() {
211
+ this.#details.open = this.open;
212
+ this.#heading.textContent = this.heading;
213
+ this.#description.textContent = this.description;
214
+ this.#description.hidden = !this.description;
215
+ }
216
+ }
217
+
218
+ GwExpansion.define("gw-expansion");
219
+
220
+ declare global {
221
+ interface HTMLElementTagNameMap {
222
+ "gw-expansion": GwExpansion;
223
+ }
224
+ }