@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.
- package/catalog.json +4600 -1584
- package/components/_shared/Button.astro +2 -1
- package/components/_shared/KitImage.astro +9 -4
- package/components/_shared/Section.astro +3 -1
- package/components/_shared/SectionHeader.astro +27 -0
- package/components/_shared/measured.ts +23 -0
- package/components/_shared/safe-url.ts +67 -0
- package/components/_shared/social.ts +73 -0
- package/components/announcement/Announcement.astro +2 -1
- package/components/announcement/meta.json +1 -1
- package/components/band/Band.astro +3 -2
- package/components/band/meta.json +1 -0
- package/components/breadcrumb/Breadcrumb.astro +2 -1
- package/components/breadcrumb/meta.json +1 -1
- package/components/card-grid/CardGrid.astro +2 -1
- package/components/card-grid/meta.json +1 -0
- package/components/consent/Consent.astro +2 -1
- package/components/consent/meta.json +1 -1
- package/components/contact-details/ContactDetails.astro +3 -2
- package/components/contact-details/meta.json +1 -0
- package/components/contact-form/ContactForm.astro +51 -14
- package/components/contact-form/fixtures.json +169 -9
- package/components/contact-form/meta.json +284 -15
- package/components/cta/Cta.astro +52 -12
- package/components/cta/fixtures.json +5 -1
- package/components/cta/meta.json +11 -2
- package/components/dialog/Dialog.astro +113 -0
- package/components/dialog/fixtures.json +84 -0
- package/components/dialog/meta.json +252 -0
- package/components/embed/meta.json +1 -1
- package/components/faq/Faq.astro +60 -17
- package/components/faq/fixtures.json +79 -0
- package/components/faq/meta.json +9 -3
- package/components/feature-list/FeatureList.astro +28 -4
- package/components/feature-list/meta.json +16 -3
- package/components/footer/Footer.astro +5 -4
- package/components/footer/meta.json +1 -0
- package/components/gallery/Gallery.astro +49 -14
- package/components/gallery/fixtures.json +275 -0
- package/components/gallery/meta.json +267 -14
- package/components/header/Header.astro +17 -5
- package/components/header/fixtures.json +90 -1
- package/components/header/meta.json +5 -3
- package/components/hero/meta.json +1 -0
- package/components/language-switcher/LanguageSwitcher.astro +68 -0
- package/components/language-switcher/fixtures.json +51 -0
- package/components/language-switcher/meta.json +73 -0
- package/components/logo-cloud/LogoCloud.astro +86 -21
- package/components/logo-cloud/fixtures.json +239 -0
- package/components/logo-cloud/meta.json +145 -5
- package/components/nav/Nav.astro +77 -24
- package/components/nav/NavTree.astro +40 -0
- package/components/nav/fixtures.json +165 -0
- package/components/nav/meta.json +11 -2
- package/components/newsletter/Newsletter.astro +2 -1
- package/components/newsletter/meta.json +1 -0
- package/components/pagination/Pagination.astro +4 -3
- package/components/pagination/meta.json +1 -1
- package/components/post-card/PostCard.astro +8 -5
- package/components/post-card/meta.json +3 -2
- package/components/post-list/PostList.astro +115 -0
- package/components/post-list/fixtures.json +387 -0
- package/components/post-list/meta.json +309 -0
- package/components/pricing/Pricing.astro +128 -24
- package/components/pricing/fixtures.json +288 -4
- package/components/pricing/meta.json +188 -1
- package/components/search-box/SearchBox.astro +51 -0
- package/components/search-box/fixtures.json +31 -0
- package/components/search-box/meta.json +75 -0
- package/components/slider/Slider.astro +19 -115
- package/components/slider/SliderTrack.astro +178 -0
- package/components/slider/fixtures.json +2 -1
- package/components/slider/meta.json +11 -5
- package/components/social-links/SocialLinks.astro +74 -0
- package/components/social-links/fixtures.json +67 -0
- package/components/social-links/meta.json +94 -0
- package/components/split/Split.astro +76 -14
- package/components/split/fixtures.json +8 -1
- package/components/split/meta.json +226 -4
- package/components/stats/Stats.astro +54 -11
- package/components/stats/fixtures.json +260 -0
- package/components/stats/meta.json +151 -4
- package/components/steps/Steps.astro +36 -8
- package/components/steps/fixtures.json +217 -5
- package/components/steps/meta.json +157 -3
- package/components/tabs/Tabs.astro +10 -3
- package/components/tabs/fixtures.json +4 -0
- package/components/tabs/meta.json +5 -2
- package/components/team/Team.astro +54 -9
- package/components/team/fixtures.json +217 -4
- package/components/team/meta.json +169 -3
- package/components/team/team.ts +4 -0
- package/components/testimonial/Testimonial.astro +71 -21
- package/components/testimonial/fixtures.json +73 -0
- package/components/testimonial/meta.json +168 -6
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/mapping/classic.json +124 -3
- package/mapping/divi.json +26 -8
- package/mapping/elementor.json +133 -14
- package/mapping/gutenberg.json +38 -10
- package/package.json +2 -2
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A carousel of images with optional captions. Without JavaScript it is a
|
|
3
|
+
// horizontally scrolling, snapping row — every slide is reachable. With it,
|
|
4
|
+
// Embla turns the row into a paged carousel with previous/next buttons and
|
|
5
|
+
// slide dots. Slider.astro frames it (the section's own look, when a measured
|
|
6
|
+
// style is given); the markup and the script are here.
|
|
7
|
+
import { tv } from 'tailwind-variants'
|
|
8
|
+
import Button from '../_shared/Button.astro'
|
|
9
|
+
import KitImage from '../_shared/KitImage.astro'
|
|
10
|
+
import SectionHeader from '../_shared/SectionHeader.astro'
|
|
11
|
+
import { imageOf, type ImageSource } from '../_shared/image'
|
|
12
|
+
import { measuredSlides } from '../_shared/measured'
|
|
13
|
+
import { type ActionInput, type ImageInput, type MeasuredStyle } from '../_shared/types'
|
|
14
|
+
|
|
15
|
+
const slider = tv({
|
|
16
|
+
slots: {
|
|
17
|
+
viewport: 'flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth group-data-[ready]:snap-none group-data-[ready]:overflow-hidden [scrollbar-width:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
|
18
|
+
slide: 'relative min-w-0 shrink-0 snap-start overflow-hidden',
|
|
19
|
+
image: 'w-full object-cover',
|
|
20
|
+
caption: 'flex flex-col items-start gap-2',
|
|
21
|
+
},
|
|
22
|
+
variants: {
|
|
23
|
+
perView: {
|
|
24
|
+
'1': { slide: 'basis-full' },
|
|
25
|
+
'2': { slide: 'basis-[85%] sm:basis-[calc(50%-0.5rem)]' },
|
|
26
|
+
'3': { slide: 'basis-[85%] sm:basis-[calc(50%-0.5rem)] lg:basis-[calc(33.333%-0.667rem)]' },
|
|
27
|
+
},
|
|
28
|
+
aspect: {
|
|
29
|
+
wide: { image: 'aspect-[4/3] md:aspect-[21/9]' },
|
|
30
|
+
standard: { image: 'aspect-[3/2]' },
|
|
31
|
+
square: { image: 'aspect-square' },
|
|
32
|
+
},
|
|
33
|
+
caption: {
|
|
34
|
+
below: { image: 'rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4' },
|
|
35
|
+
// Over the image on wide screens; under it on phones, where the image is too short to hold text.
|
|
36
|
+
overlay: { image: 'rounded-[var(--radius-image,var(--radius-card))] md:rounded-none', slide: 'md:rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4 md:absolute md:inset-x-0 md:bottom-0 md:bg-gradient-to-t md:from-ink/80 md:to-transparent md:p-6 md:pt-16 md:text-surface' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
defaultVariants: { perView: '1', aspect: 'wide', caption: 'overlay' },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
interface Slide {
|
|
43
|
+
/** The image's address (or, until 0.7, an `ImageInput`). */
|
|
44
|
+
image: ImageSource
|
|
45
|
+
imageAlt?: string | undefined
|
|
46
|
+
title?: string | undefined
|
|
47
|
+
text?: string | undefined
|
|
48
|
+
/** The slide's link, flat so a page stays within Contentrain's nesting depth. */
|
|
49
|
+
ctaLabel?: string | undefined
|
|
50
|
+
ctaHref?: string | undefined
|
|
51
|
+
/** Until 0.7: the same link as one object. */
|
|
52
|
+
action?: ActionInput | undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Props {
|
|
56
|
+
slides: Slide[]
|
|
57
|
+
/** The section's head, drawn above the carousel; none of these given, nothing is drawn. */
|
|
58
|
+
heading?: string | undefined
|
|
59
|
+
intro?: string | undefined
|
|
60
|
+
eyebrow?: string | undefined
|
|
61
|
+
actions?: ActionInput[] | undefined
|
|
62
|
+
/** Accessible name of the carousel ("Featured projects"). */
|
|
63
|
+
label: string
|
|
64
|
+
prevLabel?: string | undefined
|
|
65
|
+
nextLabel?: string | undefined
|
|
66
|
+
/** Word before a slide number in dot labels and slide names ("Slide"). */
|
|
67
|
+
slideLabel?: string | undefined
|
|
68
|
+
/** Word between a slide number and the count ("of"). */
|
|
69
|
+
ofLabel?: string | undefined
|
|
70
|
+
loop?: boolean | undefined
|
|
71
|
+
perView?: '1' | '2' | '3' | undefined
|
|
72
|
+
aspect?: 'wide' | 'standard' | 'square' | undefined
|
|
73
|
+
caption?: 'below' | 'overlay' | undefined
|
|
74
|
+
/** The source section's measured style (`MeasuredStyle`): columns per width become the slides in view, gap their spacing. */
|
|
75
|
+
measured?: MeasuredStyle | undefined
|
|
76
|
+
/** Names the track for the buttons' `aria-controls`; unique on the page. */
|
|
77
|
+
id?: string | undefined
|
|
78
|
+
/** Draw the section's own frame and spacing (when Slider.astro has no Section around it). */
|
|
79
|
+
framed?: boolean | undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { slides: given, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', loop = false, perView = '1', aspect, caption, heading, intro, eyebrow, actions, measured, id = 'slider', framed = true } = Astro.props
|
|
83
|
+
const slides = given.flatMap((slide) => {
|
|
84
|
+
const image = imageOf(slide.image, slide.imageAlt)
|
|
85
|
+
const action = slide.action ?? (slide.ctaLabel && slide.ctaHref ? { label: slide.ctaLabel, href: slide.ctaHref } : undefined)
|
|
86
|
+
return image ? [{ ...slide, image, action }] : []
|
|
87
|
+
}) satisfies Array<{ image: ImageInput, action?: ActionInput | undefined }>
|
|
88
|
+
const measuredRow = measuredSlides(measured)
|
|
89
|
+
// A measured row sets the slides in view itself, so the variant's own widths step aside.
|
|
90
|
+
const slots = slider({ perView: measuredRow ? '1' : perView, aspect, caption })
|
|
91
|
+
const trackId = `${id}-track`
|
|
92
|
+
const sizes = measuredRow ? '(min-width: 1216px) 1152px, 100vw' : perView === '1' ? '(min-width: 1216px) 1152px, 100vw' : perView === '2' ? '(min-width: 640px) 50vw, 85vw' : '(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 85vw'
|
|
93
|
+
// Arrows from sm up; on phones the carousel is swiped and the dots show the position.
|
|
94
|
+
const Root = framed ? 'section' : 'div'
|
|
95
|
+
const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-center justify-center rounded-full bg-surface/90 text-ink shadow-md ring-1 ring-line hover:bg-surface disabled:opacity-40 sm:group-data-[ready]:flex'
|
|
96
|
+
---
|
|
97
|
+
<Root class:list={['group relative', framed && 'container-page py-8']} role={framed ? undefined : 'region'} aria-roledescription="carousel" aria-label={label} data-kit-slider data-loop={loop ? '' : undefined} data-slide-label={slideLabel}>
|
|
98
|
+
<SectionHeader heading={heading} intro={intro} eyebrow={eyebrow} actions={actions} />
|
|
99
|
+
<div class={slots.viewport()} data-viewport tabindex="0" role="group" aria-label={label}>
|
|
100
|
+
<div class:list={['flex gap-4 [&>*]:shrink-0 group-data-[ready]:[&>*]:snap-align-none', measuredRow?.track]} id={trackId} data-container>
|
|
101
|
+
{slides.map((slide, index) => (
|
|
102
|
+
<div class={slots.slide({ class: measuredRow?.slide })} role="group" aria-roledescription="slide" aria-label={`${slideLabel} ${index + 1} ${ofLabel} ${slides.length}`}>
|
|
103
|
+
<KitImage image={slide.image} sizes={sizes} priority={index === 0} class={slots.image()} />
|
|
104
|
+
{(slide.title || slide.text || slide.action) && (
|
|
105
|
+
<div class={slots.caption()}>
|
|
106
|
+
{slide.title && <p class="text-xl [font-weight:var(--font-weight-heading,600)] text-balance">{slide.title}</p>}
|
|
107
|
+
{slide.text && <p class="opacity-85">{slide.text}</p>}
|
|
108
|
+
{slide.action && <Button action={slide.action} size="sm" class="mt-2" />}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
{slides.length > 1 && (
|
|
116
|
+
<>
|
|
117
|
+
<button type="button" class:list={[arrow, 'start-6 md:start-10']} aria-label={prevLabel} aria-controls={trackId} data-prev>
|
|
118
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" class="size-5 rtl:-scale-x-100" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6" /></svg>
|
|
119
|
+
</button>
|
|
120
|
+
<button type="button" class:list={[arrow, 'end-6 md:end-10']} aria-label={nextLabel} aria-controls={trackId} data-next>
|
|
121
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" class="size-5 rtl:-scale-x-100" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" /></svg>
|
|
122
|
+
</button>
|
|
123
|
+
<div class="mt-4 hidden justify-center group-data-[ready]:flex" data-dots></div>
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
</Root>
|
|
127
|
+
|
|
128
|
+
<script>
|
|
129
|
+
import EmblaCarousel from 'embla-carousel'
|
|
130
|
+
|
|
131
|
+
for (const root of document.querySelectorAll<HTMLElement>('[data-kit-slider]')) {
|
|
132
|
+
const viewport = root.querySelector<HTMLElement>('[data-viewport]')
|
|
133
|
+
if (!viewport || root.dataset.ready !== undefined) continue
|
|
134
|
+
root.dataset.ready = ''
|
|
135
|
+
// Reduced motion: slides change instantly instead of gliding.
|
|
136
|
+
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
137
|
+
// A right-to-left page runs the row from the right: the first slide at the start edge, "next" towards the end.
|
|
138
|
+
const direction = getComputedStyle(root).direction === 'rtl' ? 'rtl' : 'ltr'
|
|
139
|
+
const embla = EmblaCarousel(viewport, { loop: root.dataset.loop !== undefined, align: 'start', containScroll: 'trimSnaps', duration: reduce ? 0 : 25, direction })
|
|
140
|
+
const prev = root.querySelector<HTMLButtonElement>('[data-prev]')
|
|
141
|
+
const next = root.querySelector<HTMLButtonElement>('[data-next]')
|
|
142
|
+
const dots = root.querySelector<HTMLElement>('[data-dots]')
|
|
143
|
+
prev?.addEventListener('click', () => embla.scrollPrev())
|
|
144
|
+
next?.addEventListener('click', () => embla.scrollNext())
|
|
145
|
+
// The row takes focus (the buttons are hidden on phones): the arrow keys page it, and a slide change is announced.
|
|
146
|
+
viewport.addEventListener('keydown', (event) => {
|
|
147
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
148
|
+
event.preventDefault()
|
|
149
|
+
if ((event.key === 'ArrowRight') === (direction === 'ltr')) embla.scrollNext()
|
|
150
|
+
else embla.scrollPrev()
|
|
151
|
+
})
|
|
152
|
+
// No autoplay: the carousel only moves when asked, so a polite announcement of the slide is right.
|
|
153
|
+
root.querySelector('[data-container]')?.setAttribute('aria-live', 'polite')
|
|
154
|
+
|
|
155
|
+
const label = root.dataset.slideLabel ?? 'Slide'
|
|
156
|
+
const buttons = embla.scrollSnapList().map((_, index) => {
|
|
157
|
+
const dot = document.createElement('button')
|
|
158
|
+
dot.type = 'button'
|
|
159
|
+
// A 24px target around a 10px dot (WCAG 2.5.8): on phones the dots are the only controls.
|
|
160
|
+
dot.className = 'group/dot grid size-6 place-items-center rounded-full'
|
|
161
|
+
const mark = document.createElement('span')
|
|
162
|
+
mark.className = 'size-2.5 rounded-full bg-ink/25 group-aria-[current=true]/dot:bg-ink'
|
|
163
|
+
dot.append(mark)
|
|
164
|
+
dot.setAttribute('aria-label', `${label} ${index + 1}`)
|
|
165
|
+
dot.addEventListener('click', () => embla.scrollTo(index))
|
|
166
|
+
dots?.append(dot)
|
|
167
|
+
return dot
|
|
168
|
+
})
|
|
169
|
+
const sync = () => {
|
|
170
|
+
const selected = embla.selectedScrollSnap()
|
|
171
|
+
buttons.forEach((dot, index) => dot.setAttribute('aria-current', String(index === selected)))
|
|
172
|
+
if (prev) prev.disabled = !embla.canScrollPrev()
|
|
173
|
+
if (next) next.disabled = !embla.canScrollNext()
|
|
174
|
+
}
|
|
175
|
+
embla.on('select', sync).on('reInit', sync)
|
|
176
|
+
sync()
|
|
177
|
+
}
|
|
178
|
+
</script>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
[
|
|
2
2
|
{ "name": "single-wide-overlay", "props": { "label": "Featured work", "slides": [{ "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "Blue gradient", "width": 1200, "height": 800 }, "title": "Northwind Journal", "text": "A WordPress blog moved to Astro in an afternoon.", "action": { "label": "Read the case", "href": "/work/northwind/" } }, { "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "Green gradient", "width": 1200, "height": 800 }, "title": "Harbor & Co" }, { "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "Orange gradient", "width": 1200, "height": 800 }, "title": "Field Notes" }] } },
|
|
3
3
|
{ "name": "two-standard-below", "props": { "label": "Gallery", "perView": "2", "aspect": "standard", "caption": "below", "loop": true, "slides": [{ "image": { "src": "/kit-fixtures/photo-4.svg", "alt": "", "width": 1200, "height": 800 }, "title": "Studio" }, { "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "", "width": 1200, "height": 800 }, "title": "Workshop" }, { "image": { "src": "/kit-fixtures/photo-6.svg", "alt": "", "width": 1200, "height": 800 }, "title": "Garden" }] } },
|
|
4
|
-
{ "name": "three-square", "props": { "label": "Team", "perView": "3", "aspect": "square", "slides": [{ "image": { "src": "/kit-fixtures/avatar-1.svg", "alt": "Mira", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/avatar-2.svg", "alt": "Jon", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/avatar-3.svg", "alt": "Lea", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "Studio", "width": 1200, "height": 800 } }] } }
|
|
4
|
+
{ "name": "three-square", "props": { "label": "Team", "perView": "3", "aspect": "square", "slides": [{ "image": { "src": "/kit-fixtures/avatar-1.svg", "alt": "Mira", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/avatar-2.svg", "alt": "Jon", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/avatar-3.svg", "alt": "Lea", "width": 96, "height": 96 } }, { "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "Studio", "width": 1200, "height": 800 } }] } },
|
|
5
|
+
{ "name": "five-measured", "props": { "label": "Five slides", "id": "five", "eyebrow": "Recent work", "heading": "Five projects", "intro": "A few of the sites we moved this year.", "actions": [{ "label": "See all work", "href": "/work/" }], "slides": [{ "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "View 1", "width": 1200, "height": 800 }, "title": "Slide 1 title" }, { "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "View 2", "width": 1200, "height": 800 }, "title": "Slide 2 title" }, { "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "View 3", "width": 1200, "height": 800 }, "title": "Slide 3 title" }, { "image": { "src": "/kit-fixtures/photo-4.svg", "alt": "View 4", "width": 1200, "height": 800 }, "title": "Slide 4 title" }, { "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "View 5", "width": 1200, "height": 800 }, "title": "Slide 5 title" }], "measured": { "padY": 24, "columns": { "390": 1, "768": 2, "1280": 3 }, "gap": 20 } } }
|
|
5
6
|
]
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"description": "A carousel of images with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
|
|
5
5
|
"category": "section",
|
|
6
6
|
"uses": [],
|
|
7
|
-
"shared": ["Button.astro", "KitImage.astro", "image.ts", "types.ts"],
|
|
7
|
+
"shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "image.ts", "measured.ts", "safe-url.ts", "types.ts"],
|
|
8
8
|
"variants": {
|
|
9
9
|
"perView": { "options": ["1", "2", "3"], "default": "1", "description": "Slides visible at once on wide screens." },
|
|
10
10
|
"aspect": { "options": ["wide", "standard", "square"], "default": "wide", "description": "Image aspect ratio: 21:9, 3:2 or 1:1." },
|
|
@@ -17,16 +17,22 @@
|
|
|
17
17
|
"nextLabel": { "type": "string", "content": false, "description": "ui-strings slider.next." },
|
|
18
18
|
"slideLabel": { "type": "string", "content": false, "description": "ui-strings slider.slide." },
|
|
19
19
|
"ofLabel": { "type": "string", "content": false, "description": "ui-strings slider.of." },
|
|
20
|
-
"loop": { "type": "boolean", "default": false, "content": false, "description": "Wrap from the last slide to the first." }
|
|
20
|
+
"loop": { "type": "boolean", "default": false, "content": false, "description": "Wrap from the last slide to the first." },
|
|
21
|
+
"id": { "type": "string", "default": "slider", "content": false, "description": "Unique on the page: names the track that the previous/next buttons control (aria-controls)." },
|
|
22
|
+
"heading": {"type": "string", "label": "Heading", "description": "The section heading."},
|
|
23
|
+
"intro": {"type": "text", "label": "Introduction", "description": "A sentence under the heading."},
|
|
24
|
+
"eyebrow": {"type": "string", "label": "Eyebrow", "description": "A short line above the heading."},
|
|
25
|
+
"actions": {"type": "array", "label": "Buttons", "required": false, "items": {"type": "object", "fields": {"label": {"type": "string", "label": "Label", "required": true, "description": "The button text."}, "href": {"type": "url", "label": "Link", "required": true, "description": "Where the button goes: a page on this site or a full address."}, "newTab": {"type": "boolean", "label": "Open in a new tab", "description": "Open the link in a new tab (an external site the reader should not leave for)."}, "style": {"type": "select", "label": "Style", "options": ["primary", "secondary", "ghost"], "description": "primary for the main action, secondary or ghost for the ones beside it."}}}, "description": "Buttons under the intro, in order; none by default."},
|
|
26
|
+
"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": "Slides in view at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one. Wins over perView.", "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 slides."}, "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."}}}
|
|
21
27
|
},
|
|
22
28
|
"slots": {},
|
|
23
29
|
"js": "embla",
|
|
24
30
|
"dependencies": { "embla-carousel": "^8.6.0" },
|
|
25
31
|
"sources": {
|
|
26
32
|
"gutenberg": ["core/gallery:carousel"],
|
|
27
|
-
"elementor": ["elementor/image-carousel", "elementor/slides", "elementor/media-carousel"],
|
|
33
|
+
"elementor": ["elementor/image-carousel", "elementor/slides", "elementor/media-carousel", "elementor/nested-carousel", "elementor/loop-carousel"],
|
|
28
34
|
"divi": ["divi/et_pb_slider", "divi/et_pb_fullwidth_slider", "divi/et_pb_post_slider"],
|
|
29
|
-
"classic": ["classic/revslider", "classic/metaslider", "classic/smart-slider"]
|
|
35
|
+
"classic": ["classic/revslider", "classic/metaslider", "classic/smart-slider", "classic/swiper", "classic/slick", "classic/splide", "classic/owl"]
|
|
30
36
|
},
|
|
31
|
-
"a11y": "WAI-ARIA carousel pattern: a labelled region with aria-roledescription=carousel, slides as labelled groups ('Slide 2 of 5'), labelled previous/next buttons disabled at the ends, and dot buttons marking the current slide.
|
|
37
|
+
"a11y": "WAI-ARIA carousel pattern: a labelled region with aria-roledescription=carousel, slides as labelled groups ('Slide 2 of 5'), labelled previous/next buttons disabled at the ends, and dot buttons marking the current slide. The track and the buttons are tied by aria-controls, the row takes focus (arrow keys page it, polite live announcement). There is no autoplay, so nothing moves unasked; with prefers-reduced-motion slides change without animation. Without JavaScript every slide is reachable by scrolling."
|
|
32
38
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Links to the site's profiles on other networks: a row of marks, names, or both. The network is read from each
|
|
3
|
+
// address's host (`linkedin.com` → LinkedIn), so a link carries only its address; an address of no known network
|
|
4
|
+
// is shown as its host. Stands alone (header, top bar, contact, author) or beside the footer's own row.
|
|
5
|
+
import { tv } from 'tailwind-variants'
|
|
6
|
+
import { addressLabel, NETWORK_MARKS, networkOf } from '../_shared/social'
|
|
7
|
+
import { safeHref } from '../_shared/safe-url'
|
|
8
|
+
|
|
9
|
+
const social = tv({
|
|
10
|
+
slots: {
|
|
11
|
+
list: 'flex flex-wrap items-center',
|
|
12
|
+
link: 'inline-flex items-center justify-center gap-2 text-current no-underline opacity-85 hover:opacity-100 focus-visible:opacity-100',
|
|
13
|
+
mark: 'shrink-0',
|
|
14
|
+
},
|
|
15
|
+
variants: {
|
|
16
|
+
display: { icons: {}, text: {}, both: {} },
|
|
17
|
+
size: {
|
|
18
|
+
sm: { list: 'gap-2', link: 'min-h-8 min-w-8 text-sm', mark: 'size-4' },
|
|
19
|
+
md: { list: 'gap-3', link: 'min-h-10 min-w-10 text-base', mark: 'size-5' },
|
|
20
|
+
lg: { list: 'gap-4', link: 'min-h-12 min-w-12 text-lg', mark: 'size-6' },
|
|
21
|
+
},
|
|
22
|
+
align: { start: { list: 'justify-start' }, center: { list: 'justify-center' }, end: { list: 'justify-end' } },
|
|
23
|
+
shape: { plain: {}, circle: { link: 'rounded-full border border-line' } },
|
|
24
|
+
},
|
|
25
|
+
// A bordered link that shows a name needs room around it; a bare mark stays a circle.
|
|
26
|
+
compoundVariants: [
|
|
27
|
+
{ shape: 'circle', display: ['text', 'both'], class: { link: 'px-4' } },
|
|
28
|
+
],
|
|
29
|
+
defaultVariants: { display: 'icons', size: 'md', align: 'start', shape: 'plain' },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
/** The profiles, by address; a `label` names a link the host does not (a personal site, "Newsletter"). */
|
|
34
|
+
links: Array<{ href: string, label?: string | undefined }>
|
|
35
|
+
/** Whose profiles these are: the site or person's name, for "{name} on {network}". */
|
|
36
|
+
name?: string | undefined
|
|
37
|
+
/** Accessible name of the list ("Social media"). */
|
|
38
|
+
label?: string | undefined
|
|
39
|
+
/** How a link announces itself; `{name}` and `{network}` are replaced ("{name} on {network}"). Without a `name`, the network alone. */
|
|
40
|
+
profileLabel?: string | undefined
|
|
41
|
+
display?: 'icons' | 'text' | 'both' | undefined
|
|
42
|
+
size?: 'sm' | 'md' | 'lg' | undefined
|
|
43
|
+
align?: 'start' | 'center' | 'end' | undefined
|
|
44
|
+
shape?: 'plain' | 'circle' | undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { links, name, label = 'Social media', profileLabel = '{name} on {network}', display, size, align, shape } = Astro.props
|
|
48
|
+
const s = social({ display, size, align, shape })
|
|
49
|
+
const entries = links.map(link => {
|
|
50
|
+
const network = networkOf(link.href)
|
|
51
|
+
const shown = link.label ?? network?.label ?? addressLabel(link.href)
|
|
52
|
+
const mark = network ? NETWORK_MARKS[network.key] : undefined
|
|
53
|
+
// The accessible name always contains the visible text; a mark alone is named by the network.
|
|
54
|
+
const accessible = name ? profileLabel.replace('{name}', name).replace('{network}', shown) : shown
|
|
55
|
+
return { href: link.href, shown, mark, accessible }
|
|
56
|
+
})
|
|
57
|
+
---
|
|
58
|
+
{entries.length > 0 && (
|
|
59
|
+
<ul class={s.list()} aria-label={label} data-cr-part="social-links">
|
|
60
|
+
{entries.map(entry => {
|
|
61
|
+
// No mark (an unknown network), or the owner asked for names: the text is shown, never an empty link.
|
|
62
|
+
const text = display === 'text' || display === 'both' || !entry.mark
|
|
63
|
+
const mark = display !== 'text' && entry.mark
|
|
64
|
+
return (
|
|
65
|
+
<li>
|
|
66
|
+
<a href={safeHref(entry.href)} rel="me noopener" target="_blank" aria-label={entry.accessible} class={s.link()}>
|
|
67
|
+
{mark && <svg class={s.mark()} viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d={entry.mark} /></svg>}
|
|
68
|
+
{text && <span>{entry.shown}</span>}
|
|
69
|
+
</a>
|
|
70
|
+
</li>
|
|
71
|
+
)
|
|
72
|
+
})}
|
|
73
|
+
</ul>
|
|
74
|
+
)}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "icons",
|
|
4
|
+
"props": {
|
|
5
|
+
"name": "Northlight Studio",
|
|
6
|
+
"links": [
|
|
7
|
+
{
|
|
8
|
+
"href": "https://www.linkedin.com/company/northlight"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"href": "https://github.com/northlight"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"href": "https://x.com/northlight"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"href": "https://www.instagram.com/northlight/"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"href": "https://www.youtube.com/@northlight"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "text-small-end",
|
|
27
|
+
"props": {
|
|
28
|
+
"display": "text",
|
|
29
|
+
"size": "sm",
|
|
30
|
+
"align": "end",
|
|
31
|
+
"links": [
|
|
32
|
+
{
|
|
33
|
+
"href": "https://www.facebook.com/northlight"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"href": "https://northlight.example/newsletter",
|
|
37
|
+
"label": "Newsletter"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"href": "mailto:hello@northlight.example",
|
|
41
|
+
"label": "Email"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "both-large-centered-circle",
|
|
48
|
+
"props": {
|
|
49
|
+
"display": "both",
|
|
50
|
+
"size": "lg",
|
|
51
|
+
"align": "center",
|
|
52
|
+
"shape": "circle",
|
|
53
|
+
"name": "Ada Reyes",
|
|
54
|
+
"links": [
|
|
55
|
+
{
|
|
56
|
+
"href": "https://dribbble.com/ada"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"href": "https://www.behance.net/ada"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"href": "https://ada.example/about"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "social-links",
|
|
3
|
+
"name": "SocialLinks",
|
|
4
|
+
"description": "Links to the site's profiles on other networks as marks, names or both. The network is read from each address's host (the table Team uses), so a link carries only its address; an unknown host is shown as itself. Stands alone in a header, top bar, contact section or author box.",
|
|
5
|
+
"category": "chrome",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": [
|
|
8
|
+
"social.ts",
|
|
9
|
+
"safe-url.ts"
|
|
10
|
+
],
|
|
11
|
+
"variants": {
|
|
12
|
+
"display": {
|
|
13
|
+
"options": [
|
|
14
|
+
"icons",
|
|
15
|
+
"text",
|
|
16
|
+
"both"
|
|
17
|
+
],
|
|
18
|
+
"default": "icons",
|
|
19
|
+
"description": "Mark only (named for assistive technology), the network's name, or both."
|
|
20
|
+
},
|
|
21
|
+
"size": {
|
|
22
|
+
"options": [
|
|
23
|
+
"sm",
|
|
24
|
+
"md",
|
|
25
|
+
"lg"
|
|
26
|
+
],
|
|
27
|
+
"default": "md",
|
|
28
|
+
"description": "Mark and touch target size (32, 40 and 48 px minimum)."
|
|
29
|
+
},
|
|
30
|
+
"align": {
|
|
31
|
+
"options": [
|
|
32
|
+
"start",
|
|
33
|
+
"center",
|
|
34
|
+
"end"
|
|
35
|
+
],
|
|
36
|
+
"default": "start",
|
|
37
|
+
"description": "Where the row sits in its container."
|
|
38
|
+
},
|
|
39
|
+
"shape": {
|
|
40
|
+
"options": [
|
|
41
|
+
"plain",
|
|
42
|
+
"circle"
|
|
43
|
+
],
|
|
44
|
+
"default": "plain",
|
|
45
|
+
"description": "Bare marks, or each in a ringed circle."
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"props": {
|
|
49
|
+
"links": {
|
|
50
|
+
"type": "array",
|
|
51
|
+
"required": true,
|
|
52
|
+
"items": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"fields": {
|
|
55
|
+
"href": {
|
|
56
|
+
"type": "url",
|
|
57
|
+
"required": true
|
|
58
|
+
},
|
|
59
|
+
"label": {
|
|
60
|
+
"type": "string"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"description": "The profiles; `label` only for a link whose host names no network (a personal site, \"Newsletter\")."
|
|
65
|
+
},
|
|
66
|
+
"name": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "Whose profiles these are, for \"{name} on {network}\"."
|
|
69
|
+
},
|
|
70
|
+
"label": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "Accessible name of the list (ui-strings social.label)."
|
|
73
|
+
},
|
|
74
|
+
"profileLabel": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "How each link announces itself; {name} and {network} are replaced (ui-strings social.profile)."
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"slots": {},
|
|
80
|
+
"js": "none",
|
|
81
|
+
"dependencies": {},
|
|
82
|
+
"sources": {
|
|
83
|
+
"gutenberg": [
|
|
84
|
+
"core/social-links"
|
|
85
|
+
],
|
|
86
|
+
"elementor": [
|
|
87
|
+
"elementor/social-icons"
|
|
88
|
+
],
|
|
89
|
+
"divi": [
|
|
90
|
+
"divi/et_pb_social_media_follow"
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
"a11y": "A labelled list of links; each link's accessible name is \"{name} on {network}\" and always contains the visible text; marks are aria-hidden; links open in a new tab with rel=me noopener; every target is at least 32 px."
|
|
94
|
+
}
|
|
@@ -5,59 +5,121 @@
|
|
|
5
5
|
import { tv } from 'tailwind-variants'
|
|
6
6
|
import { renderMarkdown } from '../../../lib/markdown'
|
|
7
7
|
import Button from '../_shared/Button.astro'
|
|
8
|
+
import Embed from '../embed/Embed.astro'
|
|
9
|
+
import FeatureList from '../feature-list/FeatureList.astro'
|
|
8
10
|
import KitImage from '../_shared/KitImage.astro'
|
|
9
11
|
import Section from '../_shared/Section.astro'
|
|
10
|
-
import type { ActionInput, ImageInput } from '../_shared/types'
|
|
12
|
+
import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
|
|
11
13
|
|
|
12
14
|
const split = tv({
|
|
13
15
|
slots: {
|
|
14
|
-
grid: 'grid items-center gap-10 md:
|
|
16
|
+
grid: 'grid items-center gap-10 md:gap-16',
|
|
15
17
|
copy: 'flex flex-col gap-5',
|
|
16
18
|
eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
|
|
17
19
|
heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
|
|
18
20
|
body: 'prose prose-site max-w-none text-pretty',
|
|
19
21
|
actions: 'flex flex-wrap gap-3 pt-2',
|
|
20
|
-
media: 'overflow-hidden
|
|
22
|
+
media: 'overflow-hidden',
|
|
23
|
+
image: 'h-auto w-full object-cover',
|
|
21
24
|
},
|
|
22
25
|
variants: {
|
|
23
26
|
// Source order is text first; `start` moves the image before it on wide screens only.
|
|
24
27
|
imageSide: { start: { media: 'md:order-first' }, end: {} },
|
|
28
|
+
// The text column's share against the image's (the columns follow the sides, see `columns` below).
|
|
29
|
+
ratio: { '1:1': {}, '5:7': {}, '7:5': {} },
|
|
30
|
+
// contain: the image at its own proportions. cover: it fills the column's height.
|
|
31
|
+
imageFit: { contain: {}, cover: { grid: 'items-stretch', media: 'min-h-72 md:h-full', image: 'h-full min-h-72' } },
|
|
32
|
+
imageShape: {
|
|
33
|
+
none: { media: 'rounded-none' },
|
|
34
|
+
rounded: { media: 'rounded-[var(--radius-image,var(--radius-card))]' },
|
|
35
|
+
circle: { media: 'mx-auto aspect-square w-full max-w-lg rounded-full', image: 'h-full' },
|
|
36
|
+
},
|
|
37
|
+
// edge: the image runs on to the viewport's edge on its side (wide screens).
|
|
38
|
+
imageBleed: { none: {}, edge: { media: 'rounded-none' } },
|
|
39
|
+
// Phones show the text first, as the source does, or the picture first when it is the content itself.
|
|
40
|
+
stackOrder: { 'text-first': {}, 'media-first': { media: 'max-md:order-first' } },
|
|
41
|
+
// The media column follows the reader down a long text (wide screens).
|
|
42
|
+
sticky: { true: { media: 'md:sticky md:top-8 md:self-start' }, false: {} },
|
|
25
43
|
align: { start: {}, center: { copy: 'items-center text-center', actions: 'justify-center' } },
|
|
26
44
|
// accent/accent-ink is only ~5:1 — dimming text on it drops body copy under 4.5:1 (WCAG 1.4.3), so accent keeps it at full strength.
|
|
27
|
-
tone: { default: {}, muted: {}, inverse: {}, accent: { eyebrow: 'opacity-100' } },
|
|
45
|
+
tone: { default: {}, muted: {}, inverse: {}, accent: { eyebrow: 'opacity-100' }, brand: { eyebrow: 'opacity-100' }, none: {} },
|
|
28
46
|
},
|
|
29
|
-
defaultVariants: { imageSide: 'end', align: 'start', tone: 'default' },
|
|
47
|
+
defaultVariants: { imageSide: 'end', ratio: '1:1', imageFit: 'contain', imageShape: 'rounded', imageBleed: 'none', stackOrder: 'text-first', sticky: false, align: 'start', tone: 'default' },
|
|
30
48
|
})
|
|
31
49
|
|
|
50
|
+
// Literal class names, so the stylesheet builder sees them: the text column's share first, the image's second; a
|
|
51
|
+
// picture before the text on wide screens swaps the two columns.
|
|
52
|
+
const COLUMNS = {
|
|
53
|
+
text: { '1:1': 'md:grid-cols-2', '5:7': 'md:grid-cols-[5fr_7fr]', '7:5': 'md:grid-cols-[7fr_5fr]' },
|
|
54
|
+
image: { '1:1': 'md:grid-cols-2', '5:7': 'md:grid-cols-[7fr_5fr]', '7:5': 'md:grid-cols-[5fr_7fr]' },
|
|
55
|
+
} as const
|
|
56
|
+
// The viewport's edge, from the content's edge: what is left of the page's width, halved.
|
|
57
|
+
const BLEED = {
|
|
58
|
+
end: 'md:-me-[calc((100vw-min(var(--container-page),100vw-2*var(--spacing-gutter,2rem)))/2)]',
|
|
59
|
+
start: 'md:-ms-[calc((100vw-min(var(--container-page),100vw-2*var(--spacing-gutter,2rem)))/2)]',
|
|
60
|
+
} as const
|
|
61
|
+
|
|
62
|
+
interface Feature {
|
|
63
|
+
title: string
|
|
64
|
+
text?: string | undefined
|
|
65
|
+
/** A kit icon name (check, star, bolt, shield, heart, globe, clock, chat). */
|
|
66
|
+
icon?: string | undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
32
69
|
interface Props {
|
|
33
70
|
heading: string
|
|
71
|
+
/** The heading's level: 2 under the page title; 3 or 4 when the section sits inside another. */
|
|
72
|
+
headingLevel?: 2 | 3 | 4 | undefined
|
|
34
73
|
eyebrow?: string | undefined
|
|
35
74
|
/** Markdown, rendered at build time. */
|
|
36
75
|
body?: string | undefined
|
|
37
76
|
actions?: ActionInput[] | undefined
|
|
38
77
|
image?: ImageInput | undefined
|
|
78
|
+
/** Short points under the text, each with a title and a line. */
|
|
79
|
+
features?: Feature[] | undefined
|
|
80
|
+
/** A video page (YouTube, Vimeo); the image is its poster and nothing loads from the provider until it is played. */
|
|
81
|
+
videoUrl?: string | undefined
|
|
82
|
+
/** The video's text for the play button. */
|
|
83
|
+
playLabel?: string | undefined
|
|
39
84
|
imageSide?: 'start' | 'end' | undefined
|
|
85
|
+
ratio?: '1:1' | '5:7' | '7:5' | undefined
|
|
86
|
+
imageFit?: 'contain' | 'cover' | undefined
|
|
87
|
+
imageShape?: 'none' | 'rounded' | 'circle' | undefined
|
|
88
|
+
imageBleed?: 'none' | 'edge' | undefined
|
|
89
|
+
mediaKind?: 'image' | 'video' | undefined
|
|
90
|
+
featureStyle?: 'check' | 'icon' | undefined
|
|
91
|
+
stackOrder?: 'text-first' | 'media-first' | undefined
|
|
92
|
+
sticky?: boolean | undefined
|
|
40
93
|
align?: 'start' | 'center' | undefined
|
|
41
|
-
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
94
|
+
tone?: 'default' | 'muted' | 'inverse' | 'accent' | 'brand' | 'none' | undefined
|
|
95
|
+
/** 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. */
|
|
96
|
+
measured?: MeasuredStyle | undefined
|
|
42
97
|
id?: string | undefined
|
|
43
98
|
}
|
|
44
99
|
|
|
45
|
-
const { heading, eyebrow, body, actions = [], image, imageSide, align, tone, id } = Astro.props
|
|
46
|
-
const s = split({ imageSide, align, tone })
|
|
100
|
+
const { heading, headingLevel = 2, eyebrow, body, actions = [], image, features = [], videoUrl, playLabel, imageSide, ratio = '1:1', imageFit, imageShape, imageBleed, mediaKind, featureStyle = 'check', stackOrder, sticky = false, align, tone, measured, id } = Astro.props
|
|
101
|
+
const s = split({ imageSide, ratio, imageFit, imageShape, imageBleed, stackOrder, sticky, align, tone })
|
|
102
|
+
const Heading = `h${headingLevel}` as 'h2'
|
|
103
|
+
const columns = COLUMNS[imageSide === 'start' ? 'image' : 'text'][ratio]
|
|
104
|
+
const bleed = imageBleed === 'edge' ? BLEED[imageSide === 'start' ? 'start' : 'end'] : undefined
|
|
105
|
+
const video = mediaKind === 'video' && videoUrl
|
|
47
106
|
const headingId = id ? `${id}-title` : undefined
|
|
48
107
|
const html = await renderMarkdown(body)
|
|
49
108
|
---
|
|
50
|
-
<Section tone={tone} id={id} aria-labelledby={headingId}>
|
|
51
|
-
<div class={s.grid()}>
|
|
109
|
+
<Section measured={measured} tone={tone} id={id} aria-labelledby={headingId} class={bleed ? 'overflow-x-clip' : undefined}>
|
|
110
|
+
<div class:list={[s.grid(), columns]}>
|
|
52
111
|
<div class={s.copy()}>
|
|
53
112
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|
|
54
|
-
<
|
|
113
|
+
<Heading id={headingId} class={s.heading()}>{heading}</Heading>
|
|
55
114
|
{html && <div class={s.body()} data-kit-text set:html={html} />}
|
|
115
|
+
{features.length > 0 && <FeatureList items={features} columns="2" style={featureStyle} tone={tone === 'brand' || tone === 'none' ? undefined : tone} bare titleLevel={Math.min(5, headingLevel + 1) as 3 | 4 | 5} />}
|
|
56
116
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
|
|
57
117
|
</div>
|
|
58
|
-
{image && (
|
|
59
|
-
<div class={s.media()}>
|
|
60
|
-
|
|
118
|
+
{(video || image) && (
|
|
119
|
+
<div class:list={[s.media(), bleed]}>
|
|
120
|
+
{video
|
|
121
|
+
? <div class="[&>figure]:my-0 [&>figure]:max-w-none"><Embed url={videoUrl} title={heading} poster={image} playLabel={playLabel} aspect="video" width="page" /></div>
|
|
122
|
+
: image && <KitImage image={image} sizes="(min-width: 768px) 50vw, 100vw" class={s.image()} />}
|
|
61
123
|
</div>
|
|
62
124
|
)}
|
|
63
125
|
</div>
|