@contentrain/astro-kit 0.36.0 → 0.37.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 -0
- package/catalog.json +232 -2
- package/components/_shared/behaviors.ts +242 -0
- package/components/behavior/Behavior.astro +71 -0
- package/components/behavior/fixtures.json +54 -0
- package/components/behavior/meta.json +202 -0
- package/components/dialog/Dialog.astro +14 -4
- package/components/dialog/meta.json +19 -1
- package/components/feature-list/FeatureList.astro +11 -2
- package/components/feature-list/fixtures.json +1 -0
- package/components/feature-list/meta.json +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ They read its design tokens and pass its gates: `astro check` with the
|
|
|
32
32
|
| `pagination` | WordPress-style `/page/N/` links | none |
|
|
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
|
+
| `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 |
|
|
35
37
|
|
|
36
38
|
Each component lives in `components/<id>/`:
|
|
37
39
|
|
package/catalog.json
CHANGED
|
@@ -446,6 +446,211 @@
|
|
|
446
446
|
"actions[].style": "actions[].style"
|
|
447
447
|
}
|
|
448
448
|
},
|
|
449
|
+
{
|
|
450
|
+
"id": "behavior",
|
|
451
|
+
"name": "Behavior",
|
|
452
|
+
"description": "A behavior island: a source widget no kit component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links), kept as the site's own markup and wired by the kit's one small vanilla module. The markup is a rich-text field the writer has run through its island pass (no style, script, form or overlay; no source data-cr-*; ids prefixed); the data-cr-* attributes on the root come from props only. No script, or a refused selector: the island stays as printed, every item in reading order. Only pages with an island load the module.",
|
|
453
|
+
"category": "content",
|
|
454
|
+
"uses": [],
|
|
455
|
+
"shared": [
|
|
456
|
+
"Section.astro",
|
|
457
|
+
"behaviors.ts",
|
|
458
|
+
"measured.ts",
|
|
459
|
+
"safe-url.ts",
|
|
460
|
+
"types.ts"
|
|
461
|
+
],
|
|
462
|
+
"variants": {
|
|
463
|
+
"tone": {
|
|
464
|
+
"options": [
|
|
465
|
+
"default",
|
|
466
|
+
"muted",
|
|
467
|
+
"inverse",
|
|
468
|
+
"accent"
|
|
469
|
+
],
|
|
470
|
+
"default": "default",
|
|
471
|
+
"description": "Section background."
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
"props": {
|
|
475
|
+
"kind": {
|
|
476
|
+
"type": "select",
|
|
477
|
+
"required": true,
|
|
478
|
+
"content": false,
|
|
479
|
+
"options": [
|
|
480
|
+
"disclosure",
|
|
481
|
+
"tablist",
|
|
482
|
+
"carousel",
|
|
483
|
+
"dialog",
|
|
484
|
+
"countup",
|
|
485
|
+
"toggle",
|
|
486
|
+
"filter",
|
|
487
|
+
"anchor",
|
|
488
|
+
"lightbox"
|
|
489
|
+
],
|
|
490
|
+
"description": "What the module makes of the markup: disclosure (aria-expanded/aria-controls, details), tablist (role=tab, arrow keys), carousel (a scroll-snap track over the items, previous/next), dialog (an opener and its panel as a modal), countup (numbers count up once in view), toggle (aria-pressed shows/hides), filter (data-filter buttons over the items), anchor (in-page links scroll). lightbox (a link to an image file opens the image in a modal)."
|
|
491
|
+
},
|
|
492
|
+
"html": {
|
|
493
|
+
"type": "richtext",
|
|
494
|
+
"label": "Content",
|
|
495
|
+
"description": "The widget's markup as the source rendered it, after the writer's island pass. Editable in Studio as HTML."
|
|
496
|
+
},
|
|
497
|
+
"items": {
|
|
498
|
+
"type": "string",
|
|
499
|
+
"content": false,
|
|
500
|
+
"description": "The engine's selector for the items inside the island (slides, counters, filtered cards)."
|
|
501
|
+
},
|
|
502
|
+
"title": {
|
|
503
|
+
"type": "string",
|
|
504
|
+
"content": false,
|
|
505
|
+
"description": "A tablist's tab selector, when the source's tabs are not role=tab."
|
|
506
|
+
},
|
|
507
|
+
"panel": {
|
|
508
|
+
"type": "string",
|
|
509
|
+
"content": false,
|
|
510
|
+
"description": "A tablist's panel selector, in tab order, when the tabs do not name their panels."
|
|
511
|
+
},
|
|
512
|
+
"fact": {
|
|
513
|
+
"type": "string",
|
|
514
|
+
"content": false,
|
|
515
|
+
"description": "The source behavior's fact id; the gates find the island by it (data-cr-fact)."
|
|
516
|
+
},
|
|
517
|
+
"previousLabel": {
|
|
518
|
+
"type": "string",
|
|
519
|
+
"default": "Previous",
|
|
520
|
+
"content": false,
|
|
521
|
+
"description": "Carousel button (ui-strings slider.prev)."
|
|
522
|
+
},
|
|
523
|
+
"nextLabel": {
|
|
524
|
+
"type": "string",
|
|
525
|
+
"default": "Next",
|
|
526
|
+
"content": false,
|
|
527
|
+
"description": "Carousel button (ui-strings slider.next)."
|
|
528
|
+
},
|
|
529
|
+
"closeLabel": {
|
|
530
|
+
"type": "string",
|
|
531
|
+
"default": "Close",
|
|
532
|
+
"content": false,
|
|
533
|
+
"description": "Dialog and lightbox close button (ui-strings dialog.close)."
|
|
534
|
+
},
|
|
535
|
+
"measured": {
|
|
536
|
+
"type": "object",
|
|
537
|
+
"content": false,
|
|
538
|
+
"description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.",
|
|
539
|
+
"fields": {
|
|
540
|
+
"containerPx": {
|
|
541
|
+
"type": "integer",
|
|
542
|
+
"min": 0,
|
|
543
|
+
"max": 2560,
|
|
544
|
+
"description": "The frame's content width."
|
|
545
|
+
},
|
|
546
|
+
"padY": {
|
|
547
|
+
"type": "integer",
|
|
548
|
+
"min": 0,
|
|
549
|
+
"max": 400,
|
|
550
|
+
"description": "Top and bottom padding."
|
|
551
|
+
},
|
|
552
|
+
"padX": {
|
|
553
|
+
"type": "integer",
|
|
554
|
+
"min": 0,
|
|
555
|
+
"max": 200,
|
|
556
|
+
"description": "The frame's side padding (gutter)."
|
|
557
|
+
},
|
|
558
|
+
"bg": {
|
|
559
|
+
"type": "object",
|
|
560
|
+
"description": "The section's background.",
|
|
561
|
+
"fields": {
|
|
562
|
+
"color": {
|
|
563
|
+
"type": "string",
|
|
564
|
+
"description": "Fill colour: hex, rgb(a) or hsl(a)."
|
|
565
|
+
},
|
|
566
|
+
"image": {
|
|
567
|
+
"type": "string",
|
|
568
|
+
"description": "Background image address (http(s) or root-relative), drawn behind the content."
|
|
569
|
+
},
|
|
570
|
+
"overlay": {
|
|
571
|
+
"type": "string",
|
|
572
|
+
"description": "Colour or linear/radial gradient laid over the image or the fill."
|
|
573
|
+
},
|
|
574
|
+
"size": {
|
|
575
|
+
"type": "string",
|
|
576
|
+
"description": "cover, contain or auto; anything else draws as cover."
|
|
577
|
+
},
|
|
578
|
+
"position": {
|
|
579
|
+
"type": "string",
|
|
580
|
+
"description": "One or two of center/top/bottom/left/right or percentages."
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
"columns": {
|
|
585
|
+
"type": "object",
|
|
586
|
+
"description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
|
|
587
|
+
"fields": {
|
|
588
|
+
"390": {
|
|
589
|
+
"type": "integer",
|
|
590
|
+
"min": 1,
|
|
591
|
+
"max": 6,
|
|
592
|
+
"description": "Phone."
|
|
593
|
+
},
|
|
594
|
+
"768": {
|
|
595
|
+
"type": "integer",
|
|
596
|
+
"min": 1,
|
|
597
|
+
"max": 6,
|
|
598
|
+
"description": "Tablet."
|
|
599
|
+
},
|
|
600
|
+
"1280": {
|
|
601
|
+
"type": "integer",
|
|
602
|
+
"min": 1,
|
|
603
|
+
"max": 6,
|
|
604
|
+
"description": "Desktop."
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
"gap": {
|
|
609
|
+
"type": "integer",
|
|
610
|
+
"min": 0,
|
|
611
|
+
"max": 200,
|
|
612
|
+
"description": "Gap between items."
|
|
613
|
+
},
|
|
614
|
+
"tone": {
|
|
615
|
+
"type": "select",
|
|
616
|
+
"options": [
|
|
617
|
+
"light",
|
|
618
|
+
"dark"
|
|
619
|
+
],
|
|
620
|
+
"description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
|
|
621
|
+
},
|
|
622
|
+
"align": {
|
|
623
|
+
"type": "select",
|
|
624
|
+
"options": [
|
|
625
|
+
"start",
|
|
626
|
+
"center",
|
|
627
|
+
"end"
|
|
628
|
+
],
|
|
629
|
+
"description": "Where the heading and lead sit; read by a component whose own align is not set."
|
|
630
|
+
},
|
|
631
|
+
"radius": {
|
|
632
|
+
"type": "integer",
|
|
633
|
+
"min": 0,
|
|
634
|
+
"max": 200,
|
|
635
|
+
"description": "Card and item corner radius."
|
|
636
|
+
},
|
|
637
|
+
"minHeight": {
|
|
638
|
+
"type": "integer",
|
|
639
|
+
"min": 0,
|
|
640
|
+
"max": 2000,
|
|
641
|
+
"description": "The section's minimum height."
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
"slots": {},
|
|
647
|
+
"js": "vanilla",
|
|
648
|
+
"dependencies": {},
|
|
649
|
+
"sources": {},
|
|
650
|
+
"files": [
|
|
651
|
+
"Behavior.astro"
|
|
652
|
+
]
|
|
653
|
+
},
|
|
449
654
|
{
|
|
450
655
|
"id": "breadcrumb",
|
|
451
656
|
"name": "Breadcrumb",
|
|
@@ -1959,9 +2164,27 @@
|
|
|
1959
2164
|
"default": false,
|
|
1960
2165
|
"content": false,
|
|
1961
2166
|
"description": "Rendered already open, in the flow of the section and not modal: for previews and visual baselines only, never for a page."
|
|
2167
|
+
},
|
|
2168
|
+
"trigger": {
|
|
2169
|
+
"type": "select",
|
|
2170
|
+
"options": [
|
|
2171
|
+
"click",
|
|
2172
|
+
"load",
|
|
2173
|
+
"exit",
|
|
2174
|
+
"scroll"
|
|
2175
|
+
],
|
|
2176
|
+
"content": false,
|
|
2177
|
+
"description": "How the source popup opened. Here it always opens by its button, never by itself; the value stays on the root for the gates and the receipt, which say the change."
|
|
2178
|
+
},
|
|
2179
|
+
"fact": {
|
|
2180
|
+
"type": "string",
|
|
2181
|
+
"content": false,
|
|
2182
|
+
"description": "The source behavior's fact id; the gates find the dialog by it (data-cr-fact)."
|
|
1962
2183
|
}
|
|
1963
2184
|
},
|
|
1964
|
-
"slots": {
|
|
2185
|
+
"slots": {
|
|
2186
|
+
"form": "The popup's form, under its text and buttons: the starter's form (Studio) or its link to the form on WordPress."
|
|
2187
|
+
},
|
|
1965
2188
|
"js": "vanilla",
|
|
1966
2189
|
"dependencies": {},
|
|
1967
2190
|
"sources": {
|
|
@@ -2375,6 +2598,7 @@
|
|
|
2375
2598
|
"category": "section",
|
|
2376
2599
|
"uses": [],
|
|
2377
2600
|
"shared": [
|
|
2601
|
+
"KitImage.astro",
|
|
2378
2602
|
"Section.astro",
|
|
2379
2603
|
"measured.ts",
|
|
2380
2604
|
"safe-url.ts",
|
|
@@ -2453,6 +2677,11 @@
|
|
|
2453
2677
|
"chat"
|
|
2454
2678
|
],
|
|
2455
2679
|
"description": "The kit icon shown with the icon style; check when absent."
|
|
2680
|
+
},
|
|
2681
|
+
"iconImage": {
|
|
2682
|
+
"type": "image",
|
|
2683
|
+
"label": "Icon image",
|
|
2684
|
+
"description": "The feature's own icon as an image file (the old site's icon, moved as an svg): drawn instead of the named icon; the check style ignores it."
|
|
2456
2685
|
}
|
|
2457
2686
|
}
|
|
2458
2687
|
},
|
|
@@ -2620,7 +2849,8 @@
|
|
|
2620
2849
|
"items": "items",
|
|
2621
2850
|
"items[].title": "items[].title",
|
|
2622
2851
|
"items[].text": "items[].text",
|
|
2623
|
-
"items[].icon": "items[].icon"
|
|
2852
|
+
"items[].icon": "items[].icon",
|
|
2853
|
+
"items[].iconImage": "items[].icon_image"
|
|
2624
2854
|
}
|
|
2625
2855
|
},
|
|
2626
2856
|
{
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Behavior islands: the source site's own markup for a widget no kit component carries (a slider, tabs, an
|
|
2
|
+
// accordion, a counter, a filter, an image lightbox), kept as content and made to work again by this one small module. The markup is
|
|
3
|
+
// the content; the behavior is ours: no source library, no jQuery, nothing the source shipped runs.
|
|
4
|
+
//
|
|
5
|
+
// Each island is a root the Behavior component prints: `data-cr-behavior` names the kind, and the item, title and
|
|
6
|
+
// panel selectors come from the engine (`data-cr-items`, `data-cr-title`, `data-cr-panel`), never from the source
|
|
7
|
+
// (the writer strips every `data-cr-*` the source markup carried before it wraps it). The module reads only ARIA,
|
|
8
|
+
// `details`, `hidden` and those attributes.
|
|
9
|
+
//
|
|
10
|
+
// Rules (t13's PR-E checklist §4): text goes in through `textContent` only, never `innerHTML` or its family; no
|
|
11
|
+
// `eval`, `Function`, `fetch` or `location`; every selector is scoped to its island root and run in try/catch (a
|
|
12
|
+
// selector the browser refuses leaves that island static); `init` is idempotent; anything that fails leaves the
|
|
13
|
+
// island as it was printed: readable, every item in reading order. Reduced motion is honoured.
|
|
14
|
+
|
|
15
|
+
type Kind = 'disclosure' | 'tablist' | 'carousel' | 'dialog' | 'countup' | 'toggle' | 'filter' | 'anchor' | 'lightbox'
|
|
16
|
+
|
|
17
|
+
const KINDS: ReadonlySet<string> = new Set<Kind>(['disclosure', 'tablist', 'carousel', 'dialog', 'countup', 'toggle', 'filter', 'anchor', 'lightbox'])
|
|
18
|
+
/** Past this many items an island wires the first ones; the rest stay visible as printed (said on the receipt). */
|
|
19
|
+
const MAX_ITEMS = 50
|
|
20
|
+
|
|
21
|
+
const reduced = (): boolean => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
22
|
+
|
|
23
|
+
/** Elements under the island root for a selector the engine wrote; nothing (never a throw) for a bad selector. */
|
|
24
|
+
function within(root: HTMLElement, selector: string | undefined): HTMLElement[] {
|
|
25
|
+
if (!selector) return []
|
|
26
|
+
try { return [...root.querySelectorAll<HTMLElement>(selector)].slice(0, MAX_ITEMS) }
|
|
27
|
+
catch { return [] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The element an `aria-controls` / `href="#…"` names, looked up inside the island only (ids are island-prefixed). */
|
|
31
|
+
function target(root: HTMLElement, id: string | null): HTMLElement | null {
|
|
32
|
+
if (!id) return null
|
|
33
|
+
try { return root.querySelector<HTMLElement>(`#${CSS.escape(id.replace(/^#/, ''))}`) }
|
|
34
|
+
catch { return null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function disclosure(root: HTMLElement): void {
|
|
38
|
+
for (const button of within(root, 'button[aria-expanded][aria-controls], [role="button"][aria-expanded][aria-controls]')) {
|
|
39
|
+
const panel = target(root, button.getAttribute('aria-controls'))
|
|
40
|
+
if (!panel) continue
|
|
41
|
+
const set = (open: boolean) => { button.setAttribute('aria-expanded', String(open)); panel.hidden = !open }
|
|
42
|
+
set(button.getAttribute('aria-expanded') === 'true')
|
|
43
|
+
button.addEventListener('click', () => set(button.getAttribute('aria-expanded') !== 'true'))
|
|
44
|
+
}
|
|
45
|
+
// `details`/`summary` already open and close in the browser.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tablist(root: HTMLElement): void {
|
|
49
|
+
const tabs = within(root, root.dataset.crTitle ?? '[role="tab"]')
|
|
50
|
+
const panels = tabs.map(tab => target(root, tab.getAttribute('aria-controls')))
|
|
51
|
+
const fallback = within(root, root.dataset.crPanel)
|
|
52
|
+
const panelOf = (i: number): HTMLElement | null => panels[i] ?? fallback[i] ?? null
|
|
53
|
+
if (tabs.length < 2 || tabs.some((_, i) => !panelOf(i))) return
|
|
54
|
+
const select = (index: number, focus = false) => {
|
|
55
|
+
tabs.forEach((tab, i) => {
|
|
56
|
+
const on = i === index
|
|
57
|
+
tab.setAttribute('role', 'tab')
|
|
58
|
+
tab.setAttribute('aria-selected', String(on))
|
|
59
|
+
tab.tabIndex = on ? 0 : -1
|
|
60
|
+
panelOf(i)!.hidden = !on
|
|
61
|
+
})
|
|
62
|
+
if (focus) tabs[index]!.focus()
|
|
63
|
+
}
|
|
64
|
+
const first = Math.max(0, tabs.findIndex(tab => tab.getAttribute('aria-selected') === 'true'))
|
|
65
|
+
select(first)
|
|
66
|
+
tabs.forEach((tab, i) => {
|
|
67
|
+
tab.addEventListener('click', (event) => { event.preventDefault(); select(i) })
|
|
68
|
+
tab.addEventListener('keydown', (event) => {
|
|
69
|
+
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key]
|
|
70
|
+
if (step) { event.preventDefault(); select((i + step + tabs.length) % tabs.length, true) }
|
|
71
|
+
else if (event.key === 'Home') { event.preventDefault(); select(0, true) }
|
|
72
|
+
else if (event.key === 'End') { event.preventDefault(); select(tabs.length - 1, true) }
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function control(label: string, onClick: () => void): HTMLButtonElement {
|
|
78
|
+
const button = document.createElement('button')
|
|
79
|
+
button.type = 'button'
|
|
80
|
+
button.textContent = label
|
|
81
|
+
button.className = 'cr-behavior-control'
|
|
82
|
+
button.addEventListener('click', onClick)
|
|
83
|
+
return button
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function carousel(root: HTMLElement): void {
|
|
87
|
+
const items = within(root, root.dataset.crItems)
|
|
88
|
+
const track = items[0]?.parentElement
|
|
89
|
+
// Past MAX_ITEMS (or other children in the track) the carousel stays static: never squeeze unwired slides into the row.
|
|
90
|
+
if (items.length < 2 || !track || !items.every(item => item.parentElement === track) || track.children.length !== items.length) return
|
|
91
|
+
// A scroll-snap track: the browser moves it (swipe, keys, scroll); the buttons only scroll it. No timer: a slider
|
|
92
|
+
// that advanced by itself on the old site waits for the visitor here (said on the receipt).
|
|
93
|
+
Object.assign(track.style, { display: 'flex', overflowX: 'auto', scrollSnapType: 'x mandatory', scrollBehavior: reduced() ? 'auto' : 'smooth' })
|
|
94
|
+
for (const item of items) Object.assign(item.style, { flex: '0 0 100%', scrollSnapAlign: 'start' })
|
|
95
|
+
track.setAttribute('tabindex', '0')
|
|
96
|
+
const go = (step: number) => track.scrollBy({ left: step * track.clientWidth })
|
|
97
|
+
const bar = document.createElement('div')
|
|
98
|
+
bar.className = 'cr-behavior-controls'
|
|
99
|
+
bar.append(control(root.dataset.crPrev ?? 'Previous', () => go(-1)), control(root.dataset.crNext ?? 'Next', () => go(1)))
|
|
100
|
+
track.after(bar)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dialog(root: HTMLElement): void {
|
|
104
|
+
for (const opener of within(root, '[aria-haspopup="dialog"][aria-controls]')) {
|
|
105
|
+
const content = target(root, opener.getAttribute('aria-controls'))
|
|
106
|
+
if (!content || content.closest('dialog')) continue
|
|
107
|
+
const box = document.createElement('dialog')
|
|
108
|
+
box.className = 'cr-behavior-dialog'
|
|
109
|
+
content.before(box)
|
|
110
|
+
box.append(content)
|
|
111
|
+
content.hidden = false
|
|
112
|
+
box.append(control(root.dataset.crClose ?? 'Close', () => box.close()))
|
|
113
|
+
opener.addEventListener('click', (event) => { event.preventDefault(); box.showModal() })
|
|
114
|
+
box.addEventListener('click', (event) => { if (event.target === box) box.close() })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The first text node under `item` holding a number: the digits a counter shows, beside its icon or suffix spans. */
|
|
119
|
+
function digitsOf(item: HTMLElement): { node: Text, digits: string } | null {
|
|
120
|
+
const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT)
|
|
121
|
+
for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
|
|
122
|
+
const match = /\d[\d.,]*/.exec(node.data)
|
|
123
|
+
if (match) return { node, digits: match[0] }
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function countup(root: HTMLElement): void {
|
|
129
|
+
const items = within(root, root.dataset.crItems)
|
|
130
|
+
if (reduced() || typeof IntersectionObserver !== 'function') return
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
const found = digitsOf(item)
|
|
133
|
+
if (!found) continue
|
|
134
|
+
const { node, digits } = found
|
|
135
|
+
const text = node.data
|
|
136
|
+
const end = Number(digits.replace(/[.,](?=\d{3}\b)/g, '').replace(',', '.'))
|
|
137
|
+
if (!Number.isFinite(end) || end <= 0) continue
|
|
138
|
+
const fraction = digits.split(/[.,]/)[1] ?? ''
|
|
139
|
+
const decimals = fraction.length > 2 ? 0 : fraction.length
|
|
140
|
+
const seen = new IntersectionObserver((entries) => {
|
|
141
|
+
if (!entries.some(e => e.isIntersecting)) return
|
|
142
|
+
seen.disconnect()
|
|
143
|
+
const start = performance.now()
|
|
144
|
+
// Only the digits' own text node changes: an icon, a unit or a suffix span beside it stays as printed.
|
|
145
|
+
const tick = (now: number) => {
|
|
146
|
+
const t = Math.min(1, (now - start) / 1200)
|
|
147
|
+
node.data = text.replace(digits, t < 1 ? (end * t).toFixed(decimals) : digits)
|
|
148
|
+
if (t < 1) requestAnimationFrame(tick)
|
|
149
|
+
}
|
|
150
|
+
requestAnimationFrame(tick)
|
|
151
|
+
})
|
|
152
|
+
seen.observe(item)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toggle(root: HTMLElement): void {
|
|
157
|
+
for (const button of within(root, 'button[aria-pressed][aria-controls]')) {
|
|
158
|
+
const panel = target(root, button.getAttribute('aria-controls'))
|
|
159
|
+
if (!panel) continue
|
|
160
|
+
const set = (on: boolean) => { button.setAttribute('aria-pressed', String(on)); panel.hidden = !on }
|
|
161
|
+
set(button.getAttribute('aria-pressed') === 'true')
|
|
162
|
+
button.addEventListener('click', () => set(button.getAttribute('aria-pressed') !== 'true'))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What an item is filed under: its classes and its `data-category` / `data-filter` words. */
|
|
167
|
+
const tokens = (el: HTMLElement): string[] => `${el.className} ${el.dataset.category ?? ''} ${el.dataset.filter ?? ''}`.split(/[\s,]+/).filter(Boolean)
|
|
168
|
+
|
|
169
|
+
function filter(root: HTMLElement): void {
|
|
170
|
+
const items = within(root, root.dataset.crItems)
|
|
171
|
+
const buttons = within(root, '[data-filter]')
|
|
172
|
+
if (!items.length || !buttons.length) return
|
|
173
|
+
for (const button of buttons) {
|
|
174
|
+
button.addEventListener('click', (event) => {
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
const want = (button.dataset.filter ?? '').replace(/^\./, '')
|
|
177
|
+
for (const b of buttons) b.setAttribute('aria-pressed', String(b === button))
|
|
178
|
+
for (const item of items) item.hidden = !(want === '' || want === '*' || want === 'all' || tokens(item).includes(want))
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function anchor(root: HTMLElement): void {
|
|
184
|
+
for (const link of within(root, 'a[href^="#"]')) {
|
|
185
|
+
// The island's own (prefixed) target first; the page's only when the island has none.
|
|
186
|
+
const id = link.getAttribute('href')!.slice(1)
|
|
187
|
+
const to = target(root, id) ?? (id ? document.getElementById(id) : null)
|
|
188
|
+
if (!to) continue
|
|
189
|
+
link.addEventListener('click', (event) => { event.preventDefault(); to.scrollIntoView({ behavior: reduced() ? 'auto' : 'smooth' }) })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A link to an image file, as a source lightbox wrapped one (`a[href$=.jpg] > img`): http(s) or same-site only. */
|
|
194
|
+
function imageHref(link: HTMLAnchorElement): string | null {
|
|
195
|
+
let url: URL
|
|
196
|
+
try { url = new URL(link.getAttribute('href') ?? '', document.baseURI) }
|
|
197
|
+
catch { return null }
|
|
198
|
+
return (url.protocol === 'https:' || url.protocol === 'http:') && /\.(?:avif|gif|jpe?g|png|webp)$/i.test(url.pathname) ? url.href : null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function lightbox(root: HTMLElement): void {
|
|
202
|
+
let box: HTMLDialogElement | null = null
|
|
203
|
+
let image: HTMLImageElement | null = null
|
|
204
|
+
for (const link of within(root, 'a[href]') as HTMLAnchorElement[]) {
|
|
205
|
+
const thumb = link.querySelector('img')
|
|
206
|
+
const href = thumb ? imageHref(link) : null
|
|
207
|
+
if (!thumb || !href) continue
|
|
208
|
+
link.addEventListener('click', (event) => {
|
|
209
|
+
event.preventDefault()
|
|
210
|
+
if (!box || !image) {
|
|
211
|
+
box = document.createElement('dialog')
|
|
212
|
+
box.className = 'cr-behavior-dialog cr-behavior-lightbox'
|
|
213
|
+
image = document.createElement('img')
|
|
214
|
+
box.append(image, control(root.dataset.crClose ?? 'Close', () => box!.close()))
|
|
215
|
+
box.addEventListener('click', (e) => { if (e.target === box) box!.close() })
|
|
216
|
+
root.append(box)
|
|
217
|
+
}
|
|
218
|
+
image.src = href
|
|
219
|
+
image.alt = thumb.alt
|
|
220
|
+
box.showModal()
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const WIRE: Record<Kind, (root: HTMLElement) => void> = { disclosure, tablist, carousel, dialog, countup, toggle, filter, anchor, lightbox }
|
|
226
|
+
|
|
227
|
+
/** Wires every island on the page once. Safe to call again (a view transition, a second script copy). */
|
|
228
|
+
export function initBehaviors(scope: ParentNode = document): void {
|
|
229
|
+
for (const root of scope.querySelectorAll<HTMLElement>('[data-cr-behavior]')) {
|
|
230
|
+
const kind = root.dataset.crBehavior ?? ''
|
|
231
|
+
// A root inside another root is not ours: the component prints one root per island, so a nested one came with the
|
|
232
|
+
// markup (a spoofed island the writer's pass would strip). Never wired, so its selectors never run.
|
|
233
|
+
if (root.dataset.crReady !== undefined || !KINDS.has(kind) || root.parentElement?.closest('[data-cr-behavior]')) continue
|
|
234
|
+
try {
|
|
235
|
+
WIRE[kind as Kind](root)
|
|
236
|
+
root.dataset.crReady = ''
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Left as printed: the content is all there, it just does not move.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A behavior island: a widget of the source site that no kit component carries (a slider, tabs, an accordion, a
|
|
3
|
+
// counter, a filter), kept as the site's own markup and made to work again by the kit's one small module
|
|
4
|
+
// (`_shared/behaviors.ts`). The markup is content, a rich-text field the writer already ran through the island
|
|
5
|
+
// pass (no style element, no script, no form, no overlay, no `data-cr-*` of its own, ids prefixed); the behavior
|
|
6
|
+
// is ours. The `data-cr-*` attributes on the root are the only ones on the page, and they come from these props.
|
|
7
|
+
//
|
|
8
|
+
// With no script, or a selector the browser refuses, the island stays as printed: every item in reading order,
|
|
9
|
+
// nothing hidden. Only a page that has an island loads the module (Astro bundles a component's script once, for the
|
|
10
|
+
// pages that use it).
|
|
11
|
+
import Section from '../_shared/Section.astro'
|
|
12
|
+
import type { MeasuredStyle } from '../_shared/types'
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
/** What the module makes of it. */
|
|
16
|
+
kind: 'disclosure' | 'tablist' | 'carousel' | 'dialog' | 'countup' | 'toggle' | 'filter' | 'anchor' | 'lightbox'
|
|
17
|
+
/** The island's markup (rendered HTML of a richtext field, after the writer's island pass). */
|
|
18
|
+
html?: string | undefined
|
|
19
|
+
/** The engine's selectors inside the island: the items (slides, counters, filtered cards), a tab's title, a panel. */
|
|
20
|
+
items?: string | undefined
|
|
21
|
+
title?: string | undefined
|
|
22
|
+
panel?: string | undefined
|
|
23
|
+
/** The source behavior's fact id: the gates find this element by it (`data-cr-fact`). */
|
|
24
|
+
fact?: string | undefined
|
|
25
|
+
/** Button labels the module writes (ui-strings slider.prev / slider.next / dialog.close). */
|
|
26
|
+
previousLabel?: string | undefined
|
|
27
|
+
nextLabel?: string | undefined
|
|
28
|
+
closeLabel?: string | undefined
|
|
29
|
+
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
30
|
+
/** The source section's measured style (`MeasuredStyle`). */
|
|
31
|
+
measured?: MeasuredStyle | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { kind, html, items, title, panel, fact, previousLabel, nextLabel, closeLabel, tone, measured } = Astro.props
|
|
35
|
+
// Selectors and ids go into attributes only, never into markup or a script; an empty one is left out.
|
|
36
|
+
const attr = (value: string | undefined): string | undefined => (value && value.length <= 300 ? value : undefined)
|
|
37
|
+
const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
|
|
38
|
+
---
|
|
39
|
+
{html && (
|
|
40
|
+
<Section tone={tone} measured={measured}>
|
|
41
|
+
<div
|
|
42
|
+
class="cr-behavior"
|
|
43
|
+
data-cr-behavior={kind}
|
|
44
|
+
data-cr-items={attr(items)}
|
|
45
|
+
data-cr-title={attr(title)}
|
|
46
|
+
data-cr-panel={attr(panel)}
|
|
47
|
+
data-cr-fact={factId}
|
|
48
|
+
data-cr-prev={previousLabel}
|
|
49
|
+
data-cr-next={nextLabel}
|
|
50
|
+
data-cr-close={closeLabel}
|
|
51
|
+
set:html={html}
|
|
52
|
+
/>
|
|
53
|
+
</Section>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<script>
|
|
57
|
+
import { initBehaviors } from '../_shared/behaviors'
|
|
58
|
+
|
|
59
|
+
initBehaviors()
|
|
60
|
+
// With Astro's client router a page arrives without a reload: wire its islands too (ready roots are skipped).
|
|
61
|
+
document.addEventListener('astro:page-load', () => initBehaviors())
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<style is:global>
|
|
65
|
+
.cr-behavior-controls { display: flex; gap: 0.5rem; justify-content: center; margin-block-start: 1rem; }
|
|
66
|
+
.cr-behavior-control { cursor: pointer; border: 1px solid currentColor; border-radius: var(--radius-control, 0.375rem); padding: 0.375rem 0.875rem; background: transparent; }
|
|
67
|
+
.cr-behavior-control:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
|
68
|
+
.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
|
+
.cr-behavior-dialog::backdrop { background: rgb(0 0 0 / 0.5); }
|
|
70
|
+
.cr-behavior-lightbox img { display: block; max-width: 100%; max-height: 75dvh; margin-inline: auto; }
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "carousel",
|
|
4
|
+
"props": {
|
|
5
|
+
"kind": "carousel",
|
|
6
|
+
"items": ".slide",
|
|
7
|
+
"fact": "interactive:fx1",
|
|
8
|
+
"html": "<div class=\"slides\"><div class=\"slide\"><h2>First</h2><p>One of three slides.</p></div><div class=\"slide\"><h2>Second</h2><p>Two of three.</p></div><div class=\"slide\"><h2>Third</h2><p>Three of three.</p></div></div>"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "tablist",
|
|
13
|
+
"props": {
|
|
14
|
+
"kind": "tablist",
|
|
15
|
+
"fact": "interactive:fx2",
|
|
16
|
+
"html": "<div role=\"tablist\"><button role=\"tab\" aria-controls=\"cr-fx2-a\" aria-selected=\"true\">Plans</button><button role=\"tab\" aria-controls=\"cr-fx2-b\">Support</button></div><div id=\"cr-fx2-a\"><p>Every plan includes hosting.</p></div><div id=\"cr-fx2-b\"><p>Support answers within a day.</p></div>"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "disclosure",
|
|
21
|
+
"props": {
|
|
22
|
+
"kind": "disclosure",
|
|
23
|
+
"fact": "interactive:fx3",
|
|
24
|
+
"tone": "muted",
|
|
25
|
+
"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>"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "nested-spoof",
|
|
30
|
+
"props": {
|
|
31
|
+
"kind": "disclosure",
|
|
32
|
+
"fact": "interactive:fx4",
|
|
33
|
+
"html": "<h2><button aria-expanded=\"true\" aria-controls=\"cr-fx4-a\">Opening hours</button></h2><div id=\"cr-fx4-a\"><p>Monday to Friday.</p><div data-cr-behavior=\"carousel\" data-cr-items=\"body *\"><p>A spoofed inner root: never wired, printed as text.</p></div></div>"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "disclosure-inverse",
|
|
38
|
+
"props": {
|
|
39
|
+
"kind": "disclosure",
|
|
40
|
+
"fact": "interactive:fx3",
|
|
41
|
+
"tone": "inverse",
|
|
42
|
+
"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>"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "disclosure-accent",
|
|
47
|
+
"props": {
|
|
48
|
+
"kind": "disclosure",
|
|
49
|
+
"fact": "interactive:fx3",
|
|
50
|
+
"tone": "accent",
|
|
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
|
+
}
|
|
53
|
+
}
|
|
54
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "behavior",
|
|
3
|
+
"name": "Behavior",
|
|
4
|
+
"description": "A behavior island: a source widget no kit component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links), kept as the site's own markup and wired by the kit's one small vanilla module. The markup is a rich-text field the writer has run through its island pass (no style, script, form or overlay; no source data-cr-*; ids prefixed); the data-cr-* attributes on the root come from props only. No script, or a refused selector: the island stays as printed, every item in reading order. Only pages with an island load the module.",
|
|
5
|
+
"category": "content",
|
|
6
|
+
"uses": [],
|
|
7
|
+
"shared": [
|
|
8
|
+
"Section.astro",
|
|
9
|
+
"behaviors.ts",
|
|
10
|
+
"measured.ts",
|
|
11
|
+
"safe-url.ts",
|
|
12
|
+
"types.ts"
|
|
13
|
+
],
|
|
14
|
+
"variants": {
|
|
15
|
+
"tone": {
|
|
16
|
+
"options": [
|
|
17
|
+
"default",
|
|
18
|
+
"muted",
|
|
19
|
+
"inverse",
|
|
20
|
+
"accent"
|
|
21
|
+
],
|
|
22
|
+
"default": "default",
|
|
23
|
+
"description": "Section background."
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"props": {
|
|
27
|
+
"kind": {
|
|
28
|
+
"type": "select",
|
|
29
|
+
"required": true,
|
|
30
|
+
"content": false,
|
|
31
|
+
"options": [
|
|
32
|
+
"disclosure",
|
|
33
|
+
"tablist",
|
|
34
|
+
"carousel",
|
|
35
|
+
"dialog",
|
|
36
|
+
"countup",
|
|
37
|
+
"toggle",
|
|
38
|
+
"filter",
|
|
39
|
+
"anchor",
|
|
40
|
+
"lightbox"
|
|
41
|
+
],
|
|
42
|
+
"description": "What the module makes of the markup: disclosure (aria-expanded/aria-controls, details), tablist (role=tab, arrow keys), carousel (a scroll-snap track over the items, previous/next), dialog (an opener and its panel as a modal), countup (numbers count up once in view), toggle (aria-pressed shows/hides), filter (data-filter buttons over the items), anchor (in-page links scroll). lightbox (a link to an image file opens the image in a modal)."
|
|
43
|
+
},
|
|
44
|
+
"html": {
|
|
45
|
+
"type": "richtext",
|
|
46
|
+
"label": "Content",
|
|
47
|
+
"description": "The widget's markup as the source rendered it, after the writer's island pass. Editable in Studio as HTML."
|
|
48
|
+
},
|
|
49
|
+
"items": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"content": false,
|
|
52
|
+
"description": "The engine's selector for the items inside the island (slides, counters, filtered cards)."
|
|
53
|
+
},
|
|
54
|
+
"title": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"content": false,
|
|
57
|
+
"description": "A tablist's tab selector, when the source's tabs are not role=tab."
|
|
58
|
+
},
|
|
59
|
+
"panel": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"content": false,
|
|
62
|
+
"description": "A tablist's panel selector, in tab order, when the tabs do not name their panels."
|
|
63
|
+
},
|
|
64
|
+
"fact": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"content": false,
|
|
67
|
+
"description": "The source behavior's fact id; the gates find the island by it (data-cr-fact)."
|
|
68
|
+
},
|
|
69
|
+
"previousLabel": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"default": "Previous",
|
|
72
|
+
"content": false,
|
|
73
|
+
"description": "Carousel button (ui-strings slider.prev)."
|
|
74
|
+
},
|
|
75
|
+
"nextLabel": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"default": "Next",
|
|
78
|
+
"content": false,
|
|
79
|
+
"description": "Carousel button (ui-strings slider.next)."
|
|
80
|
+
},
|
|
81
|
+
"closeLabel": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"default": "Close",
|
|
84
|
+
"content": false,
|
|
85
|
+
"description": "Dialog and lightbox close button (ui-strings dialog.close)."
|
|
86
|
+
},
|
|
87
|
+
"measured": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"content": false,
|
|
90
|
+
"description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.",
|
|
91
|
+
"fields": {
|
|
92
|
+
"containerPx": {
|
|
93
|
+
"type": "integer",
|
|
94
|
+
"min": 0,
|
|
95
|
+
"max": 2560,
|
|
96
|
+
"description": "The frame's content width."
|
|
97
|
+
},
|
|
98
|
+
"padY": {
|
|
99
|
+
"type": "integer",
|
|
100
|
+
"min": 0,
|
|
101
|
+
"max": 400,
|
|
102
|
+
"description": "Top and bottom padding."
|
|
103
|
+
},
|
|
104
|
+
"padX": {
|
|
105
|
+
"type": "integer",
|
|
106
|
+
"min": 0,
|
|
107
|
+
"max": 200,
|
|
108
|
+
"description": "The frame's side padding (gutter)."
|
|
109
|
+
},
|
|
110
|
+
"bg": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"description": "The section's background.",
|
|
113
|
+
"fields": {
|
|
114
|
+
"color": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"description": "Fill colour: hex, rgb(a) or hsl(a)."
|
|
117
|
+
},
|
|
118
|
+
"image": {
|
|
119
|
+
"type": "string",
|
|
120
|
+
"description": "Background image address (http(s) or root-relative), drawn behind the content."
|
|
121
|
+
},
|
|
122
|
+
"overlay": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"description": "Colour or linear/radial gradient laid over the image or the fill."
|
|
125
|
+
},
|
|
126
|
+
"size": {
|
|
127
|
+
"type": "string",
|
|
128
|
+
"description": "cover, contain or auto; anything else draws as cover."
|
|
129
|
+
},
|
|
130
|
+
"position": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"description": "One or two of center/top/bottom/left/right or percentages."
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"columns": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
|
|
139
|
+
"fields": {
|
|
140
|
+
"390": {
|
|
141
|
+
"type": "integer",
|
|
142
|
+
"min": 1,
|
|
143
|
+
"max": 6,
|
|
144
|
+
"description": "Phone."
|
|
145
|
+
},
|
|
146
|
+
"768": {
|
|
147
|
+
"type": "integer",
|
|
148
|
+
"min": 1,
|
|
149
|
+
"max": 6,
|
|
150
|
+
"description": "Tablet."
|
|
151
|
+
},
|
|
152
|
+
"1280": {
|
|
153
|
+
"type": "integer",
|
|
154
|
+
"min": 1,
|
|
155
|
+
"max": 6,
|
|
156
|
+
"description": "Desktop."
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"gap": {
|
|
161
|
+
"type": "integer",
|
|
162
|
+
"min": 0,
|
|
163
|
+
"max": 200,
|
|
164
|
+
"description": "Gap between items."
|
|
165
|
+
},
|
|
166
|
+
"tone": {
|
|
167
|
+
"type": "select",
|
|
168
|
+
"options": [
|
|
169
|
+
"light",
|
|
170
|
+
"dark"
|
|
171
|
+
],
|
|
172
|
+
"description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
|
|
173
|
+
},
|
|
174
|
+
"align": {
|
|
175
|
+
"type": "select",
|
|
176
|
+
"options": [
|
|
177
|
+
"start",
|
|
178
|
+
"center",
|
|
179
|
+
"end"
|
|
180
|
+
],
|
|
181
|
+
"description": "Where the heading and lead sit; read by a component whose own align is not set."
|
|
182
|
+
},
|
|
183
|
+
"radius": {
|
|
184
|
+
"type": "integer",
|
|
185
|
+
"min": 0,
|
|
186
|
+
"max": 200,
|
|
187
|
+
"description": "Card and item corner radius."
|
|
188
|
+
},
|
|
189
|
+
"minHeight": {
|
|
190
|
+
"type": "integer",
|
|
191
|
+
"min": 0,
|
|
192
|
+
"max": 2000,
|
|
193
|
+
"description": "The section's minimum height."
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
"slots": {},
|
|
199
|
+
"js": "vanilla",
|
|
200
|
+
"dependencies": {},
|
|
201
|
+
"sources": {}
|
|
202
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
// A popup, as a dialog the visitor opens: a button in a section of the page and a modal <dialog> with a heading, text
|
|
3
|
-
// and
|
|
2
|
+
// A popup, as a dialog the visitor opens: a button in a section of the page and a modal <dialog> with a heading, text,
|
|
3
|
+
// buttons and, in its `form` slot, the popup's form. Replaces Elementor popups and Popup Maker overlays. Never opens by itself (no timers, no exit intent):
|
|
4
4
|
// content that a source popup showed on load or on scroll is reachable from the button instead, and sits in the HTML
|
|
5
5
|
// for crawlers. The section is the kit's (Section: tone, spacing, measured style), like every other section.
|
|
6
6
|
//
|
|
@@ -62,9 +62,17 @@ interface Props {
|
|
|
62
62
|
measured?: MeasuredStyle | undefined
|
|
63
63
|
/** Rendered already open, in the flow of the section and not modal: for previews and visual baselines only, never for a page. */
|
|
64
64
|
open?: boolean | undefined
|
|
65
|
+
/**
|
|
66
|
+
* How the source popup opened (`load`, `exit`, `scroll`: by itself; `click`: by its trigger). Here it always opens by
|
|
67
|
+
* the button, never by itself; the value is kept on the root for the gates and the receipt, which say the change.
|
|
68
|
+
*/
|
|
69
|
+
trigger?: 'click' | 'load' | 'exit' | 'scroll' | undefined
|
|
70
|
+
/** The source behavior's fact id: the gates find the dialog by it (`data-cr-fact`). */
|
|
71
|
+
fact?: string | undefined
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
const { id: givenId = 'dialog', triggerLabel, heading, html, actions = [], closeLabel = 'Close', triggerStyle, size, align: given, tone, measured, open = false } = Astro.props
|
|
74
|
+
const { id: givenId = 'dialog', triggerLabel, heading, html, actions = [], closeLabel = 'Close', triggerStyle, size, align: given, tone, measured, open = false, trigger, fact } = Astro.props
|
|
75
|
+
const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
|
|
68
76
|
// The id goes into attributes and into the scripting-off style sheet, so it is a plain token or the default.
|
|
69
77
|
const id = /^[A-Za-z][\w-]*$/.test(givenId) ? givenId : 'dialog'
|
|
70
78
|
const align = given ?? measured?.align
|
|
@@ -80,7 +88,7 @@ const invoker = { command: 'show-modal', commandfor: id }
|
|
|
80
88
|
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>`
|
|
81
89
|
---
|
|
82
90
|
<Section measured={measured} tone={tone} spacing="md" id={`${id}-section`}>
|
|
83
|
-
<div class={s.root()} data-kit-dialog={id}>
|
|
91
|
+
<div class={s.root()} data-kit-dialog={id} data-kit-trigger={trigger} data-cr-fact={factId}>
|
|
84
92
|
<noscript set:html={noscript} />
|
|
85
93
|
<button type="button" class={s.trigger()} data-kit-dialog-open aria-haspopup="dialog" {...invoker}>{triggerLabel}</button>
|
|
86
94
|
<dialog id={id} class={s.box()} open={open || undefined} aria-labelledby={heading ? headingId : undefined} aria-label={heading ? undefined : triggerLabel}>
|
|
@@ -88,6 +96,8 @@ const noscript = `<style>[data-kit-dialog="${id}"]>[data-kit-dialog-open],#${id}
|
|
|
88
96
|
{heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
|
|
89
97
|
{html && <Prose html={html} searchable={false} />}
|
|
90
98
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} />)}</div>}
|
|
99
|
+
{/* The popup's form (a source popup often held the contact or newsletter form): the starter's form, or its link to the form on WordPress. */}
|
|
100
|
+
{Astro.slots.has('form') && <div data-kit-dialog-form><slot name="form" /></div>}
|
|
91
101
|
</div>
|
|
92
102
|
<form method="dialog">
|
|
93
103
|
<button type="submit" class={s.close()} aria-label={closeLabel}>
|
|
@@ -235,9 +235,27 @@
|
|
|
235
235
|
"default": false,
|
|
236
236
|
"content": false,
|
|
237
237
|
"description": "Rendered already open, in the flow of the section and not modal: for previews and visual baselines only, never for a page."
|
|
238
|
+
},
|
|
239
|
+
"trigger": {
|
|
240
|
+
"type": "select",
|
|
241
|
+
"options": [
|
|
242
|
+
"click",
|
|
243
|
+
"load",
|
|
244
|
+
"exit",
|
|
245
|
+
"scroll"
|
|
246
|
+
],
|
|
247
|
+
"content": false,
|
|
248
|
+
"description": "How the source popup opened. Here it always opens by its button, never by itself; the value stays on the root for the gates and the receipt, which say the change."
|
|
249
|
+
},
|
|
250
|
+
"fact": {
|
|
251
|
+
"type": "string",
|
|
252
|
+
"content": false,
|
|
253
|
+
"description": "The source behavior's fact id; the gates find the dialog by it (data-cr-fact)."
|
|
238
254
|
}
|
|
239
255
|
},
|
|
240
|
-
"slots": {
|
|
256
|
+
"slots": {
|
|
257
|
+
"form": "The popup's form, under its text and buttons: the starter's form (Studio) or its link to the form on WordPress."
|
|
258
|
+
},
|
|
241
259
|
"js": "vanilla",
|
|
242
260
|
"dependencies": {},
|
|
243
261
|
"sources": {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// A list of short features — a title and a line of text each — marked with
|
|
3
3
|
// the feature's icon or a shared check, under an optional heading and intro.
|
|
4
4
|
import { tv } from 'tailwind-variants'
|
|
5
|
+
import KitImage from '../_shared/KitImage.astro'
|
|
5
6
|
import Section, { type Tone } from '../_shared/Section.astro'
|
|
6
7
|
import { measuredGrid } from '../_shared/measured'
|
|
7
8
|
import type { MeasuredStyle } from '../_shared/types'
|
|
@@ -51,6 +52,8 @@ interface Item {
|
|
|
51
52
|
text?: string | undefined
|
|
52
53
|
/** A kit icon name (check, star, bolt, shield, heart, globe, clock, chat). */
|
|
53
54
|
icon?: string | undefined
|
|
55
|
+
/** The feature's own icon as an image file (a site's svg): drawn instead of the named icon, except by `check`. */
|
|
56
|
+
iconImage?: string | undefined
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
interface Props {
|
|
@@ -74,6 +77,8 @@ const { heading, intro, items, columns, style = 'icon', tone, bare = false, titl
|
|
|
74
77
|
const s = list({ columns, style, tone, bare })
|
|
75
78
|
const headingId = heading && id ? `${id}-title` : undefined
|
|
76
79
|
const pathOf = (item: Item) => (style !== 'check' && item.icon && icons[item.icon]) || icons.check
|
|
80
|
+
// The feature's own image icon, where the style shows each feature's own mark.
|
|
81
|
+
const imageOf = (item: Item) => (style !== 'check' && item.iconImage) || undefined
|
|
77
82
|
// A plain list's items are not headings; otherwise one level under the section heading, or the section's when there is none.
|
|
78
83
|
const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : heading ? 'h3' : 'h2'
|
|
79
84
|
---
|
|
@@ -83,7 +88,9 @@ const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : head
|
|
|
83
88
|
{items.map(item => (
|
|
84
89
|
<li class={s.item()}>
|
|
85
90
|
<span class={s.mark()} aria-hidden="true">
|
|
86
|
-
|
|
91
|
+
{imageOf(item)
|
|
92
|
+
? <KitImage image={{ src: imageOf(item)!, alt: '' }} class="size-5 object-contain" />
|
|
93
|
+
: <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>}
|
|
87
94
|
</span>
|
|
88
95
|
<div>
|
|
89
96
|
<Title class={s.title()}>{item.title}</Title>
|
|
@@ -105,7 +112,9 @@ const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : head
|
|
|
105
112
|
{items.map(item => (
|
|
106
113
|
<li class={s.item()}>
|
|
107
114
|
<span class={s.mark()} aria-hidden="true">
|
|
108
|
-
|
|
115
|
+
{imageOf(item)
|
|
116
|
+
? <KitImage image={{ src: imageOf(item)!, alt: '' }} class="size-5 object-contain" />
|
|
117
|
+
: <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>}
|
|
109
118
|
</span>
|
|
110
119
|
<div>
|
|
111
120
|
<Title class={s.title()}>{item.title}</Title>
|
|
@@ -151,5 +151,6 @@
|
|
|
151
151
|
"style": "list"
|
|
152
152
|
}
|
|
153
153
|
},
|
|
154
|
+
{"name": "list-icon-images", "props": {"heading": "In the package", "style": "list", "items": [{"title": "A design built around your products", "iconImage": "/kit-fixtures/icon-1.svg"}, {"title": "Checkout with cards and wallets", "iconImage": "/kit-fixtures/icon-1.svg"}, {"title": "Thirty days of launch support", "icon": "chat"}]}},
|
|
154
155
|
{"name": "measured-gap", "props": {"heading": "Why us", "measured": {"columns": {"390": 1, "768": 2, "1280": 3}, "gap": 16}, "items": [{"title": "Fast", "text": "Static HTML."}, {"title": "Kept", "text": "Every address survives."}, {"title": "Editable", "text": "Content in Studio."}]}}
|
|
155
156
|
]
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"category": "section",
|
|
6
6
|
"uses": [],
|
|
7
7
|
"shared": [
|
|
8
|
+
"KitImage.astro",
|
|
8
9
|
"Section.astro",
|
|
9
10
|
"measured.ts",
|
|
10
11
|
"safe-url.ts",
|
|
@@ -83,6 +84,11 @@
|
|
|
83
84
|
"chat"
|
|
84
85
|
],
|
|
85
86
|
"description": "The kit icon shown with the icon style; check when absent."
|
|
87
|
+
},
|
|
88
|
+
"iconImage": {
|
|
89
|
+
"type": "image",
|
|
90
|
+
"label": "Icon image",
|
|
91
|
+
"description": "The feature's own icon as an image file (the old site's icon, moved as an svg): drawn instead of the named icon; the check style ignores it."
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentrain/astro-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.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",
|