diff --git a/.changeset/yellow-donkeys-own.md b/.changeset/yellow-donkeys-own.md new file mode 100644 index 00000000..e5a8555c --- /dev/null +++ b/.changeset/yellow-donkeys-own.md @@ -0,0 +1,5 @@ +--- +'@svelte-put/swipeable': major +--- + +First implementation with dedicated docs page diff --git a/packages/clickoutside/src/index.js b/packages/clickoutside/src/index.js index 5fd9fe96..34fe1286 100644 --- a/packages/clickoutside/src/index.js +++ b/packages/clickoutside/src/index.js @@ -1,5 +1,5 @@ // Copyright (c) Quang Phan. All rights reserved. Licensed under the MIT license. export * from './clickoutside.js'; -export * from './types.public'; +export * from './types.public.js'; diff --git a/packages/swipeable/CHANGELOG.md b/packages/swipeable/CHANGELOG.md new file mode 100644 index 00000000..4dc68c6f --- /dev/null +++ b/packages/swipeable/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/packages/swipeable/README.md b/packages/swipeable/README.md new file mode 100644 index 00000000..2ef8de64 --- /dev/null +++ b/packages/swipeable/README.md @@ -0,0 +1,68 @@ +
+ +# `@svelte-put/swipeable` + +[![npm.badge]][npm] [![bundlephobia.badge]][bundlephobia] [![docs.badge]][docs] + +Svelte action `use:swipeable` - event for setting up quick gestures on an element (swipe right to delete, for example). + +
+ +## `svelte-put` + +This package is part of the [@svelte-put][github.monorepo] family. For contributing guideline and more, refer to its [readme][github.monorepo]. + +## Usage & Documentation + +[See the dedicated documentation page here][docs]. + +## Quick Start + +```html + + + +``` + +## [Changelog][github.changelog] + + + +[github.monorepo]: https://github.com/vnphanquang/svelte-put +[github.changelog]: https://github.com/vnphanquang/svelte-put/blob/next/packages/swipeable/CHANGELOG.md +[github.issues]: https://github.com/vnphanquang/svelte-put/issues?q= + + + +[npm.badge]: https://img.shields.io/npm/v/@svelte-put/swipeable +[npm]: https://www.npmjs.com/package/@svelte-put/swipeable +[bundlephobia.badge]: https://img.shields.io/bundlephobia/minzip/@svelte-put/swipeable?label=minzipped +[bundlephobia]: https://bundlephobia.com/package/@svelte-put/swipeable +[docs]: https://svelte-put.vnphanquang.com/docs/swipeable +[docs.badge]: https://img.shields.io/badge/-Docs%20Site-blue + diff --git a/packages/swipeable/package.json b/packages/swipeable/package.json new file mode 100644 index 00000000..468d88f6 --- /dev/null +++ b/packages/swipeable/package.json @@ -0,0 +1,61 @@ +{ + "name": "@svelte-put/swipeable", + "version": "1.0.0-next.0", + "description": "set up quick swipe gesture action on element", + "main": "src/index.js", + "module": "src/index.js", + "types": "types/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./types/index.d.ts", + "import": "./src/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src", + "types" + ], + "scripts": { + "lint": "eslint . --ignore-path=\"../../.eslintignore\"", + "format": "prettier --ignore-path ../../.prettierignore --write .", + "dts": "dts-buddy --write && publint", + "prepublishOnly": "turbo run dts --filter=@svelte-put/swipeable" + }, + "keywords": [ + "svelte", + "action", + "event", + "swipe", + "touch", + "gesture" + ], + "author": { + "email": "vnphanquang@gmail.com", + "name": "Quang Phan", + "url": "https://github.com/vnphanquang" + }, + "license": "MIT", + "homepage": "https://svelte-put.vnphanquang.com/docs/swipeable", + "repository": { + "type": "git", + "url": "git+https://github.com/vnphanquang/svelte-put.git", + "directory": "packages/swipeable" + }, + "bugs": { + "url": "https://github.com/vnphanquang/svelte-put/issues" + }, + "devDependencies": { + "@internals/tsconfig": "workspace:*" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.181" + }, + "volta": { + "extends": "../../package.json" + } +} + diff --git a/packages/swipeable/src/index.js b/packages/swipeable/src/index.js new file mode 100644 index 00000000..b725241e --- /dev/null +++ b/packages/swipeable/src/index.js @@ -0,0 +1,5 @@ +// Copyright (c) Quang Phan. All rights reserved. Licensed under the MIT license. + +export * from './swipeable.svelte.js'; +export * from './types.public.js'; + diff --git a/packages/swipeable/src/internals.js b/packages/swipeable/src/internals.js new file mode 100644 index 00000000..e91361a6 --- /dev/null +++ b/packages/swipeable/src/internals.js @@ -0,0 +1,66 @@ +/** + * @param {Element} node + * @param {import('./types.public').SwipeThreshold} [param] + * @returns {{ x: number; y: number }} + */ +export function resolveThreshold(node, param = '20%') { + const value = parseFloat(param); + + if (param.endsWith('%')) { + return { + x: node.clientWidth * value / 100, + y: node.clientHeight * value / 100, + }; + } + + if (param.endsWith('rem')) { + const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); + return { + x: fontSize * value, + y: fontSize * value, + }; + } + + return { x: value, y: value, }; +} + +/** + * @param {import('./types.public').SwipeableConfig['direction']} param + * @returns {import('./types.public').SwipeSingleDirection[]} + */ +export function resolveDirection(param) { + if (!param) return ['up', 'down', 'left', 'right']; + + if (typeof param === 'string') { + if (param === 'x') { + return ['left', 'right']; + } else if (param === 'y') { + return ['up', 'down']; + } else if (param === 'all') { + return ['up', 'down', 'left', 'right']; + } else { + return [param]; + } + } + + /** @type {Set} */ + const set = new Set(); + for (const d of param) { + if (d === 'x') { + set.add('left'); + set.add('right'); + } else if (d === 'y') { + set.add('up'); + set.add('down'); + } else if (d === 'all') { + set.add('up'); + set.add('down'); + set.add('left'); + set.add('right'); + } else { + set.add(d); + } + } + return Array.from(set); +} + diff --git a/packages/swipeable/src/swipeable.svelte.js b/packages/swipeable/src/swipeable.svelte.js new file mode 100644 index 00000000..f7baef38 --- /dev/null +++ b/packages/swipeable/src/swipeable.svelte.js @@ -0,0 +1,223 @@ +/* eslint-disable no-undef */ +import { cubicOut } from 'svelte/easing'; +import { tweened } from 'svelte/motion'; + +import { resolveDirection, resolveThreshold } from './internals.js'; + +/** + * @param {Element} node + * @param {import('./types.public').SwipeableParameter} [param] + * @returns {import('./types.public').SwipeableActionReturn} + */ +export function swipeable(node, param) { + const tNode = /** @type {HTMLElement} */ (node); + + const config = /** @type {import('./types.private.js').ResolvedConfig} */ ({ + direction: ['left', 'right'], + threshold: '30%', + customPropertyPrefix: '--swipe', + followThrough: { + enabled: true, + easing: cubicOut, + duration: 400, + }, + allowFlick: (ms, px) => ms < 170 && Math.abs(px / ms) > 1, + enabled: true, + }); + + let threshold = { x: 0, y: 0 }; + /** @type {import('./types.private.js').PointerCoordinate | null} */ + let startCoordinate = null; + /** @type {number | null} */ + let startTimestamp = null; + /** @type {import('./types.public').SwipeSingleDirection | null} */ + let direction = null; + /** @type {'x' | 'y' | null} */ + let axis = null; + /** @type {import('./types.private.js').PointerCoordinate} */ + let distance = $state({ x: 0, y: 0 }); + + function reset() { + axis = null; + direction = null; + startCoordinate = null; + startTimestamp = null; + // unregister event listeners + tNode.removeEventListener('pointermove', onPointerMove); + tNode.removeEventListener('pointerup', onPointerUp); + } + + function resolveConfig() { + if (param) { + if (typeof param === 'string' || Array.isArray(param)) { + config.direction = resolveDirection(param); + } else { + config.direction = resolveDirection(param.direction); + if (param.threshold) config.threshold = param.threshold; + if (param.customPropertyPrefix !== undefined) + config.customPropertyPrefix = param.customPropertyPrefix; + if (param.enabled !== undefined) config.enabled = param.enabled; + if (param.allowFlick !== undefined) { + if (typeof param.allowFlick === 'function') config.allowFlick = param.allowFlick; + else if (!param.allowFlick) config.allowFlick = () => false; + } + if (param.followThrough !== undefined) { + if (typeof param.followThrough === 'object') { + if (param.followThrough.easing) config.followThrough.easing = param.followThrough.easing; + if (param.followThrough.duration) + config.followThrough.duration = param.followThrough.duration; + } else { + config.followThrough.enabled = param.followThrough; + } + } + } + } + threshold = resolveThreshold(tNode, config.threshold); + } + + function onResize() { + threshold = resolveThreshold(tNode, config.threshold); + } + + $effect.root(() => { + $effect(() => { + if (!config.customPropertyPrefix) return; + tNode.style.setProperty(`${config.customPropertyPrefix}-distance-x`, `${distance.x}px`); + }); + $effect(() => { + if (!config.customPropertyPrefix) return; + tNode.style.setProperty(`${config.customPropertyPrefix}-distance-y`, `${distance.y}px`); + }); + }); + + /** + * @param {PointerEvent} e + */ + function onPointerDown(e) { + if (!config.enabled) return; + reset(); + const target = /** @type {HTMLElement} */ (e.target); + target.setPointerCapture(e.pointerId); + startCoordinate = { x: e.clientX, y: e.clientY }; + startTimestamp = e.timeStamp; + + // register event listeners + tNode.addEventListener('pointermove', onPointerMove); + tNode.addEventListener('pointerup', onPointerUp); + } + + /** + * @param {PointerEvent} e + */ + function onPointerMove(e) { + if (!startCoordinate) return; + const newDistance = { + x: e.clientX - startCoordinate.x, + y: e.clientY - startCoordinate.y, + }; + + if (!axis) { + const allowX = config.direction.includes('left') || config.direction.includes('right'); + const allowY = config.direction.includes('up') || config.direction.includes('down'); + + if (allowX && allowY) { + axis = Math.abs(newDistance.x) >= Math.abs(newDistance.y) ? 'x' : 'y'; + } else if (allowX) { + axis = 'x'; + } else if (allowY) { + axis = 'y'; + } + } + if (!axis) return; + + const newDirection = axis === 'x' + ? newDistance[axis] > 0 ? 'right' : 'left' + : newDistance[axis] > 0 ? 'down' : 'up'; + + const shouldFire = !direction || Math.sign(distance[axis]) !== Math.sign(newDistance[axis]); + + if (config.direction.includes(newDirection)) { + direction = newDirection; + } + if (!direction) return; + + distance[axis] = newDistance[axis]; + + if (shouldFire) { + const detail = /** @satisfies {import('./types.public').SwipeStartEventDetail} */ ({ + direction, + distance: distance[axis], + }); + tNode.dispatchEvent(new CustomEvent('swipestart', { detail })); + } + } + + /** + * @param {PointerEvent} e + */ + async function onPointerUp(e) { + if (!direction || !startTimestamp) { + reset(); + return; + } + + const axis = direction === 'left' || direction === 'right' ? 'x' : 'y'; + const flick = config.allowFlick(e.timeStamp - startTimestamp, distance[axis]); + const passThreshold = flick || Math.abs(distance[axis]) >= threshold[axis]; + const detail = /** @satisfies {import('./types.public').SwipeEndEventDetail} */ ({ + direction, + distance: distance[axis], + passThreshold, + }); + + /** @type {number | null}*/ + let to = null; + let duration = config.followThrough.duration; + let easing = config.followThrough.easing; + + if (passThreshold) { + if (config.followThrough.enabled) { + const max = axis === 'x' ? tNode.clientWidth : tNode.clientHeight; + to = distance[axis] > 0 ? max : -max; + if (flick) { + const distanceToMax = Math.abs(max - Math.abs(distance[axis])); + const speed = Math.abs(distance[axis]) / (e.timeStamp - startTimestamp); + duration = Math.min(distanceToMax / speed, duration); + } + } + } else { + duration = 200; + to = 0; + } + + if (to !== null) { + const tween = tweened(distance[axis], { duration, easing }); + const unsub = tween.subscribe((v) => { + distance[axis] = v; + }); + await tween.set(to); + tNode.dispatchEvent(new CustomEvent('swipeend', { detail })); + unsub(); + } else { + tNode.dispatchEvent(new CustomEvent('swipeend', { detail })); + } + + reset(); + } + + tNode.addEventListener('resize', onResize); + tNode.addEventListener('pointerdown', onPointerDown); + resolveConfig(); + + return { + update(newParam) { + param = newParam; + resolveConfig(); + }, + destroy() { + tNode.removeEventListener('resize', onResize); + tNode.removeEventListener('pointerdown', onPointerDown); + }, + }; +} + diff --git a/packages/swipeable/src/types.private.d.ts b/packages/swipeable/src/types.private.d.ts new file mode 100644 index 00000000..f61d6a09 --- /dev/null +++ b/packages/swipeable/src/types.private.d.ts @@ -0,0 +1,16 @@ +import { SwipeableConfig, SwipeFollowThrough, SwipeSingleDirection, SwipeThreshold } from './types.public' + +export type ResolvedConfig = { + direction: SwipeSingleDirection[]; + customPropertyPrefix: SwipeableConfig['customPropertyPrefix']; + threshold: SwipeThreshold; + allowFlick: NonNullable>; + followThrough: Required & { enabled: boolean }; + enabled: boolean; +} + +export type PointerCoordinate = { + x: number; + y: number; +}; + diff --git a/packages/swipeable/src/types.public.d.ts b/packages/swipeable/src/types.public.d.ts new file mode 100644 index 00000000..530a2f7c --- /dev/null +++ b/packages/swipeable/src/types.public.d.ts @@ -0,0 +1,79 @@ +import { ActionReturn, Action } from 'svelte/action'; + +export type SwipeSingleDirection = 'up' | 'down' | 'left' | 'right'; +export type SwipeMultiDirection = 'x' | 'y' | 'all'; +export type SwipeDirection = SwipeSingleDirection | SwipeMultiDirection; + +export type SwipeThresholdUnit = 'px' | 'rem' | '%'; +export type SwipeThreshold = `${number}${SwipeThresholdUnit}`; + +export type SwipeFollowThrough = { + /** duration for the follow through animation */ + duration?: number; + /** easing function for the follow through animation */ + easing?: (t: number) => number; +} + +/** + * svelte action parameters to config behavior of `swipeable` + */ +export interface SwipeableConfig { + /** + * one or more directions to swipe. + * Default to `['left', 'right']` + */ + direction?: SwipeDirection | SwipeDirection[]; + /** + * travel distance to trigger CustomEvent. + * Take a string with unit (px, rem, %). + * Note: percentage is relative to the element size. + * Default to `30%` + */ + threshold?: SwipeThreshold; + /** + * CSS custom property to track swipe travel distance in px. + * Default to `--swipe`, i.e `--swipe-distance-x` and `--swipe-distance-y`. + * Set to `null` to disable tracking. + */ + customPropertyPrefix?: string | null; + /** + * whether to move to 100% displacement if swipe passes threshold. + * When enabled, `onswipeend` will be fired upon follow-through completion. + * Can take a {@link SwipeFollowThrough} config object for more fine-grained control. + * Default to `true` + */ + followThrough?: SwipeFollowThrough | boolean; + /** + * allow flicking, i.e fast swipe with high velocity. + * Note: flick action will ignore `threshold` + * Default to a function that returns true if swipe takes less than 170ms with speed over 1px/ms + * ie: `(ms, px) => ms < 170 && Math.abs(px / ms) > 1` + */ + allowFlick?: boolean | ((ms: number, px: number) => boolean); + /** + * whether to enable the action. + * Default to `true` + */ + enabled?: boolean; +} + +export interface SwipeStartEventDetail { + /** direction of this swipe action */ + direction: SwipeSingleDirection; + /** travel distance of this swipe action in px */ + distance: number; +} +export interface SwipeEndEventDetail extends SwipeStartEventDetail { + /** whether the swipe action passes the threshold, or is a flick */ + passThreshold: boolean; +} + +export interface SwipeableAttributes { + onswipestart?: (event: CustomEvent) => void; + onswipeend?: (event: CustomEvent) => void; +} + +export type SwipeableParameter = SwipeableConfig['direction'] | SwipeableConfig | undefined; +export type SwipeableAction = Action; +export type SwipeableActionReturn = ActionReturn; + diff --git a/packages/swipeable/src/types.public.js b/packages/swipeable/src/types.public.js new file mode 100644 index 00000000..710b17ee --- /dev/null +++ b/packages/swipeable/src/types.public.js @@ -0,0 +1,3 @@ +/** to provide typing only, see types.public.d.ts */ +export {}; + diff --git a/packages/swipeable/tsconfig.json b/packages/swipeable/tsconfig.json new file mode 100644 index 00000000..ff5b5bbf --- /dev/null +++ b/packages/swipeable/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": ["src/**/*"], + "extends": "@internals/tsconfig/base.package.js.json" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db1353cc..19c8f89e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,6 +392,16 @@ importers: specifier: workspace:* version: link:../../internals/tsconfig + packages/swipeable: + dependencies: + svelte: + specifier: ^5.0.0-next.181 + version: 5.0.0-next.181 + devDependencies: + '@internals/tsconfig': + specifier: workspace:* + version: link:../../internals/tsconfig + packages/toc: dependencies: svelte: @@ -465,6 +475,9 @@ importers: '@svelte-put/shortcut': specifier: workspace:* version: link:../../packages/shortcut + '@svelte-put/swipeable': + specifier: workspace:* + version: link:../../packages/swipeable '@svelte-put/toc': specifier: workspace:* version: link:../../packages/toc diff --git a/sites/docs/package.json b/sites/docs/package.json index 5550a69a..b2032549 100644 --- a/sites/docs/package.json +++ b/sites/docs/package.json @@ -35,6 +35,7 @@ "@svelte-put/resize": "workspace:*", "@svelte-put/shortcut": "workspace:*", "@svelte-put/toc": "workspace:*", + "@svelte-put/swipeable": "workspace:*", "@sveltejs/adapter-cloudflare": "^4.6.1", "@sveltejs/enhanced-img": "^0.3.0", "@sveltejs/kit": "^2.5.18", @@ -50,3 +51,4 @@ "extends": "../../package.json" } } + diff --git a/sites/docs/src/lib/assets/images/og/svelte-put-swipeable.jpg b/sites/docs/src/lib/assets/images/og/svelte-put-swipeable.jpg new file mode 100644 index 00000000..1ef09567 Binary files /dev/null and b/sites/docs/src/lib/assets/images/og/svelte-put-swipeable.jpg differ diff --git a/sites/docs/src/lib/assets/images/svg/phosphor/archive.svg b/sites/docs/src/lib/assets/images/svg/phosphor/archive.svg new file mode 100644 index 00000000..61516604 --- /dev/null +++ b/sites/docs/src/lib/assets/images/svg/phosphor/archive.svg @@ -0,0 +1,2 @@ + + diff --git a/sites/docs/src/lib/assets/images/svg/phosphor/envelope-simple.svg b/sites/docs/src/lib/assets/images/svg/phosphor/envelope-simple.svg new file mode 100644 index 00000000..caeb5b17 --- /dev/null +++ b/sites/docs/src/lib/assets/images/svg/phosphor/envelope-simple.svg @@ -0,0 +1,2 @@ + + diff --git a/sites/docs/src/lib/assets/images/svg/phosphor/trash.svg b/sites/docs/src/lib/assets/images/svg/phosphor/trash.svg new file mode 100644 index 00000000..6dedb52f --- /dev/null +++ b/sites/docs/src/lib/assets/images/svg/phosphor/trash.svg @@ -0,0 +1,2 @@ + + diff --git a/sites/docs/src/lib/data/packages.ts b/sites/docs/src/lib/data/packages.ts index ea685fa1..4d37bbe6 100644 --- a/sites/docs/src/lib/data/packages.ts +++ b/sites/docs/src/lib/data/packages.ts @@ -280,6 +280,20 @@ export const packages = { githubUrl: 'https://github.com/vnphanquang/svelte-put/tree/next/packages/toc', changelogUrl: 'https://github.com/vnphanquang/svelte-put/blob/next/packages/toc/CHANGELOG.md', }, + swipeable: { + id: 'swipeable', + name: '@svelte-put/swipeable', + publishedAt: 1720777185388, + description: 'set up quick swipe gesture action on element', + path: '/docs/swipeable', + replId: 'undefined', + status: 'new', + rune: true, + ready: true, + githubUrl: 'https://github.com/vnphanquang/svelte-put/tree/next/packages/swipeable', + changelogUrl: + 'https://github.com/vnphanquang/svelte-put/blob/next/packages/swipeable/CHANGELOG.md', + }, } as const satisfies Record; export type PackageId = keyof typeof packages; diff --git a/sites/docs/src/preprocess-inline-svg.d.ts b/sites/docs/src/preprocess-inline-svg.d.ts index eb6ab5c0..e633340d 100644 --- a/sites/docs/src/preprocess-inline-svg.d.ts +++ b/sites/docs/src/preprocess-inline-svg.d.ts @@ -9,11 +9,14 @@ declare module 'svelte/elements' { | 'runes' | 'simpleicon' | 'svelte-put' + | 'phosphor/archive' | 'phosphor/clipboard' + | 'phosphor/envelope-simple' | 'phosphor/moon-stars' | 'phosphor/sliders-horizontal' | 'phosphor/spinner-gap' | 'phosphor/sun' + | 'phosphor/trash' | 'phosphor/x' | 'simpleicon/cloudflare' | 'simpleicon/github' diff --git a/sites/docs/src/routes/docs/(package)/swipeable/+page.md.svelte b/sites/docs/src/routes/docs/(package)/swipeable/+page.md.svelte new file mode 100644 index 00000000..8c53083d --- /dev/null +++ b/sites/docs/src/routes/docs/(package)/swipeable/+page.md.svelte @@ -0,0 +1,190 @@ + + +## Installation + + + +```bash title=npm +npm install --save-dev @svelte-put/swipeable@latest +``` + +```bash title=pnpm +pnpm add -D @svelte-put/swipeable@latest +``` + +```bash title=yarn +yarn add -D @svelte-put/swipeable@latest +``` + + + +## Quick Start + +```svelte title="Quick Start" + + + +
...
+``` + +## Demo + +The following example demonstrates a practical use case for `swipeable` to implement swipe-to-delete or swipe-to-archive, often seen in notification center or email apps. + +
+Example +
+ +
+
+ +```svelte src=./_page/examples/demo.svelte title=demo.svelte +``` + +## Events + +`swipeable` fires `swipestart` when a swipe action in one of the allowed directions is detected (pointermove), and `swipeend` when the swipe action is completed (pointerup). + +```typescript title="SwipeableAttributes.d.ts" +interface SwipeStartEventDetail { + /** direction of this swipe action */ + direction: SwipeSingleDirection; + /** travel distance of this swipe action in px */ + distance: number; +} +interface SwipeEndEventDetail extends SwipeStartEventDetail { + /** whether the swipe action passes the threshold, or is a flick */ + passThreshold: boolean; +} + +interface SwipeableAttributes { + onswipestart?: (event: CustomEvent) => void; + onswipeend?: (event: CustomEvent) => void; +} +``` + +
+ +**Multiple `swipestart` events** + +A `swipestart` event might be fired again if user changes the swipe direction during the swipe action. In [Demo], try swiping to left and then change to right midway. Observe that the background color and icon are updated accordingly. + +
+ +## Configuration + +`swipeable` takes an optional parameter with the following interface. Details of each property are explained in next sections. + +```typescript title="SwipeableParameter.d.ts" +type SwipeableParameter = SwipeableConfig['direction'] | SwipeableConfig | undefined; + +interface SwipeableConfig { + direction?: SwipeDirection | SwipeDirection[]; + threshold?: SwipeThreshold; + customPropertyPrefix?: string | null; + followThrough?: SwipeFollowThrough | boolean; + allowFlick?: boolean | ((ms: number, px: number) => boolean); + enabled?: boolean; +} +``` + +### Direction + +`SwipeableConfig` accepts an optional `direction` property that takes one or an array of directions for `swipeable` to register at runtime. Allow values are: + +```typescript title="SwipeDirection.d.ts" +type SwipeSingleDirection = 'up' | 'down' | 'left' | 'right'; +type SwipeMultiDirection = 'x' | 'y' | 'all'; +type SwipeDirection = SwipeSingleDirection | SwipeMultiDirection; +``` + +The default is `['left', 'right']`. Although it is possible to allow `swpieable` to listen to all directions, it is recommended to constraint to either horizontal or vertical directions to avoid janky behavior. + +### Threshold + +`SwipeableConfig`accepts an optional `threshold` property that sets up the distance to trigger the `swipeend` event. The value is a string that takes a number followed by a unit (`px`, `rem`, `%`). + +```typescript title="SwipeThreshold.d.ts" +type SwipeThresholdUnit = 'px' | 'rem' | '%'; +type SwipeThreshold = `${number}${SwipeThresholdUnit}`; +``` + +The default is `30%`. Note that percentage is relative to the element size in the travel direction (i.e height for vertical swipe, and width for horizontal swipe). + +### CSS Custom Property + +`SwipeableConfig` accepts an optional `customPropertyPrefix` property that sets up the CSS custom property to track the swipe travel distance. Typically you would use this property to shift the element's position following the swipe movement for visual feedback (so that user knows that their swipe is being registered). + +```svelte +
+``` + +
+ +`swipeable` tracks displacement via a custom property instead of setting the element's `style` directly to avoid interfering with user-defined styles, allowing more flexibility. + +
+ + +The default is `--swipe`, i.e `--swipe-distance-x` for horizontal swipe, and `--swipe-distance-y` for vertical swipe. Set to `null` to disable tracking. + +### Follow Through + +`SwipeableConfig` accepts an optional `followThrough` property that instructs `swipeable` how to behave when swipe reaches the threshold: + +1. "Follow through" in the swipe direction, then fire `swipeend` event (as seen in [Demo]). That is, upon `pointerup`, the [CSS Custom Property] will be tweened to element's width / height. +2. Stop swipe action immediately and fire `swipeend` event. + +`followThrough` takes either a boolean or a config object with the following interface. + +```typescript title="SwipeFollowThrough.d.ts" +type SwipeFollowThrough = { + /** duration for the follow through animation */ + duration?: number; + /** easing function for the follow through animation */ + easing?: (t: number) => number; +} +``` + +The default `true`, i.e (1). Set to `false` for (2). + +### Flick + +It is typical to expect a quick swipe with high velocity ("flick") to bypass the threshold and be recognized as a complete swipe action (try this in [Demo]). This can be configured via the `SwipeableConfig.allowFlick`. + +```typescript title="SwipeableConfig.allowFlick" +interface SwipeableConfig { + // ... truncated ... + allowFlick?: boolean | ((ms: number, px: number) => boolean); +} +``` + +For complex configuration, you can provide a function that takes the duration in milliseconds and the distance in pixels, and returns a boolean to indicate whether the swipe should be considered a flick. The default is: + +```typescript title="default flick check" +// is flick if the duration is less than 170ms and the velocity is greater than 1px/ms +const DEFAULT_FLICK_CHECK = (ms, px) => ms < 170 && Math.abs(px / ms) > 1; +``` + +--- + +Happy swiping! + +[CustomEvent]: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent +[Demo]: #demo +[CSS Custom Property]: #css-custom-property + diff --git a/sites/docs/src/routes/docs/(package)/swipeable/+page.server.ts b/sites/docs/src/routes/docs/(package)/swipeable/+page.server.ts new file mode 100644 index 00000000..ef0b1eba --- /dev/null +++ b/sites/docs/src/routes/docs/(package)/swipeable/+page.server.ts @@ -0,0 +1,16 @@ +import ogImage from '$lib/assets/images/og/svelte-put-swipeable.jpg'; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const data = await parent(); + return { + meta: { + ...data.meta, + og: { + image: ogImage, + } + }, + }; +} + diff --git a/sites/docs/src/routes/docs/(package)/swipeable/_page/examples/demo.svelte b/sites/docs/src/routes/docs/(package)/swipeable/_page/examples/demo.svelte new file mode 100644 index 00000000..7855e990 --- /dev/null +++ b/sites/docs/src/routes/docs/(package)/swipeable/_page/examples/demo.svelte @@ -0,0 +1,96 @@ + + +
+

Swipe left to archive, swipe right to delete

+ +
+
    + {#each items as { id, title, excerpt } (id)} +
  • + {#if direction === 'right'} + + {/if} + + + +
    + + +

    + + >> + {title} +

    +
    +

    {excerpt}

    +
    + + {#if direction === 'left'} + + {/if} +
  • + {/each} +