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