@contentrain/astro-kit 0.34.2 → 0.34.3

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.
@@ -42,8 +42,14 @@ const image: ImageInput = { ...(measured ? { ...given, ...measured } : given), s
42
42
  const loading = priority ? 'eager' : 'lazy'
43
43
  const fetchpriority = priority ? 'high' : 'auto'
44
44
  const path = image.src.startsWith('/') && !image.src.startsWith('//') ? image.src.split(/[?#]/)[0]! : undefined
45
+ // Under Astro's `base` (a site served from `/blog/`) public/ is served in that directory: a root address is the file's
46
+ // path in public/, printed in the directory. An address already in it is not put there twice.
47
+ const base = (import.meta.env.BASE_URL ?? '/').replace(/\/+$/, '')
48
+ const inBase = (at: string) => base !== '' && (at === base || at.startsWith(`${base}/`))
49
+ const filePath = path !== undefined && inBase(path) ? path.slice(base.length) : path
50
+ const shown = path !== undefined && base !== '' && !inBase(path) ? `${base}${image.src}` : url
45
51
  // A malformed escape (`%E0%A4%A`) is no file in public/: the image stays a plain <img> rather than failing the build.
46
- const decoded = (() => { try { return path === undefined ? undefined : decodeURI(path) } catch { return undefined } })()
52
+ const decoded = (() => { try { return filePath === undefined ? undefined : decodeURI(filePath) } catch { return undefined } })()
47
53
  const load = decoded === undefined ? undefined : LOCAL[`/public${decoded}`]
48
54
  const local = load ? await load() : undefined
49
55
  // The stored size (WordPress's chosen size of the file), else the file's own.
@@ -57,5 +63,5 @@ const widthsUpTo = (max: number) => [...STEPS.filter(step => step < max), max]
57
63
  ? <Image src={local} alt={image.alt ?? ''} {...size} widths={widthsUpTo(image.width ?? local.width)} sizes={sizes} class={className} style={style} loading={loading} fetchpriority={fetchpriority} />
58
64
  : image.width && image.height && path === undefined
59
65
  ? <Image src={url} alt={image.alt ?? ''} width={image.width} height={image.height} widths={widthsUpTo(image.width)} sizes={sizes} class={className} style={style} loading={loading} fetchpriority={fetchpriority} />
60
- : <img src={url} alt={image.alt ?? ''} width={image.width} height={image.height} class={className} style={style} loading={loading} fetchpriority={fetchpriority} decoding="async" />
66
+ : <img src={safeSrc(shown)} alt={image.alt ?? ''} width={image.width} height={image.height} class={className} style={style} loading={loading} fetchpriority={fetchpriority} decoding="async" />
61
67
  )}
@@ -14,7 +14,8 @@ import { type ActionInput, type ImageInput, type MeasuredStyle } from '../_share
14
14
 
15
15
  const slider = tv({
16
16
  slots: {
17
- viewport: 'flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth group-data-[ready]:snap-none group-data-[ready]:overflow-hidden [scrollbar-width:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
17
+ // A block viewport around the flex row, as Embla's own markup has it: the row is the container Embla moves.
18
+ viewport: 'snap-x snap-mandatory overflow-x-auto scroll-smooth group-data-[ready]:snap-none group-data-[ready]:overflow-hidden [scrollbar-width:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
18
19
  slide: 'relative min-w-0 shrink-0 snap-start overflow-hidden',
19
20
  image: 'w-full object-cover',
20
21
  caption: 'flex flex-col items-start gap-2',
@@ -140,6 +141,7 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
140
141
  const prev = root.querySelector<HTMLButtonElement>('[data-prev]')
141
142
  const next = root.querySelector<HTMLButtonElement>('[data-next]')
142
143
  const dots = root.querySelector<HTMLElement>('[data-dots]')
144
+ const track = root.querySelector<HTMLElement>('[data-container]')
143
145
  prev?.addEventListener('click', () => embla.scrollPrev())
144
146
  next?.addEventListener('click', () => embla.scrollNext())
145
147
  // The row takes focus (the buttons are hidden on phones): the arrow keys page it, and a slide change is announced.
@@ -150,10 +152,13 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
150
152
  else embla.scrollPrev()
151
153
  })
152
154
  // No autoplay: the carousel only moves when asked, so a polite announcement of the slide is right.
153
- root.querySelector('[data-container]')?.setAttribute('aria-live', 'polite')
155
+ track?.setAttribute('aria-live', 'polite')
154
156
 
155
157
  const label = root.dataset.slideLabel ?? 'Slide'
156
- const buttons = embla.scrollSnapList().map((_, index) => {
158
+ // One dot per snap, drawn again whenever Embla measures again: the snaps of a carousel set up before its page was
159
+ // laid out (a background tab, a frame sized later) are not the ones it ends up with.
160
+ let buttons: HTMLButtonElement[] = []
161
+ const drawDots = () => { buttons = embla.scrollSnapList().map((_, index) => {
157
162
  const dot = document.createElement('button')
158
163
  dot.type = 'button'
159
164
  // A 24px target around a 10px dot (WCAG 2.5.8): on phones the dots are the only controls.
@@ -163,16 +168,27 @@ const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-cente
163
168
  dot.append(mark)
164
169
  dot.setAttribute('aria-label', `${label} ${index + 1}`)
165
170
  dot.addEventListener('click', () => embla.scrollTo(index))
166
- dots?.append(dot)
167
171
  return dot
168
172
  })
173
+ dots?.replaceChildren(...buttons) }
174
+ drawDots()
169
175
  const sync = () => {
170
176
  const selected = embla.selectedScrollSnap()
171
177
  buttons.forEach((dot, index) => dot.setAttribute('aria-current', String(index === selected)))
172
178
  if (prev) prev.disabled = !embla.canScrollPrev()
173
179
  if (next) next.disabled = !embla.canScrollNext()
174
180
  }
175
- embla.on('select', sync).on('reInit', sync)
181
+ embla.on('select', sync).on('reInit', () => { drawDots(); sync() })
176
182
  sync()
183
+ // Set up before the layout was final, Embla measures once more when it is: the page loaded, its fonts in, the tab in
184
+ // view. A background tab lays nothing out (ctrl/cmd-click), so its carousel had one snap and a dead "next" until then;
185
+ // Embla's own resize watch does not fire there, since nothing changed size while the tab was hidden. Only a carousel
186
+ // whose row overflows while Embla can page neither way is measured again: one that already pages is left where it is.
187
+ const measure = () => {
188
+ if (track && track.scrollWidth > viewport.clientWidth + 1 && !embla.canScrollNext() && !embla.canScrollPrev()) embla.reInit()
189
+ }
190
+ if (document.readyState !== 'complete') addEventListener('load', measure, { once: true })
191
+ void document.fonts?.ready.then(measure)
192
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') measure() })
177
193
  }
178
194
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.34.2",
3
+ "version": "0.34.3",
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.63.0"
45
+ "@contentrain/types": "1.63.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.10.0",