diff --git a/.changeset/scroll-solid2-migration.md b/.changeset/scroll-solid2-migration.md new file mode 100644 index 000000000..094a0b00f --- /dev/null +++ b/.changeset/scroll-solid2-migration.md @@ -0,0 +1,19 @@ +--- +"@solid-primitives/scroll": major +--- + +Migrate to Solid.js v2.0 (beta.10) + +## Breaking Changes + +**Peer dependency**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required. + +### `@solid-primitives/scroll` + +- `isServer` now imported from `@solidjs/web` (not `solid-js/web`) +- `onMount` replaced with `onSettled` for post-render position refresh +- `sharedConfig.context` replaced with `sharedConfig.hydrating` for hydration detection +- Internal signal pattern replaced: Solid 2.0's `createSignal(fn)` creates a derived signal rather than storing a function value; now uses a version counter to drive memo re-evaluation on scroll events +- Signal uses `{ ownedWrite: true }` to allow writes from DOM event handlers within reactive scopes +- Tests updated: `createComputed` removed (no longer in Solid 2.0), replaced with direct reactive reads and `flush()` for synchronous assertions; `createSignal` in tests uses `{ ownedWrite: true }` +- README: `onMount` example updated to `onSettled` diff --git a/packages/scroll/LICENSE b/packages/scroll/LICENSE index 38b41d975..303b92f6b 100644 --- a/packages/scroll/LICENSE +++ b/packages/scroll/LICENSE @@ -2,6 +2,21 @@ MIT License Copyright (c) 2021 Solid Primitives Working Group +--- + +The `createPreventScroll` primitive is adapted from +[solid-prevent-scroll](https://github.com/corvudev/corvu/tree/main/packages/solid-prevent-scroll) +by Jasmin Noetzli (GiyoMoon), part of the [corvu](https://corvu.dev) project, +which is itself inspired by [react-remove-scroll](https://github.com/theKashey/react-remove-scroll) +by Anton Korzunov. + +MIT License + +Copyright (c) Jasmin Noetzli +Copyright (c) Anton Korzunov + +--- + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/packages/scroll/README.md b/packages/scroll/README.md index ed2ed67ac..d3150460b 100644 --- a/packages/scroll/README.md +++ b/packages/scroll/README.md @@ -8,10 +8,11 @@ [![size](https://img.shields.io/npm/v/@solid-primitives/scroll?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/scroll) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-2.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Reactive primitives to react to element/window scrolling. +Reactive primitives to react to element/window scrolling, and to prevent scroll outside of a given element. - [`createScrollPosition`](#createscrollposition) - Reactive primitive providing a store-like object with current scroll position of specified target. - [`useWindowScrollPosition`](#usewindowscrollposition) - Returns a reactive object with current window scroll position. +- [`createPreventScroll`](#createpreventscroll) - Prevents scrolling outside of a given element. ## Installation @@ -45,10 +46,10 @@ createEffect(() => { ```tsx let ref: HTMLDivElement | undefined; -// pass as function +// pass as function — preferred, handles ref population automatically const scroll = createScrollPosition(() => ref); -// or wrap with onMount -onMount(() => { +// or wrap with onSettled +onSettled(() => { const scroll = createScrollPosition(ref!); }); @@ -119,6 +120,48 @@ createEffect(() => { Get an `{ x: number, y: number }` object of element/window scroll position. +## `createPreventScroll` + +Prevents scrolling outside of the given element by intercepting `wheel` and `touchmove` events and optionally hiding the `` scrollbar. + +Adapted from [solid-prevent-scroll](https://github.com/corvudev/corvu/tree/main/packages/solid-prevent-scroll) by [Jasmin Noetzli (GiyoMoon)](https://github.com/GiyoMoon), part of the [corvu](https://corvu.dev) project, which is itself inspired by [react-remove-scroll](https://github.com/theKashey/react-remove-scroll) by Anton Korzunov. Adapted for Solid 2.0 and solid-primitives conventions. + +### How to use it + +```tsx +import { createPreventScroll } from "@solid-primitives/scroll"; + +// Prevent all page scroll (no element specified) +createPreventScroll(); + +// Prevent scroll outside a specific element +createPreventScroll({ element: () => myElement }); + +// Using a signal ref — preferred pattern for JSX refs +const [ref, setRef] = createSignal(); +createPreventScroll({ element: ref }); + +
; + +// Reactive enabled toggle +const [open, setOpen] = createSignal(false); +createPreventScroll({ enabled: open }); +``` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `element` | `MaybeAccessor` | `undefined` | Allow scroll inside this element. Events outside it are cancelled. | +| `enabled` | `MaybeAccessor` | `true` | Whether scroll prevention is active. | +| `hideScrollbar` | `MaybeAccessor` | `true` | Hide the `` scrollbar while active. | +| `preventScrollbarShift` | `MaybeAccessor` | `true` | Compensate for the hidden scrollbar width to avoid layout shift. | +| `preventScrollbarShiftMode` | `MaybeAccessor<"padding" \| "margin">` | `"padding"` | Which CSS property to use for the scrollbar shift compensation. | +| `restoreScrollPosition` | `MaybeAccessor` | `true` | Restore `` scroll position via `window.scrollTo` when disabled. | +| `allowPinchZoom` | `MaybeAccessor` | `false` | Allow two-finger pinch-zoom gestures. | + +Multiple active instances are stacked; only the topmost one installs event listeners. Body styles are shared and only restored once all instances clean up. + ## Primitive ideas: _PRs Welcome :)_ diff --git a/packages/scroll/package.json b/packages/scroll/package.json index 130c6d923..f1e87299c 100644 --- a/packages/scroll/package.json +++ b/packages/scroll/package.json @@ -1,10 +1,17 @@ { "name": "@solid-primitives/scroll", "version": "2.1.5", - "description": "Reactive primitives to react to element/window scrolling.", + "description": "Reactive primitives to react to element/window scrolling, and to prevent scroll outside of a given element.", "author": "David Di Biase ", "contributors": [ - "Damian Tarnawski " + { + "name": "Damian Tarnawski", + "email": "gthetarnav@gmail.com" + }, + { + "name": "Jasmin Noetzli", + "url": "https://github.com/GiyoMoon" + } ], "license": "MIT", "homepage": "https://primitives.solidjs.community/package/scroll", @@ -17,7 +24,8 @@ "stage": 2, "list": [ "createScrollPosition", - "useWindowScrollPosition" + "useWindowScrollPosition", + "createPreventScroll" ], "category": "Inputs" }, @@ -49,19 +57,26 @@ "monitor", "scrollTo", "scroll", + "prevent-scroll", + "scroll-lock", + "accessibility", + "a11y", "solid", "primitives" ], "dependencies": { "@solid-primitives/event-listener": "workspace:^", "@solid-primitives/rootless": "workspace:^", - "@solid-primitives/static-store": "workspace:^" + "@solid-primitives/static-store": "workspace:^", + "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.12", + "solid-js": "^2.0.0-beta.12" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.12", + "solid-js": "2.0.0-beta.12" } } diff --git a/packages/scroll/src/index.ts b/packages/scroll/src/index.ts index 92f44ef32..92695036b 100644 --- a/packages/scroll/src/index.ts +++ b/packages/scroll/src/index.ts @@ -1,108 +1,11 @@ -import { createEventListener } from "@solid-primitives/event-listener"; -import { createHydratableSingletonRoot } from "@solid-primitives/rootless"; -import { createDerivedStaticStore } from "@solid-primitives/static-store"; -import { type Accessor, createSignal, onMount, sharedConfig } from "solid-js"; -import { isServer } from "solid-js/web"; - -export function getScrollParent(node: Element | null): Element { - if (isServer) { - return {} as Element; - } - while (node && !isScrollable(node)) { - node = node.parentElement; - } - - return node || document.scrollingElement || document.documentElement; -} - -export function isScrollable(node: Element): boolean { - if (isServer) { - return false; - } - const style = window.getComputedStyle(node); - return /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY); -} - -export type Position = { - x: number; - y: number; -}; - -const FALLBACK_SCROLL_POSITION = { x: 0, y: 0 } as const satisfies Position; - -/** - * Get an `{ x: number, y: number }` object of element/window scroll position. - */ -export function getScrollPosition(target: Element | Window | undefined): Position { - if (isServer || !target) { - return { ...FALLBACK_SCROLL_POSITION }; - } - if (target instanceof Window) - return { - x: target.scrollX, - y: target.scrollY, - }; - return { - x: target.scrollLeft, - y: target.scrollTop, - }; -} - -/** - * Reactive primitive providing a store-like object with current scroll position of specified target. - * @param target element/window to listen to scroll events. can be a reactive singal. - * @returns a store-like reactive object `{ x: number, y: number }` of current scroll position of {@link target} - * @example - * // target will be window by default - * const windowScroll = createScrollPosition(); - * - * createEffect(() => { - * // returned object is a reactive store-like structure - * windowScroll.x; // => number - * windowScroll.y; // => number - * }); - */ -export function createScrollPosition( - target?: Accessor | Element | Window, -): Readonly { - if (isServer) { - return FALLBACK_SCROLL_POSITION; - } - - target = target || window; - - const isFn = typeof target === "function", - isHydrating = sharedConfig.context, - getTargetPos = isFn - ? () => getScrollPosition((target as Extract)()) - : () => getScrollPosition(target as Element | Window), - // changing the calc signal will trigger the derived store to update - [calc, setCalc] = createSignal(isHydrating ? () => FALLBACK_SCROLL_POSITION : getTargetPos, { - equals: false, - }), - trigger = () => setCalc(() => getTargetPos), - pos = createDerivedStaticStore(() => calc()()); - - // update the position on mount if we are hydrating (initial pos is null) - // or if target is a function (which means it could be a ref that will be populated onMount) - if (isHydrating || isFn) onMount(trigger); - - createEventListener(target, "scroll", trigger, { passive: true }); - - return pos; -} - -/** - * Returns a reactive object with current window scroll position. - * - * This is a [singleton root](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot) primitive. - * - * @example - * const scroll = useWindowScrollPosition(); - * createEffect(() => { - * console.log(scroll.x, scroll.y) - * }) - */ -export const useWindowScrollPosition = /*#__PURE__*/ createHydratableSingletonRoot(() => - createScrollPosition(isServer ? () => undefined : window), -); +export { + getScrollParent, + isScrollable, + getScrollPosition, + createScrollPosition, + useWindowScrollPosition, +} from "./scrollPosition.js"; +export type { Position } from "./scrollPosition.js"; + +export { createPreventScroll } from "./preventScroll.js"; +export type { CreatePreventScrollProps } from "./preventScroll.js"; diff --git a/packages/scroll/src/preventScroll.ts b/packages/scroll/src/preventScroll.ts new file mode 100644 index 000000000..d5b772201 --- /dev/null +++ b/packages/scroll/src/preventScroll.ts @@ -0,0 +1,341 @@ +/**! + * Part of this code is adapted from solid-prevent-scroll by Jasmin Noetzli (GiyoMoon), + * part of the corvu project. Adapted for Solid 2.0 and solid-primitives conventions. + * MIT License, Copyright (c) Jasmin Noetzli + * + * https://github.com/corvudev/corvu/tree/main/packages/solid-prevent-scroll + * + * Part of this code is inspired by react-remove-scroll. + * MIT License, Copyright (c) Anton Korzunov + * https://github.com/theKashey/react-remove-scroll + */ + +import { createEffect, createSignal } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, type MaybeAccessor } from "@solid-primitives/utils"; + +function contains(wrapper: HTMLElement, target: HTMLElement): boolean { + if (wrapper.contains(target)) return true; + let current: HTMLElement | null = target; + while (current) { + if (current === wrapper) return true; + // @ts-expect-error: _$host is a SolidJS-internal property set on portal roots + current = current._$host ?? current.parentElement; + } + return false; +} + +type Axis = "x" | "y"; + +export type CreatePreventScrollProps = { + /** Element that is allowed to scroll. Wheel/touch events inside it are passed through. *Default = `null`* */ + element?: MaybeAccessor; + /** Whether scroll prevention is active. *Default = `true`* */ + enabled?: MaybeAccessor; + /** Hide the `` scrollbar while active. *Default = `true`* */ + hideScrollbar?: MaybeAccessor; + /** Add padding/margin to `` to compensate for the hidden scrollbar. *Default = `true`* */ + preventScrollbarShift?: MaybeAccessor; + /** Whether to use `padding` or `margin` for the scrollbar shift compensation. *Default = `"padding"`* */ + preventScrollbarShiftMode?: MaybeAccessor<"padding" | "margin">; + /** Restore `` scroll position via `window.scrollTo` when disabled to avoid layout shift. *Default = `true`* */ + restoreScrollPosition?: MaybeAccessor; + /** Allow two-finger pinch-zoom gestures. *Default = `false`* */ + allowPinchZoom?: MaybeAccessor; +}; + +// ─── Module-level stack ─────────────────────────────────────────────────────── +// Tracks active instances; only the topmost one installs wheel/touch handlers. + +const [preventScrollStack, setPreventScrollStack] = createSignal([], { + ownedWrite: true, +}); + +const isActive = (id: string): boolean => { + const stack = preventScrollStack(); + return stack.length > 0 && stack[stack.length - 1] === id; +}; + +// ─── Body style tracker ─────────────────────────────────────────────────────── +// Multiple nested instances share a key; the original styles are only restored +// once the last instance cleans up. + +type ActiveStyle = { + activeCount: number; + originalStyles: Partial; + properties: string[]; +}; + +const activeBodyStyles = new Map(); + +function applyBodyStyle( + key: string, + element: HTMLElement, + style: Partial, + properties: { key: string; value: string }[], +): () => void { + const originalStyles: Partial = {}; + for (const k in style) { + originalStyles[k] = element.style[k as keyof CSSStyleDeclaration] as string; + } + + const existing = activeBodyStyles.get(key); + if (existing) { + existing.activeCount++; + } else { + activeBodyStyles.set(key, { + activeCount: 1, + originalStyles, + properties: properties.map(p => p.key), + }); + } + + Object.assign(element.style, style); + for (const prop of properties) { + element.style.setProperty(prop.key, prop.value); + } + + return () => { + const active = activeBodyStyles.get(key); + if (!active) return; + if (active.activeCount !== 1) { + active.activeCount--; + return; + } + activeBodyStyles.delete(key); + + for (const [k, v] of Object.entries(active.originalStyles)) { + (element.style as any)[k] = v; + } + for (const prop of active.properties) { + element.style.removeProperty(prop); + } + if (element.style.length === 0) { + element.removeAttribute("style"); + } + }; +} + +// ─── Scroll helpers ─────────────────────────────────────────────────────────── + +function getScrollDimensions(element: HTMLElement, axis: Axis): [number, number, number] { + return axis === "x" + ? [element.clientWidth, element.scrollLeft, element.scrollWidth] + : [element.clientHeight, element.scrollTop, element.scrollHeight]; +} + +function isScrollContainer(element: HTMLElement, axis: Axis): boolean { + const styles = getComputedStyle(element); + const overflow = axis === "x" ? styles.overflowX : styles.overflowY; + return ( + overflow === "auto" || + overflow === "scroll" || + (element.tagName === "HTML" && overflow === "visible") + ); +} + +function getScrollAtLocation( + location: HTMLElement, + axis: Axis, + stopAt?: HTMLElement, +): [number, number] { + const directionFactor = + axis === "x" && window.getComputedStyle(location).direction === "rtl" ? -1 : 1; + + let currentElement: HTMLElement | undefined = location; + let availableScroll = 0; + let availableScrollTop = 0; + let wrapperReached = false; + + do { + const [clientSize, scrollOffset, scrollSize] = getScrollDimensions(currentElement, axis); + const scrolled = scrollSize - clientSize - directionFactor * scrollOffset; + + if ((scrollOffset !== 0 || scrolled !== 0) && isScrollContainer(currentElement, axis)) { + availableScroll += scrolled; + availableScrollTop += scrollOffset; + } + + if (currentElement === (stopAt ?? document.documentElement)) { + wrapperReached = true; + } else { + // @ts-expect-error: _$host is a SolidJS-internal property set on portal roots + currentElement = currentElement._$host ?? currentElement.parentElement; + } + } while (currentElement && !wrapperReached); + + return [availableScroll, availableScrollTop]; +} + +function wouldScroll( + target: HTMLElement, + axis: Axis, + delta: number, + wrapper: HTMLElement | undefined, +): boolean { + const targetInWrapper = wrapper && contains(wrapper, target); + const [availableScroll, availableScrollTop] = getScrollAtLocation( + target, + axis, + targetInWrapper ? wrapper : undefined, + ); + // Firefox can report availableScroll as 1 even when no scroll is possible. + if (delta > 0 && Math.abs(availableScroll) <= 1) return false; + if (delta < 0 && Math.abs(availableScrollTop) < 1) return false; + return true; +} + +// ─── Primitive ──────────────────────────────────────────────────────────────── + +let _nextId = 0; + +/** + * Prevents scrolling outside of the given element. + * + * Adapted from [solid-prevent-scroll](https://github.com/corvudev/corvu/tree/main/packages/solid-prevent-scroll) + * by Jasmin Noetzli (GiyoMoon), part of the corvu project. + */ +export const createPreventScroll = (props: CreatePreventScrollProps = {}): void => { + if (isServer) return; + + const id = String(_nextId++); + + let currentTouchStart: [number, number] = [0, 0]; + let currentTouchStartAxis: Axis | undefined; + let currentTouchStartDelta: number | undefined; + + // 1. Manage the active-instance stack. + createEffect( + () => ({ enabled: access(props.enabled) ?? true }), + ({ enabled }) => { + if (!enabled) return; + setPreventScrollStack(stack => [...stack, id]); + return () => setPreventScrollStack(stack => stack.filter(s => s !== id)); + }, + ); + + // 2. Apply body overflow styles. + createEffect( + () => ({ + enabled: access(props.enabled) ?? true, + hideScrollbar: access(props.hideScrollbar) ?? true, + preventScrollbarShift: access(props.preventScrollbarShift) ?? true, + preventScrollbarShiftMode: access(props.preventScrollbarShiftMode) ?? "padding", + restoreScrollPosition: access(props.restoreScrollPosition) ?? true, + }), + ({ + enabled, + hideScrollbar, + preventScrollbarShift, + preventScrollbarShiftMode, + restoreScrollPosition, + }) => { + if (!enabled || !hideScrollbar) return; + + const { body } = document; + const scrollbarWidth = window.innerWidth - body.offsetWidth; + const offsetTop = window.scrollY; + const offsetLeft = window.scrollX; + + const style: Partial = { overflow: "hidden" }; + const properties: { key: string; value: string }[] = []; + + if (preventScrollbarShift && scrollbarWidth > 0) { + if (preventScrollbarShiftMode === "padding") { + style.paddingRight = `calc(${window.getComputedStyle(body).paddingRight} + ${scrollbarWidth}px)`; + } else { + style.marginRight = `calc(${window.getComputedStyle(body).marginRight} + ${scrollbarWidth}px)`; + } + properties.push({ key: "--scrollbar-width", value: `${scrollbarWidth}px` }); + } + + const restoreStyle = applyBodyStyle("prevent-scroll", body, style, properties); + + return () => { + restoreStyle(); + if (restoreScrollPosition && scrollbarWidth > 0) { + window.scrollTo(offsetLeft, offsetTop); + } + }; + }, + ); + + // 3. Install wheel/touch event listeners (only when this is the topmost instance). + createEffect( + () => ({ + active: isActive(id), + enabled: access(props.enabled) ?? true, + wrapper: access(props.element) ?? undefined, + allowPinchZoom: access(props.allowPinchZoom) ?? false, + }), + ({ active, enabled, wrapper, allowPinchZoom }) => { + if (!active || !enabled) return; + + const maybePreventWheel = (event: WheelEvent) => { + const target = event.target as HTMLElement; + const delta: [number, number] = [event.deltaX, event.deltaY]; + const axis: Axis = Math.abs(delta[0]) > Math.abs(delta[1]) ? "x" : "y"; + const axisDelta = axis === "x" ? delta[0] : delta[1]; + + const shouldCancel = + wrapper && contains(wrapper, target) + ? !wouldScroll(target, axis, axisDelta, wrapper) + : true; + + if (shouldCancel && event.cancelable) event.preventDefault(); + }; + + const logTouchStart = (event: TouchEvent) => { + const touch = event.changedTouches[0]; + currentTouchStart = touch ? [touch.clientX, touch.clientY] : [0, 0]; + currentTouchStartAxis = undefined; + currentTouchStartDelta = undefined; + }; + + const maybePreventTouch = (event: TouchEvent) => { + const target = event.target as HTMLElement; + let shouldCancel: boolean; + + if (event.touches.length === 2) { + shouldCancel = !allowPinchZoom; + } else { + if (currentTouchStartAxis === undefined || currentTouchStartDelta === undefined) { + const touch = event.changedTouches[0]; + const curr: [number, number] = touch ? [touch.clientX, touch.clientY] : [0, 0]; + const delta: [number, number] = [ + currentTouchStart[0] - curr[0], + currentTouchStart[1] - curr[1], + ]; + const axis: Axis = Math.abs(delta[0]) > Math.abs(delta[1]) ? "x" : "y"; + currentTouchStartAxis = axis; + currentTouchStartDelta = axis === "x" ? delta[0] : delta[1]; + } + + if ((target as HTMLInputElement).type === "range") { + shouldCancel = false; + } else { + const wouldResultInScroll = wouldScroll( + target, + currentTouchStartAxis, + currentTouchStartDelta, + wrapper, + ); + shouldCancel = wrapper && contains(wrapper, target) ? !wouldResultInScroll : true; + } + } + + if (shouldCancel && event.cancelable) event.preventDefault(); + }; + + document.addEventListener("wheel", maybePreventWheel, { passive: false }); + document.addEventListener("touchstart", logTouchStart, { passive: false }); + document.addEventListener("touchmove", maybePreventTouch, { passive: false }); + + return () => { + document.removeEventListener("wheel", maybePreventWheel); + document.removeEventListener("touchstart", logTouchStart); + document.removeEventListener("touchmove", maybePreventTouch); + }; + }, + ); +}; diff --git a/packages/scroll/src/scrollPosition.ts b/packages/scroll/src/scrollPosition.ts new file mode 100644 index 000000000..b01716fca --- /dev/null +++ b/packages/scroll/src/scrollPosition.ts @@ -0,0 +1,114 @@ +import { type Accessor, createSignal, onSettled, sharedConfig } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { createEventListener } from "@solid-primitives/event-listener"; +import { createHydratableSingletonRoot } from "@solid-primitives/rootless"; +import { createDerivedStaticStore } from "@solid-primitives/static-store"; + +export function getScrollParent(node: Element | undefined): Element { + if (isServer) { + return {} as Element; + } + while (node && !isScrollable(node)) { + node = node.parentElement ?? undefined; + } + + return node || document.scrollingElement || document.documentElement; +} + +export function isScrollable(node: Element): boolean { + if (isServer) { + return false; + } + const style = window.getComputedStyle(node); + return /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY); +} + +export type Position = { + x: number; + y: number; +}; + +const FALLBACK_SCROLL_POSITION = { x: 0, y: 0 } as const satisfies Position; + +/** + * Get an `{ x: number, y: number }` object of element/window scroll position. + */ +export function getScrollPosition(target: Element | Window | undefined): Position { + if (isServer || !target) { + return { ...FALLBACK_SCROLL_POSITION }; + } + if (target instanceof Window) + return { + x: target.scrollX, + y: target.scrollY, + }; + return { + x: target.scrollLeft, + y: target.scrollTop, + }; +} + +/** + * Reactive primitive providing a store-like object with current scroll position of specified target. + * @param target element/window to listen to scroll events. can be a reactive singal. + * @returns a store-like reactive object `{ x: number, y: number }` of current scroll position of {@link target} + * @example + * // target will be window by default + * const windowScroll = createScrollPosition(); + * + * createEffect(() => { + * // returned object is a reactive store-like structure + * windowScroll.x; // => number + * windowScroll.y; // => number + * }); + */ +export function createScrollPosition( + target?: Accessor | Element | Window, +): Readonly { + if (isServer) { + return FALLBACK_SCROLL_POSITION; + } + + target = target || window; + + const isFn = typeof target === "function"; + const isHydrating = sharedConfig.hydrating; + + const getPos = (): Position => + getScrollPosition( + isFn ? (target as Accessor)() : (target as Element | Window), + ); + + // Plain integer counter — avoids Solid 2.0 createSignal(fn) derived-signal semantics. + // ownedWrite allows writes from DOM event handlers inside reactive scopes. + let tick = 1; + const [version, setVersion] = createSignal(0, { ownedWrite: true }); + const trigger = () => void setVersion(tick++); + + const pos = createDerivedStaticStore(() => { + const v = version(); + return isHydrating && v === 0 ? { ...FALLBACK_SCROLL_POSITION } : getPos(); + }); + + // Refs aren't populated until mount; also refreshes position post-hydration. + if (isHydrating || isFn) onSettled(trigger); + + createEventListener(target, "scroll", trigger, { passive: true }); + + return pos; +} + +/** + * Returns a reactive object with current window scroll position. + * + * This is a [singleton root](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot) primitive. + * + * @example + * const scroll = useWindowScrollPosition(); + * createEffect(() => { + * console.log(scroll.x, scroll.y) + * }) + */ +export const useWindowScrollPosition = /*#__PURE__*/ createHydratableSingletonRoot(() => + createScrollPosition(isServer ? () => undefined : window), +); diff --git a/packages/scroll/test/index.test.ts b/packages/scroll/test/index.test.ts index 7c8534a46..5e028e5bf 100644 --- a/packages/scroll/test/index.test.ts +++ b/packages/scroll/test/index.test.ts @@ -1,7 +1,7 @@ -import { createComputed, createRoot, createSignal } from "solid-js"; -import { describe, expect, it } from "vitest"; +import { createRoot, createSignal, flush } from "solid-js"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { createScrollPosition, getScrollPosition } from "../src/index.js"; +import { createScrollPosition, getScrollPosition, createPreventScroll } from "../src/index.js"; describe("getScrollPosition", () => { it("no target returns null", () => { @@ -20,28 +20,25 @@ describe("getScrollPosition", () => { describe("createScrollPosition", () => { it("will observe scroll events", () => createRoot(dispose => { - const expectedX = [0, 100, 42]; - const actualX: number[] = []; - const expectedY = [0, 34, 11]; - const actualY: number[] = []; - const target = document.createElement("div"); - const scroll = createScrollPosition(target); - createComputed(() => { - actualX.push(scroll.x); - actualY.push(scroll.y); - }); + expect(scroll.x).toBe(0); + expect(scroll.y).toBe(0); Object.assign(target, { scrollTop: 34, scrollLeft: 100 }); target.dispatchEvent(new Event("scroll")); + flush(); + + expect(scroll.x).toBe(100); + expect(scroll.y).toBe(34); Object.assign(target, { scrollTop: 11, scrollLeft: 42 }); target.dispatchEvent(new Event("scroll")); + flush(); - expect(actualX).toEqual(expectedX); - expect(actualY).toEqual(expectedY); + expect(scroll.x).toBe(42); + expect(scroll.y).toBe(11); dispose(); })); @@ -54,17 +51,219 @@ describe("createScrollPosition", () => { const div2 = document.createElement("div"); Object.assign(div2, { scrollTop: 11, scrollLeft: 42 }); - const [target, setTarget] = createSignal(div1); + const [target, setTarget] = createSignal(div1, { ownedWrite: true }); const scroll = createScrollPosition(target); expect(scroll).toEqual({ x: 100, y: 34 }); setTarget(div2); + flush(); expect(scroll).toEqual({ x: 42, y: 11 }); setTarget(); + flush(); expect(scroll).toEqual({ x: 0, y: 0 }); dispose(); })); }); + +describe("createPreventScroll", () => { + beforeEach(() => { + document.body.removeAttribute("style"); + vi.stubGlobal("scrollTo", vi.fn()); + }); + + afterEach(() => { + document.body.removeAttribute("style"); + vi.unstubAllGlobals(); + }); + + it("sets overflow:hidden on body when enabled", () => + createRoot(dispose => { + createPreventScroll(); + flush(); + + expect(document.body.style.overflow).toBe("hidden"); + + dispose(); + })); + + it("restores body style on cleanup", () => + createRoot(dispose => { + createPreventScroll(); + flush(); + + expect(document.body.style.overflow).toBe("hidden"); + + dispose(); + expect(document.body.style.overflow).toBe(""); + })); + + it("does not set overflow when hideScrollbar is false", () => + createRoot(dispose => { + createPreventScroll({ hideScrollbar: false }); + flush(); + + expect(document.body.style.overflow).not.toBe("hidden"); + + dispose(); + })); + + it("does not apply styles when enabled is false", () => + createRoot(dispose => { + createPreventScroll({ enabled: false }); + flush(); + + expect(document.body.style.overflow).not.toBe("hidden"); + + dispose(); + })); + + it("reacts to enabled signal", () => + createRoot(dispose => { + const [enabled, setEnabled] = createSignal(false, { ownedWrite: true }); + createPreventScroll({ enabled }); + flush(); + + expect(document.body.style.overflow).not.toBe("hidden"); + + setEnabled(true); + flush(); + expect(document.body.style.overflow).toBe("hidden"); + + setEnabled(false); + flush(); + expect(document.body.style.overflow).not.toBe("hidden"); + + dispose(); + })); + + it("installs wheel event listener when enabled", () => + createRoot(dispose => { + const addSpy = vi.spyOn(document, "addEventListener"); + + createPreventScroll(); + flush(); + + const wheelCalls = addSpy.mock.calls.filter(([event]) => event === "wheel"); + expect(wheelCalls.length).toBeGreaterThan(0); + + addSpy.mockRestore(); + dispose(); + })); + + it("removes wheel event listener on cleanup", () => + createRoot(dispose => { + const removeSpy = vi.spyOn(document, "removeEventListener"); + + createPreventScroll(); + flush(); + dispose(); + + const wheelCalls = removeSpy.mock.calls.filter(([event]) => event === "wheel"); + expect(wheelCalls.length).toBeGreaterThan(0); + + removeSpy.mockRestore(); + })); + + it("prevents wheel events outside element", () => + createRoot(dispose => { + const container = document.createElement("div"); + document.body.appendChild(container); + + createPreventScroll({ element: container }); + flush(); + + const outside = document.createElement("div"); + document.body.appendChild(outside); + + const event = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: 100, + target: outside, + }); + Object.defineProperty(event, "target", { value: outside, configurable: true }); + document.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + + document.body.removeChild(container); + document.body.removeChild(outside); + dispose(); + })); + + it("accepts a signal ref as element — wrapper updates reactively", () => + createRoot(dispose => { + const container = document.createElement("div"); + const [ref, setRef] = createSignal(undefined, { + ownedWrite: true, + }); + + const addSpy = vi.spyOn(document, "addEventListener"); + + createPreventScroll({ element: ref }); + flush(); + + const wheelCallsAfterInit = addSpy.mock.calls.filter(([e]) => e === "wheel").length; + expect(wheelCallsAfterInit).toBe(1); + + // Changing the signal causes the effect to re-run and reinstall listeners with the new wrapper + setRef(container); + flush(); + + const wheelCallsAfterUpdate = addSpy.mock.calls.filter(([e]) => e === "wheel").length; + expect(wheelCallsAfterUpdate).toBe(2); + + addSpy.mockRestore(); + dispose(); + })); + + it("stacks multiple instances — only top one handles events", () => + createRoot(dispose1 => { + createPreventScroll(); + flush(); + + const addSpy = vi.spyOn(document, "addEventListener"); + + createRoot(dispose2 => { + createPreventScroll(); + flush(); + + // Both instances active; second is on top + expect(document.body.style.overflow).toBe("hidden"); + + dispose2(); + }); + + flush(); + // First instance should still keep body locked + expect(document.body.style.overflow).toBe("hidden"); + + addSpy.mockRestore(); + dispose1(); + })); + + it("restores body style only after all stacked instances clean up", () => + createRoot(dispose1 => { + createPreventScroll(); + flush(); + + const cleanup2 = createRoot(dispose2 => { + createPreventScroll(); + flush(); + return dispose2; + }); + + expect(document.body.style.overflow).toBe("hidden"); + + cleanup2(); + flush(); + // First instance still active + expect(document.body.style.overflow).toBe("hidden"); + + dispose1(); + expect(document.body.style.overflow).toBe(""); + })); +}); diff --git a/packages/scroll/test/server.test.ts b/packages/scroll/test/server.test.ts index 8427402c2..c8f509e41 100644 --- a/packages/scroll/test/server.test.ts +++ b/packages/scroll/test/server.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { createScrollPosition, getScrollPosition, useWindowScrollPosition } from "../src/index.js"; +import { + createScrollPosition, + createPreventScroll, + getScrollPosition, + useWindowScrollPosition, +} from "../src/index.js"; describe("getScrollPosition", () => { it("returns null", () => { @@ -18,3 +23,10 @@ describe("useWindowScrollPosition", () => { expect(useWindowScrollPosition()).toEqual({ x: 0, y: 0 }); }); }); + +describe("createPreventScroll", () => { + it("does nothing on the server", () => { + expect(() => createPreventScroll()).not.toThrow(); + expect(() => createPreventScroll({ enabled: true, hideScrollbar: true })).not.toThrow(); + }); +}); diff --git a/packages/scroll/tsconfig.json b/packages/scroll/tsconfig.json index f696f1546..536eac32d 100644 --- a/packages/scroll/tsconfig.json +++ b/packages/scroll/tsconfig.json @@ -14,6 +14,9 @@ }, { "path": "../static-store" + }, + { + "path": "../utils" } ], "include": [ diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index df4a9c2a6..8929b6345 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -406,6 +406,23 @@ export function pipe(a: (raw: string) => A, b: (a: A) => B): (raw: string) return (raw: string): B => b(a(raw)); } +// ─── DOM helpers ───────────────────────────────────────────────────────────── + +/** + * Check if a wrapper element contains a target element. + * Portal-aware: follows SolidJS `_$host` links so elements rendered inside + * a `` are correctly treated as children of the portal's anchor. + */ +export const contains = (wrapper: HTMLElement, target: HTMLElement): boolean => { + if (wrapper.contains(target)) return true; + let current: HTMLElement | null = target; + while (current) { + if (current === wrapper) return true; + // @ts-expect-error: _$host is a SolidJS-internal property set on portal roots + current = current._$host ?? current.parentElement; + } + return false; +}; /** * Wraps a setter function of any signal or store * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 696c52a39..80f5f00e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -868,10 +868,16 @@ importers: '@solid-primitives/static-store': specifier: workspace:^ version: link:../static-store + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.12 + version: 2.0.0-beta.12(solid-js@2.0.0-beta.12) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.12 + version: 2.0.0-beta.12 packages/selection: devDependencies: