@getvitops/emdash 1.0.0 → 2.0.0-alpha.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.d.mts +34 -1
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1450 -177
  5. package/dist/index.mjs.map +1 -1
  6. package/package.json +10 -7
  7. package/src/admin/DesignSystemEditorPage.tsx +15 -42
  8. package/src/astro/Nothing.astro +3 -0
  9. package/src/astro/WidgetArea.astro +91 -0
  10. package/src/astro/blocks/Accordion.astro +2 -1
  11. package/src/astro/blocks/ActionLink.astro +2 -1
  12. package/src/astro/blocks/Badges.astro +67 -0
  13. package/src/astro/blocks/Banner.astro +3 -2
  14. package/src/astro/blocks/Callout.astro +68 -0
  15. package/src/astro/blocks/Cards.astro +54 -9
  16. package/src/astro/blocks/Carousel.astro +2 -0
  17. package/src/astro/blocks/CopyButton.astro +3 -2
  18. package/src/astro/blocks/Details.astro +2 -1
  19. package/src/astro/blocks/Embed.astro +68 -0
  20. package/src/astro/blocks/Entries.astro +31 -0
  21. package/src/astro/blocks/Form.astro +195 -0
  22. package/src/astro/blocks/Gallery.astro +2 -0
  23. package/src/astro/blocks/Hero.astro +78 -23
  24. package/src/astro/blocks/ImageCompare.astro +2 -1
  25. package/src/astro/blocks/Logos.astro +48 -0
  26. package/src/astro/blocks/Marquee.astro +2 -1
  27. package/src/astro/blocks/Nav.astro +85 -0
  28. package/src/astro/blocks/NavTree.astro +48 -0
  29. package/src/astro/blocks/Pricing.astro +66 -0
  30. package/src/astro/blocks/Reviews.astro +122 -0
  31. package/src/astro/blocks/RichText.astro +14 -0
  32. package/src/astro/blocks/Section.astro +82 -0
  33. package/src/astro/blocks/Stats.astro +59 -0
  34. package/src/astro/blocks/Steps.astro +48 -0
  35. package/src/astro/blocks/WidgetArea.astro +21 -0
  36. package/src/astro/blocks/attrs.test.ts +56 -0
  37. package/src/astro/blocks/attrs.ts +104 -0
  38. package/src/astro/blocks/embed.test.ts +43 -0
  39. package/src/astro/blocks/embed.ts +84 -0
  40. package/src/astro/blocks/form.sample.ts +33 -0
  41. package/src/astro/blocks/form.test.ts +55 -0
  42. package/src/astro/blocks/form.ts +158 -0
  43. package/src/astro/blocks/head.stub.ts +2 -0
  44. package/src/astro/blocks/nav.test.ts +51 -0
  45. package/src/astro/blocks/nav.ts +65 -0
  46. package/src/astro/blocks/render.container.test.ts +610 -0
  47. package/src/astro/blocks/tone.test.ts +53 -0
  48. package/src/astro/blocks/tone.ts +122 -0
  49. package/src/astro/blocks/value.test.ts +36 -1
  50. package/src/astro/blocks/value.ts +78 -1
  51. package/src/astro/index.ts +37 -0
  52. package/src/admin/core-editor.d.ts +0 -7
@@ -0,0 +1,195 @@
1
+ ---
2
+ /**
3
+ * A contact / enquiry form that posts to the site's conversion endpoint.
4
+ *
5
+ * Tier 1 first: a real `<form method="post">` of real `<label>`s, styled by the framework's
6
+ * `forms.css` vocabulary, that works with no JavaScript. It posts to `createConversionRoute()`
7
+ * (default `/api/track`, which the emdash template ships), which applies the honeypot, origin,
8
+ * size and rate checks, verifies Turnstile, then writes the CRM and emails the notification.
9
+ * The block itself decides none of that: it only emits markup the route understands.
10
+ *
11
+ * The no-JS round trip. The route answers a form post with a 303 back to the `_next` hidden
12
+ * field (this page's path, a same-site path only) plus `?sent=1#form-sent` or
13
+ * `?error=1#form-error`. The page is prerendered and cannot know which happened, so both
14
+ * messages are always in the markup and `.form-notice--target:not(:target)` hides whichever the
15
+ * URL fragment does not name. They carry `tabindex="-1"` so following the fragment moves focus
16
+ * to the message. One form per page: the fragment ids are fixed.
17
+ *
18
+ * Spam protection, in layers: a honeypot field named per the route's default, the route's own
19
+ * origin/rate/size checks, and Cloudflare Turnstile when `site.security.turnstile.siteKey`
20
+ * exists (the siteKey comes from the build's `virtual:getvitops/head`). Turnstile is emitted as
21
+ * `<wc-turnstile>`, which loads Cloudflare's script only on first interaction with the form.
22
+ * Its slotted paragraph is the no-JS explanation. With no site key there is no widget and no
23
+ * third-party reference at all.
24
+ *
25
+ * Field names go through `form.ts`, which keeps them clear of the names the route reserves
26
+ * (`phone`, `event`, the honeypots), and a phone field submits as `tel`.
27
+ */
28
+ import head from 'virtual:getvitops/head';
29
+ import { resolveTone, joinStyle } from './tone.ts';
30
+ import { HONEYPOT_NAME, resolveFields, safeAction, safeNext } from './form.ts';
31
+ import { headingTag, resolveBlockValue, rows, text } from './value.ts';
32
+
33
+ interface Value {
34
+ heading?: string;
35
+ level?: string | number;
36
+ intro?: string;
37
+ fields?: unknown;
38
+ submitLabel?: string;
39
+ successMessage?: string;
40
+ errorMessage?: string;
41
+ noJsMessage?: string;
42
+ action?: string;
43
+ tone?: string;
44
+ color?: string;
45
+ }
46
+
47
+ const value = resolveBlockValue<Value>(Astro.props);
48
+ const fields = resolveFields(rows(value.fields));
49
+ const heading = text(value.heading);
50
+ const intro = text(value.intro);
51
+ const Heading = headingTag(value.level, 2);
52
+ const submitLabel = text(value.submitLabel) || 'Send';
53
+ const successMessage = text(value.successMessage) || 'Thanks. Your message has been sent.';
54
+ const errorMessage =
55
+ text(value.errorMessage) ||
56
+ 'Sorry, your message could not be sent. Please try again, or contact us another way.';
57
+ const noJsMessage =
58
+ text(value.noJsMessage) ||
59
+ 'The spam check on this form needs JavaScript. If it is turned off, please contact us by email or phone instead.';
60
+ const action = safeAction(value.action);
61
+ const next = safeNext(Astro.url.pathname) ?? '/';
62
+ const siteKey = typeof head?.turnstileSiteKey === 'string' ? head.turnstileSiteKey : null;
63
+ const tone = resolveTone(value.tone, value.color);
64
+ const successTone = resolveTone('success', '');
65
+ const errorTone = resolveTone('danger', '');
66
+ const hasForm = fields.length > 0;
67
+ import { rootAttrs } from './attrs.ts';
68
+ ---
69
+
70
+ {
71
+ hasForm && (
72
+ <div {...rootAttrs(value, ['form-block', 'rhythm', tone.className], joinStyle(tone.style))}>
73
+ {heading && <Heading class="font-heading">{heading}</Heading>}
74
+ {intro && <p>{intro}</p>}
75
+
76
+ <div
77
+ id="form-sent"
78
+ class:list={['callout', 'form-notice--target', successTone.className]}
79
+ style={joinStyle(successTone.style)}
80
+ role="status"
81
+ tabindex="-1"
82
+ >
83
+ <div class="callout__body">
84
+ <p class="callout__text">{successMessage}</p>
85
+ </div>
86
+ </div>
87
+ <div
88
+ id="form-error"
89
+ class:list={['callout', 'form-notice--target', errorTone.className]}
90
+ style={joinStyle(errorTone.style)}
91
+ role="alert"
92
+ tabindex="-1"
93
+ >
94
+ <div class="callout__body">
95
+ <p class="callout__text">{errorMessage}</p>
96
+ </div>
97
+ </div>
98
+
99
+ <form method="post" action={action} class="rhythm">
100
+ <input type="hidden" name="_next" value={next} />
101
+ {fields.map((f) => {
102
+ const describedby = f.help ? `${f.id}-help` : undefined;
103
+ const requiredMark = f.required ? { required: true, 'aria-required': 'true' } : {};
104
+ return f.type === 'checkbox' ? (
105
+ <div class="form-group">
106
+ <label class="custom-check">
107
+ <input
108
+ type="checkbox"
109
+ id={f.id}
110
+ name={f.name}
111
+ value="yes"
112
+ aria-describedby={describedby}
113
+ {...requiredMark}
114
+ />
115
+ <span class="custom-check__box">
116
+ <span class="custom-check__indicator custom-check__indicator--check" />
117
+ </span>
118
+ <span>{f.label}</span>
119
+ </label>
120
+ {f.help && (
121
+ <p id={`${f.id}-help`} class="form-group__helper">
122
+ {f.help}
123
+ </p>
124
+ )}
125
+ </div>
126
+ ) : (
127
+ <div class="form-group">
128
+ <label class="form-group__label" for={f.id}>
129
+ {f.label}
130
+ {!f.required && <span class="form-group__optional">(optional)</span>}
131
+ </label>
132
+ {f.type === 'textarea' ? (
133
+ <textarea
134
+ id={f.id}
135
+ name={f.name}
136
+ rows="5"
137
+ placeholder={f.placeholder || undefined}
138
+ aria-describedby={describedby}
139
+ {...requiredMark}
140
+ />
141
+ ) : f.type === 'select' ? (
142
+ <div class="custom-select">
143
+ <select id={f.id} name={f.name} aria-describedby={describedby} {...requiredMark}>
144
+ <option value="">Choose one</option>
145
+ {f.options.map((o) => (
146
+ <option value={o}>{o}</option>
147
+ ))}
148
+ </select>
149
+ </div>
150
+ ) : (
151
+ <input
152
+ type={f.type}
153
+ id={f.id}
154
+ name={f.name}
155
+ placeholder={f.placeholder || undefined}
156
+ autocomplete={f.autocomplete}
157
+ aria-describedby={describedby}
158
+ {...requiredMark}
159
+ />
160
+ )}
161
+ {f.help && (
162
+ <p id={`${f.id}-help`} class="form-group__helper">
163
+ {f.help}
164
+ </p>
165
+ )}
166
+ </div>
167
+ );
168
+ })}
169
+
170
+ {/* Honeypot: named per createConversionRoute's default list. Positioned off-screen rather than
171
+ display:none (which simple bots skip), and hidden from assistive tech and the tab order. */}
172
+ <div class="sr-only" aria-hidden="true">
173
+ <label>
174
+ Leave this field empty
175
+ <input type="text" name={HONEYPOT_NAME} tabindex="-1" autocomplete="off" />
176
+ </label>
177
+ </div>
178
+
179
+ {siteKey && (
180
+ <wc-turnstile sitekey={siteKey}>
181
+ <p class="form-group__helper" data-turnstile-fallback>
182
+ {noJsMessage}
183
+ </p>
184
+ </wc-turnstile>
185
+ )}
186
+
187
+ <p>
188
+ <button type="submit" class="cta">
189
+ {submitLabel}
190
+ </button>
191
+ </p>
192
+ </form>
193
+ </div>
194
+ )
195
+ }
@@ -25,6 +25,7 @@ const images = rows<{ src?: unknown; alt?: unknown; caption?: unknown }>(value.i
25
25
  return src ? [{ src, alt: text(row.alt), ...(caption ? { caption } : {}) }] : [];
26
26
  },
27
27
  );
28
+ import { rootAttrs } from './attrs.ts';
28
29
  ---
29
30
 
30
31
  {
@@ -33,6 +34,7 @@ const images = rows<{ src?: unknown; alt?: unknown; caption?: unknown }>(value.i
33
34
  label={text(value.label) || 'Gallery'}
34
35
  images={images}
35
36
  id={id}
37
+ {...rootAttrs(value)}
36
38
  aspect={text(value.aspect) || undefined}
37
39
  loop={Boolean(value.loop)}
38
40
  />
@@ -1,15 +1,27 @@
1
1
  ---
2
2
  /**
3
3
  * A page opener built from the layout primitives: `.centered` for a single column, `.split`
4
- * for text beside an image. The heading is a real `<h1>` only when the editor chooses nothing
5
- * else — a page has one, and EmDash renders the page title elsewhere, so this uses `<h2>`.
4
+ * for text beside an image. The heading defaults to `<h2>` — a page has one `<h1>`, and
5
+ * EmDash renders the page title elsewhere — and an editor can change the level. Words wrapped
6
+ * in `*stars*` render as `<em class="accent">` (accent marks, no raw HTML in the field).
7
+ * An optional row of option cards sits under the buttons, as the framework's `.card` list.
6
8
  * Flat fields, because a block cannot contain other blocks.
7
9
  */
8
- import { resolveBlockValue, safeHref, text } from './value.ts';
10
+ import RichText from './RichText.astro';
11
+ import { joinStyle, resolveTone } from './tone.ts';
12
+ import {
13
+ accentRuns,
14
+ headingTag,
15
+ resolveBlockValue,
16
+ rows,
17
+ safeHref,
18
+ text,
19
+ } from './value.ts';
9
20
 
10
21
  interface Value {
11
22
  eyebrow?: string;
12
23
  heading?: string;
24
+ level?: string | number;
13
25
  body?: string;
14
26
  primaryLabel?: string;
15
27
  primaryHref?: string;
@@ -18,11 +30,16 @@ interface Value {
18
30
  image?: string;
19
31
  imageAlt?: string;
20
32
  layout?: string;
33
+ tone?: string;
34
+ color?: string;
35
+ cards?: unknown;
21
36
  }
22
37
 
23
38
  const value = resolveBlockValue<Value>(Astro.props);
24
39
  const eyebrow = text(value.eyebrow);
25
40
  const heading = text(value.heading);
41
+ const headingRuns = accentRuns(heading);
42
+ const Heading = headingTag(value.level, 2);
26
43
  const body = text(value.body);
27
44
  const primaryLabel = text(value.primaryLabel);
28
45
  const primaryHref = safeHref(value.primaryHref);
@@ -31,31 +48,69 @@ const secondaryHref = safeHref(value.secondaryHref);
31
48
  const image = safeHref(value.image);
32
49
  const imageAlt = text(value.imageAlt);
33
50
  const split = value.layout === 'split' && Boolean(image);
51
+ const tone = resolveTone(value.tone, value.color);
52
+ const cards = rows<Record<string, unknown>>(value.cards)
53
+ .map((row) => ({
54
+ title: text(row.title),
55
+ body: text(row.body),
56
+ href: safeHref(row.href),
57
+ linkLabel: text(row.linkLabel) || 'Learn more',
58
+ }))
59
+ .filter((card) => card.title || card.body);
60
+ import { rootAttrs } from './attrs.ts';
34
61
  ---
35
62
 
36
63
  {
37
64
  (heading || body) && (
38
- <section class={split ? 'split split-1-2' : 'centered'}>
39
- <div class="rhythm">
40
- {eyebrow && <p class="font-eyebrow">{eyebrow}</p>}
41
- {heading && <h2 class="font-display">{heading}</h2>}
42
- {body && <p class="font-lead">{body}</p>}
43
- {(primaryLabel && primaryHref) || (secondaryLabel && secondaryHref) ? (
44
- <p class="cluster">
45
- {primaryLabel && primaryHref && (
46
- <a class="cta" href={primaryHref}>
47
- {primaryLabel}
48
- </a>
49
- )}
50
- {secondaryLabel && secondaryHref && (
51
- <a class="link" href={secondaryHref}>
52
- {secondaryLabel}
53
- </a>
54
- )}
55
- </p>
56
- ) : null}
65
+ <section {...rootAttrs(value, [tone.style && tone.className], joinStyle(tone.style))}>
66
+ <div class={split ? 'split split-1-2' : 'centered'}>
67
+ <div class="rhythm">
68
+ {eyebrow && <p class="font-eyebrow">{eyebrow}</p>}
69
+ {heading && (
70
+ <Heading class="font-display">
71
+ {headingRuns.map((run) =>
72
+ run.accent ? <em class="accent">{run.text}</em> : run.text,
73
+ )}
74
+ </Heading>
75
+ )}
76
+ {body && <p class="font-lead">{body}</p>}
77
+ {(primaryLabel && primaryHref) || (secondaryLabel && secondaryHref) ? (
78
+ <p class="cluster">
79
+ {primaryLabel && primaryHref && (
80
+ <a class="cta" href={primaryHref}>
81
+ {primaryLabel}
82
+ </a>
83
+ )}
84
+ {secondaryLabel && secondaryHref && (
85
+ <a class="link" href={secondaryHref}>
86
+ {secondaryLabel}
87
+ </a>
88
+ )}
89
+ </p>
90
+ ) : null}
91
+ </div>
92
+ {split && <img src={image} alt={imageAlt} loading="eager" />}
57
93
  </div>
58
- {split && <img src={image} alt={imageAlt} loading="eager" />}
94
+ {cards.length > 0 && (
95
+ <ul class="subgrid centered" role="list" style={`--subgrid-cols: ${Math.min(4, cards.length)}`}>
96
+ {cards.map((card) => (
97
+ <li class="card subgrid-card">
98
+ {card.title && <h3 class="font-heading">{card.title}</h3>}
99
+ {card.body && (
100
+ <p>
101
+ <RichText text={card.body} />
102
+ </p>
103
+ )}
104
+ {card.href && (
105
+ <a class="link stretched-link" href={card.href}>
106
+ {card.linkLabel}
107
+ {card.title && <span class="sr-only">: {card.title}</span>}
108
+ </a>
109
+ )}
110
+ </li>
111
+ ))}
112
+ </ul>
113
+ )}
59
114
  </section>
60
115
  )
61
116
  }
@@ -15,10 +15,11 @@ const { beforeAlt = '', beforeLabel = 'Before', afterAlt = '', afterLabel = 'Aft
15
15
  // A `javascript:`/`data:` src is dropped, so the block renders nothing rather than that image.
16
16
  const before = safeHref(value.before);
17
17
  const after = safeHref(value.after);
18
+ import { rootAttrs } from './attrs.ts';
18
19
  ---
19
20
 
20
21
  {before && after && (
21
- <wc-image-compare before-label={beforeLabel} after-label={afterLabel}>
22
+ <wc-image-compare {...rootAttrs(value)} before-label={beforeLabel} after-label={afterLabel}>
22
23
  <img slot="before" src={before} alt={beforeAlt} loading="lazy" />
23
24
  <img slot="after" src={after} alt={afterAlt} loading="lazy" />
24
25
  </wc-image-compare>
@@ -0,0 +1,48 @@
1
+ ---
2
+ /**
3
+ * A still row of partner/client marks on the framework's `.logos` pattern. `vitops.marquee`
4
+ * is the scrolling version; this one holds still. A list, so the count is announced, and
5
+ * each mark is an image with the editor's name as its alt text — a logo with no alt is a
6
+ * link or a fact the page silently drops.
7
+ */
8
+ import { resolveBlockValue, rows, safeHref, text } from './value.ts';
9
+
10
+ interface Value {
11
+ label?: string;
12
+ logos?: unknown;
13
+ grayscale?: boolean;
14
+ size?: string;
15
+ }
16
+
17
+ const HEIGHTS: Record<string, string> = { sm: '1.75rem', md: '2.5rem', lg: '3.5rem' };
18
+
19
+ const value = resolveBlockValue<Value>(Astro.props);
20
+ const label = text(value.label) || 'Partners';
21
+ const logos = rows<Record<string, unknown>>(value.logos)
22
+ .map((row) => ({ image: safeHref(row.image), alt: text(row.alt), href: safeHref(row.href) }))
23
+ .filter((logo) => logo.image);
24
+ const height = HEIGHTS[text(value.size)];
25
+ import { rootAttrs } from './attrs.ts';
26
+ ---
27
+
28
+ {
29
+ logos.length > 0 && (
30
+ <ul
31
+ {...rootAttrs(value, ['logos', value.grayscale && 'logos--grayscale'], height ? `--logos-height: ${height}` : undefined)}
32
+ role="list"
33
+ aria-label={label}
34
+ >
35
+ {logos.map((logo) => (
36
+ <li class="logos__item">
37
+ {logo.href ? (
38
+ <a href={logo.href}>
39
+ <img src={logo.image} alt={logo.alt} loading="lazy" />
40
+ </a>
41
+ ) : (
42
+ <img src={logo.image} alt={logo.alt} loading="lazy" />
43
+ )}
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ )
48
+ }
@@ -21,11 +21,12 @@ const items = rows<Record<string, unknown>>(value.items)
21
21
  alt: text(row.alt),
22
22
  }))
23
23
  .filter((item) => item.text || item.image);
24
+ import { rootAttrs } from './attrs.ts';
24
25
  ---
25
26
 
26
27
  {
27
28
  items.length > 0 && (
28
- <Marquee pauseOnHover={Boolean(value.pauseOnHover)} reverse={Boolean(value.reverse)}>
29
+ <Marquee pauseOnHover={Boolean(value.pauseOnHover)} reverse={Boolean(value.reverse)} {...rootAttrs(value)}>
29
30
  {items.map((item) =>
30
31
  item.image ? (
31
32
  <img src={item.image} alt={item.alt} loading="lazy" />
@@ -0,0 +1,85 @@
1
+ ---
2
+ /**
3
+ * An EmDash menu inside page content — a site map, a section's link list, a set of footer-style
4
+ * columns. **Not the site header:** that is `<SiteNav>` in the layout, which owns the drawer,
5
+ * the dropdowns and the breakpoints. The `sitenav` variant exists for the rare page that wants
6
+ * that navbar in the body, and renders `<SiteNav>` itself rather than a copy of it.
7
+ *
8
+ * Tier-3, no runtime JS. The menu is fetched with EmDash's `getMenu()` at render time (so at
9
+ * build time on a prerendered page). **A missing menu, an empty one, or a name that is not a
10
+ * slug renders nothing** — never an error; a deleted menu must not take the page down.
11
+ *
12
+ * Accessibility: a `<nav>` landmark with a name that is unique on the page — the header is
13
+ * "Primary" and the footer "Site map" / "Legal", so `navLabel()` never hands this block one of
14
+ * those — and `aria-current="page"` on the current item. An optional visible heading is a real
15
+ * heading at the editor's `level` (default `h2`), kept outside the `<nav>` so the landmark's
16
+ * name is the `aria-label`, not a duplicate of the heading text.
17
+ *
18
+ * The menu-tree helpers (`trimDepth`, `isCurrent`, `linkRel`) are `@getvitops/astro`'s
19
+ * `sitenav.ts`; only the selection rules live in `nav.ts`.
20
+ */
21
+ import { getMenu } from 'emdash';
22
+ import SiteNav from '@getvitops/astro/components/SiteNav.astro';
23
+ import { menuItems, trimDepth } from '@getvitops/astro/components/sitenav.ts';
24
+ import NavTree from './NavTree.astro';
25
+ import { menuName, navDepth, navId, navLabel, navVariant } from './nav.ts';
26
+ import { joinStyle, resolveTone } from './tone.ts';
27
+ import { headingTag, resolveBlockValue, text } from './value.ts';
28
+
29
+ interface Value {
30
+ _key?: string;
31
+ menu?: string;
32
+ label?: string;
33
+ heading?: string;
34
+ level?: string | number;
35
+ variant?: string;
36
+ depth?: string | number;
37
+ tone?: string;
38
+ color?: string;
39
+ }
40
+
41
+ const value = resolveBlockValue<Value>(Astro.props);
42
+ const name = menuName(value.menu);
43
+ let menu: Awaited<ReturnType<typeof getMenu>> = null;
44
+ if (name) {
45
+ try {
46
+ menu = await getMenu(name);
47
+ } catch {
48
+ // No database or request context: the same quiet nothing as a missing menu.
49
+ menu = null;
50
+ }
51
+ }
52
+
53
+ const variant = navVariant(value.variant);
54
+ const depth = navDepth(value.depth);
55
+ const items = menuItems(menu);
56
+ const label = navLabel(value.label, value.heading, menu?.label, name ?? '');
57
+ const heading = text(value.heading);
58
+ const Heading = headingTag(value.level, 2);
59
+ const tone = resolveTone(value.tone, value.color);
60
+ const toned = Boolean(value.tone || value.color);
61
+ const trimmed = trimDepth(items, depth);
62
+ const current = Astro.url.pathname;
63
+ import { rootAttrs } from './attrs.ts';
64
+ ---
65
+
66
+ {
67
+ items.length > 0 && (
68
+ <div
69
+ {...rootAttrs(value, ['navblock', toned && tone.className], toned ? joinStyle(tone.style) : undefined)}
70
+ >
71
+ {heading && <Heading class="font-heading">{heading}</Heading>}
72
+ {variant === 'sitenav' ? (
73
+ <SiteNav primary={trimmed} label={label} depth={depth} id={navId(value._key, name ?? '')} />
74
+ ) : (
75
+ <nav aria-label={label}>
76
+ <NavTree
77
+ items={trimmed}
78
+ current={current}
79
+ modifiers={variant === 'list' ? [] : [`navlist--${variant}`]}
80
+ />
81
+ </nav>
82
+ )}
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,48 @@
1
+ ---
2
+ /**
3
+ * One `<ul class="navlist">` level of `vitops.nav`, recursing for sub-links. Internal — import
4
+ * `Nav.astro`. Plain nested lists, no disclosure widgets: a page-body menu is read, not
5
+ * collapsed, so there is no state to manage and no runtime JS. The `.navlist` pattern is
6
+ * `patterns/sitenav.css`'s (all formats).
7
+ */
8
+ import { isCurrent, linkRel, type NavItem } from '@getvitops/astro/components/sitenav.ts';
9
+ import { safeHref } from './value.ts';
10
+
11
+ interface Props {
12
+ items: NavItem[];
13
+ current: string;
14
+ /** Modifier classes on the top-level list (`navlist--inline`, …); empty for sub-levels. */
15
+ modifiers?: string[];
16
+ top?: boolean;
17
+ }
18
+
19
+ const { items, current, modifiers = [], top = true } = Astro.props;
20
+ ---
21
+
22
+ <ul class:list={[top ? 'navlist' : 'navlist__sub', ...modifiers]} role="list">
23
+ {
24
+ items.map((item) => {
25
+ const href = safeHref(item.url);
26
+ const kids = item.children ?? [];
27
+ return (
28
+ <li class="navlist__item">
29
+ {href ? (
30
+ <a
31
+ class:list={['navlist__link', item.cssClasses]}
32
+ href={href}
33
+ target={item.target || undefined}
34
+ rel={linkRel(item)}
35
+ aria-current={isCurrent(item.url, current) ? 'page' : undefined}
36
+ >
37
+ {item.label}
38
+ </a>
39
+ ) : (
40
+ // A heading-only item (no or an unsafe URL) is a label, not a dead link.
41
+ <span class="navlist__label">{item.label}</span>
42
+ )}
43
+ {kids.length > 0 && <Astro.self items={kids} current={current} top={false} />}
44
+ </li>
45
+ );
46
+ })
47
+ }
48
+ </ul>
@@ -0,0 +1,66 @@
1
+ ---
2
+ /**
3
+ * Plan cards on the framework's `.pricing-grid` / `.pricing-card` pattern (patterns/
4
+ * subgrid.css): each card is a subgrid over four shared rows — header, price, features,
5
+ * button — so features and buttons line up across plans whatever each holds.
6
+ *
7
+ * `--pricing-card-rows` must name those four rows or the cards do not span them; it is set
8
+ * here rather than left to the pattern's `auto`, which is not a valid `span`.
9
+ */
10
+ import { headingTag, lines, resolveBlockValue, rows, safeHref, text } from './value.ts';
11
+
12
+ interface Value {
13
+ plans?: unknown;
14
+ level?: string | number;
15
+ }
16
+
17
+ const value = resolveBlockValue<Value>(Astro.props);
18
+ const plans = rows<Record<string, unknown>>(value.plans)
19
+ .map((row) => ({
20
+ name: text(row.name),
21
+ price: text(row.price),
22
+ period: text(row.period),
23
+ features: lines(row.features),
24
+ ctaLabel: text(row.ctaLabel),
25
+ ctaHref: safeHref(row.ctaHref),
26
+ featured: row.featured === true,
27
+ }))
28
+ .filter((plan) => plan.name || plan.price);
29
+ const Heading = headingTag(value.level, 3);
30
+ const columns = Math.min(4, Math.max(1, plans.length));
31
+ import { rootAttrs } from './attrs.ts';
32
+ ---
33
+
34
+ {
35
+ plans.length > 0 && (
36
+ <ul
37
+ {...rootAttrs(value, ['pricing-grid'], `--pricing-columns: ${columns}; --pricing-card-rows: 4`)}
38
+ role="list"
39
+ >
40
+ {plans.map((plan) => (
41
+ <li class:list={['pricing-card', plan.featured && 'pricing-card--featured']}>
42
+ <div class="pricing-card__header">
43
+ {plan.name && <Heading class="font-title">{plan.name}</Heading>}
44
+ </div>
45
+ <p class="pricing-card__price">
46
+ {plan.price}
47
+ {plan.period && <small class="font-caption"> {plan.period}</small>}
48
+ </p>
49
+ <ul class="pricing-card__features" role="list">
50
+ {plan.features.map((feature) => (
51
+ <li class="pricing-card__feature">{feature}</li>
52
+ ))}
53
+ </ul>
54
+ <div class="pricing-card__cta">
55
+ {plan.ctaLabel && plan.ctaHref && (
56
+ <a class:list={[plan.featured ? 'cta' : 'btn']} href={plan.ctaHref}>
57
+ {plan.ctaLabel}
58
+ <span class="sr-only"> — {plan.name}</span>
59
+ </a>
60
+ )}
61
+ </div>
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ )
66
+ }