@contentrain/astro-kit 0.39.0 → 0.41.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/README.md +18 -2
- package/catalog.json +99 -18
- package/components/_shared/Section.astro +13 -9
- package/components/_shared/SectionHeader.astro +3 -2
- package/components/_shared/behaviors.ts +38 -2
- package/components/_shared/embed-frame.ts +22 -0
- package/components/{embed/embed.ts → _shared/embed-target.ts} +30 -0
- package/components/_shared/instance-id.ts +24 -0
- package/components/_shared/muted.ts +19 -0
- package/components/band/Band.astro +10 -7
- package/components/band/meta.json +2 -0
- package/components/behavior/Behavior.astro +15 -2
- package/components/behavior/fixtures.json +5 -3
- package/components/behavior/meta.json +9 -0
- package/components/card-grid/CardGrid.astro +13 -9
- package/components/card-grid/meta.json +7 -0
- package/components/contact-details/ContactDetails.astro +7 -3
- package/components/contact-details/meta.json +2 -0
- package/components/contact-form/ContactForm.astro +11 -7
- package/components/contact-form/meta.json +2 -0
- package/components/cta/Cta.astro +14 -10
- package/components/cta/meta.json +2 -2
- package/components/dialog/Dialog.astro +6 -3
- package/components/dialog/meta.json +2 -0
- package/components/embed/Embed.astro +7 -10
- package/components/embed/meta.json +1 -1
- package/components/faq/Faq.astro +8 -4
- package/components/faq/meta.json +1 -1
- package/components/feature-list/FeatureList.astro +10 -6
- package/components/feature-list/meta.json +3 -1
- package/components/footer/Footer.astro +4 -3
- package/components/footer/meta.json +1 -0
- package/components/gallery/Gallery.astro +8 -4
- package/components/gallery/meta.json +2 -0
- package/components/header/Header.astro +8 -4
- package/components/header/meta.json +1 -1
- package/components/hero/Hero.astro +13 -9
- package/components/hero/meta.json +3 -1
- package/components/language-switcher/LanguageSwitcher.astro +3 -2
- package/components/language-switcher/meta.json +1 -1
- package/components/logo-cloud/LogoCloud.astro +9 -5
- package/components/logo-cloud/meta.json +3 -1
- package/components/nav/Nav.astro +10 -6
- package/components/nav/meta.json +2 -0
- package/components/newsletter/Newsletter.astro +9 -5
- package/components/newsletter/meta.json +2 -0
- package/components/post-list/PostList.astro +7 -3
- package/components/post-list/meta.json +2 -0
- package/components/pricing/Pricing.astro +15 -11
- package/components/pricing/meta.json +3 -1
- package/components/search-box/SearchBox.astro +4 -1
- package/components/search-box/meta.json +1 -1
- package/components/slider/Slider.astro +9 -2
- package/components/slider/SliderTrack.astro +71 -15
- package/components/slider/fixtures.json +2 -1
- package/components/slider/meta.json +5 -4
- package/components/social-links/SocialLinks.astro +2 -1
- package/components/social-links/meta.json +3 -2
- package/components/split/Split.astro +9 -5
- package/components/split/meta.json +3 -1
- package/components/stats/Stats.astro +11 -7
- package/components/stats/meta.json +5 -1
- package/components/steps/Steps.astro +9 -5
- package/components/steps/meta.json +3 -1
- package/components/tabs/Tabs.astro +8 -4
- package/components/tabs/meta.json +1 -1
- package/components/team/Team.astro +10 -6
- package/components/team/meta.json +4 -2
- package/components/testimonial/Testimonial.astro +9 -5
- package/components/testimonial/fixtures.json +1 -1
- package/components/testimonial/meta.json +3 -1
- package/mapping/classic.json +14 -7
- package/mapping/divi.json +6 -3
- package/mapping/elementor.json +14 -7
- package/mapping/gutenberg.json +2 -1
- package/package.json +1 -1
|
@@ -13,19 +13,21 @@ import Section from '../_shared/Section.astro'
|
|
|
13
13
|
import type { MeasuredStyle } from '../_shared/types'
|
|
14
14
|
import { newsletterForm } from './newsletter'
|
|
15
15
|
import { safeUrl } from '../_shared/safe-url'
|
|
16
|
+
import { instanceId } from '../_shared/instance-id'
|
|
17
|
+
import { muted } from '../_shared/muted'
|
|
16
18
|
|
|
17
19
|
const newsletter = tv({
|
|
18
20
|
slots: {
|
|
19
21
|
box: 'flex flex-col gap-6',
|
|
20
22
|
copy: 'flex flex-col gap-3',
|
|
21
23
|
heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
|
|
22
|
-
intro:
|
|
24
|
+
intro: `max-w-xl text-pretty ${muted}`,
|
|
23
25
|
form: 'flex flex-col gap-3',
|
|
24
26
|
field: 'flex min-w-0 flex-col gap-1.5',
|
|
25
27
|
label: 'text-sm font-semibold',
|
|
26
28
|
input: 'min-h-11 w-full rounded-[var(--radius-control,var(--radius-md))] border border-line bg-surface px-3 py-2 text-base text-ink placeholder:text-ink-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
|
27
29
|
submit: 'inline-flex min-h-11 items-center justify-center rounded-[var(--radius-control,var(--radius-md))] px-5 py-2.5 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 bg-accent text-accent-ink hover:bg-ink hover:text-surface',
|
|
28
|
-
note:
|
|
30
|
+
note: `text-sm ${muted}`,
|
|
29
31
|
},
|
|
30
32
|
variants: {
|
|
31
33
|
layout: {
|
|
@@ -37,7 +39,7 @@ const newsletter = tv({
|
|
|
37
39
|
default: {},
|
|
38
40
|
muted: {},
|
|
39
41
|
inverse: { submit: 'hover:bg-surface hover:text-ink' },
|
|
40
|
-
accent: {
|
|
42
|
+
accent: { submit: 'bg-ink text-surface hover:bg-surface hover:text-ink' },
|
|
41
43
|
},
|
|
42
44
|
},
|
|
43
45
|
defaultVariants: { layout: 'inline', tone: 'muted' },
|
|
@@ -73,9 +75,11 @@ interface Props {
|
|
|
73
75
|
const {
|
|
74
76
|
action, provider = 'mailchimp', emailName, askName = false, heading, intro, note,
|
|
75
77
|
emailLabel = 'Email address', nameLabel = 'First name', submitLabel = 'Subscribe', newTab = true,
|
|
76
|
-
layout, tone = 'muted', id
|
|
78
|
+
layout, tone = 'muted', id: givenId,
|
|
77
79
|
measured,
|
|
78
80
|
} = Astro.props
|
|
81
|
+
// Two on one page never share an id: their labels and controls would point into each other.
|
|
82
|
+
const id = instanceId(Astro.locals, givenId, 'newsletter')
|
|
79
83
|
const form = newsletterForm(provider, action, { email: emailName }, Astro.site)
|
|
80
84
|
const s = newsletter({ layout, tone })
|
|
81
85
|
const headingId = heading ? `${id}-title` : undefined
|
|
@@ -83,7 +87,7 @@ const headingId = heading ? `${id}-title` : undefined
|
|
|
83
87
|
const formRel: Record<string, string> = newTab ? { rel: 'noopener noreferrer' } : {}
|
|
84
88
|
---
|
|
85
89
|
{form && (
|
|
86
|
-
<Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
|
|
90
|
+
<Section kit="newsletter" measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
|
|
87
91
|
<div class={s.box()}>
|
|
88
92
|
{(heading || intro) && (
|
|
89
93
|
<div class={s.copy()}>
|
|
@@ -10,6 +10,8 @@ import type { MeasuredStyle } from '../_shared/types'
|
|
|
10
10
|
import PostCard from '../post-card/PostCard.astro'
|
|
11
11
|
import type { ImageInput, LinkInput } from '../_shared/types'
|
|
12
12
|
import { safeHref } from '../_shared/safe-url'
|
|
13
|
+
import { instanceId } from '../_shared/instance-id'
|
|
14
|
+
import { muted } from '../_shared/muted'
|
|
13
15
|
|
|
14
16
|
export interface PostItem {
|
|
15
17
|
title: string
|
|
@@ -25,7 +27,7 @@ const list = tv({
|
|
|
25
27
|
slots: {
|
|
26
28
|
header: 'mb-10 flex max-w-2xl flex-col gap-3',
|
|
27
29
|
heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
|
|
28
|
-
intro:
|
|
30
|
+
intro: `text-lg ${muted}`,
|
|
29
31
|
items: 'grid gap-8',
|
|
30
32
|
more: 'mt-10',
|
|
31
33
|
},
|
|
@@ -74,7 +76,9 @@ interface Props {
|
|
|
74
76
|
id?: string | undefined
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
const { heading, intro, posts, layout = 'grid', columns, showImage = true, showExcerpt = true, showAuthor = true, showCategory = true, excerptLines, locale, timeZone, surface, moreHref, moreLabel, tone, measured, id
|
|
79
|
+
const { heading, intro, posts, layout = 'grid', columns, showImage = true, showExcerpt = true, showAuthor = true, showCategory = true, excerptLines, locale, timeZone, surface, moreHref, moreLabel, tone, measured, id: givenId } = Astro.props
|
|
80
|
+
// Two on one page never share an id: their labels and controls would point into each other.
|
|
81
|
+
const id = instanceId(Astro.locals, givenId, 'posts')
|
|
78
82
|
const s = list({ layout, columns })
|
|
79
83
|
const headingId = heading ? `${id}-title` : undefined
|
|
80
84
|
// Cards sit one heading level under the section's; with no section heading they are the page's h2s.
|
|
@@ -84,7 +88,7 @@ const sizes = layout === 'list' || layout === 'compact' ? '256px' : columns ===
|
|
|
84
88
|
const cardLayout = (i: number) => layout === 'compact' ? 'minimal' : layout === 'list' || (layout === 'featured' && i === 0) ? 'horizontal' : 'vertical'
|
|
85
89
|
---
|
|
86
90
|
{posts.length > 0 && (
|
|
87
|
-
<Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
|
|
91
|
+
<Section kit="post-list" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
|
|
88
92
|
{(heading || intro) && (
|
|
89
93
|
<header class={s.header()}>
|
|
90
94
|
{heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
|
|
@@ -8,15 +8,17 @@
|
|
|
8
8
|
import { tv } from 'tailwind-variants'
|
|
9
9
|
import Button from '../_shared/Button.astro'
|
|
10
10
|
import Section, { type Tone } from '../_shared/Section.astro'
|
|
11
|
+
import { instanceId } from '../_shared/instance-id'
|
|
11
12
|
import { measuredGrid } from '../_shared/measured'
|
|
12
13
|
import type { MeasuredStyle } from '../_shared/types'
|
|
13
14
|
import { renderMarkdown } from '../../../lib/markdown'
|
|
15
|
+
import { muted, mutedReset } from '../_shared/muted'
|
|
14
16
|
|
|
15
17
|
const pricing = tv({
|
|
16
18
|
slots: {
|
|
17
19
|
header: 'mx-auto mb-10 flex max-w-2xl flex-col items-center gap-3 text-center',
|
|
18
20
|
heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
|
|
19
|
-
intro:
|
|
21
|
+
intro: `text-lg ${muted}`,
|
|
20
22
|
list: 'grid items-stretch gap-6 sm:grid-cols-2',
|
|
21
23
|
toggle: 'mx-auto mb-8 flex w-fit rounded-full border border-line p-1',
|
|
22
24
|
toggleOption: 'rounded-full px-4 py-1.5 text-sm font-medium has-[:checked]:bg-accent has-[:checked]:text-accent-ink has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-accent',
|
|
@@ -24,13 +26,13 @@ const pricing = tv({
|
|
|
24
26
|
table: 'w-full min-w-[32rem] border-collapse text-start',
|
|
25
27
|
cell: 'border-b border-line px-4 py-3 text-center align-top',
|
|
26
28
|
rowHead: 'border-b border-line px-4 py-3 text-start font-normal',
|
|
27
|
-
plan:
|
|
29
|
+
plan: `relative flex flex-col gap-5 rounded-card border border-line bg-surface p-6 text-ink md:p-8 ${mutedReset}`,
|
|
28
30
|
badge: 'absolute -top-3 start-6 rounded-full bg-accent px-3 py-1 text-xs font-semibold text-accent-ink',
|
|
29
31
|
name: 'text-lg [font-weight:var(--font-weight-heading,600)]',
|
|
30
32
|
price: 'flex items-baseline gap-1',
|
|
31
33
|
amount: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight',
|
|
32
|
-
period:
|
|
33
|
-
description:
|
|
34
|
+
period: `text-sm ${muted}`,
|
|
35
|
+
description: `text-pretty ${muted}`,
|
|
34
36
|
features: 'prose-site prose max-w-none text-current prose-ul:ps-5 prose-li:my-1',
|
|
35
37
|
action: 'mt-auto pt-2',
|
|
36
38
|
},
|
|
@@ -47,7 +49,7 @@ const pricing = tv({
|
|
|
47
49
|
plain: {},
|
|
48
50
|
},
|
|
49
51
|
compact: { true: { list: 'gap-4', plan: 'gap-3 p-4 md:p-5', amount: 'text-3xl' }, false: {} },
|
|
50
|
-
tone: { default: {}, muted: { plan: 'border-transparent' }, inverse: { plan: 'border-transparent' }, accent: {
|
|
52
|
+
tone: { default: {}, muted: { plan: 'border-transparent' }, inverse: { plan: 'border-transparent' }, accent: { plan: 'border-transparent', badge: 'bg-ink text-surface' } },
|
|
51
53
|
},
|
|
52
54
|
defaultVariants: { columns: '3', featureStyle: 'plain', compact: false, tone: 'default' },
|
|
53
55
|
})
|
|
@@ -95,22 +97,24 @@ interface Props {
|
|
|
95
97
|
id?: string | undefined
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
const { heading, intro, plans, featuredLabel = 'Most popular', columns, layout = 'cards', featureStyle, compact, billingToggle = 'none', billingMainLabel = 'Monthly', billingAltLabel = 'Yearly', billingLegend = 'Billing period', includedLabel = 'Included', notIncludedLabel = 'Not included', tone, measured, id } = Astro.props
|
|
100
|
+
const { heading, intro, plans, featuredLabel = 'Most popular', columns, layout = 'cards', featureStyle, compact, billingToggle = 'none', billingMainLabel = 'Monthly', billingAltLabel = 'Yearly', billingLegend = 'Billing period', includedLabel = 'Included', notIncludedLabel = 'Not included', tone, measured, id: givenId } = Astro.props
|
|
101
|
+
// Every section has an id, so a theme or a design layer can address this one: the page's, or `pricing`, `pricing-2`, … in render order.
|
|
102
|
+
const id = instanceId(Astro.locals, givenId, 'pricing')
|
|
99
103
|
const s = pricing({ columns, featureStyle, compact: compact ?? false, tone })
|
|
100
|
-
// The radio group's name must be unique on the page: the section's id
|
|
101
|
-
const group = id
|
|
104
|
+
// The radio group's name must be unique on the page: the section's id is.
|
|
105
|
+
const group = id
|
|
102
106
|
const toggled = billingToggle === 'radio' && plans.some(plan => plan.priceAlt)
|
|
103
107
|
// The cards' list items, as plain strings, for the table's feature rows (the markdown list's `- ` / `* ` lines).
|
|
104
108
|
const itemsOf = (md?: string) => (md ?? '').split('\n').flatMap((line) => { const m = /^\s*[-*]\s+(.*\S)/.exec(line); return m ? [m[1]!.replace(/[*_`]/g, '')] : [] })
|
|
105
109
|
const rows = layout === 'table' ? [...new Set(plans.flatMap(plan => itemsOf(plan.features)))] : []
|
|
106
110
|
const included = plans.map(plan => new Set(itemsOf(plan.features)))
|
|
107
|
-
const headingId = heading
|
|
111
|
+
const headingId = heading ? `${id}-title` : undefined
|
|
108
112
|
// One level under the section heading, or the section's heading when there is none.
|
|
109
113
|
const PlanHeading = heading ? 'h3' : 'h2'
|
|
110
114
|
const features = await Promise.all(plans.map(plan => renderMarkdown(plan.features)))
|
|
111
115
|
---
|
|
112
116
|
{plans.length > 0 && (
|
|
113
|
-
<Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
|
|
117
|
+
<Section kit="pricing" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
|
|
114
118
|
{(heading || intro) && (
|
|
115
119
|
<header class={s.header()}>
|
|
116
120
|
{heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
|
|
@@ -128,7 +132,7 @@ const features = await Promise.all(plans.map(plan => renderMarkdown(plan.feature
|
|
|
128
132
|
<th scope="col" class={s.cell({ class: plan.featured ? 'bg-surface-muted' : undefined })}>
|
|
129
133
|
<span class="block text-lg [font-weight:var(--font-weight-heading,600)]">{plan.name}</span>
|
|
130
134
|
<span class="block text-2xl [font-weight:var(--font-weight-heading,700)]">{plan.price}</span>
|
|
131
|
-
{plan.period && <span class=
|
|
135
|
+
{plan.period && <span class:list={['block text-sm', muted]}>{plan.period}</span>}
|
|
132
136
|
{(plan.badge ?? (plan.featured ? featuredLabel : undefined)) && <span class="mt-1 inline-block rounded-full bg-accent px-3 py-0.5 text-xs font-semibold text-accent-ink">{plan.badge ?? featuredLabel}</span>}
|
|
133
137
|
</th>
|
|
134
138
|
))}
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
"shared": [
|
|
8
8
|
"Button.astro",
|
|
9
9
|
"Section.astro",
|
|
10
|
+
"instance-id.ts",
|
|
10
11
|
"measured.ts",
|
|
12
|
+
"muted.ts",
|
|
11
13
|
"safe-url.ts",
|
|
12
14
|
"types.ts"
|
|
13
15
|
],
|
|
@@ -145,7 +147,7 @@
|
|
|
145
147
|
"id": {
|
|
146
148
|
"type": "string",
|
|
147
149
|
"content": false,
|
|
148
|
-
"description": "Anchor id of the section; the heading gets `<id>-title`."
|
|
150
|
+
"description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `pricing`, `pricing-2`, … in render order, so every section on a page has its own."
|
|
149
151
|
},
|
|
150
152
|
"compact": {
|
|
151
153
|
"type": "boolean",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// assistive technology unless `labelStyle` shows it.
|
|
5
5
|
import { tv } from 'tailwind-variants'
|
|
6
6
|
import { safeUrl } from '../_shared/safe-url'
|
|
7
|
+
import { instanceId } from '../_shared/instance-id'
|
|
7
8
|
|
|
8
9
|
const search = tv({
|
|
9
10
|
slots: {
|
|
@@ -37,7 +38,9 @@ interface Props {
|
|
|
37
38
|
id?: string | undefined
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
const { action = '/search', label = 'Search', placeholder, submitLabel = label, size, width, button, labelStyle, id
|
|
41
|
+
const { action = '/search', label = 'Search', placeholder, submitLabel = label, size, width, button, labelStyle, id: givenId } = Astro.props
|
|
42
|
+
// Two on one page never share an id: their labels and controls would point into each other.
|
|
43
|
+
const id = instanceId(Astro.locals, givenId, 'site-search')
|
|
41
44
|
const s = search({ size, width, button, labelStyle })
|
|
42
45
|
---
|
|
43
46
|
<form role="search" method="get" action={safeUrl(action)} class={s.form()} data-cr-part="search-box">
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"description": "A search field that sends the visitor to the site's search page (`/search?q=…`): a plain GET form with a labelled role=search landmark, no JavaScript. For a header slot or inline in a page.",
|
|
5
5
|
"category": "chrome",
|
|
6
6
|
"uses": [],
|
|
7
|
-
"shared": ["safe-url.ts"],
|
|
7
|
+
"shared": ["instance-id.ts", "safe-url.ts"],
|
|
8
8
|
"variants": {
|
|
9
9
|
"size": {
|
|
10
10
|
"options": [
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
// A carousel of images, videos or text cards, with optional captions. Without JavaScript it is a
|
|
2
|
+
// A carousel of images, videos (a file, or a YouTube/Vimeo one loaded only on click) or text cards, with optional captions. Without JavaScript it is a
|
|
3
3
|
// horizontally scrolling, snapping row — every slide is reachable. With it,
|
|
4
4
|
// Embla turns the row into a paged carousel with previous/next buttons and
|
|
5
5
|
// slide dots. The markup and the script are in SliderTrack.astro; given a
|
|
@@ -8,6 +8,7 @@ import Section from '../_shared/Section.astro'
|
|
|
8
8
|
import type { ImageSource } from '../_shared/image'
|
|
9
9
|
import type { ActionInput, MeasuredStyle } from '../_shared/types'
|
|
10
10
|
import SliderTrack from './SliderTrack.astro'
|
|
11
|
+
import { instanceId } from '../_shared/instance-id'
|
|
11
12
|
|
|
12
13
|
interface Slide {
|
|
13
14
|
/** The image's address (or, until 0.7, an `ImageInput`). Optional: a text card (a quote, a testimonial) is a slide too. */
|
|
@@ -15,6 +16,8 @@ interface Slide {
|
|
|
15
16
|
imageAlt?: string | undefined
|
|
16
17
|
/** A video file's address (mp4, webm): drawn in place of the image (its poster), played only when the reader asks. */
|
|
17
18
|
video?: string | undefined
|
|
19
|
+
/** A video on another site (its YouTube/Vimeo/Loom/Wistia page, or `youtube:<id>` / `vimeo:<id>`): a poster until the reader clicks it. */
|
|
20
|
+
embed?: string | undefined
|
|
18
21
|
title?: string | undefined
|
|
19
22
|
text?: string | undefined
|
|
20
23
|
/** The slide's link, flat so a page stays within Contentrain's nesting depth. */
|
|
@@ -39,6 +42,8 @@ interface Props {
|
|
|
39
42
|
slideLabel?: string | undefined
|
|
40
43
|
/** Word between a slide number and the count ("of"). */
|
|
41
44
|
ofLabel?: string | undefined
|
|
45
|
+
/** An embedded video's play button, before its name ("Play"). */
|
|
46
|
+
playLabel?: string | undefined
|
|
42
47
|
loop?: boolean | undefined
|
|
43
48
|
perView?: '1' | '2' | '3' | undefined
|
|
44
49
|
aspect?: 'wide' | 'standard' | 'square' | undefined
|
|
@@ -50,7 +55,9 @@ interface Props {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
const props = Astro.props
|
|
58
|
+
// The section and its track share one id (the track is `<id>-track`), taken once here so the track keeps it.
|
|
59
|
+
const id = props.measured ? instanceId(Astro.locals, props.id, 'slider') : undefined
|
|
53
60
|
---
|
|
54
61
|
{props.measured
|
|
55
|
-
? <Section measured={props.measured} tone="none" spacing="sm" id={
|
|
62
|
+
? <Section kit="slider" measured={props.measured} tone="none" spacing="sm" id={id}><SliderTrack {...props} id={id} framed={false} /></Section>
|
|
56
63
|
: <SliderTrack {...props} />}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
// A carousel of images with optional captions, of videos (played only when the
|
|
3
|
-
// reader asks), or of text cards (a slide with no image: quotes, testimonials). Without JavaScript it is a
|
|
3
|
+
// reader asks; a YouTube or Vimeo one only once the reader clicks its poster), or of text cards (a slide with no image: quotes, testimonials). Without JavaScript it is a
|
|
4
4
|
// horizontally scrolling, snapping row — every slide is reachable. With it,
|
|
5
5
|
// Embla turns the row into a paged carousel with previous/next buttons and
|
|
6
6
|
// slide dots. Slider.astro frames it (the section's own look, when a measured
|
|
@@ -11,8 +11,11 @@ import KitImage from '../_shared/KitImage.astro'
|
|
|
11
11
|
import SectionHeader from '../_shared/SectionHeader.astro'
|
|
12
12
|
import { imageOf, type ImageSource } from '../_shared/image'
|
|
13
13
|
import { safeSrc } from '../_shared/safe-url'
|
|
14
|
+
import { onVideoHost, videoTarget, type EmbedTarget } from '../_shared/embed-target'
|
|
14
15
|
import { measuredSlides } from '../_shared/measured'
|
|
15
16
|
import { type ActionInput, type ImageInput, type MeasuredStyle } from '../_shared/types'
|
|
17
|
+
import { instanceId } from '../_shared/instance-id'
|
|
18
|
+
import { muted, mutedReset } from '../_shared/muted'
|
|
16
19
|
|
|
17
20
|
const slider = tv({
|
|
18
21
|
slots: {
|
|
@@ -22,7 +25,7 @@ const slider = tv({
|
|
|
22
25
|
image: 'w-full object-cover',
|
|
23
26
|
caption: 'flex flex-col items-start gap-2',
|
|
24
27
|
// A slide with no image: its text as a card the height of the row, never laid over an image that is not there.
|
|
25
|
-
card:
|
|
28
|
+
card: `flex h-full flex-col items-start gap-2 rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60 ${mutedReset}`,
|
|
26
29
|
},
|
|
27
30
|
variants: {
|
|
28
31
|
perView: {
|
|
@@ -38,7 +41,7 @@ const slider = tv({
|
|
|
38
41
|
caption: {
|
|
39
42
|
below: { image: 'rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4' },
|
|
40
43
|
// Over the image on wide screens; under it on phones, where the image is too short to hold text.
|
|
41
|
-
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' },
|
|
44
|
+
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 md:[--kit-muted:var(--color-ink-muted-inverse,var(--color-surface))]' },
|
|
42
45
|
},
|
|
43
46
|
},
|
|
44
47
|
defaultVariants: { perView: '1', aspect: 'wide', caption: 'overlay' },
|
|
@@ -53,6 +56,13 @@ interface Slide {
|
|
|
53
56
|
* the reader plays it, never plays by itself, and pauses when its slide is paged away. The caption sits under it.
|
|
54
57
|
*/
|
|
55
58
|
video?: string | undefined
|
|
59
|
+
/**
|
|
60
|
+
* A video on another site: its page (YouTube, Vimeo, Loom, Wistia) or `youtube:<id>` / `vimeo:<id>`. Drawn as its
|
|
61
|
+
* poster (the image, else a dark panel) with a play button; nothing from the provider loads until the reader clicks,
|
|
62
|
+
* and then the player takes the poster's place (YouTube from youtube-nocookie.com, Vimeo with dnt). Paged away, it
|
|
63
|
+
* goes back to its poster. Wins over `video`; an address that is not one of these videos is not drawn.
|
|
64
|
+
*/
|
|
65
|
+
embed?: string | undefined
|
|
56
66
|
title?: string | undefined
|
|
57
67
|
text?: string | undefined
|
|
58
68
|
/** The slide's link, flat so a page stays within Contentrain's nesting depth. */
|
|
@@ -77,6 +87,8 @@ interface Props {
|
|
|
77
87
|
slideLabel?: string | undefined
|
|
78
88
|
/** Word between a slide number and the count ("of"). */
|
|
79
89
|
ofLabel?: string | undefined
|
|
90
|
+
/** An embedded video's play button, before its name ("Play"). */
|
|
91
|
+
playLabel?: string | undefined
|
|
80
92
|
loop?: boolean | undefined
|
|
81
93
|
perView?: '1' | '2' | '3' | undefined
|
|
82
94
|
aspect?: 'wide' | 'standard' | 'square' | undefined
|
|
@@ -89,15 +101,21 @@ interface Props {
|
|
|
89
101
|
framed?: boolean | undefined
|
|
90
102
|
}
|
|
91
103
|
|
|
92
|
-
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
|
|
93
|
-
//
|
|
104
|
+
const { slides: given, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', playLabel = 'Play', loop = false, perView = '1', aspect, caption, heading, intro, eyebrow, actions, measured, id: givenId, framed = true } = Astro.props
|
|
105
|
+
// Two on one page never share an id: their labels and controls would point into each other.
|
|
106
|
+
const id = instanceId(Astro.locals, givenId, 'slider')
|
|
107
|
+
// A slide is drawn when it has something to show: an image, a video (a file or another site's), or a text card (a title, a text or a link) where
|
|
94
108
|
// the source carousel moved quotes or testimonials. A slide with none of them is dropped.
|
|
95
109
|
const slides = given.flatMap((slide) => {
|
|
96
|
-
const
|
|
97
|
-
|
|
110
|
+
const player = videoTarget(slide.embed) ?? undefined
|
|
111
|
+
// A video's poster is the site's own image: one on the provider's hosts (its thumbnail) would be a request to it
|
|
112
|
+
// before the reader asked, so the poster is a plain dark panel instead.
|
|
113
|
+
const picture = slide.image === undefined ? undefined : imageOf(slide.image, slide.imageAlt)
|
|
114
|
+
const image = player && onVideoHost(picture?.src) ? undefined : picture
|
|
115
|
+
const video = player ? undefined : safeSrc(slide.video) || undefined
|
|
98
116
|
const action = slide.action ?? (slide.ctaLabel && slide.ctaHref ? { label: slide.ctaLabel, href: slide.ctaHref } : undefined)
|
|
99
|
-
return image || video || slide.title || slide.text || action ? [{ ...slide, image, video, action }] : []
|
|
100
|
-
}) satisfies Array<{ image: ImageInput | undefined, video: string | undefined, action?: ActionInput | undefined }>
|
|
117
|
+
return image || video || player || slide.title || slide.text || action ? [{ ...slide, image, video, player, action }] : []
|
|
118
|
+
}) satisfies Array<{ image: ImageInput | undefined, video: string | undefined, player: EmbedTarget | undefined, action?: ActionInput | undefined }>
|
|
101
119
|
const measuredRow = measuredSlides(measured)
|
|
102
120
|
// A measured row sets the slides in view itself, so the variant's own widths step aside.
|
|
103
121
|
const slots = slider({ perView: measuredRow ? '1' : perView, aspect, caption })
|
|
@@ -106,26 +124,41 @@ const below = slider({ perView: measuredRow ? '1' : perView, aspect, caption: 'b
|
|
|
106
124
|
const trackId = `${id}-track`
|
|
107
125
|
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'
|
|
108
126
|
// Arrows from sm up; on phones the carousel is swiped and the dots show the position.
|
|
127
|
+
// Framed, the carousel is its own section: the same hooks as every other (`id`, `data-kit-section`), its padding its own,
|
|
128
|
+
// so a theme's section spacing leaves it as it is.
|
|
109
129
|
const Root = framed ? 'section' : 'div'
|
|
110
130
|
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'
|
|
111
131
|
// A text card has no picture for the arrows to sit on: over it they would cover its words. A carousel with any text
|
|
112
132
|
// card (all of them, or mixed with pictures) has its arrows under the row, either side of the dots.
|
|
113
|
-
const cards = slides.some(slide => !slide.image && !slide.video)
|
|
133
|
+
const cards = slides.some(slide => !slide.image && !slide.video && !slide.player)
|
|
134
|
+
// A video from another site is a link to its page, so without JavaScript it opens there; its frame keeps the image's
|
|
135
|
+
// ratio, so the player that replaces the link moves nothing.
|
|
114
136
|
const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg-surface text-ink ring-1 ring-line hover:bg-surface disabled:opacity-40'
|
|
115
137
|
---
|
|
116
|
-
<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}>
|
|
138
|
+
<Root id={framed ? id : undefined} class:list={['group relative', framed && 'container-page py-8']} data-kit-section={framed ? 'slider' : undefined} data-kit-spacing={framed ? 'none' : undefined} role={framed ? undefined : 'region'} aria-roledescription="carousel" aria-label={label} data-kit-slider data-loop={loop ? '' : undefined} data-slide-label={slideLabel}>
|
|
117
139
|
<SectionHeader heading={heading} intro={intro} eyebrow={eyebrow} actions={actions} />
|
|
118
140
|
<div class={slots.viewport()} data-viewport tabindex="0" role="group" aria-label={label}>
|
|
119
141
|
<div class:list={['flex gap-4 [&>*]:shrink-0 group-data-[ready]:[&>*]:snap-align-none', measuredRow?.track]} id={trackId} data-container>
|
|
120
142
|
{slides.map((slide, index) => (
|
|
121
143
|
<div class={slots.slide({ class: measuredRow?.slide })} role="group" aria-roledescription="slide" aria-label={`${slideLabel} ${index + 1} ${ofLabel} ${slides.length}`}>
|
|
122
|
-
{slide.
|
|
144
|
+
{slide.player
|
|
145
|
+
? (
|
|
146
|
+
<div class={below.image({ class: 'relative overflow-hidden bg-ink' })} data-slide-player>
|
|
147
|
+
<a href={slide.player.href} class="group/play absolute inset-0 block focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-accent" data-embed-src={slide.player.src} data-embed-title={slide.title || slide.image?.alt || `${slideLabel} ${index + 1}`} aria-label={`${playLabel}: ${slide.title || slide.image?.alt || `${slideLabel} ${index + 1}`}`} rel="noopener">
|
|
148
|
+
{slide.image && <KitImage image={{ ...slide.image, alt: '' }} sizes={sizes} priority={index === 0} class="absolute inset-0 h-full w-full object-cover opacity-85 transition-opacity group-hover/play:opacity-70" />}
|
|
149
|
+
<span class="absolute top-1/2 left-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-accent text-accent-ink shadow-lg transition-transform group-hover/play:scale-105 group-focus-visible/play:scale-105" aria-hidden="true">
|
|
150
|
+
<svg viewBox="0 0 24 24" class="ms-1 size-7" fill="currentColor"><path d="M7 4.5v15a.5.5 0 0 0 .77.42l11.5-7.5a.5.5 0 0 0 0-.84L7.77 4.08A.5.5 0 0 0 7 4.5Z" /></svg>
|
|
151
|
+
</span>
|
|
152
|
+
</a>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
: slide.video
|
|
123
156
|
? <video class={below.image({ class: 'bg-ink' })} src={safeSrc(slide.video)} poster={safeSrc(slide.image?.src)} controls preload="none" playsinline aria-label={slide.title ? undefined : slide.image?.alt || `${slideLabel} ${index + 1}`} />
|
|
124
157
|
: slide.image && <KitImage image={slide.image} sizes={sizes} priority={index === 0} class={slots.image()} />}
|
|
125
158
|
{(slide.title || slide.text || slide.action) && (
|
|
126
|
-
<div class={slide.video ? below.caption() : slide.image ? slots.caption() : slots.card()}>
|
|
159
|
+
<div class={slide.video || slide.player ? below.caption() : slide.image ? slots.caption() : slots.card()}>
|
|
127
160
|
{slide.title && <p class="text-xl [font-weight:var(--font-weight-heading,600)] text-balance">{slide.title}</p>}
|
|
128
|
-
{slide.text && <p class=
|
|
161
|
+
{slide.text && <p class={muted}>{slide.text}</p>}
|
|
129
162
|
{slide.action && <Button action={slide.action} size="sm" class="mt-2" />}
|
|
130
163
|
</div>
|
|
131
164
|
)}
|
|
@@ -152,6 +185,7 @@ const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg
|
|
|
152
185
|
|
|
153
186
|
<script>
|
|
154
187
|
import EmblaCarousel from 'embla-carousel'
|
|
188
|
+
import { playerFrame, plainClick } from '../_shared/embed-frame'
|
|
155
189
|
|
|
156
190
|
for (const root of document.querySelectorAll<HTMLElement>('[data-kit-slider]')) {
|
|
157
191
|
const viewport = root.querySelector<HTMLElement>('[data-viewport]')
|
|
@@ -204,7 +238,29 @@ const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg
|
|
|
204
238
|
}
|
|
205
239
|
// A video paged out of view stops: nothing plays where the reader cannot see it.
|
|
206
240
|
const pause = () => { for (const video of root.querySelectorAll('video')) if (!video.paused) video.pause() }
|
|
207
|
-
|
|
241
|
+
// Another site's video: its poster link becomes the player on a plain click (a swipe that ends on it is not one:
|
|
242
|
+
// Embla cancels that click), and goes back to its poster when its slide is paged out of view, which stops it and
|
|
243
|
+
// ends the provider's session there.
|
|
244
|
+
const posters = new Map<HTMLIFrameElement, HTMLAnchorElement>()
|
|
245
|
+
viewport.addEventListener('click', (event) => {
|
|
246
|
+
if (!plainClick(event)) return
|
|
247
|
+
const link = (event.target as Element | null)?.closest<HTMLAnchorElement>('[data-slide-player] a[data-embed-src]')
|
|
248
|
+
const frame = link && playerFrame(link.dataset.embedSrc, link.dataset.embedTitle ?? '')
|
|
249
|
+
if (!link || !frame) return
|
|
250
|
+
event.preventDefault()
|
|
251
|
+
posters.set(frame, link)
|
|
252
|
+
link.replaceWith(frame)
|
|
253
|
+
frame.focus()
|
|
254
|
+
})
|
|
255
|
+
const unplay = () => {
|
|
256
|
+
const inView = new Set(embla.slidesInView().map(index => embla.slideNodes()[index]))
|
|
257
|
+
for (const [frame, link] of posters) {
|
|
258
|
+
if (inView.has(frame.closest<HTMLElement>('[aria-roledescription="slide"]') ?? undefined)) continue
|
|
259
|
+
frame.replaceWith(link)
|
|
260
|
+
posters.delete(frame)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
embla.on('select', () => { pause(); sync() }).on('settle', unplay).on('reInit', () => { drawDots(); sync() })
|
|
208
264
|
sync()
|
|
209
265
|
// Set up before the layout was final, Embla measures once more when it is: the page loaded, its fonts in, the tab in
|
|
210
266
|
// view. A background tab lays nothing out (ctrl/cmd-click), so its carousel had one snap and a dead "next" until then;
|
|
@@ -5,5 +5,6 @@
|
|
|
5
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 } } },
|
|
6
6
|
{ "name": "text-cards", "props": { "label": "What clients say", "perView": "3", "heading": "Testimonials", "slides": [{ "title": "Ada Lovelace", "text": "They moved our whole site in a week, and nothing broke." }, { "title": "Grace Hopper", "text": "Every page is ours to edit now, without a developer." }, { "title": "Alan Turing", "text": "The new site is faster, and it looks the same.", "ctaLabel": "Read the story", "ctaHref": "/stories/" }] } },
|
|
7
7
|
{ "name": "video-slides", "props": { "label": "Feature tour", "heading": "See it work", "slides": [{ "video": "/kit-fixtures/clip-1.mp4", "image": "/kit-fixtures/photo-1.svg", "imageAlt": "", "title": "Import" }, { "video": "/kit-fixtures/clip-2.mp4", "title": "Edit" }, { "video": "/kit-fixtures/clip-3.mp4", "image": "/kit-fixtures/photo-3.svg", "imageAlt": "", "title": "Publish", "text": "One click, and the site is live." }] } },
|
|
8
|
-
{ "name": "mixed-cards", "props": { "label": "Stories", "slides": [{ "title": "Ada Lovelace", "text": "They moved our whole site in a week, and nothing broke." }, { "image": "/kit-fixtures/photo-2.svg", "imageAlt": "Green gradient", "title": "Harbor & Co" }, { "title": "Grace Hopper", "text": "Every page is ours to edit now, without a developer." }] } }
|
|
8
|
+
{ "name": "mixed-cards", "props": { "label": "Stories", "slides": [{ "title": "Ada Lovelace", "text": "They moved our whole site in a week, and nothing broke." }, { "image": "/kit-fixtures/photo-2.svg", "imageAlt": "Green gradient", "title": "Harbor & Co" }, { "title": "Grace Hopper", "text": "Every page is ours to edit now, without a developer." }] } },
|
|
9
|
+
{"name": "video-embed", "props": {"label": "Studio videos", "id": "videos", "aspect": "standard", "perView": "2", "slides": [{"embed": "youtube:aqz-KE-bpKQ", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "Blue gradient", "width": 1200, "height": 800}, "title": "Studio tour"}, {"embed": "https://vimeo.com/76979871", "title": "Behind the scenes"}, {"embed": "youtube:aqz-KE-bpKQ", "image": "https://i.ytimg.com/vi/aqz-KE-bpKQ/hqdefault.jpg", "title": "A provider thumbnail is never loaded"}, {"image": {"src": "/kit-fixtures/photo-3.svg", "alt": "Orange gradient", "width": 1200, "height": 800}, "title": "Field Notes"}]}}
|
|
9
10
|
]
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "slider",
|
|
3
3
|
"name": "Slider",
|
|
4
|
-
"description": "A carousel of images, videos or text cards, with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
|
|
4
|
+
"description": "A carousel of images, videos (a file, or a YouTube/Vimeo link loaded only on click) or text cards, 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", "Section.astro", "SectionHeader.astro", "image.ts", "measured.ts", "safe-url.ts", "types.ts"],
|
|
7
|
+
"shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "embed-frame.ts", "embed-target.ts", "image.ts", "instance-id.ts", "measured.ts", "muted.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." },
|
|
11
11
|
"caption": { "options": ["below", "overlay"], "default": "overlay", "description": "Caption under the image, or over its lower edge on a gradient." }
|
|
12
12
|
},
|
|
13
13
|
"props": {
|
|
14
|
-
"slides": { "type": "array", "label": "Slides", "required": true, "items": { "type": "object", "fields": { "image": { "type": "image", "label": "Image", "description": "The slide image; a slide without one is a text card (a quote, a testimonial)." }, "imageAlt": { "type": "string", "label": "Image description", "description": "What the image shows, for readers who cannot see it." }, "video": { "type": "video", "label": "Video", "description": "A video file (mp4, webm) shown in place of the image, which is its poster. It plays only when the reader starts it." }, "title": { "type": "string", "label": "Title", "description": "Heading over the slide." }, "text": { "type": "text", "label": "Text", "description": "A line or two under the heading." }, "ctaLabel": { "type": "string", "label": "Button label", "description": "Button text; the slide has no button without it." }, "ctaHref": { "type": "url", "label": "Button link", "description": "Where the button goes." } } }, "description": "Slides: the image's address and text, an optional video (the image is its poster), and an optional link (ctaLabel + ctaHref). A slide with no image or video is a text card; one with none of them is not drawn." },
|
|
14
|
+
"slides": { "type": "array", "label": "Slides", "required": true, "items": { "type": "object", "fields": { "image": { "type": "image", "label": "Image", "description": "The slide image; a slide without one is a text card (a quote, a testimonial)." }, "imageAlt": { "type": "string", "label": "Image description", "description": "What the image shows, for readers who cannot see it." }, "video": { "type": "video", "label": "Video", "description": "A video file (mp4, webm) shown in place of the image, which is its poster. It plays only when the reader starts it." }, "embed": { "type": "url", "label": "Video link", "description": "A video on another site: its YouTube, Vimeo, Loom or Wistia page (or youtube:<id> / vimeo:<id>). Shown as the image with a play button; nothing from that site loads until the reader clicks it. Wins over Video." }, "title": { "type": "string", "label": "Title", "description": "Heading over the slide." }, "text": { "type": "text", "label": "Text", "description": "A line or two under the heading." }, "ctaLabel": { "type": "string", "label": "Button label", "description": "Button text; the slide has no button without it." }, "ctaHref": { "type": "url", "label": "Button link", "description": "Where the button goes." } } }, "description": "Slides: the image's address and text, an optional video file or video link (the image is its poster), and an optional link (ctaLabel + ctaHref). A slide with no image or video is a text card; one with none of them is not drawn." },
|
|
15
15
|
"label": { "type": "string", "required": true, "content": false, "description": "Accessible name of the carousel." },
|
|
16
16
|
"prevLabel": { "type": "string", "content": false, "description": "ui-strings slider.prev." },
|
|
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
|
+
"playLabel": { "type": "string", "content": false, "description": "ui-strings embed.play." },
|
|
20
21
|
"loop": { "type": "boolean", "default": false, "content": false, "description": "Wrap from the last slide to the first." },
|
|
21
22
|
"id": { "type": "string", "default": "slider", "content": false, "description": "Unique on the page: names the track that the previous/next buttons control (aria-controls)." },
|
|
22
23
|
"heading": {"type": "string", "label": "Heading", "description": "The section heading."},
|
|
@@ -34,5 +35,5 @@
|
|
|
34
35
|
"divi": ["divi/et_pb_slider", "divi/et_pb_fullwidth_slider", "divi/et_pb_post_slider"],
|
|
35
36
|
"classic": ["classic/revslider", "classic/metaslider", "classic/smart-slider", "classic/swiper", "classic/slick", "classic/splide", "classic/owl"]
|
|
36
37
|
},
|
|
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: a video slide loads nothing until the reader plays it, never starts by itself, keeps its native controls with its caption under them, and pauses when its slide is paged away; with prefers-reduced-motion slides change without animation. Without JavaScript every slide is reachable by scrolling."
|
|
38
|
+
"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: a video slide loads nothing until the reader plays it, never starts by itself, keeps its native controls with its caption under them, and pauses when its slide is paged away; a video link is a labelled poster link ('Play: title') to the video's page that becomes the player only on a click (no request to the provider before it; YouTube from youtube-nocookie.com, Vimeo with dnt), keeps the poster's box so nothing moves, takes focus, and goes back to its poster when its slide is paged away; with prefers-reduced-motion slides change without animation. Without JavaScript every slide is reachable by scrolling."
|
|
38
39
|
}
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
import { tv } from 'tailwind-variants'
|
|
6
6
|
import { addressLabel, NETWORK_MARKS, networkOf } from '../_shared/social'
|
|
7
7
|
import { safeHref } from '../_shared/safe-url'
|
|
8
|
+
import { muted } from '../_shared/muted'
|
|
8
9
|
|
|
9
10
|
const social = tv({
|
|
10
11
|
slots: {
|
|
11
12
|
list: 'flex flex-wrap items-center',
|
|
12
|
-
link:
|
|
13
|
+
link: `inline-flex items-center justify-center gap-2 no-underline ${muted} hover:text-current focus-visible:text-current`,
|
|
13
14
|
mark: 'shrink-0',
|
|
14
15
|
},
|
|
15
16
|
variants: {
|
|
@@ -9,13 +9,15 @@ import Embed from '../embed/Embed.astro'
|
|
|
9
9
|
import FeatureList from '../feature-list/FeatureList.astro'
|
|
10
10
|
import KitImage from '../_shared/KitImage.astro'
|
|
11
11
|
import Section from '../_shared/Section.astro'
|
|
12
|
+
import { instanceId } from '../_shared/instance-id'
|
|
12
13
|
import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
|
|
14
|
+
import { muted } from '../_shared/muted'
|
|
13
15
|
|
|
14
16
|
const split = tv({
|
|
15
17
|
slots: {
|
|
16
18
|
grid: 'grid items-center gap-10 md:gap-16',
|
|
17
19
|
copy: 'flex flex-col gap-5',
|
|
18
|
-
eyebrow:
|
|
20
|
+
eyebrow: `text-sm font-semibold tracking-wide uppercase ${muted}`,
|
|
19
21
|
heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
|
|
20
22
|
body: 'prose prose-site max-w-none text-pretty',
|
|
21
23
|
actions: 'flex flex-wrap gap-3 pt-2',
|
|
@@ -42,7 +44,7 @@ const split = tv({
|
|
|
42
44
|
sticky: { true: { media: 'md:sticky md:top-8 md:self-start' }, false: {} },
|
|
43
45
|
align: { start: {}, center: { copy: 'items-center text-center', actions: 'justify-center' } },
|
|
44
46
|
// 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.
|
|
45
|
-
tone: { default: {}, muted: {}, inverse: {}, accent: {
|
|
47
|
+
tone: { default: {}, muted: {}, inverse: {}, accent: {}, brand: {}, none: {} },
|
|
46
48
|
},
|
|
47
49
|
defaultVariants: { imageSide: 'end', ratio: '1:1', imageFit: 'contain', imageShape: 'rounded', imageBleed: 'none', stackOrder: 'text-first', sticky: false, align: 'start', tone: 'default' },
|
|
48
50
|
})
|
|
@@ -98,16 +100,18 @@ interface Props {
|
|
|
98
100
|
id?: string | undefined
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
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
|
|
103
|
+
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: givenId } = Astro.props
|
|
104
|
+
// Every section has an id, so a theme or a design layer can address this one: the page's, or `split`, `split-2`, … in render order.
|
|
105
|
+
const id = instanceId(Astro.locals, givenId, 'split')
|
|
102
106
|
const s = split({ imageSide, ratio, imageFit, imageShape, imageBleed, stackOrder, sticky, align, tone })
|
|
103
107
|
const Heading = `h${headingLevel}` as 'h2'
|
|
104
108
|
const columns = COLUMNS[imageSide === 'start' ? 'image' : 'text'][ratio]
|
|
105
109
|
const bleed = imageBleed === 'edge' ? BLEED[imageSide === 'start' ? 'start' : 'end'] : undefined
|
|
106
110
|
const video = mediaKind === 'video' && videoUrl
|
|
107
|
-
const headingId =
|
|
111
|
+
const headingId = heading ? `${id}-title` : undefined
|
|
108
112
|
const html = await renderMarkdown(body)
|
|
109
113
|
---
|
|
110
|
-
<Section measured={measured} tone={tone} id={id} aria-labelledby={headingId} class={bleed ? 'overflow-x-clip' : undefined}>
|
|
114
|
+
<Section kit="split" measured={measured} tone={tone} id={id} aria-labelledby={headingId} class={bleed ? 'overflow-x-clip' : undefined}>
|
|
111
115
|
<div class:list={[s.grid(), columns]}>
|
|
112
116
|
<div class={s.copy()}>
|
|
113
117
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|