@contentrain/astro-kit 0.1.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 (63) hide show
  1. package/README.md +89 -0
  2. package/catalog.json +1780 -0
  3. package/components/_shared/Button.astro +37 -0
  4. package/components/_shared/KitImage.astro +21 -0
  5. package/components/_shared/Section.astro +43 -0
  6. package/components/_shared/types.ts +26 -0
  7. package/components/breadcrumb/Breadcrumb.astro +56 -0
  8. package/components/breadcrumb/fixtures.json +5 -0
  9. package/components/breadcrumb/meta.json +27 -0
  10. package/components/card-grid/CardGrid.astro +82 -0
  11. package/components/card-grid/fixtures.json +6 -0
  12. package/components/card-grid/meta.json +123 -0
  13. package/components/contact-form/ContactForm.astro +79 -0
  14. package/components/contact-form/fixtures.json +18 -0
  15. package/components/contact-form/meta.json +28 -0
  16. package/components/cta/Cta.astro +64 -0
  17. package/components/cta/fixtures.json +6 -0
  18. package/components/cta/meta.json +28 -0
  19. package/components/faq/Faq.astro +79 -0
  20. package/components/faq/fixtures.json +36 -0
  21. package/components/faq/meta.json +30 -0
  22. package/components/footer/Footer.astro +87 -0
  23. package/components/footer/fixtures.json +5 -0
  24. package/components/footer/meta.json +31 -0
  25. package/components/gallery/Gallery.astro +88 -0
  26. package/components/gallery/fixtures.json +191 -0
  27. package/components/gallery/meta.json +30 -0
  28. package/components/header/Header.astro +62 -0
  29. package/components/header/fixtures.json +125 -0
  30. package/components/header/meta.json +34 -0
  31. package/components/hero/Hero.astro +84 -0
  32. package/components/hero/fixtures.json +95 -0
  33. package/components/hero/meta.json +127 -0
  34. package/components/nav/Nav.astro +102 -0
  35. package/components/nav/fixtures.json +80 -0
  36. package/components/nav/meta.json +30 -0
  37. package/components/pagination/Pagination.astro +69 -0
  38. package/components/pagination/fixtures.json +6 -0
  39. package/components/pagination/meta.json +31 -0
  40. package/components/post-card/PostCard.astro +67 -0
  41. package/components/post-card/fixtures.json +54 -0
  42. package/components/post-card/meta.json +32 -0
  43. package/components/prose/Prose.astro +30 -0
  44. package/components/prose/fixtures.json +4 -0
  45. package/components/prose/meta.json +69 -0
  46. package/components/slider/Slider.astro +136 -0
  47. package/components/slider/fixtures.json +5 -0
  48. package/components/slider/meta.json +32 -0
  49. package/components/tabs/Tabs.astro +101 -0
  50. package/components/tabs/fixtures.json +37 -0
  51. package/components/tabs/meta.json +28 -0
  52. package/components/testimonial/Testimonial.astro +70 -0
  53. package/components/testimonial/fixtures.json +26 -0
  54. package/components/testimonial/meta.json +97 -0
  55. package/dist/index.d.mts +150 -0
  56. package/dist/index.d.mts.map +1 -0
  57. package/dist/index.mjs +199 -0
  58. package/dist/index.mjs.map +1 -0
  59. package/mapping/classic.json +261 -0
  60. package/mapping/divi.json +295 -0
  61. package/mapping/elementor.json +322 -0
  62. package/mapping/gutenberg.json +395 -0
  63. package/package.json +63 -0
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "cta",
3
+ "name": "CTA",
4
+ "description": "A call to action: a short heading, an optional line of text and one or two buttons — across the whole section, or as a card inside it.",
5
+ "category": "section",
6
+ "uses": [],
7
+ "shared": ["Button.astro", "Section.astro", "types.ts"],
8
+ "variants": {
9
+ "layout": { "options": ["inline", "centered"], "default": "inline", "description": "inline: text left, buttons right on wide screens. centered: everything centred." },
10
+ "frame": { "options": ["full", "card"], "default": "full", "description": "full: the tone fills the section. card: a rounded box in a plain section carries the tone." },
11
+ "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "accent", "description": "Background. On accent, primary buttons are drawn in the secondary style so they stay visible." }
12
+ },
13
+ "props": {
14
+ "heading": { "type": "string", "required": true, "description": "The call's heading (Divi CTA title, Elementor call-to-action title)." },
15
+ "body": { "type": "text", "description": "One or two sentences under the heading." },
16
+ "actions": { "type": "array", "required": true, "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true }, "newTab": { "type": "boolean" }, "style": { "type": "select", "options": ["primary", "secondary", "ghost"] } } }, "description": "Buttons, in order; usually one." },
17
+ "id": { "type": "string", "description": "Anchor id of the section; the heading gets `<id>-title`." }
18
+ },
19
+ "slots": {},
20
+ "js": "none",
21
+ "dependencies": {},
22
+ "sources": {
23
+ "elementor": ["elementor/call-to-action"],
24
+ "divi": ["divi/et_pb_cta"],
25
+ "classic": ["classic/cta"]
26
+ },
27
+ "a11y": "A labelled region (aria-labelledby its h2). Buttons are links with their own text. Accent tone swaps the primary button style so its contrast holds."
28
+ }
@@ -0,0 +1,79 @@
1
+ ---
2
+ // Questions and answers as native disclosure widgets (<details>) — no script.
3
+ // `exclusive` groups them by name, so opening one closes the others. With
4
+ // `schema`, the page also carries FAQPage structured data for search engines
5
+ // and AI answers.
6
+ import { tv } from 'tailwind-variants'
7
+ import Section from '../_shared/Section.astro'
8
+
9
+ export interface FaqItem {
10
+ question: string
11
+ /** The answer as HTML (the rich text the question came with). */
12
+ answer: string
13
+ }
14
+
15
+ const faq = tv({
16
+ slots: {
17
+ heading: 'mb-8 text-2xl font-bold tracking-tight text-balance md:text-3xl',
18
+ list: 'flex flex-col',
19
+ item: 'group',
20
+ summary: 'flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg font-semibold [&::-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',
22
+ answer: 'prose-site prose max-w-none pb-5 text-current',
23
+ },
24
+ variants: {
25
+ style: {
26
+ lines: { list: 'divide-y divide-line border-y border-line' },
27
+ cards: { list: 'gap-3', item: 'rounded-card border border-line px-5', summary: 'py-5' },
28
+ },
29
+ tone: { default: {}, muted: { item: 'bg-surface' }, inverse: { answer: 'prose-invert' }, accent: { answer: 'prose-invert' } },
30
+ },
31
+ compoundVariants: [
32
+ { style: 'cards', tone: 'muted', class: { item: 'border-transparent' } },
33
+ ],
34
+ defaultVariants: { style: 'lines', tone: 'default' },
35
+ })
36
+
37
+ interface Props {
38
+ items: FaqItem[]
39
+ heading?: string | undefined
40
+ style?: 'lines' | 'cards' | undefined
41
+ /** One answer open at a time (`<details name>`). */
42
+ exclusive?: boolean | undefined
43
+ /** Open the first answer. */
44
+ openFirst?: boolean | undefined
45
+ /** Add FAQPage structured data (JSON-LD). Once per page. */
46
+ schema?: boolean | undefined
47
+ tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
48
+ id?: string | undefined
49
+ }
50
+
51
+ const { items, heading, style, exclusive = false, openFirst = false, schema = false, tone, id = 'faq' } = Astro.props
52
+ const s = faq({ style, tone })
53
+ const headingId = heading ? `${id}-title` : undefined
54
+ const plain = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
55
+ const structured = schema && items.length > 0
56
+ ? JSON.stringify({
57
+ '@context': 'https://schema.org',
58
+ '@type': 'FAQPage',
59
+ mainEntity: items.map(item => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: plain(item.answer) } })),
60
+ }).replace(/</g, '\\u003c')
61
+ : null
62
+ ---
63
+ {items.length > 0 && (
64
+ <Section tone={tone} spacing="md" width="prose" id={id} aria-labelledby={headingId}>
65
+ {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
66
+ <div class={s.list()}>
67
+ {items.map((item, i) => (
68
+ <details class={s.item()} name={exclusive ? id : undefined} open={openFirst && i === 0}>
69
+ <summary class={s.summary()}>
70
+ <span>{item.question}</span>
71
+ <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>
72
+ </summary>
73
+ <div class={s.answer()} set:html={item.answer} />
74
+ </details>
75
+ ))}
76
+ </div>
77
+ {structured && <script type="application/ld+json" set:html={structured} />}
78
+ </Section>
79
+ )}
@@ -0,0 +1,36 @@
1
+ [
2
+ {
3
+ "name": "lines-exclusive-schema",
4
+ "props": {
5
+ "heading": "Frequently asked questions",
6
+ "exclusive": true,
7
+ "openFirst": true,
8
+ "schema": true,
9
+ "items": [
10
+ { "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>" },
11
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
12
+ { "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>" }
13
+ ]
14
+ }
15
+ },
16
+ {
17
+ "name": "cards-muted",
18
+ "props": {
19
+ "style": "cards",
20
+ "tone": "muted",
21
+ "id": "faq-cards",
22
+ "items": [
23
+ { "question": "Is the newsletter free?", "answer": "<p>Yes, and it always will be.</p>" },
24
+ { "question": "Do you run ads?", "answer": "<p>No. The journal is funded by members.</p>" }
25
+ ]
26
+ }
27
+ },
28
+ {
29
+ "name": "lines-inverse",
30
+ "props": { "tone": "inverse", "id": "faq-dark", "openFirst": true, "items": [{ "question": "Where are you based?", "answer": "<p>Lisbon, most of the year.</p>" }] }
31
+ },
32
+ {
33
+ "name": "cards-accent",
34
+ "props": { "style": "cards", "tone": "accent", "id": "faq-accent", "items": [{ "question": "How do I cancel my membership?", "answer": "<p>From your account page, at any time.</p>" }] }
35
+ }
36
+ ]
@@ -0,0 +1,30 @@
1
+ {
2
+ "id": "faq",
3
+ "name": "FAQ",
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
+ "category": "section",
6
+ "uses": [],
7
+ "shared": ["Section.astro"],
8
+ "variants": {
9
+ "style": { "options": ["lines", "cards"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box." },
10
+ "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." }
11
+ },
12
+ "props": {
13
+ "items": { "type": "array", "required": true, "items": { "type": "object", "fields": { "question": { "type": "string", "required": true }, "answer": { "type": "richtext", "required": true } } }, "description": "The questions in order (core/details summary + content, Elementor accordion/toggle tabs, Divi accordion/toggle items)." },
14
+ "heading": { "type": "string", "description": "Section heading." },
15
+ "exclusive": { "type": "boolean", "default": false, "description": "One answer open at a time (Elementor accordion, Divi accordion). Off for toggles." },
16
+ "openFirst": { "type": "boolean", "default": false, "description": "The first answer starts open (Divi open_toggle, Elementor active item 1)." },
17
+ "schema": { "type": "boolean", "default": 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." },
18
+ "id": { "type": "string", "description": "Section id; also the exclusive group's name." }
19
+ },
20
+ "slots": {},
21
+ "js": "none",
22
+ "dependencies": {},
23
+ "sources": {
24
+ "gutenberg": ["core/details"],
25
+ "elementor": ["elementor/accordion", "elementor/toggle", "elementor/nested-accordion"],
26
+ "divi": ["divi/et_pb_accordion", "divi/et_pb_toggle"],
27
+ "classic": ["classic/faq"]
28
+ },
29
+ "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."
30
+ }
@@ -0,0 +1,87 @@
1
+ ---
2
+ // Site footer: brand and tagline, link columns or a single link row, social
3
+ // links and the copyright line.
4
+ import { tv } from 'tailwind-variants'
5
+ import type { LinkInput } from '../_shared/types'
6
+
7
+ const footer = tv({
8
+ slots: {
9
+ root: 'mt-auto',
10
+ inner: 'container-page py-10 text-sm',
11
+ muted: 'opacity-75',
12
+ link: 'text-current no-underline opacity-75 hover:opacity-100 hover:underline',
13
+ },
14
+ variants: {
15
+ layout: {
16
+ simple: { inner: 'flex flex-col gap-6 md:flex-row md:items-center md:justify-between' },
17
+ columns: { inner: 'grid gap-10 md:grid-cols-[2fr_repeat(var(--footer-columns),1fr)] md:py-16' },
18
+ },
19
+ tone: {
20
+ default: { root: 'border-t border-line bg-surface text-ink' },
21
+ muted: { root: 'bg-surface-muted text-ink' },
22
+ inverse: { root: 'bg-ink text-surface' },
23
+ },
24
+ },
25
+ defaultVariants: { layout: 'simple', tone: 'default' },
26
+ })
27
+
28
+ interface Props {
29
+ siteName: string
30
+ tagline?: string | undefined
31
+ /** Link groups for the `columns` layout. */
32
+ columns?: Array<{ title: string, links: LinkInput[] }> | undefined
33
+ /** A single row of links (the footer menu). */
34
+ links?: LinkInput[] | undefined
35
+ social?: LinkInput[] | undefined
36
+ /** The copyright line, e.g. "© 2026 Northwind Studio". */
37
+ copyright?: string | undefined
38
+ /** Accessible name of the footer navigation ("Footer"). */
39
+ navLabel?: string | undefined
40
+ layout?: 'simple' | 'columns' | undefined
41
+ tone?: 'default' | 'muted' | 'inverse' | undefined
42
+ }
43
+
44
+ const { siteName, tagline, columns = [], links = [], social = [], copyright, navLabel = 'Footer', layout = 'simple', tone } = Astro.props
45
+ const { root, inner, muted, link } = footer({ layout, tone })
46
+ const line = copyright ?? `© ${new Date().getFullYear()} ${siteName}`
47
+ ---
48
+ <footer class={root()}>
49
+ <div class={inner()} style={layout === 'columns' ? `--footer-columns: ${Math.max(columns.length, 1)}` : undefined}>
50
+ <div class="flex flex-col gap-3">
51
+ <p class="text-base font-semibold">{siteName}</p>
52
+ {tagline && <p class={muted()}>{tagline}</p>}
53
+ {layout === 'columns' && <p class:list={[muted(), 'mt-auto pt-6']}>{line}</p>}
54
+ </div>
55
+ {layout === 'columns'
56
+ ? columns.map(column => (
57
+ <nav aria-label={column.title}>
58
+ <p class="mb-3 font-semibold">{column.title}</p>
59
+ <ul class="flex flex-col gap-2">
60
+ {column.links.map(item => <li><a href={item.href} class={link()}>{item.label}</a></li>)}
61
+ </ul>
62
+ </nav>
63
+ ))
64
+ : (
65
+ <div class="flex flex-col gap-4 md:items-end">
66
+ {social.length > 0 && (
67
+ <ul class="flex flex-wrap gap-4">
68
+ {social.map(item => <li><a href={item.href} rel="me noopener" target="_blank" class={link()}>{item.label}</a></li>)}
69
+ </ul>
70
+ )}
71
+ {links.length > 0 && (
72
+ <nav aria-label={navLabel}>
73
+ <ul class="flex flex-wrap gap-x-6 gap-y-2">
74
+ {links.map(item => <li><a href={item.href} class={link()}>{item.label}</a></li>)}
75
+ </ul>
76
+ </nav>
77
+ )}
78
+ <p class={muted()}>{line}</p>
79
+ </div>
80
+ )}
81
+ {layout === 'columns' && social.length > 0 && (
82
+ <ul class="flex flex-wrap gap-4 md:col-span-full">
83
+ {social.map(item => <li><a href={item.href} rel="me noopener" target="_blank" class={link()}>{item.label}</a></li>)}
84
+ </ul>
85
+ )}
86
+ </div>
87
+ </footer>
@@ -0,0 +1,5 @@
1
+ [
2
+ { "name": "simple", "props": { "siteName": "Northwind Studio", "copyright": "© 2026 Northwind Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Privacy", "href": "/privacy/" }, { "label": "RSS feed", "href": "/rss.xml" }] } },
3
+ { "name": "columns-inverse", "props": { "siteName": "Northwind Studio", "tagline": "Calm, fast websites for teams who write.", "layout": "columns", "tone": "inverse", "copyright": "© 2026 Northwind Studio", "columns": [{ "title": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Team", "href": "/about/team/" }] }, { "title": "Journal", "links": [{ "label": "Guides", "href": "/category/guides/" }, { "label": "News", "href": "/category/news/" }] }, { "title": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }], "social": [{ "label": "GitHub", "href": "https://github.com/contentrain" }, { "label": "Mastodon", "href": "https://mastodon.social/@contentrain" }] } },
4
+ { "name": "muted", "props": { "siteName": "Northwind", "tone": "muted", "copyright": "© 2026 Northwind", "links": [{ "label": "Imprint", "href": "/imprint/" }] } }
5
+ ]
@@ -0,0 +1,31 @@
1
+ {
2
+ "id": "footer",
3
+ "name": "Footer",
4
+ "description": "Site footer: brand and tagline, link columns or one row of links, social links and the copyright line.",
5
+ "category": "chrome",
6
+ "uses": [],
7
+ "shared": ["types.ts"],
8
+ "variants": {
9
+ "layout": { "options": ["simple", "columns"], "default": "simple", "description": "simple: brand left, links and copyright right. columns: brand column plus one column per link group." },
10
+ "tone": { "options": ["default", "muted", "inverse"], "default": "default", "description": "Surface with a top border, muted surface, or dark (ink) background." }
11
+ },
12
+ "props": {
13
+ "siteName": { "type": "string", "required": true, "description": "Site title (site.title or site.organization)." },
14
+ "tagline": { "type": "string", "description": "One line under the name (site.tagline)." },
15
+ "columns": { "type": "array", "items": { "type": "object", "fields": { "title": { "type": "string", "required": true }, "links": { "type": "array", "required": true } } }, "description": "Link groups for the columns layout; each footer menu or widget area becomes one." },
16
+ "links": { "type": "array", "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true } } }, "description": "One row of links (the footer menu) for the simple layout." },
17
+ "social": { "type": "array", "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true } } }, "description": "Social profile links (site.same_as)." },
18
+ "copyright": { "type": "string", "description": "Copyright line; defaults to © <year> <siteName>." },
19
+ "navLabel": { "type": "string", "description": "Accessible name of the footer navigation (ui-strings nav.footer)." }
20
+ },
21
+ "slots": {},
22
+ "js": "none",
23
+ "dependencies": {},
24
+ "sources": {
25
+ "gutenberg": ["core/template-part:footer", "core/social-links"],
26
+ "elementor": ["elementor/social-icons", "elementor/icon-list"],
27
+ "divi": ["divi/et_pb_social_media_follow"],
28
+ "classic": ["classic/footer", "classic/widget-area"]
29
+ },
30
+ "a11y": "One <footer> landmark; each link group is a <nav> named by its title. Social links open in a new tab with rel=me."
31
+ }
@@ -0,0 +1,88 @@
1
+ ---
2
+ // Images in a grid or in columns, with optional captions. Opening an image
3
+ // shows it large in a native popover — no script: `popovertarget` opens it,
4
+ // Escape and a click outside close it. `link` opens the image file instead.
5
+ import { tv } from 'tailwind-variants'
6
+ import KitImage from '../_shared/KitImage.astro'
7
+ import Section from '../_shared/Section.astro'
8
+ import type { ImageInput } from '../_shared/types'
9
+
10
+ export interface GalleryItem {
11
+ image: ImageInput
12
+ caption?: string | undefined
13
+ }
14
+
15
+ const gallery = tv({
16
+ slots: {
17
+ heading: 'mb-8 text-2xl font-bold tracking-tight md:text-3xl',
18
+ list: 'gap-3 md:gap-4',
19
+ item: 'm-0',
20
+ frame: 'block w-full cursor-zoom-in overflow-hidden rounded-card focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
21
+ thumb: 'h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]',
22
+ caption: 'mt-2 text-sm opacity-75',
23
+ viewer: 'm-auto max-h-[92vh] max-w-[92vw] bg-transparent p-0 backdrop:bg-ink/85',
24
+ },
25
+ variants: {
26
+ layout: {
27
+ grid: { list: 'grid', frame: 'aspect-square' },
28
+ masonry: { list: 'columns-2 [&>*]:mb-3 md:[&>*]:mb-4', item: 'break-inside-avoid' },
29
+ },
30
+ columns: { '2': { list: 'grid-cols-2' }, '3': { list: 'grid-cols-2 md:grid-cols-3' }, '4': { list: 'grid-cols-2 md:grid-cols-4' } },
31
+ open: { popover: {}, link: {}, none: { frame: 'cursor-default' } },
32
+ },
33
+ compoundVariants: [
34
+ { layout: 'masonry', columns: '3', class: { list: 'md:columns-3' } },
35
+ { layout: 'masonry', columns: '4', class: { list: 'md:columns-4' } },
36
+ ],
37
+ defaultVariants: { layout: 'grid', columns: '3', open: 'popover' },
38
+ })
39
+
40
+ interface Props {
41
+ items: GalleryItem[]
42
+ heading?: string | undefined
43
+ layout?: 'grid' | 'masonry' | undefined
44
+ columns?: '2' | '3' | '4' | undefined
45
+ /** What a click on an image does: show it large in place, open the file, or nothing. */
46
+ open?: 'popover' | 'link' | 'none' | undefined
47
+ /** Text of the viewer's close button ("Close"). */
48
+ closeLabel?: string | undefined
49
+ tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
50
+ id?: string | undefined
51
+ }
52
+
53
+ const { items, heading, layout, columns, open = 'popover', closeLabel = 'Close', tone, id = 'gallery' } = Astro.props
54
+ const s = gallery({ layout, columns, open })
55
+ const headingId = heading ? `${id}-title` : undefined
56
+ const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 25vw, 50vw' : '(min-width: 768px) 33vw, 50vw'
57
+ ---
58
+ {items.length > 0 && (
59
+ <Section tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
60
+ {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
61
+ <ul class={s.list()} role="list">
62
+ {items.map((item, i) => {
63
+ const viewer = `${id}-image-${i + 1}`
64
+ return (
65
+ <li class={s.item()}>
66
+ <figure class="m-0">
67
+ {open === 'popover'
68
+ ? <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>
69
+ : open === 'link'
70
+ ? <a href={item.image.src} class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></a>
71
+ : <div class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></div>}
72
+ {item.caption && <figcaption class={s.caption({ class: tone === 'accent' ? 'opacity-100' : undefined })}>{item.caption}</figcaption>}
73
+ </figure>
74
+ {open === 'popover' && (
75
+ <div id={viewer} popover class={s.viewer()}>
76
+ <figure class="m-0 flex flex-col items-center gap-3">
77
+ <KitImage image={item.image} sizes="92vw" class="max-h-[80vh] w-auto rounded-card object-contain" />
78
+ {item.caption && <figcaption class="text-sm text-surface">{item.caption}</figcaption>}
79
+ <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>
80
+ </figure>
81
+ </div>
82
+ )}
83
+ </li>
84
+ )
85
+ })}
86
+ </ul>
87
+ </Section>
88
+ )}
@@ -0,0 +1,191 @@
1
+ [
2
+ {
3
+ "name": "grid-popover",
4
+ "props": {
5
+ "heading": "From the road",
6
+ "id": "road",
7
+ "items": [
8
+ {
9
+ "image": {
10
+ "src": "/kit-fixtures/photo-1.svg",
11
+ "alt": "Photo 1",
12
+ "width": 1200,
13
+ "height": 800
14
+ },
15
+ "caption": "Porto at dawn"
16
+ },
17
+ {
18
+ "image": {
19
+ "src": "/kit-fixtures/photo-2.svg",
20
+ "alt": "Photo 2",
21
+ "width": 1200,
22
+ "height": 800
23
+ },
24
+ "caption": "Train to Coimbra"
25
+ },
26
+ {
27
+ "image": {
28
+ "src": "/kit-fixtures/photo-3.svg",
29
+ "alt": "Photo 3",
30
+ "width": 1200,
31
+ "height": 800
32
+ },
33
+ "caption": "Market day"
34
+ },
35
+ {
36
+ "image": {
37
+ "src": "/kit-fixtures/photo-4.svg",
38
+ "alt": "Photo 4",
39
+ "width": 1200,
40
+ "height": 800
41
+ },
42
+ "caption": "Harbour"
43
+ },
44
+ {
45
+ "image": {
46
+ "src": "/kit-fixtures/photo-5.svg",
47
+ "alt": "Photo 5",
48
+ "width": 1200,
49
+ "height": 800
50
+ },
51
+ "caption": "Old town"
52
+ },
53
+ {
54
+ "image": {
55
+ "src": "/kit-fixtures/photo-6.svg",
56
+ "alt": "Photo 6",
57
+ "width": 1200,
58
+ "height": 800
59
+ },
60
+ "caption": "Last light"
61
+ }
62
+ ]
63
+ }
64
+ },
65
+ {
66
+ "name": "masonry-4-link",
67
+ "props": {
68
+ "layout": "masonry",
69
+ "columns": "4",
70
+ "open": "link",
71
+ "id": "masonry",
72
+ "items": [
73
+ {
74
+ "image": {
75
+ "src": "/kit-fixtures/photo-1.svg",
76
+ "alt": "Photo 1",
77
+ "width": 1200,
78
+ "height": 800
79
+ }
80
+ },
81
+ {
82
+ "image": {
83
+ "src": "/kit-fixtures/photo-2.svg",
84
+ "alt": "Photo 2",
85
+ "width": 1200,
86
+ "height": 1500
87
+ }
88
+ },
89
+ {
90
+ "image": {
91
+ "src": "/kit-fixtures/photo-3.svg",
92
+ "alt": "Photo 3",
93
+ "width": 1200,
94
+ "height": 900
95
+ }
96
+ },
97
+ {
98
+ "image": {
99
+ "src": "/kit-fixtures/photo-4.svg",
100
+ "alt": "Photo 4",
101
+ "width": 1200,
102
+ "height": 1200
103
+ }
104
+ },
105
+ {
106
+ "image": {
107
+ "src": "/kit-fixtures/photo-5.svg",
108
+ "alt": "Photo 5",
109
+ "width": 1200,
110
+ "height": 1600
111
+ }
112
+ },
113
+ {
114
+ "image": {
115
+ "src": "/kit-fixtures/photo-6.svg",
116
+ "alt": "Photo 6",
117
+ "width": 1200,
118
+ "height": 700
119
+ }
120
+ }
121
+ ]
122
+ }
123
+ },
124
+ {
125
+ "name": "grid-2-none-muted",
126
+ "props": {
127
+ "columns": "2",
128
+ "open": "none",
129
+ "tone": "muted",
130
+ "id": "pair",
131
+ "items": [
132
+ {
133
+ "image": {
134
+ "src": "/kit-fixtures/photo-1.svg",
135
+ "alt": "Photo 1",
136
+ "width": 1200,
137
+ "height": 800
138
+ },
139
+ "caption": "Before"
140
+ },
141
+ {
142
+ "image": {
143
+ "src": "/kit-fixtures/photo-2.svg",
144
+ "alt": "Photo 2",
145
+ "width": 1200,
146
+ "height": 800
147
+ },
148
+ "caption": "After"
149
+ }
150
+ ]
151
+ }
152
+ },
153
+ {
154
+ "name": "grid-accent-captions",
155
+ "props": {
156
+ "tone": "accent",
157
+ "columns": "3",
158
+ "open": "none",
159
+ "id": "accent-captions",
160
+ "items": [
161
+ {
162
+ "image": {
163
+ "src": "/kit-fixtures/photo-4.svg",
164
+ "alt": "",
165
+ "width": 1200,
166
+ "height": 800
167
+ },
168
+ "caption": "Harbour"
169
+ },
170
+ {
171
+ "image": {
172
+ "src": "/kit-fixtures/photo-5.svg",
173
+ "alt": "",
174
+ "width": 1200,
175
+ "height": 800
176
+ },
177
+ "caption": "Old town"
178
+ },
179
+ {
180
+ "image": {
181
+ "src": "/kit-fixtures/photo-6.svg",
182
+ "alt": "",
183
+ "width": 1200,
184
+ "height": 800
185
+ },
186
+ "caption": "Last light"
187
+ }
188
+ ]
189
+ }
190
+ }
191
+ ]
@@ -0,0 +1,30 @@
1
+ {
2
+ "id": "gallery",
3
+ "name": "Gallery",
4
+ "description": "Images in a square grid or in masonry columns, with optional captions. A click shows the image large in a native popover (no script), opens the image file, or does nothing.",
5
+ "category": "section",
6
+ "uses": [],
7
+ "shared": ["KitImage.astro", "Section.astro", "types.ts"],
8
+ "variants": {
9
+ "layout": { "options": ["grid", "masonry"], "default": "grid", "description": "grid: square crops in rows. masonry: columns that keep each image's shape." },
10
+ "columns": { "options": ["2", "3", "4"], "default": "3", "description": "Columns on wide screens; phones show two." },
11
+ "open": { "options": ["popover", "link", "none"], "default": "popover", "description": "popover: the image opens large in place (Popover API; Escape or a click outside closes it). link: the thumbnail links to the image file (the WordPress 'link to media file'). none: images only." }
12
+ },
13
+ "props": {
14
+ "items": { "type": "array", "required": true, "items": { "type": "object", "fields": { "image": { "type": "object", "required": true, "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } } }, "caption": { "type": "string" } } }, "description": "The images in order (core/gallery inner images, Elementor gallery items, Divi gallery_ids)." },
15
+ "heading": { "type": "string", "description": "Section heading above the images." },
16
+ "closeLabel": { "type": "string", "description": "Text of the viewer's close button (ui-strings gallery.close)." },
17
+ "tone": { "type": "select", "options": ["default", "muted", "inverse", "accent"], "description": "Section background." },
18
+ "id": { "type": "string", "description": "Section id, unique per page: viewer ids derive from it (`<id>-image-<n>`)." }
19
+ },
20
+ "slots": {},
21
+ "js": "none",
22
+ "dependencies": {},
23
+ "sources": {
24
+ "gutenberg": ["core/gallery"],
25
+ "elementor": ["elementor/image-gallery", "elementor/gallery"],
26
+ "divi": ["divi/et_pb_gallery"],
27
+ "classic": ["classic/gallery"]
28
+ },
29
+ "a11y": "A list of figures. In popover mode each thumbnail is a <button> named by the image's alt (or caption) that opens the viewer; the viewer has a visible close button, and Escape closes it, returning focus to the thumbnail. Captions are <figcaption>s."
30
+ }