@weft-js/scroll 0.0.0-stage → 0.1.5-nightly.20261111.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/render.ts ADDED
@@ -0,0 +1,205 @@
1
+ // The bars: their elements and ARIA, when they show, and the frame each renderer is asked to paint.
2
+ import { thumbLength, thumbOffset } from './bar'
3
+ import type { AxisState, Engine } from './engine'
4
+ import type { BarFrame, Renderer } from './types'
5
+
6
+ /** The default renderer: a `data-part="thumb"` element per bar, sized and moved here, styled by CSS. */
7
+ export const domRenderer: Renderer = {
8
+ mount(track) {
9
+ if (track.querySelector('[data-part="thumb"]')) return
10
+ const thumb = document.createElement('div')
11
+ thumb.dataset.part = 'thumb'
12
+ thumb.dataset.made = ''
13
+ track.append(thumb)
14
+ },
15
+ draw(track, f) {
16
+ const thumb = track.querySelector<HTMLElement>('[data-part="thumb"]')
17
+ if (!thumb) return
18
+ const y = f.axis === 'y'
19
+ thumb.style.transform = y
20
+ ? `translate3d(0,${f.thumb.y}px,0)`
21
+ : `translate3d(${f.thumb.x}px,0,0)`
22
+ thumb.style[y ? 'height' : 'width'] = `${y ? f.thumb.height : f.thumb.width}px`
23
+ // What the stylesheet's scroll-timeline animation runs between, where the browser supports one.
24
+ thumb.style.setProperty('--scroll-thumb-start', `${f.span.start}px`)
25
+ thumb.style.setProperty('--scroll-thumb-end', `${f.span.end}px`)
26
+ },
27
+ unmount(track) {
28
+ const thumb = track.querySelector<HTMLElement>('[data-part="thumb"]')
29
+ if (thumb && thumb.dataset.made !== undefined) thumb.remove()
30
+ else thumb?.style.removeProperty('transform')
31
+ },
32
+ }
33
+
34
+ let ids = 0
35
+ const ARIA = [
36
+ 'role',
37
+ 'aria-controls',
38
+ 'aria-orientation',
39
+ 'aria-valuemin',
40
+ 'aria-valuemax',
41
+ 'aria-valuenow',
42
+ ]
43
+
44
+ /** Find or make each bar's track, give it scrollbar semantics, and hand it to the renderer. */
45
+ export function mountBars(e: Engine, current: () => Renderer) {
46
+ const renderer = current()
47
+ const made: HTMLElement[] = []
48
+ if (!e.vp.id) e.vp.id = `scroll-${(ids++).toString(36)}-${Math.random().toString(36).slice(2, 6)}`
49
+ for (const a of e.axes) {
50
+ if (!a.on) continue
51
+ const orientation = a.axis === 'y' ? 'vertical' : 'horizontal'
52
+ let track = e.root.querySelector<HTMLElement>(
53
+ `:scope > [data-part="scrollbar"][data-orientation="${orientation}"]`,
54
+ )
55
+ if (!track) {
56
+ track = document.createElement('div')
57
+ track.dataset.part = 'scrollbar'
58
+ track.dataset.orientation = orientation
59
+ e.root.append(track)
60
+ made.push(track)
61
+ }
62
+ track.setAttribute('role', 'scrollbar')
63
+ track.setAttribute('aria-controls', e.vp.id)
64
+ track.setAttribute('aria-orientation', orientation)
65
+ track.setAttribute('aria-valuemin', '0')
66
+ track.setAttribute('aria-valuemax', '100')
67
+ track.setAttribute('aria-valuenow', '0')
68
+ a.track = track
69
+ renderer.mount(track, a.axis)
70
+ }
71
+ return () => {
72
+ for (const a of e.axes) {
73
+ if (!a.track) continue
74
+ current().unmount(a.track)
75
+ for (const name of ARIA) a.track.removeAttribute(name)
76
+ a.track = null
77
+ a.drawn = ''
78
+ a.aria = -1
79
+ }
80
+ for (const t of made) t.remove()
81
+ }
82
+ }
83
+
84
+ /** Paint whatever changed. Returns true while a hover grow is still easing (a renderer that animates it). */
85
+ export function drawBars(e: Engine, renderer: Renderer, dt: number) {
86
+ let busy = false
87
+ for (const a of e.axes) {
88
+ if (!a.track) continue
89
+ const target = a.hover || a.active ? 1 : 0
90
+ if (a.grow !== target) {
91
+ a.grow = renderer.animates && !e.reduced() ? ease(a.grow, target, dt) : target
92
+ busy ||= a.grow !== target
93
+ }
94
+ const f = frame(e, a)
95
+ // On a timeline the compositor moves the thumb, so only its span and size are worth a draw.
96
+ const at = e.timeline ? `${f.span.start}|${f.span.end}` : `${f.thumb.x}|${f.thumb.y}`
97
+ const key = `${at}|${f.thumb.width}|${f.thumb.height}|${a.grow}|${a.hover}|${a.active}|${f.track.width}|${f.track.height}`
98
+ if (key !== a.drawn) {
99
+ a.drawn = key
100
+ renderer.draw(a.track, f)
101
+ }
102
+ const now100 = Math.round(f.progress * 100)
103
+ if (now100 !== a.aria) {
104
+ a.aria = now100
105
+ a.track.setAttribute('aria-valuenow', String(now100))
106
+ }
107
+ }
108
+ return busy
109
+ }
110
+
111
+ const ease = (from: number, to: number, dt: number) => {
112
+ const next = from + (to - from) * Math.min(1, dt / 70)
113
+ return Math.abs(to - next) < 0.02 ? to : next
114
+ }
115
+
116
+ function frame(e: Engine, a: AxisState): BarFrame {
117
+ const { bar } = e.o
118
+ const range = a.max - a.min
119
+ const progress = range > 0 ? (a.pos - a.min) / range : 0
120
+ // A native thumb shrinks against the edge while the content is pulled past it.
121
+ const len = Math.max(
122
+ Math.min(8, a.trackLen),
123
+ thumbLength(a.trackLen, a.view, a.total, e.o.thumb) - Math.abs(a.over) * 0.6,
124
+ )
125
+ const along = (p: number) => bar.inset + thumbOffset(a.trackLen, len, p)
126
+ const off = a.over > 0 ? bar.inset + a.trackLen - len : a.over < 0 ? bar.inset : along(progress)
127
+ // Right to left, a horizontal scroll starts at the right end.
128
+ const flip = a.axis === 'x' && e.rtl
129
+ a.thumbOff = off
130
+ a.thumbLen = len
131
+ const thick = bar.width + (bar.hoverWidth - bar.width) * a.grow
132
+ const y = a.axis === 'y'
133
+ const crossAt = y && e.rtl ? bar.inset : a.trackCross - bar.inset - thick
134
+ return {
135
+ axis: a.axis,
136
+ track: y
137
+ ? { width: a.trackCross, height: a.trackLen + 2 * bar.inset }
138
+ : { width: a.trackLen + 2 * bar.inset, height: a.trackCross },
139
+ thumb: y
140
+ ? { x: crossAt, y: off, width: thick, height: len }
141
+ : { x: off, y: crossAt, width: len, height: thick },
142
+ grow: a.grow,
143
+ hover: a.hover,
144
+ active: a.active,
145
+ progress: flip ? 1 - progress : progress,
146
+ span: { start: along(flip ? 1 : 0), end: along(flip ? 0 : 1) },
147
+ rtl: e.rtl,
148
+ bar,
149
+ }
150
+ }
151
+
152
+ /** Which bars show, as data attributes the stylesheet fades on. */
153
+ export class Visibility {
154
+ private timer: ReturnType<typeof setTimeout> | undefined
155
+ private lastMove = 0
156
+ scrolling = false
157
+ inside = false
158
+
159
+ constructor(private e: Engine) {}
160
+
161
+ /** The position moved: show bars that show on scroll, and hide them again after the delay. */
162
+ moved() {
163
+ this.lastMove = performance.now()
164
+ if (!this.scrolling) {
165
+ this.scrolling = true
166
+ this.e.root.toggleAttribute('data-scrolling', true)
167
+ this.apply()
168
+ }
169
+ this.timer ??= setTimeout(this.idle, this.e.o.bar.hideDelay)
170
+ }
171
+
172
+ private idle = () => {
173
+ this.timer = undefined
174
+ const left = this.e.o.bar.hideDelay - (performance.now() - this.lastMove)
175
+ if (left > 4) {
176
+ this.timer = setTimeout(this.idle, left)
177
+ return
178
+ }
179
+ this.scrolling = false
180
+ this.e.root.toggleAttribute('data-scrolling', false)
181
+ this.apply()
182
+ }
183
+
184
+ apply() {
185
+ const { show } = this.e.o.bar
186
+ for (const a of this.e.axes) {
187
+ if (!a.track) continue
188
+ const none = !a.on || a.max - a.min < 1
189
+ a.track.toggleAttribute('data-none', none)
190
+ this.e.root.toggleAttribute(`data-overflow-${a.axis}`, !none)
191
+ const visible =
192
+ !none &&
193
+ (show === 'always' ||
194
+ (show !== 'never' &&
195
+ (this.scrolling || a.hover || a.active || (show === 'hover' && this.inside))))
196
+ a.track.toggleAttribute('data-visible', visible)
197
+ a.track.toggleAttribute('data-hover', a.hover)
198
+ a.track.toggleAttribute('data-active', a.active)
199
+ }
200
+ }
201
+
202
+ destroy() {
203
+ clearTimeout(this.timer)
204
+ }
205
+ }
@@ -0,0 +1,75 @@
1
+ // The `Scroller` a caller holds: programmatic scrolling, scrollIntoView, and live option changes.
2
+ import { clamp } from './engine'
3
+ import { resolve } from './options'
4
+ import type { Setup } from './scroller'
5
+ import type { Axis, Scroller } from './types'
6
+
7
+ type Move = { top?: number; left?: number; behavior?: ScrollBehavior }
8
+
9
+ export function api(s: Setup, configure: () => void): Scroller {
10
+ const { e } = s
11
+ const move = (to: Move, by: boolean) => {
12
+ for (const [axis, v] of [
13
+ ['y', to.top],
14
+ ['x', to.left],
15
+ ] as [Axis, number | undefined][]) {
16
+ if (v === undefined || !Number.isFinite(v)) continue
17
+ const a = e.ax[axis]
18
+ const target = by ? (a.anim?.kind === 'tween' ? a.anim.to : a.pos) + v : v
19
+ if (to.behavior === 'smooth') e.tweenTo(axis, target, 'api')
20
+ else {
21
+ e.moveTo(axis, target, 'api')
22
+ e.write()
23
+ }
24
+ }
25
+ }
26
+ return {
27
+ viewport: e.vp,
28
+ state: () => e.state(),
29
+ scrollTo: (to) => move(to, false),
30
+ scrollBy: (by) => move(by, true),
31
+ scrollIntoView(el, o = {}) {
32
+ const box = e.vp.getBoundingClientRect()
33
+ const r = el.getBoundingClientRect()
34
+ const m = o.margin ?? 0
35
+ const plan = (axis: Axis, pos: ScrollLogicalPosition) => {
36
+ const a = e.ax[axis]
37
+ const y = axis === 'y'
38
+ const start = (y ? r.top - box.top : r.left - box.left) + a.pos
39
+ const size = y ? r.height : r.width
40
+ let to = a.pos
41
+ if (pos === 'start') to = start - m
42
+ else if (pos === 'end') to = start + size - a.view + m
43
+ else if (pos === 'center') to = start + size / 2 - a.view / 2
44
+ else if (start - m < a.pos || size + 2 * m > a.view) to = start - m
45
+ else if (start + size + m > a.pos + a.view) to = start + size - a.view + m
46
+ return clamp(to, a.min, a.max)
47
+ }
48
+ const top = plan('y', o.block ?? 'start')
49
+ const left = plan('x', o.inline ?? 'nearest')
50
+ move({ top, left, behavior: o.behavior ?? 'auto' }, false)
51
+ },
52
+ stop: () => e.stop(),
53
+ refresh: s.remeasure,
54
+ update(next) {
55
+ s.options = { ...s.options, ...next }
56
+ const axes = e.o.axes
57
+ e.o = resolve(s.options)
58
+ const wanted = next.renderer
59
+ if (wanted && wanted !== s.renderer) {
60
+ for (const a of e.axes) {
61
+ if (!a.track) continue
62
+ s.renderer.unmount(a.track)
63
+ wanted.mount(a.track, a.axis)
64
+ a.drawn = ''
65
+ }
66
+ s.renderer = wanted
67
+ }
68
+ configure()
69
+ s.reinput()
70
+ if (e.o.axes !== axes) s.rebar()
71
+ else s.remeasure()
72
+ },
73
+ destroy: () => e.destroyHooks.splice(0).forEach((f) => f()),
74
+ }
75
+ }
@@ -0,0 +1,240 @@
1
+ // `scroller`: puts the engine, its inputs and its bars on a root element. `scroller-api.ts` is what it returns.
2
+ import { autoscroll } from './autoscroll'
3
+ import { barInput } from './bar-input'
4
+ import { Engine, reduceQuery, registry } from './engine'
5
+ import { keyInput } from './keys'
6
+ import { resolve, writeVars } from './options'
7
+ import { domRenderer, drawBars, mountBars, Visibility } from './render'
8
+ import { api } from './scroller-api'
9
+ import { touchInput } from './touch'
10
+ import { wheelInput } from './wheel-input'
11
+ import type { Renderer, ScrollOptions, Scroller } from './types'
12
+
13
+ const FOCUSABLE =
14
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"]),[contenteditable]:not([contenteditable="false"]),summary,iframe,audio[controls],video[controls]'
15
+
16
+ /** What the setup steps share. */
17
+ export interface Setup {
18
+ e: Engine
19
+ vis: Visibility
20
+ options: ScrollOptions
21
+ renderer: Renderer
22
+ remeasure: () => void
23
+ /** Rebuild the bars, for a change of axes. */
24
+ rebar: () => void
25
+ /** Attach the inputs again, for a change of mode. */
26
+ reinput: () => void
27
+ }
28
+
29
+ /**
30
+ * Custom scrolling on `root`, which holds a `[data-part="viewport"]` element (and, inside that, the content). In the
31
+ * default `native` mode the browser scrolls the viewport, with its own bars hidden, and this draws the bars; in
32
+ * `engine` mode the viewport is `overflow: hidden` and every input runs here. Either way it keeps real scroll offsets,
33
+ * so focus, find, anchors and `scroll` listeners still work. Bars are found as `[data-part="scrollbar"]
34
+ * [data-orientation]` children of `root`, or made. Load `@weft-js/scroll/styles.css`.
35
+ */
36
+ export function scroller(root: HTMLElement, options: ScrollOptions = {}): Scroller {
37
+ const vp = root.querySelector<HTMLElement>(':scope > [data-part="viewport"]') ?? root
38
+ registry
39
+ .get(vp)
40
+ ?.destroyHooks.splice(0)
41
+ .forEach((f) => f())
42
+ const content = vp === root ? null : (vp.firstElementChild as HTMLElement | null)
43
+ const e = new Engine(root, vp, content, resolve(options))
44
+ const vis = new Visibility(e)
45
+ const hooks = e.destroyHooks
46
+ const tabStop = focusable(vp, hooks)
47
+ // Reads happen in one place (measure, from the ResizeObserver batch) and writes in the frame, so a page that mounts
48
+ // many scrollers lays out once, not once per scroller.
49
+ let stale = true
50
+ const s: Setup = {
51
+ e,
52
+ vis,
53
+ options,
54
+ renderer: e.o.renderer ?? domRenderer,
55
+ remeasure: () => {
56
+ e.measure()
57
+ stale = true
58
+ e.wake()
59
+ },
60
+ rebar: () => {},
61
+ reinput: () => {},
62
+ }
63
+ vp.setAttribute('data-scroll-viewport', '')
64
+ configure(s)
65
+ tabStop()
66
+ let unbar = mountBars(e, () => s.renderer)
67
+ hooks.push(() => unbar())
68
+ root.toggleAttribute('data-ready', true)
69
+ if (typeof ResizeObserver !== 'function') e.measure()
70
+ e.onActivity = () => vis.moved()
71
+ e.onFrame = (now) => {
72
+ if (stale && e.measured) {
73
+ stale = false
74
+ tabStop()
75
+ vis.apply()
76
+ }
77
+ return drawBars(e, s.renderer, e.last ? now - e.last : 16)
78
+ }
79
+ const watching = watch(s)
80
+ s.rebar = () => {
81
+ unbar()
82
+ unbar = mountBars(e, () => s.renderer)
83
+ barsOff()
84
+ barsOff = barInput(e, vis)
85
+ watching.observe()
86
+ s.remeasure()
87
+ }
88
+ hooks.push(watching.stop, listen(s))
89
+ let barsOff = barInput(e, vis)
90
+ let inputsOff = inputs(e)
91
+ s.reinput = () => {
92
+ inputsOff()
93
+ inputsOff = inputs(e)
94
+ }
95
+ hooks.push(
96
+ () => inputsOff(),
97
+ () => barsOff(),
98
+ )
99
+ hooks.push(() => {
100
+ vis.destroy()
101
+ if (e.frame) cancelAnimationFrame(e.frame)
102
+ if (e.content) e.content.style.transform = ''
103
+ for (const name of ['data-ready', 'data-timeline', 'data-mode', 'data-chain'])
104
+ root.removeAttribute(name)
105
+ vp.removeAttribute('data-scroll-viewport')
106
+ registry.delete(vp)
107
+ })
108
+ registry.set(vp, e)
109
+ e.wake()
110
+ return api(s, () => configure(s))
111
+ }
112
+
113
+ /**
114
+ * The engine's input. In native mode even a wheel or touch listener that does nothing would cost the compositor its
115
+ * scrolling, so only keys (which reach the page first in any case, and which some browsers don't scroll a focused
116
+ * area for) and a mouse `grab` are attached.
117
+ */
118
+ function inputs(e: Engine) {
119
+ const native = e.o.mode === 'native'
120
+ const offs = native
121
+ ? [keyInput(e), e.o.grab ? touchInput(e) : () => {}]
122
+ : [wheelInput(e), keyInput(e), touchInput(e), autoscroll(e)]
123
+ return () => offs.forEach((f) => f())
124
+ }
125
+
126
+ let timelines: boolean | undefined
127
+ /** Whether CSS can drive the thumbs from the viewport's scroll position, as a named, scoped scroll timeline. */
128
+ const canTimeline = () =>
129
+ (timelines ??=
130
+ typeof CSS !== 'undefined' &&
131
+ CSS.supports('animation-timeline: --a') &&
132
+ CSS.supports('timeline-scope: --a'))
133
+
134
+ /** Options into attributes and custom properties on the root and viewport. */
135
+ function configure({ e, options, renderer }: Setup) {
136
+ const { root, vp } = e
137
+ root.dataset.mode = e.o.mode
138
+ root.dataset.chain = e.o.chain
139
+ const timeline = e.o.mode === 'native' && renderer === domRenderer && canTimeline()
140
+ if (timeline !== e.timeline) for (const a of e.axes) a.drawn = ''
141
+ e.timeline = timeline
142
+ root.toggleAttribute('data-timeline', timeline)
143
+ e.ax.y.on = e.o.axes !== 'x'
144
+ e.ax.x.on = e.o.axes !== 'y'
145
+ root.dataset.scroll ??= ''
146
+ root.dataset.layout = e.o.bar.layout
147
+ root.dataset.show = e.o.bar.show
148
+ root.dataset.axes = e.o.axes
149
+ vp.dataset.touch = e.o.touch
150
+ writeVars(root, options.bar, e.reduced())
151
+ if (e.o.label) {
152
+ vp.setAttribute('role', 'region')
153
+ vp.setAttribute('aria-label', e.o.label)
154
+ }
155
+ }
156
+
157
+ /**
158
+ * As browsers treat scrollers: a tab stop only when nothing inside can take focus, and always focusable by a click so
159
+ * the keys work after one. Returns the check, to run again when the content changes.
160
+ */
161
+ function focusable(vp: HTMLElement, hooks: (() => void)[]) {
162
+ if (vp.hasAttribute('tabindex')) return () => {}
163
+ hooks.push(() => vp.removeAttribute('tabindex'))
164
+ return () => {
165
+ vp.tabIndex = vp.querySelector(FOCUSABLE) ? -1 : 0
166
+ }
167
+ }
168
+
169
+ /** Measure again when the viewport, the content or a bar changes size, or the content is swapped. */
170
+ function watch(s: Setup) {
171
+ const { e } = s
172
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(() => s.remeasure()) : null
173
+ const observe = () => {
174
+ ro?.disconnect()
175
+ ro?.observe(e.vp)
176
+ if (e.content) ro?.observe(e.content)
177
+ for (const a of e.axes) if (a.track) ro?.observe(a.track)
178
+ }
179
+ observe()
180
+ const mo =
181
+ typeof MutationObserver === 'function' && e.vp !== e.root
182
+ ? new MutationObserver(() => {
183
+ const next = e.vp.firstElementChild as HTMLElement | null
184
+ if (next === e.content) return
185
+ e.content = next
186
+ observe()
187
+ s.remeasure()
188
+ })
189
+ : null
190
+ mo?.observe(e.vp, { childList: true })
191
+ return {
192
+ observe,
193
+ stop: () => {
194
+ ro?.disconnect()
195
+ mo?.disconnect()
196
+ },
197
+ }
198
+ }
199
+
200
+ /** The browser's own scrolling, the pointer over the area, and the reduced-motion setting. */
201
+ function listen({ e, vis, options }: Setup) {
202
+ let idle: ReturnType<typeof setTimeout> | undefined
203
+ // In native mode the browser's scroll has ended: rest on a snap point, as the engine does after its own input.
204
+ const settle = () => {
205
+ if (e.o.mode !== 'native') return
206
+ for (const a of e.axes) {
207
+ a.v = 0
208
+ e.settle(a.axis)
209
+ }
210
+ }
211
+ const ends = typeof window !== 'undefined' && 'onscrollend' in window
212
+ const onNative = () => {
213
+ if (e.dispatching) return
214
+ e.adopt()
215
+ if (ends) return
216
+ clearTimeout(idle)
217
+ idle = setTimeout(settle, 150)
218
+ }
219
+ const inside = (on: boolean) => () => {
220
+ vis.inside = on
221
+ vis.apply()
222
+ }
223
+ const enter = inside(true)
224
+ const leave = inside(false)
225
+ const motion = reduceQuery()
226
+ const onMotion = () => writeVars(e.root, options.bar, e.reduced())
227
+ e.vp.addEventListener('scroll', onNative, { passive: true })
228
+ e.vp.addEventListener('scrollend', settle)
229
+ e.root.addEventListener('pointerenter', enter)
230
+ e.root.addEventListener('pointerleave', leave)
231
+ motion?.addEventListener?.('change', onMotion)
232
+ return () => {
233
+ clearTimeout(idle)
234
+ e.vp.removeEventListener('scroll', onNative)
235
+ e.vp.removeEventListener('scrollend', settle)
236
+ e.root.removeEventListener('pointerenter', enter)
237
+ e.root.removeEventListener('pointerleave', leave)
238
+ motion?.removeEventListener?.('change', onMotion)
239
+ }
240
+ }
package/src/snap.ts ADDED
@@ -0,0 +1,35 @@
1
+ // Choosing the snap point a scroll comes to rest on. Pure: the points come in already measured.
2
+
3
+ /**
4
+ * The point to rest at for a scroll that would end at `end`. `dir` (-1, 0, 1) is the way it was moving: a mandatory
5
+ * snap never pulls back against it once the scroll has passed a point. Returns `end` when nothing applies.
6
+ */
7
+ export function snapTo(
8
+ points: readonly number[],
9
+ end: number,
10
+ o: { type?: 'mandatory' | 'proximity'; proximity?: number; dir?: number; max?: number } = {},
11
+ ) {
12
+ if (!points.length) return end
13
+ const max = o.max ?? Infinity
14
+ let best = end
15
+ let gap = Infinity
16
+ for (const raw of points) {
17
+ const p = Math.min(max, Math.max(0, raw))
18
+ const d = Math.abs(p - end)
19
+ if (d < gap) {
20
+ gap = d
21
+ best = p
22
+ }
23
+ }
24
+ if (o.type !== 'mandatory' && gap > (o.proximity ?? 120)) return end
25
+ return best
26
+ }
27
+
28
+ /** The next point past `from` in direction `dir`, for a key press or a wheel notch under mandatory snapping. */
29
+ export function snapStep(points: readonly number[], from: number, dir: number, max: number) {
30
+ const sorted = [...new Set(points.map((p) => Math.min(max, Math.max(0, p))))].sort(
31
+ (a, b) => a - b,
32
+ )
33
+ if (dir > 0) return sorted.find((p) => p > from + 1) ?? Math.min(max, sorted.at(-1) ?? max)
34
+ return sorted.findLast((p) => p < from - 1) ?? Math.max(0, sorted[0] ?? 0)
35
+ }