@mnstry/atelier 0.2.0-alpha.16 → 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 +5 -0
- package/README.md +1 -1
- package/docs/guides/upgrade.md +7 -0
- package/docs/reference/portable-presentation.md +9 -0
- package/docs/reference/ui-primitives.md +116 -0
- package/fixtures/templates/local-library.v1.json +2 -1
- package/package.json +3 -1
- 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 +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0-alpha.17
|
|
4
|
+
|
|
5
|
+
- Add UI primitives (`@mnstry/atelier/ui/primitives` and `/ui/primitives/browser`), the shared behaviour layer beside the control system. The carousel combines the Urana Heart site's carousel (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) with Botanika's additions (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
|
+
|
|
3
7
|
## 0.2.0-alpha.16
|
|
4
8
|
|
|
5
9
|
- Rename the graph workspace's room from constellation to **map** and its middle layer from coach to **distribution**. The runtime already holds *constellation* as an identity canonical (a grouping of people), and Atelier already defines the distribution tier. New names: `ROOMS` is `map`, `workbench`, `review`; `LAYERS` is `atelier`, `distribution`, `owner`; `planMap`, `createMap`, the shell handle's `map` and `backToMap`, `setBy: 'distribution'`; interface text says "map". The alpha.15 names keep working for this release and are removed in the next: `room: 'constellation'` (listed in the new `DEPRECATED_ROOMS`), `composeRecipes({ coach })` (giving both `coach` and `distribution` refuses), `planConstellation`, `createConstellation`, the handle's `constellation` and `backToConstellation`, and the element's `constellation` class.
|
|
@@ -316,6 +320,7 @@
|
|
|
316
320
|
|
|
317
321
|
## Unreleased
|
|
318
322
|
|
|
323
|
+
- Document how a host locks page scrolling around the presentation's modal confirmation without shifting the layout (pay the hidden scrollbar back as body end padding, count locks), and test that the browser binding itself never changes the host page's overflow, padding or scroll position.
|
|
319
324
|
- Patch the fast-uri dependency and override from 3.1.7 to 3.1.8, the patched
|
|
320
325
|
3.x release for GHSA-hrr3-gc8f-f4qj (3.1.7 is in the affected range; whether
|
|
321
326
|
Atelier's use was exploitable is not established). The direct dependency
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# MNSTRY Atelier
|
|
2
2
|
|
|
3
|
-
`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.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.17
|
|
13
|
+
|
|
14
|
+
This release only adds `@mnstry/atelier/ui/primitives` and
|
|
15
|
+
`@mnstry/atelier/ui/primitives/browser`; nothing existing changes and no stored
|
|
16
|
+
record changes format. The alpha.15 graph workspace names that alpha.16 kept
|
|
17
|
+
for one release are still accepted here; their removal moves to a later release.
|
|
18
|
+
|
|
12
19
|
## Upgrading to 0.2.0-alpha.16
|
|
13
20
|
|
|
14
21
|
This release renames two graph workspace names; nothing else changes and no
|
|
@@ -119,6 +119,15 @@ request port because native has no browser default. A web host with unsaved work
|
|
|
119
119
|
must omit item hrefs until safe navigation is available through its existing
|
|
120
120
|
route/draft authority; this module does not install an unsaved-work route guard.
|
|
121
121
|
Do not infer identical web/native navigation interception from shared labels.
|
|
122
|
+
|
|
123
|
+
The confirmation is a native modal `<dialog>`. The binding never changes the
|
|
124
|
+
host document's overflow, padding or scroll position, so opening it cannot
|
|
125
|
+
shift the page. A host that also locks page scrolling while it is open owns that
|
|
126
|
+
lock and must not let it shift the layout: measure the scrollbar it is about to
|
|
127
|
+
hide (`innerWidth - documentElement.clientWidth`, zero for overlay scrollbars),
|
|
128
|
+
add that width to the body's existing end padding, expose it as a custom
|
|
129
|
+
property for fixed or sticky elements, and restore both when the last lock is
|
|
130
|
+
released. Count locks so one closing overlay cannot release another. `lockScroll` and `unlockScroll` in [UI primitives](ui-primitives.md) implement this lock.
|
|
122
131
|
Media resolves only local paths; hosts must prevent
|
|
123
132
|
those routes from redirecting outside their intended boundary. The model is not
|
|
124
133
|
a network-security boundary for a consumer's asset server.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# UI primitives
|
|
2
|
+
|
|
3
|
+
`@mnstry/atelier/ui/primitives` is the shared behaviour layer for sites and apps
|
|
4
|
+
built with Atelier. It sits beside the control system (`@mnstry/atelier/ui`) and
|
|
5
|
+
follows the same rules: no tenant content, plain HTML contracts that work
|
|
6
|
+
without script, and theming only through `--system-*` custom properties.
|
|
7
|
+
|
|
8
|
+
| Import | Runs in | Provides |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `@mnstry/atelier/ui/primitives` | Node and browsers | Pure state functions, `carouselStyles`, `renderCarouselControls` |
|
|
11
|
+
| `@mnstry/atelier/ui/primitives/browser` | Browsers | `enhanceCarousels`, `enhanceDisclosures`, `lockScroll` and `unlockScroll`, the motion owner |
|
|
12
|
+
|
|
13
|
+
The carousel brings together the Urana Heart site's carousel (seamless loops,
|
|
14
|
+
drag intent, focus safety, page dots and thumbnails, live status, height that
|
|
15
|
+
follows the slide, receding neighbours, still rows) and Botanika's additions (a
|
|
16
|
+
draggable scroll bar, momentum for multi-card strips, autoplay, a pause control).
|
|
17
|
+
|
|
18
|
+
## Carousel
|
|
19
|
+
|
|
20
|
+
### Markup contract
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<div class="system-carousel" data-carousel="track" data-select="visible" data-loop
|
|
24
|
+
data-autoplay="discrete" data-interval="6000"
|
|
25
|
+
role="region" aria-roledescription="carousel" aria-label="Featured collection">
|
|
26
|
+
<div data-carousel-viewport tabindex="0" aria-label="Featured collection, scroll horizontally">
|
|
27
|
+
<div data-carousel-track>
|
|
28
|
+
<article>…</article>
|
|
29
|
+
<article>…</article>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<!-- optional: page dots or thumbnails -->
|
|
33
|
+
<div class="system-carousel__pages">
|
|
34
|
+
<button type="button" class="system-carousel__page" data-page aria-label="Show slide 1">…</button>
|
|
35
|
+
</div>
|
|
36
|
+
<!-- controls and status: renderCarouselControls({ label, autoplay }) -->
|
|
37
|
+
</div>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Without script, the viewport is a native horizontal scroller with snap points,
|
|
41
|
+
so touch, trackpad, wheel and keyboard scrolling all work. Slides are the
|
|
42
|
+
track's children; if any child carries `data-slide`, only those count.
|
|
43
|
+
|
|
44
|
+
### Options (attributes on the root)
|
|
45
|
+
|
|
46
|
+
| Attribute | Values | Effect |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `data-carousel` | `track` (default), `fade` | Scrolling strip, or slides crossfading in one grid cell |
|
|
49
|
+
| `data-select` | `current` (default), `visible` | `current` makes every slide except the current one inert (slideshows); `visible` keeps every card on screen interactive (product strips) |
|
|
50
|
+
| `data-step` | `item`, `page` | What an arrow moves. Defaults to `page` for `visible` and `item` for `current`. A loop barely longer than a page steps by the hidden remainder |
|
|
51
|
+
| `data-loop` | present | Seamless loop over inert clones; arrows never stop |
|
|
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
|
+
| `data-fit` | `current` | The viewport height follows the slide in view, blending as slides pass |
|
|
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
|
+
|
|
56
|
+
### Behaviour
|
|
57
|
+
|
|
58
|
+
- **Mouse drag** on the slides: a drag starts only once horizontal movement
|
|
59
|
+
clearly dominates, so vertical page scrolling is never taken over. Clicks at
|
|
60
|
+
the end of a drag are swallowed; a plain click still follows its link.
|
|
61
|
+
Slideshows step one slide (distance or flick); `visible` strips glide with
|
|
62
|
+
capped momentum and settle on a card.
|
|
63
|
+
- **Scroll bar** (`[data-scrollbar]`): click to jump, or drag the thumb. It
|
|
64
|
+
covers one set of slides in a loop.
|
|
65
|
+
- **Arrows, pages and keys:** Previous, Next, page buttons, and Left, Right,
|
|
66
|
+
Home and End on the carousel. Reader-initiated moves are announced politely in
|
|
67
|
+
`[data-status]`; autoplay moves are not.
|
|
68
|
+
- **Autoplay** never runs under reduced motion. It holds while the pointer is
|
|
69
|
+
over the carousel, while focus is inside it, during a touch, wheel, drag or
|
|
70
|
+
scrub, while the tab is hidden and while the carousel is off screen. The
|
|
71
|
+
rendered toggle pauses and resumes it. `data-autoplay-state` reads `playing`,
|
|
72
|
+
`held`, `paused` or `off`.
|
|
73
|
+
- **Focus:** before a slide becomes inert, focus moves to its page button or
|
|
74
|
+
another control, so it is never lost.
|
|
75
|
+
|
|
76
|
+
The enhancer sets `data-ready`, `data-overflow`, `data-row`, `data-dragging`
|
|
77
|
+
and `data-scrubbing` for styling, and hides the controls when nothing overflows.
|
|
78
|
+
`enhanceCarousel(root)` returns `{ go, goTo, pause, play }`.
|
|
79
|
+
|
|
80
|
+
### Theming
|
|
81
|
+
|
|
82
|
+
Use `carouselStyles` once per page (for example
|
|
83
|
+
`<style set:html={carouselStyles}>`) and set custom properties on the carousel:
|
|
84
|
+
|
|
85
|
+
`--system-carousel-gap`, `-slide` (slide width), `-inset` (full-bleed side
|
|
86
|
+
padding), `-align`, `-ease`, `-focus`, `-button-size`, `-button-border`,
|
|
87
|
+
`-button-radius`, `-button-background`, `-button-hover-background`,
|
|
88
|
+
`-button-hover-border`, `-controls-justify`, `-controls-gap`,
|
|
89
|
+
`-controls-offset`, `-scrollbar-width`, `-scrollbar-thickness`,
|
|
90
|
+
`-scrollbar-track`, `-scrollbar-thumb`, `-thumb-width`, `-thumb-height`,
|
|
91
|
+
`-thumb-current`, `-recede-scale`, `-recede-fade`, `-fade-ms`.
|
|
92
|
+
|
|
93
|
+
## Disclosures
|
|
94
|
+
|
|
95
|
+
`enhanceDisclosures(root)` animates native `<details>`: open and close can be
|
|
96
|
+
reversed mid-way, `data-state` (`open` or `closed`) follows the reader's intent
|
|
97
|
+
at once so icons turn with the click, and reduced motion opens instantly.
|
|
98
|
+
Keyboard and no-script behaviour stay native.
|
|
99
|
+
|
|
100
|
+
## Scroll lock
|
|
101
|
+
|
|
102
|
+
`lockScroll(owner)` and `unlockScroll(owner)` implement the layout-stable host
|
|
103
|
+
lock described in [portable presentation](portable-presentation.md). The lock
|
|
104
|
+
hides page scrolling, pays the hidden scrollbar back as body end padding,
|
|
105
|
+
exposes it as `--scroll-lock-gap` for fixed and sticky elements (for example
|
|
106
|
+
`right: var(--scroll-lock-gap, 0px)` on a fixed panel), and counts owners so one
|
|
107
|
+
closing overlay cannot release another. The gap is measured as the growth of the
|
|
108
|
+
body's laid-out width, which stays correct with `scrollbar-gutter: stable` and
|
|
109
|
+
with overlay scrollbars.
|
|
110
|
+
|
|
111
|
+
## Motion
|
|
112
|
+
|
|
113
|
+
`animationAllowed()`, `onMotionChange(listener)` and `animate(element, frames,
|
|
114
|
+
options)` give one owner for motion: the reduced-motion preference and hidden
|
|
115
|
+
tabs stop every effect started through them. `startMotionWatch()` begins
|
|
116
|
+
watching; the carousel and disclosures call it as needed.
|
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.17",
|
|
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": [
|
|
@@ -64,6 +64,8 @@
|
|
|
64
64
|
"./readiness-protocols": "./src/readiness-protocols/bundled-pack.mjs",
|
|
65
65
|
"./server": "./src/server/local-sidecar.mjs",
|
|
66
66
|
"./ui": "./src/ui/control-system.mjs",
|
|
67
|
+
"./ui/primitives": "./src/ui/primitives/index.mjs",
|
|
68
|
+
"./ui/primitives/browser": "./src/ui/primitives/browser.mjs",
|
|
67
69
|
"./harness": "./src/harness/context.mjs",
|
|
68
70
|
"./collaboration": "./src/collaboration/index.mjs",
|
|
69
71
|
"./support": "./src/support/support-bundle.mjs",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Atelier UI primitives for the browser. */
|
|
2
|
+
export { enhanceCarousels, enhanceCarousel } from './carousel.mjs'
|
|
3
|
+
export { enhanceDisclosures } from './disclosure.mjs'
|
|
4
|
+
export { lockScroll, unlockScroll, scrollLocked } from './scroll-lock.mjs'
|
|
5
|
+
export { animate, animationAllowed, onMotionChange, startMotionWatch } from './motion.mjs'
|
|
6
|
+
export * from './state.mjs'
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** HTML for the carousel controls and live status (tenant-neutral, escaped). */
|
|
2
|
+
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]))
|
|
3
|
+
const chevron = direction => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${direction === 'previous' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'}"/></svg>`
|
|
4
|
+
const toggleIcons = '<svg class="system-carousel__pause" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg><svg class="system-carousel__play" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 5l12 7-12 7z"/></svg>'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Controls for a carousel: optional scroll bar, optional autoplay toggle,
|
|
8
|
+
* previous and next buttons, and a polite status for reader-initiated moves.
|
|
9
|
+
*/
|
|
10
|
+
export function renderCarouselControls({ label = 'Carousel', autoplay = 'off', scrollbar = true, arrows = true } = {}) {
|
|
11
|
+
const name = escape(label)
|
|
12
|
+
const parts = []
|
|
13
|
+
if (scrollbar) parts.push('<div class="system-carousel__scrollbar" data-scrollbar aria-hidden="true"><span></span></div>')
|
|
14
|
+
const buttons = []
|
|
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
|
+
if (arrows) {
|
|
17
|
+
buttons.push(`<button type="button" class="system-carousel__button" data-prev aria-label="Previous: ${name}">${chevron('previous')}</button>`)
|
|
18
|
+
buttons.push(`<button type="button" class="system-carousel__button" data-next aria-label="Next: ${name}">${chevron('next')}</button>`)
|
|
19
|
+
}
|
|
20
|
+
if (buttons.length) parts.push(`<div class="system-carousel__buttons">${buttons.join('')}</div>`)
|
|
21
|
+
return `<div class="system-carousel__controls" data-controls hidden>${parts.join('')}</div><p class="system-carousel__status" data-status aria-live="polite"></p>`
|
|
22
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout-stable page scroll lock (browser), the contract documented in
|
|
3
|
+
* portable-presentation.md: measure the scrollbar a lock hides, pay it back as
|
|
4
|
+
* body end padding, expose it as --scroll-lock-gap for fixed and sticky
|
|
5
|
+
* elements, and restore both when the last lock is released. Locks are
|
|
6
|
+
* counted, so one closing overlay cannot release another's.
|
|
7
|
+
*/
|
|
8
|
+
import { scrollLockGap } from './state.mjs'
|
|
9
|
+
|
|
10
|
+
const holders = new Set()
|
|
11
|
+
let saved = null
|
|
12
|
+
|
|
13
|
+
/** Lock page scrolling for `owner` (any value). Returns a release function. */
|
|
14
|
+
export function lockScroll(owner = Symbol('scroll-lock')) {
|
|
15
|
+
holders.add(owner)
|
|
16
|
+
if (!saved) {
|
|
17
|
+
const html = document.documentElement
|
|
18
|
+
const body = document.body
|
|
19
|
+
// Measure the body's laid-out width, not the root's clientWidth: with a stable
|
|
20
|
+
// scrollbar gutter some engines report the root inconsistently during the change.
|
|
21
|
+
const before = body.getBoundingClientRect().width
|
|
22
|
+
saved = { padding: body.style.paddingInlineEnd, overflow: html.style.overflow, overscroll: html.style.overscrollBehavior }
|
|
23
|
+
html.style.overflow = 'hidden'
|
|
24
|
+
html.style.overscrollBehavior = 'none'
|
|
25
|
+
const gap = scrollLockGap(before, body.getBoundingClientRect().width)
|
|
26
|
+
const existing = parseFloat(getComputedStyle(body).paddingInlineEnd) || 0
|
|
27
|
+
if (gap) body.style.paddingInlineEnd = `${existing + gap}px`
|
|
28
|
+
html.style.setProperty('--scroll-lock-gap', `${gap}px`)
|
|
29
|
+
html.dataset.scrollLocked = ''
|
|
30
|
+
}
|
|
31
|
+
return () => unlockScroll(owner)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Release `owner`'s lock; the page unlocks when no holder remains. */
|
|
35
|
+
export function unlockScroll(owner) {
|
|
36
|
+
holders.delete(owner)
|
|
37
|
+
if (holders.size || !saved) return
|
|
38
|
+
const html = document.documentElement
|
|
39
|
+
document.body.style.paddingInlineEnd = saved.padding
|
|
40
|
+
html.style.overflow = saved.overflow
|
|
41
|
+
html.style.overscrollBehavior = saved.overscroll
|
|
42
|
+
html.style.removeProperty('--scroll-lock-gap')
|
|
43
|
+
delete html.dataset.scrollLocked
|
|
44
|
+
saved = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const scrollLocked = () => holders.size > 0
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state for Atelier UI primitives. No DOM access, so every rule
|
|
3
|
+
* here is testable in Node and shared by the browser enhancers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Visible window of a carousel at `index`, with arrow availability. */
|
|
7
|
+
export function slideWindow(index, length, visible, loop = false) {
|
|
8
|
+
const count = Math.max(1, Math.min(visible, length))
|
|
9
|
+
const max = Math.max(0, length - count)
|
|
10
|
+
const start = loop ? ((index % length) + length) % length : Math.min(max, Math.max(0, index))
|
|
11
|
+
return { start, end: Math.min(length, start + count), previousDisabled: !loop && start === 0, nextDisabled: !loop && start === max }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A horizontal intent must clearly dominate before the page scroll is taken over. */
|
|
15
|
+
export function dragIntent(dx, dy) {
|
|
16
|
+
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return 'pending'
|
|
17
|
+
return Math.abs(dx) > Math.abs(dy) * 1.2 ? 'horizontal' : 'vertical'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Release decides the step: enough distance, or a quick flick. */
|
|
21
|
+
export function swipeStep(dx, dy, elapsed = Infinity) {
|
|
22
|
+
if (Math.abs(dx) <= Math.abs(dy) * 1.2) return 0
|
|
23
|
+
const flick = elapsed < 300 && Math.abs(dx) >= 24
|
|
24
|
+
return Math.abs(dx) >= 50 || flick ? (dx < 0 ? 1 : -1) : 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Motion runs only when the reader allows it, nothing paused it and the tab is visible. */
|
|
28
|
+
export function motionAllowed(reduced, paused, hidden = false) {
|
|
29
|
+
return !reduced && !paused && !hidden
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Clone sets needed on each side of a looping track to cover `perSide` slides. */
|
|
33
|
+
export function loopSets(length, perSide) {
|
|
34
|
+
return Math.max(1, Math.ceil(perSide / Math.max(1, length)))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The index of the real slide showing the same content as item `index` of a looping track. */
|
|
38
|
+
export function homeIndex(index, length, sets) {
|
|
39
|
+
return sets * length + ((index % length) + length) % length
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Shortest signed distance from item `from` to any copy of real slide `to` in a loop. */
|
|
43
|
+
export function nearestCopy(from, to, length) {
|
|
44
|
+
let delta = ((to - from) % length + length) % length
|
|
45
|
+
if (delta > length / 2) delta -= length
|
|
46
|
+
return delta
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** How far a slide has receded: 0 when it is the current slide, 1 from a full step away. */
|
|
50
|
+
export function recede(position, index) {
|
|
51
|
+
return Math.min(1, Math.abs(position - index))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Few cards: when every card fits side by side (no narrower than `min`), show a still row. */
|
|
55
|
+
export function rowFit(length, natural, gap, room, min) {
|
|
56
|
+
const width = Math.min(natural, (room - gap * (length - 1)) / length)
|
|
57
|
+
return width >= min ? Math.floor(width) : 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whole items that fit in the viewport: the size of one page for page-stepping carousels. */
|
|
61
|
+
export function pageSize(viewport, step) {
|
|
62
|
+
return step > 0 ? Math.max(1, Math.floor((viewport + 1) / step)) : 1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Arrow step for page-stepping carousels. A loop only slightly longer than a page steps by the hidden remainder, so Next never reads as going backwards. */
|
|
66
|
+
export function arrowStep(page, length, loop) {
|
|
67
|
+
if (!loop) return Math.max(1, page)
|
|
68
|
+
return Math.max(1, Math.min(page, length - page))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Wrap a position into [0, period). */
|
|
72
|
+
export function wrap(position, period) {
|
|
73
|
+
if (!(period > 0)) return position
|
|
74
|
+
return ((position % period) + period) % period
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Scroll position for a pointer on a scroll bar; `grab` is where the thumb was taken hold of. */
|
|
78
|
+
export function scrubPosition(pointerX, railLeft, railWidth, thumbWidth, grab, range) {
|
|
79
|
+
const travel = railWidth - thumbWidth
|
|
80
|
+
if (travel <= 0 || range <= 0) return 0
|
|
81
|
+
const ratio = Math.min(1, Math.max(0, (pointerX - railLeft - grab) / travel))
|
|
82
|
+
return ratio * range
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Scroll bar thumb geometry, as fractions of the rail, for a viewport over a content range. */
|
|
86
|
+
export function thumbGeometry(position, range, viewport) {
|
|
87
|
+
const total = range + viewport
|
|
88
|
+
if (total <= 0) return { width: 1, left: 0 }
|
|
89
|
+
const width = Math.min(1, viewport / total)
|
|
90
|
+
const left = Math.min(Math.max(0, position), range) / total
|
|
91
|
+
return { width, left }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Momentum after a mouse drag: velocity in px/ms, decaying per 16ms frame. */
|
|
95
|
+
export const MAX_GLIDE_VELOCITY = 2.5
|
|
96
|
+
/** Release velocity in px/ms, capped so a fast fling travels a few cards, not the whole strip. */
|
|
97
|
+
export function releaseVelocity(dx, dt) {
|
|
98
|
+
const velocity = -dx / Math.max(1, dt)
|
|
99
|
+
return Math.max(-MAX_GLIDE_VELOCITY, Math.min(MAX_GLIDE_VELOCITY, velocity))
|
|
100
|
+
}
|
|
101
|
+
export function glideStep(position, velocity, elapsed, friction = 0.94) {
|
|
102
|
+
const dt = Math.min(32, Math.max(0, elapsed))
|
|
103
|
+
return { position: position + velocity * dt, velocity: velocity * Math.pow(friction, dt / 16) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Autoplay modes: a discrete step every interval, or a smooth drift in px/s. */
|
|
107
|
+
export const AUTOPLAY_MODES = Object.freeze(['off', 'discrete', 'smooth'])
|
|
108
|
+
export function autoplayConfig({ mode = 'off', interval = 6000, speed = 28 } = {}) {
|
|
109
|
+
return {
|
|
110
|
+
mode: AUTOPLAY_MODES.includes(mode) ? mode : 'off',
|
|
111
|
+
interval: Math.max(2500, Number(interval) || 6000),
|
|
112
|
+
speed: Math.min(400, Math.max(4, Number(speed) || 28)),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Advance a smooth drift; loops within `period` when one is given. */
|
|
117
|
+
export function driftStep(position, speed, elapsed, period = 0, max = Infinity) {
|
|
118
|
+
const next = position + speed * Math.min(64, Math.max(0, elapsed)) / 1000
|
|
119
|
+
if (period > 0) return wrap(next, period)
|
|
120
|
+
return next >= max ? 0 : next
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Scroll lock (portable-presentation contract): the width a lock must pay back
|
|
125
|
+
* is the scrollbar it hides, measured as the growth of the body's laid-out width.
|
|
126
|
+
* It is zero for overlay scrollbars and for gutters the browser keeps.
|
|
127
|
+
*/
|
|
128
|
+
export function scrollLockGap(widthBefore, widthAfter) {
|
|
129
|
+
return Math.max(0, Math.round((widthAfter - widthBefore) * 100) / 100)
|
|
130
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-neutral carousel geometry and interaction styles. Tenants theme only
|
|
3
|
+
* through the --system-carousel-* custom properties; markup contract in
|
|
4
|
+
* docs/reference/ui-primitives.md.
|
|
5
|
+
*/
|
|
6
|
+
export const carouselStyles = `
|
|
7
|
+
.system-carousel { position: relative; }
|
|
8
|
+
.system-carousel [data-carousel-viewport] {
|
|
9
|
+
overflow-x: auto;
|
|
10
|
+
overscroll-behavior-inline: contain;
|
|
11
|
+
scroll-snap-type: x mandatory;
|
|
12
|
+
scroll-padding-inline: var(--system-carousel-inset, 0px);
|
|
13
|
+
scrollbar-width: none;
|
|
14
|
+
}
|
|
15
|
+
.system-carousel [data-carousel-viewport]::-webkit-scrollbar { display: none; }
|
|
16
|
+
.system-carousel [data-carousel-track] {
|
|
17
|
+
display: flex;
|
|
18
|
+
gap: var(--system-carousel-gap, 1.25rem);
|
|
19
|
+
padding-inline: var(--system-carousel-inset, 0px);
|
|
20
|
+
}
|
|
21
|
+
.system-carousel [data-carousel-track] > * {
|
|
22
|
+
flex: 0 0 var(--system-carousel-slide, 100%);
|
|
23
|
+
min-width: 0;
|
|
24
|
+
scroll-snap-align: var(--system-carousel-align, start);
|
|
25
|
+
}
|
|
26
|
+
.system-carousel[data-peek] [data-carousel-track] > * { scroll-snap-align: center; }
|
|
27
|
+
.system-carousel[data-drift] [data-carousel-viewport] { scroll-snap-type: none; }
|
|
28
|
+
.system-carousel[data-fit="current"] [data-carousel-viewport] { transition: height 0.3s var(--system-carousel-ease, ease); }
|
|
29
|
+
.system-carousel img { -webkit-user-drag: none; }
|
|
30
|
+
@media (hover: hover) and (pointer: fine) {
|
|
31
|
+
.system-carousel[data-ready][data-overflow="true"] [data-carousel-viewport] { cursor: grab; }
|
|
32
|
+
}
|
|
33
|
+
.system-carousel[data-dragging] [data-carousel-viewport],
|
|
34
|
+
.system-carousel[data-dragging] [data-carousel-viewport] * { cursor: grabbing; user-select: none; -webkit-user-select: none; }
|
|
35
|
+
|
|
36
|
+
.system-carousel[data-peek] [data-carousel-track] > * {
|
|
37
|
+
transform: scale(calc(1 - var(--d, 0) * var(--system-carousel-recede-scale, 0.08)));
|
|
38
|
+
opacity: calc(1 - var(--d, 0) * var(--system-carousel-recede-fade, 0.35));
|
|
39
|
+
}
|
|
40
|
+
.system-carousel[data-row] [data-carousel-track] { justify-content: center; }
|
|
41
|
+
.system-carousel[data-row] [data-carousel-track] > * { flex-basis: var(--slide-fit); transform: none; opacity: 1; }
|
|
42
|
+
|
|
43
|
+
.system-carousel[data-carousel="fade"] [data-carousel-track] { display: grid; }
|
|
44
|
+
.system-carousel[data-carousel="fade"] [data-carousel-track] > * {
|
|
45
|
+
grid-area: 1 / 1;
|
|
46
|
+
transition: opacity var(--system-carousel-fade-ms, 700ms) var(--system-carousel-ease, ease), visibility 0s linear var(--system-carousel-fade-ms, 700ms);
|
|
47
|
+
}
|
|
48
|
+
.system-carousel[data-carousel="fade"][data-ready] [data-carousel-track] > *:not(.is-current) { opacity: 0; visibility: hidden; }
|
|
49
|
+
.system-carousel[data-carousel="fade"][data-ready] [data-carousel-track] > *.is-current { opacity: 1; visibility: visible; transition-delay: 0s; }
|
|
50
|
+
|
|
51
|
+
.system-carousel__controls {
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: var(--system-carousel-controls-justify, center);
|
|
55
|
+
gap: var(--system-carousel-controls-gap, 1.25rem);
|
|
56
|
+
margin-top: var(--system-carousel-controls-offset, 1.5rem);
|
|
57
|
+
}
|
|
58
|
+
.system-carousel__controls[hidden], .system-carousel__controls [hidden] { display: none; }
|
|
59
|
+
.system-carousel__buttons { display: flex; gap: 0.5rem; }
|
|
60
|
+
.system-carousel__button {
|
|
61
|
+
display: grid;
|
|
62
|
+
place-items: center;
|
|
63
|
+
width: var(--system-carousel-button-size, 44px);
|
|
64
|
+
height: var(--system-carousel-button-size, 44px);
|
|
65
|
+
padding: 0;
|
|
66
|
+
border: 1px solid var(--system-carousel-button-border, currentColor);
|
|
67
|
+
border-radius: var(--system-carousel-button-radius, 0);
|
|
68
|
+
background: var(--system-carousel-button-background, transparent);
|
|
69
|
+
color: inherit;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
transition: background-color 0.24s var(--system-carousel-ease, ease), border-color 0.24s var(--system-carousel-ease, ease), opacity 0.24s var(--system-carousel-ease, ease);
|
|
72
|
+
}
|
|
73
|
+
.system-carousel__button:disabled { opacity: 0.32; cursor: default; }
|
|
74
|
+
.system-carousel__button:active:not(:disabled) { transform: translateY(1px); }
|
|
75
|
+
.system-carousel__button:focus-visible, .system-carousel__page:focus-visible {
|
|
76
|
+
outline: 2px solid var(--system-carousel-focus, currentColor);
|
|
77
|
+
outline-offset: 3px;
|
|
78
|
+
}
|
|
79
|
+
@media (hover: hover) and (pointer: fine) {
|
|
80
|
+
.system-carousel__button:not(:disabled):hover {
|
|
81
|
+
background: var(--system-carousel-button-hover-background, rgba(0, 0, 0, 0.06));
|
|
82
|
+
border-color: var(--system-carousel-button-hover-border, currentColor);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
.system-carousel__toggle .system-carousel__play,
|
|
86
|
+
.system-carousel__toggle[data-state="paused"] .system-carousel__pause { display: none; }
|
|
87
|
+
.system-carousel__toggle[data-state="paused"] .system-carousel__play { display: block; }
|
|
88
|
+
|
|
89
|
+
.system-carousel__scrollbar {
|
|
90
|
+
position: relative;
|
|
91
|
+
width: var(--system-carousel-scrollbar-width, min(260px, 38vw));
|
|
92
|
+
height: 28px;
|
|
93
|
+
overflow: hidden;
|
|
94
|
+
cursor: grab;
|
|
95
|
+
touch-action: none;
|
|
96
|
+
}
|
|
97
|
+
.system-carousel__scrollbar::before, .system-carousel__scrollbar > span {
|
|
98
|
+
content: '';
|
|
99
|
+
position: absolute;
|
|
100
|
+
top: 50%;
|
|
101
|
+
height: var(--system-carousel-scrollbar-thickness, 2px);
|
|
102
|
+
transform: translateY(-50%);
|
|
103
|
+
}
|
|
104
|
+
.system-carousel__scrollbar::before { left: 0; right: 0; background: var(--system-carousel-scrollbar-track, rgba(0, 0, 0, 0.14)); }
|
|
105
|
+
.system-carousel__scrollbar > span { left: 0; width: 25%; background: var(--system-carousel-scrollbar-thumb, currentColor); transition: height 0.16s var(--system-carousel-ease, ease); }
|
|
106
|
+
.system-carousel__scrollbar:hover > span, .system-carousel[data-scrubbing] .system-carousel__scrollbar > span { height: calc(var(--system-carousel-scrollbar-thickness, 2px) * 2); }
|
|
107
|
+
.system-carousel[data-scrubbing] .system-carousel__scrollbar { cursor: grabbing; }
|
|
108
|
+
|
|
109
|
+
.system-carousel__pages { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.25rem; }
|
|
110
|
+
.system-carousel__page { width: 24px; height: 24px; padding: 0; border: 0; background: none; cursor: pointer; display: grid; place-items: center; color: inherit; }
|
|
111
|
+
.system-carousel__page::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: 0.3; transition: opacity 0.24s var(--system-carousel-ease, ease); }
|
|
112
|
+
.system-carousel__page[aria-current="true"]::before { opacity: 1; }
|
|
113
|
+
.system-carousel__page[data-thumb] { width: var(--system-carousel-thumb-width, 72px); height: var(--system-carousel-thumb-height, 90px); opacity: 0.55; background: var(--thumb, none) center / cover; outline-offset: 2px; }
|
|
114
|
+
.system-carousel__page[data-thumb]::before { content: none; }
|
|
115
|
+
.system-carousel__page[data-thumb] img { width: 100%; height: 100%; object-fit: cover; }
|
|
116
|
+
.system-carousel__page[data-thumb][aria-current="true"] { opacity: 1; box-shadow: 0 0 0 1px var(--system-carousel-thumb-current, currentColor); }
|
|
117
|
+
.system-carousel__status {
|
|
118
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
119
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@media (prefers-reduced-motion: reduce) {
|
|
123
|
+
.system-carousel [data-carousel-track] > *, .system-carousel__button, .system-carousel__scrollbar > span,
|
|
124
|
+
.system-carousel[data-fit="current"] [data-carousel-viewport] { transition: none; }
|
|
125
|
+
.system-carousel[data-peek] [data-carousel-track] > * { transform: none; }
|
|
126
|
+
}
|
|
127
|
+
`
|