@stnd/layout 0.2.0 → 0.5.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/Header.astro ADDED
@@ -0,0 +1,533 @@
1
+ ---
2
+ /**
3
+ * Header.astro — Minimalist Responsive Header
4
+ *
5
+ * Brand on the left, menu on the right, hamburger on mobile.
6
+ * Reads site defaults from virtual:stnd/config.
7
+ *
8
+ * Usage:
9
+ * <Header />
10
+ * <Header brand="@stnd" items={[{ title: "Docs", url: "/docs" }]} />
11
+ */
12
+ import config from "virtual:stnd/config";
13
+
14
+ interface Props {
15
+ brand?: string;
16
+ brandHref?: string;
17
+ items?: Array<{ title: string; url: string; external?: boolean }>;
18
+ /** When false, the burger button and nav menu are not rendered. Default: true */
19
+ menu?: boolean;
20
+ /** When true, the header starts hidden and only appears on scroll-up. Default: false */
21
+ startHidden?: boolean;
22
+ class?: string;
23
+ }
24
+
25
+ const defaultNav =
26
+ config.nav?.header ?? (Array.isArray(config.nav) ? config.nav : []);
27
+
28
+ const {
29
+ brand = config.title || "Home",
30
+ brandHref = "https://standard.garden/",
31
+ items = defaultNav,
32
+ menu: menuEnabled = true,
33
+ startHidden = false,
34
+ class: className,
35
+ } = Astro.props;
36
+
37
+ const currentUrl = Astro.url?.pathname || "https://standard.garden/";
38
+
39
+ function normalizePath(p = "") {
40
+ if (!p) return "/";
41
+ const s = String(p).replace(/\/+$/, "");
42
+ return s === "" ? "/" : s;
43
+ }
44
+
45
+ function isActive(itemUrl: string) {
46
+ const a = normalizePath(itemUrl);
47
+ const b = normalizePath(currentUrl);
48
+ return a === "/" ? b === "/" : b.startsWith(a);
49
+ }
50
+
51
+ const safeItems = Array.isArray(items) ? items : [];
52
+ const siteUrl = Astro.site ? Astro.site.toString() : "";
53
+
54
+ const renderedItems = safeItems.map((item) => {
55
+ const href = item.url || "#";
56
+ const active = isActive(href);
57
+ const isAbsolute = /^https?:\/\//i.test(href);
58
+ const external =
59
+ item.external || (isAbsolute && !href.startsWith(normalizePath(siteUrl)));
60
+ const rel = external ? "noopener noreferrer" : undefined;
61
+ const target = external ? "_blank" : undefined;
62
+
63
+ return {
64
+ href,
65
+ active,
66
+ rel,
67
+ target,
68
+ title: item.title || "Untitled",
69
+ };
70
+ });
71
+ ---
72
+
73
+ <header
74
+ class:list={[
75
+ "stnd-header no-rhythm mt-0 no-decoration",
76
+ { "is-at-top": !startHidden, "is-hidden": startHidden },
77
+ className,
78
+ ]}
79
+ data-stnd-header
80
+ data-start-hidden={startHidden ? "" : undefined}
81
+ >
82
+ <nav>
83
+ <slot name="brand">
84
+ <a href={brandHref} class="stnd-header-brand">{brand}</a>
85
+ </slot>
86
+ <div class="flex items-center gap-half">
87
+ {
88
+ menuEnabled && (
89
+ <button
90
+ class="stnd-header-burger"
91
+ style="box-shadow:none;"
92
+ aria-label="Menu"
93
+ aria-expanded="false"
94
+ data-stnd-burger
95
+ >
96
+ <span />
97
+ <span />
98
+ <span />
99
+ </button>
100
+ )
101
+ }
102
+
103
+ {
104
+ menuEnabled && renderedItems.length > 0 && (
105
+ <>
106
+ <ul
107
+ class="stnd-header-menu-desktop no-rhythm"
108
+ data-stnd-menu-desktop
109
+ >
110
+ {renderedItems.map(({ href, active, target, rel, title }) => (
111
+ <li>
112
+ <a
113
+ href={href}
114
+ class:list={[{ "is-active": active }]}
115
+ aria-current={active ? "page" : undefined}
116
+ target={target}
117
+ rel={rel}
118
+ >
119
+ {title}
120
+ </a>
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ <ul class="stnd-header-menu-mobile no-rhythm" data-stnd-menu-mobile>
125
+ {renderedItems.map(({ href, active, target, rel, title }) => (
126
+ <li>
127
+ <a
128
+ href={href}
129
+ class:list={[{ "is-active": active }]}
130
+ aria-current={active ? "page" : undefined}
131
+ target={target}
132
+ rel={rel}
133
+ >
134
+ {title}
135
+ </a>
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ </>
140
+ )
141
+ }
142
+
143
+ <slot name="right" />
144
+ </div>
145
+ </nav>
146
+ </header>
147
+
148
+ <div class="stnd-slot-bl" data-stnd-bl>
149
+ <slot name="bottom-left" />
150
+ </div>
151
+ <div class="stnd-slot-br" data-stnd-br>
152
+ <slot name="bottom-right" />
153
+ </div>
154
+
155
+ <script>
156
+ document.addEventListener("astro:page-load", () => {
157
+ const header = document.querySelector("[data-stnd-header]") as HTMLElement;
158
+ const burger = document.querySelector(
159
+ "[data-stnd-burger]",
160
+ ) as HTMLButtonElement;
161
+ const mobileMenu = document.querySelector(
162
+ "[data-stnd-menu-mobile]",
163
+ ) as HTMLElement;
164
+ if (!header) return;
165
+
166
+ // ── Burger toggle ──
167
+ function toggle() {
168
+ const isOpen = header.classList.toggle("is-open");
169
+ burger?.classList.toggle("is-active", isOpen);
170
+ burger?.setAttribute("aria-expanded", String(isOpen));
171
+ document.body.classList.toggle("no-scroll", isOpen);
172
+ }
173
+
174
+ function close() {
175
+ header.classList.remove("is-open");
176
+ burger?.classList.remove("is-active");
177
+ burger?.setAttribute("aria-expanded", "false");
178
+ document.body.classList.remove("no-scroll");
179
+ }
180
+
181
+ burger?.addEventListener("click", toggle);
182
+
183
+ // Close menu when a link is clicked (mobile)
184
+ mobileMenu
185
+ ?.querySelectorAll("a")
186
+ .forEach((a) => a.addEventListener("click", close));
187
+
188
+ // Close menu when clicking backdrop (the ul itself)
189
+ mobileMenu?.addEventListener("click", (e) => {
190
+ if (e.target === mobileMenu) {
191
+ close();
192
+ }
193
+ });
194
+
195
+ // Close menu on resize if switching to desktop
196
+ function onResize() {
197
+ if (window.innerWidth > 600 && header.classList.contains("is-open")) {
198
+ close();
199
+ }
200
+ }
201
+ window.addEventListener("resize", onResize);
202
+
203
+ // ── Scroll behavior ──
204
+ const isStartHidden = header.hasAttribute("data-start-hidden");
205
+ let hasScrolled = false;
206
+ let lastY = window.scrollY;
207
+ let ticking = false;
208
+
209
+ function onScroll() {
210
+ if (ticking) return;
211
+ ticking = true;
212
+ requestAnimationFrame(() => {
213
+ const y = window.scrollY;
214
+
215
+ // Track first scroll for startHidden mode
216
+ if (!hasScrolled && y > 0) hasScrolled = true;
217
+
218
+ // At top — transparent (only if we've scrolled at least once, or not startHidden)
219
+ if (y < 10 && (hasScrolled || !isStartHidden)) {
220
+ header.classList.add("is-at-top");
221
+ header.classList.remove("is-hidden", "is-scrolled");
222
+ } else {
223
+ header.classList.remove("is-at-top");
224
+
225
+ // Scrolling down past 100px — hide
226
+ if (y > lastY && y > 100) {
227
+ header.classList.add("is-hidden");
228
+ header.classList.remove("is-scrolled");
229
+ }
230
+ // Scrolling up — show with backdrop
231
+ else if (y < lastY && (hasScrolled || !isStartHidden)) {
232
+ const deltaUp = lastY - y;
233
+ if (deltaUp > 40) {
234
+ header.classList.remove("is-hidden");
235
+ header.classList.add("is-scrolled");
236
+ }
237
+ }
238
+ }
239
+
240
+ lastY = y <= 0 ? 0 : y;
241
+ ticking = false;
242
+ });
243
+ }
244
+
245
+ window.addEventListener("scroll", onScroll, { passive: true });
246
+
247
+ // Initial check (skip if startHidden — it already has is-hidden)
248
+ if (!isStartHidden && window.scrollY > 10) {
249
+ header.classList.remove("is-at-top");
250
+ header.classList.add("is-scrolled");
251
+ }
252
+
253
+ // ── Auto-hide after scroll inactivity ──
254
+ // The header is revealed by scroll only (see onScroll). Mouse movement must
255
+ // NOT pop it out — moving the cursor used to clear `is-hidden`, which made the
256
+ // header slide out on every twitch. We tie the idle-hide to scroll activity
257
+ // and leave the header alone while it's at the top of the page.
258
+ let hideTimeout: number | undefined;
259
+
260
+ function resetHideTimeout() {
261
+ if (hideTimeout) {
262
+ clearTimeout(hideTimeout);
263
+ }
264
+ hideTimeout = window.setTimeout(() => {
265
+ if (!header.classList.contains("is-at-top")) {
266
+ header.classList.add("is-hidden");
267
+ }
268
+ }, 5000); // 5 seconds
269
+ }
270
+
271
+ // Reset the idle-hide timer on scroll only (never on mousemove).
272
+ window.addEventListener("scroll", resetHideTimeout, { passive: true });
273
+
274
+ // ── Cleanup for View Transitions ──
275
+ document.addEventListener(
276
+ "astro:before-swap",
277
+ () => {
278
+ window.removeEventListener("scroll", onScroll);
279
+ window.removeEventListener("resize", onResize);
280
+ window.removeEventListener("scroll", resetHideTimeout);
281
+ close();
282
+ },
283
+ { once: true },
284
+ );
285
+ });
286
+ </script>
287
+
288
+ <style>
289
+ :global(body.no-scroll) {
290
+ overflow: hidden;
291
+ }
292
+
293
+ /* ── Header shell ── */
294
+
295
+ .stnd-header {
296
+ position: fixed;
297
+ top: 0;
298
+ left: 0;
299
+ right: 0;
300
+ z-index: 300;
301
+ min-height: 0;
302
+ padding-inline: var(--gap-body);
303
+ padding-block: var(--space-d2);
304
+ background: var(--color-glass);
305
+ backdrop-filter: var(--filter-glass);
306
+ border-bottom: var(--border);
307
+ transition:
308
+ transform var(--transition-slow),
309
+ background var(--transition-slow),
310
+ backdrop-filter var(--transition-slow),
311
+ border-bottom var(--transition-slow),
312
+ box-shadow var(--transition-slow),
313
+ min-height var(--transition-slow),
314
+ padding var(--transition);
315
+ pointer-events: none;
316
+ font-family: var(--font-interface);
317
+ }
318
+
319
+ .stnd-header.is-at-top {
320
+ background: none;
321
+ backdrop-filter: none;
322
+ border-bottom-color: transparent;
323
+ box-shadow: none;
324
+ padding-block-start: var(--gap-body);
325
+ }
326
+
327
+ .stnd-header.is-scrolled {
328
+ box-shadow: var(--shadow);
329
+ padding-block: var(--space);
330
+ }
331
+
332
+ .stnd-header.is-hidden {
333
+ transform: translateY(-100%);
334
+ }
335
+
336
+ /* Mobile menu open (handled by mobile menu background now) */
337
+
338
+ /* ── Nav layout ── */
339
+ nav {
340
+ display: flex;
341
+ align-items: center;
342
+ justify-content: space-between;
343
+ max-width: var(--body-max-width, 100%);
344
+ margin-inline: auto;
345
+ pointer-events: auto;
346
+ }
347
+
348
+ /* ── Brand ── */
349
+ .stnd-header-brand {
350
+ font-size: var(--size-base);
351
+ font-weight: var(--font-header-weight);
352
+ text-decoration: none;
353
+ color: var(--color-foreground);
354
+ z-index: 10;
355
+ }
356
+
357
+ /* ── Menu (desktop) ── */
358
+ .stnd-header-menu-desktop {
359
+ display: flex;
360
+ gap: var(--space-half);
361
+ list-style: none;
362
+ margin: 0;
363
+ padding: 0;
364
+ padding-inline-start: 0;
365
+ transition: none;
366
+ }
367
+
368
+ .stnd-header-menu-desktop li::before {
369
+ display: none;
370
+ }
371
+
372
+ .stnd-header-menu-desktop a {
373
+ font-size: var(--size-sm);
374
+ text-decoration: none;
375
+ color: var(--color-muted);
376
+ }
377
+ .stnd-header-menu-desktop a:hover {
378
+ color: var(--color-accent);
379
+ }
380
+ .stnd-header-menu-desktop a.is-active {
381
+ color: var(--color-foreground);
382
+ }
383
+
384
+ .stnd-header-menu-mobile {
385
+ display: none;
386
+ }
387
+
388
+ /* ── Burger (hidden on desktop) ── */
389
+ .stnd-header-burger {
390
+ display: none;
391
+ position: relative;
392
+ z-index: 10;
393
+ width: var(--space-half);
394
+ height: var(--space-half);
395
+ padding: 0;
396
+ border: none;
397
+ background: none;
398
+ cursor: pointer;
399
+ -webkit-tap-highlight-color: transparent;
400
+ }
401
+ .stnd-header-burger span {
402
+ position: absolute;
403
+ display: block;
404
+ right: 0;
405
+ width: 100%;
406
+ height: 2px;
407
+ background-color: var(--color-foreground);
408
+ transition: all 0.3s ease;
409
+ }
410
+ .stnd-header-burger span:nth-child(1) {
411
+ top: 0rem;
412
+ width: 100%;
413
+ }
414
+ .stnd-header-burger span:nth-child(2) {
415
+ top: 0.3rem;
416
+ width: 100%;
417
+ }
418
+ .stnd-header-burger span:nth-child(3) {
419
+ top: 0.6rem;
420
+ width: 100%;
421
+ }
422
+
423
+ /* Burger → X animation */
424
+ .stnd-header-burger.is-active span:nth-child(1) {
425
+ top: 0.3rem;
426
+ rotate: 135deg;
427
+ }
428
+ .stnd-header-burger.is-active span:nth-child(2) {
429
+ opacity: 0;
430
+ }
431
+ .stnd-header-burger.is-active span:nth-child(3) {
432
+ top: 0.3rem;
433
+ rotate: -135deg;
434
+ }
435
+
436
+ /* ── Mobile ── */
437
+ @media screen and (max-width: 768px) {
438
+ .stnd-header-burger {
439
+ display: block;
440
+ }
441
+
442
+ .stnd-header-menu-desktop {
443
+ display: none;
444
+ }
445
+
446
+ .stnd-header-menu-mobile {
447
+ position: fixed;
448
+ display: flex;
449
+ top: 0;
450
+ left: 0;
451
+ right: 0;
452
+ height: 100dvh;
453
+ flex-direction: column;
454
+ align-items: center;
455
+ justify-content: center;
456
+ gap: var(--space-3);
457
+ opacity: 0;
458
+ pointer-events: none;
459
+ transition: all var(--transition-slow);
460
+ z-index: 5;
461
+ will-change: opacity;
462
+ background: var(--color-glass);
463
+ backdrop-filter: var(--filter-glass);
464
+ margin: 0;
465
+ padding: 0;
466
+ list-style: none;
467
+ }
468
+
469
+ .stnd-header-menu-mobile li::before {
470
+ display: none;
471
+ }
472
+
473
+ .stnd-header.is-open .stnd-header-menu-mobile {
474
+ opacity: 1;
475
+ pointer-events: auto;
476
+ }
477
+
478
+ .stnd-header-menu-mobile a {
479
+ font-size: var(--size-2xl);
480
+ font-weight: var(--font-header-weight);
481
+ color: var(--color-foreground);
482
+ text-decoration: none;
483
+ }
484
+ .stnd-header-menu-mobile a:hover {
485
+ color: var(--color-accent);
486
+ }
487
+ .stnd-header-menu-mobile a.is-active {
488
+ color: var(--color-foreground);
489
+ }
490
+ }
491
+
492
+ /* ── Bottom Slots ── */
493
+ .stnd-slot-bl,
494
+ .stnd-slot-br {
495
+ position: fixed;
496
+ z-index: 290;
497
+ bottom: var(--gap-body);
498
+
499
+ transition:
500
+ transform var(--duration-slow) var(--ease-stnd),
501
+ opacity var(--duration-slow) var(--ease-stnd);
502
+ /* Default state: Visible */
503
+ transform: translateY(0);
504
+ opacity: 1;
505
+ will-change: transform, opacity;
506
+ }
507
+
508
+ .stnd-slot-bl {
509
+ left: var(--gap-body);
510
+ }
511
+
512
+ .stnd-slot-br {
513
+ right: var(--gap-body);
514
+ }
515
+
516
+ :global(:is(.stnd-slot-bl, .stnd-slot-bl) a) {
517
+ color: inherit;
518
+ }
519
+ :global(:is(.stnd-slot-bl, .stnd-slot-bl) strong) {
520
+ color: inherit;
521
+ }
522
+ :global(:is(.stnd-slot-bl, .stnd-slot-bl) em) {
523
+ color: inherit;
524
+ }
525
+
526
+ /* Hidden state controlled by Header sibling */
527
+ .stnd-header.is-hidden ~ .stnd-slot-bl,
528
+ .stnd-header.is-hidden ~ .stnd-slot-br {
529
+ transform: translateY(var(--space));
530
+ opacity: 0;
531
+ pointer-events: none;
532
+ }
533
+ </style>