@sierragridteam/design-system 0.0.0-stage → 0.3.1

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.
Files changed (38) hide show
  1. package/README.md +17 -2
  2. package/assets/sierra-logo-full.png +0 -0
  3. package/assets/sierra-mark.png +0 -0
  4. package/package.json +36 -4
  5. package/src/components/Badge.astro +13 -0
  6. package/src/components/Button.astro +36 -0
  7. package/src/components/Callout.astro +17 -0
  8. package/src/components/Card.astro +33 -0
  9. package/src/components/CopyButton.astro +81 -0
  10. package/src/components/DataDiff.astro +50 -0
  11. package/src/components/DataRow.astro +23 -0
  12. package/src/components/DataStatus.astro +26 -0
  13. package/src/components/DataTable.astro +25 -0
  14. package/src/components/EmptyState.astro +17 -0
  15. package/src/components/Field.astro +25 -0
  16. package/src/components/FilterBar.astro +10 -0
  17. package/src/components/GeoJSONMap.astro +280 -0
  18. package/src/components/GeometryPreview.astro +11 -0
  19. package/src/components/Icon.astro +19 -0
  20. package/src/components/IconSprite.astro +68 -0
  21. package/src/components/IdentifierList.astro +54 -0
  22. package/src/components/Logo.astro +75 -0
  23. package/src/components/MapFrame.astro +25 -0
  24. package/src/components/MetricGrid.astro +35 -0
  25. package/src/components/PageHeader.astro +24 -0
  26. package/src/components/Select.astro +202 -0
  27. package/src/components/ValueTable.astro +32 -0
  28. package/src/lib/basemap-style.ts +269 -0
  29. package/src/lib/controls.ts +126 -0
  30. package/src/lib/data-values.ts +45 -0
  31. package/src/lib/dialog.ts +85 -0
  32. package/src/lib/dom.ts +68 -0
  33. package/src/lib/toast.ts +22 -0
  34. package/src/styles/base.css +165 -0
  35. package/src/styles/components.css +2235 -0
  36. package/src/styles/global.css +4 -0
  37. package/src/styles/shell.css +501 -0
  38. package/src/styles/tokens.css +172 -0
@@ -0,0 +1,202 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ interface Props {
4
+ id: string;
5
+ options: { value: string; label: string; disabled?: boolean }[];
6
+ value?: string;
7
+ name?: string;
8
+ disabled?: boolean;
9
+ "aria-describedby"?: string;
10
+ }
11
+ const {
12
+ id,
13
+ options,
14
+ value = options.find((o) => !o.disabled)?.value ?? "",
15
+ name,
16
+ disabled,
17
+ "aria-describedby": describedBy,
18
+ } = Astro.props;
19
+ const selected = options.find((o) => o.value === value);
20
+ ---
21
+
22
+ <sierra-select>
23
+ <button
24
+ id={id}
25
+ type="button"
26
+ class="select select-trigger"
27
+ role="combobox"
28
+ aria-haspopup="listbox"
29
+ aria-expanded="false"
30
+ aria-controls={`${id}-options`}
31
+ aria-describedby={describedBy}
32
+ value={value}
33
+ disabled={disabled}
34
+ >
35
+ <span data-select-label>{selected?.label}</span>
36
+ <Icon name="chevron-down" class="select-chevron" />
37
+ </button>
38
+ <input type="hidden" name={name} value={value} disabled={disabled} />
39
+ <div
40
+ id={`${id}-options`}
41
+ class="select-popover"
42
+ popover="auto"
43
+ role="listbox"
44
+ aria-labelledby={id}
45
+ >
46
+ {options.map((option, index) => (
47
+ <div
48
+ id={`${id}-option-${index}`}
49
+ class="select-option"
50
+ role="option"
51
+ aria-selected={option.value === value}
52
+ aria-disabled={option.disabled || undefined}
53
+ data-value={option.value}
54
+ >
55
+ <span>{option.label}</span>
56
+ <Icon name="check" class="select-check" />
57
+ </div>
58
+ ))}
59
+ </div>
60
+ </sierra-select>
61
+ <script>
62
+ class SierraSelect extends HTMLElement {
63
+ cleanup?: AbortController;
64
+ connectedCallback() {
65
+ this.cleanup?.abort();
66
+ this.cleanup = new AbortController();
67
+ const { signal } = this.cleanup;
68
+ const trigger = this.querySelector<HTMLButtonElement>("button")!;
69
+ const input = this.querySelector<HTMLInputElement>("input")!;
70
+ const panel = this.querySelector<HTMLElement>("[popover]")!;
71
+ const options = [...panel.querySelectorAll<HTMLElement>("[role=option]")];
72
+ const enabled = options.filter(
73
+ (o) => o.getAttribute("aria-disabled") !== "true",
74
+ );
75
+ let active = enabled.findIndex((o) => o.dataset.value === trigger.value);
76
+ let search = "";
77
+ let searchedAt = 0;
78
+ const isOpen = () => panel.matches(":popover-open");
79
+ const highlight = (index: number) => {
80
+ active = Math.max(0, Math.min(index, enabled.length - 1));
81
+ options.forEach((o) =>
82
+ o.classList.toggle("is-active", o === enabled[active]),
83
+ );
84
+ if (enabled[active]) {
85
+ trigger.setAttribute("aria-activedescendant", enabled[active].id);
86
+ enabled[active].scrollIntoView({ block: "nearest" });
87
+ }
88
+ };
89
+ const position = () => {
90
+ const box = trigger.getBoundingClientRect();
91
+ panel.style.width = `${Math.min(box.width, innerWidth - 16)}px`;
92
+ panel.style.left = `${Math.max(8, Math.min(box.left, innerWidth - panel.offsetWidth - 8))}px`;
93
+ const below = innerHeight - box.bottom - 8;
94
+ const above = box.top - 8;
95
+ const up = below < Math.min(panel.scrollHeight, 240) && above > below;
96
+ panel.style.maxHeight = `${Math.max(0, Math.min(240, up ? above : below))}px`;
97
+ panel.style.top = `${up ? box.top - panel.offsetHeight - 4 : box.bottom + 4}px`;
98
+ };
99
+ const open = () => {
100
+ panel.showPopover();
101
+ position();
102
+ trigger.setAttribute("aria-expanded", "true");
103
+ highlight(enabled.findIndex((o) => o.dataset.value === trigger.value));
104
+ };
105
+ const close = () => {
106
+ if (isOpen()) panel.hidePopover();
107
+ };
108
+ const choose = (option: HTMLElement) => {
109
+ if (!enabled.includes(option)) return;
110
+ trigger.value = input.value = option.dataset.value!;
111
+ this.querySelector("[data-select-label]")!.textContent =
112
+ option.querySelector("span")!.textContent;
113
+ options.forEach((o) =>
114
+ o.setAttribute("aria-selected", String(o === option)),
115
+ );
116
+ close();
117
+ trigger.focus();
118
+ trigger.dispatchEvent(new Event("input", { bubbles: true }));
119
+ trigger.dispatchEvent(new Event("change", { bubbles: true }));
120
+ };
121
+ trigger.addEventListener("click", () => (isOpen() ? close() : open()), {
122
+ signal,
123
+ });
124
+ panel.addEventListener(
125
+ "toggle",
126
+ () => {
127
+ trigger.setAttribute("aria-expanded", String(isOpen()));
128
+ if (!isOpen()) trigger.removeAttribute("aria-activedescendant");
129
+ },
130
+ { signal },
131
+ );
132
+ panel.addEventListener(
133
+ "click",
134
+ (event) => {
135
+ const option = (event.target as Element).closest<HTMLElement>(
136
+ "[role=option]",
137
+ );
138
+ if (option) choose(option);
139
+ },
140
+ { signal },
141
+ );
142
+ trigger.addEventListener(
143
+ "keydown",
144
+ (event) => {
145
+ if (event.key === "Tab" || event.key === "Escape") {
146
+ close();
147
+ return;
148
+ }
149
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
150
+ event.preventDefault();
151
+ const wasOpen = isOpen();
152
+ if (!wasOpen) open();
153
+ highlight(
154
+ event.key === "Home"
155
+ ? 0
156
+ : event.key === "End"
157
+ ? enabled.length - 1
158
+ : active +
159
+ (wasOpen ? (event.key === "ArrowDown" ? 1 : -1) : 0),
160
+ );
161
+ } else if ((event.key === "Enter" || event.key === " ") && isOpen()) {
162
+ event.preventDefault();
163
+ if (enabled[active]) choose(enabled[active]);
164
+ } else if (
165
+ event.key.length === 1 &&
166
+ !event.ctrlKey &&
167
+ !event.metaKey &&
168
+ !event.altKey &&
169
+ event.key !== " "
170
+ ) {
171
+ event.preventDefault();
172
+ if (!isOpen()) open();
173
+ search =
174
+ Date.now() - searchedAt > 600 ? event.key : search + event.key;
175
+ searchedAt = Date.now();
176
+ const index = enabled.findIndex((o) =>
177
+ o.textContent
178
+ ?.trim()
179
+ .toLowerCase()
180
+ .startsWith(search.toLowerCase()),
181
+ );
182
+ if (index >= 0) highlight(index);
183
+ }
184
+ },
185
+ { signal },
186
+ );
187
+ window.addEventListener("resize", close, { signal });
188
+ document.addEventListener(
189
+ "scroll",
190
+ (event) => {
191
+ if (!panel.contains(event.target as Node)) close();
192
+ },
193
+ { capture: true, signal },
194
+ );
195
+ }
196
+ disconnectedCallback() {
197
+ this.cleanup?.abort();
198
+ }
199
+ }
200
+ if (!customElements.get("sierra-select"))
201
+ customElements.define("sierra-select", SierraSelect);
202
+ </script>
@@ -0,0 +1,32 @@
1
+ ---
2
+ import { formatDataValue, type DataValue } from "../lib/data-values";
3
+ interface Props {
4
+ label: string;
5
+ rows: {
6
+ label: string;
7
+ value?: DataValue;
8
+ description?: string;
9
+ missing?: string;
10
+ }[];
11
+ }
12
+ const { label, rows } = Astro.props;
13
+ ---
14
+
15
+ <table class="ds-values">
16
+ <caption>{label}</caption>
17
+ <tbody>
18
+ {rows.map((row) => (
19
+ <tr>
20
+ <th scope="row">{row.label}</th>
21
+ <td>
22
+ <div class="ds-value">
23
+ {row.value === undefined
24
+ ? (row.missing ?? "Not provided")
25
+ : formatDataValue(row.value)}
26
+ </div>
27
+ {row.description && <p class="ds-data-note">{row.description}</p>}
28
+ </td>
29
+ </tr>
30
+ ))}
31
+ </tbody>
32
+ </table>
@@ -0,0 +1,269 @@
1
+ /**
2
+ * basemap-style.ts — the ground both maps (/live hazards, /mesh topology) draw on: OpenFreeMap
3
+ * Positron, restyled to match the homepage hero's map (src/assets/hero-map.svg) so every map
4
+ * on the site reads as one place. `map-ground.ts` fetches it and adds the runtime pieces.
5
+ *
6
+ * PURE: takes the fetched Positron style document and returns a new one. No MapLibre import,
7
+ * no DOM — `basemap-style.test.ts` validates the output against the real style spec, because
8
+ * MapLibre drops an invalid layer or expression SILENTLY (see mesh-link-paint.ts).
9
+ *
10
+ * Rather than patch Positron's ~60 layers, this composes a short layer list of its own over
11
+ * Positron's sources, glyphs and label expressions. Everything not listed is simply absent:
12
+ * landuse and park fills, buildings, POIs, rail, aeroways, hillshade, state and country lines.
13
+ *
14
+ * • Land is the hero's map ground, water a pale green-grey, no water labels below z10.
15
+ * • Roads are knocked-out lines with a warm hairline casing (without one they vanish into the
16
+ * ground at corridor zoom): highways 3.4px at z9–11 rising to 5px at z13, secondary roads
17
+ * from z8, tertiary from z10, minor from z12. The tiles carry nothing finer at corridor zoom.
18
+ * • Landmarks: villages from z8 (Arnold, Murphys, Bear Valley), lake names from z8, the top
19
+ * summit in each tile cell with a dot, and park and forest names.
20
+ * • Route shields are pills (`ROUTE_PILL`, an image the map module draws at load). OpenFreeMap
21
+ * only ships road refs from z12, so they appear once a reader zooms in.
22
+ * • County lines are warm hairlines; nothing above or below county level.
23
+ * • Place names are muted italics. The design asks for a serif italic; OpenFreeMap serves
24
+ * only Noto Sans glyphs, so Noto Sans Italic is the closest available face.
25
+ *
26
+ * Colours come in from the caller (read from tokens.css at runtime) — tokens stay the source
27
+ * of truth, and MapLibre needs literal colours.
28
+ */
29
+
30
+ /** The bits of a MapLibre style document this module reads or writes. */
31
+ export interface StyleDoc {
32
+ version: 8;
33
+ sources: Record<string, unknown>;
34
+ layers: { id: string; type: string; layout?: Record<string, unknown> }[];
35
+ glyphs?: string;
36
+ sprite?: unknown;
37
+ [k: string]: unknown;
38
+ }
39
+
40
+ export interface BasemapColors {
41
+ land: string;
42
+ water: string;
43
+ road: string;
44
+ roadCasing: string;
45
+ countyLine: string;
46
+ place: string;
47
+ shieldText: string;
48
+ }
49
+
50
+ /** Image id for the route-shield pill. The map module draws it (it needs a canvas). */
51
+ export const ROUTE_PILL = 'mesh-route-pill';
52
+
53
+ /** Positron's place-name expression, so non-Latin names keep their fallback. */
54
+ const NAME = [
55
+ 'case',
56
+ ['has', 'name:nonlatin'],
57
+ ['concat', ['get', 'name:latin'], '\n', ['get', 'name:nonlatin']],
58
+ ['coalesce', ['get', 'name_en'], ['get', 'name']],
59
+ ];
60
+
61
+ const SOURCE = 'openmaptiles';
62
+
63
+ /** Roads drawn, least to most important: [layer id, OpenMapTiles classes, minzoom, width]. */
64
+ const ROADS: [string, string[], number, unknown][] = [
65
+ ['road-minor', ['minor'], 12, 1],
66
+ ['road-tertiary', ['tertiary'], 10, ['interpolate', ['linear'], ['zoom'], 10, 0.8, 13, 1.6]],
67
+ [
68
+ 'road-secondary',
69
+ ['secondary'],
70
+ 8,
71
+ ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 1.6, 13, 2.4],
72
+ ],
73
+ [
74
+ 'road-major',
75
+ ['motorway', 'trunk', 'primary'],
76
+ 5,
77
+ ['interpolate', ['linear'], ['zoom'], 6, 1, 9, 3.4, 11, 3.4, 13, 5],
78
+ ],
79
+ ];
80
+ /** A casing is its road's width plus a hairline each side, at every zoom stop. */
81
+ const casing = (width: unknown): unknown =>
82
+ Array.isArray(width)
83
+ ? width.map((v, i) => (i >= 4 && i % 2 === 0 && typeof v === 'number' ? v + 1.4 : v))
84
+ : (width as number) + 1.4;
85
+
86
+ /** One summit per tile cell (OpenMapTiles ranks peaks within each cell; 1 is the most prominent). */
87
+ const PEAK = ['all', ['has', 'name'], ['==', ['get', 'rank'], 1]];
88
+ const isLine = ['match', ['geometry-type'], ['LineString', 'MultiLineString'], true, false];
89
+ const roadClass = (classes: string[]) => [
90
+ 'all',
91
+ isLine,
92
+ ['match', ['get', 'class'], classes, true, false],
93
+ ];
94
+
95
+ /**
96
+ * Build the site basemap from Positron. Returns null when the input has no vector source to
97
+ * draw from (the screenshot harness serves a background-only stub) — the caller then uses the
98
+ * input as-is, so an offline map still loads.
99
+ */
100
+ export function restyleBasemap(positron: StyleDoc, c: BasemapColors): StyleDoc | null {
101
+ if (!positron?.sources?.[SOURCE]) return null;
102
+
103
+ const layers: Record<string, unknown>[] = [
104
+ { id: 'background', type: 'background', paint: { 'background-color': c.land } },
105
+ {
106
+ id: 'water',
107
+ type: 'fill',
108
+ source: SOURCE,
109
+ 'source-layer': 'water',
110
+ filter: ['!=', ['get', 'brunnel'], 'tunnel'],
111
+ paint: { 'fill-color': c.water },
112
+ },
113
+ {
114
+ id: 'waterway',
115
+ type: 'line',
116
+ source: SOURCE,
117
+ 'source-layer': 'waterway',
118
+ minzoom: 9,
119
+ paint: { 'line-color': c.water, 'line-width': 1 },
120
+ },
121
+ {
122
+ id: 'boundary-county',
123
+ type: 'line',
124
+ source: SOURCE,
125
+ 'source-layer': 'boundary',
126
+ filter: ['all', ['==', ['get', 'admin_level'], 6], ['!=', ['get', 'maritime'], 1]],
127
+ paint: { 'line-color': c.countyLine, 'line-width': 1 },
128
+ },
129
+ // Casings first, so every road reads as a pale line with a warm edge rather than vanishing
130
+ // into the ground; the fill is drawn over all of them, so junctions stay knocked out.
131
+ ...ROADS.map(([id, classes, minzoom, width]) => ({
132
+ id: `${id}-casing`,
133
+ type: 'line',
134
+ source: SOURCE,
135
+ 'source-layer': 'transportation',
136
+ minzoom,
137
+ filter: roadClass(classes),
138
+ layout: { 'line-cap': 'round', 'line-join': 'round' },
139
+ paint: { 'line-color': c.roadCasing, 'line-width': casing(width) },
140
+ })),
141
+ ...ROADS.map(([id, classes, minzoom, width]) => ({
142
+ id,
143
+ type: 'line',
144
+ source: SOURCE,
145
+ 'source-layer': 'transportation',
146
+ minzoom,
147
+ filter: roadClass(classes),
148
+ layout: { 'line-cap': 'round', 'line-join': 'round' },
149
+ paint: { 'line-color': c.road, 'line-width': width },
150
+ })),
151
+ {
152
+ id: 'water-label',
153
+ type: 'symbol',
154
+ source: SOURCE,
155
+ 'source-layer': 'water_name',
156
+ minzoom: 8,
157
+ layout: {
158
+ 'text-field': NAME,
159
+ 'text-font': ['Noto Sans Italic'],
160
+ 'text-size': 10,
161
+ 'text-max-width': 6,
162
+ },
163
+ paint: { 'text-color': c.place, 'text-halo-color': c.land, 'text-halo-width': 1.2 },
164
+ },
165
+ // Landmarks, quieter than the towns and placed after them (lower layers yield on
166
+ // collision): the most prominent summit in each tile cell, marked with a small dot since
167
+ // repeaters sit on summits, and the parks and forests by name.
168
+ {
169
+ id: 'peak-dot',
170
+ type: 'circle',
171
+ source: SOURCE,
172
+ 'source-layer': 'mountain_peak',
173
+ minzoom: 8,
174
+ filter: PEAK,
175
+ paint: { 'circle-radius': 1.5, 'circle-color': c.place },
176
+ },
177
+ {
178
+ id: 'peak-label',
179
+ type: 'symbol',
180
+ source: SOURCE,
181
+ 'source-layer': 'mountain_peak',
182
+ minzoom: 8,
183
+ filter: PEAK,
184
+ layout: {
185
+ 'text-field': NAME,
186
+ 'text-font': ['Noto Sans Italic'],
187
+ 'text-size': 9,
188
+ 'text-anchor': 'left',
189
+ 'text-offset': [0.5, 0],
190
+ 'text-max-width': 7,
191
+ },
192
+ paint: { 'text-color': c.place, 'text-halo-color': c.land, 'text-halo-width': 1.2 },
193
+ },
194
+ {
195
+ id: 'park-label',
196
+ type: 'symbol',
197
+ source: SOURCE,
198
+ 'source-layer': 'park',
199
+ minzoom: 8,
200
+ filter: ['all', ['==', ['geometry-type'], 'Point'], ['<=', ['get', 'rank'], 1]],
201
+ layout: {
202
+ 'text-field': NAME,
203
+ 'text-font': ['Noto Sans Italic'],
204
+ 'text-size': 9,
205
+ 'text-max-width': 8,
206
+ 'text-letter-spacing': 0.04,
207
+ },
208
+ paint: { 'text-color': c.place, 'text-halo-color': c.land, 'text-halo-width': 1.2 },
209
+ },
210
+ {
211
+ id: 'route-shield',
212
+ type: 'symbol',
213
+ source: SOURCE,
214
+ 'source-layer': 'transportation_name',
215
+ minzoom: 8,
216
+ filter: [
217
+ 'all',
218
+ isLine,
219
+ ['<=', ['get', 'ref_length'], 3],
220
+ ['match', ['get', 'network'], ['us-interstate', 'us-highway', 'us-state'], true, false],
221
+ ],
222
+ layout: {
223
+ 'symbol-placement': 'line',
224
+ 'symbol-spacing': 420,
225
+ 'text-field': ['to-string', ['get', 'ref']],
226
+ 'text-font': ['Noto Sans Bold'],
227
+ 'text-size': 8,
228
+ 'text-rotation-alignment': 'viewport',
229
+ 'icon-image': ROUTE_PILL,
230
+ 'icon-text-fit': 'both',
231
+ 'icon-text-fit-padding': [2, 5, 2, 5],
232
+ 'icon-rotation-alignment': 'viewport',
233
+ },
234
+ paint: { 'text-color': c.shieldText },
235
+ },
236
+ // Place names: one muted italic, sized by the place's weight, never bolder than the mesh.
237
+ ...(
238
+ [
239
+ ['place-city', 'city', 4, 11],
240
+ ['place-town', 'town', 7, 11],
241
+ ['place-village', 'village', 8, 10],
242
+ ] as const
243
+ ).map(([id, cls, minzoom, size]) => ({
244
+ id,
245
+ type: 'symbol',
246
+ source: SOURCE,
247
+ 'source-layer': 'place',
248
+ minzoom,
249
+ filter: ['==', ['get', 'class'], cls],
250
+ layout: {
251
+ 'text-field': NAME,
252
+ 'text-font': ['Noto Sans Italic'],
253
+ 'text-size': size,
254
+ 'text-max-width': 8,
255
+ },
256
+ paint: { 'text-color': c.place, 'text-halo-color': c.land, 'text-halo-width': 1.4 },
257
+ })),
258
+ ];
259
+
260
+ return {
261
+ version: 8,
262
+ name: 'S.I.E.R.R.A',
263
+ sources: { [SOURCE]: positron.sources[SOURCE] },
264
+ glyphs: positron.glyphs,
265
+ // No sprite: every icon Positron's sprite provided belonged to a layer dropped above, and
266
+ // the one image we use (the route pill) is drawn at runtime.
267
+ layers: layers as StyleDoc['layers'],
268
+ };
269
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Form controls for client-rendered admin UI: buttons, the two-click confirm
3
+ * button, and labelled fields. They emit the global classes from
4
+ * src/styles/components.css, like the Field and Button components do.
5
+ */
6
+ import { el, icon } from './dom';
7
+
8
+ const CONFIRM_TIMEOUT_MS = 4000;
9
+
10
+ export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline';
11
+
12
+ export function button(
13
+ label: string,
14
+ variant: ButtonVariant,
15
+ onClick: (b: HTMLButtonElement) => void,
16
+ opts: { icon?: string; small?: boolean } = {}
17
+ ): HTMLButtonElement {
18
+ const b = el(
19
+ 'button',
20
+ { class: `btn btn--${variant}${opts.small === false ? '' : ' btn--sm'}`, type: 'button' },
21
+ opts.icon && icon(opts.icon),
22
+ label
23
+ );
24
+ b.addEventListener('click', () => onClick(b));
25
+ return b;
26
+ }
27
+
28
+ /** A square ghost button with only an icon; `label` is its accessible name and tooltip. */
29
+ export function iconButton(
30
+ iconName: string,
31
+ label: string,
32
+ onClick: (b: HTMLButtonElement) => void
33
+ ): HTMLButtonElement {
34
+ const b = el(
35
+ 'button',
36
+ {
37
+ class: 'btn btn--ghost btn--sm btn--icon',
38
+ type: 'button',
39
+ 'aria-label': label,
40
+ title: label,
41
+ },
42
+ icon(iconName)
43
+ );
44
+ b.addEventListener('click', () => onClick(b));
45
+ return b;
46
+ }
47
+
48
+ /**
49
+ * A button that needs a second click to fire: the first click turns it into
50
+ * an orange "Confirm …" for a few seconds. Used instead of window.confirm().
51
+ */
52
+ export function confirmButton(
53
+ label: string,
54
+ confirmLabel: string,
55
+ onConfirm: (b: HTMLButtonElement) => void,
56
+ variant: ButtonVariant = 'outline'
57
+ ): HTMLButtonElement {
58
+ const idle = `btn btn--${variant} btn--sm`;
59
+ const b = el('button', { class: idle, type: 'button' }, label);
60
+ let timer = 0;
61
+ const disarm = () => {
62
+ window.clearTimeout(timer);
63
+ b.dataset.armed = '';
64
+ b.textContent = label;
65
+ b.className = idle;
66
+ };
67
+ b.addEventListener('click', () => {
68
+ if (b.dataset.armed) {
69
+ window.clearTimeout(timer);
70
+ onConfirm(b);
71
+ return;
72
+ }
73
+ b.dataset.armed = 'true';
74
+ b.textContent = confirmLabel;
75
+ b.className = 'btn btn--danger btn--sm';
76
+ timer = window.setTimeout(disarm, CONFIRM_TIMEOUT_MS);
77
+ });
78
+ b.addEventListener('blur', disarm);
79
+ return b;
80
+ }
81
+
82
+ let fieldCount = 0;
83
+
84
+ export interface FieldOptions {
85
+ help?: string;
86
+ optional?: boolean;
87
+ }
88
+
89
+ /** Label, control, help and an error line; the markup of src/components/Field.astro. */
90
+ export function field(
91
+ label: string,
92
+ control: HTMLElement,
93
+ opts: FieldOptions = {}
94
+ ): HTMLDivElement {
95
+ const id = control.id || `f-${++fieldCount}`;
96
+ control.id = id;
97
+ const help = opts.help ? el('p', { class: 'field__help', id: `${id}-help` }, opts.help) : null;
98
+ if (help) control.setAttribute('aria-describedby', help.id);
99
+ return el(
100
+ 'div',
101
+ { class: 'field' },
102
+ el(
103
+ 'label',
104
+ { class: 'field__label', for: id },
105
+ label,
106
+ opts.optional && el('span', { class: 'field__optional' }, '(optional)')
107
+ ),
108
+ control,
109
+ help,
110
+ el('p', { class: 'field__error', id: `${id}-error`, hidden: true })
111
+ );
112
+ }
113
+
114
+ /** Show or clear the error under a control made by field(). */
115
+ export function setFieldError(control: HTMLElement, message: string | null): void {
116
+ const error = document.getElementById(`${control.id}-error`);
117
+ if (message) control.setAttribute('aria-invalid', 'true');
118
+ else control.removeAttribute('aria-invalid');
119
+ if (!error) return;
120
+ error.textContent = message ?? '';
121
+ error.hidden = !message;
122
+ }
123
+
124
+ export function input(attrs: Record<string, string | boolean | undefined> = {}): HTMLInputElement {
125
+ return el('input', { class: 'input', type: 'text', autocomplete: 'off', ...attrs });
126
+ }
@@ -0,0 +1,45 @@
1
+ /** JSON display preserves meaningful falsy values and distinguishes missing data. */
2
+ export type DataValue =
3
+ string | number | boolean | null | DataValue[] | { [key: string]: DataValue };
4
+ export function formatDataValue(value: DataValue | undefined): string {
5
+ if (value === undefined) return "Not provided";
6
+ if (value === null) return "null";
7
+ if (value === "") return '""';
8
+ return typeof value === "string" ? value : JSON.stringify(value);
9
+ }
10
+ export interface DataChange {
11
+ path: string;
12
+ kind: "added" | "removed" | "changed";
13
+ before?: DataValue;
14
+ after?: DataValue;
15
+ }
16
+ /** JSON Pointer paths are unambiguous even when keys contain periods or slashes. Arrays compare by index. */
17
+ export function diffValues(before: DataValue, after: DataValue): DataChange[] {
18
+ const result: DataChange[] = [];
19
+ const walk = (a: DataValue, b: DataValue, path: string) => {
20
+ if (a === b) return;
21
+ if (
22
+ a !== null &&
23
+ b !== null &&
24
+ typeof a === "object" &&
25
+ typeof b === "object" &&
26
+ Array.isArray(a) === Array.isArray(b)
27
+ ) {
28
+ const left = a as Record<string, DataValue>,
29
+ right = b as Record<string, DataValue>;
30
+ for (const key of new Set([
31
+ ...Object.keys(left),
32
+ ...Object.keys(right),
33
+ ])) {
34
+ const next = `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`;
35
+ if (!Object.hasOwn(left, key))
36
+ result.push({ path: next, kind: "added", after: right[key] });
37
+ else if (!Object.hasOwn(right, key))
38
+ result.push({ path: next, kind: "removed", before: left[key] });
39
+ else walk(left[key], right[key], next);
40
+ }
41
+ } else result.push({ path, kind: "changed", before: a, after: b });
42
+ };
43
+ walk(before, after, "");
44
+ return result;
45
+ }