@mnstry/atelier 0.2.0-alpha.15 → 0.2.0-alpha.17
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/CHANGELOG.md +9 -0
- package/README.md +1 -1
- package/docs/guides/extensions.md +6 -6
- package/docs/guides/graph-workspace.md +19 -7
- package/docs/guides/upgrade.md +24 -0
- package/docs/reference/graph-workspace.md +4 -4
- package/docs/reference/portable-presentation.md +9 -0
- package/docs/reference/ui-primitives.md +116 -0
- package/docs/start/independent-start.md +2 -2
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +3 -1
- package/src/extensions/project.mjs +5 -5
- package/src/graph-workspace/browser/index.mjs +1 -1
- package/src/graph-workspace/browser/{constellation.mjs → map.mjs} +7 -2
- package/src/graph-workspace/browser/styles.mjs +1 -1
- package/src/graph-workspace/browser/workbench.mjs +2 -2
- package/src/graph-workspace/browser/workspace.mjs +53 -51
- package/src/graph-workspace/disclosure.mjs +1 -1
- package/src/graph-workspace/geometry.mjs +1 -1
- package/src/graph-workspace/index.mjs +2 -2
- package/src/graph-workspace/recipes.mjs +24 -12
- package/src/graph-workspace/scale.mjs +5 -2
- package/src/graph-workspace/theme.mjs +2 -2
- package/src/ui/primitives/browser.mjs +6 -0
- package/src/ui/primitives/carousel.mjs +551 -0
- package/src/ui/primitives/disclosure.mjs +50 -0
- package/src/ui/primitives/index.mjs +7 -0
- package/src/ui/primitives/motion.mjs +50 -0
- package/src/ui/primitives/render.mjs +22 -0
- package/src/ui/primitives/scroll-lock.mjs +47 -0
- package/src/ui/primitives/state.mjs +130 -0
- package/src/ui/primitives/styles.mjs +127 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carousel enhancer (browser). Progressive enhancement over plain HTML: without
|
|
3
|
+
* script every slide is a native horizontal scroller (track) or a stacked list
|
|
4
|
+
* (fade). See docs/reference/ui-primitives.md for the markup contract.
|
|
5
|
+
*
|
|
6
|
+
* Track carousels scroll natively (CSS scroll snap), so touch, trackpad, wheel
|
|
7
|
+
* and keyboard keep the platform's physics. Script adds seamless loops over
|
|
8
|
+
* inert clones, mouse drag (with momentum for multi-card strips), a draggable
|
|
9
|
+
* scroll bar, arrows, pages or thumbnails, a live status, height that follows
|
|
10
|
+
* the current slide, receding neighbours, a still row when every card fits,
|
|
11
|
+
* and discrete or smooth autoplay with a pause control.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
arrowStep, autoplayConfig, dragIntent, driftStep, glideStep, homeIndex, loopSets, nearestCopy, releaseVelocity,
|
|
15
|
+
pageSize, recede, rowFit, scrubPosition, slideWindow, swipeStep, thumbGeometry, wrap,
|
|
16
|
+
} from './state.mjs'
|
|
17
|
+
import { animationAllowed, onMotionChange } from './motion.mjs'
|
|
18
|
+
|
|
19
|
+
const q = (root, selectors) => root.querySelector(selectors)
|
|
20
|
+
const qa = (root, selectors) => [...root.querySelectorAll(selectors)]
|
|
21
|
+
const finePointer = () => matchMedia('(hover: hover) and (pointer: fine)').matches
|
|
22
|
+
|
|
23
|
+
// Move keyboard focus before its slide becomes inert or hidden.
|
|
24
|
+
function focusAvailable(candidates, reveal = false) {
|
|
25
|
+
const target = candidates.find(element => element && !element.matches(':disabled') && !element.closest('[inert], [hidden]') && element.getClientRects().length && getComputedStyle(element).visibility !== 'hidden')
|
|
26
|
+
target?.focus({ preventScroll: !reveal })
|
|
27
|
+
return !!target && document.activeElement === target
|
|
28
|
+
}
|
|
29
|
+
function revealControlFocus(controls) {
|
|
30
|
+
const focused = document.activeElement
|
|
31
|
+
if (focused instanceof HTMLElement && controls?.contains(focused) && focused.getClientRects().length) focused.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' })
|
|
32
|
+
}
|
|
33
|
+
function preserveSlideFocus(slides, shown, control) {
|
|
34
|
+
if (!slides.some(slide => slide !== shown && slide.contains(document.activeElement))) return
|
|
35
|
+
const controls = control?.closest('[data-controls]')
|
|
36
|
+
focusAvailable([control, ...(controls ? controls.querySelectorAll('button') : [])])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Enhance every `[data-carousel]` under `root`. Safe to call more than once. */
|
|
40
|
+
export function enhanceCarousels(root = document) {
|
|
41
|
+
return qa(root, '[data-carousel]').map(enhanceCarousel).filter(Boolean)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function enhanceCarousel(root) {
|
|
45
|
+
if (root.dataset.ready !== undefined) return null
|
|
46
|
+
const track = q(root, '[data-carousel-track], .carousel__track')
|
|
47
|
+
if (!track) return null
|
|
48
|
+
// Slides are the track's children; `data-slide` narrows that when a track holds other elements.
|
|
49
|
+
const children = [...track.children].filter(element => !element.hasAttribute('data-clone'))
|
|
50
|
+
const slides = children.some(element => element.hasAttribute('data-slide')) ? children.filter(element => element.hasAttribute('data-slide')) : children
|
|
51
|
+
const parts = {
|
|
52
|
+
root, track, slides, n: slides.length,
|
|
53
|
+
label: root.getAttribute('aria-label') ?? 'Carousel',
|
|
54
|
+
viewport: q(root, '[data-carousel-viewport], .carousel__viewport') || track,
|
|
55
|
+
controls: q(root, '[data-controls]'),
|
|
56
|
+
back: q(root, '[data-prev]'),
|
|
57
|
+
next: q(root, '[data-next]'),
|
|
58
|
+
pages: qa(root, '[data-page]'),
|
|
59
|
+
status: q(root, '[data-status]'),
|
|
60
|
+
count: q(root, '[data-count]'),
|
|
61
|
+
scrollbar: q(root, '[data-scrollbar]'),
|
|
62
|
+
toggle: q(root, '[data-autoplay-toggle]'),
|
|
63
|
+
autoplay: autoplayConfig({ mode: root.dataset.autoplay, interval: root.dataset.interval, speed: root.dataset.speed }),
|
|
64
|
+
}
|
|
65
|
+
if (parts.n < 2) { if (parts.controls) parts.controls.hidden = true; root.dataset.ready = ''; return null }
|
|
66
|
+
slides.forEach((slide, i) => {
|
|
67
|
+
slide.setAttribute('role', 'group')
|
|
68
|
+
slide.setAttribute('aria-roledescription', 'slide')
|
|
69
|
+
if (!slide.hasAttribute('aria-label')) slide.setAttribute('aria-label', `${i + 1} of ${parts.n}`)
|
|
70
|
+
})
|
|
71
|
+
root.querySelectorAll('img, a').forEach(element => element.setAttribute('draggable', 'false'))
|
|
72
|
+
// Thumbnail pages without their own image show the slide's image.
|
|
73
|
+
parts.pages.forEach((page, i) => {
|
|
74
|
+
if (!page.hasAttribute('data-thumb') || page.querySelector('img')) return
|
|
75
|
+
const img = slides[i]?.querySelector('img')
|
|
76
|
+
const set = () => img?.currentSrc && page.style.setProperty('--thumb', `url("${img.currentSrc}")`)
|
|
77
|
+
img?.complete ? set() : img?.addEventListener('load', set, { once: true })
|
|
78
|
+
})
|
|
79
|
+
return root.dataset.carousel === 'fade' ? initFade(parts) : initTrack(parts)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ------------------------------------------------------------------ autoplay */
|
|
83
|
+
|
|
84
|
+
function createAutoplay({ root, toggle, autoplay }, { advance, startDrift, stopDrift, canRun }) {
|
|
85
|
+
const reasons = new Set()
|
|
86
|
+
let userPaused = root.dataset.autoplayPaused !== undefined
|
|
87
|
+
let onScreen = true
|
|
88
|
+
let timer = 0
|
|
89
|
+
const enabled = () => autoplay.mode !== 'off' && animationAllowed() && canRun()
|
|
90
|
+
const running = () => enabled() && !userPaused && !reasons.size && onScreen
|
|
91
|
+
const stop = () => { clearTimeout(timer); timer = 0; stopDrift() }
|
|
92
|
+
const start = () => {
|
|
93
|
+
stop()
|
|
94
|
+
if (!running()) return
|
|
95
|
+
if (autoplay.mode === 'discrete') timer = setTimeout(() => { advance(); start() }, autoplay.interval)
|
|
96
|
+
else startDrift()
|
|
97
|
+
}
|
|
98
|
+
const sync = () => {
|
|
99
|
+
root.dataset.autoplayState = enabled() ? (userPaused ? 'paused' : reasons.size ? 'held' : 'playing') : 'off'
|
|
100
|
+
if (!toggle) return
|
|
101
|
+
toggle.hidden = !enabled()
|
|
102
|
+
toggle.setAttribute('aria-label', userPaused ? 'Play automatic scrolling' : 'Pause automatic scrolling')
|
|
103
|
+
toggle.dataset.state = userPaused ? 'paused' : 'playing'
|
|
104
|
+
}
|
|
105
|
+
const hold = reason => { reasons.add(reason); stop(); sync() }
|
|
106
|
+
const release = (reason, delay = 0) => {
|
|
107
|
+
const go = () => { reasons.delete(reason); start(); sync() }
|
|
108
|
+
delay ? setTimeout(go, delay) : go()
|
|
109
|
+
}
|
|
110
|
+
if (autoplay.mode !== 'off') {
|
|
111
|
+
toggle?.addEventListener('click', () => { userPaused = !userPaused; start(); sync() })
|
|
112
|
+
root.addEventListener('mouseenter', () => { if (finePointer()) hold('hover') })
|
|
113
|
+
root.addEventListener('mouseleave', () => release('hover'))
|
|
114
|
+
root.addEventListener('focusin', event => { if (!toggle?.contains(event.target)) hold('focus') })
|
|
115
|
+
root.addEventListener('focusout', event => { if (!root.contains(event.relatedTarget)) release('focus') })
|
|
116
|
+
root.addEventListener('pointerdown', event => { if (event.pointerType !== 'mouse') hold('touch') })
|
|
117
|
+
const lift = event => { if (event.pointerType !== 'mouse') release('touch', 2500) }
|
|
118
|
+
root.addEventListener('pointerup', lift)
|
|
119
|
+
root.addEventListener('pointercancel', lift)
|
|
120
|
+
let wheelTimer = 0
|
|
121
|
+
root.addEventListener('wheel', () => { hold('wheel'); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => release('wheel'), 2500) }, { passive: true })
|
|
122
|
+
onMotionChange(() => { start(); sync() })
|
|
123
|
+
if ('IntersectionObserver' in window) new IntersectionObserver(entries => { onScreen = entries.some(entry => entry.isIntersecting); start(); sync() }, { threshold: 0.25 }).observe(root)
|
|
124
|
+
}
|
|
125
|
+
return { start, stop, hold, release, sync, restart: () => { if (autoplay.mode === 'discrete') start() }, smooth: autoplay.mode === 'smooth', active: running }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ------------------------------------------------------------- scroll bar */
|
|
129
|
+
|
|
130
|
+
function bindScrollbar({ root, scrollbar }, { geometry, scrubTo, begin, end }) {
|
|
131
|
+
const thumb = scrollbar?.querySelector('span, [data-scrollbar-thumb]')
|
|
132
|
+
if (!scrollbar || !thumb) return () => {}
|
|
133
|
+
scrollbar.addEventListener('pointerdown', event => {
|
|
134
|
+
if (event.button > 0) return
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
const rail = scrollbar.getBoundingClientRect()
|
|
137
|
+
const box = thumb.getBoundingClientRect()
|
|
138
|
+
const onThumb = event.clientX >= box.left && event.clientX <= box.right
|
|
139
|
+
const grab = onThumb ? event.clientX - box.left : box.width / 2
|
|
140
|
+
scrollbar.setPointerCapture(event.pointerId)
|
|
141
|
+
root.dataset.scrubbing = ''
|
|
142
|
+
begin()
|
|
143
|
+
const move = x => scrubTo(scrubPosition(x, rail.left, rail.width, box.width, grab, geometry().range))
|
|
144
|
+
move(event.clientX)
|
|
145
|
+
const onMove = moveEvent => move(moveEvent.clientX)
|
|
146
|
+
const onUp = () => { scrollbar.removeEventListener('pointermove', onMove); delete root.dataset.scrubbing; end() }
|
|
147
|
+
scrollbar.addEventListener('pointermove', onMove)
|
|
148
|
+
scrollbar.addEventListener('pointerup', onUp, { once: true })
|
|
149
|
+
scrollbar.addEventListener('pointercancel', onUp, { once: true })
|
|
150
|
+
})
|
|
151
|
+
return () => {
|
|
152
|
+
const { position, range, viewport } = geometry()
|
|
153
|
+
const { width, left } = thumbGeometry(position, range, viewport)
|
|
154
|
+
thumb.style.width = `${width * 100}%`
|
|
155
|
+
thumb.style.left = `${left * 100}%`
|
|
156
|
+
scrollbar.hidden = range <= 1
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ------------------------------------------------------------------ track */
|
|
161
|
+
|
|
162
|
+
function initTrack(parts) {
|
|
163
|
+
const { root, viewport, track, slides, n, label, controls, back, next, pages, status, count } = parts
|
|
164
|
+
const loop = root.hasAttribute('data-loop')
|
|
165
|
+
const peek = root.hasAttribute('data-peek') || root.classList.contains('carousel--peek')
|
|
166
|
+
// select="current": one slide is current and the rest are inert (slideshows).
|
|
167
|
+
// select="visible": every card on screen stays interactive (product strips).
|
|
168
|
+
const visibleMode = root.dataset.select === 'visible'
|
|
169
|
+
const pageStep = (root.dataset.step || (visibleMode ? 'page' : 'item')) === 'page'
|
|
170
|
+
// fit="current": the strip is as tall as the slide in view, blending between heights as they pass.
|
|
171
|
+
const fitCurrent = root.dataset.fit === 'current'
|
|
172
|
+
const rowMin = Number(root.dataset.rowMin) || 400
|
|
173
|
+
let sets = 0
|
|
174
|
+
let items = slides
|
|
175
|
+
let step = 0, current = -1, target = null, announce = false, row = false, timer = 0, realigned = false
|
|
176
|
+
let home = 0, intent = false, drifting = false, gliding = 0, scrubbing = false
|
|
177
|
+
let heights = [], padY = 0, padTop = 0
|
|
178
|
+
let drag = null, suppressClick = false
|
|
179
|
+
|
|
180
|
+
const buildClones = perSide => {
|
|
181
|
+
track.querySelectorAll('[data-clone]').forEach(clone => clone.remove())
|
|
182
|
+
sets = loop ? loopSets(n, perSide) : 0
|
|
183
|
+
if (sets) {
|
|
184
|
+
const clone = slide => {
|
|
185
|
+
const copy = slide.cloneNode(true)
|
|
186
|
+
copy.removeAttribute('id'); copy.querySelectorAll('[id]').forEach(element => element.removeAttribute('id'))
|
|
187
|
+
copy.dataset.clone = ''; copy.inert = true; copy.setAttribute('aria-hidden', 'true')
|
|
188
|
+
return copy
|
|
189
|
+
}
|
|
190
|
+
const copies = () => Array.from({ length: sets }, () => slides.map(clone)).flat()
|
|
191
|
+
track.prepend(...copies()); track.append(...copies())
|
|
192
|
+
}
|
|
193
|
+
items = [...track.children]
|
|
194
|
+
}
|
|
195
|
+
const first = () => sets * n
|
|
196
|
+
const position = () => (step ? viewport.scrollLeft / step : 0)
|
|
197
|
+
const scrollToIndex = (index, smooth) => viewport.scrollTo({ left: index * step, behavior: smooth && animationAllowed() ? 'smooth' : 'instant' })
|
|
198
|
+
const perPage = () => (pageStep ? arrowStep(pageSize(viewport.clientWidth, step), n, loop) : 1)
|
|
199
|
+
const lastIndex = () => (step ? Math.max(0, Math.round((viewport.scrollWidth - viewport.clientWidth) / step)) : 0)
|
|
200
|
+
|
|
201
|
+
const fitHeight = pos => {
|
|
202
|
+
if (!fitCurrent || row || !heights.length) return
|
|
203
|
+
const a = Math.floor(pos), h = i => heights[((i % n) + n) % n]
|
|
204
|
+
viewport.style.height = `${h(a) + (h(a + 1) - h(a)) * (pos - a) + padY}px`
|
|
205
|
+
}
|
|
206
|
+
const placeArrows = () => {
|
|
207
|
+
const middle = fitCurrent && heights.length ? padTop + Math.min(...heights) / 2 : viewport.offsetHeight / 2
|
|
208
|
+
root.style.setProperty('--mid', `${viewport.offsetTop + middle}px`)
|
|
209
|
+
}
|
|
210
|
+
const measureHeights = () => {
|
|
211
|
+
if (!fitCurrent) return
|
|
212
|
+
const style = getComputedStyle(viewport)
|
|
213
|
+
padTop = parseFloat(style.paddingTop); padY = padTop + parseFloat(style.paddingBottom)
|
|
214
|
+
heights = slides.map(slide => slide.offsetHeight)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const geometry = () => {
|
|
218
|
+
if (sets) {
|
|
219
|
+
const period = n * step
|
|
220
|
+
return { position: wrap(viewport.scrollLeft - first() * step, period), range: Math.max(0, period - viewport.clientWidth), viewport: viewport.clientWidth }
|
|
221
|
+
}
|
|
222
|
+
return { position: viewport.scrollLeft, range: Math.max(0, viewport.scrollWidth - viewport.clientWidth), viewport: viewport.clientWidth }
|
|
223
|
+
}
|
|
224
|
+
const paintScrollbar = bindScrollbar(parts, {
|
|
225
|
+
geometry,
|
|
226
|
+
scrubTo: offset => { viewport.scrollLeft = (sets ? first() * step : 0) + offset },
|
|
227
|
+
begin: () => { stopGlide(); scrubbing = true; intent = true; viewport.style.scrollSnapType = 'none'; player.hold('scrub') },
|
|
228
|
+
end: () => { scrubbing = false; intent = true; settleSoon(); player.release('scrub') },
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const paint = () => {
|
|
232
|
+
if (row || !step) return
|
|
233
|
+
const pos = position(), c = Math.max(0, Math.min(items.length - 1, Math.round(pos)))
|
|
234
|
+
if (peek) items.forEach((item, i) => item.style.setProperty('--d', recede(pos, i).toFixed(3)))
|
|
235
|
+
fitHeight(pos)
|
|
236
|
+
paintScrollbar()
|
|
237
|
+
if (c === current) return
|
|
238
|
+
current = c
|
|
239
|
+
if (!loop && back && next) { back.disabled = c <= 0; next.disabled = c >= lastIndex() }
|
|
240
|
+
if (!visibleMode) preserveSlideFocus(items, items[c], pages[c % n])
|
|
241
|
+
items.forEach((item, i) => {
|
|
242
|
+
const shown = i === c
|
|
243
|
+
item.classList.toggle('is-current', shown)
|
|
244
|
+
if (!visibleMode && !item.hasAttribute('data-clone')) { item.inert = !shown; item.toggleAttribute('aria-hidden', !shown) }
|
|
245
|
+
})
|
|
246
|
+
pages.forEach((page, i) => page.setAttribute('aria-current', String(i === c % n)))
|
|
247
|
+
if (count) count.textContent = `${(c % n) + 1} / ${n}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Once still: step back onto the real slide showing the same content (identical, so invisible).
|
|
251
|
+
const settle = () => {
|
|
252
|
+
if (row || !step || drag || gliding || scrubbing) return
|
|
253
|
+
if (drifting) return
|
|
254
|
+
if (player.smooth && player.active()) return
|
|
255
|
+
if (!intent) {
|
|
256
|
+
if (!player.smooth) viewport.style.removeProperty('scroll-snap-type')
|
|
257
|
+
const goal = (first() + home) * step
|
|
258
|
+
if (Math.abs(viewport.scrollLeft - Math.min(goal, viewport.scrollWidth - viewport.clientWidth)) > 1) scrollToIndex(first() + home, false)
|
|
259
|
+
paint(); return
|
|
260
|
+
}
|
|
261
|
+
const c = Math.round(position())
|
|
262
|
+
if (!player.smooth && !realigned && Math.abs(viewport.scrollLeft - c * step) > 2 && c < lastIndex()) {
|
|
263
|
+
realigned = true; viewport.style.removeProperty('scroll-snap-type'); scrollToIndex(c, true)
|
|
264
|
+
if (!animationAllowed()) settle()
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
realigned = false
|
|
268
|
+
if (sets && (c < first() || c >= first() + n)) viewport.scrollTo({ left: viewport.scrollLeft + (homeIndex(c, n, sets) - c) * step, behavior: 'instant' })
|
|
269
|
+
home = ((Math.round(position()) % n) + n) % n
|
|
270
|
+
if (!sets) home = Math.round(position())
|
|
271
|
+
intent = false; target = null
|
|
272
|
+
if (!player.smooth) viewport.style.removeProperty('scroll-snap-type')
|
|
273
|
+
paint()
|
|
274
|
+
if (announce && status) { status.textContent = `${label}: ${(current % n) + 1} of ${n}`; announce = false }
|
|
275
|
+
}
|
|
276
|
+
const settleSoon = () => { if (!('onscrollend' in window)) { clearTimeout(timer); timer = setTimeout(settle, 140) } else requestAnimationFrame(() => settle()) }
|
|
277
|
+
|
|
278
|
+
const go = (delta, { user = true } = {}) => {
|
|
279
|
+
if (row || !step) return
|
|
280
|
+
stopGlide()
|
|
281
|
+
intent = true
|
|
282
|
+
let base = target ?? Math.round(position())
|
|
283
|
+
if (sets) {
|
|
284
|
+
const equivalent = homeIndex(base, n, sets)
|
|
285
|
+
if (equivalent !== base) { viewport.scrollTo({ left: viewport.scrollLeft + (equivalent - base) * step, behavior: 'instant' }); base = equivalent }
|
|
286
|
+
} else if (loop && delta > 0 && base >= lastIndex()) {
|
|
287
|
+
delta = -base // a loop too short for clones still wraps
|
|
288
|
+
} else if (loop && delta < 0 && base <= 0) {
|
|
289
|
+
delta = lastIndex()
|
|
290
|
+
}
|
|
291
|
+
const to = sets ? base + delta : Math.max(0, Math.min(lastIndex(), base + delta))
|
|
292
|
+
target = to; announce = user
|
|
293
|
+
scrollToIndex(to, true)
|
|
294
|
+
}
|
|
295
|
+
const goTo = index => go(sets ? nearestCopy(target ?? Math.round(position()), index, n) : index - (target ?? Math.round(position())))
|
|
296
|
+
|
|
297
|
+
const measure = () => {
|
|
298
|
+
const focusedSlide = slides.findIndex(slide => slide.contains(document.activeElement))
|
|
299
|
+
const keep = row && focusedSlide >= 0 ? focusedSlide : home
|
|
300
|
+
home = keep
|
|
301
|
+
const controlFocused = controls?.contains(document.activeElement)
|
|
302
|
+
const focusedPage = pages.findIndex(page => page.contains(document.activeElement))
|
|
303
|
+
root.style.removeProperty('--slide-fit'); delete root.dataset.row
|
|
304
|
+
const gap = parseFloat(getComputedStyle(track).columnGap) || 0
|
|
305
|
+
const fit = peek ? rowFit(n, slides[0].offsetWidth, gap, root.clientWidth, rowMin) : 0
|
|
306
|
+
row = fit > 0
|
|
307
|
+
viewport.style.removeProperty('height')
|
|
308
|
+
if (row) {
|
|
309
|
+
root.dataset.row = ''; root.style.setProperty('--slide-fit', `${fit}px`)
|
|
310
|
+
slides.forEach(slide => { slide.inert = false; slide.removeAttribute('aria-hidden'); slide.classList.remove('is-current'); slide.style.removeProperty('--d') })
|
|
311
|
+
if (controlFocused) {
|
|
312
|
+
const shown = slides[focusedPage >= 0 ? focusedPage : keep]
|
|
313
|
+
if (!focusAvailable([...shown.querySelectorAll('a[href], button, [tabindex]:not([tabindex="-1"])')], true)) { root.tabIndex = -1; root.focus() }
|
|
314
|
+
}
|
|
315
|
+
if (controls) controls.hidden = true
|
|
316
|
+
current = -1
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
// Fractional widths matter: offsetLeft rounds, and the error grows per card.
|
|
320
|
+
const width = parseFloat(getComputedStyle(slides[0]).width)
|
|
321
|
+
step = width + gap
|
|
322
|
+
root.style.setProperty('--slide-px', `${width}px`)
|
|
323
|
+
const fitsAll = !sets && viewport.scrollWidth - viewport.clientWidth <= 1
|
|
324
|
+
if (loop) {
|
|
325
|
+
const needed = pageSize(viewport.clientWidth, step) + (peek ? 4 : 1)
|
|
326
|
+
if (!sets || loopSets(n, needed) !== sets) buildClones(needed)
|
|
327
|
+
// A loop that cannot fill the screen with one set falls back to plain wrapping arrows.
|
|
328
|
+
if (n * step < viewport.clientWidth) { track.querySelectorAll('[data-clone]').forEach(clone => clone.remove()); sets = 0; items = slides }
|
|
329
|
+
}
|
|
330
|
+
root.dataset.overflow = String(!(fitsAll || (!sets && viewport.scrollWidth - viewport.clientWidth <= 1)))
|
|
331
|
+
if (controls) controls.hidden = root.dataset.overflow === 'false'
|
|
332
|
+
measureHeights()
|
|
333
|
+
current = -1; target = null
|
|
334
|
+
scrollToIndex(first() + keep, false)
|
|
335
|
+
paint()
|
|
336
|
+
placeArrows()
|
|
337
|
+
revealControlFocus(controls)
|
|
338
|
+
player.sync()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Momentum for multi-card strips; slideshows step one slide per swipe instead.
|
|
342
|
+
function stopGlide() { cancelAnimationFrame(gliding); gliding = 0 }
|
|
343
|
+
const glide = velocity => {
|
|
344
|
+
if (!animationAllowed() || Math.abs(velocity) < 0.05) { settle(); return }
|
|
345
|
+
let last = performance.now(), pos = viewport.scrollLeft
|
|
346
|
+
const frame = now => {
|
|
347
|
+
const next = glideStep(pos, velocity, now - last)
|
|
348
|
+
last = now; pos = next.position; velocity = next.velocity
|
|
349
|
+
viewport.scrollLeft = pos
|
|
350
|
+
const atEdge = !sets && (pos <= 0 || pos >= viewport.scrollWidth - viewport.clientWidth)
|
|
351
|
+
if (Math.abs(velocity) > 0.03 && !atEdge) gliding = requestAnimationFrame(frame)
|
|
352
|
+
else { gliding = 0; settle() }
|
|
353
|
+
}
|
|
354
|
+
gliding = requestAnimationFrame(frame)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Smooth autoplay keeps its own sub-pixel position so slow speeds never stall on rounding.
|
|
358
|
+
let driftFrame = 0
|
|
359
|
+
const player = createAutoplay(parts, {
|
|
360
|
+
advance: () => go(perPage(), { user: false }),
|
|
361
|
+
canRun: () => !row && root.dataset.overflow !== 'false',
|
|
362
|
+
startDrift: () => {
|
|
363
|
+
drifting = true
|
|
364
|
+
viewport.style.scrollSnapType = 'none'
|
|
365
|
+
let pos = viewport.scrollLeft, last = 0
|
|
366
|
+
const frame = now => {
|
|
367
|
+
if (last) {
|
|
368
|
+
if (sets) pos = first() * step + driftStep(pos - first() * step, parts.autoplay.speed, now - last, n * step)
|
|
369
|
+
else pos = driftStep(pos, parts.autoplay.speed, now - last, 0, viewport.scrollWidth - viewport.clientWidth)
|
|
370
|
+
viewport.scrollLeft = pos
|
|
371
|
+
}
|
|
372
|
+
last = now
|
|
373
|
+
driftFrame = requestAnimationFrame(frame)
|
|
374
|
+
}
|
|
375
|
+
driftFrame = requestAnimationFrame(frame)
|
|
376
|
+
},
|
|
377
|
+
stopDrift: () => {
|
|
378
|
+
if (!driftFrame) return
|
|
379
|
+
cancelAnimationFrame(driftFrame); driftFrame = 0; drifting = false
|
|
380
|
+
home = ((Math.round(position()) - first()) % n + n) % n
|
|
381
|
+
},
|
|
382
|
+
})
|
|
383
|
+
if (player.smooth) root.dataset.drift = ''
|
|
384
|
+
|
|
385
|
+
viewport.addEventListener('scroll', () => {
|
|
386
|
+
requestAnimationFrame(paint)
|
|
387
|
+
if (!('onscrollend' in window)) { clearTimeout(timer); timer = setTimeout(settle, 140) }
|
|
388
|
+
}, { passive: true })
|
|
389
|
+
viewport.addEventListener('scrollend', settle)
|
|
390
|
+
const user = () => { intent = true; stopGlide() }
|
|
391
|
+
for (const type of ['pointerdown', 'touchstart', 'wheel', 'keydown']) viewport.addEventListener(type, user, { passive: true })
|
|
392
|
+
|
|
393
|
+
back?.addEventListener('click', () => { go(-perPage()); player.restart() })
|
|
394
|
+
next?.addEventListener('click', () => { go(perPage()); player.restart() })
|
|
395
|
+
pages.forEach((page, i) => page.addEventListener('click', () => { goTo(i); player.restart() }))
|
|
396
|
+
root.addEventListener('keydown', event => {
|
|
397
|
+
if (row || event.altKey || event.ctrlKey || event.metaKey) return
|
|
398
|
+
if (event.target.closest?.('input, select, textarea, [contenteditable]')) return
|
|
399
|
+
const keys = { ArrowLeft: () => go(-1), ArrowRight: () => go(1), Home: () => goTo(0), End: () => goTo(n - 1) }
|
|
400
|
+
if (!keys[event.key]) return
|
|
401
|
+
event.preventDefault(); keys[event.key]()
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
// Mouse drag (touch and trackpads already scroll natively): follow the pointer, then settle.
|
|
405
|
+
root.addEventListener('pointerdown', event => {
|
|
406
|
+
if (row || event.pointerType !== 'mouse' || event.button !== 0 || event.target.closest('button, [data-scrollbar], input, select, textarea')) return
|
|
407
|
+
if (!viewport.contains(event.target)) return
|
|
408
|
+
stopGlide()
|
|
409
|
+
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, t: event.timeStamp, left: viewport.scrollLeft, state: 'pending', samples: [{ x: event.clientX, t: event.timeStamp }] }
|
|
410
|
+
suppressClick = false
|
|
411
|
+
})
|
|
412
|
+
root.addEventListener('pointermove', event => {
|
|
413
|
+
if (!drag || event.pointerId !== drag.id) return
|
|
414
|
+
const dx = event.clientX - drag.x, dy = event.clientY - drag.y
|
|
415
|
+
if (drag.state === 'pending') {
|
|
416
|
+
drag.state = dragIntent(dx, dy)
|
|
417
|
+
if (drag.state === 'vertical') { drag = null; return }
|
|
418
|
+
if (drag.state === 'horizontal') {
|
|
419
|
+
root.setPointerCapture(event.pointerId); root.dataset.dragging = ''
|
|
420
|
+
viewport.style.scrollSnapType = 'none'; intent = true; player.hold('drag')
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (drag.state === 'horizontal') {
|
|
424
|
+
viewport.scrollLeft = drag.left - dx
|
|
425
|
+
drag.samples.push({ x: event.clientX, t: event.timeStamp })
|
|
426
|
+
if (drag.samples.length > 6) drag.samples.shift()
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
const release = (event, cancelled) => {
|
|
430
|
+
if (!drag || event.pointerId !== drag.id) return
|
|
431
|
+
const { x, y, t, left, state, samples } = drag
|
|
432
|
+
drag = null; delete root.dataset.dragging
|
|
433
|
+
if (state !== 'horizontal') return
|
|
434
|
+
suppressClick = true
|
|
435
|
+
player.release('drag')
|
|
436
|
+
if (visibleMode || player.smooth) {
|
|
437
|
+
const a = samples[0], b = samples.at(-1)
|
|
438
|
+
glide(cancelled ? 0 : releaseVelocity(b.x - a.x, b.t - a.t))
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
const start = Math.round(left / step), near = Math.round(position())
|
|
442
|
+
const flick = cancelled ? 0 : swipeStep(event.clientX - x, event.clientY - y, event.timeStamp - t)
|
|
443
|
+
let to = near === start ? start + flick : near
|
|
444
|
+
if (!sets) to = Math.max(0, Math.min(lastIndex(), to))
|
|
445
|
+
announce = to !== start; target = to
|
|
446
|
+
scrollToIndex(to, true)
|
|
447
|
+
if (Math.abs(viewport.scrollLeft - to * step) < 1) settle()
|
|
448
|
+
}
|
|
449
|
+
root.addEventListener('pointerup', event => release(event, false))
|
|
450
|
+
root.addEventListener('pointercancel', event => release(event, true))
|
|
451
|
+
root.addEventListener('dragstart', event => event.preventDefault())
|
|
452
|
+
root.addEventListener('click', event => {
|
|
453
|
+
if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false; return }
|
|
454
|
+
// A click on a receding neighbour brings it to the centre instead of following its link.
|
|
455
|
+
if (visibleMode || !peek || row || !step || event.detail === 0 || !viewport.contains(event.target)) return
|
|
456
|
+
const box = viewport.getBoundingClientRect()
|
|
457
|
+
const offset = Math.round((event.clientX - (box.left + box.width / 2)) / step)
|
|
458
|
+
if (offset) { event.preventDefault(); event.stopPropagation(); go(offset) }
|
|
459
|
+
}, true)
|
|
460
|
+
|
|
461
|
+
let lastWidth = 0
|
|
462
|
+
new ResizeObserver(() => requestAnimationFrame(() => { const w = root.clientWidth; if (Math.abs(w - lastWidth) > 0.5) { lastWidth = w; measure() } })).observe(root)
|
|
463
|
+
if (fitCurrent) {
|
|
464
|
+
const watch = new ResizeObserver(() => requestAnimationFrame(() => { if (row || !step) return; measureHeights(); fitHeight(position()); placeArrows() }))
|
|
465
|
+
slides.forEach(slide => watch.observe(slide))
|
|
466
|
+
}
|
|
467
|
+
root.dataset.ready = ''
|
|
468
|
+
measure()
|
|
469
|
+
player.start()
|
|
470
|
+
player.sync()
|
|
471
|
+
return { root, go, goTo, pause: () => player.hold('api'), play: () => player.release('api') }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/* ------------------------------------------------------------------- fade */
|
|
475
|
+
|
|
476
|
+
function initFade(parts) {
|
|
477
|
+
const { root, viewport, slides, n, label, controls, back, next, pages, status, count } = parts
|
|
478
|
+
const loop = root.hasAttribute('data-loop')
|
|
479
|
+
const fit = root.dataset.fit === 'current'
|
|
480
|
+
let active = 0
|
|
481
|
+
const fitHeight = () => { if (fit) viewport.style.height = `${slides[active].offsetHeight}px` }
|
|
482
|
+
const placeArrows = () => {
|
|
483
|
+
const height = fit ? Math.min(...slides.map(slide => slide.offsetHeight)) : viewport.offsetHeight
|
|
484
|
+
root.style.setProperty('--mid', `${viewport.offsetTop + height / 2}px`)
|
|
485
|
+
}
|
|
486
|
+
let lastWidth = root.clientWidth
|
|
487
|
+
const watch = new ResizeObserver(() => requestAnimationFrame(() => {
|
|
488
|
+
const width = root.clientWidth, resized = Math.abs(width - lastWidth) > 0.5
|
|
489
|
+
lastWidth = width
|
|
490
|
+
fitHeight(); placeArrows()
|
|
491
|
+
if (resized) revealControlFocus(controls)
|
|
492
|
+
}))
|
|
493
|
+
watch.observe(root); slides.forEach(slide => watch.observe(slide))
|
|
494
|
+
const render = announce => {
|
|
495
|
+
const state = slideWindow(active, n, 1, loop)
|
|
496
|
+
active = state.start
|
|
497
|
+
if (back) back.disabled = state.previousDisabled
|
|
498
|
+
if (next) next.disabled = state.nextDisabled
|
|
499
|
+
preserveSlideFocus(slides, slides[active], pages[active])
|
|
500
|
+
slides.forEach((slide, i) => { const shown = i === active; slide.classList.toggle('is-current', shown); slide.inert = !shown })
|
|
501
|
+
pages.forEach((page, i) => page.setAttribute('aria-current', String(i === active)))
|
|
502
|
+
if (count) count.textContent = `${active + 1} / ${n}`
|
|
503
|
+
fitHeight()
|
|
504
|
+
if (announce && status) status.textContent = `${label}: ${active + 1} of ${n}`
|
|
505
|
+
root.dispatchEvent(new CustomEvent('carousel:change', { detail: { index: active } }))
|
|
506
|
+
}
|
|
507
|
+
const go = (targetIndex, announce = true) => { active = loop ? ((targetIndex % n) + n) % n : Math.max(0, Math.min(n - 1, targetIndex)); render(announce) }
|
|
508
|
+
const player = createAutoplay(parts, { advance: () => go(active + 1, false), canRun: () => true, startDrift: () => {}, stopDrift: () => {} })
|
|
509
|
+
|
|
510
|
+
back?.addEventListener('click', () => { go(active - 1); player.restart() })
|
|
511
|
+
next?.addEventListener('click', () => { go(active + 1); player.restart() })
|
|
512
|
+
pages.forEach((page, i) => page.addEventListener('click', () => { go(i); player.restart() }))
|
|
513
|
+
root.addEventListener('keydown', event => {
|
|
514
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
515
|
+
const keys = { ArrowLeft: () => go(active - 1), ArrowRight: () => go(active + 1), Home: () => go(0), End: () => go(n - 1) }
|
|
516
|
+
if (!keys[event.key]) return
|
|
517
|
+
event.preventDefault(); keys[event.key]()
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// Swipes: a horizontal gesture changes slide; a vertical one stays a page scroll.
|
|
521
|
+
let drag = null, suppressClick = false
|
|
522
|
+
root.addEventListener('pointerdown', event => {
|
|
523
|
+
if (!event.isPrimary || event.button > 0 || event.target.closest('button')) return
|
|
524
|
+
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, t: event.timeStamp, state: 'pending' }
|
|
525
|
+
suppressClick = false
|
|
526
|
+
})
|
|
527
|
+
root.addEventListener('pointermove', event => {
|
|
528
|
+
if (!drag || event.pointerId !== drag.id || drag.state !== 'pending') return
|
|
529
|
+
drag.state = dragIntent(event.clientX - drag.x, event.clientY - drag.y)
|
|
530
|
+
if (drag.state === 'vertical') drag = null
|
|
531
|
+
else if (drag.state === 'horizontal') { root.setPointerCapture(event.pointerId); root.dataset.dragging = '' }
|
|
532
|
+
})
|
|
533
|
+
const release = (event, cancelled) => {
|
|
534
|
+
if (!drag || event.pointerId !== drag.id) return
|
|
535
|
+
const horizontal = drag.state === 'horizontal', delta = cancelled ? 0 : swipeStep(event.clientX - drag.x, event.clientY - drag.y, event.timeStamp - drag.t)
|
|
536
|
+
drag = null; delete root.dataset.dragging
|
|
537
|
+
if (!horizontal) return
|
|
538
|
+
suppressClick = true
|
|
539
|
+
if (delta) { go(active + delta); player.restart() }
|
|
540
|
+
}
|
|
541
|
+
root.addEventListener('pointerup', event => release(event, false))
|
|
542
|
+
root.addEventListener('pointercancel', event => release(event, true))
|
|
543
|
+
root.addEventListener('click', event => { if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false } }, true)
|
|
544
|
+
|
|
545
|
+
if (controls) controls.hidden = false
|
|
546
|
+
root.dataset.ready = ''
|
|
547
|
+
render(false)
|
|
548
|
+
placeArrows()
|
|
549
|
+
player.start(); player.sync()
|
|
550
|
+
return { root, go, pause: () => player.hold('api'), play: () => player.release('api') }
|
|
551
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native <details> disclosures with interruptible open and close motion
|
|
3
|
+
* (browser). Keyboard and no-script behaviour stay native. data-state follows
|
|
4
|
+
* the reader's intent at once, so icons turn with the click while the panel
|
|
5
|
+
* is still moving. Reduced motion opens and closes instantly.
|
|
6
|
+
*/
|
|
7
|
+
import { animationAllowed } from './motion.mjs'
|
|
8
|
+
|
|
9
|
+
const PROPERTIES = ['height', 'paddingTop', 'paddingBottom', 'marginTop', 'marginBottom', 'opacity']
|
|
10
|
+
|
|
11
|
+
export function enhanceDisclosures(root = document, { openMs = 360, closeMs = 280 } = {}) {
|
|
12
|
+
root.querySelectorAll('details').forEach(details => {
|
|
13
|
+
const summary = details.querySelector(':scope > summary')
|
|
14
|
+
const content = summary?.nextElementSibling
|
|
15
|
+
if (!summary || !content || details.dataset.disclosureReady !== undefined) return
|
|
16
|
+
details.dataset.disclosureReady = ''
|
|
17
|
+
details.dataset.state = details.open ? 'open' : 'closed'
|
|
18
|
+
if (!content.animate) return
|
|
19
|
+
let animation = null
|
|
20
|
+
let opening = details.open
|
|
21
|
+
const values = () => {
|
|
22
|
+
const style = getComputedStyle(content)
|
|
23
|
+
return Object.fromEntries(PROPERTIES.map(property => [property, style[property]]))
|
|
24
|
+
}
|
|
25
|
+
const clear = () => { content.style.overflow = ''; content.style.boxSizing = '' }
|
|
26
|
+
summary.addEventListener('click', event => {
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
if (!animation) opening = details.open
|
|
29
|
+
opening = !opening
|
|
30
|
+
details.dataset.state = opening ? 'open' : 'closed'
|
|
31
|
+
if (!opening && content.contains(document.activeElement)) summary.focus()
|
|
32
|
+
const current = animation ? values() : null
|
|
33
|
+
animation?.cancel()
|
|
34
|
+
animation = null
|
|
35
|
+
if (!animationAllowed()) { details.open = opening; clear(); return }
|
|
36
|
+
details.open = true
|
|
37
|
+
content.style.overflow = 'hidden'
|
|
38
|
+
content.style.boxSizing = 'border-box'
|
|
39
|
+
const expanded = values()
|
|
40
|
+
const collapsed = Object.fromEntries(PROPERTIES.map(property => [property, property === 'opacity' ? '0' : '0px']))
|
|
41
|
+
animation = content.animate([current || (opening ? collapsed : expanded), opening ? expanded : collapsed], {
|
|
42
|
+
duration: opening ? openMs : closeMs,
|
|
43
|
+
easing: opening ? 'cubic-bezier(.22, 1, .36, 1)' : 'cubic-bezier(.4, 0, 1, 1)',
|
|
44
|
+
fill: 'both',
|
|
45
|
+
})
|
|
46
|
+
animation.onfinish = () => { details.open = opening; animation.cancel(); animation = null; clear() }
|
|
47
|
+
})
|
|
48
|
+
details.addEventListener('toggle', () => { if (!animation) details.dataset.state = details.open ? 'open' : 'closed' })
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atelier UI primitives (Node-safe): pure state, styles and markup
|
|
3
|
+
* helpers. Browser enhancers live in ./browser.mjs (@mnstry/atelier/ui/primitives/browser).
|
|
4
|
+
*/
|
|
5
|
+
export * from './state.mjs'
|
|
6
|
+
export { carouselStyles } from './styles.mjs'
|
|
7
|
+
export { renderCarouselControls } from './render.mjs'
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One owner for motion (browser): the reduced-motion preference and hidden tabs
|
|
3
|
+
* stop every effect started through these helpers.
|
|
4
|
+
*/
|
|
5
|
+
import { motionAllowed } from './state.mjs'
|
|
6
|
+
|
|
7
|
+
const listeners = new Set()
|
|
8
|
+
const running = new Set()
|
|
9
|
+
let reduced = null
|
|
10
|
+
let started = false
|
|
11
|
+
|
|
12
|
+
function preference() {
|
|
13
|
+
if (!reduced && typeof matchMedia === 'function') reduced = matchMedia('(prefers-reduced-motion: reduce)')
|
|
14
|
+
return reduced
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True when animation may run now. */
|
|
18
|
+
export function animationAllowed() {
|
|
19
|
+
return motionAllowed(Boolean(preference()?.matches), false, typeof document !== 'undefined' && document.hidden)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Call `listener` whenever the motion preference or tab visibility changes. */
|
|
23
|
+
export function onMotionChange(listener) {
|
|
24
|
+
listeners.add(listener)
|
|
25
|
+
startMotionWatch()
|
|
26
|
+
return () => listeners.delete(listener)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A Web Animation owned by the motion layer: skipped when motion is off, finished when it turns off. */
|
|
30
|
+
export function animate(element, frames, options) {
|
|
31
|
+
if (!animationAllowed() || !element.animate) return null
|
|
32
|
+
const animation = element.animate(frames, options)
|
|
33
|
+
running.add(animation)
|
|
34
|
+
animation.finished.catch(() => {}).finally(() => running.delete(animation))
|
|
35
|
+
return animation
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sync() {
|
|
39
|
+
document.documentElement.classList.toggle('motion-paused', Boolean(preference()?.matches))
|
|
40
|
+
if (!animationAllowed()) running.forEach(animation => animation.finish())
|
|
41
|
+
listeners.forEach(listener => listener())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function startMotionWatch() {
|
|
45
|
+
if (started || typeof document === 'undefined') return
|
|
46
|
+
started = true
|
|
47
|
+
preference()?.addEventListener('change', sync)
|
|
48
|
+
document.addEventListener('visibilitychange', sync)
|
|
49
|
+
sync()
|
|
50
|
+
}
|