@contentrain/astro-kit 0.31.0 → 0.33.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 (102) hide show
  1. package/catalog.json +4600 -1584
  2. package/components/_shared/Button.astro +2 -1
  3. package/components/_shared/KitImage.astro +9 -4
  4. package/components/_shared/Section.astro +3 -1
  5. package/components/_shared/SectionHeader.astro +27 -0
  6. package/components/_shared/measured.ts +23 -0
  7. package/components/_shared/safe-url.ts +67 -0
  8. package/components/_shared/social.ts +73 -0
  9. package/components/announcement/Announcement.astro +2 -1
  10. package/components/announcement/meta.json +1 -1
  11. package/components/band/Band.astro +3 -2
  12. package/components/band/meta.json +1 -0
  13. package/components/breadcrumb/Breadcrumb.astro +2 -1
  14. package/components/breadcrumb/meta.json +1 -1
  15. package/components/card-grid/CardGrid.astro +2 -1
  16. package/components/card-grid/meta.json +1 -0
  17. package/components/consent/Consent.astro +2 -1
  18. package/components/consent/meta.json +1 -1
  19. package/components/contact-details/ContactDetails.astro +3 -2
  20. package/components/contact-details/meta.json +1 -0
  21. package/components/contact-form/ContactForm.astro +51 -14
  22. package/components/contact-form/fixtures.json +169 -9
  23. package/components/contact-form/meta.json +284 -15
  24. package/components/cta/Cta.astro +52 -12
  25. package/components/cta/fixtures.json +5 -1
  26. package/components/cta/meta.json +11 -2
  27. package/components/dialog/Dialog.astro +113 -0
  28. package/components/dialog/fixtures.json +84 -0
  29. package/components/dialog/meta.json +252 -0
  30. package/components/embed/meta.json +1 -1
  31. package/components/faq/Faq.astro +60 -17
  32. package/components/faq/fixtures.json +79 -0
  33. package/components/faq/meta.json +9 -3
  34. package/components/feature-list/FeatureList.astro +28 -4
  35. package/components/feature-list/meta.json +16 -3
  36. package/components/footer/Footer.astro +5 -4
  37. package/components/footer/meta.json +1 -0
  38. package/components/gallery/Gallery.astro +49 -14
  39. package/components/gallery/fixtures.json +275 -0
  40. package/components/gallery/meta.json +267 -14
  41. package/components/header/Header.astro +17 -5
  42. package/components/header/fixtures.json +90 -1
  43. package/components/header/meta.json +5 -3
  44. package/components/hero/meta.json +1 -0
  45. package/components/language-switcher/LanguageSwitcher.astro +68 -0
  46. package/components/language-switcher/fixtures.json +51 -0
  47. package/components/language-switcher/meta.json +73 -0
  48. package/components/logo-cloud/LogoCloud.astro +86 -21
  49. package/components/logo-cloud/fixtures.json +239 -0
  50. package/components/logo-cloud/meta.json +145 -5
  51. package/components/nav/Nav.astro +77 -24
  52. package/components/nav/NavTree.astro +40 -0
  53. package/components/nav/fixtures.json +165 -0
  54. package/components/nav/meta.json +11 -2
  55. package/components/newsletter/Newsletter.astro +2 -1
  56. package/components/newsletter/meta.json +1 -0
  57. package/components/pagination/Pagination.astro +4 -3
  58. package/components/pagination/meta.json +1 -1
  59. package/components/post-card/PostCard.astro +8 -5
  60. package/components/post-card/meta.json +3 -2
  61. package/components/post-list/PostList.astro +115 -0
  62. package/components/post-list/fixtures.json +387 -0
  63. package/components/post-list/meta.json +309 -0
  64. package/components/pricing/Pricing.astro +128 -24
  65. package/components/pricing/fixtures.json +288 -4
  66. package/components/pricing/meta.json +188 -1
  67. package/components/search-box/SearchBox.astro +51 -0
  68. package/components/search-box/fixtures.json +31 -0
  69. package/components/search-box/meta.json +75 -0
  70. package/components/slider/Slider.astro +19 -115
  71. package/components/slider/SliderTrack.astro +178 -0
  72. package/components/slider/fixtures.json +2 -1
  73. package/components/slider/meta.json +11 -5
  74. package/components/social-links/SocialLinks.astro +74 -0
  75. package/components/social-links/fixtures.json +67 -0
  76. package/components/social-links/meta.json +94 -0
  77. package/components/split/Split.astro +76 -14
  78. package/components/split/fixtures.json +8 -1
  79. package/components/split/meta.json +226 -4
  80. package/components/stats/Stats.astro +54 -11
  81. package/components/stats/fixtures.json +260 -0
  82. package/components/stats/meta.json +151 -4
  83. package/components/steps/Steps.astro +36 -8
  84. package/components/steps/fixtures.json +217 -5
  85. package/components/steps/meta.json +157 -3
  86. package/components/tabs/Tabs.astro +10 -3
  87. package/components/tabs/fixtures.json +4 -0
  88. package/components/tabs/meta.json +5 -2
  89. package/components/team/Team.astro +54 -9
  90. package/components/team/fixtures.json +217 -4
  91. package/components/team/meta.json +169 -3
  92. package/components/team/team.ts +4 -0
  93. package/components/testimonial/Testimonial.astro +71 -21
  94. package/components/testimonial/fixtures.json +73 -0
  95. package/components/testimonial/meta.json +168 -6
  96. package/dist/index.mjs +1 -1
  97. package/dist/index.mjs.map +1 -1
  98. package/mapping/classic.json +124 -3
  99. package/mapping/divi.json +26 -8
  100. package/mapping/elementor.json +133 -14
  101. package/mapping/gutenberg.json +38 -10
  102. package/package.json +2 -2
@@ -135,5 +135,244 @@
135
135
  "tone": "accent",
136
136
  "style": "grayscale"
137
137
  }
138
+ },
139
+ {
140
+ "name": "row-start-large",
141
+ "props": {
142
+ "heading": "Trusted by",
143
+ "items": [
144
+ {
145
+ "image": "/kit-fixtures/logo.svg",
146
+ "imageAlt": "Company 1",
147
+ "href": "https://example.com"
148
+ },
149
+ {
150
+ "image": "/kit-fixtures/logo.svg",
151
+ "imageAlt": "Company 2"
152
+ },
153
+ {
154
+ "image": "/kit-fixtures/logo.svg",
155
+ "imageAlt": "Company 3",
156
+ "href": "https://example.com"
157
+ },
158
+ {
159
+ "image": "/kit-fixtures/logo.svg",
160
+ "imageAlt": "Company 4"
161
+ },
162
+ {
163
+ "image": "/kit-fixtures/logo.svg",
164
+ "imageAlt": "Company 5",
165
+ "href": "https://example.com"
166
+ },
167
+ {
168
+ "image": "/kit-fixtures/logo.svg",
169
+ "imageAlt": "Company 6"
170
+ }
171
+ ],
172
+ "layout": "row",
173
+ "align": "start",
174
+ "logoHeight": "lg"
175
+ }
176
+ },
177
+ {
178
+ "name": "row-small-mono-inverse",
179
+ "props": {
180
+ "heading": "Trusted by",
181
+ "items": [
182
+ {
183
+ "image": "/kit-fixtures/logo.svg",
184
+ "imageAlt": "Company 1",
185
+ "href": "https://example.com"
186
+ },
187
+ {
188
+ "image": "/kit-fixtures/logo.svg",
189
+ "imageAlt": "Company 2"
190
+ },
191
+ {
192
+ "image": "/kit-fixtures/logo.svg",
193
+ "imageAlt": "Company 3",
194
+ "href": "https://example.com"
195
+ },
196
+ {
197
+ "image": "/kit-fixtures/logo.svg",
198
+ "imageAlt": "Company 4"
199
+ },
200
+ {
201
+ "image": "/kit-fixtures/logo.svg",
202
+ "imageAlt": "Company 5",
203
+ "href": "https://example.com"
204
+ },
205
+ {
206
+ "image": "/kit-fixtures/logo.svg",
207
+ "imageAlt": "Company 6"
208
+ }
209
+ ],
210
+ "layout": "row",
211
+ "logoHeight": "sm",
212
+ "style": "mono-light",
213
+ "tone": "inverse"
214
+ }
215
+ },
216
+ {
217
+ "name": "marquee",
218
+ "props": {
219
+ "heading": "Trusted by",
220
+ "items": [
221
+ {
222
+ "image": "/kit-fixtures/logo.svg",
223
+ "imageAlt": "Company 1",
224
+ "href": "https://example.com"
225
+ },
226
+ {
227
+ "image": "/kit-fixtures/logo.svg",
228
+ "imageAlt": "Company 2"
229
+ },
230
+ {
231
+ "image": "/kit-fixtures/logo.svg",
232
+ "imageAlt": "Company 3",
233
+ "href": "https://example.com"
234
+ },
235
+ {
236
+ "image": "/kit-fixtures/logo.svg",
237
+ "imageAlt": "Company 4"
238
+ },
239
+ {
240
+ "image": "/kit-fixtures/logo.svg",
241
+ "imageAlt": "Company 5",
242
+ "href": "https://example.com"
243
+ },
244
+ {
245
+ "image": "/kit-fixtures/logo.svg",
246
+ "imageAlt": "Company 6"
247
+ }
248
+ ],
249
+ "layout": "marquee",
250
+ "tone": "muted"
251
+ }
252
+ },
253
+ {
254
+ "name": "five-columns",
255
+ "props": {
256
+ "heading": "Trusted by",
257
+ "items": [
258
+ {
259
+ "image": "/kit-fixtures/logo.svg",
260
+ "imageAlt": "Company 1",
261
+ "href": "https://example.com"
262
+ },
263
+ {
264
+ "image": "/kit-fixtures/logo.svg",
265
+ "imageAlt": "Company 2"
266
+ },
267
+ {
268
+ "image": "/kit-fixtures/logo.svg",
269
+ "imageAlt": "Company 3",
270
+ "href": "https://example.com"
271
+ },
272
+ {
273
+ "image": "/kit-fixtures/logo.svg",
274
+ "imageAlt": "Company 4"
275
+ },
276
+ {
277
+ "image": "/kit-fixtures/logo.svg",
278
+ "imageAlt": "Company 5",
279
+ "href": "https://example.com"
280
+ },
281
+ {
282
+ "image": "/kit-fixtures/logo.svg",
283
+ "imageAlt": "Company 6"
284
+ }
285
+ ],
286
+ "columns": "5"
287
+ }
288
+ },
289
+ {
290
+ "name": "six-columns",
291
+ "props": {
292
+ "heading": "Trusted by",
293
+ "items": [
294
+ {
295
+ "image": "/kit-fixtures/logo.svg",
296
+ "imageAlt": "Company 1",
297
+ "href": "https://example.com"
298
+ },
299
+ {
300
+ "image": "/kit-fixtures/logo.svg",
301
+ "imageAlt": "Company 2"
302
+ },
303
+ {
304
+ "image": "/kit-fixtures/logo.svg",
305
+ "imageAlt": "Company 3",
306
+ "href": "https://example.com"
307
+ },
308
+ {
309
+ "image": "/kit-fixtures/logo.svg",
310
+ "imageAlt": "Company 4"
311
+ },
312
+ {
313
+ "image": "/kit-fixtures/logo.svg",
314
+ "imageAlt": "Company 5",
315
+ "href": "https://example.com"
316
+ },
317
+ {
318
+ "image": "/kit-fixtures/logo.svg",
319
+ "imageAlt": "Company 6"
320
+ }
321
+ ],
322
+ "columns": "6"
323
+ }
324
+ },
325
+ {
326
+ "name": "measured-columns",
327
+ "props": {
328
+ "heading": "Trusted by",
329
+ "items": [
330
+ {
331
+ "image": "/kit-fixtures/logo.svg",
332
+ "imageAlt": "Company 1",
333
+ "href": "https://example.com"
334
+ },
335
+ {
336
+ "image": "/kit-fixtures/logo.svg",
337
+ "imageAlt": "Company 2"
338
+ },
339
+ {
340
+ "image": "/kit-fixtures/logo.svg",
341
+ "imageAlt": "Company 3",
342
+ "href": "https://example.com"
343
+ },
344
+ {
345
+ "image": "/kit-fixtures/logo.svg",
346
+ "imageAlt": "Company 4"
347
+ },
348
+ {
349
+ "image": "/kit-fixtures/logo.svg",
350
+ "imageAlt": "Company 5",
351
+ "href": "https://example.com"
352
+ },
353
+ {
354
+ "image": "/kit-fixtures/logo.svg",
355
+ "imageAlt": "Company 6"
356
+ },
357
+ {
358
+ "image": "/kit-fixtures/logo.svg",
359
+ "imageAlt": "Company 7",
360
+ "href": "https://example.com"
361
+ },
362
+ {
363
+ "image": "/kit-fixtures/logo.svg",
364
+ "imageAlt": "Company 8"
365
+ }
366
+ ],
367
+ "measured": {
368
+ "columns": {
369
+ "390": 2,
370
+ "768": 3,
371
+ "1280": 6
372
+ },
373
+ "gap": 32,
374
+ "padY": 48
375
+ }
376
+ }
138
377
  }
139
378
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "logo-cloud",
3
3
  "name": "Logo cloud",
4
- "description": "A grid of client or partner logos, each optionally linked, under an optional heading (\"Trusted by\").",
4
+ "description": "A grid, a row or an endless strip of client or partner logos, each optionally linked, under an optional heading (\"Trusted by\").",
5
5
  "category": "section",
6
6
  "uses": [],
7
7
  "shared": [
@@ -9,25 +9,55 @@
9
9
  "Section.astro",
10
10
  "image.ts",
11
11
  "measured.ts",
12
+ "safe-url.ts",
12
13
  "types.ts"
13
14
  ],
14
15
  "variants": {
16
+ "layout": {
17
+ "options": [
18
+ "grid",
19
+ "row",
20
+ "marquee"
21
+ ],
22
+ "default": "grid",
23
+ "description": "grid: logos in columns. row: one line that wraps. marquee: an endless CSS-only strip that stands still for reduced motion."
24
+ },
15
25
  "columns": {
16
26
  "options": [
17
27
  "2",
18
28
  "3",
19
- "4"
29
+ "4",
30
+ "5",
31
+ "6"
20
32
  ],
21
33
  "default": "4",
22
- "description": "Logos per row on wide screens; two on phones, three on tablets."
34
+ "description": "Logos per row on wide screens (grid layout); two on phones, three on tablets."
35
+ },
36
+ "logoHeight": {
37
+ "options": [
38
+ "sm",
39
+ "md",
40
+ "lg"
41
+ ],
42
+ "default": "md",
43
+ "description": "Height of every logo (the width follows): small, medium or large."
23
44
  },
24
45
  "style": {
25
46
  "options": [
26
47
  "plain",
27
- "grayscale"
48
+ "grayscale",
49
+ "mono-light"
28
50
  ],
29
51
  "default": "plain",
30
- "description": "plain: logos in their own colours. grayscale: muted grey, full colour on hover or focus."
52
+ "description": "plain: logos in their own colours. grayscale: muted grey, full colour on hover or focus. mono-light: drawn white, for a dark band."
53
+ },
54
+ "align": {
55
+ "options": [
56
+ "center",
57
+ "start"
58
+ ],
59
+ "default": "center",
60
+ "description": "Alignment of the heading and of the logos in a row."
31
61
  },
32
62
  "tone": {
33
63
  "options": [
@@ -74,6 +104,116 @@
74
104
  },
75
105
  "description": "The logos, in order (a Gutenberg gallery or columns of logos, Elementor image carousel/gallery of logos, Divi images)."
76
106
  },
107
+ "measured": {
108
+ "type": "object",
109
+ "content": false,
110
+ "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.",
111
+ "fields": {
112
+ "containerPx": {
113
+ "type": "integer",
114
+ "min": 0,
115
+ "max": 2560,
116
+ "description": "The frame's content width."
117
+ },
118
+ "padY": {
119
+ "type": "integer",
120
+ "min": 0,
121
+ "max": 400,
122
+ "description": "Top and bottom padding."
123
+ },
124
+ "padX": {
125
+ "type": "integer",
126
+ "min": 0,
127
+ "max": 200,
128
+ "description": "The frame's side padding (gutter)."
129
+ },
130
+ "bg": {
131
+ "type": "object",
132
+ "description": "The section's background.",
133
+ "fields": {
134
+ "color": {
135
+ "type": "string",
136
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
137
+ },
138
+ "image": {
139
+ "type": "string",
140
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
141
+ },
142
+ "overlay": {
143
+ "type": "string",
144
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
145
+ },
146
+ "size": {
147
+ "type": "string",
148
+ "description": "cover, contain or auto; anything else draws as cover."
149
+ },
150
+ "position": {
151
+ "type": "string",
152
+ "description": "One or two of center/top/bottom/left/right or percentages."
153
+ }
154
+ }
155
+ },
156
+ "columns": {
157
+ "type": "object",
158
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
159
+ "fields": {
160
+ "390": {
161
+ "type": "integer",
162
+ "min": 1,
163
+ "max": 6,
164
+ "description": "Phone."
165
+ },
166
+ "768": {
167
+ "type": "integer",
168
+ "min": 1,
169
+ "max": 6,
170
+ "description": "Tablet."
171
+ },
172
+ "1280": {
173
+ "type": "integer",
174
+ "min": 1,
175
+ "max": 6,
176
+ "description": "Desktop."
177
+ }
178
+ }
179
+ },
180
+ "gap": {
181
+ "type": "integer",
182
+ "min": 0,
183
+ "max": 200,
184
+ "description": "Gap between items."
185
+ },
186
+ "tone": {
187
+ "type": "select",
188
+ "options": [
189
+ "light",
190
+ "dark"
191
+ ],
192
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
193
+ },
194
+ "align": {
195
+ "type": "select",
196
+ "options": [
197
+ "start",
198
+ "center",
199
+ "end"
200
+ ],
201
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
202
+ },
203
+ "radius": {
204
+ "type": "integer",
205
+ "min": 0,
206
+ "max": 200,
207
+ "description": "Card and item corner radius."
208
+ },
209
+ "minHeight": {
210
+ "type": "integer",
211
+ "min": 0,
212
+ "max": 2000,
213
+ "description": "The section's minimum height."
214
+ }
215
+ }
216
+ },
77
217
  "id": {
78
218
  "type": "string",
79
219
  "content": false,
@@ -1,21 +1,27 @@
1
1
  ---
2
2
  // Site navigation. Wide screens get an inline list whose submenus open on
3
3
  // hover and keyboard focus (a chevron button marks the ones that have one); narrow screens get a toggle that opens the menu as
4
- // a native popover (drawer) or a <details> dropdown. No JavaScript.
4
+ // a native popover (drawer) or a <details> dropdown. Submenus nest to any depth; `menu="mega"` draws them as a wide panel of
5
+ // columns. Everything works without JavaScript; a small script (only when a menu has submenus) is the disclosure pattern's
6
+ // last step: it keeps aria-expanded true to what is open and lets Escape close a panel and return to its button.
5
7
  import { tv } from 'tailwind-variants'
8
+ import NavTree from './NavTree.astro'
6
9
  import type { NavItem } from '../_shared/types'
10
+ import { safeHref } from '../_shared/safe-url'
7
11
 
8
12
  const nav = tv({
9
13
  slots: {
10
14
  list: 'hidden items-center md:flex',
11
15
  link: 'no-underline aria-[current=page]:font-semibold',
12
16
  // hidden, not invisible: a closed submenu takes no space, so one under the last item cannot widen the page.
13
- sub: 'absolute top-full z-30 hidden min-w-48 rounded-lg border border-line bg-surface p-2 text-ink shadow-lg group-hover:block group-focus-within:block',
17
+ sub: 'absolute top-full z-30 hidden min-w-48 rounded-lg border border-line bg-surface p-2 text-ink shadow-lg group-hover:block group-focus-within:block group-data-[closed]:hidden!',
14
18
  toggle: 'inline-flex cursor-pointer list-none items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-current/10 md:hidden [&::-webkit-details-marker]:hidden',
15
19
  },
16
20
  variants: {
17
21
  // Submenus open towards the page: leftwards under an end-aligned menu.
18
22
  align: { start: { list: 'justify-start', sub: 'start-0' }, end: { list: 'justify-end', sub: 'end-0' }, center: { list: 'justify-center', sub: 'start-0' } },
23
+ // flyout: a list under the item. mega: a wide panel, one column per child with the grandchildren listed under it.
24
+ menu: { flyout: {}, mega: { sub: 'w-max max-w-[min(64rem,90vw)] p-5' } },
19
25
  density: { compact: { list: 'gap-5 text-sm' }, comfortable: { list: 'gap-8 text-base' } },
20
26
  // current: the surrounding text, dimmed until hovered. link: the site's link colour at full strength, as themes that
21
27
  // colour every link draw their menu; the toggle keeps the surrounding text.
@@ -24,7 +30,7 @@ const nav = tv({
24
30
  link: { link: 'text-[color:var(--color-link,var(--color-accent))]' },
25
31
  },
26
32
  },
27
- defaultVariants: { align: 'end', density: 'compact', color: 'current' },
33
+ defaultVariants: { menu: 'flyout', align: 'end', density: 'compact', color: 'current' },
28
34
  })
29
35
 
30
36
  interface Props {
@@ -36,6 +42,7 @@ interface Props {
36
42
  /** Text on the drawer's close button ("Close"). */
37
43
  closeLabel?: string | undefined
38
44
  mobile?: 'popover' | 'details' | undefined
45
+ menu?: 'flyout' | 'mega' | undefined
39
46
  align?: 'start' | 'end' | 'center' | undefined
40
47
  density?: 'compact' | 'comfortable' | undefined
41
48
  color?: 'current' | 'link' | undefined
@@ -45,28 +52,40 @@ interface Props {
45
52
  part?: string | undefined
46
53
  }
47
54
 
48
- const { items, label = 'Main', menuLabel = 'Menu', closeLabel = 'Close', mobile = 'popover', align, density, color, id = 'site-nav', part } = Astro.props
49
- const { list, link, sub, toggle } = nav({ align, density, color })
55
+ const { items, label = 'Main', menuLabel = 'Menu', closeLabel = 'Close', mobile = 'popover', menu, align, density, color, id = 'site-nav', part } = Astro.props
56
+ const { list, link, sub, toggle } = nav({ menu, align, density, color })
50
57
  const current = Astro.url.pathname
51
58
  const drawer = `${id}-drawer`
59
+ const hasSub = items.some(item => (item.children?.length ?? 0) > 0)
60
+ // An empty href or "#" is a group label (a WordPress custom link), not a link.
61
+ const real = (href: string | undefined) => !!href && href !== '#'
52
62
  ---
53
63
  {items.length > 0 && (
54
64
  <nav aria-label={label} data-cr-part={part} class="relative">
55
65
  <ul class={list()}>
56
- {items.map(item => (
66
+ {items.map((item, index) => (
57
67
  <li class="group relative">
58
- <a href={item.href} aria-current={item.href === current ? 'page' : undefined} target={item.newTab ? '_blank' : undefined} rel={item.newTab ? 'noopener' : undefined} class={link()}>{item.label}</a>
68
+ {real(item.href)
69
+ ? <a href={safeHref(item.href)} aria-current={item.href === current ? 'page' : undefined} target={item.newTab ? '_blank' : undefined} rel={item.newTab ? 'noopener' : undefined} class={link()}>{item.label}</a>
70
+ : !(item.children && item.children.length > 0) && <span class={link()}>{item.label}</span>}
59
71
  {item.children && item.children.length > 0 && (
60
72
  <>
61
- {/* The chevron is the submenu's handle: focusing it (a tap on a touch screen, Tab on a keyboard) opens the submenu, with no script. */}
62
- <button type="button" aria-haspopup="true" aria-label={item.label} class="ms-1 inline-flex cursor-pointer items-center align-middle opacity-80 hover:opacity-100">
63
- <svg aria-hidden="true" viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
64
- </button>
65
- <ul class={sub()}>
66
- {item.children.map(child => (
67
- <li><a href={child.href} aria-current={child.href === current ? 'page' : undefined} class="block rounded-md px-3 py-2 text-sm text-ink no-underline hover:bg-surface-muted">{child.label}</a></li>
68
- ))}
69
- </ul>
73
+ {/* The button is the submenu's handle: focusing it (a tap on a touch screen, Tab on a keyboard) opens the submenu, with no script. With no real link of its own the item is the button, label and all. */}
74
+ {real(item.href)
75
+ ? (
76
+ <button type="button" data-kit-sub aria-controls={`${id}-sub-${index}`} aria-label={item.label} class="ms-1 inline-flex cursor-pointer items-center align-middle opacity-80 hover:opacity-100">
77
+ <svg aria-hidden="true" viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
78
+ </button>
79
+ )
80
+ : (
81
+ <button type="button" data-kit-sub aria-controls={`${id}-sub-${index}`} class={`${link()} inline-flex cursor-pointer items-center gap-1`}>
82
+ {item.label}
83
+ <svg aria-hidden="true" viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
84
+ </button>
85
+ )}
86
+ <div id={`${id}-sub-${index}`} class={sub()}>
87
+ <NavTree items={item.children} current={current} kind={menu === 'mega' ? 'mega' : 'flyout'} />
88
+ </div>
70
89
  </>
71
90
  )}
72
91
  </li>
@@ -88,12 +107,10 @@ const drawer = `${id}-drawer`
88
107
  <ul class="flex flex-col gap-1">
89
108
  {items.map(item => (
90
109
  <li>
91
- <a href={item.href} aria-current={item.href === current ? 'page' : undefined} class="block rounded-md px-3 py-2 text-ink no-underline hover:bg-surface-muted aria-[current=page]:font-semibold">{item.label}</a>
92
- {item.children && item.children.length > 0 && (
93
- <ul class="ms-3 border-s border-line ps-3">
94
- {item.children.map(child => <li><a href={child.href} class="block rounded-md px-3 py-1.5 text-sm text-ink-muted no-underline hover:text-ink">{child.label}</a></li>)}
95
- </ul>
96
- )}
110
+ {real(item.href)
111
+ ? <a href={safeHref(item.href)} aria-current={item.href === current ? 'page' : undefined} class="block rounded-md px-3 py-2 text-ink no-underline hover:bg-surface-muted aria-[current=page]:font-semibold">{item.label}</a>
112
+ : <span class="block px-3 py-2 font-semibold text-ink">{item.label}</span>}
113
+ {item.children && item.children.length > 0 && <NavTree items={item.children} current={current} kind="drawer" />}
97
114
  </li>
98
115
  ))}
99
116
  </ul>
@@ -107,11 +124,47 @@ const drawer = `${id}-drawer`
107
124
  {menuLabel}
108
125
  </summary>
109
126
  <ul class="absolute end-0 z-30 mt-2 flex w-64 flex-col gap-1 rounded-lg border border-line bg-surface p-3 text-ink shadow-lg">
110
- {items.flatMap(item => [item, ...(item.children ?? [])]).map(item => (
111
- <li><a href={item.href} aria-current={item.href === current ? 'page' : undefined} class="block rounded-md px-3 py-2 text-sm text-ink no-underline hover:bg-surface-muted">{item.label}</a></li>
127
+ {items.map(item => (
128
+ <li>
129
+ {real(item.href)
130
+ ? <a href={safeHref(item.href)} aria-current={item.href === current ? 'page' : undefined} class="block rounded-md px-3 py-2 text-sm text-ink no-underline hover:bg-surface-muted">{item.label}</a>
131
+ : <span class="block px-3 py-2 text-sm font-semibold text-ink">{item.label}</span>}
132
+ {item.children && item.children.length > 0 && <NavTree items={item.children} current={current} kind="drawer" />}
133
+ </li>
112
134
  ))}
113
135
  </ul>
114
136
  </details>
115
137
  )}
116
138
  </nav>
117
139
  )}
140
+ {hasSub && (
141
+ <script>
142
+ // Disclosure navigation, last step: aria-expanded follows what is open (hover, focus or a click), Escape closes the
143
+ // open panel and returns to its button. Without this script the panels still open on hover and focus.
144
+ for (const nav of document.querySelectorAll<HTMLElement>('nav')) {
145
+ for (const button of nav.querySelectorAll<HTMLButtonElement>('button[data-kit-sub]')) {
146
+ const item = button.closest<HTMLElement>('li')
147
+ const panel = item?.querySelector<HTMLElement>(`#${CSS.escape(button.getAttribute('aria-controls') ?? '')}`)
148
+ if (!item || !panel) continue
149
+ const sync = () => button.setAttribute('aria-expanded', String(!item.hasAttribute('data-closed') && getComputedStyle(panel).display !== 'none'))
150
+ sync()
151
+ item.addEventListener('mouseenter', () => { item.removeAttribute('data-closed'); queueMicrotask(sync) })
152
+ item.addEventListener('mouseleave', () => queueMicrotask(sync))
153
+ item.addEventListener('focusin', () => { item.removeAttribute('data-closed'); queueMicrotask(sync) })
154
+ item.addEventListener('focusout', () => queueMicrotask(sync))
155
+ item.addEventListener('keydown', (event) => {
156
+ if (event.key !== 'Escape' || item.hasAttribute('data-closed') || getComputedStyle(panel).display === 'none') return
157
+ event.preventDefault()
158
+ item.setAttribute('data-closed', '')
159
+ if (panel.contains(document.activeElement)) button.focus()
160
+ sync()
161
+ })
162
+ button.addEventListener('click', () => {
163
+ if (item.hasAttribute('data-closed')) item.removeAttribute('data-closed')
164
+ else if (getComputedStyle(panel).display !== 'none') item.setAttribute('data-closed', '')
165
+ sync()
166
+ })
167
+ }
168
+ }
169
+ </script>
170
+ )}
@@ -0,0 +1,40 @@
1
+ ---
2
+ // The submenu levels of Nav, rendered recursively so a menu of any depth keeps every level. An entry with no real link
3
+ // (an empty href or "#": a WordPress "custom link" used as a group heading) is a label, not `<a href="#">`.
4
+ import NavTree from './NavTree.astro'
5
+ import type { NavItem } from '../_shared/types'
6
+ import { safeHref } from '../_shared/safe-url'
7
+
8
+ interface Props {
9
+ items: NavItem[]
10
+ /** The current page's path, for aria-current. */
11
+ current?: string | undefined
12
+ /** flyout: a list under the item. drawer: the narrow-screen menu. mega: the columns of a wide panel. */
13
+ kind: 'flyout' | 'drawer' | 'mega'
14
+ /** 2 for the first level under a top item, 3 for the next. */
15
+ level?: number | undefined
16
+ }
17
+
18
+ const { items, current, kind, level = 2 } = Astro.props
19
+ const real = (href: string | undefined) => !!href && href !== '#'
20
+ // Column headings in a mega panel; deeper rows (and every drawer row below the top) are quieter.
21
+ const heading = kind === 'mega' && level === 2
22
+ const row = heading
23
+ ? 'block px-3 py-2 text-sm font-semibold text-ink'
24
+ : level === 2 && kind === 'flyout'
25
+ ? 'block px-3 py-2 text-sm text-ink'
26
+ : 'block px-3 py-1.5 text-sm text-ink-muted'
27
+ const hover = 'rounded-md no-underline hover:bg-surface-muted hover:text-ink'
28
+ // Nested lists are indented, except the first list of a flyout or mega panel, which fills the panel.
29
+ const nested = kind === 'drawer' || level > 2 ? 'ms-3 border-s border-line ps-3' : ''
30
+ ---
31
+ <ul class={heading ? 'grid grid-flow-col auto-cols-fr gap-x-8 gap-y-4' : nested}>
32
+ {items.map(item => (
33
+ <li>
34
+ {real(item.href)
35
+ ? <a href={safeHref(item.href)} aria-current={item.href === current ? 'page' : undefined} target={item.newTab ? '_blank' : undefined} rel={item.newTab ? 'noopener' : undefined} class={`${row} ${hover}`}>{item.label}</a>
36
+ : <span class={`${row} font-semibold`}>{item.label}</span>}
37
+ {item.children && item.children.length > 0 && <NavTree items={item.children} current={current} kind={kind === 'mega' ? 'flyout' : kind} level={level + 1} />}
38
+ </li>
39
+ ))}
40
+ </ul>