@getvitops/astro 8.2.0-alpha.0 → 8.3.0-alpha.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +584 -26
  2. package/dist/config-adapters-CbTywgEY.mjs +71 -0
  3. package/dist/config-adapters-CbTywgEY.mjs.map +1 -0
  4. package/dist/exports/tracking.d.mts +1 -1
  5. package/dist/exports/tracking.mjs +1 -1
  6. package/dist/index.d.mts +83 -43
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +351 -60
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/middleware.d.mts +0 -2
  11. package/dist/middleware.d.mts.map +1 -1
  12. package/dist/middleware.mjs +1 -2
  13. package/dist/routes/conversion.d.mts +71 -15
  14. package/dist/routes/conversion.d.mts.map +1 -1
  15. package/dist/routes/conversion.mjs +79 -68
  16. package/dist/routes/conversion.mjs.map +1 -1
  17. package/dist/{tracking-M2PqlMpw.mjs → tracking-Cb3A0BrC.mjs} +2 -3
  18. package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
  19. package/dist/{tracking-BNKKBqOe.d.mts → tracking-D2xOzjFD.d.mts} +5 -25
  20. package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
  21. package/package.json +18 -6
  22. package/src/ads.ts +15 -22
  23. package/src/components/CookieConsent.astro +2 -1
  24. package/src/components/Entries.astro +58 -0
  25. package/src/components/Head.astro +6 -37
  26. package/src/components/ReviewCardBody.astro +16 -2
  27. package/src/components/SiteFooter.astro +79 -0
  28. package/src/components/SiteNav.astro +163 -0
  29. package/src/components/SiteNavList.astro +73 -0
  30. package/src/components/sitenav.ts +260 -0
  31. package/src/inline-scripts.ts +53 -0
  32. package/src/json-ld.ts +19 -0
  33. package/src/schemas/Article.astro +2 -1
  34. package/src/schemas/BookActions.astro +2 -1
  35. package/src/schemas/Breadcrumb.astro +2 -1
  36. package/src/schemas/Carousel.astro +2 -1
  37. package/src/schemas/CourseList.astro +2 -1
  38. package/src/schemas/Dataset.astro +2 -1
  39. package/src/schemas/DiscussionForum.astro +2 -1
  40. package/src/schemas/EducationQA.astro +2 -1
  41. package/src/schemas/EmployerAggregateRating.astro +2 -1
  42. package/src/schemas/Event.astro +2 -1
  43. package/src/schemas/FAQ.astro +2 -1
  44. package/src/schemas/FactCheck.astro +2 -1
  45. package/src/schemas/ImageMetadata.astro +2 -1
  46. package/src/schemas/JobPosting.astro +2 -1
  47. package/src/schemas/LocalBusiness.astro +2 -1
  48. package/src/schemas/MathSolver.astro +2 -1
  49. package/src/schemas/MovieCarousel.astro +2 -1
  50. package/src/schemas/Organization.astro +2 -1
  51. package/src/schemas/Product.astro +2 -1
  52. package/src/schemas/ProfilePage.astro +2 -1
  53. package/src/schemas/QAPage.astro +2 -1
  54. package/src/schemas/Recipe.astro +2 -1
  55. package/src/schemas/Review.astro +2 -1
  56. package/src/schemas/SoftwareApp.astro +2 -1
  57. package/src/seo.ts +6 -5
  58. package/src/tracking.ts +5 -9
  59. package/virtual.d.ts +17 -0
  60. package/dist/tracking-BNKKBqOe.d.mts.map +0 -1
  61. package/dist/tracking-M2PqlMpw.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/astro",
3
- "version": "8.2.0-alpha.0",
3
+ "version": "8.3.0-alpha.0",
4
4
  "description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
5
5
  "homepage": "https://docs.vitops.ca",
6
6
  "bugs": "https://github.com/getvitops/design-system/issues",
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "dist",
15
15
  "src/components/Head.astro",
16
+ "src/inline-scripts.ts",
16
17
  "src/components/Seo.astro",
17
18
  "src/seo.ts",
18
19
  "src/components/Analytics.astro",
@@ -27,6 +28,7 @@
27
28
  "src/components/Gallery.astro",
28
29
  "src/components/ResponsiveImage.astro",
29
30
  "src/components/Counter.astro",
31
+ "src/components/Entries.astro",
30
32
  "src/components/NodeRenderer.astro",
31
33
  "src/components/WebComponentLoader.astro",
32
34
  "src/components/Icon.astro",
@@ -35,7 +37,12 @@
35
37
  "src/components/Drawer.astro",
36
38
  "src/components/NavShell.astro",
37
39
  "src/components/NavShellToggle.astro",
40
+ "src/components/SiteNav.astro",
41
+ "src/components/SiteNavList.astro",
42
+ "src/components/SiteFooter.astro",
43
+ "src/components/sitenav.ts",
38
44
  "src/schemas",
45
+ "src/json-ld.ts",
39
46
  "virtual.d.ts",
40
47
  "CHANGELOG.md",
41
48
  "src/components/Tracking.astro",
@@ -79,6 +86,7 @@
79
86
  "./components/Carousel.astro": "./src/components/Carousel.astro",
80
87
  "./components/Gallery.astro": "./src/components/Gallery.astro",
81
88
  "./components/Counter.astro": "./src/components/Counter.astro",
89
+ "./components/Entries.astro": "./src/components/Entries.astro",
82
90
  "./components/NodeRenderer.astro": "./src/components/NodeRenderer.astro",
83
91
  "./components/WebComponentLoader.astro": "./src/components/WebComponentLoader.astro",
84
92
  "./components/Icon.astro": "./src/components/Icon.astro",
@@ -87,6 +95,9 @@
87
95
  "./components/Drawer.astro": "./src/components/Drawer.astro",
88
96
  "./components/NavShell.astro": "./src/components/NavShell.astro",
89
97
  "./components/NavShellToggle.astro": "./src/components/NavShellToggle.astro",
98
+ "./components/SiteNav.astro": "./src/components/SiteNav.astro",
99
+ "./components/SiteFooter.astro": "./src/components/SiteFooter.astro",
100
+ "./components/sitenav.ts": "./src/components/sitenav.ts",
90
101
  "./components/Tree.astro": "./src/components/Tree.astro",
91
102
  "./components/tree.ts": "./src/components/tree.ts",
92
103
  "./components/ReviewCard.astro": "./src/components/ReviewCard.astro",
@@ -98,15 +109,16 @@
98
109
  "access": "public"
99
110
  },
100
111
  "dependencies": {
101
- "@getvitops/core": "8.2.0-alpha.0",
102
- "@getvitops/generator": "8.2.0-alpha.0",
103
- "@getvitops/utils": "8.2.0-alpha.0",
104
- "@getvitops/vite": "8.2.0-alpha.0"
112
+ "@getvitops/config": "8.3.0-alpha.0",
113
+ "@getvitops/core": "8.3.0-alpha.0",
114
+ "@getvitops/generator": "8.3.0-alpha.0",
115
+ "@getvitops/utils": "8.3.0-alpha.0",
116
+ "@getvitops/vite": "8.3.0-alpha.0"
105
117
  },
106
118
  "devDependencies": {
107
119
  "@astrojs/sitemap": "^3.7.3",
108
120
  "@tailwindcss/vite": "^4.3.3",
109
- "astro": "^7.1.2",
121
+ "astro": "^7.4.4",
110
122
  "tailwindcss": "^4.3.3"
111
123
  },
112
124
  "peerDependencies": {
package/src/ads.ts CHANGED
@@ -32,32 +32,25 @@ import {
32
32
  type AdProvider,
33
33
  type ConversionLabelMap,
34
34
  } from '@getvitops/utils/ads';
35
+ import type { AdPropertyConfig } from '@getvitops/config';
35
36
  import type { AnalyticsStrategy, ConsentCategory, GatedTag } from './analytics.ts';
36
37
 
37
38
  /**
38
- * One ad property, mirroring a `site.ads` entry field for field.
39
- *
40
- * Structural rather than `SiteAdProperty` imported from the generator, matching
41
- * how `GetvitopsSeoOptions` and `GetvitopsTrackingOptions` already work: the
42
- * component surface must stay usable by a consumer who passes a plain object.
39
+ * One ad property, as `<Ads />` reads it: the fields of a `site.ads` entry that decide what is
40
+ * emitted. A subset of `AdPropertyConfig` from `@getvitops/config` — the schema's own inferred
41
+ * type, not a copy — so a plain object is still enough for a consumer to pass, and a field the
42
+ * schema renames fails here instead of silently reading `undefined`.
43
43
  */
44
- export interface GetvitopsAdProperty {
45
- accountId?: string;
46
- pixelId?: string;
47
- /**
48
- * Google Ads only: the conversion action label(s) paired with `pixelId` (the
49
- * `AW-…` conversion id) — a bare string (back-compat, treated as `call`) or a
50
- * map of action → label. `<Tracking />`'s tel:/mailto: click handler and
51
- * `vitopsFormConversion()` each fire `gtag('event', 'conversion', …)` for
52
- * their own action at the moment of the interaction — see `gtagBootstrap` in
53
- * `@getvitops/utils/ads`.
54
- */
55
- conversionLabel?: string | ConversionLabelMap;
56
- /** Default `marketing`. `analytics` for a property used purely for measurement. */
57
- category?: 'marketing' | 'analytics';
58
- /** False keeps the property on record without emitting its tag. */
59
- enabled?: boolean;
60
- }
44
+ export type GetvitopsAdProperty = Pick<
45
+ AdPropertyConfig,
46
+ | 'accountId'
47
+ | 'pixelId'
48
+ | 'conversionLabel'
49
+ | 'conversionActionId'
50
+ | 'reporting'
51
+ | 'category'
52
+ | 'enabled'
53
+ >;
61
54
 
62
55
  export type GetvitopsAdsOptions = Partial<Record<AdProvider, GetvitopsAdProperty>>;
63
56
 
@@ -28,6 +28,7 @@
28
28
  * `<Analytics />`.
29
29
  */
30
30
  // @ts-ignore — virtual module provided by the vitops() integration at build time
31
+ import { safeHref } from '@getvitops/utils/url';
31
32
  import head from 'virtual:getvitops/head';
32
33
  import type { OptionalConsentCategory } from '../analytics.ts';
33
34
 
@@ -87,7 +88,7 @@ const wording = { ...DEFAULT_LABELS, ...labels };
87
88
  losing any functionality.
88
89
  </p>
89
90
  </slot>
90
- {policyUrl && <a class="link" href={policyUrl}>{policyLabel}</a>}
91
+ {safeHref(policyUrl) && <a class="link" href={safeHref(policyUrl)}>{policyLabel}</a>}
91
92
  </div>
92
93
 
93
94
  {/*
@@ -0,0 +1,58 @@
1
+ ---
2
+ /**
3
+ * Entries — rows of term/value pairs that read as a stacked list and, with JS on a wide
4
+ * container, become a table (specs, opening hours, plan comparisons).
5
+ *
6
+ * Tier-3 like `Counter.astro` and `Gallery.astro`: the markup is semantic with no JS — a
7
+ * heading followed by a `<dl>` of `<dt>`/`<dd>` pairs per entry — and `<wc-entries>` only
8
+ * parses that fallback into a table above its `breakpoint`.
9
+ *
10
+ * **This component emits its own `<wc-entries>`** — do NOT wrap it in one. Same rule as
11
+ * `Cards.astro` and `Carousel.astro`.
12
+ *
13
+ * <Entries entries={[{ heading: 'Alice', pairs: [{ term: 'Email', value: 'a@x.ca' }] }]} />
14
+ *
15
+ * Every entry should carry the SAME terms in the same order: the table's columns are the
16
+ * terms of the first entry, so a differing set misaligns rather than errors.
17
+ */
18
+ import type { HTMLAttributes } from 'astro/types';
19
+
20
+ export interface EntriesRow {
21
+ /** The row's name — its heading, and the table's row header. */
22
+ heading: string;
23
+ pairs: { term: string; value: string }[];
24
+ }
25
+
26
+ export interface Props extends Omit<HTMLAttributes<'div'>, 'slot'> {
27
+ entries: EntriesRow[];
28
+ /** Container width at which the stacked list becomes a table, e.g. `"40rem"`. */
29
+ breakpoint?: string;
30
+ /** Keep the table at every width, scrolling sideways when narrow. */
31
+ projection?: boolean;
32
+ /** With `projection` and a narrow container, show one data column at a time. */
33
+ singular?: boolean;
34
+ /** Heading element for each entry (`<wc-entries>` accepts h1–h6). Default `h3`. */
35
+ level?: 2 | 3 | 4 | 5 | 6;
36
+ }
37
+
38
+ const { entries, breakpoint, projection, singular, level = 3, ...rest } = Astro.props;
39
+ const Heading = `h${level}` as 'h3';
40
+ ---
41
+
42
+ <wc-entries breakpoint={breakpoint} projection={projection} singular={singular} {...rest}>
43
+ {
44
+ entries.map((entry) => (
45
+ <>
46
+ <Heading>{entry.heading}</Heading>
47
+ <dl>
48
+ {entry.pairs.map((pair) => (
49
+ <>
50
+ <dt>{pair.term}</dt>
51
+ <dd>{pair.value}</dd>
52
+ </>
53
+ ))}
54
+ </dl>
55
+ </>
56
+ ))
57
+ }
58
+ </wc-entries>
@@ -13,6 +13,7 @@
13
13
  import head from 'virtual:getvitops/head';
14
14
  // @ts-ignore — `Font` is provided by Astro's Fonts API (astro >= 7)
15
15
  import { Font } from 'astro:assets';
16
+ import { CONSENT_STUB, THEME_SCRIPT } from '../inline-scripts.ts';
16
17
 
17
18
  interface Props {
18
19
  favicons?: boolean;
@@ -29,44 +30,12 @@ const {
29
30
  } = Astro.props;
30
31
  const wc = head.wcBase;
31
32
 
32
- /**
33
- * Applies the remembered colour scheme before first paint.
34
- *
35
- * The key MUST match `WCColorSchemeToggle.STORAGE_KEY` in @getvitops/core;
36
- * `head.test.ts` asserts they stay in step. try/catch because localStorage
37
- * throws in private/partitioned contexts.
38
- */
39
- const THEME_SCRIPT =
40
- `try{var s=localStorage.getItem('vitops-color-scheme');` +
41
- `if(s==='light'||s==='dark'){document.documentElement.dataset.theme=s;` +
42
- `document.documentElement.style.colorScheme=s+' only'}}catch(e){}`;
43
-
44
- /**
45
- * The consent gate's build-time signal: "something on this page may need
46
- * permission, and the real gate hasn't loaded yet."
47
- *
48
- * `consent.js` and `elements.js` are both deferred modules with no ordering
49
- * guarantee between them, so `<wc-color-scheme-toggle>` can upgrade and be clicked
50
- * before the gate exists. Without this, a caller probing for
51
- * `window.vitopsConsent` in that window would see nothing, correctly conclude
52
- * that sites without the gate should just store — and store. The stub closes the
53
- * window by making the gate's *existence* known synchronously in `<head>`, which
54
- * is the only place that beats every deferred bundle.
55
- *
56
- * It answers `false` to everything, because nothing is granted until the store
57
- * has been read, and queues what it was asked so `consent/runtime.ts` can replay
58
- * it on evaluation. `q` entries are `[category]` for `require()` and
59
- * `[category, resolve]` for `request()`. Same shape as the `dataLayer`/`gtag`
60
- * stub in `analytics.ts` and for the same reason.
61
- *
62
- * Emitted only when the gate is on: its absence is what tells a caller the site
63
- * has no gate at all.
33
+ /*
34
+ * The two inline scripts below (THEME_SCRIPT, CONSENT_STUB) are defined in
35
+ * `../inline-scripts.ts`, with the reasoning for each. They are imported rather
36
+ * than written here so `csp.ts` hashes the very same constants this emits — a
37
+ * strict Content-Security-Policy names inline scripts by hash of their bytes.
64
38
  */
65
- const CONSENT_STUB =
66
- `window.vitopsConsent=window.vitopsConsent||{q:[],` +
67
- `require:function(c){this.q.push([c]);return false},` +
68
- `request:function(c){var s=this;return new Promise(function(r){s.q.push([c,r])})},` +
69
- `granted:function(){return false},needed:function(){return false}};`;
70
39
 
71
40
  /*
72
41
  * No `<html>` class flips here on purpose. `animation.css` used to need `no-js`
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { HTMLAttributes } from 'astro/types';
9
9
  import type { Review } from '@getvitops/utils/reviews';
10
+ import { safeHref } from '@getvitops/utils/url';
10
11
  import Icon from './Icon.astro';
11
12
 
12
13
  export interface Props extends Omit<HTMLAttributes<'article'>, 'slot'> {
@@ -23,6 +24,9 @@ const initials = review.author
23
24
  .map((w) => w[0]?.toUpperCase())
24
25
  .join('');
25
26
 
27
+ // Google-hosted and fetched from outside the repo, so never trusted as a URL.
28
+ const authorPhoto = safeHref(review.authorPhoto);
29
+ const sourceUrl = safeHref(review.url);
26
30
  const full = Math.floor(review.rating);
27
31
  const hasHalf = review.rating - full >= 0.5;
28
32
  ---
@@ -30,8 +34,8 @@ const hasHalf = review.rating - full >= 0.5;
30
34
  <article class:list={['review', className]} {...rest}>
31
35
  <header class="review__header">
32
36
  {
33
- avatar && review.authorPhoto ? (
34
- <img class="review__avatar" src={review.authorPhoto} alt="" loading="lazy" />
37
+ avatar && authorPhoto ? (
38
+ <img class="review__avatar" src={authorPhoto} alt="" loading="lazy" />
35
39
  ) : (
36
40
  <span class="review__initials" aria-hidden="true">{initials}</span>
37
41
  )
@@ -56,6 +60,16 @@ const hasHalf = review.rating - full >= 0.5;
56
60
  </span>
57
61
  </header>
58
62
  {review.text && <blockquote class="review__body">{review.text}</blockquote>}
63
+ {
64
+ sourceUrl && (
65
+ // `nofollow`: the link is to a third party's page about the business itself, and
66
+ // Google's guidance for duplicated review content is to not pass it endorsement.
67
+ <a class="review__source link" href={sourceUrl} rel="nofollow noopener noreferrer">
68
+ View on Google
69
+ <span class="sr-only"> (review by {review.author})</span>
70
+ </a>
71
+ )
72
+ }
59
73
  {
60
74
  review.reply && (
61
75
  <footer class="review__reply">
@@ -0,0 +1,79 @@
1
+ ---
2
+ /**
3
+ * SiteFooter — a `contentinfo` landmark with a site map, legal links and a copyright
4
+ * line. Markup + classes only; styles are `patterns/sitenav.css`'s `.sitefooter*`.
5
+ *
6
+ * const sitemap = await getMenu('footer-sitemap');
7
+ * const legal = await getMenu('footer-legal');
8
+ * <SiteFooter {sitemap} {legal} name={siteTitle} />
9
+ *
10
+ * Both menus are optional and each renders only if it has items, so a one-page site
11
+ * gets just the copyright line. Menus are FLAT here (depth 1): a footer that nests is
12
+ * a site map, and wants a page, not a column. `name` is `organization.name`.
13
+ *
14
+ * The two `<nav>`s are named ("Site map", "Legal") so the landmark list is not a pile
15
+ * of unlabeled regions, and the copyright is plain text — never a link.
16
+ */
17
+ import { isCurrent, linkRel, menuItems, type NavMenuInput } from './sitenav.ts';
18
+
19
+ interface Props {
20
+ /** `footer-sitemap` menu. */
21
+ sitemap?: NavMenuInput;
22
+ /** `footer-legal` menu. */
23
+ legal?: NavMenuInput;
24
+ /** Company/site name for the copyright line (`organization.name`). */
25
+ name: string;
26
+ /** Override the year (tests, or a "since" range string). Default: build year. */
27
+ year?: number | string;
28
+ /** Accessible names. */
29
+ sitemapLabel?: string;
30
+ legalLabel?: string;
31
+ current?: string;
32
+ class?: string;
33
+ }
34
+
35
+ const {
36
+ sitemap,
37
+ legal,
38
+ name,
39
+ year = new Date().getFullYear(),
40
+ sitemapLabel = 'Site map',
41
+ legalLabel = 'Legal',
42
+ current = Astro.url.pathname,
43
+ class: className,
44
+ } = Astro.props;
45
+
46
+ const groups = [
47
+ { label: sitemapLabel, items: menuItems(sitemap), kind: 'sitemap' },
48
+ { label: legalLabel, items: menuItems(legal), kind: 'legal' },
49
+ ].filter((g) => g.items.length > 0);
50
+ ---
51
+
52
+ <footer class:list={['sitefooter', className]}>
53
+ {
54
+ groups.length > 0 && (
55
+ <div class="sitefooter__nav">
56
+ {groups.map((g) => (
57
+ <nav class={`sitefooter__group sitefooter__group--${g.kind}`} aria-label={g.label}>
58
+ <ul class="sitefooter__list" role="list">
59
+ {g.items.map((item) => (
60
+ <li>
61
+ <a
62
+ class="sitefooter__link"
63
+ href={item.url || '#'}
64
+ target={item.target || undefined}
65
+ rel={linkRel(item)}
66
+ aria-current={isCurrent(item.url, current) ? 'page' : undefined}
67
+ >
68
+ {item.label}
69
+ </a>
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ </nav>
74
+ ))}
75
+ </div>
76
+ )
77
+ }
78
+ <p class="sitefooter__copy">&copy; {year} {name}</p>
79
+ </footer>
@@ -0,0 +1,163 @@
1
+ ---
2
+ /**
3
+ * SiteNav — the site header's navigation, from EmDash menus (or any menu data of the
4
+ * same shape). Markup + classes only, no runtime JS: the hamburger is a Popover API
5
+ * invoker for a `[popover]` drawer, branches are native `<details>` accordions in the
6
+ * drawer and hover/focus/click dropdowns at width. Every declaration lives in the
7
+ * framework's `sitenav` pattern (patterns/sitenav.css), emitted in all three formats.
8
+ * No `wc-*` element is involved, so there is no `display` to declare.
9
+ *
10
+ * import { loadNavMenus } from '@getvitops/astro/components/sitenav.ts';
11
+ * const nav = await loadNavMenus(getMenu);
12
+ * <SiteNav {...nav} />
13
+ *
14
+ * ── TIERS ──
15
+ * ONE menu is the default and is enough: `primary`, depth 3, a dropdown from the `md`
16
+ * container width (48rem) and a drawer below it. Two optional variants are each their
17
+ * own EmDash menu; `<SiteNav>` renders whichever exist, with no code change:
18
+ *
19
+ * `primary-mobile` Add it to give small screens a simpler nav (depth 1, the few links
20
+ * a thumb wants). It shows BELOW `mobileBreakpoint` (default `md`);
21
+ * `primary` takes over from there.
22
+ * `primary-mega` Add it for a mega menu on wide screens (full-width panels, depth 3).
23
+ * It shows from `megaBreakpoint` (default `xl`, 80rem); `primary`
24
+ * covers the widths below.
25
+ *
26
+ * Breakpoints are container widths (sm/md/lg/xl = 30/48/64/80rem), matched to the
27
+ * `.sitenav--bp-*` classes, so they follow the same `body` container every other
28
+ * responsive utility does. Only one tier is ever in the accessibility tree: the others
29
+ * are `display: none`, so a screen-reader user never meets two "Primary" landmarks.
30
+ *
31
+ * ── DESCRIPTIONS ──
32
+ * EmDash menu items have no description field. Convention until they do (an upstream
33
+ * request): an item's **`titleAttr` is its description**, rendered as muted second-line
34
+ * text under the link. It is NOT emitted as a `title` tooltip. `cssClasses` pass through
35
+ * on the link.
36
+ *
37
+ * Accessibility: `<nav aria-label>` landmark, `aria-current="page"` on the current
38
+ * item, the link is never inside `<summary>` (no nested interactive controls), caret
39
+ * buttons are named, `:focus-visible` rings and forced-colors / reduced-motion handling
40
+ * are in the pattern.
41
+ */
42
+ import {
43
+ drawerId,
44
+ navClass,
45
+ selectTiers,
46
+ tierClass,
47
+ type NavBreakpoint,
48
+ type NavMenuInput,
49
+ } from './sitenav.ts';
50
+ import SiteNavList from './SiteNavList.astro';
51
+
52
+ interface Props {
53
+ /** `primary` menu (the default, medium tier). */
54
+ primary?: NavMenuInput;
55
+ /** `primary-mobile` menu. Optional variant. */
56
+ mobile?: NavMenuInput;
57
+ /** `primary-mega` menu. Optional variant. */
58
+ mega?: NavMenuInput;
59
+ /** Accessible name of the `<nav>`. Default "Primary". */
60
+ label?: string;
61
+ /** Hamburger button name. Default "Menu". */
62
+ toggleLabel?: string;
63
+ /** Where the default nav promotes from drawer to dropdowns. Default `md`. */
64
+ breakpoint?: NavBreakpoint;
65
+ /** Where `primary-mobile` hands over to `primary`. Default = `breakpoint`. */
66
+ mobileBreakpoint?: NavBreakpoint;
67
+ /** Where `primary-mega` takes over. Default `xl`. */
68
+ megaBreakpoint?: NavBreakpoint;
69
+ /** Levels in the default nav. Default 3. */
70
+ depth?: number;
71
+ /** Override the current path (for `aria-current`). Default: the request path. */
72
+ current?: string;
73
+ /** Id prefix for the drawer popovers. Required only if two SiteNavs share a page. */
74
+ id?: string;
75
+ class?: string;
76
+ }
77
+
78
+ const {
79
+ primary,
80
+ mobile,
81
+ mega,
82
+ label = 'Primary',
83
+ toggleLabel = 'Menu',
84
+ breakpoint,
85
+ mobileBreakpoint,
86
+ megaBreakpoint,
87
+ depth,
88
+ current = Astro.url.pathname,
89
+ id = 'sitenav',
90
+ class: className,
91
+ } = Astro.props;
92
+
93
+ const tiers = selectTiers({
94
+ primary,
95
+ mobile,
96
+ mega,
97
+ breakpoint,
98
+ mobileBreakpoint,
99
+ megaBreakpoint,
100
+ depth,
101
+ });
102
+ ---
103
+
104
+ {
105
+ tiers.map((tier) => {
106
+ const panel = drawerId(id, tier);
107
+ return (
108
+ <div class:list={[...tierClass(tier), className]}>
109
+ <nav class:list={navClass(tier)} aria-label={label}>
110
+ <button
111
+ type="button"
112
+ class="sitenav__toggle"
113
+ popovertarget={panel}
114
+ aria-label={toggleLabel}
115
+ >
116
+ <svg
117
+ class="sitenav__toggle-icon"
118
+ viewBox="0 0 24 24"
119
+ width="24"
120
+ height="24"
121
+ fill="none"
122
+ stroke="currentColor"
123
+ stroke-width="2"
124
+ stroke-linecap="round"
125
+ aria-hidden="true"
126
+ focusable="false"
127
+ >
128
+ <line x1="3" y1="6" x2="21" y2="6" />
129
+ <line x1="3" y1="12" x2="21" y2="12" />
130
+ <line x1="3" y1="18" x2="21" y2="18" />
131
+ </svg>
132
+ </button>
133
+ <div id={panel} popover class="sitenav__panel">
134
+ <button
135
+ type="button"
136
+ class="sitenav__close"
137
+ popovertarget={panel}
138
+ popovertargetaction="hide"
139
+ aria-label="Close menu"
140
+ >
141
+ <svg
142
+ class="sitenav__close-icon"
143
+ viewBox="0 0 24 24"
144
+ width="24"
145
+ height="24"
146
+ fill="none"
147
+ stroke="currentColor"
148
+ stroke-width="2"
149
+ stroke-linecap="round"
150
+ aria-hidden="true"
151
+ focusable="false"
152
+ >
153
+ <line x1="6" y1="6" x2="18" y2="18" />
154
+ <line x1="18" y1="6" x2="6" y2="18" />
155
+ </svg>
156
+ </button>
157
+ <SiteNavList items={tier.items} current={current} />
158
+ </div>
159
+ </nav>
160
+ </div>
161
+ );
162
+ })
163
+ }
@@ -0,0 +1,73 @@
1
+ ---
2
+ /**
3
+ * One `<ul>` level of a `<SiteNav>`, recursing for sub-menus. Internal — import
4
+ * `SiteNav.astro`. Markup is `bricks/elements/sitenav.php`'s, deliberately identical,
5
+ * so `patterns/sitenav.css` styles both and a fix to one reaches the other:
6
+ *
7
+ * <li class="sitenav__item sitenav__item--branch">
8
+ * <a class="sitenav__link split-link__link">…</a> ← the link is NOT in the summary
9
+ * <details class="sitenav__disclosure"><summary>caret</summary></details>
10
+ * <ul class="sitenav__submenu">…</ul> ← sibling, driven by :has([open])
11
+ * </li>
12
+ *
13
+ * The `<details>` is never pre-opened for the current branch: at width the same native
14
+ * `[open]` PINS the dropdown visible, so an open-by-default branch would hang a menu
15
+ * over the page on every load. The current item is marked with `aria-current` instead.
16
+ */
17
+ import { isCurrent, itemDescription, linkRel, type NavItem } from './sitenav.ts';
18
+
19
+ interface Props {
20
+ items: NavItem[];
21
+ current: string;
22
+ top?: boolean;
23
+ }
24
+
25
+ const { items, current, top = true } = Astro.props;
26
+ ---
27
+
28
+ <ul class={top ? 'sitenav__list' : 'sitenav__submenu'} role="list">
29
+ {
30
+ items.map((item) => {
31
+ const kids = item.children ?? [];
32
+ const desc = itemDescription(item);
33
+ const classes = [
34
+ 'sitenav__link',
35
+ 'split-link__link',
36
+ desc && 'sitenav__link--described',
37
+ item.cssClasses,
38
+ ]
39
+ .filter(Boolean)
40
+ .join(' ');
41
+ const link = (
42
+ <a
43
+ class={classes}
44
+ href={item.url || '#'}
45
+ target={item.target || undefined}
46
+ rel={linkRel(item)}
47
+ aria-current={isCurrent(item.url, current) ? 'page' : undefined}
48
+ >
49
+ <span class="sitenav__label">{item.label}</span>
50
+ {desc && <span class="sitenav__desc">{desc}</span>}
51
+ </a>
52
+ );
53
+ return kids.length > 0 ? (
54
+ <li class="sitenav__item sitenav__item--branch">
55
+ {link}
56
+ <details class="sitenav__disclosure">
57
+ <summary
58
+ class="sitenav__subtoggle split-link__toggle"
59
+ aria-label={`Show ${item.label} submenu`}
60
+ >
61
+ <span class="split-link__caret" aria-hidden="true">
62
+ &#9662;
63
+ </span>
64
+ </summary>
65
+ </details>
66
+ <Astro.self items={kids} current={current} top={false} />
67
+ </li>
68
+ ) : (
69
+ <li class="sitenav__item">{link}</li>
70
+ );
71
+ })
72
+ }
73
+ </ul>