diff --git a/package.json b/package.json index 78aa4bd..8327ae3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-atom-trigger", - "version": "2.0.7", + "version": "2.0.8", "description": "Geometry-based scroll trigger for React with precise enter/leave control. A modern alternative to react-waypoint.", "keywords": [ "intersection", @@ -26,6 +26,7 @@ "MIGRATION.md" ], "type": "module", + "sideEffects": false, "main": "./lib/index.js", "module": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/src/AtomTrigger.childMode.helpers.test.ts b/src/AtomTrigger.childMode.helpers.test.ts index bd0bb2e..da80057 100644 --- a/src/AtomTrigger.childMode.helpers.test.ts +++ b/src/AtomTrigger.childMode.helpers.test.ts @@ -10,11 +10,14 @@ import { } from './AtomTrigger.childMode'; import { fragmentChildWarning, + getWarningMessage, invalidChildCountWarning, invalidChildElementWarning, unsupportedChildRefWarning, } from './AtomTrigger.warnings'; +const initialNodeEnv = process.env.NODE_ENV; + function stubProcess(processValue: Partial>): void { vi.stubGlobal('process', processValue as unknown as NodeJS.Process); } @@ -46,7 +49,13 @@ function createBrokenChildElement( describe('AtomTrigger child mode helpers', () => { afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); + if (initialNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = initialNodeEnv; + } }); describe('assignRef', () => { @@ -139,6 +148,31 @@ describe('AtomTrigger child mode helpers', () => { }); describe('useObservedChildNode', () => { + it('keeps non-DOM child ref warnings out of non-development runtimes', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let attachObservedChildRef: ((value: unknown) => void) | undefined; + process.env.NODE_ENV = 'production'; + + function Harness() { + const binding = useObservedChildNode({ + originalChildRef: undefined, + hasObservedChild: true, + invalidChildWarning: null, + shouldWarnAboutMissingDomRef: true, + }); + attachObservedChildRef = binding.attachObservedChildRef; + return null; + } + + render(React.createElement(Harness)); + + act(() => { + attachObservedChildRef?.({ current: document.createElement('div') }); + }); + + expect(warn).not.toHaveBeenCalled(); + }); + it('keeps the delayed missing-dom warning silent when a DOM ref appears before the timer callback runs', () => { vi.useFakeTimers(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -168,7 +202,63 @@ describe('AtomTrigger child mode helpers', () => { vi.advanceTimersByTime(16); }); - expect(warn).not.toHaveBeenCalledWith(unsupportedChildRefWarning); + expect(warn).not.toHaveBeenCalledWith(getWarningMessage(unsupportedChildRefWarning)); + }); + + it('keeps the observed child node stable when the same DOM ref is attached again', () => { + const node = document.createElement('div'); + let attachObservedChildRef: ((value: unknown) => void) | undefined; + let observedChildNode: Element | null = null; + + function Harness() { + const binding = useObservedChildNode({ + originalChildRef: undefined, + hasObservedChild: true, + invalidChildWarning: null, + shouldWarnAboutMissingDomRef: false, + }); + attachObservedChildRef = binding.attachObservedChildRef; + observedChildNode = binding.childNode; + return null; + } + + render(React.createElement(Harness)); + + act(() => { + attachObservedChildRef?.(node); + }); + + expect(observedChildNode).toBe(node); + + act(() => { + attachObservedChildRef?.(node); + }); + + expect(observedChildNode).toBe(node); + }); + + it('keeps delayed missing-dom warnings out of non-development runtimes', () => { + vi.useFakeTimers(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + process.env.NODE_ENV = 'production'; + + function Harness() { + useObservedChildNode({ + originalChildRef: undefined, + hasObservedChild: true, + invalidChildWarning: null, + shouldWarnAboutMissingDomRef: true, + }); + return null; + } + + render(React.createElement(Harness)); + + act(() => { + vi.advanceTimersByTime(16); + }); + + expect(warn).not.toHaveBeenCalledWith(getWarningMessage(unsupportedChildRefWarning)); }); }); }); diff --git a/src/AtomTrigger.childMode.test.tsx b/src/AtomTrigger.childMode.test.tsx index 54434f4..8ab04a5 100644 --- a/src/AtomTrigger.childMode.test.tsx +++ b/src/AtomTrigger.childMode.test.tsx @@ -10,7 +10,11 @@ import { setRect, setupChildRootHarness, } from './AtomTrigger.testUtils'; -import { nonDomChildRefWarning, unsupportedChildRefWarning } from './AtomTrigger.warnings'; +import { + getWarningMessage, + nonDomChildRefWarning, + unsupportedChildRefWarning, +} from './AtomTrigger.warnings'; beforeEach(() => { prepareDomTestRun(); @@ -100,7 +104,7 @@ describe('AtomTrigger child mode', () => { ); expect(view.getByTestId('imperative-handle-child')).toBeTruthy(); - expect(warn).toHaveBeenCalledWith(nonDomChildRefWarning); + expect(warn).toHaveBeenCalledWith(getWarningMessage(nonDomChildRefWarning)); expect(error).not.toHaveBeenCalled(); }); @@ -166,7 +170,11 @@ describe('AtomTrigger child mode', () => { vi.advanceTimersByTime(16); }); - expect(warn.mock.calls.some(([message]) => message === unsupportedChildRefWarning)).toBe(false); + expect( + warn.mock.calls.some( + ([message]) => message === getWarningMessage(unsupportedChildRefWarning), + ), + ).toBe(false); }); it('warns and ignores className in child mode', () => { diff --git a/src/AtomTrigger.childMode.ts b/src/AtomTrigger.childMode.ts index 0eb3985..01a3abc 100644 --- a/src/AtomTrigger.childMode.ts +++ b/src/AtomTrigger.childMode.ts @@ -1,10 +1,12 @@ import React from 'react'; import { fragmentChildWarning, + getWarningMessage, invalidChildCountWarning, invalidChildElementWarning, nonDomChildRefWarning, unsupportedChildRefWarning, + type AtomTriggerWarning, warnOnce, } from './AtomTrigger.warnings'; import { isDomElementLike } from './AtomTrigger.runtime'; @@ -68,7 +70,7 @@ export function getInvalidChildWarning( usesChildObservation: boolean, childCount: number, singleChildElement: React.ReactElement | null, -): string | null { +): AtomTriggerWarning | null { if (!usesChildObservation) { return null; } @@ -96,7 +98,7 @@ export function useObservedChildNode({ }: { originalChildRef: React.Ref | undefined; hasObservedChild: boolean; - invalidChildWarning: string | null; + invalidChildWarning: AtomTriggerWarning | null; shouldWarnAboutMissingDomRef: boolean; }): ObservedChildBinding { const [childNode, setChildNode] = React.useState(null); @@ -123,7 +125,9 @@ export function useObservedChildNode({ } clearObservedChildNode(); - warnOnce(nonDomChildRefWarning); + if (process.env.NODE_ENV === 'development') { + warnOnce(getWarningMessage(nonDomChildRefWarning)); + } }, [clearObservedChildNode, originalChildRef], ); @@ -141,7 +145,9 @@ export function useObservedChildNode({ return; } - warnOnce(unsupportedChildRefWarning); + if (process.env.NODE_ENV === 'development') { + warnOnce(getWarningMessage(unsupportedChildRefWarning)); + } }, missingDomRefWarningDelayMs); return () => { diff --git a/src/AtomTrigger.geometry.test.ts b/src/AtomTrigger.geometry.test.ts index ae136aa..3924011 100644 --- a/src/AtomTrigger.geometry.test.ts +++ b/src/AtomTrigger.geometry.test.ts @@ -57,6 +57,17 @@ describe('AtomTrigger geometry helpers', () => { '[react-atom-trigger] Invalid rootMargin array [1,2,null,4]. Use exactly four finite numbers: [top, right, bottom, left]. Falling back to 0px.', ); }); + + it('keeps invalid array warnings out of non-development runtimes', () => { + process.env.NODE_ENV = 'production'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(normalizeRootMargin([1, 2, Number.NaN, 4] as [number, number, number, number])).toBe( + '0px', + ); + + expect(warn).not.toHaveBeenCalled(); + }); }); describe('getEffectiveRootBounds', () => { @@ -93,6 +104,15 @@ describe('AtomTrigger geometry helpers', () => { '[react-atom-trigger] Invalid rootMargin "1px 2px 3px 4px 5px". Use 1 to 4 values in IntersectionObserver order. Falling back to 0px.', ); }); + + it('keeps invalid rootMargin token warnings out of non-development runtimes', () => { + process.env.NODE_ENV = 'production'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(getEffectiveRootBounds(base, '1px auto')).toEqual(new DOMRect(10, 19, 200, 101)); + expect(getEffectiveRootBounds(base, '1px 2px 3px 4px 5px')).toEqual(base); + expect(warn).not.toHaveBeenCalled(); + }); }); describe('intersection math', () => { @@ -162,6 +182,17 @@ describe('AtomTrigger geometry helpers', () => { '[react-atom-trigger] `threshold` should be between 0 and 1. Values are clamped.', ); }); + + it('keeps invalid threshold warnings out of non-development runtimes', () => { + process.env.NODE_ENV = 'production'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(normalizeThreshold([Number.NaN, 0.25])).toBe(0.25); + expect(normalizeThreshold([Number.NaN])).toBe(0); + expect(normalizeThreshold('nope')).toBe(0); + expect(normalizeThreshold(1.5)).toBe(1); + expect(warn).not.toHaveBeenCalled(); + }); }); it('detects movement direction across stationary, vertical, and horizontal changes', () => { diff --git a/src/AtomTrigger.geometry.ts b/src/AtomTrigger.geometry.ts index 5f0365e..66be236 100644 --- a/src/AtomTrigger.geometry.ts +++ b/src/AtomTrigger.geometry.ts @@ -36,28 +36,28 @@ export function normalizeRootMargin( return '0px'; } - warnOnce( - `[react-atom-trigger] Invalid rootMargin array ${JSON.stringify(rootMargin)}. Use exactly four finite numbers: [top, right, bottom, left]. Falling back to 0px.`, - ); + if (process.env.NODE_ENV === 'development') { + warnOnce( + `[react-atom-trigger] Invalid rootMargin array ${JSON.stringify(rootMargin)}. Use exactly four finite numbers: [top, right, bottom, left]. Falling back to 0px.`, + ); + } return '0px'; } function parseMarginPart(part: string, axisSize: number): number { const value = part.trim(); - if (!value) { - return 0; - } - if (/^[+-]?0(?:\.0+)?$/.test(value)) { return 0; } const match = value.match(/^([+-]?(?:\d+\.?\d*|\.\d+))(px|%)$/); if (!match) { - warnOnce( - `[react-atom-trigger] Invalid rootMargin token "${value}". Use px, % or 0. Falling back to 0px.`, - ); + if (process.env.NODE_ENV === 'development') { + warnOnce( + `[react-atom-trigger] Invalid rootMargin token "${value}". Use px, % or 0. Falling back to 0px.`, + ); + } return 0; } @@ -78,9 +78,11 @@ function resolveRootMargin( ): RootMarginValues { const parts = rootMargin.trim().split(/\s+/).filter(Boolean); if (parts.length > 4) { - warnOnce( - `[react-atom-trigger] Invalid rootMargin "${rootMargin}". Use 1 to 4 values in IntersectionObserver order. Falling back to 0px.`, - ); + if (process.env.NODE_ENV === 'development') { + warnOnce( + `[react-atom-trigger] Invalid rootMargin "${rootMargin}". Use 1 to 4 values in IntersectionObserver order. Falling back to 0px.`, + ); + } return { top: 0, @@ -159,9 +161,11 @@ function clampThreshold(value: number): number { export function normalizeThreshold(threshold: unknown): number { if (Array.isArray(threshold)) { - warnOnce( - '[react-atom-trigger] `threshold` expects a single number in v2. Using the first finite numeric entry.', - ); + if (process.env.NODE_ENV === 'development') { + warnOnce( + '[react-atom-trigger] `threshold` expects a single number in v2. Using the first finite numeric entry.', + ); + } const firstNumeric = threshold.find( (value): value is number => typeof value === 'number' && Number.isFinite(value), ); @@ -173,14 +177,18 @@ export function normalizeThreshold(threshold: unknown): number { } if (typeof threshold !== 'number' || !Number.isFinite(threshold)) { - warnOnce( - '[react-atom-trigger] `threshold` must be a finite number between 0 and 1. Falling back to 0.', - ); + if (process.env.NODE_ENV === 'development') { + warnOnce( + '[react-atom-trigger] `threshold` must be a finite number between 0 and 1. Falling back to 0.', + ); + } return 0; } if (threshold < 0 || threshold > 1) { - warnOnce('[react-atom-trigger] `threshold` should be between 0 and 1. Values are clamped.'); + if (process.env.NODE_ENV === 'development') { + warnOnce('[react-atom-trigger] `threshold` should be between 0 and 1. Values are clamped.'); + } } return clampThreshold(threshold); diff --git a/src/AtomTrigger.observation.ts b/src/AtomTrigger.observation.ts index 9154211..e2a777e 100644 --- a/src/AtomTrigger.observation.ts +++ b/src/AtomTrigger.observation.ts @@ -44,15 +44,6 @@ function createRegistration( }; } -function applyObservationCallbacks( - registration: SentinelRegistration, - callbacks: ObservationCallbacks, -): void { - registration.onEnter = callbacks.onEnter; - registration.onLeave = callbacks.onLeave; - registration.onEvent = callbacks.onEvent; -} - function clearObservationBinding(controller: ObservationController): void { controller.dispose?.(); controller.dispose = null; @@ -74,7 +65,9 @@ export function updateObservationCallbacks( controller: ObservationController, callbacks: ObservationCallbacks, ): void { - applyObservationCallbacks(controller.registration, callbacks); + controller.registration.onEnter = callbacks.onEnter; + controller.registration.onLeave = callbacks.onLeave; + controller.registration.onEvent = callbacks.onEvent; } export function reconcileObservationBinding( diff --git a/src/AtomTrigger.root.ts b/src/AtomTrigger.root.ts index 0f22dde..a8332e7 100644 --- a/src/AtomTrigger.root.ts +++ b/src/AtomTrigger.root.ts @@ -1,6 +1,11 @@ import React from 'react'; import { isDomElementLike } from './AtomTrigger.runtime'; -import { invalidRootRefWarning, invalidRootWarning, warnOnce } from './AtomTrigger.warnings'; +import { + getWarningMessage, + invalidRootRefWarning, + invalidRootWarning, + warnOnce, +} from './AtomTrigger.warnings'; export type SchedulerTarget = Window | Element; @@ -23,7 +28,9 @@ function resolveExplicitRootTarget( return target; } - warnOnce(warningMessage); + if (process.env.NODE_ENV === 'development') { + warnOnce(getWarningMessage(warningMessage)); + } return null; } diff --git a/src/AtomTrigger.scheduler.test.ts b/src/AtomTrigger.scheduler.test.ts index 25dcb3b..c3a660a 100644 --- a/src/AtomTrigger.scheduler.test.ts +++ b/src/AtomTrigger.scheduler.test.ts @@ -4,7 +4,11 @@ import { registerSentinel, type SentinelRegistration } from './AtomTrigger.sched import { resolveSchedulerTarget } from './AtomTrigger.root'; import { resetObservationState } from './AtomTrigger.sampling'; import { finishDomTestRun, prepareDomTestRun, setNodeEnv, setRect } from './AtomTrigger.testUtils'; -import { invalidRootRefWarning, invalidRootWarning } from './AtomTrigger.warnings'; +import { + getWarningMessage, + invalidRootRefWarning, + invalidRootWarning, +} from './AtomTrigger.warnings'; function createRegistration( node: Element, @@ -127,8 +131,18 @@ describe('AtomTrigger scheduler helpers', () => { expect(resolveSchedulerTarget({ kind: 'root', target: pseudoRoot })).toBeNull(); expect(resolveSchedulerTarget({ kind: 'rootRef', target: pseudoRoot })).toBeNull(); - expect(warn).toHaveBeenCalledWith(invalidRootWarning); - expect(warn).toHaveBeenCalledWith(invalidRootRefWarning); + expect(warn).toHaveBeenCalledWith(getWarningMessage(invalidRootWarning)); + expect(warn).toHaveBeenCalledWith(getWarningMessage(invalidRootRefWarning)); + }); + + it('keeps invalid explicit root warnings out of non-development runtimes', () => { + setNodeEnv('production'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pseudoRoot = { nodeType: 1 } as unknown as HTMLDivElement; + + expect(resolveSchedulerTarget({ kind: 'root', target: pseudoRoot })).toBeNull(); + expect(resolveSchedulerTarget({ kind: 'rootRef', target: pseudoRoot })).toBeNull(); + expect(warn).not.toHaveBeenCalled(); }); it('returns null when the runtime has no viewport target', () => { diff --git a/src/AtomTrigger.tsx b/src/AtomTrigger.tsx index 21d51c3..47e13bc 100644 --- a/src/AtomTrigger.tsx +++ b/src/AtomTrigger.tsx @@ -22,6 +22,7 @@ import { import { childModeClassNameWarning, conflictingOnceModesWarning, + getWarningMessage, warnOnce, } from './AtomTrigger.warnings'; @@ -72,20 +73,20 @@ const AtomTrigger: React.FC = ({ }); React.useEffect(() => { - if (hasObservedChild && className) { - warnOnce(childModeClassNameWarning); + if (process.env.NODE_ENV === 'development' && hasObservedChild && className) { + warnOnce(getWarningMessage(childModeClassNameWarning)); } }, [className, hasObservedChild]); React.useEffect(() => { - if (invalidChildWarning) { - warnOnce(invalidChildWarning); + if (process.env.NODE_ENV === 'development' && invalidChildWarning) { + warnOnce(getWarningMessage(invalidChildWarning)); } }, [invalidChildWarning]); React.useEffect(() => { - if (once && oncePerDirection) { - warnOnce(conflictingOnceModesWarning); + if (process.env.NODE_ENV === 'development' && once && oncePerDirection) { + warnOnce(getWarningMessage(conflictingOnceModesWarning)); } }, [once, oncePerDirection]); diff --git a/src/AtomTrigger.warnings.ts b/src/AtomTrigger.warnings.ts index e77bbf3..72430e5 100644 --- a/src/AtomTrigger.warnings.ts +++ b/src/AtomTrigger.warnings.ts @@ -1,30 +1,55 @@ const devWarnings = new Set(); -export const invalidChildCountWarning = - '[react-atom-trigger] Child mode expects exactly one top-level React element. Observation is disabled for this render.'; - -export const invalidChildElementWarning = - '[react-atom-trigger] Child mode expects a React element child. Observation is disabled for this render.'; - -export const unsupportedChildRefWarning = - '[react-atom-trigger] Child mode expects a DOM element or a component that forwards its ref to a DOM element. Observation is disabled for this render.'; - -export const fragmentChildWarning = - '[react-atom-trigger] Child mode does not support React.Fragment. Wrap the content in a single DOM element. Observation is disabled for this render.'; - -export const nonDomChildRefWarning = - '[react-atom-trigger] Child mode requires the child ref to resolve to a DOM element. Observation is disabled for this render.'; - -export const childModeClassNameWarning = - '[react-atom-trigger] `className` only applies to the internal sentinel. In child mode, style the child element directly.'; - -export const conflictingOnceModesWarning = - '[react-atom-trigger] `once` and `oncePerDirection` were both provided. `once` takes precedence.'; - -export const invalidRootWarning = - '[react-atom-trigger] `root` must be a real DOM element when provided. Observation is paused until it is.'; - -export const invalidRootRefWarning = - '[react-atom-trigger] `rootRef.current` must resolve to a real DOM element. Observation is paused until it does.'; +export type AtomTriggerWarning = + | 'invalidChildCount' + | 'invalidChildElement' + | 'unsupportedChildRef' + | 'fragmentChild' + | 'nonDomChildRef' + | 'childModeClassName' + | 'conflictingOnceModes' + | 'invalidRoot' + | 'invalidRootRef'; + +export const invalidChildCountWarning = 'invalidChildCount' satisfies AtomTriggerWarning; + +export const invalidChildElementWarning = 'invalidChildElement' satisfies AtomTriggerWarning; + +export const unsupportedChildRefWarning = 'unsupportedChildRef' satisfies AtomTriggerWarning; + +export const fragmentChildWarning = 'fragmentChild' satisfies AtomTriggerWarning; + +export const nonDomChildRefWarning = 'nonDomChildRef' satisfies AtomTriggerWarning; + +export const childModeClassNameWarning = 'childModeClassName' satisfies AtomTriggerWarning; + +export const conflictingOnceModesWarning = 'conflictingOnceModes' satisfies AtomTriggerWarning; + +export const invalidRootWarning = 'invalidRoot' satisfies AtomTriggerWarning; + +export const invalidRootRefWarning = 'invalidRootRef' satisfies AtomTriggerWarning; + +export function getWarningMessage(warning: AtomTriggerWarning): string { + switch (warning) { + case 'invalidChildCount': + return '[react-atom-trigger] Child mode expects exactly one top-level React element. Observation is disabled for this render.'; + case 'invalidChildElement': + return '[react-atom-trigger] Child mode expects a React element child. Observation is disabled for this render.'; + case 'unsupportedChildRef': + return '[react-atom-trigger] Child mode expects a DOM element or a component that forwards its ref to a DOM element. Observation is disabled for this render.'; + case 'fragmentChild': + return '[react-atom-trigger] Child mode does not support React.Fragment. Wrap the content in a single DOM element. Observation is disabled for this render.'; + case 'nonDomChildRef': + return '[react-atom-trigger] Child mode requires the child ref to resolve to a DOM element. Observation is disabled for this render.'; + case 'childModeClassName': + return '[react-atom-trigger] `className` only applies to the internal sentinel. In child mode, style the child element directly.'; + case 'conflictingOnceModes': + return '[react-atom-trigger] `once` and `oncePerDirection` were both provided. `once` takes precedence.'; + case 'invalidRoot': + return '[react-atom-trigger] `root` must be a real DOM element when provided. Observation is paused until it is.'; + case 'invalidRootRef': + return '[react-atom-trigger] `rootRef.current` must resolve to a real DOM element. Observation is paused until it does.'; + } +} function getKnownNodeEnv(): 'development' | 'production' | null { if (typeof process === 'undefined' || !process.env) { @@ -48,7 +73,7 @@ export function __isDevelopmentRuntimeForTests(overrides?: { } export function warnOnce(message: string): void { - if (!__isDevelopmentRuntimeForTests()) { + if (getKnownNodeEnv() !== 'development') { return; } diff --git a/src/stories/AtomTriggerChildModeInteractions.stories.tsx b/src/stories/AtomTriggerChildModeInteractions.stories.tsx index c7a50c8..4a0e481 100644 --- a/src/stories/AtomTriggerChildModeInteractions.stories.tsx +++ b/src/stories/AtomTriggerChildModeInteractions.stories.tsx @@ -4,7 +4,7 @@ import { expect, waitFor } from 'storybook/test'; import { ChildModeInteractionHarness, type ChildModeInteractionHarnessProps, -} from './components/DeterministicInteractionHarness'; +} from './components/InteractionHarness'; import { atomTriggerActionArgs, atomTriggerArgTypes } from './storybookArgs'; const meta: Meta = { diff --git a/src/stories/AtomTriggerInteractions.stories.tsx b/src/stories/AtomTriggerInteractions.stories.tsx index e137006..2ff6187 100644 --- a/src/stories/AtomTriggerInteractions.stories.tsx +++ b/src/stories/AtomTriggerInteractions.stories.tsx @@ -2,11 +2,11 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, waitFor } from 'storybook/test'; import { - DeterministicInteractionHarness, FixedHeaderViewportHarness, + InteractionHarness, MultiSentinelInteractionHarness, type InteractionHarnessProps, -} from './components/DeterministicInteractionHarness'; +} from './components/InteractionHarness'; import { StorySentinelStyles } from './components/StoryStyles'; import { atomTriggerActionArgs, atomTriggerArgTypes } from './storybookArgs'; @@ -50,15 +50,15 @@ export const InteractionOnceOnly: Story = { args: { once: true, }, - render: args => , + render: args => , play: async ({ canvas, userEvent, step }) => { const triggerEnter = canvas.getByRole('button', { name: /trigger enter/i }); const triggerLeave = canvas.getByRole('button', { name: /trigger leave/i }); const reset = canvas.getByRole('button', { name: /reset/i }); - const observerReady = canvas.getByTestId('deterministic-observer-ready'); - const enteredCount = canvas.getByTestId('deterministic-entered'); - const leftCount = canvas.getByTestId('deterministic-left'); - const totalEvents = canvas.getByTestId('deterministic-total'); + const observerReady = canvas.getByTestId('observer-ready'); + const enteredCount = canvas.getByTestId('entered'); + const leftCount = canvas.getByTestId('left'); + const totalEvents = canvas.getByTestId('total'); await step('Wait for observer setup', async () => { await waitFor(() => { @@ -99,14 +99,14 @@ export const InteractionOncePerDirection: Story = { args: { oncePerDirection: true, }, - render: args => , + render: args => , play: async ({ canvas, userEvent, step }) => { const triggerEnter = canvas.getByRole('button', { name: /trigger enter/i }); const triggerLeave = canvas.getByRole('button', { name: /trigger leave/i }); - const observerReady = canvas.getByTestId('deterministic-observer-ready'); - const enteredCount = canvas.getByTestId('deterministic-entered'); - const leftCount = canvas.getByTestId('deterministic-left'); - const totalEvents = canvas.getByTestId('deterministic-total'); + const observerReady = canvas.getByTestId('observer-ready'); + const enteredCount = canvas.getByTestId('entered'); + const leftCount = canvas.getByTestId('left'); + const totalEvents = canvas.getByTestId('total'); await step('Wait for observer setup', async () => { await waitFor(() => { @@ -147,14 +147,14 @@ export const InitialVisibleOnLoad: Story = { fireOnInitialVisible: true, initialVerticalScrollTop: 120, }, - render: args => , + render: args => , play: async ({ canvas, step }) => { - const observerReady = canvas.getByTestId('deterministic-observer-ready'); - const enteredCount = canvas.getByTestId('deterministic-entered'); - const leftCount = canvas.getByTestId('deterministic-left'); - const totalEvents = canvas.getByTestId('deterministic-total'); - const latestType = canvas.getByTestId('deterministic-latest-type'); - const latestInitial = canvas.getByTestId('deterministic-latest-initial'); + const observerReady = canvas.getByTestId('observer-ready'); + const enteredCount = canvas.getByTestId('entered'); + const leftCount = canvas.getByTestId('left'); + const totalEvents = canvas.getByTestId('total'); + const latestType = canvas.getByTestId('latest-type'); + const latestInitial = canvas.getByTestId('latest-initial'); await step('Fire enter immediately when the trigger starts visible', async () => { await waitFor(() => { @@ -170,15 +170,15 @@ export const InitialVisibleOnLoad: Story = { }; export const VerticalScrollBehavior: Story = { - render: args => , + render: args => , play: async ({ canvas, userEvent, step }) => { const runVertical = canvas.getByRole('button', { name: /run vertical sequence/i }); - const observerReady = canvas.getByTestId('deterministic-observer-ready'); - const enteredCount = canvas.getByTestId('deterministic-entered'); - const leftCount = canvas.getByTestId('deterministic-left'); - const totalEvents = canvas.getByTestId('deterministic-total'); - const latestMovement = canvas.getByTestId('deterministic-latest-movement'); - const latestPosition = canvas.getByTestId('deterministic-latest-position'); + const observerReady = canvas.getByTestId('observer-ready'); + const enteredCount = canvas.getByTestId('entered'); + const leftCount = canvas.getByTestId('left'); + const totalEvents = canvas.getByTestId('total'); + const latestMovement = canvas.getByTestId('latest-movement'); + const latestPosition = canvas.getByTestId('latest-position'); await step('Wait for observer setup', async () => { await waitFor(() => { @@ -186,7 +186,7 @@ export const VerticalScrollBehavior: Story = { }); }); - await step('Run deterministic vertical transition sequence', async () => { + await step('Run controlled vertical transition sequence', async () => { await userEvent.click(runVertical); await waitFor(() => { expect(enteredCount).toHaveTextContent('1'); @@ -200,15 +200,15 @@ export const VerticalScrollBehavior: Story = { }; export const HorizontalScrollBehavior: Story = { - render: args => , + render: args => , play: async ({ canvas, userEvent, step }) => { const runHorizontal = canvas.getByRole('button', { name: /run horizontal sequence/i }); - const observerReady = canvas.getByTestId('deterministic-observer-ready'); - const enteredCount = canvas.getByTestId('deterministic-entered'); - const leftCount = canvas.getByTestId('deterministic-left'); - const totalEvents = canvas.getByTestId('deterministic-total'); - const latestMovement = canvas.getByTestId('deterministic-latest-movement'); - const latestPosition = canvas.getByTestId('deterministic-latest-position'); + const observerReady = canvas.getByTestId('observer-ready'); + const enteredCount = canvas.getByTestId('entered'); + const leftCount = canvas.getByTestId('left'); + const totalEvents = canvas.getByTestId('total'); + const latestMovement = canvas.getByTestId('latest-movement'); + const latestPosition = canvas.getByTestId('latest-position'); await step('Wait for observer setup', async () => { await waitFor(() => { @@ -216,7 +216,7 @@ export const HorizontalScrollBehavior: Story = { }); }); - await step('Run deterministic horizontal transition sequence', async () => { + await step('Run controlled horizontal transition sequence', async () => { await userEvent.click(runHorizontal); await waitFor(() => { expect(enteredCount).toHaveTextContent('1'); diff --git a/src/stories/components/AnimationDemo/AnimationDemo.config.ts b/src/stories/components/AnimationDemo/AnimationDemo.config.ts index b800b2a..f0354d2 100644 --- a/src/stories/components/AnimationDemo/AnimationDemo.config.ts +++ b/src/stories/components/AnimationDemo/AnimationDemo.config.ts @@ -1,8 +1,8 @@ import type { AircraftAnimation, AnimationMode, + AnimationTransition, AnimationTransitionDirection, - AnimationTransitionMap, AnimationTriggerId, } from './types'; @@ -17,7 +17,7 @@ export type JumpDefinition = { direction: AnimationTransitionDirection; }; -export const defaultTransitionMap: AnimationTransitionMap = { +export const defaultTransitionMap = { top: { up: { nextMode: 'day', @@ -40,7 +40,10 @@ export const defaultTransitionMap: AnimationTransitionMap = { aircraft: 'plane', }, }, -}; +} satisfies Record< + AnimationTriggerId, + Partial> +>; export const triggerDefinitions: readonly TriggerDefinition[] = [ { diff --git a/src/stories/components/AnimationDemo/AnimationDemo.state.ts b/src/stories/components/AnimationDemo/AnimationDemo.state.ts index d5245a0..6915352 100644 --- a/src/stories/components/AnimationDemo/AnimationDemo.state.ts +++ b/src/stories/components/AnimationDemo/AnimationDemo.state.ts @@ -5,7 +5,6 @@ import type { AnimationMode, AnimationTransition, AnimationTransitionDirection, - AnimationTransitionMap, AnimationTriggerId, } from './types'; @@ -81,7 +80,6 @@ export function getTransitionDirectionFromEvent( } export function resolveAnimationTransition( - transitionMap: AnimationTransitionMap | undefined, triggerId: AnimationTriggerId, direction: MovementDirection | AnimationTransitionDirection, ): AnimationTransition | null { @@ -91,9 +89,5 @@ export function resolveAnimationTransition( return null; } - return ( - transitionMap?.[triggerId]?.[verticalDirection] ?? - defaultTransitionMap[triggerId]?.[verticalDirection] ?? - null - ); + return defaultTransitionMap[triggerId]?.[verticalDirection] ?? null; } diff --git a/src/stories/components/AnimationDemo/AnimationDemo.test.ts b/src/stories/components/AnimationDemo/AnimationDemo.test.ts index 7b1aab7..50be88f 100644 --- a/src/stories/components/AnimationDemo/AnimationDemo.test.ts +++ b/src/stories/components/AnimationDemo/AnimationDemo.test.ts @@ -1,6 +1,5 @@ import type { AtomTriggerEvent } from '../../../index'; import { describe, expect, it } from 'vitest'; -import { defaultTransitionMap } from './AnimationDemo.config'; import { animationDemoReducer, createInitialState, @@ -10,77 +9,37 @@ import { describe('AnimationDemo transition resolver', () => { it('resolves middle trigger while moving down to sunset with both aircraft', () => { - expect(resolveAnimationTransition(defaultTransitionMap, 'middle', 'down')).toEqual({ + expect(resolveAnimationTransition('middle', 'down')).toEqual({ nextMode: 'sunset', aircraft: 'both', }); }); it('resolves bottom trigger while moving down to night with plane only', () => { - expect(resolveAnimationTransition(defaultTransitionMap, 'bottom', 'down')).toEqual({ + expect(resolveAnimationTransition('bottom', 'down')).toEqual({ nextMode: 'night', aircraft: 'plane', }); }); it('resolves middle trigger while moving up to sunrise with helicopter only', () => { - expect(resolveAnimationTransition(defaultTransitionMap, 'middle', 'up')).toEqual({ + expect(resolveAnimationTransition('middle', 'up')).toEqual({ nextMode: 'sunrise', aircraft: 'helicopter', }); }); it('resolves top trigger while moving up to day with both aircraft', () => { - expect(resolveAnimationTransition(defaultTransitionMap, 'top', 'up')).toEqual({ + expect(resolveAnimationTransition('top', 'up')).toEqual({ nextMode: 'day', aircraft: 'both', }); }); it('ignores unsupported trigger and direction combinations', () => { - expect(resolveAnimationTransition(defaultTransitionMap, 'top', 'down')).toBeNull(); - expect(resolveAnimationTransition(defaultTransitionMap, 'bottom', 'up')).toBeNull(); - expect(resolveAnimationTransition(defaultTransitionMap, 'middle', 'left')).toBeNull(); - }); - - it('falls back to the default transition map when a custom map is partial', () => { - expect( - resolveAnimationTransition( - { - middle: { - down: { - nextMode: 'night', - aircraft: 'plane', - }, - }, - }, - 'top', - 'up', - ), - ).toEqual({ - nextMode: 'day', - aircraft: 'both', - }); - }); - - it('prefers the custom transition over the default transition', () => { - expect( - resolveAnimationTransition( - { - middle: { - down: { - nextMode: 'night', - aircraft: 'plane', - }, - }, - }, - 'middle', - 'down', - ), - ).toEqual({ - nextMode: 'night', - aircraft: 'plane', - }); + expect(resolveAnimationTransition('top', 'down')).toBeNull(); + expect(resolveAnimationTransition('bottom', 'up')).toBeNull(); + expect(resolveAnimationTransition('middle', 'left')).toBeNull(); }); }); diff --git a/src/stories/components/AnimationDemo/AnimationDemo.tsx b/src/stories/components/AnimationDemo/AnimationDemo.tsx index 5326e20..d9c22f0 100644 --- a/src/stories/components/AnimationDemo/AnimationDemo.tsx +++ b/src/stories/components/AnimationDemo/AnimationDemo.tsx @@ -7,8 +7,7 @@ import { Scene } from './Scene'; import { AnimationControls } from './AnimationControls'; import { AnimationStatusPanel } from './AnimationStatusPanel'; import { AnimationTriggerTrack } from './AnimationTriggerTrack'; -import { scrollHintsByMode } from './AnimationDemo.config'; -import { defaultTransitionMap } from './AnimationDemo.config'; +import { defaultTransitionMap, scrollHintsByMode } from './AnimationDemo.config'; import { animationDemoReducer, createInitialState, @@ -20,7 +19,6 @@ import type { AnimationMode, AnimationTransition, AnimationTransitionDirection, - AnimationTransitionMap, AnimationTriggerId, } from './types'; @@ -29,7 +27,6 @@ export type AnimationDemoProps = { viewportHeight?: number; defaultShowTriggers?: boolean; scrollBehavior?: ScrollBehavior; - transitionMap?: AnimationTransitionMap; onModeChange?: (mode: AnimationMode, event: AtomTriggerEvent) => void; }; @@ -38,7 +35,6 @@ export function AnimationDemo({ viewportHeight = 720, defaultShowTriggers = false, scrollBehavior = 'smooth', - transitionMap, onModeChange, }: AnimationDemoProps) { const scrollRootRef = React.useRef(null); @@ -109,7 +105,7 @@ export function AnimationDemo({ return; } - const transition = resolveAnimationTransition(transitionMap, triggerId, direction); + const transition = resolveAnimationTransition(triggerId, direction); if (!transition) { return; @@ -117,14 +113,14 @@ export function AnimationDemo({ dispatchTransition(transition, direction, event); }, - [dispatchTransition, transitionMap], + [dispatchTransition], ); const jumpToTransition = React.useCallback( (triggerId: AnimationTriggerId, direction: AnimationTransitionDirection) => { const root = scrollRootRef.current; const target = triggerAnchorRefs.current[triggerId]; - const transition = resolveAnimationTransition(transitionMap, triggerId, direction); + const transition = resolveAnimationTransition(triggerId, direction); if (!root || !target || !transition) { return; @@ -137,7 +133,7 @@ export function AnimationDemo({ scrollRootToPosition(root, getTargetScrollTop(root, target), scrollBehavior); dispatchTransition(transition, direction, createJumpEvent(root, target, direction)); }, - [dispatchTransition, scrollBehavior, transitionMap], + [dispatchTransition, scrollBehavior], ); const resetDemo = React.useCallback(() => { diff --git a/src/stories/components/AnimationDemo/index.ts b/src/stories/components/AnimationDemo/index.ts index 880589a..2b4ca2e 100644 --- a/src/stories/components/AnimationDemo/index.ts +++ b/src/stories/components/AnimationDemo/index.ts @@ -9,6 +9,5 @@ export type { AnimationMode, AnimationTransition, AnimationTransitionDirection, - AnimationTransitionMap, AnimationTriggerId, } from './types'; diff --git a/src/stories/components/AnimationDemo/types.ts b/src/stories/components/AnimationDemo/types.ts index a1946ab..13a861b 100644 --- a/src/stories/components/AnimationDemo/types.ts +++ b/src/stories/components/AnimationDemo/types.ts @@ -10,7 +10,3 @@ export type AnimationTransition = { nextMode: AnimationMode; aircraft: AircraftAnimation; }; - -export type AnimationTransitionMap = Partial< - Record>> ->; diff --git a/src/stories/components/ChildModeDemoLayouts.tsx b/src/stories/components/ChildModeDemoLayouts.tsx index 19783fc..2ce3ff0 100644 --- a/src/stories/components/ChildModeDemoLayouts.tsx +++ b/src/stories/components/ChildModeDemoLayouts.tsx @@ -51,14 +51,7 @@ export function ChildModeDemo({ onEvent, }: ChildModeDemoProps) { const containerRef = React.useRef(null); - const { events, handleEvent } = useDemoEvents(); - const handleChildEvent = React.useCallback( - (event: Parameters>[0]) => { - handleEvent(event); - onEvent?.(event); - }, - [handleEvent, onEvent], - ); + const { events, handleEvent } = useDemoEvents(onEvent); return (
@@ -85,7 +78,7 @@ export function ChildModeDemo({ fireOnInitialVisible={fireOnInitialVisible} onEnter={onEnter} onLeave={onLeave} - onEvent={handleChildEvent} + onEvent={handleEvent} >

([]); - const handleEvent = React.useCallback((event: AtomTriggerEvent) => { - setEvents(prev => [event, ...prev].slice(0, 12)); - }, []); + const handleEvent = React.useCallback( + (event: AtomTriggerEvent) => { + setEvents(prev => [event, ...prev].slice(0, 12)); + forwardEvent?.(event); + }, + [forwardEvent], + ); return { events, - latestEvent: events[0], handleEvent, }; } diff --git a/src/stories/components/DeterministicInteractionHarness.tsx b/src/stories/components/DeterministicInteractionHarness.tsx deleted file mode 100644 index 0886175..0000000 --- a/src/stories/components/DeterministicInteractionHarness.tsx +++ /dev/null @@ -1,1010 +0,0 @@ -import React from 'react'; -import { AtomTrigger } from '../../index'; -import type { AtomTriggerEvent, AtomTriggerProps } from '../../index'; - -export type InteractionHarnessProps = { - once?: boolean; - oncePerDirection?: boolean; - fireOnInitialVisible?: boolean; - initialVerticalScrollTop?: number; -} & Pick; - -export type ChildModeInteractionHarnessProps = { - threshold?: number; -} & Pick; - -type SharedHarnessEventCallbacks = Pick; - -function CounterPanel({ - title, - testIdPrefix, - events, - harnessReady, -}: { - title: string; - testIdPrefix: string; - events: AtomTriggerEvent[]; - harnessReady: boolean; -}) { - const latestCounts = events[0]?.counts ?? { entered: 0, left: 0 }; - const latestEvent = events[0]; - - return ( -

- ); -} - -export function DeterministicInteractionHarness({ - once = false, - oncePerDirection = false, - fireOnInitialVisible = false, - initialVerticalScrollTop = 0, - onEnter, - onLeave, - onEvent, -}: InteractionHarnessProps) { - const verticalRootRef = React.useRef(null); - const horizontalRootRef = React.useRef(null); - const verticalScrollTopRef = React.useRef(0); - const horizontalScrollLeftRef = React.useRef(0); - const [harnessReady, setHarnessReady] = React.useState(false); - const [triggerKey, setTriggerKey] = React.useState(0); - const [events, setEvents] = React.useState([]); - - React.useLayoutEffect(() => { - const verticalRoot = verticalRootRef.current; - const horizontalRoot = horizontalRootRef.current; - const verticalSentinel = verticalRoot?.querySelector('.deterministic-vertical-sentinel'); - const horizontalSentinel = horizontalRoot?.querySelector('.deterministic-horizontal-sentinel'); - - if ( - !(verticalRoot instanceof HTMLDivElement) || - !(horizontalRoot instanceof HTMLDivElement) || - !(verticalSentinel instanceof HTMLDivElement) || - !(horizontalSentinel instanceof HTMLDivElement) - ) { - setHarnessReady(false); - return; - } - - Object.defineProperty(verticalRoot, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(0, 0, 200, 180), - }); - Object.defineProperty(horizontalRoot, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(0, 0, 200, 120), - }); - Object.defineProperty(verticalSentinel, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(0, 260 - verticalScrollTopRef.current, 10, 10), - }); - Object.defineProperty(horizontalSentinel, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(260 - horizontalScrollLeftRef.current, 0, 2, 160), - }); - - verticalScrollTopRef.current = initialVerticalScrollTop; - horizontalScrollLeftRef.current = 0; - const readyId = window.requestAnimationFrame(() => { - setHarnessReady(true); - }); - - return () => { - window.cancelAnimationFrame(readyId); - }; - }, [initialVerticalScrollTop, triggerKey]); - - const handleEvent = React.useCallback((event: AtomTriggerEvent) => { - setEvents(prev => [event, ...prev].slice(0, 12)); - }, []); - - const handleHarnessEvent = React.useCallback( - (event: AtomTriggerEvent) => { - handleEvent(event); - onEvent?.(event); - }, - [handleEvent, onEvent], - ); - - const scrollVertical = React.useCallback((nextTop: number) => { - const root = verticalRootRef.current; - if (!root) { - return; - } - - verticalScrollTopRef.current = nextTop; - root.dispatchEvent( - new root.ownerDocument.defaultView!.Event('scroll', { - bubbles: true, - }), - ); - }, []); - - const scrollHorizontal = React.useCallback((nextLeft: number) => { - const root = horizontalRootRef.current; - if (!root) { - return; - } - - horizontalScrollLeftRef.current = nextLeft; - root.dispatchEvent( - new root.ownerDocument.defaultView!.Event('scroll', { - bubbles: true, - }), - ); - }, []); - - const emitEnter = React.useCallback(() => { - scrollVertical(120); - }, [scrollVertical]); - - const emitLeave = React.useCallback(() => { - scrollVertical(280); - }, [scrollVertical]); - - const runVerticalSequence = React.useCallback(() => { - scrollVertical(0); - - window.requestAnimationFrame(() => { - scrollVertical(120); - window.requestAnimationFrame(() => { - scrollVertical(280); - }); - }); - }, [scrollVertical]); - - const runHorizontalSequence = React.useCallback(() => { - scrollHorizontal(0); - - window.requestAnimationFrame(() => { - scrollHorizontal(120); - window.requestAnimationFrame(() => { - scrollHorizontal(320); - }); - }); - }, [scrollHorizontal]); - - const resetHarness = React.useCallback(() => { - setEvents([]); - setHarnessReady(false); - setTriggerKey(prev => prev + 1); - }, []); - - return ( -
-
-
- - - - - -
- -
-

Deterministic geometry harness for transition tests.

- -
-
- Vertical top spacer -
- -
- Vertical bottom spacer -
-
- -
-
-
- Horizontal left spacer -
- -
- Horizontal right spacer -
-
-
-
-
- - -
- ); -} - -export function MultiSentinelInteractionHarness({ - onEnter, - onLeave, - onEvent, -}: SharedHarnessEventCallbacks) { - const sharedRootRef = React.useRef(null); - const scrollTopRef = React.useRef(0); - const scrollLeftRef = React.useRef(0); - const [harnessReady, setHarnessReady] = React.useState(false); - const [triggerKey, setTriggerKey] = React.useState(0); - const [firstEvents, setFirstEvents] = React.useState([]); - const [secondEvents, setSecondEvents] = React.useState([]); - const [thirdEvents, setThirdEvents] = React.useState([]); - const [fourthEvents, setFourthEvents] = React.useState([]); - - React.useLayoutEffect(() => { - const sharedRoot = sharedRootRef.current; - const horizontalFirst = sharedRoot?.querySelector('.multi-horizontal-first'); - const horizontalSecond = sharedRoot?.querySelector('.multi-horizontal-second'); - const verticalThird = sharedRoot?.querySelector('.multi-vertical-third'); - const verticalFourth = sharedRoot?.querySelector('.multi-vertical-fourth'); - - if ( - !(sharedRoot instanceof HTMLDivElement) || - !(horizontalFirst instanceof HTMLDivElement) || - !(horizontalSecond instanceof HTMLDivElement) || - !(verticalThird instanceof HTMLDivElement) || - !(verticalFourth instanceof HTMLDivElement) - ) { - setHarnessReady(false); - return; - } - - Object.defineProperty(sharedRoot, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(0, 0, 200, 180), - }); - Object.defineProperty(horizontalFirst, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(40, 260 - scrollTopRef.current, 120, 2), - }); - Object.defineProperty(horizontalSecond, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(40, 290 - scrollTopRef.current, 120, 2), - }); - Object.defineProperty(verticalThird, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(260 - scrollLeftRef.current, 10, 2, 160), - }); - Object.defineProperty(verticalFourth, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(275 - scrollLeftRef.current, 10, 2, 160), - }); - - scrollTopRef.current = 0; - scrollLeftRef.current = 0; - const readyId = window.requestAnimationFrame(() => { - setHarnessReady(true); - }); - - return () => { - window.cancelAnimationFrame(readyId); - }; - }, [triggerKey]); - - const scrollVertical = React.useCallback((nextTop: number) => { - const root = sharedRootRef.current; - if (!root) { - return; - } - - scrollTopRef.current = nextTop; - root.dispatchEvent( - new root.ownerDocument.defaultView!.Event('scroll', { - bubbles: true, - }), - ); - }, []); - - const scrollHorizontal = React.useCallback((nextLeft: number) => { - const root = sharedRootRef.current; - if (!root) { - return; - } - - scrollLeftRef.current = nextLeft; - root.dispatchEvent( - new root.ownerDocument.defaultView!.Event('scroll', { - bubbles: true, - }), - ); - }, []); - - const runVerticalSequence = React.useCallback(() => { - scrollVertical(0); - - window.requestAnimationFrame(() => { - scrollVertical(120); - window.requestAnimationFrame(() => { - scrollVertical(320); - }); - }); - }, [scrollVertical]); - - const runHorizontalSequence = React.useCallback(() => { - scrollHorizontal(0); - - window.requestAnimationFrame(() => { - scrollHorizontal(120); - window.requestAnimationFrame(() => { - scrollHorizontal(280); - }); - }); - }, [scrollHorizontal]); - - const resetHarness = React.useCallback(() => { - setFirstEvents([]); - setSecondEvents([]); - setThirdEvents([]); - setFourthEvents([]); - setHarnessReady(false); - setTriggerKey(prev => prev + 1); - }, []); - - return ( -
-
-
- - - -
- -
-

- One bi-axial root with four sentinels registered into the same scheduler. -

- -
-
-
- Scroll this shared root vertically and horizontally. -
- { - setFirstEvents(prev => [event, ...prev].slice(0, 12)); - onEvent?.(event); - }} - /> - { - setSecondEvents(prev => [event, ...prev].slice(0, 12)); - onEvent?.(event); - }} - /> - { - setThirdEvents(prev => [event, ...prev].slice(0, 12)); - onEvent?.(event); - }} - /> - { - setFourthEvents(prev => [event, ...prev].slice(0, 12)); - onEvent?.(event); - }} - /> -
-
-
-
- - - - - -
- ); -} - -type FixedHeaderViewportHarnessProps = Pick< - AtomTriggerProps, - 'threshold' | 'onEnter' | 'onLeave' | 'onEvent' ->; - -export function FixedHeaderViewportHarness({ - threshold = 0, - onEnter, - onLeave, - onEvent, -}: FixedHeaderViewportHarnessProps) { - const scrollTopRef = React.useRef(0); - const [harnessReady, setHarnessReady] = React.useState(false); - const [triggerKey, setTriggerKey] = React.useState(0); - const [currentScrollTop, setCurrentScrollTop] = React.useState(0); - const [events, setEvents] = React.useState([]); - - React.useLayoutEffect(() => { - const sentinel = document.querySelector('.fixed-header-viewport-sentinel'); - if (!(sentinel instanceof HTMLDivElement)) { - setHarnessReady(false); - return; - } - - const previousInnerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth'); - const previousInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight'); - - Object.defineProperty(window, 'innerWidth', { - configurable: true, - value: 200, - }); - Object.defineProperty(window, 'innerHeight', { - configurable: true, - value: 200, - }); - Object.defineProperty(sentinel, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(40, 260 - scrollTopRef.current, 120, 2), - }); - - scrollTopRef.current = 0; - setCurrentScrollTop(0); - const readyId = window.requestAnimationFrame(() => { - setHarnessReady(true); - }); - - return () => { - window.cancelAnimationFrame(readyId); - - if (previousInnerWidth) { - Object.defineProperty(window, 'innerWidth', previousInnerWidth); - } - if (previousInnerHeight) { - Object.defineProperty(window, 'innerHeight', previousInnerHeight); - } - }; - }, [triggerKey]); - - const dispatchViewportScroll = React.useCallback((nextTop: number) => { - scrollTopRef.current = nextTop; - setCurrentScrollTop(nextTop); - window.dispatchEvent(new Event('scroll')); - }, []); - - const triggerEnter = React.useCallback(() => { - dispatchViewportScroll(120); - }, [dispatchViewportScroll]); - - const scrollBeforeBoundary = React.useCallback(() => { - dispatchViewportScroll(161); - }, [dispatchViewportScroll]); - - const scrollPastBoundary = React.useCallback(() => { - dispatchViewportScroll(162); - }, [dispatchViewportScroll]); - - const resetHarness = React.useCallback(() => { - setEvents([]); - setHarnessReady(false); - setTriggerKey(prev => prev + 1); - }, []); - - const latestEvent = events[0]; - const latestRootTop = Math.round(latestEvent?.entry.rootBounds?.top ?? 0); - const latestRectBottom = Math.round(latestEvent?.entry.boundingClientRect.bottom ?? 0); - const currentRootTop = 100; - const currentRectBottom = 262 - currentScrollTop; - - return ( -
-
-
- - - - -
- -
-
- Fixed header (100px) -
- -
- Viewport-root harness with rootMargin: -100px 0px 0px 0px. -
- - { - setEvents(prev => [event, ...prev].slice(0, 12)); - onEvent?.(event); - }} - /> -
-
- - -
- ); -} - -export function ChildModeInteractionHarness({ - threshold = 0, - onEnter, - onLeave, - onEvent, -}: ChildModeInteractionHarnessProps) { - const rootRef = React.useRef(null); - const scrollTopRef = React.useRef(0); - const [harnessReady, setHarnessReady] = React.useState(false); - const [triggerKey, setTriggerKey] = React.useState(0); - const [currentScrollTop, setCurrentScrollTop] = React.useState(0); - const [events, setEvents] = React.useState([]); - - React.useLayoutEffect(() => { - const root = rootRef.current; - const child = root?.querySelector('.deterministic-child-observed'); - - if (!(root instanceof HTMLDivElement) || !(child instanceof HTMLElement)) { - setHarnessReady(false); - return; - } - - Object.defineProperty(root, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(0, 0, 200, 200), - }); - Object.defineProperty(child, 'getBoundingClientRect', { - configurable: true, - value: () => new DOMRect(20, 260 - scrollTopRef.current, 160, 100), - }); - - scrollTopRef.current = 0; - setCurrentScrollTop(0); - const readyId = window.requestAnimationFrame(() => { - setHarnessReady(true); - }); - - return () => { - window.cancelAnimationFrame(readyId); - }; - }, [triggerKey]); - - const scrollVertical = React.useCallback((nextTop: number) => { - const root = rootRef.current; - if (!root) { - return; - } - - scrollTopRef.current = nextTop; - setCurrentScrollTop(nextTop); - root.dispatchEvent( - new root.ownerDocument.defaultView!.Event('scroll', { - bubbles: true, - }), - ); - }, []); - - const triggerBasicEnter = React.useCallback(() => { - scrollVertical(120); - }, [scrollVertical]); - - const triggerBelowThreshold = React.useCallback(() => { - scrollVertical(134); - }, [scrollVertical]); - - const triggerThresholdEnter = React.useCallback(() => { - scrollVertical(135); - }, [scrollVertical]); - - const triggerLeave = React.useCallback(() => { - scrollVertical(360); - }, [scrollVertical]); - - const runSequence = React.useCallback(() => { - scrollVertical(0); - - window.requestAnimationFrame(() => { - scrollVertical(threshold > 0 ? 134 : 120); - window.requestAnimationFrame(() => { - if (threshold > 0) { - scrollVertical(135); - } - - window.requestAnimationFrame(() => { - scrollVertical(360); - }); - }); - }); - }, [scrollVertical, threshold]); - - const resetHarness = React.useCallback(() => { - setEvents([]); - setHarnessReady(false); - setCurrentScrollTop(0); - setTriggerKey(prev => prev + 1); - }, []); - - const latestEvent = events[0]; - const visibleHeight = Math.max( - 0, - Math.min(200, 360 - currentScrollTop) - Math.max(0, 260 - currentScrollTop), - ); - const visibleRatio = visibleHeight / 100; - - return ( -
-
-
- - - - - - -
- -
-

- Deterministic child observation harness. The card is the observed element. -

- -
-
- Child mode top spacer -
- { - setEvents(prev => [event, ...prev].slice(0, 12)); - onEvent?.(event); - }} - > -
- Observed child -

- Threshold is {threshold}. -

-
-
-
- Child mode bottom spacer -
-
-
-
- -
- - -
-
- ); -} diff --git a/src/stories/components/InteractionHarness.tsx b/src/stories/components/InteractionHarness.tsx new file mode 100644 index 0000000..292cae8 --- /dev/null +++ b/src/stories/components/InteractionHarness.tsx @@ -0,0 +1,8 @@ +export { ChildModeInteractionHarness } from './InteractionHarness/ChildModeInteractionHarness'; +export { FixedHeaderViewportHarness } from './InteractionHarness/FixedHeaderViewportHarness'; +export { InteractionHarness } from './InteractionHarness/InteractionHarness'; +export { MultiSentinelInteractionHarness } from './InteractionHarness/MultiSentinelInteractionHarness'; +export type { + ChildModeInteractionHarnessProps, + InteractionHarnessProps, +} from './InteractionHarness/shared'; diff --git a/src/stories/components/InteractionHarness/ChildModeInteractionHarness.tsx b/src/stories/components/InteractionHarness/ChildModeInteractionHarness.tsx new file mode 100644 index 0000000..38f2f5a --- /dev/null +++ b/src/stories/components/InteractionHarness/ChildModeInteractionHarness.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import { AtomTrigger } from '../../../index'; +import type { AtomTriggerEvent } from '../../../index'; +import { addHarnessEvent, CounterPanel, type ChildModeInteractionHarnessProps } from './shared'; + +export function ChildModeInteractionHarness({ + threshold = 0, + onEnter, + onLeave, + onEvent, +}: ChildModeInteractionHarnessProps) { + const rootRef = React.useRef(null); + const scrollTopRef = React.useRef(0); + const [harnessReady, setHarnessReady] = React.useState(false); + const [triggerKey, setTriggerKey] = React.useState(0); + const [currentScrollTop, setCurrentScrollTop] = React.useState(0); + const [events, setEvents] = React.useState([]); + + React.useLayoutEffect(() => { + const root = rootRef.current; + const child = root?.querySelector('.observed-child'); + + if (!(root instanceof HTMLDivElement) || !(child instanceof HTMLElement)) { + setHarnessReady(false); + return; + } + + Object.defineProperty(root, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(0, 0, 200, 200), + }); + Object.defineProperty(child, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(20, 260 - scrollTopRef.current, 160, 100), + }); + + scrollTopRef.current = 0; + setCurrentScrollTop(0); + const readyId = window.requestAnimationFrame(() => { + setHarnessReady(true); + }); + + return () => { + window.cancelAnimationFrame(readyId); + }; + }, [triggerKey]); + + const scrollVertical = React.useCallback((nextTop: number) => { + const root = rootRef.current; + if (!root) { + return; + } + + scrollTopRef.current = nextTop; + setCurrentScrollTop(nextTop); + root.dispatchEvent( + new root.ownerDocument.defaultView!.Event('scroll', { + bubbles: true, + }), + ); + }, []); + + const triggerBasicEnter = React.useCallback(() => { + scrollVertical(120); + }, [scrollVertical]); + + const triggerBelowThreshold = React.useCallback(() => { + scrollVertical(134); + }, [scrollVertical]); + + const triggerThresholdEnter = React.useCallback(() => { + scrollVertical(135); + }, [scrollVertical]); + + const triggerLeave = React.useCallback(() => { + scrollVertical(360); + }, [scrollVertical]); + + const runSequence = React.useCallback(() => { + scrollVertical(0); + + window.requestAnimationFrame(() => { + scrollVertical(threshold > 0 ? 134 : 120); + window.requestAnimationFrame(() => { + if (threshold > 0) { + scrollVertical(135); + } + + window.requestAnimationFrame(() => { + scrollVertical(360); + }); + }); + }); + }, [scrollVertical, threshold]); + + const resetHarness = React.useCallback(() => { + setEvents([]); + setHarnessReady(false); + setCurrentScrollTop(0); + setTriggerKey(prev => prev + 1); + }, []); + + const latestEvent = events[0]; + const visibleHeight = Math.max( + 0, + Math.min(200, 360 - currentScrollTop) - Math.max(0, 260 - currentScrollTop), + ); + const visibleRatio = visibleHeight / 100; + + return ( +
+
+
+ + + + + + +
+ +
+

+ Controlled child observation harness. The card is the observed element. +

+ +
+
+ Child mode top spacer +
+ addHarnessEvent(setEvents, event, onEvent)} + > +
+ Observed child +

+ Threshold is {threshold}. +

+
+
+
+ Child mode bottom spacer +
+
+
+
+ +
+ + +
+
+ ); +} diff --git a/src/stories/components/InteractionHarness/FixedHeaderViewportHarness.tsx b/src/stories/components/InteractionHarness/FixedHeaderViewportHarness.tsx new file mode 100644 index 0000000..095379c --- /dev/null +++ b/src/stories/components/InteractionHarness/FixedHeaderViewportHarness.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { AtomTrigger } from '../../../index'; +import type { AtomTriggerEvent, AtomTriggerProps } from '../../../index'; +import { addHarnessEvent } from './shared'; + +type FixedHeaderViewportHarnessProps = Pick< + AtomTriggerProps, + 'threshold' | 'onEnter' | 'onLeave' | 'onEvent' +>; + +export function FixedHeaderViewportHarness({ + threshold = 0, + onEnter, + onLeave, + onEvent, +}: FixedHeaderViewportHarnessProps) { + const scrollTopRef = React.useRef(0); + const [harnessReady, setHarnessReady] = React.useState(false); + const [triggerKey, setTriggerKey] = React.useState(0); + const [currentScrollTop, setCurrentScrollTop] = React.useState(0); + const [events, setEvents] = React.useState([]); + + React.useLayoutEffect(() => { + const sentinel = document.querySelector('.fixed-header-viewport-sentinel'); + if (!(sentinel instanceof HTMLDivElement)) { + setHarnessReady(false); + return; + } + + const previousInnerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth'); + const previousInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight'); + + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 200, + }); + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: 200, + }); + Object.defineProperty(sentinel, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(40, 260 - scrollTopRef.current, 120, 2), + }); + + scrollTopRef.current = 0; + setCurrentScrollTop(0); + const readyId = window.requestAnimationFrame(() => { + setHarnessReady(true); + }); + + return () => { + window.cancelAnimationFrame(readyId); + + if (previousInnerWidth) { + Object.defineProperty(window, 'innerWidth', previousInnerWidth); + } + if (previousInnerHeight) { + Object.defineProperty(window, 'innerHeight', previousInnerHeight); + } + }; + }, [triggerKey]); + + const dispatchViewportScroll = React.useCallback((nextTop: number) => { + scrollTopRef.current = nextTop; + setCurrentScrollTop(nextTop); + window.dispatchEvent(new Event('scroll')); + }, []); + + const triggerEnter = React.useCallback(() => { + dispatchViewportScroll(120); + }, [dispatchViewportScroll]); + + const scrollBeforeBoundary = React.useCallback(() => { + dispatchViewportScroll(161); + }, [dispatchViewportScroll]); + + const scrollPastBoundary = React.useCallback(() => { + dispatchViewportScroll(162); + }, [dispatchViewportScroll]); + + const resetHarness = React.useCallback(() => { + setEvents([]); + setHarnessReady(false); + setTriggerKey(prev => prev + 1); + }, []); + + const latestEvent = events[0]; + const latestRootTop = Math.round(latestEvent?.entry.rootBounds?.top ?? 0); + const latestRectBottom = Math.round(latestEvent?.entry.boundingClientRect.bottom ?? 0); + const currentRootTop = 100; + const currentRectBottom = 262 - currentScrollTop; + + return ( +
+
+
+ + + + +
+ +
+
+ Fixed header (100px) +
+ +
+ Viewport-root harness with rootMargin: -100px 0px 0px 0px. +
+ + addHarnessEvent(setEvents, event, onEvent)} + /> +
+
+ + +
+ ); +} diff --git a/src/stories/components/InteractionHarness/InteractionHarness.tsx b/src/stories/components/InteractionHarness/InteractionHarness.tsx new file mode 100644 index 0000000..61c8d25 --- /dev/null +++ b/src/stories/components/InteractionHarness/InteractionHarness.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { AtomTrigger } from '../../../index'; +import type { AtomTriggerEvent } from '../../../index'; +import { addHarnessEvent, CounterPanel, type InteractionHarnessProps } from './shared'; + +export function InteractionHarness({ + once = false, + oncePerDirection = false, + fireOnInitialVisible = false, + initialVerticalScrollTop = 0, + onEnter, + onLeave, + onEvent, +}: InteractionHarnessProps) { + const verticalRootRef = React.useRef(null); + const horizontalRootRef = React.useRef(null); + const verticalScrollTopRef = React.useRef(0); + const horizontalScrollLeftRef = React.useRef(0); + const [harnessReady, setHarnessReady] = React.useState(false); + const [triggerKey, setTriggerKey] = React.useState(0); + const [events, setEvents] = React.useState([]); + + React.useLayoutEffect(() => { + const verticalRoot = verticalRootRef.current; + const horizontalRoot = horizontalRootRef.current; + const verticalSentinel = verticalRoot?.querySelector('.vertical-sentinel'); + const horizontalSentinel = horizontalRoot?.querySelector('.horizontal-sentinel'); + + if ( + !(verticalRoot instanceof HTMLDivElement) || + !(horizontalRoot instanceof HTMLDivElement) || + !(verticalSentinel instanceof HTMLDivElement) || + !(horizontalSentinel instanceof HTMLDivElement) + ) { + setHarnessReady(false); + return; + } + + Object.defineProperty(verticalRoot, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(0, 0, 200, 180), + }); + Object.defineProperty(horizontalRoot, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(0, 0, 200, 120), + }); + Object.defineProperty(verticalSentinel, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(0, 260 - verticalScrollTopRef.current, 10, 10), + }); + Object.defineProperty(horizontalSentinel, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(260 - horizontalScrollLeftRef.current, 0, 2, 160), + }); + + verticalScrollTopRef.current = initialVerticalScrollTop; + horizontalScrollLeftRef.current = 0; + const readyId = window.requestAnimationFrame(() => { + setHarnessReady(true); + }); + + return () => { + window.cancelAnimationFrame(readyId); + }; + }, [initialVerticalScrollTop, triggerKey]); + + const handleHarnessEvent = React.useCallback( + (event: AtomTriggerEvent) => { + addHarnessEvent(setEvents, event, onEvent); + }, + [onEvent], + ); + + const scrollVertical = React.useCallback((nextTop: number) => { + const root = verticalRootRef.current; + if (!root) { + return; + } + + verticalScrollTopRef.current = nextTop; + root.dispatchEvent( + new root.ownerDocument.defaultView!.Event('scroll', { + bubbles: true, + }), + ); + }, []); + + const scrollHorizontal = React.useCallback((nextLeft: number) => { + const root = horizontalRootRef.current; + if (!root) { + return; + } + + horizontalScrollLeftRef.current = nextLeft; + root.dispatchEvent( + new root.ownerDocument.defaultView!.Event('scroll', { + bubbles: true, + }), + ); + }, []); + + const emitEnter = React.useCallback(() => { + scrollVertical(120); + }, [scrollVertical]); + + const emitLeave = React.useCallback(() => { + scrollVertical(280); + }, [scrollVertical]); + + const runVerticalSequence = React.useCallback(() => { + scrollVertical(0); + + window.requestAnimationFrame(() => { + scrollVertical(120); + window.requestAnimationFrame(() => { + scrollVertical(280); + }); + }); + }, [scrollVertical]); + + const runHorizontalSequence = React.useCallback(() => { + scrollHorizontal(0); + + window.requestAnimationFrame(() => { + scrollHorizontal(120); + window.requestAnimationFrame(() => { + scrollHorizontal(320); + }); + }); + }, [scrollHorizontal]); + + const resetHarness = React.useCallback(() => { + setEvents([]); + setHarnessReady(false); + setTriggerKey(prev => prev + 1); + }, []); + + return ( +
+
+
+ + + + + +
+ +
+

Controlled geometry harness for transition tests.

+ +
+
+ Vertical top spacer +
+ +
+ Vertical bottom spacer +
+
+ +
+
+
+ Horizontal left spacer +
+ +
+ Horizontal right spacer +
+
+
+
+
+ + +
+ ); +} diff --git a/src/stories/components/InteractionHarness/MultiSentinelInteractionHarness.tsx b/src/stories/components/InteractionHarness/MultiSentinelInteractionHarness.tsx new file mode 100644 index 0000000..25fb0f4 --- /dev/null +++ b/src/stories/components/InteractionHarness/MultiSentinelInteractionHarness.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { AtomTrigger } from '../../../index'; +import type { AtomTriggerEvent } from '../../../index'; +import { addHarnessEvent, CounterPanel, type SharedHarnessEventCallbacks } from './shared'; + +export function MultiSentinelInteractionHarness({ + onEnter, + onLeave, + onEvent, +}: SharedHarnessEventCallbacks) { + const sharedRootRef = React.useRef(null); + const scrollTopRef = React.useRef(0); + const scrollLeftRef = React.useRef(0); + const [harnessReady, setHarnessReady] = React.useState(false); + const [triggerKey, setTriggerKey] = React.useState(0); + const [firstEvents, setFirstEvents] = React.useState([]); + const [secondEvents, setSecondEvents] = React.useState([]); + const [thirdEvents, setThirdEvents] = React.useState([]); + const [fourthEvents, setFourthEvents] = React.useState([]); + + React.useLayoutEffect(() => { + const sharedRoot = sharedRootRef.current; + const horizontalFirst = sharedRoot?.querySelector('.multi-horizontal-first'); + const horizontalSecond = sharedRoot?.querySelector('.multi-horizontal-second'); + const verticalThird = sharedRoot?.querySelector('.multi-vertical-third'); + const verticalFourth = sharedRoot?.querySelector('.multi-vertical-fourth'); + + if ( + !(sharedRoot instanceof HTMLDivElement) || + !(horizontalFirst instanceof HTMLDivElement) || + !(horizontalSecond instanceof HTMLDivElement) || + !(verticalThird instanceof HTMLDivElement) || + !(verticalFourth instanceof HTMLDivElement) + ) { + setHarnessReady(false); + return; + } + + Object.defineProperty(sharedRoot, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(0, 0, 200, 180), + }); + Object.defineProperty(horizontalFirst, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(40, 260 - scrollTopRef.current, 120, 2), + }); + Object.defineProperty(horizontalSecond, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(40, 290 - scrollTopRef.current, 120, 2), + }); + Object.defineProperty(verticalThird, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(260 - scrollLeftRef.current, 10, 2, 160), + }); + Object.defineProperty(verticalFourth, 'getBoundingClientRect', { + configurable: true, + value: () => new DOMRect(275 - scrollLeftRef.current, 10, 2, 160), + }); + + scrollTopRef.current = 0; + scrollLeftRef.current = 0; + const readyId = window.requestAnimationFrame(() => { + setHarnessReady(true); + }); + + return () => { + window.cancelAnimationFrame(readyId); + }; + }, [triggerKey]); + + const scrollVertical = React.useCallback((nextTop: number) => { + const root = sharedRootRef.current; + if (!root) { + return; + } + + scrollTopRef.current = nextTop; + root.dispatchEvent( + new root.ownerDocument.defaultView!.Event('scroll', { + bubbles: true, + }), + ); + }, []); + + const scrollHorizontal = React.useCallback((nextLeft: number) => { + const root = sharedRootRef.current; + if (!root) { + return; + } + + scrollLeftRef.current = nextLeft; + root.dispatchEvent( + new root.ownerDocument.defaultView!.Event('scroll', { + bubbles: true, + }), + ); + }, []); + + const runVerticalSequence = React.useCallback(() => { + scrollVertical(0); + + window.requestAnimationFrame(() => { + scrollVertical(120); + window.requestAnimationFrame(() => { + scrollVertical(320); + }); + }); + }, [scrollVertical]); + + const runHorizontalSequence = React.useCallback(() => { + scrollHorizontal(0); + + window.requestAnimationFrame(() => { + scrollHorizontal(120); + window.requestAnimationFrame(() => { + scrollHorizontal(280); + }); + }); + }, [scrollHorizontal]); + + const resetHarness = React.useCallback(() => { + setFirstEvents([]); + setSecondEvents([]); + setThirdEvents([]); + setFourthEvents([]); + setHarnessReady(false); + setTriggerKey(prev => prev + 1); + }, []); + + return ( +
+
+
+ + + +
+ +
+

+ One bi-axial root with four sentinels registered into the same scheduler. +

+ +
+
+
+ Scroll this shared root vertically and horizontally. +
+ addHarnessEvent(setFirstEvents, event, onEvent)} + /> + addHarnessEvent(setSecondEvents, event, onEvent)} + /> + addHarnessEvent(setThirdEvents, event, onEvent)} + /> + addHarnessEvent(setFourthEvents, event, onEvent)} + /> +
+
+
+
+ + + + + +
+ ); +} diff --git a/src/stories/components/InteractionHarness/shared.tsx b/src/stories/components/InteractionHarness/shared.tsx new file mode 100644 index 0000000..b4f0012 --- /dev/null +++ b/src/stories/components/InteractionHarness/shared.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import type { AtomTriggerEvent, AtomTriggerProps } from '../../../index'; + +export type InteractionHarnessProps = { + once?: boolean; + oncePerDirection?: boolean; + fireOnInitialVisible?: boolean; + initialVerticalScrollTop?: number; +} & Pick; + +export type ChildModeInteractionHarnessProps = { + threshold?: number; +} & Pick; + +export type SharedHarnessEventCallbacks = Pick; + +export function addHarnessEvent( + setEvents: React.Dispatch>, + event: AtomTriggerEvent, + forwardEvent?: AtomTriggerProps['onEvent'], +): void { + setEvents(prev => [event, ...prev].slice(0, 12)); + forwardEvent?.(event); +} + +export function CounterPanel({ + title, + testIdPrefix, + events, + harnessReady, +}: { + title: string; + testIdPrefix?: string; + events: AtomTriggerEvent[]; + harnessReady: boolean; +}) { + const latestCounts = events[0]?.counts ?? { entered: 0, left: 0 }; + const latestEvent = events[0]; + const getTestId = (name: string) => (testIdPrefix ? `${testIdPrefix}-${name}` : name); + + return ( + + ); +} diff --git a/src/stories/components/SentinelDemoLayouts.tsx b/src/stories/components/SentinelDemoLayouts.tsx index 7145cd9..88a047a 100644 --- a/src/stories/components/SentinelDemoLayouts.tsx +++ b/src/stories/components/SentinelDemoLayouts.tsx @@ -22,7 +22,7 @@ export function AtomTriggerDemo({ onEvent, }: DemoProps) { const containerRef = React.useRef(null); - const { events, handleEvent } = useDemoEvents(); + const { events, handleEvent } = useDemoEvents(onEvent); React.useLayoutEffect(() => { const container = containerRef.current; @@ -33,14 +33,6 @@ export function AtomTriggerDemo({ container.scrollTop = initialScrollTop; }, [initialScrollTop]); - const handleDemoEvent = React.useCallback( - (event: Parameters>[0]) => { - handleEvent(event); - onEvent?.(event); - }, - [handleEvent, onEvent], - ); - return (
@@ -68,7 +60,7 @@ export function AtomTriggerDemo({ fireOnInitialVisible={fireOnInitialVisible} onEnter={onEnter} onLeave={onLeave} - onEvent={handleDemoEvent} + onEvent={handleEvent} />
@@ -101,14 +93,7 @@ export function FixedHeaderOffsetDemo({ onEvent, }: FixedHeaderDemoProps) { const viewportRef = React.useRef(null); - const { events, handleEvent } = useDemoEvents(); - const handleFixedHeaderEvent = React.useCallback( - (event: Parameters>[0]) => { - handleEvent(event); - onEvent?.(event); - }, - [handleEvent, onEvent], - ); + const { events, handleEvent } = useDemoEvents(onEvent); return (
@@ -197,14 +182,7 @@ export function FixedHeaderOffsetViewportDemo({ onLeave, onEvent, }: FixedHeaderDemoProps) { - const { events, handleEvent } = useDemoEvents(); - const handleViewportEvent = React.useCallback( - (event: Parameters>[0]) => { - handleEvent(event); - onEvent?.(event); - }, - [handleEvent, onEvent], - ); + const { events, handleEvent } = useDemoEvents(onEvent); return (
@@ -249,7 +227,7 @@ export function FixedHeaderOffsetViewportDemo({ oncePerDirection={oncePerDirection} onEnter={onEnter} onLeave={onLeave} - onEvent={handleViewportEvent} + onEvent={handleEvent} />
@@ -282,14 +260,7 @@ export function HorizontalScrollDemo({ onEvent, }: HorizontalScrollDemoProps) { const containerRef = React.useRef(null); - const { events, handleEvent } = useDemoEvents(); - const handleHorizontalEvent = React.useCallback( - (event: Parameters>[0]) => { - handleEvent(event); - onEvent?.(event); - }, - [handleEvent, onEvent], - ); + const { events, handleEvent } = useDemoEvents(onEvent); return (
( getServerSnapshot: GetSnapshot, ) => T; -const reactUseSyncExternalStore = ( - React as typeof React & { useSyncExternalStore?: UseSyncExternalStoreHook } -).useSyncExternalStore; - -const useClientSubscriptionEffect = - typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; - export function useCompatSyncExternalStore( subscribe: Subscribe, getSnapshot: GetSnapshot, getServerSnapshot: GetSnapshot, ): T { + const reactUseSyncExternalStore = ( + React as typeof React & { useSyncExternalStore?: UseSyncExternalStoreHook } + ).useSyncExternalStore; + if (reactUseSyncExternalStore) { return reactUseSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } const [snapshot, setSnapshot] = React.useState(() => getServerSnapshot()); const snapshotRef = React.useRef(snapshot); + const useClientSubscriptionEffect = + typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; useClientSubscriptionEffect(() => { const updateSnapshot = () => { diff --git a/src/utils.test.tsx b/src/utils.test.tsx index 81714a0..edc4c8e 100644 --- a/src/utils.test.tsx +++ b/src/utils.test.tsx @@ -4,7 +4,6 @@ import { hydrateRoot } from 'react-dom/client'; import { renderToString } from 'react-dom/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useScrollPosition, useViewportSize } from './index'; -import { __getScrollTargetForTests, __getViewportSizeForTests } from './utils'; import { finishDomTestRun, prepareDomTestRun, @@ -478,24 +477,4 @@ describe('utility hooks', () => { expect(size.textContent).toBe('1400,900'); }); - - it('falls back to zero viewport size when window is unavailable', () => { - const originalWindow = globalThis.window; - - vi.stubGlobal('window', undefined); - - expect(__getViewportSizeForTests()).toEqual({ width: 0, height: 0 }); - - vi.stubGlobal('window', originalWindow); - }); - - it('returns null scroll target when no explicit target exists and window is unavailable', () => { - const originalWindow = globalThis.window; - - vi.stubGlobal('window', undefined); - - expect(__getScrollTargetForTests(undefined)).toBeNull(); - - vi.stubGlobal('window', originalWindow); - }); }); diff --git a/src/utils.tsx b/src/utils.tsx index 846bf67..73b0313 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -25,9 +25,6 @@ export type UseScrollPositionOptions = ListenerOptions & { const zeroScrollPosition: ScrollPosition = { x: 0, y: 0 }; const zeroViewportSize: ViewportSize = { width: 0, height: 0 }; -const useIsomorphicLayoutEffect = - typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; - type ThrottleController = { schedule: () => void; cancel: () => void; @@ -90,8 +87,6 @@ function getViewportSize(): ViewportSize { }; } -export const __getViewportSizeForTests = getViewportSize; - function isRefBasedTarget( target: UseScrollPositionOptions['target'], ): target is React.RefObject { @@ -116,8 +111,6 @@ function getScrollTarget(target: UseScrollPositionOptions['target']): Window | H return window; } -export const __getScrollTargetForTests = getScrollTarget; - function isWindowTarget(target: Window | HTMLElement): target is Window { return target === window || isWindowLike(target); } @@ -214,8 +207,10 @@ export function useScrollPosition(options?: UseScrollPositionOptions): ScrollPos const [trackedRefTarget, setTrackedRefTarget] = React.useState(() => targetUsesRef ? getScrollTarget(target) : null, ); + const useRefTargetEffect = + typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; - useIsomorphicLayoutEffect(() => { + useRefTargetEffect(() => { if (!targetUsesRef) { return; } diff --git a/tsdown.config.ts b/tsdown.config.ts index 347d62f..d62270e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -13,6 +13,9 @@ const baseConfig = { hash: false, platform: 'browser', target: 'es2020', + define: { + 'process.env.NODE_ENV': '"production"', + }, deps: { neverBundle: ['react'], }, @@ -73,6 +76,7 @@ export default defineConfig([ dts: false, clean: false, define: { + 'process.env.NODE_ENV': '"production"', 'import.meta': '{}', }, outExtensions: ({ format }) => getOutExtensions(format),