@contentrain/astro-kit 0.37.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 +38 -10
- package/components/_shared/BehaviorScript.astro +11 -0
- package/components/_shared/behaviors.ts +20 -5
- package/components/behavior/Behavior.astro +3 -0
- package/components/contact-form/ContactForm.astro +6 -1
- package/components/contact-form/fixtures.json +25 -0
- package/components/contact-form/meta.json +6 -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
|
@@ -1315,6 +1315,7 @@
|
|
|
1315
1315
|
"embed"
|
|
1316
1316
|
],
|
|
1317
1317
|
"shared": [
|
|
1318
|
+
"KitImage.astro",
|
|
1318
1319
|
"Section.astro",
|
|
1319
1320
|
"measured.ts",
|
|
1320
1321
|
"safe-url.ts",
|
|
@@ -1416,6 +1417,11 @@
|
|
|
1416
1417
|
"type": "url",
|
|
1417
1418
|
"label": "Link",
|
|
1418
1419
|
"description": "A tel:, mailto: or map link for it."
|
|
1420
|
+
},
|
|
1421
|
+
"iconImage": {
|
|
1422
|
+
"type": "image",
|
|
1423
|
+
"label": "Icon image",
|
|
1424
|
+
"description": "The detail's own icon as an image file (the old site's map marker or phone, moved as an svg), drawn before it."
|
|
1419
1425
|
}
|
|
1420
1426
|
}
|
|
1421
1427
|
},
|
|
@@ -1617,6 +1623,7 @@
|
|
|
1617
1623
|
"details[].label": "details[].label",
|
|
1618
1624
|
"details[].value": "details[].value",
|
|
1619
1625
|
"details[].href": "details[].href",
|
|
1626
|
+
"details[].iconImage": "details[].icon_image",
|
|
1620
1627
|
"hours": "hours",
|
|
1621
1628
|
"socials": "socials",
|
|
1622
1629
|
"socials[].label": "socials[].label",
|
|
@@ -1948,10 +1955,11 @@
|
|
|
1948
1955
|
"primary",
|
|
1949
1956
|
"secondary",
|
|
1950
1957
|
"ghost",
|
|
1951
|
-
"link"
|
|
1958
|
+
"link",
|
|
1959
|
+
"none"
|
|
1952
1960
|
],
|
|
1953
1961
|
"default": "primary",
|
|
1954
|
-
"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)."
|
|
1955
1963
|
},
|
|
1956
1964
|
"size": {
|
|
1957
1965
|
"options": [
|
|
@@ -3461,6 +3469,10 @@
|
|
|
3461
3469
|
"default": false,
|
|
3462
3470
|
"description": "The navigation in the site's link colour, as themes that colour every link do."
|
|
3463
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
|
+
},
|
|
3464
3476
|
"logo": {
|
|
3465
3477
|
"type": "object",
|
|
3466
3478
|
"fields": {
|
|
@@ -3590,7 +3602,8 @@
|
|
|
3590
3602
|
},
|
|
3591
3603
|
"a11y": "One <header> landmark; the brand link is named by the site name when only the logo shows. Navigation as in nav.",
|
|
3592
3604
|
"files": [
|
|
3593
|
-
"Header.astro"
|
|
3605
|
+
"Header.astro",
|
|
3606
|
+
"header.ts"
|
|
3594
3607
|
]
|
|
3595
3608
|
},
|
|
3596
3609
|
{
|
|
@@ -5720,7 +5733,7 @@
|
|
|
5720
5733
|
{
|
|
5721
5734
|
"id": "slider",
|
|
5722
5735
|
"name": "Slider",
|
|
5723
|
-
"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.",
|
|
5724
5737
|
"category": "section",
|
|
5725
5738
|
"uses": [],
|
|
5726
5739
|
"shared": [
|
|
@@ -5772,14 +5785,18 @@
|
|
|
5772
5785
|
"image": {
|
|
5773
5786
|
"type": "image",
|
|
5774
5787
|
"label": "Image",
|
|
5775
|
-
"
|
|
5776
|
-
"description": "The slide image."
|
|
5788
|
+
"description": "The slide image; a slide without one is a text card (a quote, a testimonial)."
|
|
5777
5789
|
},
|
|
5778
5790
|
"imageAlt": {
|
|
5779
5791
|
"type": "string",
|
|
5780
5792
|
"label": "Image description",
|
|
5781
5793
|
"description": "What the image shows, for readers who cannot see it."
|
|
5782
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
|
+
},
|
|
5783
5800
|
"title": {
|
|
5784
5801
|
"type": "string",
|
|
5785
5802
|
"label": "Title",
|
|
@@ -5802,7 +5819,7 @@
|
|
|
5802
5819
|
}
|
|
5803
5820
|
}
|
|
5804
5821
|
},
|
|
5805
|
-
"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."
|
|
5806
5823
|
},
|
|
5807
5824
|
"label": {
|
|
5808
5825
|
"type": "string",
|
|
@@ -6038,7 +6055,7 @@
|
|
|
6038
6055
|
"classic/owl"
|
|
6039
6056
|
]
|
|
6040
6057
|
},
|
|
6041
|
-
"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.",
|
|
6042
6059
|
"files": [
|
|
6043
6060
|
"Slider.astro",
|
|
6044
6061
|
"SliderTrack.astro"
|
|
@@ -6047,6 +6064,7 @@
|
|
|
6047
6064
|
"slides": "slides",
|
|
6048
6065
|
"slides[].image": "slides[].image",
|
|
6049
6066
|
"slides[].imageAlt": "slides[].image_alt",
|
|
6067
|
+
"slides[].video": "slides[].video",
|
|
6050
6068
|
"slides[].title": "slides[].title",
|
|
6051
6069
|
"slides[].text": "slides[].text",
|
|
6052
6070
|
"slides[].ctaLabel": "slides[].cta_label",
|
|
@@ -6560,12 +6578,14 @@
|
|
|
6560
6578
|
{
|
|
6561
6579
|
"id": "stats",
|
|
6562
6580
|
"name": "Stats",
|
|
6563
|
-
"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.",
|
|
6564
6582
|
"category": "section",
|
|
6565
6583
|
"uses": [],
|
|
6566
6584
|
"shared": [
|
|
6585
|
+
"BehaviorScript.astro",
|
|
6567
6586
|
"KitImage.astro",
|
|
6568
6587
|
"Section.astro",
|
|
6588
|
+
"behaviors.ts",
|
|
6569
6589
|
"image.ts",
|
|
6570
6590
|
"measured.ts",
|
|
6571
6591
|
"safe-url.ts",
|
|
@@ -6644,6 +6664,14 @@
|
|
|
6644
6664
|
],
|
|
6645
6665
|
"default": "label-first",
|
|
6646
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."
|
|
6647
6675
|
}
|
|
6648
6676
|
},
|
|
6649
6677
|
"props": {
|
|
@@ -6812,7 +6840,7 @@
|
|
|
6812
6840
|
}
|
|
6813
6841
|
},
|
|
6814
6842
|
"slots": {},
|
|
6815
|
-
"js": "
|
|
6843
|
+
"js": "vanilla",
|
|
6816
6844
|
"dependencies": {},
|
|
6817
6845
|
"sources": {
|
|
6818
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
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// that posts to their form service, an address to write to, or a link to the
|
|
11
11
|
// form on WordPress.
|
|
12
12
|
import { tv } from 'tailwind-variants'
|
|
13
|
+
import KitImage from '../_shared/KitImage.astro'
|
|
13
14
|
import Section from '../_shared/Section.astro'
|
|
14
15
|
import type { MeasuredStyle } from '../_shared/types'
|
|
15
16
|
import Embed from '../embed/Embed.astro'
|
|
@@ -24,6 +25,8 @@ export interface ContactDetail {
|
|
|
24
25
|
value: string
|
|
25
26
|
/** `mailto:`, `tel:` or a map link. */
|
|
26
27
|
href?: string | undefined
|
|
28
|
+
/** The detail's own icon as an image file (the old site's map marker or phone, moved as an svg), drawn before it. */
|
|
29
|
+
iconImage?: string | undefined
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const contact = tv({
|
|
@@ -35,6 +38,7 @@ const contact = tv({
|
|
|
35
38
|
details: 'mt-2 flex flex-col gap-4',
|
|
36
39
|
term: 'text-sm font-semibold opacity-75',
|
|
37
40
|
value: 'text-current',
|
|
41
|
+
mark: 'mr-2 inline-flex size-5 shrink-0 align-[-0.25em]',
|
|
38
42
|
formColumn: 'flex flex-col gap-4',
|
|
39
43
|
formHeading: 'text-xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-2xl',
|
|
40
44
|
form: 'rounded-card',
|
|
@@ -128,7 +132,7 @@ const FormHeading = heading ? 'h3' : 'h2'
|
|
|
128
132
|
{details.map(detail => (
|
|
129
133
|
<div class={detailsStyle === 'cards' ? s.card() : undefined}>
|
|
130
134
|
<dt class={s.term()}>{detail.label}</dt>
|
|
131
|
-
<dd class={s.value()}>{detail.href ? <a href={safeHref(detail.href)} class={LINK}>{detail.value}</a> : detail.value}</dd>
|
|
135
|
+
<dd class={s.value()}>{detail.iconImage && <span class={s.mark()} aria-hidden="true"><KitImage image={{ src: detail.iconImage, alt: '' }} class="size-5 object-contain" /></span>}{detail.href ? <a href={safeHref(detail.href)} class={LINK}>{detail.value}</a> : detail.value}</dd>
|
|
132
136
|
</div>
|
|
133
137
|
))}
|
|
134
138
|
</dl>
|
|
@@ -137,6 +141,7 @@ const FormHeading = heading ? 'h3' : 'h2'
|
|
|
137
141
|
<ul class={s.details()}>
|
|
138
142
|
{details.map(detail => (
|
|
139
143
|
<li class={s.value({ class: detailsStyle === 'cards' ? s.card() : undefined })}>
|
|
144
|
+
{detail.iconImage && <span class={s.mark()} aria-hidden="true"><KitImage image={{ src: detail.iconImage, alt: '' }} class="size-5 object-contain" /></span>}
|
|
140
145
|
{detail.label && <span class={s.term()}>{detail.label}: </span>}
|
|
141
146
|
{detail.href ? <a href={safeHref(detail.href)} class={LINK}>{detail.value}</a> : detail.value}
|
|
142
147
|
</li>
|
|
@@ -177,5 +177,30 @@
|
|
|
177
177
|
"title": "Our studio"
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"name": "detail-icon-images",
|
|
183
|
+
"props": {
|
|
184
|
+
"model": "contact-submissions",
|
|
185
|
+
"heading": "Contact",
|
|
186
|
+
"intro": "Tell us what you make and when you want to launch.",
|
|
187
|
+
"layout": "stacked",
|
|
188
|
+
"align": "start",
|
|
189
|
+
"details": [
|
|
190
|
+
{
|
|
191
|
+
"value": "Studio 4, Harbour Lane",
|
|
192
|
+
"iconImage": "/kit-fixtures/icon-1.svg"
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"value": "hello@northwind.example",
|
|
196
|
+
"href": "mailto:hello@northwind.example",
|
|
197
|
+
"iconImage": "/kit-fixtures/icon-1.svg"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"value": "+351 21 000 0000",
|
|
201
|
+
"href": "tel:+351210000000"
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
}
|
|
180
205
|
}
|
|
181
206
|
]
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"embed"
|
|
8
8
|
],
|
|
9
9
|
"shared": [
|
|
10
|
+
"KitImage.astro",
|
|
10
11
|
"Section.astro",
|
|
11
12
|
"measured.ts",
|
|
12
13
|
"safe-url.ts",
|
|
@@ -108,6 +109,11 @@
|
|
|
108
109
|
"type": "url",
|
|
109
110
|
"label": "Link",
|
|
110
111
|
"description": "A tel:, mailto: or map link for it."
|
|
112
|
+
},
|
|
113
|
+
"iconImage": {
|
|
114
|
+
"type": "image",
|
|
115
|
+
"label": "Icon image",
|
|
116
|
+
"description": "The detail's own icon as an image file (the old site's map marker or phone, moved as an svg), drawn before it."
|
|
111
117
|
}
|
|
112
118
|
}
|
|
113
119
|
},
|
|
@@ -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",
|