@half-built/astro 0.3.0 → 0.4.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/README.md
CHANGED
|
@@ -43,6 +43,9 @@ name) instead of `hex`: the swatch then paints `var(token)` and
|
|
|
43
43
|
follows the live cascade with no script, and the hex cell renders
|
|
44
44
|
empty with a `data-token-hex` attribute for a consumer script to fill
|
|
45
45
|
from computed styles. Entries with `hex` render exactly as before.
|
|
46
|
+
The table's scroll box is a keyboard tab stop named by the `label`
|
|
47
|
+
prop (default "Palette"), since the table scrolls sideways below the
|
|
48
|
+
column's width.
|
|
46
49
|
|
|
47
50
|
## Ecosystem island
|
|
48
51
|
|
|
@@ -70,6 +73,26 @@ The document is `{ version: 1, entries: [...] }` where each entry has
|
|
|
70
73
|
0 highest) and `family`. Entries are sorted with the self entry's own
|
|
71
74
|
family first, then by priority, and capped at `limit`, default 6.
|
|
72
75
|
|
|
76
|
+
## Footer reserve for bottom-docked controls
|
|
77
|
+
|
|
78
|
+
A control cluster fixed to the viewport's bottom corner takes no room
|
|
79
|
+
in flow, so the page's last lines end underneath it. Set the css
|
|
80
|
+
package's `--dock-bottom` token to the room the cluster occupies
|
|
81
|
+
(its height, inset, and air) and `Footer` pads its band by that much
|
|
82
|
+
below the last link, so the page scrolls far enough to clear the
|
|
83
|
+
cluster. The token is `0px` by default and can be set inside the same
|
|
84
|
+
media block that pins the cluster.
|
|
85
|
+
|
|
86
|
+
## Search flyout
|
|
87
|
+
|
|
88
|
+
`scripts/site-header` drives the masthead's search flyout as a
|
|
89
|
+
disclosure: the magnifier toggles it open and closed, opening moves
|
|
90
|
+
focus to the field, Escape closes and returns focus to the magnifier,
|
|
91
|
+
and a press or keyboard focus leaving the flyout closes it. The island
|
|
92
|
+
marks the wrap `data-search-js`; without it, the stylesheet's
|
|
93
|
+
focus-within rule opens the flyout on focus alone, so it still works
|
|
94
|
+
with no script.
|
|
95
|
+
|
|
73
96
|
## Import notes
|
|
74
97
|
|
|
75
98
|
Wildcard subpath imports need explicit file extensions under
|
package/package.json
CHANGED
|
@@ -66,7 +66,7 @@ const year = new Date().getFullYear();
|
|
|
66
66
|
{ecosystem.map((entry) => (
|
|
67
67
|
<li>
|
|
68
68
|
{entry.key === ecosystemSelf ? (
|
|
69
|
-
<span class="footer-sitemap-self">{entry.label}</span>
|
|
69
|
+
<span class="footer-sitemap-self" aria-current="page">{entry.label}</span>
|
|
70
70
|
) : entry.href !== null ? (
|
|
71
71
|
<a href={entry.href}>{entry.label}</a>
|
|
72
72
|
) : (
|
|
@@ -97,8 +97,12 @@ const year = new Date().getFullYear();
|
|
|
97
97
|
|
|
98
98
|
<style>
|
|
99
99
|
.site-footer { margin-top: 50px; }
|
|
100
|
-
/* Tint comes from the .tint-overlay pattern (default 0.1 opacity).
|
|
101
|
-
|
|
100
|
+
/* Tint comes from the .tint-overlay pattern (default 0.1 opacity).
|
|
101
|
+
The bottom padding adds --dock-bottom (tokens/primitives.css): a
|
|
102
|
+
site with a viewport-fixed bottom cluster sets it to the room
|
|
103
|
+
that cluster occupies, and the tinted band runs on below the last
|
|
104
|
+
link by that much, so the page scrolls far enough to clear it. */
|
|
105
|
+
.bottom-footer { padding: 15px 0 calc(20px + var(--dock-bottom, 0px)); z-index: 1; }
|
|
102
106
|
.bottom-footer-info {
|
|
103
107
|
display: flex;
|
|
104
108
|
flex-wrap: wrap;
|
|
@@ -112,9 +112,11 @@ const isCurrent = (href: string) =>
|
|
|
112
112
|
<div class="navigation-search-wrap">
|
|
113
113
|
{/* A control that reveals UI is a button, not a link: a
|
|
114
114
|
bare-hash anchor navigates (scroll jump + history
|
|
115
|
-
entry), audit finding A3.
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
entry), audit finding A3. With the island mounted it
|
|
116
|
+
is the flyout's toggle (site-header.ts); without
|
|
117
|
+
JavaScript, focus-within still opens the flyout when
|
|
118
|
+
the button takes focus. */}
|
|
119
|
+
<button type="button" title="Search" class="navigation-search-icon icon-box" aria-expanded="false">
|
|
118
120
|
<svg viewBox="0 0 512 512" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M505 442.7L405.3 343c28.4-34.9 45.7-79 45.7-127C451 96.5 354.5 0 235.5 0S20 96.5 20 215.5 116.5 431 235.5 431c48 0 92.1-17.3 127-45.7L462.3 485c5.9 5.9 15.4 5.9 21.3 0l21.4-21.4c5.9-5.9 5.9-15.4 0-20.9zM235.5 371c-85.9 0-155.5-69.6-155.5-155.5S149.6 60 235.5 60 391 129.6 391 215.5 321.4 371 235.5 371z"/></svg>
|
|
119
121
|
<span class="screen-reader-text">Search</span>
|
|
120
122
|
</button>
|
|
@@ -293,8 +295,13 @@ const isCurrent = (href: string) =>
|
|
|
293
295
|
.masthead-top { display: none; }
|
|
294
296
|
}
|
|
295
297
|
|
|
296
|
-
/* Search flyout: icon on a primary-filled box
|
|
297
|
-
(
|
|
298
|
+
/* Search flyout: icon on a primary-filled box. The island
|
|
299
|
+
(site-header.ts) marks the wrap data-search-js and drives the
|
|
300
|
+
.search-open class, so the magnifier can close what it opened
|
|
301
|
+
(owner report 2026-09-09: focus-within alone could never close on
|
|
302
|
+
the button's own click, since the click leaves focus on the
|
|
303
|
+
button). Without the island the focus-within rule stands, so the
|
|
304
|
+
flyout still opens with no script. */
|
|
298
305
|
.navigation-search { width: 30px; position: relative; display: inline-block; text-align: center; }
|
|
299
306
|
.navigation-search-form {
|
|
300
307
|
position: absolute;
|
|
@@ -307,11 +314,17 @@ const isCurrent = (href: string) =>
|
|
|
307
314
|
z-index: var(--z-search-flyout);
|
|
308
315
|
background-color: var(--surface);
|
|
309
316
|
border: var(--stroke) solid var(--rule);
|
|
310
|
-
|
|
317
|
+
/* Opacity fades both ways; visibility flips at once on open and
|
|
318
|
+
waits out the fade on close. Under the house all-transition the
|
|
319
|
+
visibility interpolation read hidden at the instant the open
|
|
320
|
+
class landed, so the island's focus() on the field was refused. */
|
|
321
|
+
transition: opacity 0.1s ease, visibility 0s linear 0.1s;
|
|
311
322
|
}
|
|
312
|
-
.navigation-search-wrap:focus-within .navigation-search-form
|
|
323
|
+
.navigation-search-wrap:not([data-search-js]):focus-within .navigation-search-form,
|
|
324
|
+
.navigation-search-wrap.search-open .navigation-search-form {
|
|
313
325
|
opacity: 1;
|
|
314
326
|
visibility: visible;
|
|
327
|
+
transition-delay: 0s;
|
|
315
328
|
}
|
|
316
329
|
/* Form anatomy comes from the .field-join house pattern (owner request
|
|
317
330
|
2026-07-30, replacing the live theme's 75/25 split form). Only the
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
cell renders empty with data-token-hex for a consumer script to
|
|
11
11
|
fill from computed styles; the component itself stays inert. */
|
|
12
12
|
interface Entry { role: string; hex?: string; token?: string; note?: string }
|
|
13
|
-
interface Props { entries: Entry[]; class?: string }
|
|
14
|
-
const { entries, class: className } = Astro.props;
|
|
13
|
+
interface Props { entries: Entry[]; class?: string; label?: string }
|
|
14
|
+
const { entries, class: className, label = "Palette" } = Astro.props;
|
|
15
15
|
---
|
|
16
|
-
|
|
16
|
+
{/* The scroll box is a tab stop with a name: below the column's width
|
|
17
|
+
the table scrolls sideways, and a scrolling region a keyboard cannot
|
|
18
|
+
reach fails WCAG 2.1.1 (axe scrollable-region-focusable). */}
|
|
19
|
+
<div class:list={["table-scroll", className]} tabindex="0" role="region" aria-label={label}>
|
|
17
20
|
<table class="palette">
|
|
18
21
|
<thead>
|
|
19
22
|
<tr>
|
package/src/scripts/ecosystem.ts
CHANGED
|
@@ -215,10 +215,31 @@ export interface EcosystemOptions {
|
|
|
215
215
|
cacheKey?: string;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/* Astro compiles a component's scoped rules to `.cls[data-astro-cid-x]`,
|
|
219
|
+
and it stamps that attribute at build time on markup it renders. An
|
|
220
|
+
element built here with createElement never gets stamped, so it
|
|
221
|
+
matches none of Footer.astro's scoped rules: the self entry loses its
|
|
222
|
+
bold, a pending entry loses its dimming, and every link falls back to
|
|
223
|
+
the browser's default underline. The server-rendered list is stamped,
|
|
224
|
+
so the fix is to read the attribute off it and carry it onto whatever
|
|
225
|
+
we create. Read rather than hardcoded, because the hash changes
|
|
226
|
+
whenever the component's styles change. Consumers who render the
|
|
227
|
+
footer unscoped simply have nothing to copy, and the loop is a no-op. */
|
|
228
|
+
function scopeOf(list: Element): string | null {
|
|
229
|
+
for (const { name } of list.attributes) {
|
|
230
|
+
if (name.startsWith("data-astro-cid-")) return name;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
218
235
|
function entryNode(doc: Document, entry: EcosystemDocEntry, selfKey: string): HTMLElement {
|
|
219
236
|
if (entry.key === selfKey) {
|
|
220
237
|
const self = doc.createElement("span");
|
|
221
238
|
self.className = "footer-sitemap-self";
|
|
239
|
+
/* The bold is the visual "you are here"; this is the same statement
|
|
240
|
+
for a screen reader, which cannot see weight. Without it the self
|
|
241
|
+
entry is announced exactly like an undeployed one. */
|
|
242
|
+
self.setAttribute("aria-current", "page");
|
|
222
243
|
self.textContent = entry.label;
|
|
223
244
|
return self;
|
|
224
245
|
}
|
|
@@ -263,10 +284,16 @@ export async function mountEcosystem(root: Document, opts: EcosystemOptions): Pr
|
|
|
263
284
|
const entries = sortEntries(document_.entries, selfKey, limit);
|
|
264
285
|
if (!entries || entries.length === 0) return;
|
|
265
286
|
|
|
287
|
+
const scope = scopeOf(list);
|
|
266
288
|
const fragment = root.createDocumentFragment();
|
|
267
289
|
for (const entry of entries) {
|
|
268
290
|
const item = root.createElement("li");
|
|
269
|
-
|
|
291
|
+
const node = entryNode(root, entry, selfKey);
|
|
292
|
+
if (scope !== null) {
|
|
293
|
+
item.setAttribute(scope, "");
|
|
294
|
+
node.setAttribute(scope, "");
|
|
295
|
+
}
|
|
296
|
+
item.append(node);
|
|
270
297
|
fragment.append(item);
|
|
271
298
|
}
|
|
272
299
|
list.replaceChildren(fragment);
|
|
@@ -2,18 +2,28 @@ import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
|
2
2
|
import { formatHeaderDate } from "../lib/header-date";
|
|
3
3
|
import { docOf } from "./core/dom";
|
|
4
4
|
|
|
5
|
-
/* The masthead header island (step 9): the date-box refresh
|
|
6
|
-
phone menu toggle, born from SiteHeader.astro's
|
|
7
|
-
The
|
|
8
|
-
original: the date box refreshes on every mount
|
|
9
|
-
menu (it is idempotent, same text each time), and
|
|
10
|
-
claims
|
|
11
|
-
its click/pointerdown/keydown handlers.
|
|
5
|
+
/* The masthead header island (step 9): the date-box refresh, the
|
|
6
|
+
phone menu toggle, and the search flyout, born from SiteHeader.astro's
|
|
7
|
+
inline script. The halves are unconditional and independent,
|
|
8
|
+
mirroring the original: the date box refreshes on every mount
|
|
9
|
+
regardless of the menu (it is idempotent, same text each time), and
|
|
10
|
+
each pop-out claims its own button so a second mount does not
|
|
11
|
+
double-bind its click/pointerdown/keydown handlers.
|
|
12
|
+
|
|
13
|
+
The search flyout joined the island on 2026-09-09 (owner report: the
|
|
14
|
+
magnifier could open it but never close it, unlike the menu button).
|
|
15
|
+
It had opened on :focus-within alone, which no button click can undo
|
|
16
|
+
while the button holds the focus. The island marks the wrap
|
|
17
|
+
data-search-js, the stylesheet hands the open state to a class, and
|
|
18
|
+
the flyout becomes the same disclosure the menu is. */
|
|
12
19
|
|
|
13
20
|
export interface SiteHeaderOptions {
|
|
14
21
|
dateId?: string;
|
|
15
22
|
menuButton?: string;
|
|
16
23
|
menuId?: string;
|
|
24
|
+
searchButton?: string;
|
|
25
|
+
searchWrap?: string;
|
|
26
|
+
searchField?: string;
|
|
17
27
|
/* Defaults to formatHeaderDate, the same function Base.astro uses for
|
|
18
28
|
the server render. Its package-time home (staying in src/lib vs.
|
|
19
29
|
moving into the library) is an 11.3 decision; this option just
|
|
@@ -21,8 +31,65 @@ export interface SiteHeaderOptions {
|
|
|
21
31
|
formatDate?: (d: Date) => string;
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
/* One pop-out over the page, the menu's manners (step 9): the button
|
|
35
|
+
toggles, a press anywhere outside closes, Escape closes and hands
|
|
36
|
+
focus back to the button. The open state is a class on `panel` and
|
|
37
|
+
aria-expanded on the button. The flyout adds the two focus manners
|
|
38
|
+
its :focus-within past had: the field takes focus on open, and
|
|
39
|
+
keyboard focus leaving the panel closes it. A focusout with no
|
|
40
|
+
destination is not a leave: Safari blurs the field before a click
|
|
41
|
+
on the button lands, and closing there would make the button's own
|
|
42
|
+
click reopen what it meant to close. */
|
|
43
|
+
interface Popout {
|
|
44
|
+
btn: HTMLElement;
|
|
45
|
+
panel: HTMLElement;
|
|
46
|
+
openClass: string;
|
|
47
|
+
onOpen?: () => void;
|
|
48
|
+
closeOnFocusLeave?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function wirePopout(doc: Document, { btn, panel, openClass, onOpen, closeOnFocusLeave = false }: Popout): () => void {
|
|
52
|
+
const isOpen = (): boolean => panel.classList.contains(openClass);
|
|
53
|
+
const inside = (t: EventTarget | null): boolean => t instanceof Node && (panel.contains(t) || btn.contains(t));
|
|
54
|
+
const setOpen = (open: boolean): void => {
|
|
55
|
+
panel.classList.toggle(openClass, open);
|
|
56
|
+
btn.setAttribute("aria-expanded", String(open));
|
|
57
|
+
if (open) onOpen?.();
|
|
58
|
+
};
|
|
59
|
+
const onClick = (): void => { setOpen(!isOpen()); };
|
|
60
|
+
const onPointerdown = (ev: PointerEvent): void => {
|
|
61
|
+
if (isOpen() && !inside(ev.target)) setOpen(false);
|
|
62
|
+
};
|
|
63
|
+
const onKeydown = (ev: KeyboardEvent): void => {
|
|
64
|
+
if (ev.key !== "Escape" || !isOpen()) return;
|
|
65
|
+
setOpen(false);
|
|
66
|
+
btn.focus();
|
|
67
|
+
};
|
|
68
|
+
const onFocusout = (ev: FocusEvent): void => {
|
|
69
|
+
if (isOpen() && ev.relatedTarget !== null && !inside(ev.relatedTarget)) setOpen(false);
|
|
70
|
+
};
|
|
71
|
+
btn.addEventListener("click", onClick);
|
|
72
|
+
doc.addEventListener("pointerdown", onPointerdown);
|
|
73
|
+
doc.addEventListener("keydown", onKeydown);
|
|
74
|
+
if (closeOnFocusLeave) panel.addEventListener("focusout", onFocusout);
|
|
75
|
+
return () => {
|
|
76
|
+
btn.removeEventListener("click", onClick);
|
|
77
|
+
doc.removeEventListener("pointerdown", onPointerdown);
|
|
78
|
+
doc.removeEventListener("keydown", onKeydown);
|
|
79
|
+
if (closeOnFocusLeave) panel.removeEventListener("focusout", onFocusout);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
24
83
|
export const mountSiteHeader: Island<SiteHeaderOptions> = (root, options = {}): IslandHandle => {
|
|
25
|
-
const {
|
|
84
|
+
const {
|
|
85
|
+
dateId = "header-date",
|
|
86
|
+
menuButton = ".menu-toggle",
|
|
87
|
+
menuId = "primary-menu",
|
|
88
|
+
searchButton = ".navigation-search-icon",
|
|
89
|
+
searchWrap = ".navigation-search-wrap",
|
|
90
|
+
searchField = ".search-field",
|
|
91
|
+
formatDate = formatHeaderDate,
|
|
92
|
+
} = options;
|
|
26
93
|
const doc = docOf(root);
|
|
27
94
|
|
|
28
95
|
// Header date: live like the WordPress original (build-time text is the
|
|
@@ -30,44 +97,37 @@ export const mountSiteHeader: Island<SiteHeaderOptions> = (root, options = {}):
|
|
|
30
97
|
const dateBox = doc.getElementById(dateId);
|
|
31
98
|
if (dateBox) dateBox.textContent = formatDate(new Date());
|
|
32
99
|
|
|
100
|
+
const unwire: (() => void)[] = [];
|
|
101
|
+
|
|
33
102
|
const btn = root.querySelector<HTMLButtonElement>(menuButton);
|
|
34
103
|
const menu = doc.getElementById(menuId);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
104
|
+
if (btn && menu && claim(btn, "site-header")) {
|
|
105
|
+
const off = wirePopout(doc, { btn, panel: menu, openClass: "open" });
|
|
106
|
+
unwire.push(() => { off(); release(btn, "site-header"); });
|
|
107
|
+
}
|
|
38
108
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
};
|
|
56
|
-
onKeydown = (ev) => {
|
|
57
|
-
if (ev.key !== "Escape" || !isOpen()) return;
|
|
58
|
-
setOpen(false);
|
|
59
|
-
btn.focus();
|
|
60
|
-
};
|
|
61
|
-
doc.addEventListener("pointerdown", onPointerdown);
|
|
62
|
-
doc.addEventListener("keydown", onKeydown);
|
|
109
|
+
const searchBtn = root.querySelector<HTMLButtonElement>(searchButton);
|
|
110
|
+
const wrap = searchBtn?.closest<HTMLElement>(searchWrap) ?? null;
|
|
111
|
+
if (searchBtn && wrap && claim(searchBtn, "site-header")) {
|
|
112
|
+
const field = wrap.querySelector<HTMLInputElement>(searchField);
|
|
113
|
+
wrap.setAttribute("data-search-js", "");
|
|
114
|
+
const off = wirePopout(doc, {
|
|
115
|
+
btn: searchBtn,
|
|
116
|
+
panel: wrap,
|
|
117
|
+
openClass: "search-open",
|
|
118
|
+
onOpen: () => field?.focus(),
|
|
119
|
+
closeOnFocusLeave: true,
|
|
120
|
+
});
|
|
121
|
+
unwire.push(() => {
|
|
122
|
+
off();
|
|
123
|
+
wrap.removeAttribute("data-search-js");
|
|
124
|
+
release(searchBtn, "site-header");
|
|
125
|
+
});
|
|
63
126
|
}
|
|
64
127
|
|
|
65
128
|
return {
|
|
66
129
|
destroy(): void {
|
|
67
|
-
|
|
68
|
-
if (onPointerdown) doc.removeEventListener("pointerdown", onPointerdown);
|
|
69
|
-
if (onKeydown) doc.removeEventListener("keydown", onKeydown);
|
|
70
|
-
if (btn && onClick) release(btn, "site-header");
|
|
130
|
+
for (const off of unwire) off();
|
|
71
131
|
},
|
|
72
132
|
};
|
|
73
133
|
};
|