@half-built/astro 0.3.1 → 0.4.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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@half-built/astro",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- .bottom-footer { padding: 15px 0 20px; z-index: 1; }
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. focus-within still opens the
116
- flyout when the button takes focus. */}
117
- <button type="button" title="Search" class="navigation-search-icon icon-box">
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, form revealed on focus-within
297
- (the live theme toggles a .show class with jQuery; focus-within needs no script). */
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
- transition: var(--transition);
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,16 @@
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
- <div class:list={["table-scroll", className]}>
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). A named
19
+ <section> is the region landmark natively; a div with role=region
20
+ fails html-validate's prefer-native-element (the blog's gate caught
21
+ it at the 0.4.0 pin bump). */}
22
+ <section class:list={["table-scroll", className]} tabindex="0" aria-label={label}>
17
23
  <table class="palette">
18
24
  <thead>
19
25
  <tr>
@@ -34,7 +40,7 @@ const { entries, class: className } = Astro.props;
34
40
  ))}
35
41
  </tbody>
36
42
  </table>
37
- </div>
43
+ </section>
38
44
 
39
45
  <style>
40
46
  .palette-chip {
@@ -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 and the
6
- phone menu toggle, born from SiteHeader.astro's inline script.
7
- The two halves are unconditional and independent, mirroring the
8
- original: the date box refreshes on every mount regardless of the
9
- menu (it is idempotent, same text each time), and the menu wiring
10
- claims the button separately so a second mount does not double-bind
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 { dateId = "header-date", menuButton = ".menu-toggle", menuId = "primary-menu", formatDate = formatHeaderDate } = options;
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
- let onClick: (() => void) | null = null;
36
- let onPointerdown: ((ev: PointerEvent) => void) | null = null;
37
- let onKeydown: ((ev: KeyboardEvent) => void) | null = null;
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
- if (btn && claim(btn, "site-header")) {
40
- const isOpen = (): boolean => menu?.classList.contains("open") ?? false;
41
- const setOpen = (open: boolean): void => {
42
- menu?.classList.toggle("open", open);
43
- btn.setAttribute("aria-expanded", String(open));
44
- };
45
- onClick = () => { setOpen(!isOpen()); };
46
- btn.addEventListener("click", onClick);
47
- /* A pop-out over the page closes the way the search flyout does: a
48
- press anywhere outside it, or Escape (which hands focus back to the
49
- button). */
50
- onPointerdown = (ev) => {
51
- if (!isOpen()) return;
52
- const t = ev.target;
53
- if (t instanceof Node && (menu?.contains(t) || btn.contains(t))) return;
54
- setOpen(false);
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
- if (btn && onClick) btn.removeEventListener("click", onClick);
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
  };