@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/element.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GwElement`, the small base every `gw-` element builds on, with the `css`
|
|
3
|
+
* and `h` helpers. It stands in for a framework: reflected properties,
|
|
4
|
+
* batched updates and adopted stylesheets, and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** How a property reads its attribute and writes it back. */
|
|
10
|
+
export interface PropertyOptions {
|
|
11
|
+
/** How the attribute's text becomes the value. Defaults to `string`. */
|
|
12
|
+
readonly type?: "string" | "boolean" | "number" | "json";
|
|
13
|
+
/** Whether setting the property writes the attribute back. */
|
|
14
|
+
readonly reflect?: boolean;
|
|
15
|
+
/** The attribute's name, or `false` for a property with none. */
|
|
16
|
+
readonly attribute?: string | false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Properties = Readonly<Record<string, PropertyOptions>>;
|
|
20
|
+
|
|
21
|
+
/** A value parsed from JSON, as a `json` property holds. */
|
|
22
|
+
export type Json =
|
|
23
|
+
| string
|
|
24
|
+
| number
|
|
25
|
+
| boolean
|
|
26
|
+
| null
|
|
27
|
+
| readonly Json[]
|
|
28
|
+
| { readonly [key: string]: Json };
|
|
29
|
+
|
|
30
|
+
/** What a declared property can hold. */
|
|
31
|
+
export type PropertyValue = Json | undefined;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A stylesheet from CSS text, shared by every element that adopts it.
|
|
35
|
+
* Interpolations are joined in as text.
|
|
36
|
+
*/
|
|
37
|
+
export const css = (
|
|
38
|
+
strings: TemplateStringsArray,
|
|
39
|
+
...values: readonly (string | number)[]
|
|
40
|
+
) => {
|
|
41
|
+
const sheet = new CSSStyleSheet();
|
|
42
|
+
|
|
43
|
+
sheet.replaceSync(String.raw({ raw: strings }, ...values));
|
|
44
|
+
|
|
45
|
+
return sheet;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type Child = Node | string | false | null | undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates an element with attributes and children. An attribute of `false`,
|
|
52
|
+
* `null` or `undefined` is left off, and `true` sets it empty.
|
|
53
|
+
*/
|
|
54
|
+
export const h = <K extends keyof HTMLElementTagNameMap>(
|
|
55
|
+
tag: K,
|
|
56
|
+
attributes: Readonly<
|
|
57
|
+
Record<string, string | number | boolean | null | undefined>
|
|
58
|
+
> = {},
|
|
59
|
+
...children: readonly Child[]
|
|
60
|
+
) => {
|
|
61
|
+
const element = document.createElement(tag);
|
|
62
|
+
|
|
63
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
64
|
+
setAttribute(element, name, value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
append(element, children);
|
|
68
|
+
|
|
69
|
+
return element;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Appends children, skipping the empty ones. */
|
|
73
|
+
export const append = (parent: ParentNode, children: readonly Child[]) => {
|
|
74
|
+
for (const child of children) {
|
|
75
|
+
if (child !== false && child !== null && child !== undefined) {
|
|
76
|
+
parent.append(child);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Sets or removes an attribute: `false`, `null` and `undefined` remove it,
|
|
83
|
+
* `true` sets it empty and anything else sets its text.
|
|
84
|
+
*/
|
|
85
|
+
export const setAttribute = (
|
|
86
|
+
element: Element,
|
|
87
|
+
name: string,
|
|
88
|
+
value: string | number | boolean | null | undefined,
|
|
89
|
+
) => {
|
|
90
|
+
if (value === false || value === null || value === undefined) {
|
|
91
|
+
element.removeAttribute(name);
|
|
92
|
+
} else {
|
|
93
|
+
element.setAttribute(name, value === true ? "" : String(value));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const fromAttribute = (
|
|
98
|
+
value: string | null,
|
|
99
|
+
type: PropertyOptions["type"],
|
|
100
|
+
): PropertyValue => {
|
|
101
|
+
switch (type) {
|
|
102
|
+
case "boolean": {
|
|
103
|
+
return value !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case "number": {
|
|
107
|
+
return value === null ? undefined : Number(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case "json": {
|
|
111
|
+
if (value === null) return undefined;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const parsed: Json = JSON.parse(value);
|
|
115
|
+
|
|
116
|
+
return parsed;
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
default: {
|
|
123
|
+
return value ?? undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const toAttribute = (value: PropertyValue, type: PropertyOptions["type"]) => {
|
|
129
|
+
if (type === "boolean") return value ? "" : null;
|
|
130
|
+
|
|
131
|
+
if (value === undefined || value === null) return null;
|
|
132
|
+
|
|
133
|
+
return value instanceof Object ? JSON.stringify(value) : String(value);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const attributeName = (name: string, options: PropertyOptions) =>
|
|
137
|
+
options.attribute === undefined ? name.toLowerCase() : options.attribute;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The base for every `gw-` element, with no framework underneath.
|
|
141
|
+
*
|
|
142
|
+
* Declare `static properties` and `static styles`, give each property its
|
|
143
|
+
* default in the constructor, build the shadow root once in `create()`, then
|
|
144
|
+
* set what changed in `update()`. Changes made in the same task are batched
|
|
145
|
+
* into one update. Define it with `define()`, which adds the properties'
|
|
146
|
+
* accessors.
|
|
147
|
+
*/
|
|
148
|
+
export class GwElement extends HTMLElement {
|
|
149
|
+
static properties: Properties = {};
|
|
150
|
+
|
|
151
|
+
static styles: readonly CSSStyleSheet[] = [];
|
|
152
|
+
|
|
153
|
+
/** Whether to attach a shadow root. Light DOM elements set this false. */
|
|
154
|
+
static shadow = true;
|
|
155
|
+
|
|
156
|
+
/** How the shadow root is attached. */
|
|
157
|
+
static shadowRootOptions: ShadowRootInit = { mode: "open" };
|
|
158
|
+
|
|
159
|
+
static get observedAttributes() {
|
|
160
|
+
return Object.entries(this.properties).flatMap(([name, options]) => {
|
|
161
|
+
const attribute = attributeName(name, options);
|
|
162
|
+
|
|
163
|
+
return attribute === false ? [] : [attribute];
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Adds the property accessors, then defines the element. */
|
|
168
|
+
static define(tag: string) {
|
|
169
|
+
for (const [name, options] of Object.entries(this.properties)) {
|
|
170
|
+
if (Object.hasOwn(this.prototype, name)) continue;
|
|
171
|
+
|
|
172
|
+
Object.defineProperty(this.prototype, name, {
|
|
173
|
+
configurable: true,
|
|
174
|
+
enumerable: true,
|
|
175
|
+
get(this: GwElement) {
|
|
176
|
+
return this.#values.get(name);
|
|
177
|
+
},
|
|
178
|
+
set(this: GwElement, value: PropertyValue) {
|
|
179
|
+
this.#setProperty(name, options, value);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!customElements.get(tag)) customElements.define(tag, this);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
readonly #values = new Map<string, PropertyValue>();
|
|
188
|
+
|
|
189
|
+
readonly #defaults = new Map<string, PropertyValue>();
|
|
190
|
+
|
|
191
|
+
readonly #changed = new Set<string>();
|
|
192
|
+
|
|
193
|
+
// Attributes can't be set in the constructor, so reflection waits until
|
|
194
|
+
// the element first connects.
|
|
195
|
+
readonly #unreflected = new Set<string>();
|
|
196
|
+
|
|
197
|
+
#created = false;
|
|
198
|
+
|
|
199
|
+
#queued = false;
|
|
200
|
+
|
|
201
|
+
#pending: Promise<void> | undefined;
|
|
202
|
+
|
|
203
|
+
#reflecting = false;
|
|
204
|
+
|
|
205
|
+
#resolveUpdated: () => void = () => {};
|
|
206
|
+
|
|
207
|
+
readonly #updated = new Promise<void>((resolve) => {
|
|
208
|
+
this.#resolveUpdated = resolve;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** The shadow root, or the element itself for a light DOM element. */
|
|
212
|
+
readonly renderRoot: ShadowRoot | HTMLElement;
|
|
213
|
+
|
|
214
|
+
/** The element's own class, for its static properties and styles. */
|
|
215
|
+
readonly #own: typeof GwElement;
|
|
216
|
+
|
|
217
|
+
constructor() {
|
|
218
|
+
super();
|
|
219
|
+
|
|
220
|
+
const own = new.target;
|
|
221
|
+
|
|
222
|
+
this.#own = own;
|
|
223
|
+
|
|
224
|
+
if (own.shadow) {
|
|
225
|
+
const root = this.attachShadow(own.shadowRootOptions);
|
|
226
|
+
|
|
227
|
+
root.adoptedStyleSheets = [...own.styles];
|
|
228
|
+
this.renderRoot = root;
|
|
229
|
+
} else {
|
|
230
|
+
this.renderRoot = this;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Resolves once the pending update has run, or the first one before it connects. */
|
|
235
|
+
get updateComplete() {
|
|
236
|
+
return this.#pending ?? this.#updated;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
connectedCallback() {
|
|
240
|
+
const own = this.#own;
|
|
241
|
+
|
|
242
|
+
// A property set before the element upgraded sits on the instance and
|
|
243
|
+
// hides the accessor, so move it through the accessor.
|
|
244
|
+
for (const name of Object.keys(own.properties)) {
|
|
245
|
+
if (Object.hasOwn(this, name)) {
|
|
246
|
+
const value: PropertyValue = Object.getOwnPropertyDescriptor(
|
|
247
|
+
this,
|
|
248
|
+
name,
|
|
249
|
+
)?.value;
|
|
250
|
+
|
|
251
|
+
Reflect.deleteProperty(this, name);
|
|
252
|
+
Reflect.set(this, name, value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (this.#created) return;
|
|
257
|
+
|
|
258
|
+
this.#created = true;
|
|
259
|
+
|
|
260
|
+
for (const name of this.#unreflected) {
|
|
261
|
+
const options = own.properties[name] ?? {};
|
|
262
|
+
const attribute = attributeName(name, options);
|
|
263
|
+
|
|
264
|
+
if (attribute !== false && !this.hasAttribute(attribute)) {
|
|
265
|
+
this.#reflecting = true;
|
|
266
|
+
setAttribute(
|
|
267
|
+
this,
|
|
268
|
+
attribute,
|
|
269
|
+
toAttribute(this.#values.get(name), options.type),
|
|
270
|
+
);
|
|
271
|
+
this.#reflecting = false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.#unreflected.clear();
|
|
276
|
+
this.create(this.renderRoot);
|
|
277
|
+
this.#changed.clear();
|
|
278
|
+
|
|
279
|
+
for (const name of Object.keys(own.properties)) this.#changed.add(name);
|
|
280
|
+
|
|
281
|
+
this.#flush();
|
|
282
|
+
this.#resolveUpdated();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
disconnectedCallback() {}
|
|
286
|
+
|
|
287
|
+
attributeChangedCallback(
|
|
288
|
+
attribute: string,
|
|
289
|
+
previous: string | null,
|
|
290
|
+
value: string | null,
|
|
291
|
+
) {
|
|
292
|
+
if (this.#reflecting || previous === value) return;
|
|
293
|
+
|
|
294
|
+
const own = this.#own;
|
|
295
|
+
|
|
296
|
+
const entry = Object.entries(own.properties).find(
|
|
297
|
+
([name, options]) => attributeName(name, options) === attribute,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
if (!entry) return;
|
|
301
|
+
|
|
302
|
+
const [name, options] = entry;
|
|
303
|
+
const parsed = fromAttribute(value, options.type);
|
|
304
|
+
|
|
305
|
+
this.#setProperty(
|
|
306
|
+
name,
|
|
307
|
+
{ ...options, reflect: false },
|
|
308
|
+
parsed === undefined ? this.defaultOf(name) : parsed,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The value a property returns to when its attribute is removed. Defaults
|
|
314
|
+
* to the value it was given in the constructor.
|
|
315
|
+
*/
|
|
316
|
+
protected defaultOf(name: string) {
|
|
317
|
+
return this.#defaults.get(name);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Asks for an update with the given properties marked as changed. */
|
|
321
|
+
requestUpdate(...names: readonly string[]) {
|
|
322
|
+
for (const name of names) this.#changed.add(name);
|
|
323
|
+
|
|
324
|
+
if (!this.#created || this.#queued) return;
|
|
325
|
+
|
|
326
|
+
this.#queued = true;
|
|
327
|
+
this.#pending = new Promise((resolve) => {
|
|
328
|
+
queueMicrotask(() => {
|
|
329
|
+
this.#queued = false;
|
|
330
|
+
this.#pending = undefined;
|
|
331
|
+
this.#flush();
|
|
332
|
+
resolve();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Builds the shadow root's content once, on first connection. */
|
|
338
|
+
protected create(_root: ShadowRoot | HTMLElement) {}
|
|
339
|
+
|
|
340
|
+
/** Sets what the changed properties affect. */
|
|
341
|
+
protected update(_changed: ReadonlySet<string>) {}
|
|
342
|
+
|
|
343
|
+
#flush() {
|
|
344
|
+
if (this.#changed.size === 0) return;
|
|
345
|
+
|
|
346
|
+
const changed = new Set(this.#changed);
|
|
347
|
+
|
|
348
|
+
this.#changed.clear();
|
|
349
|
+
this.update(changed);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#setProperty(name: string, options: PropertyOptions, value: PropertyValue) {
|
|
353
|
+
if (!this.#defaults.has(name)) this.#defaults.set(name, value);
|
|
354
|
+
|
|
355
|
+
const previous = this.#values.get(name);
|
|
356
|
+
|
|
357
|
+
if (Object.is(previous, value) && this.#values.has(name)) return;
|
|
358
|
+
|
|
359
|
+
this.#values.set(name, value);
|
|
360
|
+
|
|
361
|
+
const attribute = attributeName(name, options);
|
|
362
|
+
|
|
363
|
+
if (options.reflect && attribute !== false) {
|
|
364
|
+
if (this.#created) {
|
|
365
|
+
this.#reflecting = true;
|
|
366
|
+
setAttribute(this, attribute, toAttribute(value, options.type));
|
|
367
|
+
this.#reflecting = false;
|
|
368
|
+
} else {
|
|
369
|
+
this.#unreflected.add(name);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this.requestUpdate(name);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The (i) button by a field's label that opens its longer help, shared by
|
|
3
|
+
* `gw-input` and `gw-select`.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { h, setAttribute } from "./element.js";
|
|
8
|
+
import "./gw-popover.js";
|
|
9
|
+
import { createIcon } from "./styles.js";
|
|
10
|
+
|
|
11
|
+
// MDI's information-outline
|
|
12
|
+
const infoIcon =
|
|
13
|
+
"M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A field's heading: its label, and an (i) button beside it when the field
|
|
17
|
+
* has `info`, which opens the longer help in a popover anchored to it. The
|
|
18
|
+
* one-line `hint` stays under the field.
|
|
19
|
+
*/
|
|
20
|
+
export class FieldInfo {
|
|
21
|
+
readonly #button = h(
|
|
22
|
+
"button",
|
|
23
|
+
{ type: "button", class: "gw-info", hidden: true },
|
|
24
|
+
createIcon(infoIcon),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
readonly #text = h("p");
|
|
28
|
+
|
|
29
|
+
readonly #popover = h("gw-popover", {}, this.#text);
|
|
30
|
+
|
|
31
|
+
readonly heading: HTMLElement;
|
|
32
|
+
|
|
33
|
+
constructor(label: HTMLElement) {
|
|
34
|
+
this.heading = h(
|
|
35
|
+
"div",
|
|
36
|
+
{ class: "gw-field-heading" },
|
|
37
|
+
label,
|
|
38
|
+
this.#button,
|
|
39
|
+
this.#popover,
|
|
40
|
+
);
|
|
41
|
+
this.#button.addEventListener("click", () => {
|
|
42
|
+
this.#popover.show(this.#button);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
update(label: string, info: string) {
|
|
47
|
+
this.#button.hidden = !info;
|
|
48
|
+
setAttribute(
|
|
49
|
+
this.#button,
|
|
50
|
+
"aria-label",
|
|
51
|
+
label ? `About ${label}` : "About",
|
|
52
|
+
);
|
|
53
|
+
this.#popover.heading = label;
|
|
54
|
+
this.#text.textContent = info;
|
|
55
|
+
}
|
|
56
|
+
}
|