@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
@@ -5,6 +5,8 @@
5
5
  // and AI answers.
6
6
  import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
+ import { measuredColumns } from '../_shared/measured'
9
+ import type { MeasuredStyle } from '../_shared/types'
8
10
 
9
11
  export interface FaqItem {
10
12
  question: string
@@ -14,14 +16,28 @@ export interface FaqItem {
14
16
 
15
17
  const faq = tv({
16
18
  slots: {
17
- heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
19
+ root: '',
20
+ head: 'mb-8 flex flex-col gap-3',
21
+ heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
22
+ intro: 'max-w-2xl text-lg opacity-80',
18
23
  list: 'flex flex-col',
19
24
  item: 'group',
25
+ question: '',
20
26
  summary: 'flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg [font-weight:var(--font-weight-heading,600)] [&::-webkit-details-marker]:hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
21
- marker: 'size-5 shrink-0 transition-transform duration-200 group-open:rotate-45',
27
+ marker: 'size-5 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
22
28
  answer: 'prose-site prose max-w-none pb-5 text-current',
23
29
  },
24
30
  variants: {
31
+ // single: heading above, one column. split: the heading and intro stay in a left column (sticky) beside the list.
32
+ layout: {
33
+ single: {},
34
+ split: { root: 'grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-16', head: 'mb-0 lg:sticky lg:top-8 lg:self-start' },
35
+ },
36
+ // Two balanced columns of questions on wide screens.
37
+ columns: { '1': {}, '2': { list: 'lg:block lg:columns-2 lg:gap-x-12', item: 'break-inside-avoid' } },
38
+ // The marker in each question's row: a plus that turns into a cross, or a chevron that turns over.
39
+ icon: { plus: {}, chevron: { marker: 'group-open:rotate-180' } },
40
+ numbered: { true: { list: '[counter-reset:faq]', item: '[counter-increment:faq]', question: 'before:me-4 before:tabular-nums before:opacity-60 before:content-[counter(faq,decimal-leading-zero)]' }, false: {} },
25
41
  style: {
26
42
  lines: { list: 'divide-y divide-line border-y border-line' },
27
43
  cards: { list: 'gap-3', item: 'rounded-card border border-line px-5', summary: 'py-5' },
@@ -36,13 +52,23 @@ const faq = tv({
36
52
  },
37
53
  compoundVariants: [
38
54
  { style: 'cards', tone: 'muted', class: { item: 'border-transparent' } },
55
+ { icon: 'plus', class: { marker: 'group-open:rotate-45' } },
56
+ // A rule between items does not cross columns: each item carries its own, and the list's are dropped.
57
+ { columns: '2', style: 'lines', class: { list: 'lg:divide-y-0 lg:border-y-0', item: 'lg:border-b lg:border-line' } },
58
+ { columns: '2', style: 'cards', class: { item: 'lg:mb-3' } },
39
59
  ],
40
- defaultVariants: { style: 'lines', tone: 'default', questionStyle: 'heading' },
60
+ defaultVariants: { layout: 'single', columns: '1', icon: 'plus', numbered: false, style: 'lines', tone: 'default', questionStyle: 'heading' },
41
61
  })
42
62
 
43
63
  interface Props {
44
64
  items: FaqItem[]
45
65
  heading?: string | undefined
66
+ intro?: string | undefined
67
+ layout?: 'single' | 'split' | undefined
68
+ columns?: '1' | '2' | undefined
69
+ icon?: 'plus' | 'chevron' | undefined
70
+ /** Count the questions with a leading number (drawn by CSS, not part of the text). */
71
+ numbered?: boolean | undefined
46
72
  style?: 'lines' | 'cards' | 'plain' | undefined
47
73
  /** How the questions read; `plain` sets its own and ignores this. */
48
74
  questionStyle?: 'heading' | 'bold' | undefined
@@ -53,11 +79,19 @@ interface Props {
53
79
  /** Add FAQPage structured data (JSON-LD). Once per page. */
54
80
  schema?: boolean | undefined
55
81
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
82
+ /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
83
+ measured?: MeasuredStyle | undefined
56
84
  id?: string | undefined
57
85
  }
58
86
 
59
- const { items, heading, style, questionStyle, exclusive = false, openFirst = false, schema = false, tone, id = 'faq' } = Astro.props
60
- const s = faq({ style, questionStyle: style === 'plain' ? 'heading' : questionStyle, tone })
87
+ const { items, heading, intro, layout, columns, icon, numbered = false, style, questionStyle, exclusive = false, openFirst = false, schema = false, tone, measured, id = 'faq' } = Astro.props
88
+ const s = faq({ layout, columns, icon, numbered, style, questionStyle: style === 'plain' ? 'heading' : questionStyle, tone })
89
+ // Two columns and the split layout need the page's width; one column reads in the narrow one.
90
+ const measuredCols = measuredColumns(measured)
91
+ const wide = layout === 'split' || columns === '2' || measuredCols !== undefined
92
+ // Measured columns win over `columns`. A rule between items does not cross columns, so each item carries its own.
93
+ const listClass = measuredCols && (style === 'cards' || style === 'plain' ? measuredCols : `${measuredCols} divide-y-0 border-b-0`)
94
+ const itemClass = measuredCols ? (style === 'cards' ? 'break-inside-avoid mb-3' : style === 'plain' ? 'break-inside-avoid' : 'break-inside-avoid border-b border-line') : undefined
61
95
  const headingId = heading ? `${id}-title` : undefined
62
96
  const plain = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
63
97
  const structured = schema && items.length > 0
@@ -69,18 +103,27 @@ const structured = schema && items.length > 0
69
103
  : null
70
104
  ---
71
105
  {items.length > 0 && (
72
- <Section tone={tone} spacing="md" width="prose" id={id} aria-labelledby={headingId}>
73
- {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
74
- <div class={s.list()}>
75
- {items.map((item, i) => (
76
- <details class={s.item()} name={exclusive ? id : undefined} open={openFirst && i === 0}>
77
- <summary class={s.summary()}>
78
- <span>{item.question}</span>
79
- <svg class={s.marker()} viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="M10 4v12M4 10h12" /></svg>
80
- </summary>
81
- <div class={s.answer()} set:html={item.answer} />
82
- </details>
83
- ))}
106
+ <Section measured={measured} tone={tone} spacing="md" width={wide ? 'page' : 'prose'} id={id} aria-labelledby={headingId}>
107
+ <div class={s.root()}>
108
+ {(heading || intro) && (
109
+ <header class={s.head()}>
110
+ {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
111
+ {intro && <p class={s.intro()} data-kit-text>{intro}</p>}
112
+ </header>
113
+ )}
114
+ <div class={s.list({ class: listClass })}>
115
+ {items.map((item, i) => (
116
+ <details class={s.item({ class: itemClass })} name={exclusive ? id : undefined} open={openFirst && i === 0}>
117
+ <summary class={s.summary()}>
118
+ <span class={s.question()}>{item.question}</span>
119
+ {icon === 'chevron'
120
+ ? <svg class={s.marker()} viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 8 5 5 5-5" /></svg>
121
+ : <svg class={s.marker()} viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="M10 4v12M4 10h12" /></svg>}
122
+ </summary>
123
+ <div class={s.answer()} set:html={item.answer} />
124
+ </details>
125
+ ))}
126
+ </div>
84
127
  </div>
85
128
  {structured && <script type="application/ld+json" set:html={structured} />}
86
129
  </Section>
@@ -40,5 +40,84 @@
40
40
  {
41
41
  "name": "lines-bold",
42
42
  "props": { "heading": "Questions", "questionStyle": "bold", "id": "faq-bold", "items": [{ "question": "Do you ship printed guides abroad?", "answer": "<p>Yes, to every country in the EU and the UK.</p>" }, { "question": "Can I change my route later?", "answer": "<p>Any time before the trip starts.</p>" }] }
43
+ },
44
+ {
45
+ "name": "split-chevron",
46
+ "props": {
47
+ "layout": "split",
48
+ "icon": "chevron",
49
+ "heading": "Questions",
50
+ "intro": "Everything you need before you start.",
51
+ "id": "faq-split",
52
+ "items": [
53
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
54
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
55
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" }
56
+ ]
57
+ }
58
+ },
59
+ {
60
+ "name": "two-columns-numbered",
61
+ "props": {
62
+ "columns": "2",
63
+ "numbered": true,
64
+ "icon": "chevron",
65
+ "id": "faq-cols",
66
+ "items": [
67
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
68
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
69
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" },
70
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
71
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
72
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" }
73
+ ],
74
+ "heading": "Common questions"
75
+ }
76
+ },
77
+ {
78
+ "name": "split-two-cards-plus",
79
+ "props": {
80
+ "layout": "split",
81
+ "columns": "2",
82
+ "style": "cards",
83
+ "icon": "plus",
84
+ "id": "faq-split-cols",
85
+ "heading": "Help",
86
+ "intro": "Short answers.",
87
+ "items": [
88
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
89
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
90
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" },
91
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
92
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
93
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" }
94
+ ]
95
+ }
96
+ },
97
+ {
98
+ "name": "measured-frame",
99
+ "props": {
100
+ "heading": "Frequently asked questions",
101
+ "exclusive": true,
102
+ "openFirst": true,
103
+ "schema": true,
104
+ "items": [
105
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
106
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
107
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" }
108
+ ],
109
+ "measured": {
110
+ "containerPx": 720,
111
+ "padY": 64,
112
+ "bg": {
113
+ "color": "#111827"
114
+ },
115
+ "tone": "dark"
116
+ }
117
+ }
118
+ },
119
+ {
120
+ "name": "six-measured-columns",
121
+ "props": { "heading": "Six questions", "id": "six", "style": "lines", "items": [{ "question": "Question 1?", "answer": "<p>Answer 1, in a sentence or two.</p>" }, { "question": "Question 2?", "answer": "<p>Answer 2, in a sentence or two.</p>" }, { "question": "Question 3?", "answer": "<p>Answer 3, in a sentence or two.</p>" }, { "question": "Question 4?", "answer": "<p>Answer 4, in a sentence or two.</p>" }, { "question": "Question 5?", "answer": "<p>Answer 5, in a sentence or two.</p>" }, { "question": "Question 6?", "answer": "<p>Answer 6, in a sentence or two.</p>" }], "measured": { "columns": { "390": 1, "768": 2, "1280": 3 }, "gap": 32, "padY": 48 } }
43
122
  }
44
123
  ]
@@ -4,8 +4,11 @@
4
4
  "description": "Questions and answers as native <details> disclosures (no script), in lines or cards; optionally one open at a time, and optionally with FAQPage structured data for search engines and AI answers.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Section.astro", "measured.ts", "types.ts"],
7
+ "shared": ["Section.astro", "measured.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
+ "layout": { "options": ["single", "split"], "default": "single", "description": "single: the heading above one column. split: the heading and introduction stay in a left column beside the list; stacks on a phone." },
10
+ "columns": { "options": ["1", "2"], "default": "1", "description": "Columns of questions on wide screens." },
11
+ "icon": { "options": ["plus", "chevron"], "default": "plus", "description": "The marker in each question's row: a plus that turns into a cross, or a chevron that turns over." },
9
12
  "style": { "options": ["lines", "cards", "plain"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box. plain: the browser's own disclosure triangle without rules or boxes, questions at the body size (WordPress's details block)." },
10
13
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." },
11
14
  "questionStyle": { "options": ["heading", "bold"], "default": "heading", "description": "How the questions read. heading: a small heading (lg, heading weight). bold: body size in bold, as accordion and toggle titles often are. The plain style sets its own and ignores this." }
@@ -13,10 +16,13 @@
13
16
  "props": {
14
17
  "items": { "type": "array", "label": "Questions", "required": true, "items": { "type": "object", "fields": { "question": { "type": "string", "label": "Question", "required": true, "description": "The question, as the visitor would ask it." }, "answer": { "type": "richtext", "label": "Answer", "required": true, "description": "The answer; links and lists allowed." } } }, "description": "The questions in order (core/details summary + content, Elementor accordion/toggle tabs, Divi accordion/toggle items)." },
15
18
  "heading": { "type": "string", "label": "Heading", "description": "Section heading." },
19
+ "intro": { "type": "text", "label": "Introduction", "description": "A sentence under the heading." },
16
20
  "exclusive": { "type": "boolean", "default": false, "content": false, "description": "One answer open at a time (Elementor accordion, Divi accordion). Off for toggles." },
17
21
  "openFirst": { "type": "boolean", "default": false, "content": false, "description": "The first answer starts open (Divi open_toggle, Elementor active item 1)." },
18
22
  "schema": { "type": "boolean", "default": false, "content": false, "description": "Emit FAQPage JSON-LD built from these items. Use on at most one FAQ per page, and only when the page is really a FAQ." },
19
- "id": { "type": "string", "content": false, "description": "Section id; also the exclusive group's name." }
23
+ "measured": { "type": "object", "content": false, "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.", "fields": { "containerPx": { "type": "integer", "min": 0, "max": 2560, "description": "The frame's content width." }, "padY": { "type": "integer", "min": 0, "max": 400, "description": "Top and bottom padding." }, "padX": { "type": "integer", "min": 0, "max": 200, "description": "The frame's side padding (gutter)." }, "bg": { "type": "object", "description": "The section's background.", "fields": { "color": { "type": "string", "description": "Fill colour: hex, rgb(a) or hsl(a)." }, "image": { "type": "string", "description": "Background image address (http(s) or root-relative), drawn behind the content." }, "overlay": { "type": "string", "description": "Colour or linear/radial gradient laid over the image or the fill." }, "size": { "type": "string", "description": "cover, contain or auto; anything else draws as cover." }, "position": { "type": "string", "description": "One or two of center/top/bottom/left/right or percentages." } } }, "columns": { "type": "object", "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.", "fields": { "390": { "type": "integer", "min": 1, "max": 6, "description": "Phone." }, "768": { "type": "integer", "min": 1, "max": 6, "description": "Tablet." }, "1280": { "type": "integer", "min": 1, "max": 6, "description": "Desktop." } } }, "gap": { "type": "integer", "min": 0, "max": 200, "description": "Gap between items." }, "tone": { "type": "select", "options": ["light", "dark"], "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides." }, "align": { "type": "select", "options": ["start", "center", "end"], "description": "Where the heading and lead sit; read by a component whose own align is not set." }, "radius": { "type": "integer", "min": 0, "max": 200, "description": "Card and item corner radius." }, "minHeight": { "type": "integer", "min": 0, "max": 2000, "description": "The section's minimum height." } } },
24
+ "id": { "type": "string", "content": false, "description": "Section id; also the exclusive group's name." },
25
+ "numbered": { "type": "boolean", "default": false, "content": false, "description": "Count the questions with a leading number, drawn by the page's style rather than written into the text." }
20
26
  },
21
27
  "slots": {},
22
28
  "js": "none",
@@ -25,7 +31,7 @@
25
31
  "gutenberg": ["core/details"],
26
32
  "elementor": ["elementor/accordion", "elementor/toggle", "elementor/nested-accordion"],
27
33
  "divi": ["divi/et_pb_accordion", "divi/et_pb_toggle"],
28
- "classic": ["classic/faq"]
34
+ "classic": ["classic/faq", "classic/details"]
29
35
  },
30
36
  "a11y": "Native <details>/<summary>: keyboard operable and announced as expandable by every screen reader, with no script to fail. The plus icon is decorative. The JSON-LD answer text is the answer with its markup removed."
31
37
  }
@@ -28,8 +28,10 @@ const list = tv({
28
28
  },
29
29
  // On accent the mark takes the ink colour, and text is never dimmed.
30
30
  tone: { default: {}, muted: {}, inverse: {}, accent: { mark: 'text-current', text: 'opacity-100' } },
31
+ // A list inside another section's column: one column, tighter, no section around it.
32
+ bare: { true: { grid: 'sm:grid-cols-1 gap-y-5' }, false: {} },
31
33
  },
32
- defaultVariants: { columns: '3', style: 'icon', tone: 'default' },
34
+ defaultVariants: { columns: '3', style: 'icon', tone: 'default', bare: false },
33
35
  })
34
36
 
35
37
  // 24×24 stroke paths (Lucide-style); unknown names fall back to check.
@@ -59,18 +61,39 @@ interface Props {
59
61
  columns?: '2' | '3' | '4' | undefined
60
62
  style?: 'icon' | 'check' | 'list' | undefined
61
63
  tone?: Tone | undefined
64
+ /** Only the list, with no section of its own, for use inside another section's column. */
65
+ bare?: boolean | undefined
66
+ /** The level of the item titles when `bare` (the section's heading level plus one). */
67
+ titleLevel?: 3 | 4 | 5 | undefined
62
68
  id?: string | undefined
63
69
  /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
64
70
  measured?: MeasuredStyle | undefined
65
71
  }
66
72
 
67
- const { heading, intro, items, columns, style = 'icon', tone, id, measured } = Astro.props
68
- const s = list({ columns, style, tone })
73
+ const { heading, intro, items, columns, style = 'icon', tone, bare = false, titleLevel = 3, id, measured } = Astro.props
74
+ const s = list({ columns, style, tone, bare })
69
75
  const headingId = heading && id ? `${id}-title` : undefined
70
76
  const pathOf = (item: Item) => (style !== 'check' && item.icon && icons[item.icon]) || icons.check
71
77
  // A plain list's items are not headings; otherwise one level under the section heading, or the section's when there is none.
72
- const Title = style === 'list' ? 'span' : heading ? 'h3' : 'h2'
78
+ const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : heading ? 'h3' : 'h2'
73
79
  ---
80
+ {bare
81
+ ? (
82
+ <ul class={s.grid({ class: measuredGrid(measured) })}>
83
+ {items.map(item => (
84
+ <li class={s.item()}>
85
+ <span class={s.mark()} aria-hidden="true">
86
+ <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>
87
+ </span>
88
+ <div>
89
+ <Title class={s.title()}>{item.title}</Title>
90
+ {item.text && <p class={s.text()} data-kit-text>{item.text}</p>}
91
+ </div>
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ )
96
+ : (
74
97
  <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
75
98
  {(heading || intro) && (
76
99
  <header class="mb-10 flex max-w-2xl flex-col gap-3">
@@ -92,3 +115,4 @@ const Title = style === 'list' ? 'span' : heading ? 'h3' : 'h2'
92
115
  ))}
93
116
  </ul>
94
117
  </Section>
118
+ )}
@@ -7,6 +7,7 @@
7
7
  "shared": [
8
8
  "Section.astro",
9
9
  "measured.ts",
10
+ "safe-url.ts",
10
11
  "types.ts"
11
12
  ],
12
13
  "variants": {
@@ -87,10 +88,17 @@
87
88
  },
88
89
  "description": "The features, in order (Elementor icon list or icon boxes without images, Gutenberg list of features, Divi blurbs with icons)."
89
90
  },
90
- "id": {
91
- "type": "string",
91
+ "bare": {
92
+ "type": "boolean",
93
+ "default": false,
92
94
  "content": false,
93
- "description": "Anchor id of the section; the heading gets `<id>-title`."
95
+ "description": "Only the list, with no section of its own; a split section uses it under its text."
96
+ },
97
+ "titleLevel": {
98
+ "type": "integer",
99
+ "default": 3,
100
+ "content": false,
101
+ "description": "With `bare`: the item titles' heading level (the section's plus one)."
94
102
  },
95
103
  "measured": {
96
104
  "type": "object",
@@ -201,6 +209,11 @@
201
209
  "description": "The section's minimum height."
202
210
  }
203
211
  }
212
+ },
213
+ "id": {
214
+ "type": "string",
215
+ "content": false,
216
+ "description": "Anchor id of the section; the heading gets `<id>-title`."
204
217
  }
205
218
  },
206
219
  "slots": {},
@@ -4,6 +4,7 @@
4
4
  import { tv } from 'tailwind-variants'
5
5
  import type { LinkInput } from '../_shared/types'
6
6
  import { footerLinkSizeClasses, footerVars } from './footer'
7
+ import { safeHref } from '../_shared/safe-url'
7
8
 
8
9
  const footer = tv({
9
10
  slots: {
@@ -107,7 +108,7 @@ const hasBottom = Boolean(line || consentLabel)
107
108
  <nav aria-label={column.label ?? column.title} data-cr-part="nav-footer">
108
109
  {column.title && <p class="mb-3 font-semibold">{column.title}</p>}
109
110
  <ul class="flex flex-col gap-2">
110
- {column.links.map(item => <li><a href={item.href} class={link()}>{item.label}</a></li>)}
111
+ {column.links.map(item => <li><a href={safeHref(item.href)} class={link()}>{item.label}</a></li>)}
111
112
  </ul>
112
113
  </nav>
113
114
  ))
@@ -115,13 +116,13 @@ const hasBottom = Boolean(line || consentLabel)
115
116
  <div class:list={['flex flex-col gap-4', align === 'center' ? 'items-center' : 'md:items-end']}>
116
117
  {social.length > 0 && (
117
118
  <ul class="flex flex-wrap gap-4">
118
- {social.map(item => <li><a href={item.href} rel="me noopener" target="_blank" class={link()}>{item.label}</a></li>)}
119
+ {social.map(item => <li><a href={safeHref(item.href)} rel="me noopener" target="_blank" class={link()}>{item.label}</a></li>)}
119
120
  </ul>
120
121
  )}
121
122
  {links.length > 0 && (
122
123
  <nav aria-label={navLabel} data-cr-part="nav-footer">
123
124
  <ul class="flex flex-wrap gap-x-6 gap-y-2">
124
- {links.map(item => <li><a href={item.href} class={link()}>{item.label}</a></li>)}
125
+ {links.map(item => <li><a href={safeHref(item.href)} class={link()}>{item.label}</a></li>)}
125
126
  </ul>
126
127
  </nav>
127
128
  )}
@@ -130,7 +131,7 @@ const hasBottom = Boolean(line || consentLabel)
130
131
  )}
131
132
  {layout === 'columns' && social.length > 0 && (
132
133
  <ul class="flex flex-wrap gap-4 md:col-span-full">
133
- {social.map(item => <li><a href={item.href} rel="me noopener" target="_blank" class={link()}>{item.label}</a></li>)}
134
+ {social.map(item => <li><a href={safeHref(item.href)} rel="me noopener" target="_blank" class={link()}>{item.label}</a></li>)}
134
135
  </ul>
135
136
  )}
136
137
  </div>
@@ -5,6 +5,7 @@
5
5
  "category": "chrome",
6
6
  "uses": [],
7
7
  "shared": [
8
+ "safe-url.ts",
8
9
  "types.ts"
9
10
  ],
10
11
  "variants": {
@@ -2,17 +2,24 @@
2
2
  // Images in a grid or in columns, with optional captions. Opening an image
3
3
  // shows it large in a native popover — no script: `popovertarget` opens it,
4
4
  // Escape and a click outside close it. `link` opens the image file instead.
5
+ // Layouts: grid, masonry, a scroll-snap `row`, and a `mosaic` whose first image
6
+ // spans 2×2. The popover shows one image; next/previous would need script.
5
7
  import { tv } from 'tailwind-variants'
6
8
  import KitImage from '../_shared/KitImage.astro'
7
9
  import Section from '../_shared/Section.astro'
10
+ import { measuredGrid } from '../_shared/measured'
11
+ import type { MeasuredStyle } from '../_shared/types'
8
12
  import { imageOf, type ImageSource } from '../_shared/image'
9
13
  import { type ImageInput } from '../_shared/types'
14
+ import { safeHref } from '../_shared/safe-url'
10
15
 
11
16
  export interface GalleryItem {
12
17
  /** The image's address (or, until 0.7, an `ImageInput`). */
13
18
  image: ImageSource
14
19
  imageAlt?: string | undefined
15
20
  caption?: string | undefined
21
+ /** The full-size file for the viewer, where the source's node points at the original; otherwise the image itself. */
22
+ full?: ImageSource | undefined
16
23
  }
17
24
 
18
25
  const gallery = tv({
@@ -20,7 +27,7 @@ const gallery = tv({
20
27
  heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight md:text-3xl',
21
28
  list: 'gap-3 md:gap-4',
22
29
  item: 'm-0',
23
- frame: 'block w-full cursor-zoom-in overflow-hidden rounded-[var(--radius-image,var(--radius-card))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
30
+ frame: 'group relative block w-full cursor-zoom-in overflow-hidden rounded-[var(--radius-image,var(--radius-card))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
24
31
  thumb: 'h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]',
25
32
  caption: 'mt-2 text-sm opacity-75',
26
33
  viewer: 'm-auto max-h-[92vh] max-w-[92vw] bg-transparent p-0 backdrop:bg-ink/85',
@@ -29,56 +36,83 @@ const gallery = tv({
29
36
  layout: {
30
37
  grid: { list: 'grid', frame: 'aspect-square' },
31
38
  masonry: { list: 'columns-2 [&>*]:mb-3 md:[&>*]:mb-4', item: 'break-inside-avoid' },
39
+ // A strip scrolled sideways, each tile snapping into place; it is a keyboard-focusable region (see the markup).
40
+ row: { list: 'flex snap-x snap-mandatory overflow-x-auto pb-2 [scrollbar-width:thin]', item: 'w-64 shrink-0 snap-start md:w-80', frame: 'aspect-[4/3]' },
41
+ // The first tile takes 2×2 of the grid; the rest fill around it.
42
+ mosaic: { list: 'grid grid-flow-dense', item: '[&:first-child]:col-span-2 [&:first-child]:row-span-2' },
32
43
  },
33
- columns: { '2': { list: 'grid-cols-2' }, '3': { list: 'grid-cols-2 md:grid-cols-3' }, '4': { list: 'grid-cols-2 md:grid-cols-4' } },
44
+ aspect: { square: { frame: 'aspect-square' }, '4:3': { frame: 'aspect-[4/3]' }, '16:9': { frame: 'aspect-video' }, auto: { frame: 'aspect-auto' } },
45
+ gap: { sm: { list: 'gap-1.5 md:gap-2' }, md: {}, lg: { list: 'gap-5 md:gap-8' } },
46
+ // Under the image, or laid over its foot on hover and keyboard focus — always visible below `md`.
47
+ captionStyle: { below: {}, overlay: { caption: 'absolute inset-x-0 bottom-0 m-0 bg-ink/70 p-2 text-surface md:translate-y-full md:transition-transform md:group-focus-within:translate-y-0 md:group-hover:translate-y-0' } },
48
+ hoverEffect: { none: {}, zoom: { thumb: 'motion-safe:group-hover:scale-105' } },
49
+ columns: { '2': { list: 'grid-cols-2' }, '3': { list: 'grid-cols-2 md:grid-cols-3' }, '4': { list: 'grid-cols-2 md:grid-cols-4' }, '5': { list: 'grid-cols-2 md:grid-cols-5' }, '6': { list: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6' } },
34
50
  open: { popover: {}, link: {}, none: { frame: 'cursor-default' } },
35
51
  },
36
52
  compoundVariants: [
37
53
  { layout: 'masonry', columns: '3', class: { list: 'md:columns-3' } },
38
54
  { layout: 'masonry', columns: '4', class: { list: 'md:columns-4' } },
55
+ { layout: 'masonry', columns: '5', class: { list: 'md:columns-5' } },
56
+ { layout: 'masonry', columns: '6', class: { list: 'md:columns-3 lg:columns-6' } },
57
+ // A row and a mosaic size by their own tiles, not by `columns`.
58
+ { layout: 'row', class: { list: 'grid-cols-none md:grid-cols-none' } },
59
+ { layout: 'masonry', aspect: ['square', '4:3', '16:9'], class: { frame: 'aspect-auto' } },
39
60
  ],
40
- defaultVariants: { layout: 'grid', columns: '3', open: 'popover' },
61
+ defaultVariants: { layout: 'grid', columns: '3', open: 'popover', aspect: 'square', gap: 'md', captionStyle: 'below', hoverEffect: 'none' },
41
62
  })
42
63
 
43
64
  interface Props {
44
65
  items: GalleryItem[]
45
66
  heading?: string | undefined
46
- layout?: 'grid' | 'masonry' | undefined
47
- columns?: '2' | '3' | '4' | undefined
67
+ layout?: 'grid' | 'masonry' | 'row' | 'mosaic' | undefined
68
+ columns?: '2' | '3' | '4' | '5' | '6' | undefined
69
+ /** Tile shape (a masonry keeps each image's own). */
70
+ aspect?: 'square' | '4:3' | '16:9' | 'auto' | undefined
71
+ gap?: 'sm' | 'md' | 'lg' | undefined
72
+ captionStyle?: 'below' | 'overlay' | undefined
73
+ hoverEffect?: 'none' | 'zoom' | undefined
74
+ /** Prefix of the enlarge button's name for readers ("Enlarge"). */
75
+ /** Name of the scrolling row's region when there is no heading ("Images"). */
76
+ regionLabel?: string | undefined
77
+ enlargeLabel?: string | undefined
48
78
  /** What a click on an image does: show it large in place, open the file, or nothing. */
49
79
  open?: 'popover' | 'link' | 'none' | undefined
50
80
  /** Text of the viewer's close button ("Close"). */
51
81
  closeLabel?: string | undefined
52
82
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
83
+ /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
84
+ measured?: MeasuredStyle | undefined
53
85
  id?: string | undefined
54
86
  }
55
87
 
56
- const { items: given, heading, layout, columns, open = 'popover', closeLabel = 'Close', tone, id = 'gallery' } = Astro.props
57
- const items = given.flatMap(item => { const image = imageOf(item.image, item.imageAlt); return image ? [{ ...item, image }] : [] }) satisfies Array<{ image: ImageInput, caption?: string | undefined }>
58
- const s = gallery({ layout, columns, open })
88
+ const { items: given, heading, layout, columns, aspect, gap, captionStyle, hoverEffect, enlargeLabel = 'Enlarge', regionLabel = 'Images', open = 'popover', closeLabel = 'Close', tone, measured, id = 'gallery' } = Astro.props
89
+ const items = given.flatMap(item => { const image = imageOf(item.image, item.imageAlt); return image ? [{ ...item, image, full: imageOf(item.full, item.imageAlt) }] : [] }) satisfies Array<{ image: ImageInput, caption?: string | undefined }>
90
+ const s = gallery({ layout, columns, open, aspect, gap, captionStyle, hoverEffect })
59
91
  const headingId = heading ? `${id}-title` : undefined
60
92
  const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 25vw, 50vw' : '(min-width: 768px) 33vw, 50vw'
61
93
  ---
62
94
  {items.length > 0 && (
63
- <Section tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
95
+ <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
64
96
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
65
- <ul class={s.list()} role="list">
97
+ {/* A scrolling strip is a labelled, focusable region so the keyboard can scroll it. */}
98
+ <div tabindex={layout === 'row' ? 0 : undefined} role={layout === 'row' ? 'region' : undefined} aria-label={layout === 'row' ? heading ?? regionLabel : undefined}>
99
+ <ul class={s.list({ class: layout === 'grid' || layout === 'mosaic' ? measuredGrid(measured) : undefined })} role="list">
66
100
  {items.map((item, i) => {
67
101
  const viewer = `${id}-image-${i + 1}`
68
102
  return (
69
103
  <li class={s.item()}>
70
- <figure class="m-0">
104
+ <figure class={captionStyle === 'overlay' ? 'group relative m-0 overflow-hidden' : 'm-0'}>
71
105
  {open === 'popover'
72
- ? <button type="button" popovertarget={viewer} class={s.frame()} aria-label={item.image.alt || item.caption || undefined}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></button>
106
+ ? <button type="button" popovertarget={viewer} class={s.frame()} aria-label={`${enlargeLabel}: ${item.image.alt || item.caption || ''}`.trim()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></button>
73
107
  : open === 'link'
74
- ? <a href={item.image.src} class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></a>
108
+ ? <a href={safeHref(item.image.src)} class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></a>
75
109
  : <div class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></div>}
76
110
  {item.caption && <figcaption class={s.caption({ class: tone === 'accent' ? 'opacity-100' : undefined })}>{item.caption}</figcaption>}
77
111
  </figure>
78
112
  {open === 'popover' && (
79
113
  <div id={viewer} popover class={s.viewer()}>
80
114
  <figure class="m-0 flex flex-col items-center gap-3">
81
- <KitImage image={item.image} sizes="92vw" class="max-h-[80vh] w-auto rounded-[var(--radius-image,var(--radius-card))] object-contain" />
115
+ <KitImage image={item.full ?? item.image} sizes="92vw" class="max-h-[80vh] w-auto rounded-[var(--radius-image,var(--radius-card))] object-contain" />
82
116
  {item.caption && <figcaption class="text-sm text-surface">{item.caption}</figcaption>}
83
117
  <button type="button" popovertarget={viewer} popovertargetaction="hide" class="rounded-md border border-surface/40 px-4 py-2 text-sm text-surface hover:bg-surface/10">{closeLabel}</button>
84
118
  </figure>
@@ -88,5 +122,6 @@ const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 2
88
122
  )
89
123
  })}
90
124
  </ul>
125
+ </div>
91
126
  </Section>
92
127
  )}