@contentrain/astro-kit 0.38.0 → 0.40.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 +2 -2
- package/catalog.json +51 -12
- package/components/_shared/BehaviorScript.astro +11 -0
- package/components/_shared/behaviors.ts +58 -7
- package/components/_shared/embed-frame.ts +22 -0
- package/components/{embed/embed.ts → _shared/embed-target.ts} +30 -0
- package/components/behavior/Behavior.astro +11 -0
- package/components/behavior/fixtures.json +3 -1
- package/components/behavior/meta.json +2 -0
- package/components/dialog/Dialog.astro +31 -7
- package/components/dialog/fixtures.json +19 -0
- package/components/dialog/meta.json +3 -2
- package/components/embed/Embed.astro +7 -10
- package/components/embed/meta.json +1 -1
- 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 +9 -3
- package/components/slider/SliderTrack.astro +97 -21
- package/components/slider/fixtures.json +5 -1
- package/components/slider/meta.json +5 -4
- package/components/stats/Stats.astro +15 -6
- package/components/stats/fixtures.json +25 -0
- package/components/stats/meta.json +14 -2
- package/mapping/classic.json +14 -7
- package/mapping/divi.json +8 -4
- package/mapping/elementor.json +16 -8
- package/mapping/gutenberg.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 (a file played only on request, or a YouTube/Vimeo link loaded only on click) 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 |
|
|
@@ -33,7 +33,7 @@ They read its design tokens and pass its gates: `astro check` with the
|
|
|
33
33
|
| `breadcrumb` | Ancestors, then the current page, optional JSON-LD | none |
|
|
34
34
|
| `band` | The styled fallback section: heading, lead, image, a grid of items (figure, title, detail, text, points, link) and buttons, in the measured frame | none |
|
|
35
35
|
| `dialog` | A popup the visitor opens: a button and a modal `<dialog>` with text, buttons and a `form` slot; never opens by itself | a few lines, no dependency |
|
|
36
|
-
| `behavior` | A behavior island: a source widget no component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links, image lightbox), kept as its own markup and wired by one vanilla module; static and readable without it | `_shared/behaviors.ts`, no dependency |
|
|
36
|
+
| `behavior` | A behavior island: a source widget no component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links, image lightbox, a video player kept as a poster link that plays in place on click), kept as its own markup and wired by one vanilla module; static and readable without it | `_shared/behaviors.ts`, no dependency |
|
|
37
37
|
|
|
38
38
|
Each component lives in `components/<id>/`:
|
|
39
39
|
|
package/catalog.json
CHANGED
|
@@ -455,6 +455,8 @@
|
|
|
455
455
|
"shared": [
|
|
456
456
|
"Section.astro",
|
|
457
457
|
"behaviors.ts",
|
|
458
|
+
"embed-frame.ts",
|
|
459
|
+
"embed-target.ts",
|
|
458
460
|
"measured.ts",
|
|
459
461
|
"safe-url.ts",
|
|
460
462
|
"types.ts"
|
|
@@ -1955,10 +1957,11 @@
|
|
|
1955
1957
|
"primary",
|
|
1956
1958
|
"secondary",
|
|
1957
1959
|
"ghost",
|
|
1958
|
-
"link"
|
|
1960
|
+
"link",
|
|
1961
|
+
"none"
|
|
1959
1962
|
],
|
|
1960
1963
|
"default": "primary",
|
|
1961
|
-
"description": "How the opening button looks: a filled or outlined button, or a text link."
|
|
1964
|
+
"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
1965
|
},
|
|
1963
1966
|
"size": {
|
|
1964
1967
|
"options": [
|
|
@@ -2225,6 +2228,8 @@
|
|
|
2225
2228
|
"uses": [],
|
|
2226
2229
|
"shared": [
|
|
2227
2230
|
"KitImage.astro",
|
|
2231
|
+
"embed-frame.ts",
|
|
2232
|
+
"embed-target.ts",
|
|
2228
2233
|
"safe-url.ts",
|
|
2229
2234
|
"types.ts"
|
|
2230
2235
|
],
|
|
@@ -2322,8 +2327,7 @@
|
|
|
2322
2327
|
},
|
|
2323
2328
|
"a11y": "The poster is one link named '<action>: <title>' that opens the provider's page without script. With script, Enter or a click puts the iframe in its place with the title as its accessible name and moves focus into it. Modified clicks (new tab) keep the link behaviour. The play icon is decorative.",
|
|
2324
2329
|
"files": [
|
|
2325
|
-
"Embed.astro"
|
|
2326
|
-
"embed.ts"
|
|
2330
|
+
"Embed.astro"
|
|
2327
2331
|
]
|
|
2328
2332
|
},
|
|
2329
2333
|
{
|
|
@@ -3468,6 +3472,10 @@
|
|
|
3468
3472
|
"default": false,
|
|
3469
3473
|
"description": "The navigation in the site's link colour, as themes that colour every link do."
|
|
3470
3474
|
},
|
|
3475
|
+
"brandColor": {
|
|
3476
|
+
"type": "string",
|
|
3477
|
+
"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())."
|
|
3478
|
+
},
|
|
3471
3479
|
"logo": {
|
|
3472
3480
|
"type": "object",
|
|
3473
3481
|
"fields": {
|
|
@@ -3597,7 +3605,8 @@
|
|
|
3597
3605
|
},
|
|
3598
3606
|
"a11y": "One <header> landmark; the brand link is named by the site name when only the logo shows. Navigation as in nav.",
|
|
3599
3607
|
"files": [
|
|
3600
|
-
"Header.astro"
|
|
3608
|
+
"Header.astro",
|
|
3609
|
+
"header.ts"
|
|
3601
3610
|
]
|
|
3602
3611
|
},
|
|
3603
3612
|
{
|
|
@@ -5727,7 +5736,7 @@
|
|
|
5727
5736
|
{
|
|
5728
5737
|
"id": "slider",
|
|
5729
5738
|
"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.",
|
|
5739
|
+
"description": "A carousel of images, videos (a file, or a YouTube/Vimeo link loaded only on click) or text cards, with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
|
|
5731
5740
|
"category": "section",
|
|
5732
5741
|
"uses": [],
|
|
5733
5742
|
"shared": [
|
|
@@ -5735,6 +5744,8 @@
|
|
|
5735
5744
|
"KitImage.astro",
|
|
5736
5745
|
"Section.astro",
|
|
5737
5746
|
"SectionHeader.astro",
|
|
5747
|
+
"embed-frame.ts",
|
|
5748
|
+
"embed-target.ts",
|
|
5738
5749
|
"image.ts",
|
|
5739
5750
|
"measured.ts",
|
|
5740
5751
|
"safe-url.ts",
|
|
@@ -5779,14 +5790,23 @@
|
|
|
5779
5790
|
"image": {
|
|
5780
5791
|
"type": "image",
|
|
5781
5792
|
"label": "Image",
|
|
5782
|
-
"
|
|
5783
|
-
"description": "The slide image."
|
|
5793
|
+
"description": "The slide image; a slide without one is a text card (a quote, a testimonial)."
|
|
5784
5794
|
},
|
|
5785
5795
|
"imageAlt": {
|
|
5786
5796
|
"type": "string",
|
|
5787
5797
|
"label": "Image description",
|
|
5788
5798
|
"description": "What the image shows, for readers who cannot see it."
|
|
5789
5799
|
},
|
|
5800
|
+
"video": {
|
|
5801
|
+
"type": "video",
|
|
5802
|
+
"label": "Video",
|
|
5803
|
+
"description": "A video file (mp4, webm) shown in place of the image, which is its poster. It plays only when the reader starts it."
|
|
5804
|
+
},
|
|
5805
|
+
"embed": {
|
|
5806
|
+
"type": "url",
|
|
5807
|
+
"label": "Video link",
|
|
5808
|
+
"description": "A video on another site: its YouTube, Vimeo, Loom or Wistia page (or youtube:<id> / vimeo:<id>). Shown as the image with a play button; nothing from that site loads until the reader clicks it. Wins over Video."
|
|
5809
|
+
},
|
|
5790
5810
|
"title": {
|
|
5791
5811
|
"type": "string",
|
|
5792
5812
|
"label": "Title",
|
|
@@ -5809,7 +5829,7 @@
|
|
|
5809
5829
|
}
|
|
5810
5830
|
}
|
|
5811
5831
|
},
|
|
5812
|
-
"description": "Slides: the image's address and text, and an optional link (ctaLabel + ctaHref)."
|
|
5832
|
+
"description": "Slides: the image's address and text, an optional video file or video link (the image is its poster), and an optional link (ctaLabel + ctaHref). A slide with no image or video is a text card; one with none of them is not drawn."
|
|
5813
5833
|
},
|
|
5814
5834
|
"label": {
|
|
5815
5835
|
"type": "string",
|
|
@@ -5837,6 +5857,11 @@
|
|
|
5837
5857
|
"content": false,
|
|
5838
5858
|
"description": "ui-strings slider.of."
|
|
5839
5859
|
},
|
|
5860
|
+
"playLabel": {
|
|
5861
|
+
"type": "string",
|
|
5862
|
+
"content": false,
|
|
5863
|
+
"description": "ui-strings embed.play."
|
|
5864
|
+
},
|
|
5840
5865
|
"loop": {
|
|
5841
5866
|
"type": "boolean",
|
|
5842
5867
|
"default": false,
|
|
@@ -6045,7 +6070,7 @@
|
|
|
6045
6070
|
"classic/owl"
|
|
6046
6071
|
]
|
|
6047
6072
|
},
|
|
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.",
|
|
6073
|
+
"a11y": "WAI-ARIA carousel pattern: a labelled region with aria-roledescription=carousel, slides as labelled groups ('Slide 2 of 5'), labelled previous/next buttons disabled at the ends, and dot buttons marking the current slide. The track and the buttons are tied by aria-controls, the row takes focus (arrow keys page it, polite live announcement). There is no autoplay, so nothing moves unasked: a video slide loads nothing until the reader plays it, never starts by itself, keeps its native controls with its caption under them, and pauses when its slide is paged away; a video link is a labelled poster link ('Play: title') to the video's page that becomes the player only on a click (no request to the provider before it; YouTube from youtube-nocookie.com, Vimeo with dnt), keeps the poster's box so nothing moves, takes focus, and goes back to its poster when its slide is paged away; with prefers-reduced-motion slides change without animation. Without JavaScript every slide is reachable by scrolling.",
|
|
6049
6074
|
"files": [
|
|
6050
6075
|
"Slider.astro",
|
|
6051
6076
|
"SliderTrack.astro"
|
|
@@ -6054,6 +6079,8 @@
|
|
|
6054
6079
|
"slides": "slides",
|
|
6055
6080
|
"slides[].image": "slides[].image",
|
|
6056
6081
|
"slides[].imageAlt": "slides[].image_alt",
|
|
6082
|
+
"slides[].video": "slides[].video",
|
|
6083
|
+
"slides[].embed": "slides[].embed",
|
|
6057
6084
|
"slides[].title": "slides[].title",
|
|
6058
6085
|
"slides[].text": "slides[].text",
|
|
6059
6086
|
"slides[].ctaLabel": "slides[].cta_label",
|
|
@@ -6567,12 +6594,16 @@
|
|
|
6567
6594
|
{
|
|
6568
6595
|
"id": "stats",
|
|
6569
6596
|
"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
|
|
6597
|
+
"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
6598
|
"category": "section",
|
|
6572
6599
|
"uses": [],
|
|
6573
6600
|
"shared": [
|
|
6601
|
+
"BehaviorScript.astro",
|
|
6574
6602
|
"KitImage.astro",
|
|
6575
6603
|
"Section.astro",
|
|
6604
|
+
"behaviors.ts",
|
|
6605
|
+
"embed-frame.ts",
|
|
6606
|
+
"embed-target.ts",
|
|
6576
6607
|
"image.ts",
|
|
6577
6608
|
"measured.ts",
|
|
6578
6609
|
"safe-url.ts",
|
|
@@ -6651,6 +6682,14 @@
|
|
|
6651
6682
|
],
|
|
6652
6683
|
"default": "label-first",
|
|
6653
6684
|
"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."
|
|
6685
|
+
},
|
|
6686
|
+
"motion": {
|
|
6687
|
+
"options": [
|
|
6688
|
+
"static",
|
|
6689
|
+
"count"
|
|
6690
|
+
],
|
|
6691
|
+
"default": "static",
|
|
6692
|
+
"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
6693
|
}
|
|
6655
6694
|
},
|
|
6656
6695
|
"props": {
|
|
@@ -6819,7 +6858,7 @@
|
|
|
6819
6858
|
}
|
|
6820
6859
|
},
|
|
6821
6860
|
"slots": {},
|
|
6822
|
-
"js": "
|
|
6861
|
+
"js": "vanilla",
|
|
6823
6862
|
"dependencies": {},
|
|
6824
6863
|
"sources": {
|
|
6825
6864
|
"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>
|
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Each island is a root the Behavior component prints: `data-cr-behavior` names the kind, and the item, title and
|
|
6
6
|
// panel selectors come from the engine (`data-cr-items`, `data-cr-title`, `data-cr-panel`), never from the source
|
|
7
|
-
// (the
|
|
8
|
-
// `details`, `hidden` and those attributes
|
|
7
|
+
// (the engine's island pass strips every `data-cr-*` the source markup carried before it wraps it). The module reads only ARIA,
|
|
8
|
+
// `details`, `hidden` and those attributes, plus `data-cr-video` on a link the engine wrote after that strip (a source
|
|
9
|
+
// video player kept as a poster link to its page).
|
|
9
10
|
//
|
|
10
11
|
// Rules (t13's PR-E checklist §4): text goes in through `textContent` only, never `innerHTML` or its family; no
|
|
11
12
|
// `eval`, `Function`, `fetch` or `location`; every selector is scoped to its island root and run in try/catch (a
|
|
12
13
|
// selector the browser refuses leaves that island static); `init` is idempotent; anything that fails leaves the
|
|
13
14
|
// island as it was printed: readable, every item in reading order. Reduced motion is honoured.
|
|
14
15
|
|
|
16
|
+
import { playerFrame, plainClick } from './embed-frame'
|
|
17
|
+
import { videoTarget } from './embed-target'
|
|
18
|
+
|
|
15
19
|
type Kind = 'disclosure' | 'tablist' | 'carousel' | 'dialog' | 'countup' | 'toggle' | 'filter' | 'anchor' | 'lightbox'
|
|
16
20
|
|
|
17
21
|
const KINDS: ReadonlySet<string> = new Set<Kind>(['disclosure', 'tablist', 'carousel', 'dialog', 'countup', 'toggle', 'filter', 'anchor', 'lightbox'])
|
|
@@ -103,7 +107,17 @@ function carousel(root: HTMLElement): void {
|
|
|
103
107
|
function dialog(root: HTMLElement): void {
|
|
104
108
|
for (const opener of within(root, '[aria-haspopup="dialog"][aria-controls]')) {
|
|
105
109
|
const content = target(root, opener.getAttribute('aria-controls'))
|
|
106
|
-
if (!content
|
|
110
|
+
if (!content) continue
|
|
111
|
+
// The source's own closed <dialog> (the island pass keeps it closed, its openers named by aria-controls): opened as it
|
|
112
|
+
// is, with a close button where it has none. One inside another dialog is that dialog's content, not ours to open.
|
|
113
|
+
if (content instanceof HTMLDialogElement) {
|
|
114
|
+
if (content.parentElement?.closest('dialog')) continue
|
|
115
|
+
if (!content.querySelector('form[method="dialog"], .cr-behavior-control')) content.append(control(root.dataset.crClose ?? 'Close', () => content.close()))
|
|
116
|
+
opener.addEventListener('click', (event) => { event.preventDefault(); if (!content.open) content.showModal() })
|
|
117
|
+
content.addEventListener('click', (event) => { if (event.target === content) content.close() })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (content.closest('dialog')) continue
|
|
107
121
|
const box = document.createElement('dialog')
|
|
108
122
|
box.className = 'cr-behavior-dialog'
|
|
109
123
|
content.before(box)
|
|
@@ -222,6 +236,35 @@ function lightbox(root: HTMLElement): void {
|
|
|
222
236
|
}
|
|
223
237
|
}
|
|
224
238
|
|
|
239
|
+
/**
|
|
240
|
+
* A source video player the engine kept as a link to its page (`<a href=watch-url data-cr-video="youtube:<id>">`, a
|
|
241
|
+
* poster image inside): a plain click plays it in place. Nothing loads before the click, and the player's address is
|
|
242
|
+
* built from the checked provider and id (embed-target.ts), never from the link's `href` or the attribute's text: an
|
|
243
|
+
* id the parser refuses leaves the link as it is, opening the provider's page. The player takes the link's own box,
|
|
244
|
+
* so nothing around it moves.
|
|
245
|
+
*/
|
|
246
|
+
function video(root: HTMLElement): void {
|
|
247
|
+
for (const link of within(root, 'a[href][data-cr-video]') as HTMLAnchorElement[]) {
|
|
248
|
+
// Only `youtube:<id>` / `vimeo:<id>`, the form the engine writes: never an address, so no text of the attribute can
|
|
249
|
+
// reach the frame. One inside a nested root came with that (spoofed) root's markup: never ours to wire.
|
|
250
|
+
const value = link.dataset.crVideo ?? ''
|
|
251
|
+
const found = /^(?:youtube|vimeo):[\w-]+$/.test(value) && link.parentElement?.closest('[data-cr-behavior]') === root ? videoTarget(value) : null
|
|
252
|
+
if (!found) continue
|
|
253
|
+
link.addEventListener('click', (event) => {
|
|
254
|
+
if (!plainClick(event)) return
|
|
255
|
+
// A poster's own box; a link with no picture (a title only) plays at 16:9, never as tall as one line of text.
|
|
256
|
+
const box = link.querySelector('img') ? link.getBoundingClientRect() : null
|
|
257
|
+
const name = link.getAttribute('aria-label') || link.textContent?.trim() || link.querySelector('img')?.alt || found.provider
|
|
258
|
+
const frame = playerFrame(found.src, name, 'cr-behavior-video')
|
|
259
|
+
if (!frame) return
|
|
260
|
+
event.preventDefault()
|
|
261
|
+
Object.assign(frame.style, { display: 'block', width: '100%', height: 'auto', border: '0', aspectRatio: box && box.width > 0 && box.height > 0 ? `${box.width} / ${box.height}` : '16 / 9' })
|
|
262
|
+
link.replaceWith(frame)
|
|
263
|
+
frame.focus()
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
225
268
|
const WIRE: Record<Kind, (root: HTMLElement) => void> = { disclosure, tablist, carousel, dialog, countup, toggle, filter, anchor, lightbox }
|
|
226
269
|
|
|
227
270
|
/** Wires every island on the page once. Safe to call again (a view transition, a second script copy). */
|
|
@@ -231,12 +274,20 @@ export function initBehaviors(scope: ParentNode = document): void {
|
|
|
231
274
|
// A root inside another root is not ours: the component prints one root per island, so a nested one came with the
|
|
232
275
|
// markup (a spoofed island the writer's pass would strip). Never wired, so its selectors never run.
|
|
233
276
|
if (root.dataset.crReady !== undefined || !KINDS.has(kind) || root.parentElement?.closest('[data-cr-behavior]')) continue
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
277
|
+
// Marked first: a wiring that fails part-way is never run again over what it already wired.
|
|
278
|
+
root.dataset.crReady = ''
|
|
279
|
+
try { WIRE[kind as Kind](root) }
|
|
238
280
|
catch {
|
|
239
281
|
// Left as printed: the content is all there, it just does not move.
|
|
240
282
|
}
|
|
283
|
+
// A video link plays in place in any island: a player sat in a tab, a slide or a panel as often as on its own.
|
|
284
|
+
try { video(root) }
|
|
285
|
+
catch { /* its links stay links */ }
|
|
286
|
+
// A dialog opener inside an island of another kind (a popup button in a tab): opened too, never left dead, even
|
|
287
|
+
// where the island's own wiring failed.
|
|
288
|
+
if (kind !== 'dialog') {
|
|
289
|
+
try { dialog(root) }
|
|
290
|
+
catch { /* its dialogs stay as printed */ }
|
|
291
|
+
}
|
|
241
292
|
}
|
|
242
293
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The player a click on an embed poster becomes: one recipe for the kit Embed, a Slider slide and a behaviour
|
|
2
|
+
// island's video link, so the frame's permissions are written once. The address is checked again at the click: what
|
|
3
|
+
// the page carries (`data-embed-src`, which an edited page could change) goes through embed-target.ts once more, and
|
|
4
|
+
// the frame loads the address that builds from a known provider, never the attribute's text.
|
|
5
|
+
import { embedTarget } from './embed-target'
|
|
6
|
+
|
|
7
|
+
/** An iframe for the player at `src`, named `title`, filling its positioned frame; none when `src` is no known player. */
|
|
8
|
+
export function playerFrame(src: string | undefined, title: string, className = 'absolute inset-0 h-full w-full border-0'): HTMLIFrameElement | null {
|
|
9
|
+
const target = src ? embedTarget(src) : null
|
|
10
|
+
if (!target) return null
|
|
11
|
+
const frame = document.createElement('iframe')
|
|
12
|
+
frame.src = target.src
|
|
13
|
+
frame.title = title
|
|
14
|
+
frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write'
|
|
15
|
+
frame.allowFullscreen = true
|
|
16
|
+
frame.referrerPolicy = 'strict-origin-when-cross-origin'
|
|
17
|
+
frame.className = className
|
|
18
|
+
return frame
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A plain left click: a modified one (new tab, new window) keeps the link's own behaviour. */
|
|
22
|
+
export const plainClick = (event: MouseEvent): boolean => !event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
|
|
@@ -107,3 +107,33 @@ export function mapTarget(address: string): EmbedTarget | null {
|
|
|
107
107
|
const query = encodeURIComponent(q)
|
|
108
108
|
return { provider: 'google-maps', kind: 'map', src: `https://www.google.com/maps?q=${query}&output=embed`, href: `https://www.google.com/maps/search/?api=1&query=${query}` }
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
const VIDEO_PAGE: Record<string, (id: string) => string> = {
|
|
112
|
+
youtube: id => `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`,
|
|
113
|
+
vimeo: id => `https://vimeo.com/${encodeURIComponent(id)}`,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A video to play in place: a video page or player URL, or `youtube:<id>` / `vimeo:<id>` (what the engine writes on a
|
|
118
|
+
* slide or an island link). Only a video comes back (never a map or a form); the id is checked by the same parser as a
|
|
119
|
+
* URL, so the player address is always built here, never taken from the content.
|
|
120
|
+
*/
|
|
121
|
+
export function videoTarget(value: string | undefined): EmbedTarget | null {
|
|
122
|
+
if (!value) return null
|
|
123
|
+
const short = /^(youtube|vimeo):([\w-]+)$/.exec(value.trim())
|
|
124
|
+
const target = embedTarget(short ? VIDEO_PAGE[short[1]!]!(short[2]!) : value)
|
|
125
|
+
return target?.kind === 'video' ? target : null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Hosts a video provider serves its players, thumbnails and streams from. */
|
|
129
|
+
const VIDEO_HOST = /(?:^|\.)(?:youtube|youtube-nocookie|youtu|ytimg|googlevideo|ggpht|vimeo|vimeocdn|loom|wistia)\.(?:com|be|net)$/i
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* An address on a video provider's own hosts (a YouTube thumbnail on i.ytimg.com, a Vimeo one on vimeocdn.com): never
|
|
133
|
+
* drawn as a poster, since loading it is a request to the provider before the reader asked for the video.
|
|
134
|
+
*/
|
|
135
|
+
export function onVideoHost(src: string | undefined): boolean {
|
|
136
|
+
if (!src) return false
|
|
137
|
+
try { return VIDEO_HOST.test(new URL(src, 'https://site.invalid').hostname) }
|
|
138
|
+
catch { return false }
|
|
139
|
+
}
|
|
@@ -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
|
|
|
@@ -68,4 +71,12 @@ const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
|
|
|
68
71
|
.cr-behavior-dialog { margin: auto; max-width: min(92vw, 40rem); max-height: 85dvh; border: 1px solid var(--color-line); border-radius: var(--radius-card, 0.75rem); padding: 1.5rem; }
|
|
69
72
|
.cr-behavior-dialog::backdrop { background: rgb(0 0 0 / 0.5); }
|
|
70
73
|
.cr-behavior-lightbox img { display: block; max-width: 100%; max-height: 75dvh; margin-inline: auto; }
|
|
74
|
+
/* A source video player kept as a poster link (data-cr-video): a play button over it, drawn in CSS (nothing loads). */
|
|
75
|
+
.cr-behavior a[data-cr-video] { position: relative; display: block; }
|
|
76
|
+
.cr-behavior a[data-cr-video] img { display: block; width: 100%; height: auto; }
|
|
77
|
+
.cr-behavior a[data-cr-video]::before { content: ''; position: absolute; z-index: 1; top: 50%; left: 50%; width: 4rem; height: 4rem; translate: -50% -50%; border-radius: 9999px; background: var(--color-accent); box-shadow: 0 4px 12px rgb(0 0 0 / 0.25); }
|
|
78
|
+
.cr-behavior a[data-cr-video]::after { content: ''; position: absolute; z-index: 2; top: 50%; left: 50%; translate: -35% -50%; border-top: 0.75rem solid transparent; border-bottom: 0.75rem solid transparent; border-left: 1.25rem solid var(--color-accent-ink, #fff); }
|
|
79
|
+
/* No poster (only the title): a 16:9 dark panel, the title along its foot, the button in its middle. */
|
|
80
|
+
.cr-behavior a[data-cr-video]:not(:has(img)) { aspect-ratio: 16 / 9; display: flex; align-items: flex-end; padding: 1rem 1.25rem; border-radius: var(--radius-card, 0.75rem); background: var(--color-ink, #111827); color: var(--color-surface, #fff); font-weight: 600; text-decoration: none; }
|
|
81
|
+
.cr-behavior a[data-cr-video]:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
|
71
82
|
</style>
|
|
@@ -50,5 +50,7 @@
|
|
|
50
50
|
"tone": "accent",
|
|
51
51
|
"html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx3-a\">Do you ship abroad?</button></h2><div id=\"cr-fx3-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
|
|
52
52
|
}
|
|
53
|
-
}
|
|
53
|
+
},
|
|
54
|
+
{"name": "video-link", "props": {"kind": "anchor", "fact": "interactive:fx5", "html": "<h2>Studio tour</h2><p>A walk through the workshop.</p><a href=\"https://www.youtube.com/watch?v=aqz-KE-bpKQ\" target=\"_blank\" rel=\"noopener noreferrer\" data-cr-video=\"youtube:aqz-KE-bpKQ\"><img src=\"/kit-fixtures/photo-2.svg\" alt=\"\" loading=\"lazy\" width=\"1200\" height=\"800\"><span>Studio tour</span></a>"}},
|
|
55
|
+
{"name": "video-link-no-poster", "props": {"kind": "anchor", "fact": "interactive:fx6", "html": "<h2>Studio tour</h2><a href=\"https://vimeo.com/76979871\" target=\"_blank\" rel=\"noopener noreferrer\" data-cr-video=\"vimeo:76979871\"><span>Behind the scenes</span></a>"}}
|
|
54
56
|
]
|
|
@@ -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,7 +8,7 @@
|
|
|
8
8
|
import { tv } from 'tailwind-variants'
|
|
9
9
|
import KitImage from '../_shared/KitImage.astro'
|
|
10
10
|
import type { ImageInput } from '../_shared/types'
|
|
11
|
-
import { embedTarget, mapTarget } from '
|
|
11
|
+
import { embedTarget, mapTarget } from '../_shared/embed-target'
|
|
12
12
|
|
|
13
13
|
const embed = tv({
|
|
14
14
|
slots: {
|
|
@@ -84,20 +84,17 @@ const linkUrl = url && /^https?:\/\//i.test(url) ? url : undefined
|
|
|
84
84
|
)}
|
|
85
85
|
|
|
86
86
|
<script>
|
|
87
|
+
import { playerFrame, plainClick } from '../_shared/embed-frame'
|
|
88
|
+
|
|
87
89
|
// Click (or Enter) on the poster: the player replaces it, focused, so keyboard and screen-reader users land in it.
|
|
88
90
|
// A modified click (new tab, new window) keeps the link's own behaviour.
|
|
89
91
|
document.addEventListener('click', (event) => {
|
|
90
|
-
if (event
|
|
92
|
+
if (!plainClick(event)) return
|
|
91
93
|
const link = (event.target as Element | null)?.closest<HTMLAnchorElement>('[data-kit-embed] a[data-embed-src]')
|
|
92
|
-
|
|
94
|
+
// An address that is no known player stays what it is: a link.
|
|
95
|
+
const frame = link && playerFrame(link.dataset.embedSrc, link.dataset.embedTitle ?? '')
|
|
96
|
+
if (!link || !frame) return
|
|
93
97
|
event.preventDefault()
|
|
94
|
-
const frame = document.createElement('iframe')
|
|
95
|
-
frame.src = link.dataset.embedSrc
|
|
96
|
-
frame.title = link.dataset.embedTitle ?? ''
|
|
97
|
-
frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write'
|
|
98
|
-
frame.allowFullscreen = true
|
|
99
|
-
frame.referrerPolicy = 'strict-origin-when-cross-origin'
|
|
100
|
-
frame.className = 'absolute inset-0 h-full w-full border-0'
|
|
101
98
|
link.replaceWith(frame)
|
|
102
99
|
frame.focus()
|
|
103
100
|
})
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"description": "A video, map or player from another site (YouTube, Vimeo, Google Maps, OpenStreetMap, Spotify, SoundCloud, Calendly, Typeform, Google Forms, Loom, Wistia), loaded only on click: until then a poster link to the provider's page, with no iframe, no third-party request and no cookie (YouTube through youtube-nocookie.com). Unknown providers are never framed; they stay a plain link.",
|
|
5
5
|
"category": "content",
|
|
6
6
|
"uses": [],
|
|
7
|
-
"shared": ["KitImage.astro", "safe-url.ts", "types.ts"],
|
|
7
|
+
"shared": ["KitImage.astro", "embed-frame.ts", "embed-target.ts", "safe-url.ts", "types.ts"],
|
|
8
8
|
"variants": {
|
|
9
9
|
"aspect": { "options": ["auto", "video", "portrait", "square", "map"], "default": "auto", "description": "auto: 16:9 for videos, 9:16 for YouTube Shorts, 4:3 (16:9 wide) for maps, a fixed strip for audio players. The others force a ratio." },
|
|
10
10
|
"width": { "options": ["prose", "page"], "default": "prose", "description": "prose: as wide as the reading column. page: the full page frame." }
|