@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,120 @@
1
+ /**
2
+ * `gw-banner`: a slim bar for app-wide state, such as a lost connection.
3
+ * Docs: https://groundwork.timmo.dev/components/feedback/#banner
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h, setAttribute } from "./element.js";
8
+ import { base, type Tone } from "./styles.js";
9
+
10
+ /**
11
+ * A slim bar across the top of the app for app-wide state the person can
12
+ * work through: a restart is needed, the connection is lost, or a new
13
+ * version is ready. Put it above the app's content, not over it. Its height
14
+ * opens and closes smoothly, so the page below moves with it rather than
15
+ * jumping. Put one action in the `actions` slot, which opens what it's
16
+ * about. `urgent` announces it at once.
17
+ *
18
+ * @element gw-banner
19
+ * @summary A slim bar for app-wide state, such as a lost connection.
20
+ *
21
+ * @attr {"neutral"|"accent"|"info"|"success"|"warning"|"danger"|"important"} tone - The color. Defaults to `info`.
22
+ * @attr {boolean} open - Whether it shows.
23
+ * @attr {boolean} urgent - Announces it at once.
24
+ *
25
+ * @slot - The message.
26
+ * @slot icon - An icon before the message.
27
+ * @slot actions - One button, opening what it's about.
28
+ *
29
+ * @csspart bar - The bar.
30
+ */
31
+ export class GwBanner extends GwElement {
32
+ static override properties = {
33
+ tone: { reflect: true },
34
+ open: { type: "boolean", reflect: true },
35
+ urgent: { type: "boolean" },
36
+ } as const;
37
+
38
+ static override styles = [
39
+ base,
40
+ css`
41
+ :host {
42
+ display: grid;
43
+ grid-template-rows: 0fr;
44
+ transition:
45
+ grid-template-rows var(--gw-motion-normal) var(--gw-motion-easing),
46
+ visibility var(--gw-motion-normal) allow-discrete;
47
+ visibility: hidden;
48
+ }
49
+
50
+ :host([open]) {
51
+ grid-template-rows: 1fr;
52
+ visibility: visible;
53
+ }
54
+
55
+ .clip {
56
+ min-height: 0;
57
+ overflow: clip;
58
+ }
59
+
60
+ .bar {
61
+ display: flex;
62
+ flex-wrap: wrap;
63
+ gap: var(--gw-space-1) var(--gw-space-3);
64
+ align-items: center;
65
+ justify-content: center;
66
+ min-height: 2.5rem;
67
+ padding: var(--gw-space-1) var(--gw-space-3);
68
+ font-size: var(--gw-font-size-s);
69
+ color: var(--gw-color-text-strong);
70
+ background: var(--gw-tone-fill, var(--gw-color-fill-info-quiet));
71
+ border-block-end: var(--gw-border-width) solid
72
+ var(--gw-tone-border, var(--gw-color-border-info));
73
+ }
74
+
75
+ ::slotted([slot="icon"]) {
76
+ color: var(--gw-tone, var(--gw-color-info));
77
+ }
78
+
79
+ ::slotted(p) {
80
+ margin: 0;
81
+ }
82
+ `,
83
+ ];
84
+
85
+ declare tone: Tone;
86
+ declare open: boolean;
87
+ declare urgent: boolean;
88
+
89
+ readonly #bar = h(
90
+ "div",
91
+ { class: "bar", part: "bar" },
92
+ h("slot", { name: "icon" }),
93
+ h("slot"),
94
+ h("slot", { name: "actions" }),
95
+ );
96
+
97
+ constructor() {
98
+ super();
99
+ this.tone = "info";
100
+ this.open = false;
101
+ this.urgent = false;
102
+ }
103
+
104
+ protected override create(root: ShadowRoot) {
105
+ root.append(h("div", { class: "clip" }, this.#bar));
106
+ }
107
+
108
+ protected override update() {
109
+ setAttribute(this.#bar, "role", this.urgent ? "alert" : "status");
110
+ setAttribute(this, "aria-hidden", this.open ? null : "true");
111
+ }
112
+ }
113
+
114
+ GwBanner.define("gw-banner");
115
+
116
+ declare global {
117
+ interface HTMLElementTagNameMap {
118
+ "gw-banner": GwBanner;
119
+ }
120
+ }
@@ -0,0 +1,438 @@
1
+ /**
2
+ * `gw-button`: the button, in every variant, appearance and size.
3
+ * Docs: https://groundwork.timmo.dev/components/actions/#button
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h, setAttribute } from "./element.js";
8
+ import { base, createIcon, setIconPath } from "./styles.js";
9
+
10
+ /**
11
+ * A native button styled through `--gw-*` variables, so it follows the
12
+ * theme and mode around it. With `href` it renders a link that looks the
13
+ * same. Put an icon in the `icon` slot.
14
+ *
15
+ * `variant` picks the color and `appearance` how it's applied, from most to
16
+ * least important: `accent` (a solid fill), `filled` (a soft tint),
17
+ * `outlined` (a stroke) or `plain` (text only). Every button is a pill.
18
+ * Sizes run `xs` to `xl`, and `--gw-button-scale` sets any size in between.
19
+ *
20
+ * While `loading`, a spinning ring takes the icon's place, or the text's
21
+ * when there's no icon, so the button keeps its size, and clicks are
22
+ * ignored. The icon fades back once it's done.
23
+ *
24
+ * @element gw-button
25
+ * @summary A pill button, or a link that looks like one.
26
+ *
27
+ * @attr {"brand"|"neutral"|"info"|"success"|"warning"|"danger"} variant - The color. Defaults to `brand`.
28
+ * @attr {"accent"|"filled"|"outlined"|"plain"} appearance - How the color is applied. Defaults to `accent`.
29
+ * @attr {"xs"|"small"|"medium"|"large"|"xl"} size - The size, 24 to 56px tall. Defaults to `medium`.
30
+ * @attr {"button"|"submit"|"reset"} type - The native button type. Defaults to `button`.
31
+ * @attr {string} href - Renders a link to this address instead of a button.
32
+ * @attr {string} label - The accessible name, when the text alone isn't enough.
33
+ * @attr {boolean} disabled - Disables the button.
34
+ * @attr {boolean} loading - Shows a spinning ring in the icon's place and ignores clicks.
35
+ * @attr {string} icon - SVG path data for an icon before the text.
36
+ * @attr {"show"|"hide"|"auto"} labels - Whether the text shows beside an icon: always, never, or not on narrow screens. Defaults to `show`.
37
+ *
38
+ * @slot - The text.
39
+ * @slot icon - An icon before the text, in place of `icon`.
40
+ *
41
+ * @csspart button - The native button or link.
42
+ *
43
+ * @cssprop --gw-button-scale - Scales every dimension, around 1 for medium.
44
+ * @cssprop --gw-button-tone - The color, set by `variant`.
45
+ * @cssprop --gw-button-ink - The text color, when it differs from the tone.
46
+ */
47
+ export class GwButton extends GwElement {
48
+ static override properties = {
49
+ variant: { reflect: true },
50
+ appearance: { reflect: true },
51
+ size: { reflect: true },
52
+ type: {},
53
+ href: {},
54
+ label: {},
55
+ disabled: { type: "boolean", reflect: true },
56
+ loading: { type: "boolean", reflect: true },
57
+ icon: {},
58
+ labels: { reflect: true },
59
+ } as const;
60
+
61
+ /** Focusing the element focuses its native button, such as from a dialog opening. */
62
+ static override shadowRootOptions: ShadowRootInit = {
63
+ mode: "open",
64
+ delegatesFocus: true,
65
+ };
66
+
67
+ static override styles = [
68
+ base,
69
+ css`
70
+ :host {
71
+ --gw-button-tone: var(--gw-color-accent);
72
+
73
+ display: inline-block;
74
+ }
75
+
76
+ /* Everything scales from --gw-button-scale, around a 40px medium
77
+ button: the presets give 24, 32, 48 and 56px, and an app can set any
78
+ value for a size between or beyond them */
79
+ :host([size="xs"]) {
80
+ --gw-button-scale: 0.6;
81
+ }
82
+
83
+ :host([size="small"]) {
84
+ --gw-button-scale: 0.8;
85
+ }
86
+
87
+ :host([size="large"]) {
88
+ --gw-button-scale: 1.2;
89
+ }
90
+
91
+ :host([size="xl"]) {
92
+ --gw-button-scale: 1.4;
93
+ }
94
+
95
+ :host([variant="neutral"]) {
96
+ --gw-button-tone: var(--gw-color-text-secondary);
97
+ --gw-button-ink: var(--gw-color-text-strong);
98
+ }
99
+
100
+ :host([variant="info"]) {
101
+ --gw-button-tone: var(--gw-color-info);
102
+ }
103
+
104
+ :host([variant="success"]) {
105
+ --gw-button-tone: var(--gw-color-success);
106
+ }
107
+
108
+ :host([variant="warning"]) {
109
+ --gw-button-tone: var(--gw-color-warning);
110
+ }
111
+
112
+ :host([variant="danger"]) {
113
+ --gw-button-tone: var(--gw-color-danger);
114
+ }
115
+
116
+ .button {
117
+ --gw-scale: var(--gw-button-scale, 1);
118
+ --gw-icon-size: calc(1.125rem * var(--gw-scale));
119
+
120
+ position: relative;
121
+ display: inline-flex;
122
+ gap: calc(var(--gw-space-2) * var(--gw-scale));
123
+ align-items: center;
124
+ justify-content: center;
125
+ min-height: calc(2.5rem * var(--gw-scale));
126
+ padding: 0 calc(var(--gw-space-4) * var(--gw-scale));
127
+ font: inherit;
128
+ font-size: calc(var(--gw-font-size-s) * var(--gw-scale));
129
+ font-weight: var(--gw-font-weight-strong);
130
+ line-height: 1;
131
+ color: var(--gw-button-ink, var(--gw-button-tone));
132
+ text-decoration: none;
133
+ text-transform: var(--gw-label-transform);
134
+ letter-spacing: var(--gw-label-tracking);
135
+ white-space: nowrap;
136
+ cursor: pointer;
137
+ background: transparent;
138
+ border: max(var(--gw-border-width), 1px) solid transparent;
139
+ border-radius: var(--gw-radius-pill);
140
+ transition:
141
+ background-color var(--gw-motion-fast) var(--gw-motion-easing),
142
+ border-color var(--gw-motion-fast) var(--gw-motion-easing),
143
+ color var(--gw-motion-fast) var(--gw-motion-easing),
144
+ scale var(--gw-motion-fast) var(--gw-motion-easing);
145
+ }
146
+
147
+ /* A slight press, so a click feels physical */
148
+ .button:active {
149
+ scale: 0.97;
150
+ }
151
+
152
+ .button:hover {
153
+ background: color-mix(in oklch, var(--gw-button-tone) 16%, transparent);
154
+ }
155
+
156
+ .button:active {
157
+ background: color-mix(in oklch, var(--gw-button-tone) 24%, transparent);
158
+ }
159
+
160
+ /* A solid fill, with text that flips between black and white by the
161
+ fill's lightness so it reads on every theme's colors */
162
+ :host([appearance="accent"]) .button {
163
+ color: oklch(
164
+ from var(--gw-button-tone) clamp(0, (0.66 - l) * 1000, 1) 0 0
165
+ );
166
+ background: var(--gw-button-tone);
167
+ }
168
+
169
+ :host([appearance="accent"]) .button:hover {
170
+ background: oklch(
171
+ from var(--gw-button-tone) calc(l + (0.6 - l) * 0.2) c h
172
+ );
173
+ }
174
+
175
+ :host([appearance="accent"]) .button:active {
176
+ background: oklch(
177
+ from var(--gw-button-tone) calc(l + (0.6 - l) * 0.35) c h
178
+ );
179
+ }
180
+
181
+ /* The tint lowers the tone's contrast, so the text leans toward the
182
+ strongest text color to stay readable on it */
183
+ :host([appearance="filled"]) .button {
184
+ color: var(
185
+ --gw-button-ink,
186
+ color-mix(
187
+ in oklch,
188
+ var(--gw-button-tone) 70%,
189
+ var(--gw-color-text-strong)
190
+ )
191
+ );
192
+ background: color-mix(in oklch, var(--gw-button-tone) 16%, transparent);
193
+ }
194
+
195
+ :host([appearance="filled"]) .button:hover {
196
+ background: color-mix(in oklch, var(--gw-button-tone) 24%, transparent);
197
+ }
198
+
199
+ :host([appearance="filled"]) .button:active {
200
+ background: color-mix(in oklch, var(--gw-button-tone) 32%, transparent);
201
+ }
202
+
203
+ :host([appearance="outlined"]) .button {
204
+ border-color: var(--gw-button-tone);
205
+ }
206
+
207
+ .button:disabled,
208
+ .button[aria-disabled="true"] {
209
+ color: var(--gw-color-text-disabled);
210
+ pointer-events: none;
211
+ cursor: not-allowed;
212
+ background: var(--gw-color-fill-neutral-disabled);
213
+ border-color: transparent;
214
+ }
215
+
216
+ :host([appearance="plain"]) .button:disabled,
217
+ :host([appearance="plain"]) .button[aria-disabled="true"] {
218
+ background: transparent;
219
+ }
220
+
221
+ ::slotted([slot="icon"]),
222
+ .icon {
223
+ flex: none;
224
+ width: var(--gw-icon-size);
225
+ height: var(--gw-icon-size);
226
+ fill: currentColor;
227
+ }
228
+
229
+ /* The icon and the ring share one cell and crossfade */
230
+ .lead {
231
+ display: grid;
232
+ place-items: center;
233
+ }
234
+
235
+ .lead > * {
236
+ grid-area: 1 / 1;
237
+ }
238
+
239
+ .lead > *,
240
+ .text,
241
+ .ring {
242
+ transition: opacity var(--gw-motion-normal) cubic-bezier(0.4, 0, 0.2, 1);
243
+ }
244
+
245
+ /* Sized to the icon's drawn area, which is inset in its 24px box */
246
+ .ring {
247
+ width: calc(var(--gw-icon-size) * 0.8);
248
+ height: calc(var(--gw-icon-size) * 0.8);
249
+ border: calc(2px * var(--gw-scale)) solid
250
+ color-mix(in oklch, currentColor 25%, transparent);
251
+ border-top-color: currentColor;
252
+ border-radius: 50%;
253
+ opacity: 0;
254
+ animation: spin 0.9s linear infinite;
255
+ }
256
+
257
+ @keyframes spin {
258
+ to {
259
+ transform: rotate(360deg);
260
+ }
261
+ }
262
+
263
+ /* Without an icon, the ring sits over the hidden text */
264
+ .ring.over {
265
+ position: absolute;
266
+ inset: 0;
267
+ margin: auto;
268
+ }
269
+
270
+ :host([loading]) .button {
271
+ cursor: progress;
272
+ }
273
+
274
+ :host([loading]) .lead > .ring,
275
+ :host([loading]) .button:not(.has-icon) > .ring.over {
276
+ opacity: 1;
277
+ }
278
+
279
+ :host([loading]) .lead > :not(.ring),
280
+ :host([loading]) .button:not(.has-icon) > .text {
281
+ opacity: 0;
282
+ }
283
+
284
+ .button:not(.has-icon) > .lead {
285
+ display: none;
286
+ }
287
+
288
+ /* Icon only, to save space: square, with the text kept for assistive
289
+ technology. auto does this on narrow screens */
290
+ :host([labels="hide"]) .button.has-icon {
291
+ min-width: calc(2.5rem * var(--gw-scale));
292
+ padding: 0;
293
+ }
294
+
295
+ :host([labels="hide"]) .has-icon .text {
296
+ position: absolute;
297
+ width: 1px;
298
+ height: 1px;
299
+ overflow: hidden;
300
+ clip-path: inset(50%);
301
+ white-space: nowrap;
302
+ }
303
+
304
+ @media (width < 40rem) {
305
+ :host([labels="auto"]) .button.has-icon {
306
+ min-width: calc(2.5rem * var(--gw-scale));
307
+ padding: 0;
308
+ }
309
+
310
+ :host([labels="auto"]) .has-icon .text {
311
+ position: absolute;
312
+ width: 1px;
313
+ height: 1px;
314
+ overflow: hidden;
315
+ clip-path: inset(50%);
316
+ white-space: nowrap;
317
+ }
318
+ }
319
+ `,
320
+ ];
321
+
322
+ declare variant:
323
+ "brand" | "neutral" | "info" | "success" | "warning" | "danger";
324
+ declare appearance: "accent" | "filled" | "outlined" | "plain";
325
+ declare size: "xs" | "small" | "medium" | "large" | "xl";
326
+ declare type: "button" | "submit" | "reset";
327
+ declare href: string;
328
+ declare label: string;
329
+ declare disabled: boolean;
330
+ /** Shows a spinning ring in place of the icon and ignores clicks. */
331
+ declare loading: boolean;
332
+ /** SVG path data for an icon before the text. */
333
+ declare icon: string;
334
+ /** Whether the text shows beside an icon: always, never, or not on narrow screens. */
335
+ declare labels: "show" | "hide" | "auto";
336
+
337
+ readonly #icon = createIcon("");
338
+
339
+ readonly #iconSlot = h("slot", { name: "icon" });
340
+
341
+ readonly #lead = h(
342
+ "span",
343
+ { class: "lead" },
344
+ this.#icon,
345
+ this.#iconSlot,
346
+ h("span", { class: "ring" }),
347
+ );
348
+
349
+ readonly #text = h("span", { class: "text" }, h("slot"));
350
+
351
+ readonly #over = h("span", { class: "ring over" });
352
+
353
+ #control: HTMLButtonElement | HTMLAnchorElement = h("button", {
354
+ class: "button",
355
+ part: "button",
356
+ });
357
+
358
+ constructor() {
359
+ super();
360
+ this.icon = "";
361
+ this.labels = "show";
362
+ this.variant = "brand";
363
+ this.appearance = "accent";
364
+ this.size = "medium";
365
+ this.type = "button";
366
+ this.href = "";
367
+ this.label = "";
368
+ this.disabled = false;
369
+ this.loading = false;
370
+
371
+ // Registered first, so it runs before any listener an app adds
372
+ this.addEventListener("click", (event) => {
373
+ if (this.loading) {
374
+ event.preventDefault();
375
+ event.stopImmediatePropagation();
376
+ }
377
+ });
378
+
379
+ this.#iconSlot.addEventListener("slotchange", () => {
380
+ this.requestUpdate("icon");
381
+ });
382
+ }
383
+
384
+ protected override create(root: ShadowRoot) {
385
+ this.#control.append(this.#lead, this.#text, this.#over);
386
+ root.append(this.#control);
387
+ }
388
+
389
+ protected override update(changed: ReadonlySet<string>) {
390
+ if (changed.has("href")) {
391
+ const wanted = this.href ? "A" : "BUTTON";
392
+
393
+ if (this.#control.tagName !== wanted) {
394
+ const next = this.href
395
+ ? h("a", { class: "button", part: "button" })
396
+ : h("button", { class: "button", part: "button" });
397
+
398
+ next.append(...this.#control.childNodes);
399
+ this.#control.replaceWith(next);
400
+ this.#control = next;
401
+ }
402
+ }
403
+
404
+ const control = this.#control;
405
+
406
+ const hasIcon =
407
+ this.icon !== "" || this.querySelector("[slot='icon']") !== null;
408
+
409
+ control.classList.toggle("has-icon", hasIcon);
410
+ setIconPath(this.#icon, this.icon);
411
+ this.#icon.style.display = this.icon ? "" : "none";
412
+ setAttribute(
413
+ control,
414
+ "title",
415
+ hasIcon && this.labels !== "show"
416
+ ? this.textContent.trim() || null
417
+ : null,
418
+ );
419
+ setAttribute(control, "aria-label", this.label || null);
420
+ setAttribute(control, "aria-busy", this.loading ? "true" : null);
421
+
422
+ if (control instanceof HTMLAnchorElement) {
423
+ setAttribute(control, "href", this.disabled ? null : this.href);
424
+ setAttribute(control, "aria-disabled", this.disabled ? "true" : null);
425
+ } else {
426
+ control.type = this.type;
427
+ control.disabled = this.disabled;
428
+ }
429
+ }
430
+ }
431
+
432
+ GwButton.define("gw-button");
433
+
434
+ declare global {
435
+ interface HTMLElementTagNameMap {
436
+ "gw-button": GwButton;
437
+ }
438
+ }