create-website-build-kit 0.1.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 +54 -0
- package/index.mjs +149 -0
- package/package.json +42 -0
- package/template/.dev.vars.example +3 -0
- package/template/.github/workflows/gates.yml +58 -0
- package/template/.node-version +1 -0
- package/template/.pa11yci.json +24 -0
- package/template/BUILD-STATE.md +47 -0
- package/template/CLAUDE.md +153 -0
- package/template/astro.config.mjs +150 -0
- package/template/docs/analytics.md +86 -0
- package/template/docs/content.md +138 -0
- package/template/docs/handover.md +182 -0
- package/template/docs/handover.pdf +0 -0
- package/template/docs/runbook.md +661 -0
- package/template/docs/traps.md +903 -0
- package/template/gitignore +31 -0
- package/template/package-lock.json +8159 -0
- package/template/package.json +53 -0
- package/template/public/_headers +61 -0
- package/template/public/_redirects +39 -0
- package/template/public/site.webmanifest +13 -0
- package/template/scripts/a11y-evidence.mjs +258 -0
- package/template/scripts/check-console.mjs +125 -0
- package/template/scripts/check-env.mjs +99 -0
- package/template/scripts/check-reflow.mjs +148 -0
- package/template/scripts/check-sitemap.mjs +113 -0
- package/template/scripts/dns-snapshot.mjs +267 -0
- package/template/scripts/extract.mjs +317 -0
- package/template/scripts/indexnow.mjs +154 -0
- package/template/scripts/lastmod.mjs +147 -0
- package/template/scripts/lib/inventory.mjs +104 -0
- package/template/scripts/lib/preserved.mjs +42 -0
- package/template/scripts/lib/routes.mjs +92 -0
- package/template/scripts/md-to-pdf.mjs +335 -0
- package/template/scripts/og-cards.config.mjs +114 -0
- package/template/scripts/og-cards.mjs +487 -0
- package/template/scripts/optimize-media.mjs +380 -0
- package/template/scripts/recon.mjs +480 -0
- package/template/scripts/redirects.mjs +298 -0
- package/template/scripts/shots.mjs +447 -0
- package/template/scripts/staging-headers.mjs +102 -0
- package/template/scripts/tells.mjs +268 -0
- package/template/scripts/verify.mjs +1069 -0
- package/template/src/components/ContactForm.astro +405 -0
- package/template/src/components/CtaBand.astro +82 -0
- package/template/src/components/EnvBadge.astro +146 -0
- package/template/src/components/Footer.astro +210 -0
- package/template/src/components/Header.astro +530 -0
- package/template/src/components/Icon.astro +56 -0
- package/template/src/components/Img.astro +129 -0
- package/template/src/components/PageHero.astro +88 -0
- package/template/src/components/Seo.astro +119 -0
- package/template/src/components/StructuredData.astro +173 -0
- package/template/src/content/blog/.gitkeep +5 -0
- package/template/src/content/legal/.gitkeep +0 -0
- package/template/src/content.config.ts +81 -0
- package/template/src/data/areas.ts +31 -0
- package/template/src/data/business.ts +121 -0
- package/template/src/data/categories.ts +37 -0
- package/template/src/data/fonts.ts +25 -0
- package/template/src/data/image-manifest.json +1 -0
- package/template/src/data/lastmod.json +1 -0
- package/template/src/data/nav.ts +49 -0
- package/template/src/data/services.ts +39 -0
- package/template/src/data/site.ts +136 -0
- package/template/src/env.d.ts +28 -0
- package/template/src/layouts/Base.astro +223 -0
- package/template/src/lib/brevo.ts +96 -0
- package/template/src/lib/hast-media.mjs +55 -0
- package/template/src/lib/lastmod.mjs +47 -0
- package/template/src/lib/lead.ts +92 -0
- package/template/src/lib/legal-routes.mjs +31 -0
- package/template/src/lib/legal.ts +75 -0
- package/template/src/lib/posts.ts +64 -0
- package/template/src/lib/runtime.ts +33 -0
- package/template/src/pages/404.astro +51 -0
- package/template/src/pages/[slug].astro +111 -0
- package/template/src/pages/accessibility.astro +128 -0
- package/template/src/pages/api/contact.ts +191 -0
- package/template/src/pages/api/leads.csv.ts +82 -0
- package/template/src/pages/contact.astro +112 -0
- package/template/src/pages/index.astro +84 -0
- package/template/src/pages/robots.txt.ts +38 -0
- package/template/src/pages/rss.xml.ts +27 -0
- package/template/src/styles/global.css +463 -0
- package/template/src/styles/project.css +14 -0
- package/template/src/styles/prose.css +182 -0
- package/template/src/styles/tokens.css +218 -0
- package/template/tsconfig.json +5 -0
- package/template/wrangler.jsonc +63 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { business } from '../data/business';
|
|
3
|
+
import { nav, primaryAction } from '../data/nav';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Site header and mobile menu.
|
|
7
|
+
*
|
|
8
|
+
* This component is here for its BEHAVIOUR, not its appearance. The script
|
|
9
|
+
* below is the part worth keeping: the focus trap, the persisted-header
|
|
10
|
+
* listener guard, the sticky close control, `dvh` instead of `vh`, and
|
|
11
|
+
* `margin: auto` instead of `justify-content: center` — each one is a bug that
|
|
12
|
+
* shipped before it was a rule (see docs/traps.md).
|
|
13
|
+
*
|
|
14
|
+
* The styles are the minimum that makes it usable and reachable. There is
|
|
15
|
+
* deliberately no visual treatment: no backdrop blur, no underline animation,
|
|
16
|
+
* no transparent-over-hero behaviour. Style it per project.
|
|
17
|
+
*
|
|
18
|
+
* `data-scrolled` is set on the header past 24px of scroll and carries no
|
|
19
|
+
* styling of its own — hook the design's scrolled state onto it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const path = Astro.url.pathname;
|
|
23
|
+
const isCurrent = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<header class="header no-print" data-header transition:persist="site-header" transition:name="site-header">
|
|
27
|
+
<div class="header__inner container container--wide">
|
|
28
|
+
<a class="header__brand" href="/" aria-label={`${business.name} — home`}>
|
|
29
|
+
{/* Text until there is a logo. A brand mark referenced before the file
|
|
30
|
+
exists renders as a broken image on every page, which is worse than
|
|
31
|
+
plain type — swap this for <img> once the artwork is in place, and
|
|
32
|
+
look at it on both a light and a dark background before shipping. */}
|
|
33
|
+
{business.name}
|
|
34
|
+
</a>
|
|
35
|
+
|
|
36
|
+
<nav class="header__nav" aria-label="Primary">
|
|
37
|
+
<ul role="list" class="header__list">
|
|
38
|
+
{
|
|
39
|
+
nav.map((item) => (
|
|
40
|
+
<li>
|
|
41
|
+
<a
|
|
42
|
+
href={item.href}
|
|
43
|
+
class="header__link"
|
|
44
|
+
aria-current={isCurrent(item.href) ? 'page' : undefined}
|
|
45
|
+
>
|
|
46
|
+
{item.label}
|
|
47
|
+
</a>
|
|
48
|
+
</li>
|
|
49
|
+
))
|
|
50
|
+
}
|
|
51
|
+
</ul>
|
|
52
|
+
</nav>
|
|
53
|
+
|
|
54
|
+
<div class="header__actions">
|
|
55
|
+
<a class="header__phone" href={business.phone.href}>{business.phone.display}</a>
|
|
56
|
+
<a class="btn header__cta" href={primaryAction.href}>{primaryAction.label}</a>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
class="header__toggle"
|
|
60
|
+
aria-expanded="false"
|
|
61
|
+
aria-controls="mobile-menu"
|
|
62
|
+
aria-label="Open menu"
|
|
63
|
+
data-menu-toggle
|
|
64
|
+
>
|
|
65
|
+
<span class="header__bars" aria-hidden="true">
|
|
66
|
+
<span></span>
|
|
67
|
+
<span></span>
|
|
68
|
+
</span>
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</header>
|
|
73
|
+
|
|
74
|
+
<!--
|
|
75
|
+
A full-screen panel that replaces the header rather than sitting under it.
|
|
76
|
+
The bar is hidden while this is open, so the panel owns the close control —
|
|
77
|
+
otherwise the fixed header covers the top of the menu on short screens.
|
|
78
|
+
|
|
79
|
+
`hidden` in the markup, so with JavaScript off it never appears — and every
|
|
80
|
+
link inside it is duplicated in the footer, so nothing is stranded.
|
|
81
|
+
-->
|
|
82
|
+
<div class="menu" id="mobile-menu" data-menu-panel hidden>
|
|
83
|
+
<div class="menu__panel" role="dialog" aria-modal="true" aria-label="Menu">
|
|
84
|
+
<!--
|
|
85
|
+
Sticky, so the close control stays reachable when the links scroll on a
|
|
86
|
+
short screen.
|
|
87
|
+
-->
|
|
88
|
+
<div class="menu__bar">
|
|
89
|
+
<a class="menu__brand" href="/" aria-label={`${business.name} — home`}>{business.name}</a>
|
|
90
|
+
<button type="button" class="menu__close" data-menu-close aria-label="Close menu">
|
|
91
|
+
<span class="menu__close-bars" aria-hidden="true"><span></span><span></span></span>
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="menu__inner">
|
|
96
|
+
<nav>
|
|
97
|
+
<ul role="list" class="menu__list">
|
|
98
|
+
{
|
|
99
|
+
nav.map((item) => (
|
|
100
|
+
<li>
|
|
101
|
+
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
|
102
|
+
{item.label}
|
|
103
|
+
</a>
|
|
104
|
+
</li>
|
|
105
|
+
))
|
|
106
|
+
}
|
|
107
|
+
</ul>
|
|
108
|
+
</nav>
|
|
109
|
+
|
|
110
|
+
<div class="menu__foot">
|
|
111
|
+
<a class="btn" href={business.phone.href}>Call {business.phone.display}</a>
|
|
112
|
+
<a class="btn btn--ghost" href={primaryAction.href}>{primaryAction.label}</a>
|
|
113
|
+
<p class="menu__hours">{business.hours.display}</p>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<style>
|
|
120
|
+
/*
|
|
121
|
+
* Fixed rather than sticky, so a full-bleed opening section can run under
|
|
122
|
+
* it. Every page's first section reserves --header-h with `.under-header`.
|
|
123
|
+
*/
|
|
124
|
+
.header {
|
|
125
|
+
position: fixed;
|
|
126
|
+
inset-block-start: 0;
|
|
127
|
+
inset-inline: 0;
|
|
128
|
+
z-index: var(--z-header);
|
|
129
|
+
background: var(--bg);
|
|
130
|
+
border-block-end: 1px solid var(--border);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* Hidden while the menu is open. The bar is fixed and would otherwise sit
|
|
135
|
+
* over the top of the panel, covering the first nav item on short screens.
|
|
136
|
+
* The panel carries its own close button, so nothing is lost.
|
|
137
|
+
*/
|
|
138
|
+
.header[data-menu-open] {
|
|
139
|
+
opacity: 0;
|
|
140
|
+
visibility: hidden;
|
|
141
|
+
pointer-events: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.header__inner {
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
gap: var(--space-l);
|
|
148
|
+
min-block-size: var(--header-h);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.header__brand {
|
|
152
|
+
flex: none;
|
|
153
|
+
font-family: var(--font-display);
|
|
154
|
+
font-size: var(--step-1);
|
|
155
|
+
font-weight: var(--weight-semibold);
|
|
156
|
+
letter-spacing: var(--tracking-tight);
|
|
157
|
+
text-decoration: none;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.header__nav {
|
|
161
|
+
margin-inline-start: auto;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.header__list {
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
gap: clamp(var(--space-xs), 1.6vw, var(--space-l));
|
|
168
|
+
margin: 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.header__link {
|
|
172
|
+
display: block;
|
|
173
|
+
padding-block: var(--space-2xs);
|
|
174
|
+
font-size: var(--step--1);
|
|
175
|
+
color: var(--text-muted);
|
|
176
|
+
text-decoration: none;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.header__link:hover,
|
|
180
|
+
.header__link[aria-current='page'] {
|
|
181
|
+
color: var(--text);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.header__actions {
|
|
185
|
+
display: flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
gap: var(--space-s);
|
|
188
|
+
flex: none;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.header__phone {
|
|
192
|
+
font-size: var(--step--1);
|
|
193
|
+
font-variant-numeric: tabular-nums;
|
|
194
|
+
text-decoration: none;
|
|
195
|
+
white-space: nowrap;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.header__cta {
|
|
199
|
+
font-size: var(--step--1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ── Menu button ───────────────────────────────────────────────────────── */
|
|
203
|
+
.header__toggle {
|
|
204
|
+
display: none;
|
|
205
|
+
place-items: center;
|
|
206
|
+
/* 44px minimum tap target. */
|
|
207
|
+
inline-size: 2.75rem;
|
|
208
|
+
block-size: 2.75rem;
|
|
209
|
+
margin-inline-end: calc(var(--space-2xs) * -1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* Two bars that rotate into an X. No icon swap, so there is no second glyph
|
|
213
|
+
to leak through if a style fails to match. */
|
|
214
|
+
.header__bars {
|
|
215
|
+
display: grid;
|
|
216
|
+
gap: 6px;
|
|
217
|
+
inline-size: 22px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.header__bars span {
|
|
221
|
+
display: block;
|
|
222
|
+
block-size: 2px;
|
|
223
|
+
inline-size: 100%;
|
|
224
|
+
background: currentColor;
|
|
225
|
+
transition: transform var(--dur-base) var(--ease-out);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.header__toggle[aria-expanded='true'] .header__bars span:first-child {
|
|
229
|
+
transform: translateY(4px) rotate(45deg);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.header__toggle[aria-expanded='true'] .header__bars span:last-child {
|
|
233
|
+
transform: translateY(-4px) rotate(-45deg);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@media (max-width: 60rem) {
|
|
237
|
+
.header__nav,
|
|
238
|
+
.header__phone {
|
|
239
|
+
display: none;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.header__actions {
|
|
243
|
+
margin-inline-start: auto;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.header__toggle {
|
|
247
|
+
display: grid;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* ── Mobile menu ───────────────────────────────────────────────────────── */
|
|
252
|
+
.menu {
|
|
253
|
+
position: fixed;
|
|
254
|
+
inset: 0;
|
|
255
|
+
z-index: var(--z-menu);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.menu[hidden] {
|
|
259
|
+
display: none;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.menu__panel {
|
|
263
|
+
position: absolute;
|
|
264
|
+
inset: 0;
|
|
265
|
+
display: flex;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
/*
|
|
268
|
+
* `dvh`, not `vh`. On mobile browsers `vh` is the height *without* the
|
|
269
|
+
* collapsing address bar, so a 100vh panel is taller than the visible
|
|
270
|
+
* area and the bottom call-to-action sits below the fold.
|
|
271
|
+
*/
|
|
272
|
+
block-size: 100dvh;
|
|
273
|
+
padding: 0 var(--gutter) var(--space-xl);
|
|
274
|
+
background: var(--bg);
|
|
275
|
+
overflow-y: auto;
|
|
276
|
+
overscroll-behavior: contain;
|
|
277
|
+
opacity: 0;
|
|
278
|
+
transition: opacity var(--dur-base) var(--ease-out);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.menu[data-open] .menu__panel {
|
|
282
|
+
opacity: 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/*
|
|
286
|
+
* Centred by `margin: auto`, NOT `justify-content: center`.
|
|
287
|
+
* On a short screen — a landscape phone, or a small device with the browser
|
|
288
|
+
* chrome expanded — centred flex content overflows equally in both
|
|
289
|
+
* directions and the top becomes unreachable, because scrolling cannot go
|
|
290
|
+
* past the container's start edge. Auto margins collapse to zero once the
|
|
291
|
+
* content is taller than the box, so it stays scrollable from the top.
|
|
292
|
+
*/
|
|
293
|
+
.menu__inner {
|
|
294
|
+
inline-size: 100%;
|
|
295
|
+
max-inline-size: 26rem;
|
|
296
|
+
margin: auto;
|
|
297
|
+
display: grid;
|
|
298
|
+
gap: var(--space-xl);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/*
|
|
302
|
+
* The panel's own bar. Sticky rather than absolute: absolute inside a
|
|
303
|
+
* scrolling container scrolls away with the content, which took the close
|
|
304
|
+
* button off-screen on a landscape phone.
|
|
305
|
+
*/
|
|
306
|
+
.menu__bar {
|
|
307
|
+
position: sticky;
|
|
308
|
+
inset-block-start: 0;
|
|
309
|
+
z-index: 1;
|
|
310
|
+
flex: none;
|
|
311
|
+
display: flex;
|
|
312
|
+
align-items: center;
|
|
313
|
+
justify-content: space-between;
|
|
314
|
+
gap: var(--space-m);
|
|
315
|
+
min-block-size: var(--header-h);
|
|
316
|
+
margin-inline: calc(var(--gutter) * -1);
|
|
317
|
+
padding-inline: var(--gutter);
|
|
318
|
+
background: var(--bg);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.menu__brand {
|
|
322
|
+
font-family: var(--font-display);
|
|
323
|
+
font-size: var(--step-1);
|
|
324
|
+
font-weight: var(--weight-semibold);
|
|
325
|
+
text-decoration: none;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.menu__close {
|
|
329
|
+
display: grid;
|
|
330
|
+
margin-inline-end: calc(var(--space-2xs) * -1);
|
|
331
|
+
place-items: center;
|
|
332
|
+
inline-size: 2.75rem;
|
|
333
|
+
block-size: 2.75rem;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.menu__close-bars {
|
|
337
|
+
display: grid;
|
|
338
|
+
inline-size: 22px;
|
|
339
|
+
block-size: 22px;
|
|
340
|
+
place-items: center;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.menu__close-bars span {
|
|
344
|
+
grid-area: 1 / 1;
|
|
345
|
+
inline-size: 100%;
|
|
346
|
+
block-size: 2px;
|
|
347
|
+
background: currentColor;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.menu__close-bars span:first-child {
|
|
351
|
+
transform: rotate(45deg);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.menu__close-bars span:last-child {
|
|
355
|
+
transform: rotate(-45deg);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.menu__list {
|
|
359
|
+
display: grid;
|
|
360
|
+
margin: 0;
|
|
361
|
+
padding: 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.menu__list a {
|
|
365
|
+
display: flex;
|
|
366
|
+
align-items: center;
|
|
367
|
+
min-block-size: 2.75rem;
|
|
368
|
+
padding: var(--space-s) 0;
|
|
369
|
+
font-family: var(--font-display);
|
|
370
|
+
font-size: var(--step-2);
|
|
371
|
+
text-decoration: none;
|
|
372
|
+
border-block-end: 1px solid var(--border);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.menu__foot {
|
|
376
|
+
display: grid;
|
|
377
|
+
gap: var(--space-2xs);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.menu__hours {
|
|
381
|
+
margin-block-start: var(--space-2xs);
|
|
382
|
+
font-size: var(--step--1);
|
|
383
|
+
color: var(--text-muted);
|
|
384
|
+
}
|
|
385
|
+
</style>
|
|
386
|
+
|
|
387
|
+
<script>
|
|
388
|
+
/**
|
|
389
|
+
* Header state and the mobile menu.
|
|
390
|
+
*
|
|
391
|
+
* Two things make this trickier than it looks:
|
|
392
|
+
*
|
|
393
|
+
* 1. With `<ClientRouter />` a module script runs on first load only, so
|
|
394
|
+
* everything binds from `astro:page-load`.
|
|
395
|
+
* 2. The header is `transition:persist`, so it survives navigation and must
|
|
396
|
+
* not accumulate a new set of listeners each time — hence the `bound`
|
|
397
|
+
* guard. But the MENU is re-rendered on every navigation, so a handler
|
|
398
|
+
* that captured it in a closure would go on mutating the previous page's
|
|
399
|
+
* detached element while the live one stayed shut. Every handler
|
|
400
|
+
* therefore looks the menu up at call time.
|
|
401
|
+
*/
|
|
402
|
+
const menuEl = () => document.querySelector<HTMLElement>('[data-menu-panel]');
|
|
403
|
+
|
|
404
|
+
function setupHeader() {
|
|
405
|
+
const header = document.querySelector<HTMLElement>('[data-header]');
|
|
406
|
+
const toggle = document.querySelector<HTMLButtonElement>('[data-menu-toggle]');
|
|
407
|
+
if (!header || !toggle) return;
|
|
408
|
+
if (header.dataset.bound === '1') return;
|
|
409
|
+
header.dataset.bound = '1';
|
|
410
|
+
|
|
411
|
+
let open = false;
|
|
412
|
+
let lastFocus: HTMLElement | null = null;
|
|
413
|
+
|
|
414
|
+
// Carries no styling of its own. Hook the design's scrolled state onto it.
|
|
415
|
+
const scrolled = () => header.toggleAttribute('data-scrolled', open || window.scrollY > 24);
|
|
416
|
+
|
|
417
|
+
const setOpen = (next: boolean) => {
|
|
418
|
+
const menu = menuEl();
|
|
419
|
+
if (!menu || next === open) return;
|
|
420
|
+
open = next;
|
|
421
|
+
|
|
422
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
423
|
+
toggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
|
|
424
|
+
// Hide the fixed bar so it cannot cover the panel. Set before any focus
|
|
425
|
+
// move, or focus would land on a visibility:hidden element.
|
|
426
|
+
header.toggleAttribute('data-menu-open', open);
|
|
427
|
+
document.documentElement.style.overflow = open ? 'hidden' : '';
|
|
428
|
+
|
|
429
|
+
if (open) {
|
|
430
|
+
lastFocus = document.activeElement as HTMLElement;
|
|
431
|
+
menu.hidden = false;
|
|
432
|
+
// One frame before flipping the attribute, so the browser has a start
|
|
433
|
+
// value to animate from.
|
|
434
|
+
requestAnimationFrame(() => {
|
|
435
|
+
menu.setAttribute('data-open', '');
|
|
436
|
+
// Focus the close button, not the first link — a keyboard or
|
|
437
|
+
// screen-reader user's first option should be "get out of here".
|
|
438
|
+
menu.querySelector<HTMLElement>('[data-menu-close]')?.focus();
|
|
439
|
+
});
|
|
440
|
+
} else {
|
|
441
|
+
menu.removeAttribute('data-open');
|
|
442
|
+
const panel = menu.querySelector<HTMLElement>('.menu__panel');
|
|
443
|
+
const finish = () => {
|
|
444
|
+
if (!open) menu.hidden = true;
|
|
445
|
+
};
|
|
446
|
+
if (panel && window.matchMedia('(prefers-reduced-motion: no-preference)').matches) {
|
|
447
|
+
panel.addEventListener('transitionend', finish, { once: true });
|
|
448
|
+
// Belt and braces: if the transition never fires, hide anyway.
|
|
449
|
+
setTimeout(finish, 600);
|
|
450
|
+
} else {
|
|
451
|
+
finish();
|
|
452
|
+
}
|
|
453
|
+
lastFocus?.focus();
|
|
454
|
+
}
|
|
455
|
+
scrolled();
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
toggle.addEventListener('click', () => setOpen(!open));
|
|
459
|
+
|
|
460
|
+
// Delegated, because the menu is a different element on every page.
|
|
461
|
+
document.addEventListener('click', (event) => {
|
|
462
|
+
const target = event.target as HTMLElement;
|
|
463
|
+
if (!target.closest('[data-menu-panel]')) return;
|
|
464
|
+
if (target.closest('[data-menu-close]')) {
|
|
465
|
+
setOpen(false);
|
|
466
|
+
toggle.focus();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (target.closest('a')) setOpen(false);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
document.addEventListener('keydown', (event) => {
|
|
473
|
+
if (!open) return;
|
|
474
|
+
const menu = menuEl();
|
|
475
|
+
if (!menu) return;
|
|
476
|
+
|
|
477
|
+
if (event.key === 'Escape') {
|
|
478
|
+
setOpen(false);
|
|
479
|
+
toggle.focus();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// Keep Tab inside the open dialog.
|
|
483
|
+
if (event.key !== 'Tab') return;
|
|
484
|
+
const focusable = menu.querySelectorAll<HTMLElement>('a[href], button:not([disabled])');
|
|
485
|
+
if (!focusable.length) return;
|
|
486
|
+
const first = focusable[0];
|
|
487
|
+
const last = focusable[focusable.length - 1];
|
|
488
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
489
|
+
event.preventDefault();
|
|
490
|
+
last.focus();
|
|
491
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
492
|
+
event.preventDefault();
|
|
493
|
+
first.focus();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
window.matchMedia('(min-width: 60.0625rem)').addEventListener('change', (event) => {
|
|
498
|
+
if (event.matches) setOpen(false);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
let ticking = false;
|
|
502
|
+
window.addEventListener(
|
|
503
|
+
'scroll',
|
|
504
|
+
() => {
|
|
505
|
+
if (ticking) return;
|
|
506
|
+
ticking = true;
|
|
507
|
+
requestAnimationFrame(() => {
|
|
508
|
+
scrolled();
|
|
509
|
+
ticking = false;
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
{ passive: true },
|
|
513
|
+
);
|
|
514
|
+
scrolled();
|
|
515
|
+
|
|
516
|
+
// Leaving the page closes the menu, so the next one never inherits a
|
|
517
|
+
// locked <html> or a hidden header.
|
|
518
|
+
document.addEventListener('astro:before-preparation', () => setOpen(false));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
document.addEventListener('astro:page-load', () => {
|
|
522
|
+
setupHeader();
|
|
523
|
+
// A persisted header keeps its scrolled state from the previous page even
|
|
524
|
+
// though the new one starts at the top. Re-derive it.
|
|
525
|
+
const header = document.querySelector<HTMLElement>('[data-header]');
|
|
526
|
+
header?.removeAttribute('data-menu-open');
|
|
527
|
+
header?.toggleAttribute('data-scrolled', window.scrollY > 24);
|
|
528
|
+
document.documentElement.style.overflow = '';
|
|
529
|
+
});
|
|
530
|
+
</script>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Inline SVG icons. Stroke-based, 24px grid, currentColor — so an icon always
|
|
4
|
+
* matches the text it sits beside and needs no per-use colour token.
|
|
5
|
+
* `aria-hidden` and `focusable="false"` because an icon beside a label is
|
|
6
|
+
* decoration; an icon that is the only content needs a label on the control.
|
|
7
|
+
*
|
|
8
|
+
* The set below is UI furniture only — arrows, chevrons, contact details.
|
|
9
|
+
* Anything that depicts what the business does is a design decision and
|
|
10
|
+
* belongs in the project, drawn or licensed to match the direction. A stock
|
|
11
|
+
* stroke set used for subject matter is one of the things that makes a site
|
|
12
|
+
* read as templated.
|
|
13
|
+
*/
|
|
14
|
+
/*
|
|
15
|
+
* `paths` is declared BEFORE Props, which references `keyof typeof paths`.
|
|
16
|
+
* Declared after, the key type resolves to `any`, `name` stops being checked
|
|
17
|
+
* against the set, and `paths[name]` becomes an implicit-any index — so a
|
|
18
|
+
* typo in an icon name renders nothing instead of failing the build, which is
|
|
19
|
+
* the entire reason the type is written this way.
|
|
20
|
+
*/
|
|
21
|
+
const paths = {
|
|
22
|
+
phone: '<path d="M6.5 3h3l1.5 4-2 1.5a12 12 0 0 0 6.5 6.5L17 13l4 1.5v3a2 2 0 0 1-2.2 2A17 17 0 0 1 4 5.2 2 2 0 0 1 6 3Z"/>',
|
|
23
|
+
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3.5 7 7.4 5.3a2 2 0 0 0 2.2 0L20.5 7"/>',
|
|
24
|
+
pin: '<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z"/><circle cx="12" cy="10" r="2.6"/>',
|
|
25
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.2 2"/>',
|
|
26
|
+
arrowRight: '<path d="M4 12h15"/><path d="m13 6 6 6-6 6"/>',
|
|
27
|
+
arrowUpRight: '<path d="M7 17 17 7"/><path d="M8 7h9v9"/>',
|
|
28
|
+
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
|
29
|
+
menu: '<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>',
|
|
30
|
+
close: '<path d="m6 6 12 12"/><path d="m18 6-12 12"/>',
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
name: keyof typeof paths;
|
|
35
|
+
size?: number | string;
|
|
36
|
+
class?: string;
|
|
37
|
+
strokeWidth?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { name, size = 24, class: className, strokeWidth = 1.6 } = Astro.props;
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<svg
|
|
44
|
+
class={className}
|
|
45
|
+
width={size}
|
|
46
|
+
height={size}
|
|
47
|
+
viewBox="0 0 24 24"
|
|
48
|
+
fill="none"
|
|
49
|
+
stroke="currentColor"
|
|
50
|
+
stroke-width={strokeWidth}
|
|
51
|
+
stroke-linecap="round"
|
|
52
|
+
stroke-linejoin="round"
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
focusable="false"
|
|
55
|
+
set:html={paths[name]}
|
|
56
|
+
/>
|