@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,125 @@
1
+ /**
2
+ * `gw-page-header`: a page's title, icon, description and actions.
3
+ * Docs: https://groundwork.timmo.dev/components/layout/#page-header-and-section-title
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base, createIcon, setIconPath } from "./styles.js";
9
+
10
+ /**
11
+ * A page's title, with an optional icon tile and description, and its
12
+ * controls at the top right in the `actions` slot.
13
+ *
14
+ * @element gw-page-header
15
+ * @summary A page's title, icon, description and actions.
16
+ *
17
+ * @attr {string} heading - The page's title.
18
+ * @attr {string} description - A line under the title.
19
+ * @attr {string} icon - SVG path data for the icon tile.
20
+ *
21
+ * @slot actions - The page's controls.
22
+ */
23
+ export class GwPageHeader extends GwElement {
24
+ static override properties = {
25
+ heading: {},
26
+ description: {},
27
+ icon: {},
28
+ } as const;
29
+
30
+ static override styles = [
31
+ base,
32
+ css`
33
+ :host {
34
+ display: flex;
35
+ gap: var(--gw-space-3);
36
+ align-items: flex-start;
37
+ }
38
+
39
+ .tile {
40
+ display: grid;
41
+ flex: none;
42
+ place-items: center;
43
+ width: 3rem;
44
+ height: 3rem;
45
+ color: var(--gw-color-accent);
46
+ background: var(--gw-color-fill-accent-quiet);
47
+ border-radius: var(--gw-radius-l);
48
+ --gw-icon-size: 1.625rem;
49
+ }
50
+
51
+ .titles {
52
+ display: flex;
53
+ flex: 1;
54
+ flex-direction: column;
55
+ gap: var(--gw-space-0-5);
56
+ min-width: 0;
57
+ }
58
+
59
+ h1 {
60
+ margin: 0;
61
+ font-size: var(--gw-font-size-2xl);
62
+ font-weight: var(--gw-font-weight-strong);
63
+ line-height: 1.2;
64
+ color: var(--gw-color-text-strong);
65
+ }
66
+
67
+ p {
68
+ margin: 0;
69
+ font-size: var(--gw-font-size-s);
70
+ color: var(--gw-color-text-secondary);
71
+ }
72
+
73
+ .actions {
74
+ display: flex;
75
+ flex: none;
76
+ gap: var(--gw-space-1);
77
+ align-items: center;
78
+ margin-inline-start: auto;
79
+ }
80
+ `,
81
+ ];
82
+
83
+ declare heading: string;
84
+ declare description: string;
85
+ declare icon: string;
86
+
87
+ readonly #icon = createIcon("");
88
+
89
+ readonly #tile = h("span", { class: "tile" }, this.#icon);
90
+
91
+ readonly #heading = h("h1");
92
+
93
+ readonly #description = h("p");
94
+
95
+ constructor() {
96
+ super();
97
+ this.heading = "";
98
+ this.description = "";
99
+ this.icon = "";
100
+ }
101
+
102
+ protected override create(root: ShadowRoot) {
103
+ root.append(
104
+ this.#tile,
105
+ h("div", { class: "titles" }, this.#heading, this.#description),
106
+ h("div", { class: "actions" }, h("slot", { name: "actions" })),
107
+ );
108
+ }
109
+
110
+ protected override update() {
111
+ setIconPath(this.#icon, this.icon);
112
+ this.#tile.hidden = !this.icon;
113
+ this.#heading.textContent = this.heading;
114
+ this.#description.textContent = this.description;
115
+ this.#description.hidden = !this.description;
116
+ }
117
+ }
118
+
119
+ GwPageHeader.define("gw-page-header");
120
+
121
+ declare global {
122
+ interface HTMLElementTagNameMap {
123
+ "gw-page-header": GwPageHeader;
124
+ }
125
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * `gw-popover`: a small surface anchored to the button that opened it.
3
+ * Docs: https://groundwork.timmo.dev/components/overlays/#popover
4
+ *
5
+ * @module
6
+ */
7
+ import { Dismissal, dismissalStyles } from "./dismiss.js";
8
+ import { GwElement, css, h, setAttribute } from "./element.js";
9
+ import "./gw-button.js";
10
+ import { base } from "./styles.js";
11
+
12
+ const gap = 8;
13
+
14
+ // MDI's close
15
+ const closeIcon =
16
+ "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";
17
+
18
+ /** Below this width the popover is a bottom sheet, as Home Assistant's adaptive dialogs are. */
19
+ const sheetBelow = 600;
20
+
21
+ /**
22
+ * A confirmation, rename or small form that opens next to the button that
23
+ * asked for it, rather than as a dialog in the middle of the screen. On
24
+ * narrow screens it's a bottom sheet. Opened without a button, such as after
25
+ * a save, it sits in the middle like a dialog.
26
+ *
27
+ * It's on the Popover API, in the top layer, and closes on Esc or a click
28
+ * outside. Open it with `show(anchor)`. Put buttons in the `actions` slot: a
29
+ * click on one with a `value` closes it with that value, and fires `close`.
30
+ * `confirm` makes it an `alertdialog`, for a question that needs an answer.
31
+ *
32
+ * @element gw-popover
33
+ * @summary A small surface anchored to the button that opened it.
34
+ *
35
+ * @attr {string} heading - The heading beside the close button.
36
+ * @attr {boolean} confirm - Makes it an `alertdialog`, for a question that needs an answer.
37
+ *
38
+ * @slot - The content.
39
+ * @slot actions - Buttons; one with a `value` closes it with that value.
40
+ *
41
+ * @fires close - When it closes; `returnValue` holds the button's value.
42
+ */
43
+ export class GwPopover extends GwElement {
44
+ static override properties = {
45
+ heading: {},
46
+ confirm: { type: "boolean" },
47
+ } as const;
48
+
49
+ static override styles = [
50
+ base,
51
+ dismissalStyles,
52
+ css`
53
+ [popover] {
54
+ position: fixed;
55
+ inset: unset;
56
+ box-sizing: border-box;
57
+ width: min(20rem, calc(100vw - 2 * ${gap}px));
58
+ padding: var(--gw-space-4);
59
+ margin: 0;
60
+ color: var(--gw-color-text-primary);
61
+ background: var(--gw-color-surface-overlay);
62
+ border: max(var(--gw-border-width), 1px) solid
63
+ var(--gw-color-border-normal);
64
+ border-radius: var(--gw-radius-panel);
65
+ box-shadow: var(--gw-shadow-l);
66
+ opacity: 0;
67
+ scale: 0.96;
68
+ transition:
69
+ opacity var(--gw-motion-fast) 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
+ [popover]:popover-open {
77
+ opacity: 1;
78
+ scale: 1;
79
+ }
80
+
81
+ @starting-style {
82
+ [popover]:popover-open {
83
+ opacity: 0;
84
+ scale: 0.96;
85
+ }
86
+ }
87
+
88
+ /* Grows from the corner nearest the button */
89
+ [data-placement="below"] {
90
+ transform-origin: top right;
91
+ }
92
+
93
+ [data-placement="above"] {
94
+ transform-origin: bottom right;
95
+ }
96
+
97
+ [data-placement="below"][data-side="start"] {
98
+ transform-origin: top left;
99
+ }
100
+
101
+ [data-placement="above"][data-side="start"] {
102
+ transform-origin: bottom left;
103
+ }
104
+
105
+ [data-placement="center"] {
106
+ inset: 0;
107
+ margin: auto;
108
+ height: fit-content;
109
+ }
110
+
111
+ [data-placement="center"]::backdrop {
112
+ background: rgb(0 0 0 / 40%);
113
+ }
114
+
115
+ /* A bottom sheet on narrow screens, sliding up from the edge */
116
+ [data-placement="sheet"] {
117
+ inset: auto 0 0;
118
+ width: 100%;
119
+ max-width: none;
120
+ padding-block-end: calc(
121
+ var(--gw-space-4) + env(safe-area-inset-bottom)
122
+ );
123
+ border-end-start-radius: 0;
124
+ border-end-end-radius: 0;
125
+ scale: 1;
126
+ translate: 0 100%;
127
+ }
128
+
129
+ [data-placement="sheet"]:popover-open {
130
+ translate: 0 0;
131
+ }
132
+
133
+ @starting-style {
134
+ [data-placement="sheet"]:popover-open {
135
+ translate: 0 100%;
136
+ }
137
+ }
138
+
139
+ [data-placement="sheet"]::backdrop {
140
+ background: rgb(0 0 0 / 40%);
141
+ }
142
+
143
+ header {
144
+ display: flex;
145
+ gap: var(--gw-space-2);
146
+ align-items: flex-start;
147
+ margin: calc(var(--gw-space-2) * -1) calc(var(--gw-space-2) * -1)
148
+ var(--gw-space-2) 0;
149
+ }
150
+
151
+ header .content {
152
+ flex: 1;
153
+ min-width: 0;
154
+ padding-block-start: var(--gw-space-2);
155
+ }
156
+
157
+ h2 {
158
+ margin: 0 0 var(--gw-space-2);
159
+ font-size: var(--gw-font-size-m);
160
+ font-weight: var(--gw-font-weight-strong);
161
+ color: var(--gw-color-text-strong);
162
+ }
163
+
164
+ .body {
165
+ font-size: var(--gw-font-size-s);
166
+ }
167
+
168
+ ::slotted(p) {
169
+ margin: 0;
170
+ }
171
+
172
+ footer {
173
+ display: flex;
174
+ flex-wrap: wrap;
175
+ gap: var(--gw-space-2);
176
+ justify-content: flex-end;
177
+ margin-block-start: var(--gw-space-4);
178
+ }
179
+ `,
180
+ ];
181
+
182
+ declare heading: string;
183
+ declare confirm: boolean;
184
+
185
+ /** The value of the action that closed it, or "" for Esc or a click outside. */
186
+ returnValue = "";
187
+
188
+ readonly #heading = h("h2", { id: "heading" });
189
+
190
+ readonly #closeButton = h(
191
+ "gw-button",
192
+ {
193
+ class: "close",
194
+ variant: "neutral",
195
+ appearance: "plain",
196
+ size: "small",
197
+ labels: "hide",
198
+ icon: closeIcon,
199
+ },
200
+ "Close",
201
+ );
202
+
203
+ readonly #popover = h(
204
+ "div",
205
+ { popover: "manual", tabindex: "-1", "data-placement": "center" },
206
+ h(
207
+ "header",
208
+ {},
209
+ h(
210
+ "div",
211
+ { class: "content" },
212
+ this.#heading,
213
+ h("div", { class: "body", id: "body" }, h("slot")),
214
+ ),
215
+ this.#closeButton,
216
+ ),
217
+ h("footer", {}, h("slot", { name: "actions" })),
218
+ );
219
+
220
+ readonly #dismissal = new Dismissal(
221
+ this,
222
+ this.#popover,
223
+ () => this.close(),
224
+ () => this.#position(),
225
+ );
226
+
227
+ #anchor: Element | undefined;
228
+
229
+ constructor() {
230
+ super();
231
+ this.heading = "";
232
+ this.confirm = false;
233
+ }
234
+
235
+ /** Whether its content has changed since it opened, so closing it asks first. */
236
+ get dirty() {
237
+ return this.#dismissal.dirty;
238
+ }
239
+
240
+ set dirty(dirty: boolean) {
241
+ this.#dismissal.mark(dirty);
242
+ }
243
+
244
+ /** Opens next to `anchor`, or in the middle without one. */
245
+ show(anchor?: Element) {
246
+ this.#anchor = anchor;
247
+ this.returnValue = "";
248
+ this.#dismissal.reset();
249
+ this.#popover.dataset.placement = this.#placementFor(anchor);
250
+ this.#popover.showPopover();
251
+ }
252
+
253
+ close(value = "") {
254
+ this.returnValue = value;
255
+
256
+ if (this.#popover.matches(":popover-open")) this.#popover.hidePopover();
257
+ }
258
+
259
+ // Esc and a click outside close it at once, unless there are changes:
260
+ // then Esc asks first and a click outside does nothing
261
+ readonly #onKeydown = (event: KeyboardEvent) => {
262
+ if (event.key !== "Escape") return;
263
+
264
+ event.preventDefault();
265
+ event.stopPropagation();
266
+ this.#dismissal.request();
267
+ };
268
+
269
+ readonly #onPointerdown = (event: PointerEvent) => {
270
+ if (event.composedPath().includes(this)) return;
271
+
272
+ if (!this.#dismissal.dirty) this.close();
273
+ };
274
+
275
+ #placementFor(anchor: Element | undefined) {
276
+ if (window.innerWidth < sheetBelow) return "sheet";
277
+
278
+ return anchor === undefined ? "center" : "below";
279
+ }
280
+
281
+ /** Under the button, lined up with its right edge, or above it when there's more room there. */
282
+ readonly #position = () => {
283
+ const popover = this.#popover;
284
+ const anchor = this.#anchor;
285
+ const placement = this.#placementFor(anchor);
286
+
287
+ if (anchor === undefined || placement === "sheet") {
288
+ popover.style.top = "";
289
+ popover.style.left = "";
290
+ popover.dataset.placement = placement;
291
+
292
+ return;
293
+ }
294
+
295
+ const rect = anchor.getBoundingClientRect();
296
+ const below = window.innerHeight - rect.bottom - gap;
297
+ const fitsBelow = below >= popover.offsetHeight || below >= rect.top;
298
+
299
+ // Lined up with the button's edge on its side of the screen, so the
300
+ // popover opens toward the space rather than across the content
301
+ const fromStart = rect.left + rect.width / 2 < window.innerWidth / 2;
302
+ const preferred = fromStart ? rect.left : rect.right - popover.offsetWidth;
303
+
304
+ const left = Math.min(
305
+ Math.max(gap, preferred),
306
+ window.innerWidth - popover.offsetWidth - gap,
307
+ );
308
+
309
+ popover.dataset.side = fromStart ? "start" : "end";
310
+
311
+ popover.dataset.placement = fitsBelow ? "below" : "above";
312
+ popover.style.top = `${fitsBelow ? rect.bottom + gap : Math.max(gap, rect.top - gap - popover.offsetHeight)}px`;
313
+ popover.style.left = `${left}px`;
314
+ };
315
+
316
+ readonly #onToggle = (event: ToggleEvent) => {
317
+ if (event.newState === "open") {
318
+ this.#position();
319
+ window.addEventListener("resize", this.#position);
320
+ window.addEventListener("scroll", this.#position, true);
321
+ document.addEventListener("pointerdown", this.#onPointerdown, true);
322
+
323
+ // The close button takes focus, so Enter never confirms by accident
324
+ this.#closeButton.focus();
325
+
326
+ return;
327
+ }
328
+
329
+ window.removeEventListener("resize", this.#position);
330
+ window.removeEventListener("scroll", this.#position, true);
331
+ document.removeEventListener("pointerdown", this.#onPointerdown, true);
332
+
333
+ if (this.#anchor instanceof HTMLElement) this.#anchor.focus();
334
+
335
+ this.dispatchEvent(new Event("close", { bubbles: true, composed: true }));
336
+ };
337
+
338
+ readonly #onAction = (event: Event) => {
339
+ const target = event.target;
340
+
341
+ if (target instanceof HTMLElement && target.hasAttribute("value")) {
342
+ this.close(target.getAttribute("value") ?? "");
343
+ }
344
+ };
345
+
346
+ override disconnectedCallback() {
347
+ window.removeEventListener("resize", this.#position);
348
+ window.removeEventListener("scroll", this.#position, true);
349
+ document.removeEventListener("pointerdown", this.#onPointerdown, true);
350
+ super.disconnectedCallback();
351
+ }
352
+
353
+ protected override create(root: ShadowRoot) {
354
+ this.#popover.addEventListener("toggle", this.#onToggle);
355
+ this.#popover.addEventListener("keydown", this.#onKeydown);
356
+ this.#closeButton.addEventListener("click", () => {
357
+ this.#dismissal.request();
358
+ });
359
+ this.#popover
360
+ .querySelector("footer")
361
+ ?.addEventListener("click", this.#onAction);
362
+ root.append(this.#popover);
363
+ }
364
+
365
+ protected override update() {
366
+ const popover = this.#popover;
367
+
368
+ setAttribute(popover, "role", this.confirm ? "alertdialog" : "dialog");
369
+ setAttribute(popover, "aria-labelledby", this.heading ? "heading" : "body");
370
+ setAttribute(popover, "aria-describedby", this.heading ? "body" : null);
371
+ this.#heading.textContent = this.heading;
372
+ this.#heading.hidden = !this.heading;
373
+ }
374
+ }
375
+
376
+ GwPopover.define("gw-popover");
377
+
378
+ declare global {
379
+ interface HTMLElementTagNameMap {
380
+ "gw-popover": GwPopover;
381
+ }
382
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `gw-section-title`: a heading for a section of a page.
3
+ * Docs: https://groundwork.timmo.dev/components/layout/#page-header-and-section-title
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base } from "./styles.js";
9
+
10
+ /**
11
+ * A small heading between groups on a page, as an `<h2>` unless `level` says otherwise.
12
+ *
13
+ * @element gw-section-title
14
+ * @summary A heading for a section of a page.
15
+ *
16
+ * @attr {number} level - The heading level. Defaults to 2.
17
+ *
18
+ * @slot - The heading text.
19
+ * @slot actions - Buttons at the end of the row.
20
+ */
21
+ export class GwSectionTitle extends GwElement {
22
+ static override properties = {
23
+ level: { type: "number" },
24
+ } as const;
25
+
26
+ static override styles = [
27
+ base,
28
+ css`
29
+ :host {
30
+ display: flex;
31
+ gap: var(--gw-space-2);
32
+ align-items: center;
33
+ }
34
+
35
+ [role="heading"] {
36
+ flex: 1;
37
+ margin: 0;
38
+ font-size: var(--gw-font-size-xs);
39
+ font-weight: var(--gw-font-weight-strong);
40
+ color: var(--gw-color-text-secondary);
41
+ text-transform: uppercase;
42
+ letter-spacing: 0.04em;
43
+ }
44
+ `,
45
+ ];
46
+
47
+ declare level: number;
48
+
49
+ readonly #heading = h("div", { role: "heading" }, h("slot"));
50
+
51
+ constructor() {
52
+ super();
53
+ this.level = 2;
54
+ }
55
+
56
+ protected override create(root: ShadowRoot) {
57
+ root.append(this.#heading, h("slot", { name: "actions" }));
58
+ }
59
+
60
+ protected override update() {
61
+ this.#heading.setAttribute("aria-level", String(this.level));
62
+ }
63
+ }
64
+
65
+ GwSectionTitle.define("gw-section-title");
66
+
67
+ declare global {
68
+ interface HTMLElementTagNameMap {
69
+ "gw-section-title": GwSectionTitle;
70
+ }
71
+ }