@anyknown/axis 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,247 @@
1
+ // One coordinator per window decides which instance owns a mouse drag. Nested instances therefore never
2
+ // fight: a press is shared by every instance under the pointer until the movement passes the threshold, and
3
+ // then exactly one of them takes the whole drag.
4
+ import { dominantAxis, VELOCITY_WINDOW, velocityFromSamples } from "./math.js";
5
+ const DRAG_THRESHOLD = 5;
6
+ const CLICK_SUPPRESS_MS = 100;
7
+ const EDITABLE = "input, textarea, select, [contenteditable]:not([contenteditable='false'])";
8
+ const NO_DRAG = `${EDITABLE}, [data-axis-no-drag]`;
9
+ const SCROLL_KEYS = new Set([
10
+ "ArrowUp",
11
+ "ArrowDown",
12
+ "ArrowLeft",
13
+ "ArrowRight",
14
+ "PageUp",
15
+ "PageDown",
16
+ "Home",
17
+ "End",
18
+ " ",
19
+ ]);
20
+ /** True for a key press that scrolls: arrows, Page Up/Down, Home, End and Space, outside editable elements. */
21
+ export function isScrollKey(event) {
22
+ if (!SCROLL_KEYS.has(event.key))
23
+ return false;
24
+ const target = event.target;
25
+ return !target?.closest?.(EDITABLE);
26
+ }
27
+ const coordinators = new WeakMap();
28
+ /** Registers `client` with the coordinator of its window. Returns the function that unregisters it. */
29
+ export function register(client) {
30
+ const win = client.el.ownerDocument.defaultView ?? window;
31
+ let coordinator = coordinators.get(win);
32
+ if (!coordinator) {
33
+ coordinator = createCoordinator(win);
34
+ coordinators.set(win, coordinator);
35
+ }
36
+ return coordinator.add(client);
37
+ }
38
+ /**
39
+ * Picks the owner for a drag that moved by `delta`. `chain` runs from the innermost instance outwards.
40
+ * First choice: the innermost instance that drags along the dominant axis and can still scroll that way.
41
+ * Otherwise the innermost one that drags along that axis at all (it is at its edge, but still owns the
42
+ * gesture so the click is not fired). Otherwise nobody, and the browser handles the movement natively.
43
+ */
44
+ export function pickOwner(chain, delta) {
45
+ const axis = dominantAxis(delta);
46
+ const candidates = chain.filter((client) => client.accepts(axis));
47
+ return candidates.find((client) => client.canScroll(delta)) ?? candidates[0] ?? null;
48
+ }
49
+ // True when the press starts in content that keeps its own mouse behaviour (form fields, opt-outs).
50
+ const blockedByContent = (path, outer) => {
51
+ for (const node of path) {
52
+ if (node === outer)
53
+ return false;
54
+ if (node.nodeType === 1 && node.matches(NO_DRAG))
55
+ return true;
56
+ }
57
+ return false;
58
+ };
59
+ function createCoordinator(win) {
60
+ const clients = new Map();
61
+ let session = null;
62
+ let suppressClick = false;
63
+ let suppressTimer = 0;
64
+ const capture = { capture: true };
65
+ const passiveCapture = { capture: true, passive: true };
66
+ // An instance destroyed by a listener while the coordinator works on it is no longer registered.
67
+ const alive = (client) => clients.get(client.el) === client;
68
+ const detach = () => {
69
+ win.removeEventListener("pointermove", onPointerMove);
70
+ win.removeEventListener("pointerup", onPointerUp);
71
+ win.removeEventListener("pointercancel", onPointerCancel);
72
+ win.removeEventListener("wheel", onUserScroll, passiveCapture);
73
+ win.removeEventListener("keydown", onUserScroll, capture);
74
+ win.removeEventListener("contextmenu", cancel, capture);
75
+ win.removeEventListener("blur", cancel);
76
+ win.document.removeEventListener("visibilitychange", onVisibilityChange);
77
+ session = null;
78
+ };
79
+ // Ends the session: the owner ends its drag, every other instance lets go of the press.
80
+ const close = (ended, velocity) => {
81
+ detach();
82
+ if (ended.owner && alive(ended.owner))
83
+ ended.owner.end(velocity);
84
+ for (const client of ended.chain)
85
+ if (client !== ended.owner && alive(client))
86
+ client.pass();
87
+ };
88
+ // The press ended without a pointerup the window saw: a context menu, the window losing focus or the
89
+ // tab being hidden, or a release over an iframe (the next move then has no button down).
90
+ const cancel = () => {
91
+ if (session)
92
+ close(session, null);
93
+ };
94
+ const onVisibilityChange = () => {
95
+ if (win.document.visibilityState === "hidden")
96
+ cancel();
97
+ };
98
+ // Wheel or keyboard scrolling anywhere, also outside the instances under the pointer, lets go of the holds.
99
+ const onUserScroll = (event) => {
100
+ if (event.type === "keydown" && !isScrollKey(event))
101
+ return;
102
+ if (session)
103
+ for (const client of session.chain)
104
+ client.interrupt();
105
+ };
106
+ const onPointerDown = (event) => {
107
+ // The click that follows a drag fires right after its pointerup, so it can no longer come now. Safari
108
+ // fires none when the press and the release hit different elements; it must not eat this press's click.
109
+ suppressClick = false;
110
+ win.clearTimeout(suppressTimer);
111
+ if (session)
112
+ return;
113
+ const path = event.composedPath();
114
+ const chain = [];
115
+ for (const node of path) {
116
+ const client = clients.get(node);
117
+ if (client)
118
+ chain.push(client);
119
+ }
120
+ if (chain.length === 0)
121
+ return;
122
+ const draggable = event.pointerType === "mouse" &&
123
+ event.button === 0 &&
124
+ !blockedByContent(path, chain.at(-1).el) &&
125
+ !chain.some((client) => client.onScrollbar(event));
126
+ if (!draggable) {
127
+ for (const client of chain)
128
+ client.interrupt();
129
+ return;
130
+ }
131
+ for (const client of chain)
132
+ client.press();
133
+ const pressed = chain.filter(alive);
134
+ if (pressed.length === 0)
135
+ return;
136
+ session = {
137
+ pointerId: event.pointerId,
138
+ start: { x: event.clientX, y: event.clientY },
139
+ chain: pressed,
140
+ owner: null,
141
+ samples: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }],
142
+ };
143
+ win.addEventListener("pointermove", onPointerMove);
144
+ win.addEventListener("pointerup", onPointerUp);
145
+ win.addEventListener("pointercancel", onPointerCancel);
146
+ win.addEventListener("wheel", onUserScroll, passiveCapture);
147
+ win.addEventListener("keydown", onUserScroll, capture);
148
+ win.addEventListener("contextmenu", cancel, capture);
149
+ win.addEventListener("blur", cancel);
150
+ win.document.addEventListener("visibilitychange", onVisibilityChange);
151
+ };
152
+ const onPointerMove = (event) => {
153
+ const current = session;
154
+ if (!current || event.pointerId !== current.pointerId)
155
+ return;
156
+ if (!(event.buttons & 1)) {
157
+ cancel();
158
+ return;
159
+ }
160
+ const { samples } = current;
161
+ samples.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
162
+ while (samples.length > 2 && event.timeStamp - samples[0].t > VELOCITY_WINDOW * 2)
163
+ samples.shift();
164
+ const delta = { x: event.clientX - current.start.x, y: event.clientY - current.start.y };
165
+ if (!current.owner) {
166
+ if (Math.hypot(delta.x, delta.y) <= DRAG_THRESHOLD)
167
+ return;
168
+ const owner = pickOwner(current.chain, delta);
169
+ if (!owner) {
170
+ // Nobody drags this way: the browser handles the movement natively.
171
+ close(current, null);
172
+ return;
173
+ }
174
+ current.owner = owner;
175
+ for (const client of current.chain)
176
+ if (client !== owner)
177
+ client.yield();
178
+ owner.start(current.pointerId);
179
+ }
180
+ // A listener may have destroyed the owner (which ends the session) since the last move.
181
+ if (session !== current || !current.owner)
182
+ return;
183
+ current.owner.move(delta);
184
+ };
185
+ const onPointerUp = (event) => {
186
+ const current = session;
187
+ if (!current || event.pointerId !== current.pointerId)
188
+ return;
189
+ current.samples.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });
190
+ if (current.owner) {
191
+ suppressClick = true;
192
+ win.clearTimeout(suppressTimer);
193
+ suppressTimer = win.setTimeout(() => (suppressClick = false), CLICK_SUPPRESS_MS);
194
+ }
195
+ close(current, current.owner ? velocityFromSamples(current.samples) : null);
196
+ };
197
+ const onPointerCancel = (event) => {
198
+ if (session && event.pointerId === session.pointerId)
199
+ cancel();
200
+ };
201
+ // A drag that ends over a link or button must not activate it: swallow the one click that follows.
202
+ const onClick = (event) => {
203
+ if (!suppressClick)
204
+ return;
205
+ suppressClick = false;
206
+ win.clearTimeout(suppressTimer);
207
+ event.preventDefault();
208
+ event.stopPropagation();
209
+ };
210
+ // Native drag and drop of images and links would steal the pointer before the threshold is reached.
211
+ const onDragStart = (event) => {
212
+ if (session)
213
+ event.preventDefault();
214
+ };
215
+ return {
216
+ add(client) {
217
+ if (clients.size === 0) {
218
+ win.addEventListener("pointerdown", onPointerDown, capture);
219
+ win.addEventListener("click", onClick, capture);
220
+ win.addEventListener("dragstart", onDragStart, capture);
221
+ }
222
+ clients.set(client.el, client);
223
+ return () => {
224
+ if (clients.get(client.el) !== client)
225
+ return;
226
+ clients.delete(client.el);
227
+ if (session) {
228
+ const current = session;
229
+ current.chain = current.chain.filter((other) => other !== client);
230
+ if (current.owner === client || current.chain.length === 0) {
231
+ // The destroyed instance has cleaned up after itself; the others let go of the press.
232
+ current.owner = null;
233
+ close(current, null);
234
+ }
235
+ }
236
+ if (clients.size === 0) {
237
+ win.removeEventListener("pointerdown", onPointerDown, capture);
238
+ win.removeEventListener("click", onClick, capture);
239
+ win.removeEventListener("dragstart", onDragStart, capture);
240
+ win.clearTimeout(suppressTimer);
241
+ suppressClick = false;
242
+ coordinators.delete(win);
243
+ }
244
+ };
245
+ },
246
+ };
247
+ }
@@ -0,0 +1,2 @@
1
+ export { createAxis } from "./axis.js";
2
+ export type { AxisInstance, AxisMode, AxisName, AxisOptions, AxisPosition, AxisScrollState, AxisScrollToOptions, AxisState, } from "./axis.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createAxis } from "./axis.js";
package/dist/math.d.ts ADDED
@@ -0,0 +1,90 @@
1
+ /** A 2D vector, e.g. a position, a pointer delta or a velocity. */
2
+ export interface Vector {
3
+ x: number;
4
+ y: number;
5
+ }
6
+ /** A pointer position sample: `t` in ms, `x`/`y` in px. */
7
+ export interface Sample extends Vector {
8
+ t: number;
9
+ }
10
+ /** Only the samples from the last VELOCITY_WINDOW ms before release count towards the fling velocity. */
11
+ export declare const VELOCITY_WINDOW = 100;
12
+ /** Fling speed cap per axis in px/ms, so a jittery sample pair cannot launch the content to the far end. */
13
+ export declare const MAX_VELOCITY = 5;
14
+ /** Per-frame velocity multiplier for momentum. */
15
+ export declare const DECAY = 0.95;
16
+ /** Reference frame duration that DECAY is defined against. */
17
+ export declare const FRAME_MS: number;
18
+ /** Momentum stops below this speed in px/ms. */
19
+ export declare const MIN_VELOCITY = 0.02;
20
+ export declare const clamp: (value: number, min: number, max: number) => number;
21
+ /**
22
+ * Velocity in px/ms over the last VELOCITY_WINDOW ms, measured up to the newest sample (the release).
23
+ * A pointer that stopped before release has no recent movement and yields ~0.
24
+ */
25
+ export declare function velocityFromSamples(samples: readonly Sample[], window?: number): Vector;
26
+ /** Advances a momentum step on one axis: the distance travelled in `dt` ms and the decayed velocity. */
27
+ export declare function momentumStep(velocity: number, dt: number): {
28
+ distance: number;
29
+ velocity: number;
30
+ };
31
+ /** Total distance momentum travels on one axis from `velocity` (px/ms) until it decays to rest. */
32
+ export declare function momentumDistance(velocity: number): number;
33
+ /** The axis a pointer delta mostly moves along. Ties go to x. */
34
+ export declare function dominantAxis(delta: Vector): "x" | "y";
35
+ /** Converts a physical `scrollLeft` into a logical offset from the inline start (always >= 0). */
36
+ export declare function toLogical(scrollLeft: number, rtl: boolean): number;
37
+ /** Converts a logical horizontal offset back into a physical `scrollLeft`. */
38
+ export declare function toPhysical(logical: number, rtl: boolean): number;
39
+ /**
40
+ * Which axes a computed `scroll-snap-type` snaps. `inline` and `block` assume a horizontal writing mode.
41
+ */
42
+ export declare function parseSnapType(value: string): {
43
+ x: boolean;
44
+ y: boolean;
45
+ };
46
+ export type SnapAlign = "start" | "center" | "end" | "none";
47
+ /**
48
+ * Per-axis value of a computed `scroll-snap-align` (`"<block> <inline>"`, or one value for both).
49
+ * The inline value applies to x and the block value to y (horizontal writing mode).
50
+ */
51
+ export declare function parseSnapAlign(value: string): {
52
+ x: SnapAlign;
53
+ y: SnapAlign;
54
+ };
55
+ /**
56
+ * One axis of a snapport, in viewport px. `start` is the physical low edge (left or top) of the padding box,
57
+ * `size` its length (clientWidth or clientHeight), and the paddings are physical too (left/right, top/bottom).
58
+ */
59
+ export interface SnapAxis {
60
+ start: number;
61
+ size: number;
62
+ paddingStart: number;
63
+ paddingEnd: number;
64
+ /** The physical scroll offset (scrollLeft or scrollTop). */
65
+ scroll: number;
66
+ maxScroll: number;
67
+ /** True for x in RTL: logical start is the physical high edge and scrollLeft runs negative. */
68
+ rtl: boolean;
69
+ }
70
+ /** A snap area on one axis in viewport px, already including its scroll-margin. */
71
+ export interface SnapItem {
72
+ start: number;
73
+ size: number;
74
+ align: SnapAlign;
75
+ }
76
+ /**
77
+ * Logical scroll offsets at which each item satisfies its snap alignment on one axis, clamped to the
78
+ * scroll range, sorted ascending and de-duplicated within 1px.
79
+ */
80
+ export declare function snapPositions(axis: SnapAxis, items: readonly SnapItem[]): number[];
81
+ /** The snap position closest to `target`, or `target` itself when there are none. */
82
+ export declare function nearestSnap(positions: readonly number[], target: number): number;
83
+ /** The first snap position past `current` in `direction` (1 = next, -1 = prev), or undefined. */
84
+ export declare function adjacentSnap(positions: readonly number[], current: number, direction: 1 | -1): number | undefined;
85
+ export declare const easeOutCubic: (t: number) => number;
86
+ /**
87
+ * Duration in ms of an ease-out-cubic glide over `distance` on one axis. When the release velocity points
88
+ * the same way, the glide starts at that velocity (ease-out-cubic starts at 3 × average speed).
89
+ */
90
+ export declare function glideDuration(distance: number, velocity?: number): number;
package/dist/math.js ADDED
@@ -0,0 +1,139 @@
1
+ // Pure geometry and physics helpers. No DOM access here, so every function is unit-testable in node.
2
+ /** Only the samples from the last VELOCITY_WINDOW ms before release count towards the fling velocity. */
3
+ export const VELOCITY_WINDOW = 100;
4
+ /** Fling speed cap per axis in px/ms, so a jittery sample pair cannot launch the content to the far end. */
5
+ export const MAX_VELOCITY = 5;
6
+ /** Per-frame velocity multiplier for momentum. */
7
+ export const DECAY = 0.95;
8
+ /** Reference frame duration that DECAY is defined against. */
9
+ export const FRAME_MS = 1000 / 60;
10
+ /** Momentum stops below this speed in px/ms. */
11
+ export const MIN_VELOCITY = 0.02;
12
+ export const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
13
+ /**
14
+ * Velocity in px/ms over the last VELOCITY_WINDOW ms, measured up to the newest sample (the release).
15
+ * A pointer that stopped before release has no recent movement and yields ~0.
16
+ */
17
+ export function velocityFromSamples(samples, window = VELOCITY_WINDOW) {
18
+ const last = samples.at(-1);
19
+ if (!last)
20
+ return { x: 0, y: 0 };
21
+ let first = last;
22
+ for (let i = samples.length - 2; i >= 0; i--) {
23
+ const sample = samples[i];
24
+ if (last.t - sample.t > window)
25
+ break;
26
+ first = sample;
27
+ }
28
+ const dt = last.t - first.t;
29
+ if (dt <= 0)
30
+ return { x: 0, y: 0 };
31
+ return {
32
+ x: clamp((last.x - first.x) / dt, -MAX_VELOCITY, MAX_VELOCITY),
33
+ y: clamp((last.y - first.y) / dt, -MAX_VELOCITY, MAX_VELOCITY),
34
+ };
35
+ }
36
+ /** Advances a momentum step on one axis: the distance travelled in `dt` ms and the decayed velocity. */
37
+ export function momentumStep(velocity, dt) {
38
+ return { distance: velocity * dt, velocity: velocity * DECAY ** (dt / FRAME_MS) };
39
+ }
40
+ /** Total distance momentum travels on one axis from `velocity` (px/ms) until it decays to rest. */
41
+ export function momentumDistance(velocity) {
42
+ return (velocity * FRAME_MS) / (1 - DECAY);
43
+ }
44
+ /** The axis a pointer delta mostly moves along. Ties go to x. */
45
+ export function dominantAxis(delta) {
46
+ return Math.abs(delta.x) >= Math.abs(delta.y) ? "x" : "y";
47
+ }
48
+ /** Converts a physical `scrollLeft` into a logical offset from the inline start (always >= 0). */
49
+ export function toLogical(scrollLeft, rtl) {
50
+ // Modern browsers report RTL scrollLeft as 0 at the start, going negative towards the end.
51
+ return rtl ? -scrollLeft : scrollLeft;
52
+ }
53
+ /** Converts a logical horizontal offset back into a physical `scrollLeft`. */
54
+ export function toPhysical(logical, rtl) {
55
+ return rtl ? -logical : logical;
56
+ }
57
+ /**
58
+ * Which axes a computed `scroll-snap-type` snaps. `inline` and `block` assume a horizontal writing mode.
59
+ */
60
+ export function parseSnapType(value) {
61
+ const axis = value.trim().split(/\s+/)[0];
62
+ return {
63
+ x: axis === "x" || axis === "both" || axis === "inline",
64
+ y: axis === "y" || axis === "both" || axis === "block",
65
+ };
66
+ }
67
+ const toAlign = (value) => value === "start" || value === "center" || value === "end" ? value : "none";
68
+ /**
69
+ * Per-axis value of a computed `scroll-snap-align` (`"<block> <inline>"`, or one value for both).
70
+ * The inline value applies to x and the block value to y (horizontal writing mode).
71
+ */
72
+ export function parseSnapAlign(value) {
73
+ const [block, inline = block] = value.trim().split(/\s+/);
74
+ return { x: toAlign(inline), y: toAlign(block) };
75
+ }
76
+ /**
77
+ * Logical scroll offsets at which each item satisfies its snap alignment on one axis, clamped to the
78
+ * scroll range, sorted ascending and de-duplicated within 1px.
79
+ */
80
+ export function snapPositions(axis, items) {
81
+ const { rtl } = axis;
82
+ const portLow = axis.start + axis.paddingStart;
83
+ const portHigh = axis.start + axis.size - axis.paddingEnd;
84
+ const positions = [];
85
+ for (const item of items) {
86
+ if (item.align === "none")
87
+ continue;
88
+ const high = item.start + item.size;
89
+ // start/end follow the inline direction, so in RTL "start" is the high (right) edge.
90
+ const delta = item.align === "center"
91
+ ? (item.start + high) / 2 - (portLow + portHigh) / 2
92
+ : (item.align === "start") !== rtl
93
+ ? item.start - portLow
94
+ : high - portHigh;
95
+ positions.push(clamp(toLogical(axis.scroll + delta, rtl), 0, axis.maxScroll));
96
+ }
97
+ positions.sort((a, b) => a - b);
98
+ const unique = [];
99
+ for (const position of positions) {
100
+ const previous = unique.at(-1);
101
+ if (previous === undefined || position - previous > 1)
102
+ unique.push(position);
103
+ }
104
+ return unique;
105
+ }
106
+ /** The snap position closest to `target`, or `target` itself when there are none. */
107
+ export function nearestSnap(positions, target) {
108
+ let best = target;
109
+ let bestDistance = Infinity;
110
+ for (const position of positions) {
111
+ const distance = Math.abs(position - target);
112
+ if (distance < bestDistance) {
113
+ best = position;
114
+ bestDistance = distance;
115
+ }
116
+ }
117
+ return best;
118
+ }
119
+ /** The first snap position past `current` in `direction` (1 = next, -1 = prev), or undefined. */
120
+ export function adjacentSnap(positions, current, direction) {
121
+ if (direction === 1)
122
+ return positions.find((position) => position > current + 1);
123
+ for (let i = positions.length - 1; i >= 0; i--)
124
+ if (positions[i] < current - 1)
125
+ return positions[i];
126
+ return undefined;
127
+ }
128
+ export const easeOutCubic = (t) => 1 - (1 - t) ** 3;
129
+ /**
130
+ * Duration in ms of an ease-out-cubic glide over `distance` on one axis. When the release velocity points
131
+ * the same way, the glide starts at that velocity (ease-out-cubic starts at 3 × average speed).
132
+ */
133
+ export function glideDuration(distance, velocity = 0) {
134
+ const d = Math.abs(distance);
135
+ if (velocity !== 0 && Math.sign(velocity) === Math.sign(distance)) {
136
+ return clamp((3 * d) / Math.abs(velocity), 180, 900);
137
+ }
138
+ return clamp(200 + d * 0.5, 200, 600);
139
+ }
@@ -0,0 +1,22 @@
1
+ import { type AxisInstance, type AxisOptions, type AxisScrollState } from "./axis.js";
2
+ /** Imperative scroll controls returned by {@link useAxis}. Safe to call before the element mounts (no-op). */
3
+ export type AxisApi = Pick<AxisInstance, "scrollPrev" | "scrollNext" | "scrollTo" | "refresh">;
4
+ /** What {@link useAxis} returns. */
5
+ export interface UseAxisResult<T extends HTMLElement = HTMLElement, S = AxisScrollState> {
6
+ /** Callback ref for the scroll container. Stable. */
7
+ ref: (element: T | null) => void;
8
+ /** The scroll state of both axes, or what the selector picked from it. Re-renders only when it changes. */
9
+ state: S;
10
+ /** Stable scroll controls. */
11
+ api: AxisApi;
12
+ }
13
+ /**
14
+ * Binds axis to an element through a callback ref. The instance is created when the element mounts and
15
+ * destroyed when it unmounts or changes. Option changes are applied without re-creating the instance.
16
+ *
17
+ * With a `selector`, `state` is `selector(state)` and the component re-renders only when that result changes
18
+ * (compared with `Object.is`).
19
+ */
20
+ export declare function useAxis<T extends HTMLElement = HTMLElement>(options?: AxisOptions): UseAxisResult<T>;
21
+ export declare function useAxis<S>(options: AxisOptions | undefined, selector: (state: AxisScrollState) => S): UseAxisResult<HTMLElement, S>;
22
+ export type { AxisMode, AxisName, AxisOptions, AxisPosition, AxisScrollState, AxisScrollToOptions, AxisState, } from "./axis.js";
package/dist/react.js ADDED
@@ -0,0 +1,49 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
2
+ import { createAxis } from "./axis.js";
3
+ const EMPTY_AXIS = { scroll: 0, maxScroll: 0, progress: 0, canScrollPrev: false, canScrollNext: false };
4
+ const EMPTY_STATE = { x: EMPTY_AXIS, y: EMPTY_AXIS, isDragging: false, isMomentum: false };
5
+ const noop = () => { };
6
+ // useLayoutEffect, without React 18's warning during server rendering (where effects never run anyway).
7
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
8
+ export function useAxis(options = {}, selector) {
9
+ const [element, setElement] = useState(null);
10
+ const [instance, setInstance] = useState(null);
11
+ const instanceRef = useRef(null);
12
+ const optionsRef = useRef(options);
13
+ // Declared before the instance effect, so a new instance is created with the options of the same render.
14
+ const { axis, momentum, wheel, cursor } = options;
15
+ useIsomorphicLayoutEffect(() => {
16
+ optionsRef.current = { axis, momentum, wheel, cursor };
17
+ instanceRef.current?.setOptions(optionsRef.current);
18
+ }, [axis, momentum, wheel, cursor]);
19
+ useIsomorphicLayoutEffect(() => {
20
+ if (!element)
21
+ return;
22
+ const created = createAxis(element, optionsRef.current);
23
+ instanceRef.current = created;
24
+ setInstance(created);
25
+ return () => {
26
+ created.destroy();
27
+ instanceRef.current = null;
28
+ setInstance(null);
29
+ };
30
+ }, [element]);
31
+ const subscribe = useCallback((listener) => instance?.subscribe(listener) ?? noop, [instance]);
32
+ // Memoised on the state object and the selector, so the snapshot is stable until either changes.
33
+ const memo = useRef(null);
34
+ const getSnapshot = useCallback(() => {
35
+ const current = instance?.getState() ?? EMPTY_STATE;
36
+ if (memo.current?.state !== current || memo.current.selector !== selector) {
37
+ memo.current = { state: current, selector, selected: selector ? selector(current) : current };
38
+ }
39
+ return memo.current.selected;
40
+ }, [instance, selector]);
41
+ const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
42
+ const api = useMemo(() => ({
43
+ scrollPrev: (target) => instanceRef.current?.scrollPrev(target),
44
+ scrollNext: (target) => instanceRef.current?.scrollNext(target),
45
+ scrollTo: (position, scrollOptions) => instanceRef.current?.scrollTo(position, scrollOptions),
46
+ refresh: () => instanceRef.current?.refresh(),
47
+ }), []);
48
+ return { ref: setElement, state, api };
49
+ }
package/package.json CHANGED
@@ -1,6 +1,84 @@
1
1
  {
2
- "name": "@anyknown/axis",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@anyknown/axis",
3
+ "version": "0.1.0",
4
+ "description": "Headless scroll primitive for x, y and 2D panning: native scroll first, mouse drag as an enhancement",
5
+ "keywords": [
6
+ "carousel",
7
+ "drag-scroll",
8
+ "headless",
9
+ "kanban",
10
+ "pan",
11
+ "react",
12
+ "scroll",
13
+ "scroll-snap"
14
+ ],
15
+ "bugs": {
16
+ "url": "https://github.com/anyknown-com/axis/issues"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/anyknown-com/axis.git"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "LICENSE"
26
+ ],
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "types": "./dist/index.d.ts",
30
+ "typesVersions": {
31
+ "*": {
32
+ "react": [
33
+ "./dist/react.d.ts"
34
+ ]
35
+ }
36
+ },
37
+ "exports": {
38
+ ".": {
39
+ "types": "./dist/index.d.ts",
40
+ "default": "./dist/index.js"
41
+ },
42
+ "./react": {
43
+ "types": "./dist/react.d.ts",
44
+ "default": "./dist/react.js"
45
+ }
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/esm-extensions.mjs",
52
+ "typecheck": "tsc --noEmit",
53
+ "lint": "oxlint src test playground",
54
+ "fmt": "oxfmt",
55
+ "fmt:check": "oxfmt --check",
56
+ "check": "pnpm typecheck && pnpm lint && pnpm fmt:check",
57
+ "test": "vitest run",
58
+ "dev": "vite --config playground/vite.config.ts",
59
+ "prepublishOnly": "pnpm check && pnpm test && pnpm build"
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "^26.4.0",
63
+ "@types/react": "^19.2.0",
64
+ "@types/react-dom": "^19.2.5",
65
+ "@vitest/browser-playwright": "^4.1.11",
66
+ "oxfmt": "^0.65.0",
67
+ "oxlint": "^1.80.0",
68
+ "playwright": "^1.55.0",
69
+ "react": "^19.2.8",
70
+ "react-dom": "^19.2.8",
71
+ "typescript": "^5.9.0",
72
+ "vite": "^8.2.2",
73
+ "vitest": "^4.1.11"
74
+ },
75
+ "peerDependencies": {
76
+ "react": "^18.0.0 || ^19.0.0"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "react": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "packageManager": "pnpm@10.30.3"
84
+ }