@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.
package/src/fields.ts ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * What the form fields share: the light DOM base and the styles for labels,
3
+ * hints, errors and the select's trigger and list.
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css } from "./element.js";
8
+
9
+ /**
10
+ * Field components render their native control in light DOM, never in a
11
+ * shadow root: password managers and `<label for>` can't reach across one,
12
+ * and a form only submits controls it can see. Their styles are adopted into
13
+ * whichever document or shadow root they sit in.
14
+ */
15
+ export const fieldStyles = css`
16
+ :where(gw-input, gw-select, gw-switch, gw-checkbox) {
17
+ display: flex;
18
+ flex-direction: column;
19
+ gap: var(--gw-space-2);
20
+ font-family: var(--gw-font-sans);
21
+ color: var(--gw-color-text-primary);
22
+ }
23
+
24
+ :where(gw-input, gw-select) .gw-field-heading {
25
+ display: flex;
26
+ gap: var(--gw-space-1);
27
+ align-items: center;
28
+ }
29
+
30
+ :where(gw-input, gw-select) .gw-field-heading > label {
31
+ font-size: var(--gw-font-size-s);
32
+ font-weight: var(--gw-font-weight-strong);
33
+ color: var(--gw-color-text-strong);
34
+ }
35
+
36
+ /* The (i) button by the label that opens a field's longer help */
37
+ :where(gw-input, gw-select) .gw-info {
38
+ display: inline-grid;
39
+ place-items: center;
40
+ width: 1.5rem;
41
+ height: 1.5rem;
42
+ padding: 0;
43
+ color: var(--gw-color-text-secondary);
44
+ cursor: pointer;
45
+ background: none;
46
+ border: 0;
47
+ border-radius: var(--gw-radius-pill);
48
+ transition:
49
+ color var(--gw-motion-fast) var(--gw-motion-easing),
50
+ background-color var(--gw-motion-fast) var(--gw-motion-easing);
51
+ }
52
+
53
+ :where(gw-input, gw-select) .gw-info:hover {
54
+ color: var(--gw-color-text-strong);
55
+ background: var(--gw-color-fill-neutral-hovered);
56
+ }
57
+
58
+ :where(gw-input, gw-select) .gw-info:focus-visible {
59
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
60
+ outline-offset: var(--gw-focus-offset);
61
+ }
62
+
63
+ :where(gw-input, gw-select) .gw-info[hidden] {
64
+ display: none;
65
+ }
66
+
67
+ :where(gw-input, gw-select) .gw-info > .icon {
68
+ width: 1rem;
69
+ height: 1rem;
70
+ fill: currentColor;
71
+ }
72
+
73
+ :where(gw-input, gw-select) :where(input, .gw-select-trigger) {
74
+ width: 100%;
75
+ min-height: 2.5rem;
76
+ padding: 0 var(--gw-space-3);
77
+ font: inherit;
78
+ font-size: var(--gw-font-size-s);
79
+ color: var(--gw-color-text-strong);
80
+ background: var(--gw-color-surface-lower);
81
+ border: var(--gw-border-width) solid var(--gw-color-border-normal);
82
+ border-radius: var(--gw-radius-control);
83
+ transition: border-color var(--gw-motion-fast) var(--gw-motion-easing);
84
+ }
85
+
86
+ /* The trigger shows the chosen option, with its icon, and a chevron with
87
+ room around it that turns over while the list is open */
88
+ gw-select .gw-select-trigger {
89
+ display: flex;
90
+ gap: var(--gw-space-2);
91
+ align-items: center;
92
+ padding-inline-end: var(--gw-space-3);
93
+ text-align: start;
94
+ cursor: pointer;
95
+ }
96
+
97
+ gw-select .gw-select-value {
98
+ flex: 1;
99
+ min-width: 0;
100
+ overflow: hidden;
101
+ text-overflow: ellipsis;
102
+ white-space: nowrap;
103
+ }
104
+
105
+ /* With an inline label the select sits among toolbar controls, so it's a
106
+ pill that fits its content, like the toggles and buttons beside it */
107
+ gw-select[inline-label] {
108
+ display: inline-flex;
109
+ }
110
+
111
+ gw-select[inline-label] .gw-select-trigger {
112
+ width: auto;
113
+ padding-inline: var(--gw-space-4) var(--gw-space-3);
114
+ border-radius: var(--gw-radius-pill);
115
+ }
116
+
117
+ /* An inline label reads as a quiet prefix to the value */
118
+ gw-select .gw-select-inline-label {
119
+ flex: none;
120
+ color: var(--gw-color-text-secondary);
121
+ }
122
+
123
+ gw-select .gw-select-inline-label[hidden] {
124
+ display: none;
125
+ }
126
+
127
+ gw-select .gw-select-value[data-placeholder] {
128
+ color: var(--gw-color-text-secondary);
129
+ }
130
+
131
+ gw-select .gw-select-trigger > .icon {
132
+ flex: none;
133
+ width: var(--gw-icon-size, 1.25rem);
134
+ height: var(--gw-icon-size, 1.25rem);
135
+ fill: currentColor;
136
+ }
137
+
138
+ gw-select .gw-select-trigger > .icon:last-child {
139
+ color: var(--gw-color-text-secondary);
140
+ transition: rotate var(--gw-motion-normal) var(--gw-motion-easing);
141
+ }
142
+
143
+ gw-select[open] .gw-select-trigger > .icon:last-child {
144
+ rotate: 180deg;
145
+ }
146
+
147
+ gw-select .gw-select-trigger > .icon[hidden] {
148
+ display: none;
149
+ }
150
+
151
+ /* The list opens under the trigger, growing from it, or as a bottom sheet
152
+ sliding up on phones */
153
+ gw-select .gw-select-popup {
154
+ position: fixed;
155
+ inset: unset;
156
+ box-sizing: border-box;
157
+ max-width: calc(100vw - 12px);
158
+ padding: 0;
159
+ margin: 0;
160
+ overflow: hidden;
161
+ color: var(--gw-color-text-primary);
162
+ background: var(--gw-color-surface-overlay);
163
+ border: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal);
164
+ border-radius: var(--gw-radius-panel);
165
+ box-shadow: var(--gw-shadow-l);
166
+ opacity: 0;
167
+ scale: 1 0.96;
168
+ transform-origin: top;
169
+ transition:
170
+ opacity var(--gw-motion-fast) var(--gw-motion-easing),
171
+ scale var(--gw-motion-normal) var(--gw-motion-easing),
172
+ translate var(--gw-motion-normal) var(--gw-motion-easing),
173
+ display var(--gw-motion-normal) allow-discrete,
174
+ overlay var(--gw-motion-normal) allow-discrete;
175
+ }
176
+
177
+ gw-select .gw-select-popup[data-placement="above"] {
178
+ transform-origin: bottom;
179
+ }
180
+
181
+ gw-select .gw-select-popup:popover-open {
182
+ opacity: 1;
183
+ scale: 1;
184
+ }
185
+
186
+ @starting-style {
187
+ gw-select .gw-select-popup:popover-open {
188
+ opacity: 0;
189
+ scale: 1 0.96;
190
+ }
191
+ }
192
+
193
+ gw-select .gw-select-popup[data-placement="sheet"] {
194
+ inset: auto 0 0;
195
+ width: 100%;
196
+ max-width: none;
197
+ padding-block-end: env(safe-area-inset-bottom);
198
+ border-end-start-radius: 0;
199
+ border-end-end-radius: 0;
200
+ scale: 1;
201
+ translate: 0 100%;
202
+ --gw-option-list-height: 60vh;
203
+ }
204
+
205
+ gw-select .gw-select-popup[data-placement="sheet"]:popover-open {
206
+ translate: 0 0;
207
+ }
208
+
209
+ @starting-style {
210
+ gw-select .gw-select-popup[data-placement="sheet"]:popover-open {
211
+ translate: 0 100%;
212
+ }
213
+ }
214
+
215
+ gw-select .gw-select-popup[data-placement="sheet"]::backdrop {
216
+ background: rgb(0 0 0 / 40%);
217
+ }
218
+
219
+ :where(gw-input, gw-select) :where(input, .gw-select-trigger):hover {
220
+ border-color: var(--gw-color-border-loud);
221
+ }
222
+
223
+ :where(gw-input, gw-select, gw-switch, gw-checkbox)
224
+ :where(input, .gw-select-trigger):focus-visible {
225
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
226
+ outline-offset: var(--gw-focus-offset);
227
+ }
228
+
229
+ :where(gw-input, gw-select) [aria-invalid="true"] {
230
+ border-color: var(--gw-color-border-danger);
231
+ }
232
+
233
+ :where(gw-input, gw-select, gw-switch, gw-checkbox) :disabled {
234
+ color: var(--gw-color-text-disabled);
235
+ cursor: not-allowed;
236
+ }
237
+
238
+ :where(gw-input, gw-select, gw-switch, gw-checkbox) .gw-hint {
239
+ margin: 0;
240
+ font-size: var(--gw-font-size-xs);
241
+ color: var(--gw-color-text-secondary);
242
+ }
243
+
244
+ :where(gw-input, gw-select, gw-switch, gw-checkbox) .gw-error {
245
+ margin: 0;
246
+ font-size: var(--gw-font-size-xs);
247
+ color: var(--gw-color-danger);
248
+ }
249
+
250
+ :where(gw-switch, gw-checkbox) > label {
251
+ display: inline-flex;
252
+ gap: var(--gw-space-3);
253
+ align-items: center;
254
+ font-size: var(--gw-font-size-s);
255
+ cursor: pointer;
256
+ }
257
+
258
+ :where(gw-switch, gw-checkbox, gw-select) .gw-visually-hidden {
259
+ position: absolute;
260
+ width: 1px;
261
+ height: 1px;
262
+ overflow: hidden;
263
+ clip-path: inset(50%);
264
+ white-space: nowrap;
265
+ }
266
+
267
+ gw-checkbox input {
268
+ width: 1.125rem;
269
+ height: 1.125rem;
270
+ margin: 0;
271
+ accent-color: var(--gw-color-fill-accent-resting);
272
+ }
273
+
274
+ gw-switch input {
275
+ position: relative;
276
+ flex: none;
277
+ width: 2.5rem;
278
+ height: 1.5rem;
279
+ margin: 0;
280
+ cursor: pointer;
281
+ appearance: none;
282
+ background: var(--gw-color-surface-lower);
283
+ border: var(--gw-border-width) solid var(--gw-color-border-loud);
284
+ border-radius: var(--gw-radius-pill);
285
+ transition:
286
+ background-color var(--gw-motion-normal) var(--gw-motion-easing),
287
+ border-color var(--gw-motion-normal) var(--gw-motion-easing);
288
+ }
289
+
290
+ gw-switch input::after {
291
+ position: absolute;
292
+ inset-block-start: 0.1875rem;
293
+ inset-inline-start: 0.1875rem;
294
+ width: 1rem;
295
+ height: 1rem;
296
+ content: "";
297
+ background: var(--gw-color-text-secondary);
298
+ border-radius: 50%;
299
+ transition:
300
+ translate var(--gw-motion-normal) var(--gw-motion-easing),
301
+ background-color var(--gw-motion-normal) var(--gw-motion-easing);
302
+ }
303
+
304
+ gw-switch input:checked {
305
+ background: var(--gw-color-fill-accent-resting);
306
+ border-color: transparent;
307
+ }
308
+
309
+ gw-switch input:checked::after {
310
+ translate: 1rem 0;
311
+ background: var(--gw-color-on-accent);
312
+ }
313
+
314
+ gw-switch input:disabled {
315
+ opacity: 0.5;
316
+ }
317
+ `;
318
+
319
+ const adoptStyles = (element: Element, sheet: CSSStyleSheet) => {
320
+ const root = element.getRootNode();
321
+
322
+ if (root instanceof Document || root instanceof ShadowRoot) {
323
+ if (!root.adoptedStyleSheets.includes(sheet)) {
324
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
325
+ }
326
+ }
327
+ };
328
+
329
+ let lastId = 0;
330
+
331
+ /** An id for a control, unique on the page, for labels and descriptions. */
332
+ export const fieldId = () => {
333
+ lastId += 1;
334
+
335
+ return `gw-field-${lastId}`;
336
+ };
337
+
338
+ /** A component that renders into itself, so its native control stays in light DOM. */
339
+ export class LightElement extends GwElement {
340
+ static override shadow = false;
341
+
342
+ override connectedCallback() {
343
+ super.connectedCallback();
344
+ adoptStyles(this, fieldStyles);
345
+ }
346
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `gw-alert`: a message in the flow of the page.
3
+ * Docs: https://groundwork.timmo.dev/components/display/#alert
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base, type Tone } from "./styles.js";
9
+
10
+ /**
11
+ * A message in the flow of the page. `urgent` announces it at once, for
12
+ * errors that need attention; otherwise it's announced politely.
13
+ *
14
+ * @element gw-alert
15
+ * @summary A message in the flow of the page.
16
+ *
17
+ * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The color and default icon. Defaults to `info`.
18
+ * @attr {string} heading - A heading above the message.
19
+ * @attr {boolean} urgent - Announces it at once.
20
+ *
21
+ * @slot - The message.
22
+ * @slot icon - An icon in place of the tone's.
23
+ * @slot actions - Buttons beside the message.
24
+ *
25
+ * @csspart alert - The alert.
26
+ */
27
+ export class GwAlert extends GwElement {
28
+ static override properties = {
29
+ tone: { reflect: true },
30
+ heading: {},
31
+ urgent: { type: "boolean" },
32
+ } as const;
33
+
34
+ static override styles = [
35
+ base,
36
+ css`
37
+ :host {
38
+ display: block;
39
+ }
40
+
41
+ .alert {
42
+ display: flex;
43
+ gap: var(--gw-space-2);
44
+ align-items: flex-start;
45
+ padding: var(--gw-space-2) var(--gw-space-3);
46
+ font-size: var(--gw-font-size-s);
47
+ color: var(--gw-color-text-primary);
48
+ background: var(--gw-tone-fill, var(--gw-color-fill-info-quiet));
49
+ border: var(--gw-border-width) solid
50
+ var(--gw-tone-border, var(--gw-color-border-info));
51
+ border-inline-start-width: calc(var(--gw-border-width) * 4);
52
+ border-radius: var(--gw-radius-control);
53
+ }
54
+
55
+ ::slotted(p) {
56
+ margin: 0;
57
+ }
58
+
59
+ ::slotted([slot="actions"]) {
60
+ align-self: center;
61
+ }
62
+
63
+ ::slotted([slot="icon"]) {
64
+ color: var(--gw-tone, var(--gw-color-info));
65
+ }
66
+
67
+ .body {
68
+ flex: 1;
69
+ min-width: 0;
70
+ }
71
+
72
+ strong {
73
+ display: block;
74
+ font-weight: var(--gw-font-weight-strong);
75
+ color: var(--gw-color-text-strong);
76
+ }
77
+ `,
78
+ ];
79
+
80
+ declare tone: Tone;
81
+ declare heading: string;
82
+ declare urgent: boolean;
83
+
84
+ readonly #alert = h("div", { class: "alert", part: "alert" });
85
+
86
+ readonly #heading = h("strong");
87
+
88
+ constructor() {
89
+ super();
90
+ this.tone = "info";
91
+ this.heading = "";
92
+ this.urgent = false;
93
+ }
94
+
95
+ protected override create(root: ShadowRoot) {
96
+ this.#alert.append(
97
+ h("slot", { name: "icon" }),
98
+ h("div", { class: "body" }, this.#heading, h("slot")),
99
+ h("slot", { name: "actions" }),
100
+ );
101
+ root.append(this.#alert);
102
+ }
103
+
104
+ protected override update() {
105
+ this.#alert.setAttribute("role", this.urgent ? "alert" : "status");
106
+ this.#heading.textContent = this.heading;
107
+ this.#heading.hidden = !this.heading;
108
+ }
109
+ }
110
+
111
+ GwAlert.define("gw-alert");
112
+
113
+ declare global {
114
+ interface HTMLElementTagNameMap {
115
+ "gw-alert": GwAlert;
116
+ }
117
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `gw-badge`: a pill with an icon, a value and an optional label.
3
+ * Docs: https://groundwork.timmo.dev/components/display/#badge
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 pill with an icon, a value and an optional small label above it. The
14
+ * icon takes the `tone`'s color. While `loading`, a skeleton stands in for
15
+ * the value, and for the icon if it has none yet, and the value fades in
16
+ * once it's loaded.
17
+ *
18
+ * @element gw-badge
19
+ * @summary A pill with an icon, a value and an optional label.
20
+ *
21
+ * @attr {string} icon - SVG path data for the icon.
22
+ * @attr {string} label - A small label above the value.
23
+ * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The icon's color. Defaults to `accent`.
24
+ * @attr {"small"|"medium"} size - The size. Defaults to `medium`.
25
+ * @attr {boolean} loading - Shows a skeleton for the value, and the icon if it has none.
26
+ *
27
+ * @slot - The value.
28
+ *
29
+ * @csspart badge - The badge.
30
+ */
31
+ export class GwBadge extends GwElement {
32
+ static override properties = {
33
+ icon: {},
34
+ label: {},
35
+ tone: { reflect: true },
36
+ size: { reflect: true },
37
+ loading: { type: "boolean", reflect: true },
38
+ } as const;
39
+
40
+ static override styles = [
41
+ base,
42
+ css`
43
+ :host {
44
+ display: inline-flex;
45
+ max-width: 100%;
46
+ vertical-align: middle;
47
+ }
48
+
49
+ /* 36px tall, 12px padding, 8px between the 18px icon and the text */
50
+ .badge {
51
+ --gw-icon-size: 1.125rem;
52
+
53
+ display: inline-flex;
54
+ gap: var(--gw-space-2);
55
+ align-items: center;
56
+ box-sizing: border-box;
57
+ max-width: 100%;
58
+ height: 2.25rem;
59
+ padding: 0 var(--gw-space-4) 0 var(--gw-space-3);
60
+ vertical-align: middle;
61
+ color: var(--gw-color-text-strong);
62
+ background: var(--gw-color-surface-raised);
63
+ border: var(--gw-border-width) solid var(--gw-color-border-normal);
64
+ border-radius: var(--gw-radius-pill);
65
+ }
66
+
67
+ :host([size="small"]) .badge {
68
+ --gw-icon-size: 1rem;
69
+
70
+ gap: var(--gw-space-1);
71
+ height: 1.75rem;
72
+ padding: 0 var(--gw-space-3) 0 var(--gw-space-2);
73
+ }
74
+
75
+ .icon {
76
+ color: var(--gw-tone, var(--gw-color-accent));
77
+ }
78
+
79
+ .info {
80
+ display: flex;
81
+ flex-direction: column;
82
+ min-width: 0;
83
+ }
84
+
85
+ .label {
86
+ font-size: var(--gw-font-size-2xs);
87
+ line-height: 1.1;
88
+ color: var(--gw-color-text-secondary);
89
+ }
90
+
91
+ .content {
92
+ font-size: var(--gw-font-size-xs);
93
+ font-weight: var(--gw-font-weight-strong);
94
+ line-height: 1.2;
95
+ white-space: nowrap;
96
+ }
97
+
98
+ .value {
99
+ display: inline-block;
100
+ }
101
+ `,
102
+ ];
103
+
104
+ declare icon: string;
105
+ declare label: string;
106
+ declare tone: Tone;
107
+ declare size: "small" | "medium";
108
+ declare loading: boolean;
109
+
110
+ readonly #badge = h("span", { class: "badge", part: "badge" });
111
+
112
+ readonly #icon = createIcon("");
113
+
114
+ readonly #iconSkeleton = h("gw-skeleton-icon");
115
+
116
+ readonly #label = h("span", { class: "label" });
117
+
118
+ readonly #skeleton = h("gw-skeleton-text", {
119
+ style: "--gw-skeleton-width: 3rem",
120
+ });
121
+
122
+ readonly #value = h("span", { class: "value" }, h("slot"));
123
+
124
+ constructor() {
125
+ super();
126
+ this.icon = "";
127
+ this.label = "";
128
+ this.tone = "accent";
129
+ this.size = "medium";
130
+ this.loading = false;
131
+ }
132
+
133
+ protected override create(root: ShadowRoot) {
134
+ this.#badge.append(
135
+ this.#icon,
136
+ this.#iconSkeleton,
137
+ h(
138
+ "span",
139
+ { class: "info" },
140
+ this.#label,
141
+ h("span", { class: "content" }, this.#skeleton, this.#value),
142
+ ),
143
+ );
144
+ root.append(this.#badge);
145
+ }
146
+
147
+ protected override update(changed: ReadonlySet<string>) {
148
+ const wasLoading = this.#value.hidden;
149
+
150
+ setAttribute(this.#badge, "aria-busy", this.loading ? "true" : null);
151
+ setIconPath(this.#icon, this.icon);
152
+ this.#icon.style.display = this.icon ? "" : "none";
153
+ this.#iconSkeleton.hidden = this.icon !== "" || !this.loading;
154
+ this.#label.textContent = this.label;
155
+ this.#label.hidden = !this.label;
156
+ this.#skeleton.hidden = !this.loading;
157
+ this.#value.hidden = this.loading;
158
+
159
+ if (changed.has("loading") && wasLoading && !this.loading) {
160
+ fadeIn(this.#value);
161
+ }
162
+ }
163
+ }
164
+
165
+ GwBadge.define("gw-badge");
166
+
167
+ declare global {
168
+ interface HTMLElementTagNameMap {
169
+ "gw-badge": GwBadge;
170
+ }
171
+ }