@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/LICENSE +201 -0
- package/README.md +32 -2
- package/package.json +38 -4
- package/src/dismiss.ts +165 -0
- package/src/element.ts +375 -0
- package/src/field-info.ts +56 -0
- package/src/fields.ts +346 -0
- package/src/gw-alert.ts +117 -0
- package/src/gw-badge.ts +171 -0
- package/src/gw-banner.ts +120 -0
- package/src/gw-button.ts +438 -0
- package/src/gw-card.ts +187 -0
- package/src/gw-checkbox.ts +113 -0
- package/src/gw-chip.ts +122 -0
- package/src/gw-command-menu.ts +283 -0
- package/src/gw-dialog.ts +246 -0
- package/src/gw-dot.ts +89 -0
- package/src/gw-empty-state.ts +107 -0
- package/src/gw-expansion.ts +224 -0
- package/src/gw-facts.ts +141 -0
- package/src/gw-fade-in.ts +108 -0
- package/src/gw-gauge.ts +328 -0
- package/src/gw-input.ts +127 -0
- package/src/gw-menu-button.ts +386 -0
- package/src/gw-meter.ts +194 -0
- package/src/gw-notification-center.ts +155 -0
- package/src/gw-notification.ts +222 -0
- package/src/gw-option-list.ts +616 -0
- package/src/gw-page-header.ts +125 -0
- package/src/gw-popover.ts +382 -0
- package/src/gw-section-title.ts +71 -0
- package/src/gw-segmented.ts +301 -0
- package/src/gw-select.ts +361 -0
- package/src/gw-setting-row.ts +168 -0
- package/src/gw-skeleton.ts +178 -0
- package/src/gw-spinner.ts +84 -0
- package/src/gw-toast.ts +167 -0
- package/src/index.ts +76 -0
- package/src/layout.ts +182 -0
- package/src/motion.ts +105 -0
- package/src/styles.ts +122 -0
package/src/gw-facts.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gw-facts`: label and value pairs, as a description list.
|
|
3
|
+
* Docs: https://groundwork.timmo.dev/components/display/#facts
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { GwElement, css, h } from "./element.js";
|
|
8
|
+
import "./gw-skeleton.js";
|
|
9
|
+
import { animateHeight, fadeIn } from "./motion.js";
|
|
10
|
+
import { base } from "./styles.js";
|
|
11
|
+
|
|
12
|
+
export interface Fact {
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly value: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Widths for skeleton values, so the rows don't read as a solid block. */
|
|
18
|
+
const skeletonWidths = ["60%", "40%", "75%", "50%", "65%"];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Label and value pairs, as a description list. While `loading`, `rows`
|
|
22
|
+
* skeleton pairs stand in for them, keeping any labels already known. The
|
|
23
|
+
* list glides to its new height and the values fade in once they've loaded.
|
|
24
|
+
*
|
|
25
|
+
* @element gw-facts
|
|
26
|
+
* @summary Label and value pairs, as a description list.
|
|
27
|
+
*
|
|
28
|
+
* @attr {Fact[]} items - The pairs, as JSON.
|
|
29
|
+
* @attr {boolean} loading - Shows skeleton pairs, keeping any labels already known.
|
|
30
|
+
* @attr {number} rows - How many skeleton pairs while loading. Defaults to 4.
|
|
31
|
+
*/
|
|
32
|
+
export class GwFacts extends GwElement {
|
|
33
|
+
static override properties = {
|
|
34
|
+
items: { type: "json" },
|
|
35
|
+
loading: { type: "boolean", reflect: true },
|
|
36
|
+
rows: { type: "number" },
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
static override styles = [
|
|
40
|
+
base,
|
|
41
|
+
css`
|
|
42
|
+
:host {
|
|
43
|
+
display: block;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
dl {
|
|
47
|
+
display: grid;
|
|
48
|
+
grid-template-columns: minmax(7rem, max-content) 1fr;
|
|
49
|
+
gap: var(--gw-space-1) var(--gw-space-3);
|
|
50
|
+
margin: 0;
|
|
51
|
+
font-size: var(--gw-font-size-s);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
dt {
|
|
55
|
+
color: var(--gw-color-text-secondary);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
dd {
|
|
59
|
+
min-width: 0;
|
|
60
|
+
margin: 0;
|
|
61
|
+
font-variant-numeric: var(--gw-font-figures);
|
|
62
|
+
color: var(--gw-color-text-strong);
|
|
63
|
+
overflow-wrap: anywhere;
|
|
64
|
+
}
|
|
65
|
+
`,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
declare items: readonly Fact[];
|
|
69
|
+
declare loading: boolean;
|
|
70
|
+
declare rows: number;
|
|
71
|
+
|
|
72
|
+
readonly #list = h("dl");
|
|
73
|
+
|
|
74
|
+
#rendered = false;
|
|
75
|
+
|
|
76
|
+
constructor() {
|
|
77
|
+
super();
|
|
78
|
+
this.items = [];
|
|
79
|
+
this.loading = false;
|
|
80
|
+
this.rows = 4;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
protected override create(root: ShadowRoot) {
|
|
84
|
+
root.append(this.#list);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
protected override update(changed: ReadonlySet<string>) {
|
|
88
|
+
const items = Array.isArray(this.items) ? this.items : [];
|
|
89
|
+
const before = this.#rendered ? this.offsetHeight : 0;
|
|
90
|
+
const loaded = this.#rendered && changed.has("loading") && !this.loading;
|
|
91
|
+
|
|
92
|
+
if (this.loading) {
|
|
93
|
+
const count = Math.max(this.rows, items.length);
|
|
94
|
+
|
|
95
|
+
this.#list.setAttribute("aria-busy", "true");
|
|
96
|
+
this.#list.replaceChildren(
|
|
97
|
+
...Array.from({ length: count }, (_, index) => [
|
|
98
|
+
h(
|
|
99
|
+
"dt",
|
|
100
|
+
{},
|
|
101
|
+
items[index]?.label ??
|
|
102
|
+
h("gw-skeleton-text", { style: "--gw-skeleton-width: 5rem" }),
|
|
103
|
+
),
|
|
104
|
+
h(
|
|
105
|
+
"dd",
|
|
106
|
+
{},
|
|
107
|
+
h("gw-skeleton-text", {
|
|
108
|
+
style: `--gw-skeleton-width: ${skeletonWidths[index % skeletonWidths.length]}`,
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
]).flat(),
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
this.#list.removeAttribute("aria-busy");
|
|
115
|
+
this.#list.replaceChildren(
|
|
116
|
+
...items.flatMap((item) => [
|
|
117
|
+
h("dt", {}, item.label),
|
|
118
|
+
h("dd", {}, loaded ? h("span", {}, item.value) : item.value),
|
|
119
|
+
]),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (before > 0) animateHeight(this, before);
|
|
124
|
+
|
|
125
|
+
if (loaded) {
|
|
126
|
+
for (const value of this.#list.querySelectorAll("dd > span")) {
|
|
127
|
+
if (value instanceof HTMLElement) fadeIn(value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.#rendered = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
GwFacts.define("gw-facts");
|
|
136
|
+
|
|
137
|
+
declare global {
|
|
138
|
+
interface HTMLElementTagNameMap {
|
|
139
|
+
"gw-facts": GwFacts;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gw-fade-in`: fades its content in after a delay.
|
|
3
|
+
* Docs: https://groundwork.timmo.dev/components/feedback/#fading-in
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { GwElement, css, h } from "./element.js";
|
|
8
|
+
import { fadeEasing, fadeIn, motionDuration, reducedMotion } from "./motion.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Waits `delay` milliseconds, then fades its content in over `duration`.
|
|
12
|
+
* Wrap a skeleton in one with a delay, so a quick load never flashes a
|
|
13
|
+
* placeholder, and wrap content as it arrives so it eases in rather than
|
|
14
|
+
* popping. With reduced motion it still waits, then shows at once. The tokens
|
|
15
|
+
* stylesheet hides it until it's defined, so its content never shows first.
|
|
16
|
+
*
|
|
17
|
+
* @element gw-fade-in
|
|
18
|
+
* @summary Fades its content in after a delay.
|
|
19
|
+
*
|
|
20
|
+
* @attr {number} delay - Milliseconds to wait before fading in. Defaults to 0.
|
|
21
|
+
* @attr {number} duration - Milliseconds the fade takes. Defaults to `--gw-motion-slow`.
|
|
22
|
+
*
|
|
23
|
+
* @slot - The content to fade in.
|
|
24
|
+
*/
|
|
25
|
+
export class GwFadeIn extends GwElement {
|
|
26
|
+
static override properties = {
|
|
27
|
+
delay: { type: "number" },
|
|
28
|
+
duration: { type: "number" },
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
static override styles = [
|
|
32
|
+
css`
|
|
33
|
+
:host {
|
|
34
|
+
display: block;
|
|
35
|
+
}
|
|
36
|
+
`,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
declare delay: number;
|
|
40
|
+
declare duration: number | undefined;
|
|
41
|
+
|
|
42
|
+
#animation: Animation | undefined;
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
super();
|
|
46
|
+
this.delay = 0;
|
|
47
|
+
this.duration = undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
protected override create(root: ShadowRoot) {
|
|
51
|
+
root.append(h("slot"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override connectedCallback() {
|
|
55
|
+
super.connectedCallback();
|
|
56
|
+
this.#fadeIn();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override disconnectedCallback() {
|
|
60
|
+
this.#animation?.cancel();
|
|
61
|
+
this.#animation = undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Fades the content out, then runs the wait and fade in again. */
|
|
65
|
+
async replay() {
|
|
66
|
+
const from = Number(getComputedStyle(this).opacity);
|
|
67
|
+
|
|
68
|
+
if (from > 0 && !reducedMotion()) {
|
|
69
|
+
this.#animation?.cancel();
|
|
70
|
+
|
|
71
|
+
const out = this.animate([{ opacity: from }, { opacity: 0 }], {
|
|
72
|
+
duration: motionDuration(this, "--gw-motion-fast", 150),
|
|
73
|
+
easing: fadeEasing,
|
|
74
|
+
fill: "forwards",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.#animation = out;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await out.finished;
|
|
81
|
+
} catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.#animation !== out) return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.#fadeIn();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#fadeIn() {
|
|
92
|
+
this.#animation?.cancel();
|
|
93
|
+
this.#animation = fadeIn(
|
|
94
|
+
this,
|
|
95
|
+
this.duration && this.duration > 0
|
|
96
|
+
? { delay: this.delay || 0, duration: this.duration }
|
|
97
|
+
: { delay: this.delay || 0 },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
GwFadeIn.define("gw-fade-in");
|
|
103
|
+
|
|
104
|
+
declare global {
|
|
105
|
+
interface HTMLElementTagNameMap {
|
|
106
|
+
"gw-fade-in": GwFadeIn;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/gw-gauge.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gw-gauge`: a reading on a dial.
|
|
3
|
+
* Docs: https://groundwork.timmo.dev/components/display/#gauge
|
|
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
|
+
/** How much of the circle the gauge's track covers. */
|
|
13
|
+
const sweep = 270;
|
|
14
|
+
|
|
15
|
+
const radius = 40;
|
|
16
|
+
|
|
17
|
+
const circumference = 2 * Math.PI * radius;
|
|
18
|
+
|
|
19
|
+
const track = (circumference * sweep) / 360;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The color a reading shows in: the accent until it reaches `warning`, then
|
|
23
|
+
* the warning and danger colors. Lower is worse when `inverted`, such as for
|
|
24
|
+
* a battery.
|
|
25
|
+
*/
|
|
26
|
+
export const severityColor = (
|
|
27
|
+
value: number | undefined,
|
|
28
|
+
warning: number,
|
|
29
|
+
danger: number,
|
|
30
|
+
inverted = false,
|
|
31
|
+
) => {
|
|
32
|
+
if (value === undefined) return "var(--gw-color-text-disabled)";
|
|
33
|
+
|
|
34
|
+
const worse = (limit: number) => (inverted ? value <= limit : value >= limit);
|
|
35
|
+
|
|
36
|
+
if (worse(danger)) return "var(--gw-color-danger)";
|
|
37
|
+
|
|
38
|
+
if (worse(warning)) return "var(--gw-color-warning)";
|
|
39
|
+
|
|
40
|
+
return "var(--gw-gauge-color, var(--gw-color-fill-accent-resting))";
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const circle = (className: string) => {
|
|
44
|
+
const element = document.createElementNS(
|
|
45
|
+
"http://www.w3.org/2000/svg",
|
|
46
|
+
"circle",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
element.setAttribute("class", className);
|
|
50
|
+
element.setAttribute("cx", "50");
|
|
51
|
+
element.setAttribute("cy", "50");
|
|
52
|
+
element.setAttribute("r", String(radius));
|
|
53
|
+
element.setAttribute("stroke-dasharray", `${track} ${circumference}`);
|
|
54
|
+
|
|
55
|
+
return element;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A reading on a dial. The arc fills to `value` between `min` and `max`,
|
|
60
|
+
* colored by `warning` and `danger`, and glides when the value changes.
|
|
61
|
+
* While `loading`, the track pulses and a skeleton stands in for the
|
|
62
|
+
* reading.
|
|
63
|
+
*
|
|
64
|
+
* @element gw-gauge
|
|
65
|
+
* @summary A reading on a dial.
|
|
66
|
+
*
|
|
67
|
+
* @attr {number} value - The reading.
|
|
68
|
+
* @attr {number} min - The bottom of the dial. Defaults to 0.
|
|
69
|
+
* @attr {number} max - The top of the dial. Defaults to 100.
|
|
70
|
+
* @attr {string} display - The reading as shown, in place of `value`.
|
|
71
|
+
* @attr {string} unit - The unit after the reading. Defaults to `%`.
|
|
72
|
+
* @attr {string} label - What it measures, under the dial.
|
|
73
|
+
* @attr {string} caption - A line under the label.
|
|
74
|
+
* @attr {number} warning - From here it turns warning. Defaults to 75.
|
|
75
|
+
* @attr {number} danger - From here it turns danger. Defaults to 90.
|
|
76
|
+
* @attr {boolean} inverted - Lower is worse, such as for a battery.
|
|
77
|
+
* @attr {boolean} loading - Pulses the track and shows a skeleton for the reading.
|
|
78
|
+
*
|
|
79
|
+
* @cssprop --gw-gauge-size - The dial's width. Defaults to `8.5rem`.
|
|
80
|
+
* @cssprop --gw-gauge-color - The arc's color, overriding the thresholds.
|
|
81
|
+
*/
|
|
82
|
+
export class GwGauge extends GwElement {
|
|
83
|
+
static override properties = {
|
|
84
|
+
value: { type: "number" },
|
|
85
|
+
min: { type: "number" },
|
|
86
|
+
max: { type: "number" },
|
|
87
|
+
display: {},
|
|
88
|
+
unit: {},
|
|
89
|
+
label: {},
|
|
90
|
+
caption: {},
|
|
91
|
+
warning: { type: "number" },
|
|
92
|
+
danger: { type: "number" },
|
|
93
|
+
inverted: { type: "boolean" },
|
|
94
|
+
loading: { type: "boolean", reflect: true },
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
static override styles = [
|
|
98
|
+
base,
|
|
99
|
+
css`
|
|
100
|
+
:host {
|
|
101
|
+
display: inline-flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
gap: var(--gw-space-1);
|
|
104
|
+
/* Dials line up in a row even when only some have captions */
|
|
105
|
+
align-self: flex-start;
|
|
106
|
+
align-items: center;
|
|
107
|
+
width: var(--gw-gauge-size, 8.5rem);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.dial {
|
|
111
|
+
position: relative;
|
|
112
|
+
width: 100%;
|
|
113
|
+
aspect-ratio: 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
svg {
|
|
117
|
+
width: 100%;
|
|
118
|
+
height: 100%;
|
|
119
|
+
/* Start the arc at the bottom left, leaving the gap at the bottom */
|
|
120
|
+
rotate: ${90 + (360 - sweep) / 2}deg;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
circle {
|
|
124
|
+
fill: none;
|
|
125
|
+
stroke-width: 9;
|
|
126
|
+
stroke-linecap: round;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.track {
|
|
130
|
+
stroke: var(--gw-color-surface-lower);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
:host([loading]) .track {
|
|
134
|
+
stroke: var(--gw-color-fill-neutral-resting);
|
|
135
|
+
animation: pulse 2s ease-in-out infinite;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@keyframes pulse {
|
|
139
|
+
50% {
|
|
140
|
+
opacity: 0.45;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.reading gw-skeleton-text {
|
|
145
|
+
font-size: calc(var(--gw-gauge-size, 8.5rem) * 0.2);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.fill {
|
|
149
|
+
transition:
|
|
150
|
+
stroke-dashoffset var(--gw-motion-slow) var(--gw-motion-easing),
|
|
151
|
+
stroke var(--gw-motion-normal) var(--gw-motion-easing);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.reading {
|
|
155
|
+
position: absolute;
|
|
156
|
+
inset: 0;
|
|
157
|
+
display: flex;
|
|
158
|
+
flex-direction: column;
|
|
159
|
+
align-items: center;
|
|
160
|
+
justify-content: center;
|
|
161
|
+
padding-block-start: 6%;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.shown:not([hidden]) {
|
|
165
|
+
display: contents;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.value {
|
|
169
|
+
font-size: calc(var(--gw-gauge-size, 8.5rem) * 0.2);
|
|
170
|
+
font-weight: var(--gw-font-weight-strong);
|
|
171
|
+
font-variant-numeric: var(--gw-font-figures);
|
|
172
|
+
line-height: 1;
|
|
173
|
+
color: var(--gw-color-text-strong);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.unit,
|
|
177
|
+
.caption {
|
|
178
|
+
font-size: var(--gw-font-size-xs);
|
|
179
|
+
color: var(--gw-color-text-secondary);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.label {
|
|
183
|
+
margin-block-start: calc(var(--gw-gauge-size, 8.5rem) * -0.12);
|
|
184
|
+
font-size: var(--gw-font-size-s);
|
|
185
|
+
font-weight: var(--gw-font-weight-strong);
|
|
186
|
+
text-align: center;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.caption {
|
|
190
|
+
text-align: center;
|
|
191
|
+
}
|
|
192
|
+
`,
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
declare value: number | undefined;
|
|
196
|
+
declare min: number;
|
|
197
|
+
declare max: number;
|
|
198
|
+
/** The text in the middle. Defaults to the rounded value. */
|
|
199
|
+
declare display: string;
|
|
200
|
+
declare unit: string;
|
|
201
|
+
declare label: string;
|
|
202
|
+
declare caption: string;
|
|
203
|
+
declare warning: number;
|
|
204
|
+
declare danger: number;
|
|
205
|
+
/** Lower readings are worse, such as for a battery. */
|
|
206
|
+
declare inverted: boolean;
|
|
207
|
+
declare loading: boolean;
|
|
208
|
+
|
|
209
|
+
readonly #fill = circle("fill");
|
|
210
|
+
|
|
211
|
+
readonly #dial = h("div", { class: "dial", role: "meter" });
|
|
212
|
+
|
|
213
|
+
readonly #value = h("span", { class: "value" });
|
|
214
|
+
|
|
215
|
+
readonly #unit = h("span", { class: "unit" });
|
|
216
|
+
|
|
217
|
+
readonly #shown = h("span", { class: "shown" }, this.#value, this.#unit);
|
|
218
|
+
|
|
219
|
+
readonly #readingSkeleton = h("gw-skeleton-text", {
|
|
220
|
+
style: "--gw-skeleton-width: 2em",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
readonly #label = h("span", { class: "label" });
|
|
224
|
+
|
|
225
|
+
readonly #caption = h("span", { class: "caption" });
|
|
226
|
+
|
|
227
|
+
readonly #captionSkeleton = h("gw-skeleton-text", {
|
|
228
|
+
style: "--gw-skeleton-width: 5rem",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
#rendered = false;
|
|
232
|
+
|
|
233
|
+
constructor() {
|
|
234
|
+
super();
|
|
235
|
+
this.value = undefined;
|
|
236
|
+
this.min = 0;
|
|
237
|
+
this.max = 100;
|
|
238
|
+
this.display = "";
|
|
239
|
+
this.unit = "%";
|
|
240
|
+
this.label = "";
|
|
241
|
+
this.caption = "";
|
|
242
|
+
this.warning = 75;
|
|
243
|
+
this.danger = 90;
|
|
244
|
+
this.inverted = false;
|
|
245
|
+
this.loading = false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
protected override create(root: ShadowRoot) {
|
|
249
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
250
|
+
|
|
251
|
+
svg.setAttribute("viewBox", "0 0 100 100");
|
|
252
|
+
svg.setAttribute("aria-hidden", "true");
|
|
253
|
+
svg.append(circle("track"), this.#fill);
|
|
254
|
+
this.#dial.append(
|
|
255
|
+
svg,
|
|
256
|
+
h("div", { class: "reading" }, this.#readingSkeleton, this.#shown),
|
|
257
|
+
);
|
|
258
|
+
root.append(this.#dial, this.#label, this.#captionSkeleton, this.#caption);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
protected override update(changed: ReadonlySet<string>) {
|
|
262
|
+
const value =
|
|
263
|
+
this.value !== undefined && Number.isFinite(this.value) && !this.loading
|
|
264
|
+
? this.value
|
|
265
|
+
: undefined;
|
|
266
|
+
|
|
267
|
+
const span = this.max - this.min || 1;
|
|
268
|
+
|
|
269
|
+
const fraction =
|
|
270
|
+
value === undefined
|
|
271
|
+
? 0
|
|
272
|
+
: Math.min(1, Math.max(0, (value - this.min) / span));
|
|
273
|
+
|
|
274
|
+
const shown =
|
|
275
|
+
this.display || (value === undefined ? "-" : String(Math.round(value)));
|
|
276
|
+
|
|
277
|
+
const text = this.loading
|
|
278
|
+
? "Loading"
|
|
279
|
+
: value === undefined
|
|
280
|
+
? "Unknown"
|
|
281
|
+
: [`${shown}${this.unit}`, this.caption]
|
|
282
|
+
.filter((part) => part !== "")
|
|
283
|
+
.join(", ");
|
|
284
|
+
|
|
285
|
+
setAttribute(this.#dial, "aria-busy", this.loading ? "true" : null);
|
|
286
|
+
setAttribute(this.#dial, "aria-label", this.label || null);
|
|
287
|
+
setAttribute(this.#dial, "aria-valuemin", this.min);
|
|
288
|
+
setAttribute(this.#dial, "aria-valuemax", this.max);
|
|
289
|
+
// A meter needs a value; while loading or unknown, the text says so
|
|
290
|
+
setAttribute(this.#dial, "aria-valuenow", value ?? this.min);
|
|
291
|
+
setAttribute(this.#dial, "aria-valuetext", text);
|
|
292
|
+
this.#fill.setAttribute(
|
|
293
|
+
"stroke",
|
|
294
|
+
severityColor(value, this.warning, this.danger, this.inverted),
|
|
295
|
+
);
|
|
296
|
+
this.#fill.setAttribute(
|
|
297
|
+
"stroke-dashoffset",
|
|
298
|
+
String(track * (1 - fraction)),
|
|
299
|
+
);
|
|
300
|
+
this.#fill.setAttribute("opacity", fraction === 0 ? "0" : "1");
|
|
301
|
+
this.#readingSkeleton.hidden = !this.loading;
|
|
302
|
+
this.#shown.hidden = this.loading;
|
|
303
|
+
this.#value.textContent = shown;
|
|
304
|
+
this.#unit.textContent = this.unit;
|
|
305
|
+
this.#unit.hidden = this.unit === "";
|
|
306
|
+
this.#label.textContent = this.label;
|
|
307
|
+
this.#label.hidden = this.label === "";
|
|
308
|
+
this.#captionSkeleton.hidden = !this.loading || this.caption === "";
|
|
309
|
+
this.#caption.textContent = this.caption;
|
|
310
|
+
this.#caption.hidden = this.loading || this.caption === "";
|
|
311
|
+
|
|
312
|
+
if (this.#rendered && changed.has("loading") && !this.loading) {
|
|
313
|
+
fadeIn(this.#value);
|
|
314
|
+
fadeIn(this.#unit);
|
|
315
|
+
fadeIn(this.#caption);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
this.#rendered = true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
GwGauge.define("gw-gauge");
|
|
323
|
+
|
|
324
|
+
declare global {
|
|
325
|
+
interface HTMLElementTagNameMap {
|
|
326
|
+
"gw-gauge": GwGauge;
|
|
327
|
+
}
|
|
328
|
+
}
|
package/src/gw-input.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gw-input`: a labeled native input in light DOM.
|
|
3
|
+
* Docs: https://groundwork.timmo.dev/components/inputs/#input
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { h, setAttribute } from "./element.js";
|
|
8
|
+
import { FieldInfo } from "./field-info.js";
|
|
9
|
+
import { LightElement, fieldId } from "./fields.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A labeled native `<input>`, with an optional hint and error.
|
|
13
|
+
*
|
|
14
|
+
* @element gw-input
|
|
15
|
+
* @summary A labeled text field in light DOM, so forms and autofill see it.
|
|
16
|
+
*
|
|
17
|
+
* @attr {string} label - The field's label.
|
|
18
|
+
* @attr {string} name - The form field name.
|
|
19
|
+
* @attr {string} type - The native input type. Defaults to `text`.
|
|
20
|
+
* @attr {string} value - The value.
|
|
21
|
+
* @attr {string} placeholder - Placeholder text.
|
|
22
|
+
* @attr {string} autocomplete - The native autocomplete hint.
|
|
23
|
+
* @attr {string} hint - One line of help under the field.
|
|
24
|
+
* @attr {string} info - Longer help, opened from an (i) button by the label.
|
|
25
|
+
* @attr {string} error - An error under the field, which marks it invalid.
|
|
26
|
+
* @attr {boolean} required - Requires a value.
|
|
27
|
+
* @attr {boolean} disabled - Disables the field.
|
|
28
|
+
*/
|
|
29
|
+
export class GwInput extends LightElement {
|
|
30
|
+
static override properties = {
|
|
31
|
+
label: {},
|
|
32
|
+
name: {},
|
|
33
|
+
type: {},
|
|
34
|
+
value: {},
|
|
35
|
+
placeholder: {},
|
|
36
|
+
autocomplete: {},
|
|
37
|
+
hint: {},
|
|
38
|
+
info: {},
|
|
39
|
+
error: {},
|
|
40
|
+
required: { type: "boolean" },
|
|
41
|
+
disabled: { type: "boolean" },
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
declare label: string;
|
|
45
|
+
declare name: string;
|
|
46
|
+
declare type: string;
|
|
47
|
+
declare value: string;
|
|
48
|
+
declare placeholder: string;
|
|
49
|
+
declare autocomplete: string;
|
|
50
|
+
declare hint: string;
|
|
51
|
+
/** Longer help, opened from an (i) button by the label. */
|
|
52
|
+
declare info: string;
|
|
53
|
+
declare error: string;
|
|
54
|
+
declare required: boolean;
|
|
55
|
+
declare disabled: boolean;
|
|
56
|
+
|
|
57
|
+
readonly #id = fieldId();
|
|
58
|
+
|
|
59
|
+
readonly #label = h("label", { for: this.#id });
|
|
60
|
+
|
|
61
|
+
readonly #info = new FieldInfo(this.#label);
|
|
62
|
+
|
|
63
|
+
readonly #input = h("input", { id: this.#id });
|
|
64
|
+
|
|
65
|
+
readonly #hint = h("p", { class: "gw-hint", id: `${this.#id}-hint` });
|
|
66
|
+
|
|
67
|
+
readonly #error = h("p", { class: "gw-error", id: `${this.#id}-error` });
|
|
68
|
+
|
|
69
|
+
constructor() {
|
|
70
|
+
super();
|
|
71
|
+
this.label = "";
|
|
72
|
+
this.name = "";
|
|
73
|
+
this.type = "text";
|
|
74
|
+
this.value = "";
|
|
75
|
+
this.placeholder = "";
|
|
76
|
+
this.autocomplete = "";
|
|
77
|
+
this.hint = "";
|
|
78
|
+
this.info = "";
|
|
79
|
+
this.error = "";
|
|
80
|
+
this.required = false;
|
|
81
|
+
this.disabled = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
protected override create(root: HTMLElement) {
|
|
85
|
+
this.#input.addEventListener("input", () => {
|
|
86
|
+
this.value = this.#input.value;
|
|
87
|
+
});
|
|
88
|
+
root.append(this.#info.heading, this.#input, this.#hint, this.#error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected override update() {
|
|
92
|
+
const input = this.#input;
|
|
93
|
+
|
|
94
|
+
const described = [
|
|
95
|
+
this.hint ? this.#hint.id : "",
|
|
96
|
+
this.error ? this.#error.id : "",
|
|
97
|
+
]
|
|
98
|
+
.join(" ")
|
|
99
|
+
.trim();
|
|
100
|
+
|
|
101
|
+
this.#label.textContent = this.label;
|
|
102
|
+
this.#info.update(this.label, this.info);
|
|
103
|
+
input.type = this.type;
|
|
104
|
+
setAttribute(input, "name", this.name || null);
|
|
105
|
+
setAttribute(input, "placeholder", this.placeholder || null);
|
|
106
|
+
setAttribute(input, "autocomplete", this.autocomplete || null);
|
|
107
|
+
setAttribute(input, "aria-invalid", this.error ? "true" : null);
|
|
108
|
+
setAttribute(input, "aria-describedby", described || null);
|
|
109
|
+
input.required = this.required;
|
|
110
|
+
input.disabled = this.disabled;
|
|
111
|
+
|
|
112
|
+
if (input.value !== this.value) input.value = this.value;
|
|
113
|
+
|
|
114
|
+
this.#hint.textContent = this.hint;
|
|
115
|
+
this.#hint.hidden = !this.hint;
|
|
116
|
+
this.#error.textContent = this.error;
|
|
117
|
+
this.#error.hidden = !this.error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
GwInput.define("gw-input");
|
|
122
|
+
|
|
123
|
+
declare global {
|
|
124
|
+
interface HTMLElementTagNameMap {
|
|
125
|
+
"gw-input": GwInput;
|
|
126
|
+
}
|
|
127
|
+
}
|