@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/layout.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `gw-page`, `gw-stack`, `gw-row` and `gw-grid`: page width and spacing.
3
+ * Docs: https://groundwork.timmo.dev/components/layout/#stack-row-and-grid
4
+ *
5
+ * @module
6
+ */
7
+ import { GwElement, css, h } from "./element.js";
8
+ import { base } from "./styles.js";
9
+
10
+ /**
11
+ * Centers a page's content at the theme's width, with the page gap between
12
+ * its children.
13
+ *
14
+ * @element gw-page
15
+ * @summary A page's content column.
16
+ *
17
+ * @attr {boolean} narrow - Uses the narrow content width, for forms and settings.
18
+ *
19
+ * @slot - The page's content.
20
+ *
21
+ * @csspart page - The content column.
22
+ */
23
+ export class GwPage extends GwElement {
24
+ static override properties = {
25
+ narrow: { type: "boolean", reflect: true },
26
+ } as const;
27
+
28
+ static override styles = [
29
+ base,
30
+ css`
31
+ :host {
32
+ display: block;
33
+ }
34
+
35
+ .page {
36
+ display: flex;
37
+ flex-direction: column;
38
+ gap: var(--gw-space-6);
39
+ width: 100%;
40
+ max-width: var(--gw-content-width);
41
+ margin-inline: auto;
42
+ }
43
+
44
+ :host([narrow]) .page {
45
+ max-width: var(--gw-content-width-narrow);
46
+ }
47
+ `,
48
+ ];
49
+
50
+ declare narrow: boolean;
51
+
52
+ constructor() {
53
+ super();
54
+ this.narrow = false;
55
+ }
56
+
57
+ protected override create(root: ShadowRoot) {
58
+ root.append(h("div", { class: "page", part: "page" }, h("slot")));
59
+ }
60
+ }
61
+
62
+ const gaps = css`
63
+ :host([gap="s"]) {
64
+ --gw-gap: var(--gw-space-1);
65
+ }
66
+
67
+ :host([gap="l"]) {
68
+ --gw-gap: var(--gw-space-6);
69
+ }
70
+ `;
71
+
72
+ /** A layout element whose shadow root is a single slot. */
73
+ class GwSlotted extends GwElement {
74
+ protected override create(root: ShadowRoot) {
75
+ root.append(h("slot"));
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Stacks its children in a column.
81
+ *
82
+ * @element gw-stack
83
+ * @summary Children in a column.
84
+ *
85
+ * @attr {"s"|"l"} gap - A smaller or larger gap.
86
+ *
87
+ * @slot - The children.
88
+ *
89
+ * @cssprop --gw-gap - The gap. Defaults to `--gw-space-3`.
90
+ */
91
+ export class GwStack extends GwSlotted {
92
+ static override styles = [
93
+ base,
94
+ gaps,
95
+ css`
96
+ :host {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: var(--gw-gap, var(--gw-space-3));
100
+ }
101
+ `,
102
+ ];
103
+ }
104
+
105
+ /**
106
+ * Lines its children up in a row, wrapping when they run out of room.
107
+ *
108
+ * @element gw-row
109
+ * @summary Children in a wrapping row.
110
+ *
111
+ * @attr {"s"|"l"} gap - A smaller or larger gap.
112
+ * @attr {boolean} nowrap - Keeps the children on one line.
113
+ *
114
+ * @slot - The children.
115
+ *
116
+ * @cssprop --gw-gap - The gap. Defaults to `--gw-space-2`.
117
+ */
118
+ export class GwRow extends GwSlotted {
119
+ static override styles = [
120
+ base,
121
+ gaps,
122
+ css`
123
+ :host {
124
+ display: flex;
125
+ flex-wrap: wrap;
126
+ gap: var(--gw-gap, var(--gw-space-2));
127
+ align-items: center;
128
+ }
129
+
130
+ :host([nowrap]) {
131
+ flex-wrap: nowrap;
132
+ }
133
+ `,
134
+ ];
135
+ }
136
+
137
+ /**
138
+ * Fills as many columns as fit, each at least `--gw-grid-min` wide.
139
+ *
140
+ * @element gw-grid
141
+ * @summary Children in responsive columns.
142
+ *
143
+ * @attr {"s"|"l"} gap - A smaller or larger gap.
144
+ *
145
+ * @slot - The children.
146
+ *
147
+ * @cssprop --gw-grid-min - The narrowest a column gets. Defaults to `18rem`.
148
+ * @cssprop --gw-gap - The gap. Defaults to `--gw-space-3`.
149
+ */
150
+ export class GwGrid extends GwSlotted {
151
+ static override styles = [
152
+ base,
153
+ gaps,
154
+ css`
155
+ :host {
156
+ display: grid;
157
+ grid-template-columns: repeat(
158
+ auto-fill,
159
+ minmax(min(100%, var(--gw-grid-min, 18rem)), 1fr)
160
+ );
161
+ gap: var(--gw-gap, var(--gw-space-3));
162
+ }
163
+ `,
164
+ ];
165
+ }
166
+
167
+ GwPage.define("gw-page");
168
+
169
+ GwStack.define("gw-stack");
170
+
171
+ GwRow.define("gw-row");
172
+
173
+ GwGrid.define("gw-grid");
174
+
175
+ declare global {
176
+ interface HTMLElementTagNameMap {
177
+ "gw-page": GwPage;
178
+ "gw-stack": GwStack;
179
+ "gw-row": GwRow;
180
+ "gw-grid": GwGrid;
181
+ }
182
+ }
package/src/motion.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Motion helpers: fades that never pop, heights that glide instead of jump,
3
+ * and View Transitions for whole-view changes. All respect reduced motion.
4
+ *
5
+ * @module
6
+ */
7
+
8
+ /** Whether the person asked for reduced motion. */
9
+ export const reducedMotion = () =>
10
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
11
+
12
+ /**
13
+ * Reads a motion duration token in milliseconds, such as
14
+ * `--gw-motion-slow`, falling back when the tokens aren't loaded.
15
+ */
16
+ export const motionDuration = (
17
+ element: Element,
18
+ token: string,
19
+ fallback: number,
20
+ ) =>
21
+ Number.parseFloat(getComputedStyle(element).getPropertyValue(token)) ||
22
+ fallback;
23
+
24
+ /**
25
+ * The curve for fades. A decelerating curve lands most of an opacity change
26
+ * in the first frames and reads as a pop, so fades ease in and out.
27
+ */
28
+ export const fadeEasing = "cubic-bezier(0.4, 0, 0.2, 1)";
29
+
30
+ /**
31
+ * Fades `element` in, after `delay`, over `duration` (`--gw-motion-slow` by
32
+ * default). Use it when content replaces a skeleton. With reduced motion it
33
+ * shows at once.
34
+ */
35
+ export const fadeIn = (
36
+ element: HTMLElement,
37
+ { delay = 0, duration }: { delay?: number; duration?: number } = {},
38
+ ) =>
39
+ element.animate([{ opacity: 0 }, { opacity: 1 }], {
40
+ delay,
41
+ duration: reducedMotion()
42
+ ? 0
43
+ : (duration ?? motionDuration(element, "--gw-motion-slow", 350)),
44
+ easing: fadeEasing,
45
+ fill: "backwards",
46
+ });
47
+
48
+ /**
49
+ * Glides `element` from the height it had before a change to the height it
50
+ * has after, as an expansion panel opens, so content arriving or going never
51
+ * jumps the layout. Measure with `before = element.offsetHeight` ahead of the
52
+ * change, then call this once it's rendered. Skipped when motion is reduced.
53
+ */
54
+ export const animateHeight = (element: HTMLElement, before: number) => {
55
+ const after = element.offsetHeight;
56
+
57
+ if (before === after || before === 0 || reducedMotion()) {
58
+ return;
59
+ }
60
+
61
+ const duration = motionDuration(element, "--gw-motion-normal", 250);
62
+
63
+ element.animate(
64
+ { height: [`${before}px`, `${after}px`], overflow: ["clip", "clip"] },
65
+ { duration, easing: "cubic-bezier(0.2, 0, 0, 1)" },
66
+ );
67
+ };
68
+
69
+ /**
70
+ * Runs a DOM change inside a View Transition where the browser supports one
71
+ * and motion isn't reduced, otherwise runs it straight away, after Home
72
+ * Assistant's `withViewTransition()`. Use it for changes to the whole view,
73
+ * such as switching theme or mode; component state uses CSS transitions.
74
+ *
75
+ * The change may return a promise, such as a Lit element's
76
+ * `updateComplete`, so the new view is captured once it has rendered. Keep
77
+ * it quick: the page is frozen until it settles.
78
+ */
79
+ export const withViewTransition = (
80
+ change: () => void | Promise<void>,
81
+ ): Promise<void> => {
82
+ const reduced = reducedMotion();
83
+
84
+ if (document.startViewTransition === undefined || reduced) {
85
+ return Promise.resolve(change());
86
+ }
87
+
88
+ let changed = false;
89
+
90
+ try {
91
+ const transition = document.startViewTransition(() => {
92
+ changed = true;
93
+
94
+ return change();
95
+ });
96
+
97
+ // A transition skipped by a newer one rejects with AbortError; the change
98
+ // itself still happened.
99
+ transition.ready.catch(() => undefined);
100
+
101
+ return transition.finished.catch(() => undefined);
102
+ } catch {
103
+ return changed ? Promise.resolve() : Promise.resolve(change());
104
+ }
105
+ };
package/src/styles.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Styles and helpers every component shares: the base sheet, tones and icons.
3
+ *
4
+ * @module
5
+ */
6
+ import { css } from "./element.js";
7
+
8
+ const svgNamespace = "http://www.w3.org/2000/svg";
9
+
10
+ /**
11
+ * What every component shares: box sizing, the focus ring, icons and the
12
+ * tones that set `--gw-tone` from a `tone` attribute.
13
+ */
14
+ export const base = css`
15
+ :host {
16
+ font-family: var(--gw-font-sans);
17
+ color: var(--gw-color-text-primary);
18
+ }
19
+
20
+ *,
21
+ *::before,
22
+ *::after {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ [hidden] {
27
+ display: none !important;
28
+ }
29
+
30
+ :focus-visible {
31
+ outline: var(--gw-focus-width) solid var(--gw-color-focus);
32
+ outline-offset: var(--gw-focus-offset);
33
+ }
34
+
35
+ .icon {
36
+ flex: none;
37
+ width: var(--gw-icon-size, 1.25rem);
38
+ height: var(--gw-icon-size, 1.25rem);
39
+ fill: currentColor;
40
+ }
41
+
42
+ :host([tone="accent"]) {
43
+ --gw-tone: var(--gw-color-accent);
44
+ --gw-tone-fill: var(--gw-color-fill-accent-quiet);
45
+ --gw-tone-border: var(--gw-color-border-accent);
46
+ }
47
+
48
+ :host([tone="info"]) {
49
+ --gw-tone: var(--gw-color-info);
50
+ --gw-tone-fill: var(--gw-color-fill-info-quiet);
51
+ --gw-tone-border: var(--gw-color-border-info);
52
+ }
53
+
54
+ :host([tone="success"]) {
55
+ --gw-tone: var(--gw-color-success);
56
+ --gw-tone-fill: var(--gw-color-fill-success-quiet);
57
+ --gw-tone-border: var(--gw-color-border-success);
58
+ }
59
+
60
+ :host([tone="warning"]) {
61
+ --gw-tone: var(--gw-color-warning);
62
+ --gw-tone-fill: var(--gw-color-fill-warning-quiet);
63
+ --gw-tone-border: var(--gw-color-border-warning);
64
+ }
65
+
66
+ :host([tone="danger"]) {
67
+ --gw-tone: var(--gw-color-danger);
68
+ --gw-tone-fill: var(--gw-color-fill-danger-quiet);
69
+ --gw-tone-border: var(--gw-color-border-danger);
70
+ }
71
+
72
+ :host([tone="important"]) {
73
+ --gw-tone: var(--gw-color-important);
74
+ --gw-tone-fill: var(--gw-color-fill-important-quiet);
75
+ --gw-tone-border: var(--gw-color-border-important);
76
+ }
77
+
78
+ @media (prefers-reduced-motion: reduce) {
79
+ *,
80
+ *::before,
81
+ *::after {
82
+ animation-duration: 0s !important;
83
+ transition-duration: 0s !important;
84
+ }
85
+ }
86
+ `;
87
+
88
+ /** The tones a component's `tone` attribute takes. */
89
+ export type Tone =
90
+ | "neutral"
91
+ | "accent"
92
+ | "info"
93
+ | "success"
94
+ | "warning"
95
+ | "danger"
96
+ | "important";
97
+
98
+ /**
99
+ * An icon from SVG path data, such as one of `@mdi/js`'s or Lucide's paths,
100
+ * so each app picks its own set. Without a label it's hidden from assistive
101
+ * technology. Change its path later with `setIconPath`.
102
+ */
103
+ export const createIcon = (path: string, label?: string) => {
104
+ const element = document.createElementNS(svgNamespace, "svg");
105
+
106
+ element.classList.add("icon");
107
+ element.setAttribute("viewBox", "0 0 24 24");
108
+ element.setAttribute("role", label === undefined ? "presentation" : "img");
109
+ element.setAttribute("aria-hidden", label === undefined ? "true" : "false");
110
+
111
+ if (label !== undefined) element.setAttribute("aria-label", label);
112
+
113
+ element.append(document.createElementNS(svgNamespace, "path"));
114
+ setIconPath(element, path);
115
+
116
+ return element;
117
+ };
118
+
119
+ /** Changes an icon's path data. */
120
+ export const setIconPath = (icon: SVGSVGElement, path: string) => {
121
+ icon.querySelector("path")?.setAttribute("d", path);
122
+ };