diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4bcd3..f0f6654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.0-alpha.1 — 2026-09-06 + +- Preserve continuous offset and progress across React renders and preserve consumer transforms with independent CSS translation. +- Add velocity-aware bounded settling, deterministic gesture-start snapshots, and interruptible snap-back continuity. +- Add optional `onDismissComplete` after mounted departure while keeping `onDismiss` as immediate semantic commitment. +- Scope global blur handling to active pointer sessions and keep 1,000 idle instances free of global listeners, animation frames, and observers. +- Reject empty or mixed-axis directions and invalid thresholds with precise errors. +- Protect text-editing controls for mouse, touch, and pen, and add `data-drag-dismiss-ignore` for custom interaction surfaces. +- Expand lifecycle, integration, troubleshooting, performance, and real-device documentation. + ## 0.1.0-alpha.0 - Initial drag-to-dismiss primitive with logical directions, intent arbitration, recent velocity, resistance, interruptible settling, reduced motion, SSR-safe packaging, and documentation. diff --git a/README.md b/README.md index 724edd4..d0dad0f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ import '@nipe-solutions/react-drag-dismiss/core.css' ; removeItem(id, direction)} + onDismiss={({ direction }) => recordCommit(id, direction)} + onDismissComplete={() => removeItem(id)} > @@ -24,6 +25,8 @@ import '@nipe-solutions/react-drag-dismiss/core.css' The package detects intent and provides motion mechanics. Your application owns removal, unmounting, undo, focus, persistence, and side effects. Always provide an accessible semantic control for required dismissal actions. +`onDismiss` fires once when release commits. Optional `onDismissComplete` fires once after a mounted departure reaches its visual target; it does not fire if the consumer unmounts first. Use `data-drag-dismiss-ignore` on custom controls that must never initiate a gesture. Inputs, textareas, selects, and contenteditable descendants are ignored automatically. + Full documentation: [react-drag-dismiss.nipesolutions.com](https://react-drag-dismiss.nipesolutions.com) Part of [NIPE Open Source](https://opensource.nipesolutions.com). @@ -38,7 +41,7 @@ npm run check npm run test:e2e ``` -See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [docs/REAL_DEVICE_QA.md](docs/REAL_DEVICE_QA.md). +See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), [interaction architecture](docs/ARCHITECTURE.md), [performance notes](docs/PERFORMANCE.md), and [real-device QA](docs/REAL_DEVICE_QA.md). ## License diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..50500c2 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,27 @@ +# Interaction architecture + +Drag Dismiss separates continuous interaction mechanics from semantic React state. + +## Pointer session snapshot + +Pointer down measures the element once and snapshots the axis, logical directions, physical signs, writing direction, threshold, size, and lifecycle callbacks. Prop changes and resizing during that gesture take effect on the next gesture. Pointer movement performs no layout or computed-style reads. + +## Continuous DOM state + +The interaction controller owns `translate`, `--drag-dismiss-offset`, and `--drag-dismiss-progress` through refs and direct DOM writes. React initializes these values when the node connects but does not render-own them afterward. React state owns only semantic attributes such as `data-state`. + +Progress is signed and threshold-relative: zero is origin, `1` or `-1` is the configured threshold, and travel beyond the threshold may exceed that range. Offset is a signed CSS pixel value. + +## Ownership and settling + +A session starts pending, claims only after dominant-axis intent, and otherwise abandons without preventing default behavior. A claimed cancellation begins an interruptible return. A new gesture starts from the currently rendered return offset. Committed departure is final and cannot be re-grabbed. + +Recent sampled velocity participates in both commitment and bounded settle duration. A pause expires stale velocity. The controller measures no geometry during animation frames and schedules no frame while idle. + +## Dismiss lifecycle + +`onDismiss` reports semantic commitment exactly once before departure. If the node remains mounted, `onDismissComplete` reports visual completion exactly once after the departure target is reached. Unmounting from `onDismiss` safely cancels motion and intentionally prevents completion. The package never manages presence or removes DOM. + +## Resource lifecycle + +Each idle instance owns only element-level React handlers. The window blur safeguard exists only while a pointer session is active and is removed when that session ends, abandons, or unmounts. There are no observers or idle animation frames. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..8dd5f82 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,9 @@ +# Performance notes + +The performance fixture mounts 1,000 independent `DragDismiss` instances and instruments resources owned by the package. + +While idle, the group creates no window blur listeners, animation frames, or observers. Each instance retains only its element-level React pointer handlers and refs. Starting one gesture attaches one session-scoped blur listener, measures that element once, and leaves the other 999 instances inactive. Ending, abandoning, disabling, or unmounting the active session removes that listener. + +Pointer movement writes `translate`, offset, progress, and semantic data attributes directly to the active element. It performs no React render per frame, computed-style lookup, or geometry measurement. Settle frames reuse the gesture-start size and configuration snapshot. + +These are qualitative resource invariants rather than timing claims. Render duration depends on React mode, consumer content, browser, and device, so the project does not advertise a synthetic rows-per-second benchmark. diff --git a/docs/REAL_DEVICE_QA.md b/docs/REAL_DEVICE_QA.md index 8dcf34c..23267fa 100644 --- a/docs/REAL_DEVICE_QA.md +++ b/docs/REAL_DEVICE_QA.md @@ -9,6 +9,16 @@ Record device, OS, browser version, date, and result for: - Desktop Chrome - Desktop Safari -For each environment verify horizontal and vertical dismissal, slow threshold drag, fast flick, pause before release, reversal, cancel, unsupported direction, buttons and links, scrolling, RTL, reduced motion, browser-edge behavior, resize, and repeated gestures. +For each environment verify: + +- slow horizontal drag, fast flick, pause before release, reversal, cancel, and wrong-direction resistance; +- vertical dismissal where it does not conflict with primary scrolling; +- button tap, link navigation, drag beginning on a button/link, and release-click suppression; +- input, textarea, select, contenteditable, and `data-drag-dismiss-ignore` interaction; +- vertical scrolling feed coexistence, RTL, reduced motion, and browser-edge behavior; +- resize during a gesture, snap-back re-grab, repeated gestures, and committed departure; +- consumer `transform` composition during translation. + +Record qualitative motion continuity for both a slow release and a fast flick. Record browser and OS versions rather than inferring physical iOS behavior from desktop WebKit. Status for `0.1.0-alpha.0`: physical iPhone and Android testing pending. diff --git a/package-lock.json b/package-lock.json index b07b09b..25a5f1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nipe-solutions/react-drag-dismiss", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nipe-solutions/react-drag-dismiss", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "license": "MIT", "devDependencies": { "@axe-core/playwright": "4.10.2", diff --git a/package.json b/package.json index b5295b9..a6dda18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nipe-solutions/react-drag-dismiss", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "description": "Composable drag-to-dismiss mechanics for React.", "homepage": "https://react-drag-dismiss.nipesolutions.com", "repository": { diff --git a/src/DragDismiss.test.tsx b/src/DragDismiss.test.tsx index bbe5969..6132ac3 100644 --- a/src/DragDismiss.test.tsx +++ b/src/DragDismiss.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render } from '@testing-library/react' -import { StrictMode } from 'react' +import { act, fireEvent, render } from '@testing-library/react' +import { StrictMode, useState } from 'react' import { describe, expect, test, vi } from 'vitest' import { DragDismiss } from './DragDismiss' @@ -15,6 +15,22 @@ function pointer(target: Element, type: string, init: Record) { ) } +function measure(element: Element, width = 200, height = 80) { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + width, + height, + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: height, + }), + }) +} + describe('DragDismiss', () => { test('renders arbitrary content with the idle styling contract', () => { const { getByTestId } = render( @@ -73,6 +89,558 @@ describe('DragDismiss', () => { expect(onDismiss).toHaveBeenCalledWith({ direction: 'end' }) }) + test('continuous progress and offset survive semantic and parent rerenders', () => { + function Fixture() { + const [label, setLabel] = useState('Before') + return ( + <> + + {label} + + ) + } + const { getByRole, getByTestId } = render() + const root = getByTestId('root') + measure(root) + pointer(root, 'pointerdown', { + clientX: 0, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointermove', { + clientX: 100, + clientY: 0, + pointerType: 'touch', + }) + expect(root.style.getPropertyValue('--drag-dismiss-progress')).toBe('1.25') + expect(root.style.getPropertyValue('--drag-dismiss-offset')).toBe('100px') + fireEvent.click(getByRole('button', { name: 'Rerender' })) + expect(root).toHaveTextContent('After') + expect(root.style.getPropertyValue('--drag-dismiss-progress')).toBe('1.25') + expect(root.style.getPropertyValue('--drag-dismiss-offset')).toBe('100px') + }) + + test('validates impossible public configuration precisely', () => { + expect(() => + render(Item), + ).toThrow('`directions` must contain at least one direction.') + expect(() => + render(Item), + ).toThrow( + '`directions` must belong to one axis. Received "start" and "down".', + ) + for (const threshold of [0, -0.1, 1.1, Number.NaN, Infinity]) { + expect(() => + render(Item), + ).toThrow( + '`threshold` must be a finite number greater than 0 and less than or equal to 1.', + ) + } + }) + + test('fires commit then completion exactly once when departure finishes', async () => { + const order: string[] = [] + vi.stubGlobal('matchMedia', () => ({ matches: true })) + const { getByTestId } = render( + order.push('commit')} + onDismissComplete={() => order.push('complete')} + > + Item + , + ) + const root = getByTestId('root') + measure(root) + pointer(root, 'pointerdown', { + clientX: 0, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointermove', { + clientX: 100, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointerup', { + clientX: 100, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointercancel', { + clientX: 100, + clientY: 0, + pointerType: 'touch', + }) + expect(order).toEqual(['commit']) + await act(async () => Promise.resolve()) + expect(order).toEqual(['commit', 'complete']) + vi.unstubAllGlobals() + }) + + test('committed departure never reverses from an overshot release', () => { + let frame: FrameRequestCallback | undefined + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + frame = callback + return 1 + }), + ) + const { getByTestId } = render( + Item, + ) + const root = getByTestId('root') + measure(root) + pointer(root, 'pointerdown', { + clientX: 0, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointermove', { + clientX: 400, + clientY: 0, + pointerType: 'touch', + }) + const releasedAt = Number.parseFloat( + root.style.getPropertyValue('--drag-dismiss-offset'), + ) + pointer(root, 'pointerup', { + clientX: 400, + clientY: 0, + pointerType: 'touch', + }) + expect(releasedAt).toBeLessThan(250) + expect(releasedAt).toBeGreaterThan(200) + act(() => frame?.(performance.now() + 16)) + expect( + Number.parseFloat(root.style.getPropertyValue('--drag-dismiss-offset')), + ).toBeGreaterThan(releasedAt) + vi.unstubAllGlobals() + }) + + test('cancel fires neither dismissal lifecycle callback', () => { + const onDismiss = vi.fn() + const onDismissComplete = vi.fn() + const { getByTestId } = render( + + Item + , + ) + const root = getByTestId('root') + measure(root) + pointer(root, 'pointerdown', { + clientX: 0, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointermove', { + clientX: 10, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointerup', { + clientX: 10, + clientY: 0, + pointerType: 'touch', + }) + expect(onDismiss).not.toHaveBeenCalled() + expect(onDismissComplete).not.toHaveBeenCalled() + }) + + test('unmounting from onDismiss is safe and does not call completion', () => { + const onDismissComplete = vi.fn() + function Fixture() { + const [visible, setVisible] = useState(true) + return visible ? ( + setVisible(false)} + onDismissComplete={onDismissComplete} + > + Item + + ) : null + } + const { getByTestId } = render() + const root = getByTestId('root') + measure(root) + pointer(root, 'pointerdown', { + clientX: 0, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointermove', { + clientX: 100, + clientY: 0, + pointerType: 'touch', + }) + expect(() => + pointer(root, 'pointerup', { + clientX: 100, + clientY: 0, + pointerType: 'touch', + }), + ).not.toThrow() + expect(onDismissComplete).not.toHaveBeenCalled() + }) + + test('preserves a consumer transform while applying drag translation', () => { + const { getByTestId } = render( + + Item + , + ) + const root = getByTestId('root') + measure(root) + pointer(root, 'pointerdown', { + clientX: 0, + clientY: 0, + pointerType: 'touch', + }) + pointer(root, 'pointermove', { + clientX: 40, + clientY: 0, + pointerType: 'touch', + }) + expect(root.style.transform).toBe('scale(.9) rotate(2deg)') + expect(root.style.translate).toBe('40px 0px') + }) + + test.each(['input', 'textarea', 'select', 'contenteditable'])( + 'does not start a touch drag from %s', + (kind) => { + const child = + kind === 'input' ? ( + + ) : kind === 'textarea' ? ( +