@mnstry/atelier 0.2.0-alpha.17 → 0.2.0-alpha.18
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 -1
- package/README.md +1 -1
- package/docs/guides/upgrade.md +7 -0
- package/docs/reference/ui-primitives.md +21 -9
- package/fixtures/templates/local-library.v1.json +2 -1
- package/package.json +1 -1
- package/src/ui/primitives/carousel.mjs +113 -37
- package/src/ui/primitives/render.mjs +1 -1
- package/src/ui/primitives/state.mjs +23 -0
- package/templates/external-project-workspace/pack-lifecycle.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0-alpha.18
|
|
4
|
+
|
|
5
|
+
- Carousel autoplay keeps running under the pointer. It now holds for a press (any click, tap or drag), keyboard focus (not focus from a click), a wheel, an expanded slide, a hidden tab or the carousel being off screen, and resumes `data-resume` ms (default 2000) after the reader lets go; a smooth drift eases back to speed instead of jumping.
|
|
6
|
+
- A thrown drifting strip coasts longer and stops where it stops: nothing pulls it back to a card or to its earlier resting place, and the loop renumbers invisibly over its clones while coasting. A scrub, or a slide brought to the centre, also stays put until an arrow or a drag moves it.
|
|
7
|
+
- Opening a `<details>` inside a slide (a quick view, for example) brings that slide to the centre and holds autoplay until every expanded panel in the strip is closed.
|
|
8
|
+
- In a loop, the scroll bar's end now shows the last slide at the viewport's inner end edge (measured, so side insets are respected), and the thumb wraps: as the strip drifts it leaves the right end and re-enters on the left. `renderCarouselControls` emits a second thumb segment for this; an enhancer adds it to older markup.
|
|
9
|
+
- New pure helpers: `loopThumbGeometry`, `rampFactor`, `resumeDelay`.
|
|
10
|
+
|
|
3
11
|
## 0.2.0-alpha.17
|
|
4
12
|
|
|
5
|
-
- Add UI primitives (`@mnstry/atelier/ui/primitives` and `/ui/primitives/browser`), the shared behaviour layer beside the control system. The carousel
|
|
13
|
+
- Add UI primitives (`@mnstry/atelier/ui/primitives` and `/ui/primitives/browser`), the shared behaviour layer beside the control system. The carousel provides seamless loops over inert clones, horizontal drag intent, flick steps, focus kept off inert slides, page dots and thumbnails, a polite live status, height that follows the slide, receding neighbours and a still row when every card fits, and a crossfade mode, plus a draggable scroll bar, capped momentum for multi-card strips, page stepping that never reads as backwards in a short loop, and discrete or smooth autoplay with a pause control that holds on hover, focus, touch, wheel, drag, a hidden tab or off screen and never runs under reduced motion. A `visible` selection mode keeps every on-screen card interactive for product strips. Also: interruptible `<details>` disclosures, the counted layout-stable scroll lock from the portable presentation contract (measured from the body's laid-out width, so it holds with `scrollbar-gutter: stable`), and one motion owner. Pure state is exported for Node; tenants theme through `--system-carousel-*`. See the [UI primitives reference](docs/reference/ui-primitives.md).
|
|
6
14
|
|
|
7
15
|
## 0.2.0-alpha.16
|
|
8
16
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# MNSTRY Atelier
|
|
2
2
|
|
|
3
|
-
`0.2.0-alpha.17` adds shared [UI primitives](docs/reference/ui-primitives.md): one carousel, disclosure, scroll lock and motion layer for Atelier-built sites. `0.2.0-alpha.16` renames the [graph workspace](docs/guides/graph-workspace.md)'s room to the map and its middle layer to distribution, keeping the alpha.15 names for one release. `0.2.0-alpha.15` added the workspace (a map of every readable record, a focused authoring workbench and a review room, built from primitives distributions and owners can compose), renderer-neutral graph views and an [extension SDK and Postgres project workflow](docs/guides/extensions.md). Native host qualification remains a separate gate.
|
|
3
|
+
`0.2.0-alpha.18` refines carousel motion (gentle resume, coasting throws, centred expanded slides, a looping scroll bar). `0.2.0-alpha.17` adds shared [UI primitives](docs/reference/ui-primitives.md): one carousel, disclosure, scroll lock and motion layer for Atelier-built sites. `0.2.0-alpha.16` renames the [graph workspace](docs/guides/graph-workspace.md)'s room to the map and its middle layer to distribution, keeping the alpha.15 names for one release. `0.2.0-alpha.15` added the workspace (a map of every readable record, a focused authoring workbench and a review room, built from primitives distributions and owners can compose), renderer-neutral graph views and an [extension SDK and Postgres project workflow](docs/guides/extensions.md). Native host qualification remains a separate gate.
|
|
4
4
|
|
|
5
5
|
**Turn a repository into a living, governed system.**
|
|
6
6
|
|
package/docs/guides/upgrade.md
CHANGED
|
@@ -9,6 +9,13 @@ workspace between Atelier package releases. The flow is local-only: it does not
|
|
|
9
9
|
provision repositories, contact a Git host, mutate the MNSTRY runtime, or write
|
|
10
10
|
through a browser view.
|
|
11
11
|
|
|
12
|
+
## Upgrading to 0.2.0-alpha.18
|
|
13
|
+
|
|
14
|
+
Carousel autoplay no longer holds on hover; it holds for a press, keyboard
|
|
15
|
+
focus, a wheel or an expanded slide and resumes after `data-resume` ms. A site
|
|
16
|
+
that relied on hover to pause autoplay should rely on the pause control. No
|
|
17
|
+
stored record changes format.
|
|
18
|
+
|
|
12
19
|
## Upgrading to 0.2.0-alpha.17
|
|
13
20
|
|
|
14
21
|
This release only adds `@mnstry/atelier/ui/primitives` and
|
|
@@ -10,10 +10,10 @@ without script, and theming only through `--system-*` custom properties.
|
|
|
10
10
|
| `@mnstry/atelier/ui/primitives` | Node and browsers | Pure state functions, `carouselStyles`, `renderCarouselControls` |
|
|
11
11
|
| `@mnstry/atelier/ui/primitives/browser` | Browsers | `enhanceCarousels`, `enhanceDisclosures`, `lockScroll` and `unlockScroll`, the motion owner |
|
|
12
12
|
|
|
13
|
-
The carousel
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
The carousel covers slideshows and multi-card strips: seamless loops, drag
|
|
14
|
+
intent, focus safety, page dots and thumbnails, live status, height that follows
|
|
15
|
+
the slide, receding neighbours, still rows, a draggable scroll bar, momentum,
|
|
16
|
+
autoplay and a pause control.
|
|
17
17
|
|
|
18
18
|
## Carousel
|
|
19
19
|
|
|
@@ -52,6 +52,7 @@ track's children; if any child carries `data-slide`, only those count.
|
|
|
52
52
|
| `data-peek` | present | Centre-aligned slides; neighbours recede (`--d` from 0 to 1) and clicking one centres it; a still row when every card fits at `data-row-min` (default 400px) |
|
|
53
53
|
| `data-fit` | `current` | The viewport height follows the slide in view, blending as slides pass |
|
|
54
54
|
| `data-autoplay` | `off` (default), `discrete`, `smooth` | Advance one page every `data-interval` ms (at least 2500), or drift continuously at `data-speed` px/s |
|
|
55
|
+
| `data-resume` | ms, default 2000 | How long autoplay waits after the reader lets go before easing back in |
|
|
55
56
|
|
|
56
57
|
### Behaviour
|
|
57
58
|
|
|
@@ -65,11 +66,22 @@ track's children; if any child carries `data-slide`, only those count.
|
|
|
65
66
|
- **Arrows, pages and keys:** Previous, Next, page buttons, and Left, Right,
|
|
66
67
|
Home and End on the carousel. Reader-initiated moves are announced politely in
|
|
67
68
|
`[data-status]`; autoplay moves are not.
|
|
68
|
-
- **Autoplay** never runs under reduced motion. It
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
- **Autoplay** never runs under reduced motion. It keeps running under the
|
|
70
|
+
pointer, and holds for a press (any click, tap or drag), keyboard focus inside
|
|
71
|
+
the carousel, a wheel, an expanded slide, a hidden tab or the carousel being
|
|
72
|
+
off screen. It resumes `data-resume` ms after the reader lets go, and a smooth
|
|
73
|
+
drift eases back to speed over about a second. The rendered toggle pauses and
|
|
74
|
+
resumes it. `data-autoplay-state` reads `playing`, `held`, `paused` or `off`.
|
|
75
|
+
- **Throws and resting places:** in a drifting strip, a thrown strip coasts to a
|
|
76
|
+
stop where it stops; nothing pulls it back to a card or to where it was. A
|
|
77
|
+
scrub, or a slide brought to the centre, also stays where the reader left it
|
|
78
|
+
until an arrow or a drag moves it again.
|
|
79
|
+
- **Expanded slides:** opening a `<details>` inside a slide (a quick view, for
|
|
80
|
+
example) brings that slide to the centre and holds autoplay until every
|
|
81
|
+
expanded panel in the strip is closed again.
|
|
82
|
+
- **Looping scroll bar:** in a loop the thumb is a window over one period. As the
|
|
83
|
+
strip drifts, the thumb leaves the right end and re-enters on the left.
|
|
84
|
+
Dragging the thumb to the right end shows the last slide at the right edge.
|
|
73
85
|
- **Focus:** before a slide becomes inert, focus moves to its page button or
|
|
74
86
|
another control, so it is never lost.
|
|
75
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnstry/atelier",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local toolkit that turns a Git repository into an ontology-governed knowledge graph, enforcement layer, and runtime for people, agents, and tools. No telemetry or required hosted account; optional host-bound vault adapters.",
|
|
6
6
|
"keywords": [
|
|
@@ -11,14 +11,13 @@
|
|
|
11
11
|
* and discrete or smooth autoplay with a pause control.
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
|
-
arrowStep, autoplayConfig, dragIntent, driftStep, glideStep, homeIndex, loopSets, nearestCopy, releaseVelocity,
|
|
14
|
+
arrowStep, autoplayConfig, dragIntent, driftStep, glideStep, homeIndex, loopSets, loopThumbGeometry, nearestCopy, rampFactor, releaseVelocity, resumeDelay,
|
|
15
15
|
pageSize, recede, rowFit, scrubPosition, slideWindow, swipeStep, thumbGeometry, wrap,
|
|
16
16
|
} from './state.mjs'
|
|
17
17
|
import { animationAllowed, onMotionChange } from './motion.mjs'
|
|
18
18
|
|
|
19
19
|
const q = (root, selectors) => root.querySelector(selectors)
|
|
20
20
|
const qa = (root, selectors) => [...root.querySelectorAll(selectors)]
|
|
21
|
-
const finePointer = () => matchMedia('(hover: hover) and (pointer: fine)').matches
|
|
22
21
|
|
|
23
22
|
// Move keyboard focus before its slide becomes inert or hidden.
|
|
24
23
|
function focusAvailable(candidates, reveal = false) {
|
|
@@ -83,6 +82,8 @@ export function enhanceCarousel(root) {
|
|
|
83
82
|
|
|
84
83
|
function createAutoplay({ root, toggle, autoplay }, { advance, startDrift, stopDrift, canRun }) {
|
|
85
84
|
const reasons = new Set()
|
|
85
|
+
const pause = resumeDelay(root.dataset.resume)
|
|
86
|
+
const timers = new Map()
|
|
86
87
|
let userPaused = root.dataset.autoplayPaused !== undefined
|
|
87
88
|
let onScreen = true
|
|
88
89
|
let timer = 0
|
|
@@ -102,45 +103,54 @@ function createAutoplay({ root, toggle, autoplay }, { advance, startDrift, stopD
|
|
|
102
103
|
toggle.setAttribute('aria-label', userPaused ? 'Play automatic scrolling' : 'Pause automatic scrolling')
|
|
103
104
|
toggle.dataset.state = userPaused ? 'paused' : 'playing'
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
+
// A later hold for the same reason cancels a pending release.
|
|
107
|
+
const hold = reason => { clearTimeout(timers.get(reason)); timers.delete(reason); reasons.add(reason); stop(); sync() }
|
|
106
108
|
const release = (reason, delay = 0) => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
clearTimeout(timers.get(reason))
|
|
110
|
+
const go = () => { timers.delete(reason); reasons.delete(reason); start(); sync() }
|
|
111
|
+
if (delay) timers.set(reason, setTimeout(go, delay)); else go()
|
|
109
112
|
}
|
|
110
113
|
if (autoplay.mode !== 'off') {
|
|
114
|
+
// Autoplay keeps running under the pointer. It holds for a press (any click,
|
|
115
|
+
// tap or drag), keyboard focus, a wheel, an expanded slide, a hidden tab or
|
|
116
|
+
// off screen, and resumes gently `data-resume` ms (default 2000) after the
|
|
117
|
+
// reader lets go.
|
|
111
118
|
toggle?.addEventListener('click', () => { userPaused = !userPaused; start(); sync() })
|
|
112
|
-
root.addEventListener('
|
|
113
|
-
|
|
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) }
|
|
119
|
+
root.addEventListener('pointerdown', event => { if (!toggle?.contains(event.target)) hold('press') })
|
|
120
|
+
const lift = () => release('press', pause)
|
|
118
121
|
root.addEventListener('pointerup', lift)
|
|
119
122
|
root.addEventListener('pointercancel', lift)
|
|
123
|
+
root.addEventListener('focusin', event => { if (!toggle?.contains(event.target) && event.target.matches?.(':focus-visible')) hold('focus') })
|
|
124
|
+
root.addEventListener('focusout', event => { if (!root.contains(event.relatedTarget)) release('focus', pause) })
|
|
120
125
|
let wheelTimer = 0
|
|
121
|
-
root.addEventListener('wheel', () => { hold('wheel'); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => release('wheel'),
|
|
126
|
+
root.addEventListener('wheel', () => { hold('wheel'); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => release('wheel'), pause) }, { passive: true })
|
|
122
127
|
onMotionChange(() => { start(); sync() })
|
|
123
128
|
if ('IntersectionObserver' in window) new IntersectionObserver(entries => { onScreen = entries.some(entry => entry.isIntersecting); start(); sync() }, { threshold: 0.25 }).observe(root)
|
|
124
129
|
}
|
|
125
|
-
return { start, stop, hold, release, sync, restart: () => { if (autoplay.mode === 'discrete') start() }, smooth: autoplay.mode === 'smooth', active: running }
|
|
130
|
+
return { start, stop, hold, release, sync, pause, restart: () => { if (autoplay.mode === 'discrete') start() }, smooth: autoplay.mode === 'smooth', active: running }
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
/* ------------------------------------------------------------- scroll bar */
|
|
129
134
|
|
|
130
135
|
function bindScrollbar({ root, scrollbar }, { geometry, scrubTo, begin, end }) {
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
if (!scrollbar) return () => {}
|
|
137
|
+
// Two thumb segments: in a loop, the part of the window past the end is drawn
|
|
138
|
+
// again from the start, so the thumb leaves on the right and re-enters on the left.
|
|
139
|
+
const thumbs = [...scrollbar.querySelectorAll(':scope > span, [data-scrollbar-thumb]')]
|
|
140
|
+
if (!thumbs.length) return () => {}
|
|
141
|
+
while (thumbs.length < 2) { const extra = document.createElement('span'); scrollbar.append(extra); thumbs.push(extra) }
|
|
133
142
|
scrollbar.addEventListener('pointerdown', event => {
|
|
134
143
|
if (event.button > 0) return
|
|
135
144
|
event.preventDefault()
|
|
136
145
|
const rail = scrollbar.getBoundingClientRect()
|
|
137
|
-
const box =
|
|
138
|
-
const
|
|
139
|
-
const
|
|
146
|
+
const box = thumbs[0].getBoundingClientRect()
|
|
147
|
+
const width = Math.min(box.width, rail.width)
|
|
148
|
+
const onThumb = event.clientX >= box.left && event.clientX <= Math.min(box.right, rail.right)
|
|
149
|
+
const grab = onThumb ? event.clientX - box.left : width / 2
|
|
140
150
|
scrollbar.setPointerCapture(event.pointerId)
|
|
141
151
|
root.dataset.scrubbing = ''
|
|
142
152
|
begin()
|
|
143
|
-
const move = x => scrubTo(scrubPosition(x, rail.left, rail.width,
|
|
153
|
+
const move = x => scrubTo(scrubPosition(x, rail.left, rail.width, width, grab, geometry().range))
|
|
144
154
|
move(event.clientX)
|
|
145
155
|
const onMove = moveEvent => move(moveEvent.clientX)
|
|
146
156
|
const onUp = () => { scrollbar.removeEventListener('pointermove', onMove); delete root.dataset.scrubbing; end() }
|
|
@@ -149,10 +159,14 @@ function bindScrollbar({ root, scrollbar }, { geometry, scrubTo, begin, end }) {
|
|
|
149
159
|
scrollbar.addEventListener('pointercancel', onUp, { once: true })
|
|
150
160
|
})
|
|
151
161
|
return () => {
|
|
152
|
-
const { position, range, viewport } = geometry()
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
162
|
+
const { position, range, viewport, period } = geometry()
|
|
163
|
+
// In a loop the thumb spans the part of the period not covered by the scroll range, so it
|
|
164
|
+
// reaches the right end exactly when the last slide does, and wraps only beyond that.
|
|
165
|
+
const { width, left } = period ? loopThumbGeometry(position, period, period - range) : thumbGeometry(position, range, viewport)
|
|
166
|
+
thumbs[0].style.width = thumbs[1].style.width = `${width * 100}%`
|
|
167
|
+
thumbs[0].style.left = `${left * 100}%`
|
|
168
|
+
thumbs[1].style.left = `${(left - 1) * 100}%`
|
|
169
|
+
thumbs[1].hidden = !period
|
|
156
170
|
scrollbar.hidden = range <= 1
|
|
157
171
|
}
|
|
158
172
|
}
|
|
@@ -174,6 +188,8 @@ function initTrack(parts) {
|
|
|
174
188
|
let items = slides
|
|
175
189
|
let step = 0, current = -1, target = null, announce = false, row = false, timer = 0, realigned = false
|
|
176
190
|
let home = 0, intent = false, drifting = false, gliding = 0, scrubbing = false
|
|
191
|
+
// free: the reader left the strip between cards (a scrub, a throw, a centred slide); nothing pulls it back.
|
|
192
|
+
let free = false
|
|
177
193
|
let heights = [], padY = 0, padTop = 0
|
|
178
194
|
let drag = null, suppressClick = false
|
|
179
195
|
|
|
@@ -214,10 +230,18 @@ function initTrack(parts) {
|
|
|
214
230
|
heights = slides.map(slide => slide.offsetHeight)
|
|
215
231
|
}
|
|
216
232
|
|
|
233
|
+
// The scroll position at which the last real slide sits at the viewport's inner end edge
|
|
234
|
+
// (measured, so side insets and gaps are respected), relative to the start of the loop.
|
|
235
|
+
const loopEnd = () => {
|
|
236
|
+
const last = slides.at(-1)
|
|
237
|
+
const box = viewport.getBoundingClientRect(), edge = parseFloat(getComputedStyle(viewport).scrollPaddingRight) || 0
|
|
238
|
+
const end = viewport.scrollLeft + last.getBoundingClientRect().right - (box.right - edge)
|
|
239
|
+
return Math.max(0, wrap(end - first() * step, n * step) || 0)
|
|
240
|
+
}
|
|
217
241
|
const geometry = () => {
|
|
218
242
|
if (sets) {
|
|
219
243
|
const period = n * step
|
|
220
|
-
return { position: wrap(viewport.scrollLeft - first() * step, period), range:
|
|
244
|
+
return { position: wrap(viewport.scrollLeft - first() * step, period), range: loopEnd(), viewport: viewport.clientWidth, period }
|
|
221
245
|
}
|
|
222
246
|
return { position: viewport.scrollLeft, range: Math.max(0, viewport.scrollWidth - viewport.clientWidth), viewport: viewport.clientWidth }
|
|
223
247
|
}
|
|
@@ -225,7 +249,7 @@ function initTrack(parts) {
|
|
|
225
249
|
geometry,
|
|
226
250
|
scrubTo: offset => { viewport.scrollLeft = (sets ? first() * step : 0) + offset },
|
|
227
251
|
begin: () => { stopGlide(); scrubbing = true; intent = true; viewport.style.scrollSnapType = 'none'; player.hold('scrub') },
|
|
228
|
-
end: () => { scrubbing = false;
|
|
252
|
+
end: () => { scrubbing = false; free = true; settleSoon(); player.release('scrub', player.pause) },
|
|
229
253
|
})
|
|
230
254
|
|
|
231
255
|
const paint = () => {
|
|
@@ -247,11 +271,26 @@ function initTrack(parts) {
|
|
|
247
271
|
if (count) count.textContent = `${(c % n) + 1} / ${n}`
|
|
248
272
|
}
|
|
249
273
|
|
|
274
|
+
// Keep a looping strip inside the real slides by moving whole periods over identical clones (invisible).
|
|
275
|
+
const loopBase = () => first() * step
|
|
276
|
+
const normalizeTo = position => (sets && step ? loopBase() + wrap(position - loopBase(), n * step) : position)
|
|
277
|
+
const normalize = () => {
|
|
278
|
+
const position = viewport.scrollLeft, wrapped = normalizeTo(position)
|
|
279
|
+
if (Math.abs(wrapped - position) > 0.5) viewport.scrollTo({ left: wrapped, behavior: 'instant' })
|
|
280
|
+
}
|
|
281
|
+
|
|
250
282
|
// Once still: step back onto the real slide showing the same content (identical, so invisible).
|
|
251
283
|
const settle = () => {
|
|
252
284
|
if (row || !step || drag || gliding || scrubbing) return
|
|
253
285
|
if (drifting) return
|
|
254
|
-
|
|
286
|
+
// Drifting strips and free positions stay exactly where the reader left them.
|
|
287
|
+
if (player.smooth || free) {
|
|
288
|
+
normalize()
|
|
289
|
+
home = sets ? wrap(Math.round(position()) - first(), n) : Math.round(position())
|
|
290
|
+
intent = false; target = null; realigned = false
|
|
291
|
+
paint()
|
|
292
|
+
return
|
|
293
|
+
}
|
|
255
294
|
if (!intent) {
|
|
256
295
|
if (!player.smooth) viewport.style.removeProperty('scroll-snap-type')
|
|
257
296
|
const goal = (first() + home) * step
|
|
@@ -279,6 +318,7 @@ function initTrack(parts) {
|
|
|
279
318
|
if (row || !step) return
|
|
280
319
|
stopGlide()
|
|
281
320
|
intent = true
|
|
321
|
+
free = false
|
|
282
322
|
let base = target ?? Math.round(position())
|
|
283
323
|
if (sets) {
|
|
284
324
|
const equivalent = homeIndex(base, n, sets)
|
|
@@ -339,17 +379,24 @@ function initTrack(parts) {
|
|
|
339
379
|
}
|
|
340
380
|
|
|
341
381
|
// Momentum for multi-card strips; slideshows step one slide per swipe instead.
|
|
342
|
-
|
|
382
|
+
// A drifting strip coasts longer and stops where it stops; others settle on a card.
|
|
383
|
+
function stopGlide() { if (gliding) { cancelAnimationFrame(gliding); gliding = 0; player.release('glide', player.pause) } }
|
|
343
384
|
const glide = velocity => {
|
|
344
|
-
|
|
385
|
+
const coast = player.smooth
|
|
386
|
+
if (!animationAllowed() || Math.abs(velocity) < 0.05) { if (coast) free = true; settle(); return }
|
|
387
|
+
player.hold('glide')
|
|
345
388
|
let last = performance.now(), pos = viewport.scrollLeft
|
|
346
389
|
const frame = now => {
|
|
347
|
-
const next = glideStep(pos, velocity, now - last)
|
|
348
|
-
last = now;
|
|
390
|
+
const next = glideStep(pos, velocity, now - last, coast ? 0.975 : 0.94)
|
|
391
|
+
last = now; velocity = next.velocity
|
|
392
|
+
pos = normalizeTo(next.position)
|
|
349
393
|
viewport.scrollLeft = pos
|
|
350
394
|
const atEdge = !sets && (pos <= 0 || pos >= viewport.scrollWidth - viewport.clientWidth)
|
|
351
|
-
if (Math.abs(velocity) > 0.03 && !atEdge) gliding = requestAnimationFrame(frame)
|
|
352
|
-
|
|
395
|
+
if (Math.abs(velocity) > (coast ? 0.01 : 0.03) && !atEdge) { gliding = requestAnimationFrame(frame); return }
|
|
396
|
+
gliding = 0
|
|
397
|
+
if (coast) free = true
|
|
398
|
+
settle()
|
|
399
|
+
player.release('glide', player.pause)
|
|
353
400
|
}
|
|
354
401
|
gliding = requestAnimationFrame(frame)
|
|
355
402
|
}
|
|
@@ -362,11 +409,14 @@ function initTrack(parts) {
|
|
|
362
409
|
startDrift: () => {
|
|
363
410
|
drifting = true
|
|
364
411
|
viewport.style.scrollSnapType = 'none'
|
|
365
|
-
let pos = viewport.scrollLeft, last = 0
|
|
412
|
+
let pos = viewport.scrollLeft, last = 0, began = 0
|
|
366
413
|
const frame = now => {
|
|
414
|
+
if (!began) began = now
|
|
367
415
|
if (last) {
|
|
368
|
-
|
|
369
|
-
|
|
416
|
+
// Ease back in after a pause instead of jumping to full speed.
|
|
417
|
+
const speed = parts.autoplay.speed * rampFactor(now - began, 1200)
|
|
418
|
+
if (sets) pos = first() * step + driftStep(pos - first() * step, speed, now - last, n * step)
|
|
419
|
+
else pos = driftStep(pos, speed, now - last, 0, viewport.scrollWidth - viewport.clientWidth)
|
|
370
420
|
viewport.scrollLeft = pos
|
|
371
421
|
}
|
|
372
422
|
last = now
|
|
@@ -377,11 +427,32 @@ function initTrack(parts) {
|
|
|
377
427
|
stopDrift: () => {
|
|
378
428
|
if (!driftFrame) return
|
|
379
429
|
cancelAnimationFrame(driftFrame); driftFrame = 0; drifting = false
|
|
430
|
+
free = true
|
|
380
431
|
home = ((Math.round(position()) - first()) % n + n) % n
|
|
381
432
|
},
|
|
382
433
|
})
|
|
383
434
|
if (player.smooth) root.dataset.drift = ''
|
|
384
435
|
|
|
436
|
+
// Something expanded inside a slide (a quick view, a details panel): centre that slide and hold
|
|
437
|
+
// autoplay until everything inside the strip is closed again.
|
|
438
|
+
const expanded = () => slides.some(slide => slide.querySelector('details[open]'))
|
|
439
|
+
root.addEventListener('toggle', event => {
|
|
440
|
+
const details = event.target
|
|
441
|
+
if (!(details instanceof HTMLDetailsElement) || !track.contains(details) || details.closest('[data-clone]')) return
|
|
442
|
+
if (details.open) {
|
|
443
|
+
player.hold('expanded')
|
|
444
|
+
stopGlide()
|
|
445
|
+
const slide = slides.find(item => item.contains(details))
|
|
446
|
+
if (!slide || row || !step) return
|
|
447
|
+
free = true
|
|
448
|
+
viewport.style.scrollSnapType = 'none'
|
|
449
|
+
const box = viewport.getBoundingClientRect(), card = slide.getBoundingClientRect()
|
|
450
|
+
viewport.scrollBy({ left: card.left + card.width / 2 - (box.left + box.width / 2), behavior: animationAllowed() ? 'smooth' : 'instant' })
|
|
451
|
+
} else if (!expanded()) {
|
|
452
|
+
player.release('expanded', player.pause)
|
|
453
|
+
}
|
|
454
|
+
}, true)
|
|
455
|
+
|
|
385
456
|
viewport.addEventListener('scroll', () => {
|
|
386
457
|
requestAnimationFrame(paint)
|
|
387
458
|
if (!('onscrollend' in window)) { clearTimeout(timer); timer = setTimeout(settle, 140) }
|
|
@@ -421,7 +492,10 @@ function initTrack(parts) {
|
|
|
421
492
|
}
|
|
422
493
|
}
|
|
423
494
|
if (drag.state === 'horizontal') {
|
|
424
|
-
|
|
495
|
+
let to = drag.left - dx
|
|
496
|
+
const wrapped = normalizeTo(to)
|
|
497
|
+
drag.left += wrapped - to; to = wrapped
|
|
498
|
+
viewport.scrollLeft = to
|
|
425
499
|
drag.samples.push({ x: event.clientX, t: event.timeStamp })
|
|
426
500
|
if (drag.samples.length > 6) drag.samples.shift()
|
|
427
501
|
}
|
|
@@ -432,12 +506,14 @@ function initTrack(parts) {
|
|
|
432
506
|
drag = null; delete root.dataset.dragging
|
|
433
507
|
if (state !== 'horizontal') return
|
|
434
508
|
suppressClick = true
|
|
435
|
-
|
|
509
|
+
free = false
|
|
436
510
|
if (visibleMode || player.smooth) {
|
|
437
511
|
const a = samples[0], b = samples.at(-1)
|
|
438
512
|
glide(cancelled ? 0 : releaseVelocity(b.x - a.x, b.t - a.t))
|
|
513
|
+
player.release('drag', player.pause)
|
|
439
514
|
return
|
|
440
515
|
}
|
|
516
|
+
player.release('drag', player.pause)
|
|
441
517
|
const start = Math.round(left / step), near = Math.round(position())
|
|
442
518
|
const flick = cancelled ? 0 : swipeStep(event.clientX - x, event.clientY - y, event.timeStamp - t)
|
|
443
519
|
let to = near === start ? start + flick : near
|
|
@@ -10,7 +10,7 @@ const toggleIcons = '<svg class="system-carousel__pause" width="16" height="16"
|
|
|
10
10
|
export function renderCarouselControls({ label = 'Carousel', autoplay = 'off', scrollbar = true, arrows = true } = {}) {
|
|
11
11
|
const name = escape(label)
|
|
12
12
|
const parts = []
|
|
13
|
-
if (scrollbar) parts.push('<div class="system-carousel__scrollbar" data-scrollbar aria-hidden="true"><span></span></div>')
|
|
13
|
+
if (scrollbar) parts.push('<div class="system-carousel__scrollbar" data-scrollbar aria-hidden="true"><span></span><span hidden></span></div>')
|
|
14
14
|
const buttons = []
|
|
15
15
|
if (autoplay === 'discrete' || autoplay === 'smooth') buttons.push(`<button type="button" class="system-carousel__button system-carousel__toggle" data-autoplay-toggle aria-label="Pause automatic scrolling" hidden>${toggleIcons}</button>`)
|
|
16
16
|
if (arrows) {
|
|
@@ -91,6 +91,29 @@ export function thumbGeometry(position, range, viewport) {
|
|
|
91
91
|
return { width, left }
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Thumb for a looping strip: one window over a repeating period. `left` can run
|
|
96
|
+
* past 1; the part beyond the end is drawn again from the start, so the thumb
|
|
97
|
+
* leaves the right end and re-enters on the left as the loop continues.
|
|
98
|
+
*/
|
|
99
|
+
export function loopThumbGeometry(position, period, viewport) {
|
|
100
|
+
if (!(period > 0)) return { width: 1, left: 0 }
|
|
101
|
+
return { width: Math.min(1, viewport / period), left: wrap(position, period) / period }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Speed factor for a drift easing back in after a pause: 0 to 1 over `duration` ms (smoothstep). */
|
|
105
|
+
export function rampFactor(elapsed, duration = 1200) {
|
|
106
|
+
if (!(duration > 0)) return 1
|
|
107
|
+
const t = Math.min(1, Math.max(0, elapsed / duration))
|
|
108
|
+
return t * t * (3 - 2 * t)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Delay before autoplay resumes after a reader's interaction. */
|
|
112
|
+
export function resumeDelay(value) {
|
|
113
|
+
const ms = Number(value)
|
|
114
|
+
return Number.isFinite(ms) ? Math.min(10000, Math.max(0, ms)) : 2000
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
/** Momentum after a mouse drag: velocity in px/ms, decaying per 16ms frame. */
|
|
95
118
|
export const MAX_GLIDE_VELOCITY = 2.5
|
|
96
119
|
/** Release velocity in px/ms, capped so a fast fling travels a few cards, not the whole strip. */
|