@contentrain/astro-kit 0.45.8 → 0.46.1
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/catalog.json +20 -0
- package/components/_shared/behaviors.ts +34 -6
- package/components/_shared/muted.ts +7 -0
- package/components/behavior/Behavior.astro +1 -0
- package/components/footer/Footer.astro +3 -3
- package/components/footer/meta.json +4 -0
- package/components/post-card/PostCard.astro +10 -3
- package/components/post-card/meta.json +3 -1
- package/components/split/Split.astro +5 -3
- package/package.json +2 -2
package/catalog.json
CHANGED
|
@@ -3065,6 +3065,10 @@
|
|
|
3065
3065
|
"type": "string",
|
|
3066
3066
|
"description": "Heading shown above the links; leave out when the source shows none."
|
|
3067
3067
|
},
|
|
3068
|
+
"titleHref": {
|
|
3069
|
+
"type": "url",
|
|
3070
|
+
"description": "Where the heading links, when the source prints it as a link; leave out for a plain heading."
|
|
3071
|
+
},
|
|
3068
3072
|
"label": {
|
|
3069
3073
|
"type": "string",
|
|
3070
3074
|
"description": "Accessible name of the column navigation (the menu name); defaults to the title."
|
|
@@ -5125,6 +5129,22 @@
|
|
|
5125
5129
|
"max": 4,
|
|
5126
5130
|
"content": false,
|
|
5127
5131
|
"description": "Clamp the excerpt to this many lines; absent, the layout's own (3 vertical, 2 otherwise)."
|
|
5132
|
+
},
|
|
5133
|
+
"moreLabel": {
|
|
5134
|
+
"type": "string",
|
|
5135
|
+
"description": "The source's own read-more words under the excerpt (READ ARTICLE); absent, none. Shown only on a card with a link; the title link stays the one link."
|
|
5136
|
+
},
|
|
5137
|
+
"dateStyle": {
|
|
5138
|
+
"type": "select",
|
|
5139
|
+
"options": [
|
|
5140
|
+
"full",
|
|
5141
|
+
"long",
|
|
5142
|
+
"medium",
|
|
5143
|
+
"short"
|
|
5144
|
+
],
|
|
5145
|
+
"default": "long",
|
|
5146
|
+
"content": false,
|
|
5147
|
+
"description": "How the date is written (Intl dateStyle), as the source writes it; absent, long."
|
|
5128
5148
|
}
|
|
5129
5149
|
},
|
|
5130
5150
|
"slots": {},
|
|
@@ -19,15 +19,20 @@ import { videoTarget } from './embed-target'
|
|
|
19
19
|
type Kind = 'disclosure' | 'tablist' | 'carousel' | 'dialog' | 'countup' | 'toggle' | 'filter' | 'anchor' | 'lightbox'
|
|
20
20
|
|
|
21
21
|
const KINDS: ReadonlySet<string> = new Set<Kind>(['disclosure', 'tablist', 'carousel', 'dialog', 'countup', 'toggle', 'filter', 'anchor', 'lightbox'])
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Past this many items an island that wires each item (tabs, a counter, a filter) wires the first ones; the rest stay
|
|
24
|
+
* visible as printed (said on the receipt). A carousel wires none of its slides one by one (one scroll-snap track), so it
|
|
25
|
+
* takes every slide: a 59-slide slider (x10's metaslider) cut at 50 left its track with slides it did not count, and stayed
|
|
26
|
+
* static with no controls.
|
|
27
|
+
*/
|
|
23
28
|
const MAX_ITEMS = 50
|
|
24
29
|
|
|
25
30
|
const reduced = (): boolean => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
26
31
|
|
|
27
32
|
/** Elements under the island root for a selector the engine wrote; nothing (never a throw) for a bad selector. */
|
|
28
|
-
function within(root: HTMLElement, selector: string | undefined): HTMLElement[] {
|
|
33
|
+
function within(root: HTMLElement, selector: string | undefined, limit = MAX_ITEMS): HTMLElement[] {
|
|
29
34
|
if (!selector) return []
|
|
30
|
-
try { return [...root.querySelectorAll<HTMLElement>(selector)].slice(0,
|
|
35
|
+
try { return [...root.querySelectorAll<HTMLElement>(selector)].slice(0, limit) }
|
|
31
36
|
catch { return [] }
|
|
32
37
|
}
|
|
33
38
|
|
|
@@ -87,10 +92,16 @@ function control(label: string, onClick: () => void): HTMLButtonElement {
|
|
|
87
92
|
return button
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
/** A carousel button on or off; `aria-disabled` is said once the row has been measured, and kept true to its state after. */
|
|
96
|
+
function setEnd(button: HTMLButtonElement, off: boolean, measured: boolean): void {
|
|
97
|
+
button.disabled = off
|
|
98
|
+
if (measured || button.hasAttribute('aria-disabled')) button.setAttribute('aria-disabled', String(off))
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
function carousel(root: HTMLElement): void {
|
|
91
|
-
const items = within(root, root.dataset.crItems)
|
|
102
|
+
const items = within(root, root.dataset.crItems, Infinity)
|
|
92
103
|
const track = items[0]?.parentElement
|
|
93
|
-
//
|
|
104
|
+
// Other children in the track keep the carousel static: never squeeze what is not a slide into the row.
|
|
94
105
|
if (items.length < 2 || !track || !items.every(item => item.parentElement === track) || track.children.length !== items.length) return
|
|
95
106
|
// A scroll-snap track: the browser moves it (swipe, keys, scroll); the buttons only scroll it. No timer: a slider
|
|
96
107
|
// that advanced by itself on the old site waits for the visitor here (said on the receipt).
|
|
@@ -98,10 +109,27 @@ function carousel(root: HTMLElement): void {
|
|
|
98
109
|
for (const item of items) Object.assign(item.style, { flex: '0 0 100%', scrollSnapAlign: 'start' })
|
|
99
110
|
track.setAttribute('tabindex', '0')
|
|
100
111
|
const go = (step: number) => track.scrollBy({ left: step * track.clientWidth })
|
|
112
|
+
const prev = control(root.dataset.crPrev ?? 'Previous', () => go(-1))
|
|
113
|
+
const next = control(root.dataset.crNext ?? 'Next', () => go(1))
|
|
114
|
+
// No loop (owl's own default): Previous is off on the first slide and Next on the last, so neither is a dead button.
|
|
115
|
+
// A row that does not overflow yet (a closed tab or dialog, a frame still sized to its slides) keeps both on: only a
|
|
116
|
+
// track measured wider than its frame has ends. The track or a slide resizing, and the page's load, measure it again.
|
|
117
|
+
const ends = () => {
|
|
118
|
+
const overflows = track.clientWidth > 0 && track.scrollWidth > track.clientWidth + 1
|
|
119
|
+
setEnd(prev, overflows && track.scrollLeft <= 1, overflows)
|
|
120
|
+
setEnd(next, overflows && track.scrollLeft + track.clientWidth >= track.scrollWidth - 1, overflows)
|
|
121
|
+
}
|
|
122
|
+
track.addEventListener('scroll', ends, { passive: true })
|
|
123
|
+
if (typeof ResizeObserver === 'function') {
|
|
124
|
+
const resized = new ResizeObserver(ends)
|
|
125
|
+
for (const box of [track, ...items]) resized.observe(box)
|
|
126
|
+
}
|
|
127
|
+
if (document.readyState !== 'complete') addEventListener('load', ends, { once: true })
|
|
101
128
|
const bar = document.createElement('div')
|
|
102
129
|
bar.className = 'cr-behavior-controls'
|
|
103
|
-
bar.append(
|
|
130
|
+
bar.append(prev, next)
|
|
104
131
|
track.after(bar)
|
|
132
|
+
ends()
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
function dialog(root: HTMLElement): void {
|
|
@@ -17,3 +17,10 @@ export const mutedBrand = '[--kit-muted:var(--color-brand-ink)]'
|
|
|
17
17
|
|
|
18
18
|
/** A light surface inside a band (a card, a form panel, a drawer): back to the page's muted role. */
|
|
19
19
|
export const mutedReset = '[--kit-muted:initial]'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A Typography body (`prose prose-site`) on a band: its text in the band's own colour and its quieter parts (lead,
|
|
23
|
+
* bullets, counters, captions) in the band's muted role. `prose-site` binds the page's ink, and on a dark band that is
|
|
24
|
+
* the band's own fill (1:1); on a filled band, ink under 4.5:1. On the page's own surface both are the ink, as before.
|
|
25
|
+
*/
|
|
26
|
+
export const proseOnBand = '[--tw-prose-body:currentColor] [--tw-prose-headings:currentColor] [--tw-prose-bold:currentColor] [--tw-prose-quotes:currentColor] [--tw-prose-code:currentColor] [--tw-prose-lead:var(--kit-muted,var(--color-ink-muted))] [--tw-prose-counters:var(--kit-muted,var(--color-ink-muted))] [--tw-prose-bullets:var(--kit-muted,var(--color-ink-muted))] [--tw-prose-captions:var(--kit-muted,var(--color-ink-muted))]'
|
|
@@ -75,6 +75,7 @@ const NOSCRIPT = '<style>.cr-behavior dialog:not([open]){display:block;position:
|
|
|
75
75
|
.cr-behavior-controls { display: flex; gap: 0.5rem; justify-content: center; margin-block-start: 1rem; }
|
|
76
76
|
.cr-behavior-control { cursor: pointer; border: 1px solid currentColor; border-radius: var(--radius-control, 0.375rem); padding: 0.375rem 0.875rem; background: transparent; }
|
|
77
77
|
.cr-behavior-control:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
|
78
|
+
.cr-behavior-control:disabled { cursor: default; opacity: 0.4; }
|
|
78
79
|
.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; }
|
|
79
80
|
.cr-behavior-dialog::backdrop { background: rgb(0 0 0 / 0.5); }
|
|
80
81
|
.cr-behavior-lightbox img { display: block; max-width: 100%; max-height: 75dvh; margin-inline: auto; }
|
|
@@ -46,8 +46,8 @@ const footer = tv({
|
|
|
46
46
|
interface Props {
|
|
47
47
|
siteName: string
|
|
48
48
|
tagline?: string | undefined
|
|
49
|
-
/** Link groups for the `columns` layout. */
|
|
50
|
-
columns?: Array<{ title?: string | undefined, label?: string | undefined, links: LinkInput[] }> | undefined
|
|
49
|
+
/** Link groups for the `columns` layout. `titleHref`: the heading is a link to it (a source heading printed as one). */
|
|
50
|
+
columns?: Array<{ title?: string | undefined, titleHref?: string | undefined, label?: string | undefined, links: LinkInput[] }> | undefined
|
|
51
51
|
/** A single row of links (the footer menu). */
|
|
52
52
|
links?: LinkInput[] | undefined
|
|
53
53
|
social?: LinkInput[] | undefined
|
|
@@ -125,7 +125,7 @@ const mark = wordmark === 'fit' || wordmark === 'crop' ? wordmarkBox(siteName, w
|
|
|
125
125
|
{layout === 'columns'
|
|
126
126
|
? columns.map(column => (
|
|
127
127
|
<nav aria-label={column.label ?? column.title} data-cr-part="nav-footer">
|
|
128
|
-
{column.title && <p class="mb-3 font-semibold">{column.title}</p>}
|
|
128
|
+
{column.title && <p class="mb-3 font-semibold">{column.titleHref ? <a href={safeHref(column.titleHref)} class="text-current no-underline hover:underline">{column.title}</a> : column.title}</p>}
|
|
129
129
|
<ul class="flex flex-col gap-2">
|
|
130
130
|
{column.links.map(item => <li><a href={safeHref(item.href)} class={link()}>{item.label}</a></li>)}
|
|
131
131
|
</ul>
|
|
@@ -88,6 +88,10 @@
|
|
|
88
88
|
"type": "string",
|
|
89
89
|
"description": "Heading shown above the links; leave out when the source shows none."
|
|
90
90
|
},
|
|
91
|
+
"titleHref": {
|
|
92
|
+
"type": "url",
|
|
93
|
+
"description": "Where the heading links, when the source prints it as a link; leave out for a plain heading."
|
|
94
|
+
},
|
|
91
95
|
"label": {
|
|
92
96
|
"type": "string",
|
|
93
97
|
"description": "Accessible name of the column navigation (the menu name); defaults to the title."
|
|
@@ -16,7 +16,8 @@ const card = tv({
|
|
|
16
16
|
body: 'flex min-w-0 flex-col gap-2',
|
|
17
17
|
meta: 'flex flex-wrap items-center gap-x-2 text-sm text-ink-muted',
|
|
18
18
|
title: 'leading-snug [font-weight:var(--font-weight-heading,600)] text-balance text-ink',
|
|
19
|
-
|
|
19
|
+
// At most ~75 characters a line, however wide the card (a one-column list at 1280).
|
|
20
|
+
excerpt: 'max-w-[75ch] text-ink-muted',
|
|
20
21
|
},
|
|
21
22
|
variants: {
|
|
22
23
|
layout: {
|
|
@@ -56,6 +57,10 @@ interface Props {
|
|
|
56
57
|
byline?: 'inline' | 'below' | undefined
|
|
57
58
|
/** Clamp the excerpt to this many lines; absent, the layout's own (3 vertical, 2 otherwise). */
|
|
58
59
|
excerptLines?: 2 | 3 | 4 | undefined
|
|
60
|
+
/** The source's own "read more" words under the excerpt ("READ ARTICLE"); absent, none. The card's title link is the link. */
|
|
61
|
+
moreLabel?: string | undefined
|
|
62
|
+
/** How the date is written (`Intl.DateTimeFormat` `dateStyle`), as the source writes it; absent, long. */
|
|
63
|
+
dateStyle?: 'full' | 'long' | 'medium' | 'short' | undefined
|
|
59
64
|
/** The title in the site's link colour, as themes that colour every link do (Hello Elementor). */
|
|
60
65
|
linkTitle?: boolean | undefined
|
|
61
66
|
/** The image's `sizes` hint; absent, the card's own guess. A list passes the slot its columns give. */
|
|
@@ -66,11 +71,11 @@ interface Props {
|
|
|
66
71
|
imageAspect?: string | undefined
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
const { title, href, excerpt, date: rawDate, locale = 'en', timeZone = 'UTC', image, category, author, headingLevel = 2, layout, surface, byline, linkTitle, excerptLines, sizes, imageHeight, imageAspect } = Astro.props
|
|
74
|
+
const { title, href, excerpt, date: rawDate, locale = 'en', timeZone = 'UTC', image, category, author, headingLevel = 2, layout, surface, byline, linkTitle, excerptLines, moreLabel, dateStyle = 'long', sizes, imageHeight, imageAspect } = Astro.props
|
|
70
75
|
const date = rawDate === undefined ? undefined : new Date(rawDate)
|
|
71
76
|
const slots = card({ layout, surface, byline })
|
|
72
77
|
const below = byline === 'below'
|
|
73
|
-
const shown = date?.toLocaleDateString(locale, { dateStyle
|
|
78
|
+
const shown = date?.toLocaleDateString(locale, { dateStyle, timeZone })
|
|
74
79
|
const imageStyle = imageHeight ? `height:${imageHeight}` : imageAspect ? `aspect-ratio:${imageAspect}` : undefined
|
|
75
80
|
const Heading = `h${headingLevel}` as 'h2' | 'h3'
|
|
76
81
|
---
|
|
@@ -105,5 +110,7 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3'
|
|
|
105
110
|
)}
|
|
106
111
|
{excerpt && <p class={slots.excerpt({ class: excerptLines ? { 2: 'line-clamp-2', 3: 'line-clamp-3', 4: 'line-clamp-4' }[excerptLines] : undefined })}>{excerpt}</p>}
|
|
107
112
|
{!below && author && <p class="text-sm text-ink-muted">{author}</p>}
|
|
113
|
+
{/* Words, not a second link to the same page: a click on them reaches the title link's overlay. */}
|
|
114
|
+
{href && moreLabel && <p class="text-sm font-semibold text-accent-text" aria-hidden="true">{moreLabel}</p>}
|
|
108
115
|
</div>
|
|
109
116
|
</article>
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"sizes": { "type": "string", "description": "The image's `sizes` hint; absent, the card's own guess. A list passes the slot its columns give." },
|
|
26
26
|
"imageHeight": { "type": "string", "description": "The image block's height as the source sets it (a CSS length or clamp()), cropped to fit; wins over imageAspect." },
|
|
27
27
|
"imageAspect": { "type": "string", "description": "The image block's aspect ratio (16 / 9) where the source sets an aspect ratio; absent, 16 / 9." },
|
|
28
|
-
"excerptLines": { "type": "integer", "min": 2, "max": 4, "content": false, "description": "Clamp the excerpt to this many lines; absent, the layout's own (3 vertical, 2 otherwise)." }
|
|
28
|
+
"excerptLines": { "type": "integer", "min": 2, "max": 4, "content": false, "description": "Clamp the excerpt to this many lines; absent, the layout's own (3 vertical, 2 otherwise)." },
|
|
29
|
+
"moreLabel": { "type": "string", "description": "The source's own read-more words under the excerpt (READ ARTICLE); absent, none. Shown only on a card with a link; the title link stays the one link." },
|
|
30
|
+
"dateStyle": { "type": "select", "options": ["full", "long", "medium", "short"], "default": "long", "content": false, "description": "How the date is written (Intl dateStyle), as the source writes it; absent, long." }
|
|
29
31
|
},
|
|
30
32
|
"slots": {},
|
|
31
33
|
"js": "none",
|
|
@@ -11,7 +11,7 @@ import KitImage from '../_shared/KitImage.astro'
|
|
|
11
11
|
import Section from '../_shared/Section.astro'
|
|
12
12
|
import { instanceId } from '../_shared/instance-id'
|
|
13
13
|
import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
|
|
14
|
-
import { muted } from '../_shared/muted'
|
|
14
|
+
import { muted, proseOnBand } from '../_shared/muted'
|
|
15
15
|
import { BLEED } from '../_shared/bleed'
|
|
16
16
|
|
|
17
17
|
const split = tv({
|
|
@@ -20,7 +20,8 @@ const split = tv({
|
|
|
20
20
|
copy: 'flex flex-col gap-5',
|
|
21
21
|
eyebrow: `text-sm font-semibold tracking-wide uppercase ${muted}`,
|
|
22
22
|
heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
|
|
23
|
-
body
|
|
23
|
+
// The body in the band's own colours, as the heading beside it (on default and muted that is the page's ink).
|
|
24
|
+
body: `prose prose-site max-w-none text-pretty ${proseOnBand}`,
|
|
24
25
|
actions: 'flex flex-wrap gap-3 pt-2',
|
|
25
26
|
media: 'overflow-hidden',
|
|
26
27
|
image: 'h-auto w-full object-cover',
|
|
@@ -49,7 +50,8 @@ const split = tv({
|
|
|
49
50
|
sticky: { true: { media: 'md:sticky md:top-8 md:self-start' }, false: {} },
|
|
50
51
|
align: { start: {}, center: { copy: 'items-center text-center', actions: 'justify-center' } },
|
|
51
52
|
// accent/accent-ink is only ~5:1 — dimming text on it drops body copy under 4.5:1 (WCAG 1.4.3), so accent keeps it at full strength.
|
|
52
|
-
|
|
53
|
+
// A fill sets no accent text of its own: a link in the body is drawn in the fill's ink (underlined, as prose draws it).
|
|
54
|
+
tone: { default: {}, muted: {}, inverse: {}, accent: { body: '[--tw-prose-links:currentColor]' }, brand: { body: '[--tw-prose-links:currentColor]' }, none: {} },
|
|
53
55
|
},
|
|
54
56
|
defaultVariants: { imageSide: 'end', ratio: '1:1', imageFit: 'contain', imageShape: 'rounded', imageBleed: 'none', stackOrder: 'text-first', sticky: false, align: 'start', tone: 'default' },
|
|
55
57
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentrain/astro-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.1",
|
|
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,7 +42,7 @@
|
|
|
42
42
|
"mapping"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@contentrain/types": "1.
|
|
45
|
+
"@contentrain/types": "1.71.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^22.10.0",
|