@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,168 @@
1
+ /**
2
+ * `gw-setting-row`: a setting's name and description beside its control.
3
+ * Docs: https://groundwork.timmo.dev/components/inputs/#setting-row
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 } from "./styles.js";
11
+
12
+ /**
13
+ * A setting: its name and description on one side, its control on the
14
+ * other. A wide control drops under the text when there isn't room. Rows
15
+ * after the first in their parent get a line above them. While `loading`, a
16
+ * skeleton stands in for the control, and for the text until it's known.
17
+ *
18
+ * @element gw-setting-row
19
+ * @summary A setting's name and description beside its control.
20
+ *
21
+ * @attr {string} heading - The setting's name.
22
+ * @attr {string} description - What it does.
23
+ * @attr {boolean} loading - Shows skeletons for the control and any unknown text.
24
+ *
25
+ * @slot - The control.
26
+ *
27
+ * @csspart row - The row.
28
+ *
29
+ * @cssprop --gw-row-padding - The padding above and below. Defaults to `--gw-space-2`.
30
+ */
31
+ export class GwSettingRow extends GwElement {
32
+ static override properties = {
33
+ heading: {},
34
+ description: {},
35
+ loading: { type: "boolean", reflect: true },
36
+ } as const;
37
+
38
+ static override styles = [
39
+ base,
40
+ css`
41
+ :host {
42
+ display: block;
43
+ }
44
+
45
+ .row {
46
+ display: flex;
47
+ flex-wrap: wrap;
48
+ gap: var(--gw-space-1) var(--gw-space-3);
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ padding: var(--gw-row-padding, var(--gw-space-2)) 0;
52
+ }
53
+
54
+ /* A full line even in themes without borders, since it separates rows */
55
+ :host(:not(:first-of-type)) .row {
56
+ border-block-start: max(var(--gw-border-width), 1px) solid
57
+ var(--gw-color-border-quiet);
58
+ }
59
+
60
+ .text {
61
+ flex: 1 1 12rem;
62
+ min-width: 0;
63
+ }
64
+
65
+ .heading {
66
+ font-size: var(--gw-font-size-s);
67
+ font-weight: var(--gw-font-weight-strong);
68
+ color: var(--gw-color-text-strong);
69
+ }
70
+
71
+ .description {
72
+ font-size: var(--gw-font-size-xs);
73
+ color: var(--gw-color-text-secondary);
74
+ }
75
+
76
+ .control {
77
+ max-width: 100%;
78
+ }
79
+
80
+ .control-skeleton {
81
+ width: 3.25rem;
82
+ min-height: 0;
83
+ height: 2rem;
84
+ --gw-skeleton-radius: var(--gw-radius-pill);
85
+ }
86
+ `,
87
+ ];
88
+
89
+ declare heading: string;
90
+ declare description: string;
91
+ declare loading: boolean;
92
+
93
+ readonly #row = h("div", { class: "row", part: "row" });
94
+
95
+ readonly #heading = h("span");
96
+
97
+ readonly #headingSkeleton = h("gw-skeleton-text", {
98
+ style: "--gw-skeleton-width: 8rem",
99
+ });
100
+
101
+ readonly #description = h("div", { class: "description" });
102
+
103
+ readonly #descriptionSkeleton = h(
104
+ "div",
105
+ { class: "description" },
106
+ h("gw-skeleton-text", { style: "--gw-skeleton-width: 12rem" }),
107
+ );
108
+
109
+ readonly #slot = h("slot");
110
+
111
+ readonly #controlSkeleton = h("gw-skeleton", { class: "control-skeleton" });
112
+
113
+ readonly #control = h(
114
+ "div",
115
+ { class: "control" },
116
+ this.#controlSkeleton,
117
+ this.#slot,
118
+ );
119
+
120
+ #rendered = false;
121
+
122
+ constructor() {
123
+ super();
124
+ this.heading = "";
125
+ this.description = "";
126
+ this.loading = false;
127
+ }
128
+
129
+ protected override create(root: ShadowRoot) {
130
+ this.#row.append(
131
+ h(
132
+ "div",
133
+ { class: "text" },
134
+ h("div", { class: "heading" }, this.#heading, this.#headingSkeleton),
135
+ this.#description,
136
+ this.#descriptionSkeleton,
137
+ ),
138
+ this.#control,
139
+ );
140
+ root.append(this.#row);
141
+ }
142
+
143
+ protected override update(changed: ReadonlySet<string>) {
144
+ setAttribute(this.#row, "aria-busy", this.loading ? "true" : null);
145
+ this.#heading.textContent = this.heading;
146
+ this.#headingSkeleton.hidden = !this.loading || this.heading !== "";
147
+ this.#description.textContent = this.description;
148
+ this.#description.hidden = !this.description;
149
+ this.#descriptionSkeleton.hidden =
150
+ !this.loading || this.heading !== "" || this.description !== "";
151
+ this.#controlSkeleton.hidden = !this.loading;
152
+ this.#slot.hidden = this.loading;
153
+
154
+ if (this.#rendered && changed.has("loading") && !this.loading) {
155
+ fadeIn(this.#control);
156
+ }
157
+
158
+ this.#rendered = true;
159
+ }
160
+ }
161
+
162
+ GwSettingRow.define("gw-setting-row");
163
+
164
+ declare global {
165
+ interface HTMLElementTagNameMap {
166
+ "gw-setting-row": GwSettingRow;
167
+ }
168
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `gw-skeleton` and its text, lines and icon shapes: placeholders for what's
3
+ * loading.
4
+ * Docs: https://groundwork.timmo.dev/components/feedback/#loading
5
+ *
6
+ * @module
7
+ */
8
+ import { GwElement, css, h } from "./element.js";
9
+ import { base } from "./styles.js";
10
+
11
+ /**
12
+ * A placeholder for what's loading, sized by its container or by `width`
13
+ * and `height` in CSS. The shapes below extend it, and components with a
14
+ * `loading` attribute draw their own from them, so a spinner is only needed
15
+ * for an action in progress. It's hidden from assistive technology, so mark
16
+ * the region it sits in `aria-busy`.
17
+ *
18
+ * @element gw-skeleton
19
+ * @summary A pulsing placeholder for what's loading.
20
+ *
21
+ * @cssprop --gw-skeleton-color - The fill. Defaults to the neutral fill.
22
+ * @cssprop --gw-skeleton-radius - The corners. Defaults to the control radius.
23
+ */
24
+ export class GwSkeleton extends GwElement {
25
+ static override styles: readonly CSSStyleSheet[] = [
26
+ base,
27
+ css`
28
+ :host {
29
+ display: block;
30
+ min-height: 1rem;
31
+ background: var(
32
+ --gw-skeleton-color,
33
+ var(--gw-color-fill-neutral-resting)
34
+ );
35
+ border-radius: var(--gw-skeleton-radius, var(--gw-radius-control));
36
+ animation: pulse 2s ease-in-out infinite;
37
+ }
38
+
39
+ @keyframes pulse {
40
+ 50% {
41
+ opacity: 0.45;
42
+ }
43
+ }
44
+
45
+ @media (forced-colors: active) {
46
+ :host {
47
+ background: GrayText;
48
+ }
49
+ }
50
+ `,
51
+ ];
52
+
53
+ override connectedCallback() {
54
+ super.connectedCallback();
55
+ this.setAttribute("aria-hidden", "true");
56
+ }
57
+ }
58
+
59
+ /**
60
+ * A line of text. Its height follows the font size around it, so it lines up
61
+ * with the text it stands in for.
62
+ *
63
+ * @element gw-skeleton-text
64
+ * @summary A placeholder for a line of text.
65
+ *
66
+ * @cssprop --gw-skeleton-width - Defaults to `8rem`.
67
+ */
68
+ export class GwSkeletonText extends GwSkeleton {
69
+ static override styles = [
70
+ ...GwSkeleton.styles,
71
+ css`
72
+ :host {
73
+ display: inline-block;
74
+ width: var(--gw-skeleton-width, 8rem);
75
+ max-width: 100%;
76
+ height: 1em;
77
+ min-height: 0;
78
+ vertical-align: middle;
79
+ }
80
+ `,
81
+ ];
82
+ }
83
+
84
+ /**
85
+ * Several lines of text, as a paragraph, with a shorter last line so it
86
+ * doesn't read as a solid block.
87
+ *
88
+ * @element gw-skeleton-lines
89
+ * @summary A placeholder for a paragraph.
90
+ *
91
+ * @attr {number} lines - How many lines. Defaults to 3.
92
+ */
93
+ export class GwSkeletonLines extends GwElement {
94
+ static override properties = {
95
+ lines: { type: "number" },
96
+ } as const;
97
+
98
+ static override styles = [
99
+ base,
100
+ css`
101
+ :host {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 0.5em;
105
+ }
106
+
107
+ gw-skeleton-text {
108
+ --gw-skeleton-width: 100%;
109
+ }
110
+
111
+ gw-skeleton-text:last-child:not(:first-child) {
112
+ --gw-skeleton-width: 60%;
113
+ }
114
+ `,
115
+ ];
116
+
117
+ declare lines: number;
118
+
119
+ constructor() {
120
+ super();
121
+ this.lines = 3;
122
+ }
123
+
124
+ override connectedCallback() {
125
+ super.connectedCallback();
126
+ this.setAttribute("aria-hidden", "true");
127
+ }
128
+
129
+ protected override update() {
130
+ this.renderRoot.replaceChildren(
131
+ ...Array.from({ length: Math.max(1, this.lines || 1) }, () =>
132
+ h("gw-skeleton-text"),
133
+ ),
134
+ );
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A round placeholder for an icon or avatar.
140
+ *
141
+ * @element gw-skeleton-icon
142
+ * @summary A round placeholder for an icon or avatar.
143
+ *
144
+ * @cssprop --gw-icon-size - Its size. Defaults to `1.25rem`.
145
+ */
146
+ export class GwSkeletonIcon extends GwSkeleton {
147
+ static override styles = [
148
+ ...GwSkeleton.styles,
149
+ css`
150
+ :host {
151
+ display: inline-block;
152
+ flex: none;
153
+ width: var(--gw-icon-size, 1.25rem);
154
+ height: var(--gw-icon-size, 1.25rem);
155
+ min-height: 0;
156
+ vertical-align: middle;
157
+ border-radius: var(--gw-radius-pill);
158
+ }
159
+ `,
160
+ ];
161
+ }
162
+
163
+ GwSkeleton.define("gw-skeleton");
164
+
165
+ GwSkeletonText.define("gw-skeleton-text");
166
+
167
+ GwSkeletonLines.define("gw-skeleton-lines");
168
+
169
+ GwSkeletonIcon.define("gw-skeleton-icon");
170
+
171
+ declare global {
172
+ interface HTMLElementTagNameMap {
173
+ "gw-skeleton": GwSkeleton;
174
+ "gw-skeleton-text": GwSkeletonText;
175
+ "gw-skeleton-lines": GwSkeletonLines;
176
+ "gw-skeleton-icon": GwSkeletonIcon;
177
+ }
178
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `gw-spinner`: a spinning ring, for waits a skeleton can't stand in for.
3
+ * Docs: https://groundwork.timmo.dev/components/feedback/#spinner
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base } from "./styles.js";
9
+
10
+ /**
11
+ * A spinning ring while something loads, announced by its `label`.
12
+ *
13
+ * @element gw-spinner
14
+ * @summary A spinning ring for a wait a skeleton can't stand in for.
15
+ *
16
+ * @attr {string} label - What's loading, for assistive technology. Defaults to `Loading`.
17
+ *
18
+ * @cssprop --gw-spinner-size - The ring's size. Defaults to `1.25rem`.
19
+ */
20
+ export class GwSpinner extends GwElement {
21
+ static override properties = {
22
+ label: {},
23
+ } as const;
24
+
25
+ static override styles = [
26
+ base,
27
+ css`
28
+ :host {
29
+ display: inline-flex;
30
+ width: var(--gw-spinner-size, 1.25rem);
31
+ height: var(--gw-spinner-size, 1.25rem);
32
+ color: var(--gw-color-accent);
33
+ }
34
+
35
+ .ring {
36
+ width: 100%;
37
+ height: 100%;
38
+ border: 2px solid var(--gw-color-border-quiet);
39
+ border-top-color: currentColor;
40
+ border-radius: 50%;
41
+ animation: spin 0.9s linear infinite;
42
+ }
43
+
44
+ @keyframes spin {
45
+ to {
46
+ transform: rotate(360deg);
47
+ }
48
+ }
49
+
50
+ .label {
51
+ position: absolute;
52
+ width: 1px;
53
+ height: 1px;
54
+ overflow: hidden;
55
+ clip-path: inset(50%);
56
+ }
57
+ `,
58
+ ];
59
+
60
+ declare label: string;
61
+
62
+ readonly #label = h("span", { class: "label" });
63
+
64
+ constructor() {
65
+ super();
66
+ this.label = "Loading";
67
+ }
68
+
69
+ protected override create(root: ShadowRoot) {
70
+ root.append(h("span", { class: "ring", role: "status" }, this.#label));
71
+ }
72
+
73
+ protected override update() {
74
+ this.#label.textContent = this.label;
75
+ }
76
+ }
77
+
78
+ GwSpinner.define("gw-spinner");
79
+
80
+ declare global {
81
+ interface HTMLElementTagNameMap {
82
+ "gw-spinner": GwSpinner;
83
+ }
84
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `gw-toaster`: brief, non-interactive toasts in the bottom end corner.
3
+ * Docs: https://groundwork.timmo.dev/components/feedback/#toast
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { fadeEasing, motionDuration, reducedMotion } from "./motion.js";
9
+ import { base, type Tone } from "./styles.js";
10
+
11
+ /**
12
+ * Where toasts appear: the bottom corner on the end side, away from what
13
+ * people click. Call `show(message, tone)`; each toast is announced politely
14
+ * and fades out after `duration` milliseconds.
15
+ *
16
+ * A toast only tells; it has no actions and lets every click through, since
17
+ * it may sit over something interactive and leaves before it can be used.
18
+ * It's in the top layer and rises above any dialog each time one shows. It's
19
+ * a last resort: show an outcome where it happened instead.
20
+ *
21
+ * @element gw-toaster
22
+ * @summary Brief, non-interactive toasts in the bottom end corner.
23
+ *
24
+ * @attr {number} duration - Milliseconds each toast shows for. Defaults to 5000.
25
+ */
26
+ export class GwToaster extends GwElement {
27
+ static override properties = {
28
+ duration: { type: "number" },
29
+ } as const;
30
+
31
+ static override styles = [
32
+ base,
33
+ css`
34
+ :host {
35
+ position: fixed;
36
+ inset: auto var(--gw-space-4) var(--gw-space-4) auto;
37
+ width: min(22rem, calc(100vw - 2 * var(--gw-space-4)));
38
+ padding: 0;
39
+ margin: 0;
40
+ overflow: visible;
41
+ color: inherit;
42
+ pointer-events: none;
43
+ user-select: none;
44
+ background: none;
45
+ border: 0;
46
+ }
47
+
48
+ :host(:dir(rtl)) {
49
+ inset: auto auto var(--gw-space-4) var(--gw-space-4);
50
+ }
51
+
52
+ :host(:not(:popover-open)) {
53
+ display: none;
54
+ }
55
+
56
+ .list {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: var(--gw-space-1);
60
+ align-items: flex-end;
61
+ }
62
+
63
+ .toast {
64
+ max-width: 100%;
65
+ padding: var(--gw-space-2) var(--gw-space-3);
66
+ font-size: var(--gw-font-size-s);
67
+ color: var(--gw-color-text-strong);
68
+ background: var(--gw-color-surface-overlay);
69
+ border: var(--gw-border-width) solid var(--gw-color-border-normal);
70
+ border-inline-start: calc(var(--gw-border-width) * 4) solid
71
+ var(--gw-toast-tone);
72
+ border-radius: var(--gw-radius-control);
73
+ box-shadow: var(--gw-shadow-m);
74
+ }
75
+ `,
76
+ ];
77
+
78
+ declare duration: number;
79
+
80
+ readonly #list = h("div", {
81
+ class: "list",
82
+ role: "status",
83
+ "aria-live": "polite",
84
+ });
85
+
86
+ constructor() {
87
+ super();
88
+ this.duration = 5000;
89
+ }
90
+
91
+ override connectedCallback() {
92
+ this.setAttribute("popover", "manual");
93
+ super.connectedCallback();
94
+ document.addEventListener("gw-dialog-open", this.#onDialogOpen);
95
+ // It stays open while empty, as nothing in it can be clicked, so the
96
+ // live region is in place before a message arrives.
97
+ this.#raise();
98
+ }
99
+
100
+ override disconnectedCallback() {
101
+ super.disconnectedCallback();
102
+ document.removeEventListener("gw-dialog-open", this.#onDialogOpen);
103
+ }
104
+
105
+ readonly #onDialogOpen = () => {
106
+ if (this.#list.childElementCount > 0) this.#raise();
107
+ };
108
+
109
+ protected override create(root: ShadowRoot) {
110
+ root.append(this.#list);
111
+ }
112
+
113
+ show(message: string, tone: Tone = "neutral") {
114
+ const toast = h("div", { class: "toast" }, message);
115
+
116
+ toast.style.setProperty(
117
+ "--gw-toast-tone",
118
+ tone === "neutral"
119
+ ? "var(--gw-color-border-loud)"
120
+ : `var(--gw-color-${tone})`,
121
+ );
122
+ this.#raise();
123
+ this.#list.append(toast);
124
+
125
+ const reduced = reducedMotion();
126
+ const slow = motionDuration(this, "--gw-motion-slow", 350);
127
+
128
+ toast.animate(
129
+ [
130
+ { opacity: 0, translate: "0 0.5rem" },
131
+ { opacity: 1, translate: "0 0" },
132
+ ],
133
+ { duration: reduced ? 0 : slow, easing: fadeEasing },
134
+ );
135
+
136
+ setTimeout(() => {
137
+ const leave = toast.animate([{ opacity: 1 }, { opacity: 0 }], {
138
+ duration: reduced ? 0 : slow,
139
+ easing: fadeEasing,
140
+ fill: "forwards",
141
+ });
142
+
143
+ leave.addEventListener("finish", () => toast.remove());
144
+ leave.addEventListener("cancel", () => toast.remove());
145
+ }, this.duration);
146
+ }
147
+
148
+ /**
149
+ * Puts the toaster on top of the top layer, above any dialog opened since
150
+ * it last showed.
151
+ */
152
+ #raise() {
153
+ if (!this.isConnected) return;
154
+
155
+ if (this.matches(":popover-open")) this.hidePopover();
156
+
157
+ this.showPopover();
158
+ }
159
+ }
160
+
161
+ GwToaster.define("gw-toaster");
162
+
163
+ declare global {
164
+ interface HTMLElementTagNameMap {
165
+ "gw-toaster": GwToaster;
166
+ }
167
+ }
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Groundwork's web components: framework-free custom elements styled by the
3
+ * `--gw-*` tokens. Importing this defines every `gw-` element.
4
+ * Docs: https://groundwork.timmo.dev/components
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+ export * from "./element.js";
9
+
10
+ export * from "./styles.js";
11
+
12
+ export * from "./motion.js";
13
+
14
+ export * from "./fields.js";
15
+
16
+ export * from "./layout.js";
17
+
18
+ export * from "./gw-alert.js";
19
+
20
+ export * from "./gw-badge.js";
21
+
22
+ export * from "./gw-banner.js";
23
+
24
+ export * from "./gw-button.js";
25
+
26
+ export * from "./gw-card.js";
27
+
28
+ export * from "./gw-checkbox.js";
29
+
30
+ export * from "./gw-chip.js";
31
+
32
+ export * from "./gw-command-menu.js";
33
+
34
+ export * from "./gw-dialog.js";
35
+
36
+ export * from "./gw-dot.js";
37
+
38
+ export * from "./gw-empty-state.js";
39
+
40
+ export * from "./gw-expansion.js";
41
+
42
+ export * from "./gw-facts.js";
43
+
44
+ export * from "./gw-fade-in.js";
45
+
46
+ export * from "./gw-gauge.js";
47
+
48
+ export * from "./gw-input.js";
49
+
50
+ export * from "./gw-menu-button.js";
51
+
52
+ export * from "./gw-meter.js";
53
+
54
+ export * from "./gw-notification.js";
55
+
56
+ export * from "./gw-notification-center.js";
57
+
58
+ export * from "./gw-option-list.js";
59
+
60
+ export * from "./gw-page-header.js";
61
+
62
+ export * from "./gw-popover.js";
63
+
64
+ export * from "./gw-section-title.js";
65
+
66
+ export * from "./gw-segmented.js";
67
+
68
+ export * from "./gw-select.js";
69
+
70
+ export * from "./gw-setting-row.js";
71
+
72
+ export * from "./gw-skeleton.js";
73
+
74
+ export * from "./gw-spinner.js";
75
+
76
+ export * from "./gw-toast.js";