@contentrain/astro-kit 0.38.0 → 0.39.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 +1 -1
- package/catalog.json +31 -10
- package/components/_shared/BehaviorScript.astro +11 -0
- package/components/_shared/behaviors.ts +20 -5
- package/components/behavior/Behavior.astro +3 -0
- package/components/dialog/Dialog.astro +31 -7
- package/components/dialog/fixtures.json +19 -0
- package/components/dialog/meta.json +3 -2
- package/components/header/Header.astro +9 -2
- package/components/header/header.ts +13 -0
- package/components/header/meta.json +1 -0
- package/components/slider/Slider.astro +5 -3
- package/components/slider/SliderTrack.astro +46 -20
- package/components/slider/fixtures.json +4 -1
- package/components/slider/meta.json +3 -3
- package/components/stats/Stats.astro +15 -6
- package/components/stats/fixtures.json +25 -0
- package/components/stats/meta.json +12 -2
- package/mapping/divi.json +2 -1
- package/mapping/elementor.json +2 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ They read its design tokens and pass its gates: `astro check` with the
|
|
|
25
25
|
| `tabs` | Tabbed panels; readable as headed sections without JavaScript | Zag.js |
|
|
26
26
|
| `testimonial` | Quotes with author and role | none |
|
|
27
27
|
| `gallery` | Image grid with a popover lightbox | none |
|
|
28
|
-
| `slider` | Carousel on a scroll-snap row | Embla |
|
|
28
|
+
| `slider` | Carousel on a scroll-snap row: image, video (played only on request) or text-card slides | Embla |
|
|
29
29
|
| `contact-form` | Contentrain Studio form in a section | Studio runtime |
|
|
30
30
|
| `newsletter` | Sign-up posting straight to the list provider (Mailchimp, Kit, MailerLite, Brevo, Buttondown) | none |
|
|
31
31
|
| `embed` | Video, map or player loaded on click; a provider link until then (YouTube via nocookie) | a few lines, no dependency |
|
package/catalog.json
CHANGED
|
@@ -1955,10 +1955,11 @@
|
|
|
1955
1955
|
"primary",
|
|
1956
1956
|
"secondary",
|
|
1957
1957
|
"ghost",
|
|
1958
|
-
"link"
|
|
1958
|
+
"link",
|
|
1959
|
+
"none"
|
|
1959
1960
|
],
|
|
1960
1961
|
"default": "primary",
|
|
1961
|
-
"description": "How the opening button looks: a filled or outlined button, or a text link."
|
|
1962
|
+
"description": "How the opening button looks: a filled or outlined button, or a text link. none: no button of its own and no section band; opened by a link to #<id> or a commandfor button elsewhere on the page, or an address ending in #<id> (a popup drawn over the page, its opener in the content)."
|
|
1962
1963
|
},
|
|
1963
1964
|
"size": {
|
|
1964
1965
|
"options": [
|
|
@@ -3468,6 +3469,10 @@
|
|
|
3468
3469
|
"default": false,
|
|
3469
3470
|
"description": "The navigation in the site's link colour, as themes that colour every link do."
|
|
3470
3471
|
},
|
|
3472
|
+
"brandColor": {
|
|
3473
|
+
"type": "string",
|
|
3474
|
+
"description": "The site name's colour where the source theme's is not the header's text colour: link for the site's link colour, or a colour (#hex, rgb(), hsl())."
|
|
3475
|
+
},
|
|
3471
3476
|
"logo": {
|
|
3472
3477
|
"type": "object",
|
|
3473
3478
|
"fields": {
|
|
@@ -3597,7 +3602,8 @@
|
|
|
3597
3602
|
},
|
|
3598
3603
|
"a11y": "One <header> landmark; the brand link is named by the site name when only the logo shows. Navigation as in nav.",
|
|
3599
3604
|
"files": [
|
|
3600
|
-
"Header.astro"
|
|
3605
|
+
"Header.astro",
|
|
3606
|
+
"header.ts"
|
|
3601
3607
|
]
|
|
3602
3608
|
},
|
|
3603
3609
|
{
|
|
@@ -5727,7 +5733,7 @@
|
|
|
5727
5733
|
{
|
|
5728
5734
|
"id": "slider",
|
|
5729
5735
|
"name": "Slider",
|
|
5730
|
-
"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.",
|
|
5736
|
+
"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.",
|
|
5731
5737
|
"category": "section",
|
|
5732
5738
|
"uses": [],
|
|
5733
5739
|
"shared": [
|
|
@@ -5779,14 +5785,18 @@
|
|
|
5779
5785
|
"image": {
|
|
5780
5786
|
"type": "image",
|
|
5781
5787
|
"label": "Image",
|
|
5782
|
-
"
|
|
5783
|
-
"description": "The slide image."
|
|
5788
|
+
"description": "The slide image; a slide without one is a text card (a quote, a testimonial)."
|
|
5784
5789
|
},
|
|
5785
5790
|
"imageAlt": {
|
|
5786
5791
|
"type": "string",
|
|
5787
5792
|
"label": "Image description",
|
|
5788
5793
|
"description": "What the image shows, for readers who cannot see it."
|
|
5789
5794
|
},
|
|
5795
|
+
"video": {
|
|
5796
|
+
"type": "video",
|
|
5797
|
+
"label": "Video",
|
|
5798
|
+
"description": "A video file (mp4, webm) shown in place of the image, which is its poster. It plays only when the reader starts it."
|
|
5799
|
+
},
|
|
5790
5800
|
"title": {
|
|
5791
5801
|
"type": "string",
|
|
5792
5802
|
"label": "Title",
|
|
@@ -5809,7 +5819,7 @@
|
|
|
5809
5819
|
}
|
|
5810
5820
|
}
|
|
5811
5821
|
},
|
|
5812
|
-
"description": "Slides: the image's address and text, and an optional link (ctaLabel + ctaHref)."
|
|
5822
|
+
"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."
|
|
5813
5823
|
},
|
|
5814
5824
|
"label": {
|
|
5815
5825
|
"type": "string",
|
|
@@ -6045,7 +6055,7 @@
|
|
|
6045
6055
|
"classic/owl"
|
|
6046
6056
|
]
|
|
6047
6057
|
},
|
|
6048
|
-
"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.",
|
|
6058
|
+
"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.",
|
|
6049
6059
|
"files": [
|
|
6050
6060
|
"Slider.astro",
|
|
6051
6061
|
"SliderTrack.astro"
|
|
@@ -6054,6 +6064,7 @@
|
|
|
6054
6064
|
"slides": "slides",
|
|
6055
6065
|
"slides[].image": "slides[].image",
|
|
6056
6066
|
"slides[].imageAlt": "slides[].image_alt",
|
|
6067
|
+
"slides[].video": "slides[].video",
|
|
6057
6068
|
"slides[].title": "slides[].title",
|
|
6058
6069
|
"slides[].text": "slides[].text",
|
|
6059
6070
|
"slides[].ctaLabel": "slides[].cta_label",
|
|
@@ -6567,12 +6578,14 @@
|
|
|
6567
6578
|
{
|
|
6568
6579
|
"id": "stats",
|
|
6569
6580
|
"name": "Stats",
|
|
6570
|
-
"description": "A row of figures with a short label each (years in business, clients, uptime), with an optional heading and intro. The counter/number section of a landing page; numbers are
|
|
6581
|
+
"description": "A row of figures with a short label each (years in business, clients, uptime), with an optional heading and intro. The counter/number section of a landing page; numbers are text (the final value in the HTML); motion \"count\" counts them up once in view, as a source counter did.",
|
|
6571
6582
|
"category": "section",
|
|
6572
6583
|
"uses": [],
|
|
6573
6584
|
"shared": [
|
|
6585
|
+
"BehaviorScript.astro",
|
|
6574
6586
|
"KitImage.astro",
|
|
6575
6587
|
"Section.astro",
|
|
6588
|
+
"behaviors.ts",
|
|
6576
6589
|
"image.ts",
|
|
6577
6590
|
"measured.ts",
|
|
6578
6591
|
"safe-url.ts",
|
|
@@ -6651,6 +6664,14 @@
|
|
|
6651
6664
|
],
|
|
6652
6665
|
"default": "label-first",
|
|
6653
6666
|
"description": "Which the page's text reads first, as the source does: label-first (an Elementor counter) or value-first (a Gutenberg figure heading over its label). The figure is drawn on top either way."
|
|
6667
|
+
},
|
|
6668
|
+
"motion": {
|
|
6669
|
+
"options": [
|
|
6670
|
+
"static",
|
|
6671
|
+
"count"
|
|
6672
|
+
],
|
|
6673
|
+
"default": "static",
|
|
6674
|
+
"description": "static: the figures as printed. count: each counts up from 0 to its value once in view (a source counter that animated); the final number is in the HTML, nothing moves under reduced motion or without IntersectionObserver."
|
|
6654
6675
|
}
|
|
6655
6676
|
},
|
|
6656
6677
|
"props": {
|
|
@@ -6819,7 +6840,7 @@
|
|
|
6819
6840
|
}
|
|
6820
6841
|
},
|
|
6821
6842
|
"slots": {},
|
|
6822
|
-
"js": "
|
|
6843
|
+
"js": "vanilla",
|
|
6823
6844
|
"dependencies": {},
|
|
6824
6845
|
"sources": {
|
|
6825
6846
|
"gutenberg": [
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The behaviour islands' module for a kit component that is its own island (a Stats section set to count up), rendered
|
|
3
|
+
// only where that component asks for it: a page without one ships no script. Same module and init as Behavior.astro.
|
|
4
|
+
---
|
|
5
|
+
<script>
|
|
6
|
+
import { initBehaviors } from './behaviors'
|
|
7
|
+
|
|
8
|
+
initBehaviors()
|
|
9
|
+
// With Astro's client router a page arrives without a reload: wire its islands too (ready roots are skipped).
|
|
10
|
+
document.addEventListener('astro:page-load', () => initBehaviors())
|
|
11
|
+
</script>
|
|
@@ -103,7 +103,17 @@ function carousel(root: HTMLElement): void {
|
|
|
103
103
|
function dialog(root: HTMLElement): void {
|
|
104
104
|
for (const opener of within(root, '[aria-haspopup="dialog"][aria-controls]')) {
|
|
105
105
|
const content = target(root, opener.getAttribute('aria-controls'))
|
|
106
|
-
if (!content
|
|
106
|
+
if (!content) continue
|
|
107
|
+
// The source's own closed <dialog> (the island pass keeps it closed, its openers named by aria-controls): opened as it
|
|
108
|
+
// is, with a close button where it has none. One inside another dialog is that dialog's content, not ours to open.
|
|
109
|
+
if (content instanceof HTMLDialogElement) {
|
|
110
|
+
if (content.parentElement?.closest('dialog')) continue
|
|
111
|
+
if (!content.querySelector('form[method="dialog"], .cr-behavior-control')) content.append(control(root.dataset.crClose ?? 'Close', () => content.close()))
|
|
112
|
+
opener.addEventListener('click', (event) => { event.preventDefault(); if (!content.open) content.showModal() })
|
|
113
|
+
content.addEventListener('click', (event) => { if (event.target === content) content.close() })
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (content.closest('dialog')) continue
|
|
107
117
|
const box = document.createElement('dialog')
|
|
108
118
|
box.className = 'cr-behavior-dialog'
|
|
109
119
|
content.before(box)
|
|
@@ -231,12 +241,17 @@ export function initBehaviors(scope: ParentNode = document): void {
|
|
|
231
241
|
// A root inside another root is not ours: the component prints one root per island, so a nested one came with the
|
|
232
242
|
// markup (a spoofed island the writer's pass would strip). Never wired, so its selectors never run.
|
|
233
243
|
if (root.dataset.crReady !== undefined || !KINDS.has(kind) || root.parentElement?.closest('[data-cr-behavior]')) continue
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
244
|
+
// Marked first: a wiring that fails part-way is never run again over what it already wired.
|
|
245
|
+
root.dataset.crReady = ''
|
|
246
|
+
try { WIRE[kind as Kind](root) }
|
|
238
247
|
catch {
|
|
239
248
|
// Left as printed: the content is all there, it just does not move.
|
|
240
249
|
}
|
|
250
|
+
// A dialog opener inside an island of another kind (a popup button in a tab): opened too, never left dead, even
|
|
251
|
+
// where the island's own wiring failed.
|
|
252
|
+
if (kind !== 'dialog') {
|
|
253
|
+
try { dialog(root) }
|
|
254
|
+
catch { /* its dialogs stay as printed */ }
|
|
255
|
+
}
|
|
241
256
|
}
|
|
242
257
|
}
|
|
@@ -35,6 +35,8 @@ const { kind, html, items, title, panel, fact, previousLabel, nextLabel, closeLa
|
|
|
35
35
|
// Selectors and ids go into attributes only, never into markup or a script; an empty one is left out.
|
|
36
36
|
const attr = (value: string | undefined): string | undefined => (value && value.length <= 300 ? value : undefined)
|
|
37
37
|
const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
|
|
38
|
+
// Scripting off: a dialog the island holds closed is laid out in place, so its content is never hidden. A fixed sheet.
|
|
39
|
+
const NOSCRIPT = '<style>.cr-behavior dialog:not([open]){display:block;position:static;margin:0;max-width:none}</style>'
|
|
38
40
|
---
|
|
39
41
|
{html && (
|
|
40
42
|
<Section tone={tone} measured={measured}>
|
|
@@ -50,6 +52,7 @@ const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
|
|
|
50
52
|
data-cr-close={closeLabel}
|
|
51
53
|
set:html={html}
|
|
52
54
|
/>
|
|
55
|
+
<noscript set:html={NOSCRIPT} />
|
|
53
56
|
</Section>
|
|
54
57
|
)}
|
|
55
58
|
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// opens it with no script where Invoker Commands exist (`command`/`commandfor`); a small script covers browsers
|
|
10
10
|
// without them and closes it on a click outside. With scripting off altogether, the dialog's content is laid out in
|
|
11
11
|
// the section itself, under where the button was: nothing is hidden and nothing blocks the page.
|
|
12
|
+
//
|
|
13
|
+
// `triggerStyle: 'none'`: the dialog has no button of its own and no section band. It is opened from elsewhere on the
|
|
14
|
+
// page, as the source popup was: a link to `#<id>` (the migrated popup opener), a `commandfor` button, or an address
|
|
15
|
+
// that ends in `#<id>`. Used where the popup is drawn over the page (the starter's overlays, after the footer). With
|
|
16
|
+
// scripting off its content sits there as a plain block, and the `#<id>` link jumps to it.
|
|
12
17
|
import { tv } from 'tailwind-variants'
|
|
13
18
|
import Button from '../_shared/Button.astro'
|
|
14
19
|
import Section from '../_shared/Section.astro'
|
|
@@ -35,6 +40,7 @@ const dialog = tv({
|
|
|
35
40
|
secondary: { trigger: 'bg-ink text-surface hover:bg-accent hover:text-accent-ink' },
|
|
36
41
|
ghost: { trigger: 'border border-current bg-transparent text-current hover:bg-ink/5' },
|
|
37
42
|
link: { trigger: 'bg-transparent p-0 text-[color:var(--color-link,var(--color-accent))] underline underline-offset-4' },
|
|
43
|
+
none: {},
|
|
38
44
|
},
|
|
39
45
|
size: { sm: { box: '[--dialog-width:28rem]' }, md: { box: '[--dialog-width:40rem]' }, lg: { box: '[--dialog-width:56rem]' } },
|
|
40
46
|
// Where the button sits in the section.
|
|
@@ -54,7 +60,8 @@ interface Props {
|
|
|
54
60
|
actions?: ActionInput[] | undefined
|
|
55
61
|
/** Accessible name of the close button (ui-strings dialog.close). */
|
|
56
62
|
closeLabel?: string | undefined
|
|
57
|
-
|
|
63
|
+
/** `none`: no button of its own; opened by a link to `#<id>` or a `commandfor` button elsewhere on the page. */
|
|
64
|
+
triggerStyle?: 'primary' | 'secondary' | 'ghost' | 'link' | 'none' | undefined
|
|
58
65
|
size?: 'sm' | 'md' | 'lg' | undefined
|
|
59
66
|
align?: 'start' | 'center' | 'end' | undefined
|
|
60
67
|
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
@@ -81,16 +88,20 @@ const align = given ?? measured?.align
|
|
|
81
88
|
const onAccent = tone === 'accent' && !measured?.bg?.image
|
|
82
89
|
const s = dialog({ triggerStyle: onAccent && (triggerStyle ?? 'primary') === 'primary' ? 'secondary' : triggerStyle, size, align })
|
|
83
90
|
const headingId = `${id}-title`
|
|
91
|
+
// Opened only from elsewhere: no button, no band of its own (the page's opener carries the source's label).
|
|
92
|
+
const external = triggerStyle === 'none'
|
|
93
|
+
const Frame = external ? Fragment : Section
|
|
94
|
+
const frame = external ? {} : { measured, tone, spacing: 'md' as const, id: `${id}-section` }
|
|
84
95
|
// Invoker Commands attributes are not in Astro's button types.
|
|
85
96
|
const invoker = { command: 'show-modal', commandfor: id }
|
|
86
97
|
// Scripting off: the content takes the button's place in the section, as a plain block, and the close button goes
|
|
87
98
|
// (it can only close an open dialog). Styles scoped by the dialog's id (a checked token, nothing from content); the selectors win over the UA sheet by specificity.
|
|
88
99
|
const noscript = `<style>[data-kit-dialog="${id}"]>[data-kit-dialog-open],#${id} form[method=dialog]{display:none}#${id}:not([open]){display:block;position:static;margin:0;width:100%;max-width:none;max-height:none;box-shadow:none}</style>`
|
|
89
100
|
---
|
|
90
|
-
<
|
|
91
|
-
<div class={s.root()} data-kit-dialog={id} data-kit-trigger={trigger} data-cr-fact={factId}>
|
|
101
|
+
<Frame {...frame}>
|
|
102
|
+
<div class={external ? undefined : s.root()} data-kit-dialog={id} data-kit-external={external || undefined} data-kit-trigger={trigger} data-cr-fact={factId}>
|
|
92
103
|
<noscript set:html={noscript} />
|
|
93
|
-
<button type="button" class={s.trigger()} data-kit-dialog-open aria-haspopup="dialog" {...invoker}>{triggerLabel}</button>
|
|
104
|
+
{!external && <button type="button" class={s.trigger()} data-kit-dialog-open aria-haspopup="dialog" {...invoker}>{triggerLabel}</button>}
|
|
94
105
|
<dialog id={id} class={s.box()} open={open || undefined} aria-labelledby={heading ? headingId : undefined} aria-label={heading ? undefined : triggerLabel}>
|
|
95
106
|
<div class={s.inner()}>
|
|
96
107
|
{heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
|
|
@@ -106,7 +117,7 @@ const noscript = `<style>[data-kit-dialog="${id}"]>[data-kit-dialog-open],#${id}
|
|
|
106
117
|
</form>
|
|
107
118
|
</dialog>
|
|
108
119
|
</div>
|
|
109
|
-
</
|
|
120
|
+
</Frame>
|
|
110
121
|
|
|
111
122
|
<script>
|
|
112
123
|
// Browsers without Invoker Commands: the button opens the dialog here. Everywhere, a click on the backdrop closes it.
|
|
@@ -114,9 +125,22 @@ const noscript = `<style>[data-kit-dialog="${id}"]>[data-kit-dialog-open],#${id}
|
|
|
114
125
|
for (const root of document.querySelectorAll<HTMLElement>('[data-kit-dialog]')) {
|
|
115
126
|
const dialog = root.querySelector('dialog')
|
|
116
127
|
const open = root.querySelector<HTMLButtonElement>('[data-kit-dialog-open]')
|
|
117
|
-
if (!dialog || !open || root.dataset.ready !== undefined) continue
|
|
128
|
+
if (!dialog || (!open && root.dataset.kitExternal === undefined) || root.dataset.ready !== undefined) continue
|
|
118
129
|
root.dataset.ready = ''
|
|
119
|
-
|
|
130
|
+
const show = () => { if (!dialog.open) dialog.showModal() }
|
|
131
|
+
if (open && !invokers) open.addEventListener('click', show)
|
|
132
|
+
// Opened from elsewhere (`triggerStyle: 'none'`): a link to `#<id>` anywhere on the page, a `commandfor` button where
|
|
133
|
+
// the browser has no Invoker Commands, and an address that arrives with `#<id>`. Ids are plain tokens (checked above).
|
|
134
|
+
if (root.dataset.kitExternal !== undefined) {
|
|
135
|
+
for (const link of document.querySelectorAll<HTMLAnchorElement>(`a[href="#${dialog.id}"]`)) {
|
|
136
|
+
link.setAttribute('aria-haspopup', 'dialog')
|
|
137
|
+
link.addEventListener('click', (event) => { event.preventDefault(); show() })
|
|
138
|
+
}
|
|
139
|
+
if (!invokers) for (const button of document.querySelectorAll<HTMLButtonElement>(`button[commandfor="${dialog.id}"]`)) button.addEventListener('click', show)
|
|
140
|
+
const fromHash = () => { if (location.hash === `#${dialog.id}`) show() }
|
|
141
|
+
fromHash()
|
|
142
|
+
addEventListener('hashchange', fromHash)
|
|
143
|
+
}
|
|
120
144
|
// The inner box fills the dialog and owns the scrollbar, so a click whose target is the dialog itself landed on the backdrop.
|
|
121
145
|
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close() })
|
|
122
146
|
}
|
|
@@ -80,5 +80,24 @@
|
|
|
80
80
|
"open": true,
|
|
81
81
|
"tone": "accent"
|
|
82
82
|
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "external-open",
|
|
86
|
+
"props": {
|
|
87
|
+
"id": "fx-dialog-ext",
|
|
88
|
+
"triggerLabel": "Sign up now",
|
|
89
|
+
"triggerStyle": "none",
|
|
90
|
+
"open": true,
|
|
91
|
+
"heading": "Get the monthly guide",
|
|
92
|
+
"html": "<p>One email a month: what we built, what we learned.</p>",
|
|
93
|
+
"actions": [
|
|
94
|
+
{
|
|
95
|
+
"label": "Read past issues",
|
|
96
|
+
"href": "/journal/"
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
"closeLabel": "Close",
|
|
100
|
+
"trigger": "click"
|
|
101
|
+
}
|
|
83
102
|
}
|
|
84
103
|
]
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
"primary",
|
|
20
20
|
"secondary",
|
|
21
21
|
"ghost",
|
|
22
|
-
"link"
|
|
22
|
+
"link",
|
|
23
|
+
"none"
|
|
23
24
|
],
|
|
24
25
|
"default": "primary",
|
|
25
|
-
"description": "How the opening button looks: a filled or outlined button, or a text link."
|
|
26
|
+
"description": "How the opening button looks: a filled or outlined button, or a text link. none: no button of its own and no section band; opened by a link to #<id> or a commandfor button elsewhere on the page, or an address ending in #<id> (a popup drawn over the page, its opener in the content)."
|
|
26
27
|
},
|
|
27
28
|
"size": {
|
|
28
29
|
"options": [
|
|
@@ -8,6 +8,7 @@ import type { ActionInput, ImageInput, NavItem } from '../_shared/types'
|
|
|
8
8
|
import Nav from '../nav/Nav.astro'
|
|
9
9
|
import SearchBox from '../search-box/SearchBox.astro'
|
|
10
10
|
import { safeHref } from '../_shared/safe-url'
|
|
11
|
+
import { brandColorValue } from './header'
|
|
11
12
|
|
|
12
13
|
const header = tv({
|
|
13
14
|
slots: {
|
|
@@ -50,6 +51,11 @@ interface Props {
|
|
|
50
51
|
padBottom?: string | undefined
|
|
51
52
|
/** The navigation in the site's link colour, as themes that colour every link do (Hello Elementor). */
|
|
52
53
|
linkNav?: boolean | undefined
|
|
54
|
+
/**
|
|
55
|
+
* The site name's colour where the source theme's is not the header's text colour: `link` for the site's link colour
|
|
56
|
+
* (a title that is a link styled like every other), or a colour (`#hex`, `rgb()`, `hsl()`; anything else is ignored).
|
|
57
|
+
*/
|
|
58
|
+
brandColor?: string | undefined
|
|
53
59
|
logo?: ImageInput | undefined
|
|
54
60
|
/** Show the site name next to the logo. */
|
|
55
61
|
showName?: boolean | undefined
|
|
@@ -68,11 +74,12 @@ interface Props {
|
|
|
68
74
|
menu?: 'flyout' | 'mega' | undefined
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
const { siteName, tagline, brandSize, brandWeight, brandTracking, bordered, offsetTop, padTop, padBottom, linkNav, logo, showName = true, homeHref = '/', items, action, search, navLabel, menuLabel, closeLabel, layout, tone, position, menu } = Astro.props
|
|
77
|
+
const { siteName, tagline, brandSize, brandWeight, brandTracking, bordered, offsetTop, padTop, padBottom, linkNav, brandColor, logo, showName = true, homeHref = '/', items, action, search, navLabel, menuLabel, closeLabel, layout, tone, position, menu } = Astro.props
|
|
72
78
|
const { root, inner, brand } = header({ layout, tone, position, bordered })
|
|
73
79
|
const rootStyle = offsetTop ? `margin-top: ${offsetTop}` : undefined
|
|
74
80
|
const innerStyle = [padTop && `padding-top: ${padTop}`, padBottom && `padding-bottom: ${padBottom}`].filter(Boolean).join('; ') || undefined
|
|
75
|
-
const
|
|
81
|
+
const brandColour = brandColorValue(brandColor)
|
|
82
|
+
const brandStyle = [brandSize && `font-size: ${brandSize}`, brandWeight && `font-weight: ${brandWeight}`, brandTracking && `letter-spacing: ${brandTracking}`, brandColour && `color: ${brandColour}`].filter(Boolean).join('; ') || undefined
|
|
76
83
|
// A tagline under the name keeps its line on a phone, as themes print it: the navigation wraps below.
|
|
77
84
|
// The search field sits in the bar from lg; at md the bar already holds the navigation, so the field keeps its own row.
|
|
78
85
|
const wrap = search ? 'flex-wrap lg:flex-nowrap' : tagline ? 'flex-wrap md:flex-nowrap' : undefined
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The header's site-name colour: the site's link colour, a colour the source theme gives the name, or nothing.
|
|
2
|
+
|
|
3
|
+
/** A colour that may go into the brand's style attribute: hex, `rgb()`/`rgba()` or `hsl()`/`hsla()` of numbers. */
|
|
4
|
+
const COLOR = /^(?:#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([\d\s.,%/deg]+\))$/i
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `color` value for the brand: `link` → the site's link colour (the accent where none is set), a colour as it is,
|
|
8
|
+
* anything else (a `;`, a `url()`, a name the plan never writes) → none, so the header keeps its text colour.
|
|
9
|
+
*/
|
|
10
|
+
export function brandColorValue(brandColor: string | undefined): string | undefined {
|
|
11
|
+
if (brandColor === 'link') return 'var(--color-link, var(--color-accent))'
|
|
12
|
+
return brandColor && COLOR.test(brandColor) ? brandColor : undefined
|
|
13
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"padTop": { "type": "string", "description": "Space inside the bar above its content where the source's header has its own (a CSS length)." },
|
|
23
23
|
"padBottom": { "type": "string", "description": "Space inside the bar below its content where the source's header has its own (a CSS length)." },
|
|
24
24
|
"linkNav": { "type": "boolean", "default": false, "description": "The navigation in the site's link colour, as themes that colour every link do." },
|
|
25
|
+
"brandColor": { "type": "string", "description": "The site name's colour where the source theme's is not the header's text colour: link for the site's link colour, or a colour (#hex, rgb(), hsl())." },
|
|
25
26
|
"logo": { "type": "object", "fields": { "src": { "type": "image", "required": true }, "alt": { "type": "string", "required": true }, "width": { "type": "integer" }, "height": { "type": "integer" } }, "description": "Logo image (site.logo)." },
|
|
26
27
|
"showName": { "type": "boolean", "default": true, "description": "Show the site name next to the logo." },
|
|
27
28
|
"homeHref": { "type": "url", "default": "/", "description": "Where the brand links." },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
// A carousel of images with optional captions. Without JavaScript it is a
|
|
2
|
+
// A carousel of images, videos 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
|
|
@@ -10,9 +10,11 @@ import type { ActionInput, MeasuredStyle } from '../_shared/types'
|
|
|
10
10
|
import SliderTrack from './SliderTrack.astro'
|
|
11
11
|
|
|
12
12
|
interface Slide {
|
|
13
|
-
/** The image's address (or, until 0.7, an `ImageInput`). */
|
|
14
|
-
image
|
|
13
|
+
/** The image's address (or, until 0.7, an `ImageInput`). Optional: a text card (a quote, a testimonial) is a slide too. */
|
|
14
|
+
image?: ImageSource | undefined
|
|
15
15
|
imageAlt?: string | undefined
|
|
16
|
+
/** A video file's address (mp4, webm): drawn in place of the image (its poster), played only when the reader asks. */
|
|
17
|
+
video?: string | undefined
|
|
16
18
|
title?: string | undefined
|
|
17
19
|
text?: string | undefined
|
|
18
20
|
/** The slide's link, flat so a page stays within Contentrain's nesting depth. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
// A carousel of images with optional captions
|
|
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
4
|
// horizontally scrolling, snapping row — every slide is reachable. With it,
|
|
4
5
|
// Embla turns the row into a paged carousel with previous/next buttons and
|
|
5
6
|
// slide dots. Slider.astro frames it (the section's own look, when a measured
|
|
@@ -9,6 +10,7 @@ import Button from '../_shared/Button.astro'
|
|
|
9
10
|
import KitImage from '../_shared/KitImage.astro'
|
|
10
11
|
import SectionHeader from '../_shared/SectionHeader.astro'
|
|
11
12
|
import { imageOf, type ImageSource } from '../_shared/image'
|
|
13
|
+
import { safeSrc } from '../_shared/safe-url'
|
|
12
14
|
import { measuredSlides } from '../_shared/measured'
|
|
13
15
|
import { type ActionInput, type ImageInput, type MeasuredStyle } from '../_shared/types'
|
|
14
16
|
|
|
@@ -19,6 +21,8 @@ const slider = tv({
|
|
|
19
21
|
slide: 'relative min-w-0 shrink-0 snap-start overflow-hidden',
|
|
20
22
|
image: 'w-full object-cover',
|
|
21
23
|
caption: 'flex flex-col items-start gap-2',
|
|
24
|
+
// 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: 'flex h-full flex-col items-start gap-2 rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60',
|
|
22
26
|
},
|
|
23
27
|
variants: {
|
|
24
28
|
perView: {
|
|
@@ -41,9 +45,14 @@ const slider = tv({
|
|
|
41
45
|
})
|
|
42
46
|
|
|
43
47
|
interface Slide {
|
|
44
|
-
/** The image's address (or, until 0.7, an `ImageInput`). */
|
|
45
|
-
image
|
|
48
|
+
/** The image's address (or, until 0.7, an `ImageInput`). Optional: a text card (a quote, a testimonial) is a slide too. */
|
|
49
|
+
image?: ImageSource | undefined
|
|
46
50
|
imageAlt?: string | undefined
|
|
51
|
+
/**
|
|
52
|
+
* A video file's address (mp4, webm). Drawn in place of the image, which becomes its poster; it loads nothing until
|
|
53
|
+
* the reader plays it, never plays by itself, and pauses when its slide is paged away. The caption sits under it.
|
|
54
|
+
*/
|
|
55
|
+
video?: string | undefined
|
|
47
56
|
title?: string | undefined
|
|
48
57
|
text?: string | undefined
|
|
49
58
|
/** The slide's link, flat so a page stays within Contentrain's nesting depth. */
|
|
@@ -81,19 +90,28 @@ interface Props {
|
|
|
81
90
|
}
|
|
82
91
|
|
|
83
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 = 'slider', framed = true } = Astro.props
|
|
93
|
+
// A slide is drawn when it has something to show: an image, a video, or a text card (a title, a text or a link) where
|
|
94
|
+
// the source carousel moved quotes or testimonials. A slide with none of them is dropped.
|
|
84
95
|
const slides = given.flatMap((slide) => {
|
|
85
|
-
const image = imageOf(slide.image, slide.imageAlt)
|
|
96
|
+
const image = slide.image === undefined ? undefined : imageOf(slide.image, slide.imageAlt)
|
|
97
|
+
const video = safeSrc(slide.video) || undefined
|
|
86
98
|
const action = slide.action ?? (slide.ctaLabel && slide.ctaHref ? { label: slide.ctaLabel, href: slide.ctaHref } : undefined)
|
|
87
|
-
return image ? [{ ...slide, image, action }] : []
|
|
88
|
-
}) satisfies Array<{ image: ImageInput, action?: ActionInput | 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 }>
|
|
89
101
|
const measuredRow = measuredSlides(measured)
|
|
90
102
|
// A measured row sets the slides in view itself, so the variant's own widths step aside.
|
|
91
103
|
const slots = slider({ perView: measuredRow ? '1' : perView, aspect, caption })
|
|
104
|
+
// A video's caption is never laid over it: it would cover the player's controls.
|
|
105
|
+
const below = slider({ perView: measuredRow ? '1' : perView, aspect, caption: 'below' })
|
|
92
106
|
const trackId = `${id}-track`
|
|
93
107
|
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'
|
|
94
108
|
// Arrows from sm up; on phones the carousel is swiped and the dots show the position.
|
|
95
109
|
const Root = framed ? 'section' : 'div'
|
|
96
110
|
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
|
+
// 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
|
+
// 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)
|
|
114
|
+
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'
|
|
97
115
|
---
|
|
98
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}>
|
|
99
117
|
<SectionHeader heading={heading} intro={intro} eyebrow={eyebrow} actions={actions} />
|
|
@@ -101,9 +119,11 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
|
|
|
101
119
|
<div class:list={['flex gap-4 [&>*]:shrink-0 group-data-[ready]:[&>*]:snap-align-none', measuredRow?.track]} id={trackId} data-container>
|
|
102
120
|
{slides.map((slide, index) => (
|
|
103
121
|
<div class={slots.slide({ class: measuredRow?.slide })} role="group" aria-roledescription="slide" aria-label={`${slideLabel} ${index + 1} ${ofLabel} ${slides.length}`}>
|
|
104
|
-
|
|
122
|
+
{slide.video
|
|
123
|
+
? <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
|
+
: slide.image && <KitImage image={slide.image} sizes={sizes} priority={index === 0} class={slots.image()} />}
|
|
105
125
|
{(slide.title || slide.text || slide.action) && (
|
|
106
|
-
<div class={slots.caption()}>
|
|
126
|
+
<div class={slide.video ? below.caption() : slide.image ? slots.caption() : slots.card()}>
|
|
107
127
|
{slide.title && <p class="text-xl [font-weight:var(--font-weight-heading,600)] text-balance">{slide.title}</p>}
|
|
108
128
|
{slide.text && <p class="opacity-85">{slide.text}</p>}
|
|
109
129
|
{slide.action && <Button action={slide.action} size="sm" class="mt-2" />}
|
|
@@ -113,17 +133,21 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
|
|
|
113
133
|
))}
|
|
114
134
|
</div>
|
|
115
135
|
</div>
|
|
116
|
-
{slides.length > 1 && (
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
<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>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
{slides.length > 1 && (cards
|
|
137
|
+
? (
|
|
138
|
+
<div class="mt-4 hidden items-center justify-center gap-3 group-data-[ready]:flex">
|
|
139
|
+
<button type="button" class={under} aria-label={prevLabel} aria-controls={trackId} data-prev><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></button>
|
|
140
|
+
<div class="flex justify-center" data-dots></div>
|
|
141
|
+
<button type="button" class={under} aria-label={nextLabel} aria-controls={trackId} data-next><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></button>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
: (
|
|
145
|
+
<>
|
|
146
|
+
<button type="button" class:list={[arrow, 'start-6 md:start-10']} aria-label={prevLabel} aria-controls={trackId} data-prev><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></button>
|
|
147
|
+
<button type="button" class:list={[arrow, 'end-6 md:end-10']} aria-label={nextLabel} aria-controls={trackId} data-next><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></button>
|
|
148
|
+
<div class="mt-4 hidden justify-center group-data-[ready]:flex" data-dots></div>
|
|
149
|
+
</>
|
|
150
|
+
))}
|
|
127
151
|
</Root>
|
|
128
152
|
|
|
129
153
|
<script>
|
|
@@ -178,7 +202,9 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
|
|
|
178
202
|
if (prev) prev.disabled = !embla.canScrollPrev()
|
|
179
203
|
if (next) next.disabled = !embla.canScrollNext()
|
|
180
204
|
}
|
|
181
|
-
|
|
205
|
+
// A video paged out of view stops: nothing plays where the reader cannot see it.
|
|
206
|
+
const pause = () => { for (const video of root.querySelectorAll('video')) if (!video.paused) video.pause() }
|
|
207
|
+
embla.on('select', () => { pause(); sync() }).on('reInit', () => { drawDots(); sync() })
|
|
182
208
|
sync()
|
|
183
209
|
// Set up before the layout was final, Embla measures once more when it is: the page loaded, its fonts in, the tab in
|
|
184
210
|
// view. A background tab lays nothing out (ctrl/cmd-click), so its carousel had one snap and a dead "next" until then;
|
|
@@ -2,5 +2,8 @@
|
|
|
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
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
|
+
{ "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
|
+
{ "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
|
+
{ "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." }] } }
|
|
6
9
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "slider",
|
|
3
3
|
"name": "Slider",
|
|
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.",
|
|
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.",
|
|
5
5
|
"category": "section",
|
|
6
6
|
"uses": [],
|
|
7
7
|
"shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "image.ts", "measured.ts", "safe-url.ts", "types.ts"],
|
|
@@ -11,7 +11,7 @@
|
|
|
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", "
|
|
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." },
|
|
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." },
|
|
@@ -34,5 +34,5 @@
|
|
|
34
34
|
"divi": ["divi/et_pb_slider", "divi/et_pb_fullwidth_slider", "divi/et_pb_post_slider"],
|
|
35
35
|
"classic": ["classic/revslider", "classic/metaslider", "classic/smart-slider", "classic/swiper", "classic/slick", "classic/splide", "classic/owl"]
|
|
36
36
|
},
|
|
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."
|
|
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
38
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
// A row of figures, each with a short label, under an optional heading and
|
|
3
|
-
// intro. Figures are
|
|
3
|
+
// intro. Figures are text: the final number is in the HTML. `motion: 'count'` (a source counter that counted up) makes
|
|
4
|
+
// the list a count-up behaviour island: once in view each figure counts from 0 to it, never under reduced motion or
|
|
5
|
+
// without IntersectionObserver, and only the figure's own digits change.
|
|
4
6
|
import { tv } from 'tailwind-variants'
|
|
7
|
+
import BehaviorScript from '../_shared/BehaviorScript.astro'
|
|
5
8
|
import KitImage from '../_shared/KitImage.astro'
|
|
6
9
|
import Section, { type Tone } from '../_shared/Section.astro'
|
|
7
10
|
import { imageOf, type ImageSource } from '../_shared/image'
|
|
@@ -84,13 +87,18 @@ interface Props {
|
|
|
84
87
|
valueSize?: 'md' | 'lg' | 'xl' | 'display' | undefined
|
|
85
88
|
labelSize?: 'sm' | 'base' | 'lg' | undefined
|
|
86
89
|
order?: 'label-first' | 'value-first' | undefined
|
|
90
|
+
/** static: the figures as printed. count: they count up once in view, as the source's counter did. */
|
|
91
|
+
motion?: 'static' | 'count' | undefined
|
|
87
92
|
tone?: Tone | undefined
|
|
88
93
|
/** 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. */
|
|
89
94
|
measured?: MeasuredStyle | undefined
|
|
90
95
|
id?: string | undefined
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
const { heading, intro, items, layout, columns, style, align, valueSize, labelSize, order, tone, measured, id } = Astro.props
|
|
98
|
+
const { heading, intro, items, layout, columns, style, align, valueSize, labelSize, order, motion, tone, measured, id } = Astro.props
|
|
99
|
+
const count = motion === 'count'
|
|
100
|
+
// A count-up island (behaviors.ts `countup`): the root names the kind, each figure's digits are their own span.
|
|
101
|
+
const island = count ? { 'data-cr-behavior': 'countup', 'data-cr-items': '[data-kit-count]' } : {}
|
|
94
102
|
const s = stats({ layout, columns, style, align, valueSize, labelSize, order, tone })
|
|
95
103
|
const grid = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
|
|
96
104
|
const headingId = heading && id ? `${id}-title` : undefined
|
|
@@ -107,22 +115,23 @@ const widthOf = (item: Item) => `--stat-chars:${(item.prefix ?? '').length + ite
|
|
|
107
115
|
</header>
|
|
108
116
|
)}
|
|
109
117
|
{order === 'value-first' ? (
|
|
110
|
-
<ul class={s.list({ class: grid })} role="list">
|
|
118
|
+
<ul class={s.list({ class: grid })} role="list" {...island}>
|
|
111
119
|
{items.map(item => (
|
|
112
120
|
<li class={s.item()}>
|
|
113
|
-
<p class={s.value()} style={widthOf(item)}>{iconOf(item) && <KitImage image={iconOf(item)!} sizes="40px" class={s.icon()} />}{item.prefix}{item.value}{item.suffix}</p>
|
|
121
|
+
<p class={s.value()} style={widthOf(item)}>{iconOf(item) && <KitImage image={iconOf(item)!} sizes="40px" class={s.icon()} />}{item.prefix}{count ? <span data-kit-count>{item.value}</span> : item.value}{item.suffix}</p>
|
|
114
122
|
<p class={s.label()}>{item.label}</p>
|
|
115
123
|
</li>
|
|
116
124
|
))}
|
|
117
125
|
</ul>
|
|
118
126
|
) : (
|
|
119
|
-
<dl class={s.list({ class: grid })}>
|
|
127
|
+
<dl class={s.list({ class: grid })} {...island}>
|
|
120
128
|
{items.map(item => (
|
|
121
129
|
<div class={s.item()}>
|
|
122
130
|
<dt class={s.label()}>{item.label}</dt>
|
|
123
|
-
<dd class={s.value()} style={widthOf(item)}>{iconOf(item) && <KitImage image={iconOf(item)!} sizes="40px" class={s.icon()} />}{item.prefix}{item.value}{item.suffix}</dd>
|
|
131
|
+
<dd class={s.value()} style={widthOf(item)}>{iconOf(item) && <KitImage image={iconOf(item)!} sizes="40px" class={s.icon()} />}{item.prefix}{count ? <span data-kit-count>{item.value}</span> : item.value}{item.suffix}</dd>
|
|
124
132
|
</div>
|
|
125
133
|
))}
|
|
126
134
|
</dl>
|
|
127
135
|
)}
|
|
136
|
+
{count && <BehaviorScript />}
|
|
128
137
|
</Section>
|
|
@@ -416,5 +416,30 @@
|
|
|
416
416
|
"valueSize": "display",
|
|
417
417
|
"align": "center"
|
|
418
418
|
}
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"name": "count-label-lg",
|
|
422
|
+
"props": {
|
|
423
|
+
"motion": "count",
|
|
424
|
+
"labelSize": "lg",
|
|
425
|
+
"heading": "Since 2019",
|
|
426
|
+
"items": [
|
|
427
|
+
{
|
|
428
|
+
"value": "1,200",
|
|
429
|
+
"prefix": "$",
|
|
430
|
+
"suffix": "+",
|
|
431
|
+
"label": "Shops launched"
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
"value": "99.9",
|
|
435
|
+
"suffix": "%",
|
|
436
|
+
"label": "Uptime"
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
"value": "11",
|
|
440
|
+
"label": "Countries shipped to"
|
|
441
|
+
}
|
|
442
|
+
]
|
|
443
|
+
}
|
|
419
444
|
}
|
|
420
445
|
]
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "stats",
|
|
3
3
|
"name": "Stats",
|
|
4
|
-
"description": "A row of figures with a short label each (years in business, clients, uptime), with an optional heading and intro. The counter/number section of a landing page; numbers are
|
|
4
|
+
"description": "A row of figures with a short label each (years in business, clients, uptime), with an optional heading and intro. The counter/number section of a landing page; numbers are text (the final value in the HTML); motion \"count\" counts them up once in view, as a source counter did.",
|
|
5
5
|
"category": "section",
|
|
6
6
|
"uses": [],
|
|
7
7
|
"shared": [
|
|
8
|
+
"BehaviorScript.astro",
|
|
8
9
|
"KitImage.astro",
|
|
9
10
|
"Section.astro",
|
|
11
|
+
"behaviors.ts",
|
|
10
12
|
"image.ts",
|
|
11
13
|
"measured.ts",
|
|
12
14
|
"safe-url.ts",
|
|
@@ -85,6 +87,14 @@
|
|
|
85
87
|
],
|
|
86
88
|
"default": "label-first",
|
|
87
89
|
"description": "Which the page's text reads first, as the source does: label-first (an Elementor counter) or value-first (a Gutenberg figure heading over its label). The figure is drawn on top either way."
|
|
90
|
+
},
|
|
91
|
+
"motion": {
|
|
92
|
+
"options": [
|
|
93
|
+
"static",
|
|
94
|
+
"count"
|
|
95
|
+
],
|
|
96
|
+
"default": "static",
|
|
97
|
+
"description": "static: the figures as printed. count: each counts up from 0 to its value once in view (a source counter that animated); the final number is in the HTML, nothing moves under reduced motion or without IntersectionObserver."
|
|
88
98
|
}
|
|
89
99
|
},
|
|
90
100
|
"props": {
|
|
@@ -253,7 +263,7 @@
|
|
|
253
263
|
}
|
|
254
264
|
},
|
|
255
265
|
"slots": {},
|
|
256
|
-
"js": "
|
|
266
|
+
"js": "vanilla",
|
|
257
267
|
"dependencies": {},
|
|
258
268
|
"sources": {
|
|
259
269
|
"gutenberg": [
|
package/mapping/divi.json
CHANGED
package/mapping/elementor.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentrain/astro-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Copy-in Astro components for Contentrain sites (shadcn model): typed props, tailwind-variants, zero JavaScript by default — with a machine-readable catalog",
|
|
6
6
|
"type": "module",
|
|
@@ -42,10 +42,11 @@
|
|
|
42
42
|
"mapping"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@contentrain/types": "1.
|
|
45
|
+
"@contentrain/types": "1.64.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^22.10.0",
|
|
49
|
+
"happy-dom": "^20.14.5",
|
|
49
50
|
"pixelmatch": "^7.2.0",
|
|
50
51
|
"playwright": "^1.63.0",
|
|
51
52
|
"pngjs": "^7.0.0",
|