diff --git a/.changeset/eleven-scissors-design.md b/.changeset/eleven-scissors-design.md new file mode 100644 index 0000000..fab7a6b --- /dev/null +++ b/.changeset/eleven-scissors-design.md @@ -0,0 +1,5 @@ +--- +"svelte-interactions": minor +--- + +feat: long press diff --git a/README.md b/README.md index e08068c..3afe682 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ type PressConfig = PressHandlers { The `PressConfig` object also includes handlers for all the different `PressHandlers`. These are provided as a convenience, should you prefer to handle the events here rather than the custom `on:press*` events dispatched by the element with the `pressAction`. -Be aware that event if you use these handlers, the custom `on:press*` events will still be dispatched, so be sure you aren't handling the same event twice. +Be aware that event if you use these handlers, the custom `on:press*` events for whatever handlers you use will not be dispatched to the element. We only dispatch the events that aren't handled by the `PressHandlers`. ```ts type PressHandlers = { @@ -160,7 +160,7 @@ type PressResult = { ### Custom Events -When you apply the `pressAction` to an element, it will dispatch custom `on:press*` events. You can use these and/or the `PressHandlers` to handle the various press events. +When you apply the `pressAction` to an element, it will dispatch custom `on:press*` events. You can use these or the `PressHandlers` to handle the various press events. ```ts type PressActionReturn = ActionReturn< @@ -229,6 +229,183 @@ interface PressEvent { } ``` +## Long Press Interaction + +The `hover` interaction provides an API for consistent long press behavior across all browsers and devices, with support for a custom time threshold and accessible description. + +#### Basic Usage + +```svelte + + + +``` + +### createLongPress + +Creates a new `longpress` interaction instance. Each element should have its own instance, as it maintains state for a single element. For example, if you had multiple buttons on a page: + +```svelte + + + + +``` + +#### LongPressConfig + +`createLongPress` takes in an optional `LongPressConfig` object, which can be used to customize the interaction. + +```ts +import { createLongPress } from 'svelte-interactions'; + +const { pressLongAction } = createPress({ isDisabled: true, threshold: 1000 }); +``` + +```ts +type LongPressConfig = LongPressHandlers & { + /** + * Whether the long press events should be disabled + */ + isDisabled?: boolean; + + /** + * The amount of time (in milliseconds) to wait before + * triggering a long press event. + */ + threshold?: number; + + /** + * A description for assistive techology users indicating that a + * long press action is available, e.g. "Long press to open menu". + */ + accessibilityDescription?: string; +}; +``` + +The `LongPressConfig` object also includes handlers for all the different `LongPressHandlers`. These are provided as a convenience, should you prefer to handle the events here rather than the custom `on:longpress*` events dispatched by the element with the `longPressAction`. + +Be aware that event if you use these handlers, the custom `on:longpress*` events for whatever handlers you use will not be dispatched to the element. We only dispatch the events that aren't handled by the `LongPressHandlers`. + +```ts +export type LongPressHandlers = { + /** + * Handler that is called when a long press interaction starts. + */ + onLongPressStart?: (e: LongPressEvent) => void; + + /** + * Handler that is called when a long press interaction ends, either + * over the target or when the pointer leaves the target. + */ + onLongPressEnd?: (e: LongPressEvent) => void; + + /** + * Handler that is called when the threshold time is met while + * the press is over the target. + */ + onLongPress?: (e: LongPressEvent) => void; +}; +``` + +### LongPressResult + +The `createLongPress` function returns a `LongPressResult` object, which contains the `longPressAction` action, and the `description` state. More returned properties may be added in the future if needed. + +```ts +type LongPressResult = { + /** + * A Svelte action which handles applying the event listeners + * and dispatching events to the element + */ + longPressAction: (node: HTMLElement | SVGElement) => LongPressActionReturn; + + /** + * A writable store to manage the accessible description for the long + * press action. It's initially populated with the value passed to the + * `accessibilityDescription` config option, but can be updated at any + * time by calling `description.set()`, and the new description will + * reflect in the DOM. + */ + accessibilityDescription: Writable; +}; +``` + +### Custom Events + +When you apply the `longPressAction` to an element, it will dispatch custom `on:longpress*` events for events you aren't handling via the `LongPressConfig` props. You can use these or the `LongPressHandlers` to handle the various `longpress` events. + +```ts +type LongPressActionReturn = ActionReturn< + undefined, + { + /** + * Dispatched when the threshold time is met while + * the press is over the target. + */ + 'on:longpress'?: (e: CustomEvent) => void; + + /** + * Dispatched when a long press interaction starts. + */ + 'on:longpressstart'?: (e: CustomEvent) => void; + + /** + * Dispatched when a long press interaction ends, either + * over the target or when the pointer leaves the target. + */ + 'on:longpressend'?: (e: CustomEvent) => void; + } +>; +``` + +#### PressEvent + +This is the event object dispatched by the custom `on:press*` events, and is also passed to the `PressHandlers` should you choose to use them. + +```ts +type PointerType = 'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual'; + +interface PressEvent { + /** The type of longpress event being fired. */ + type: 'longpressstart' | 'longpressend' | 'longpress'; + + /** The pointer type that triggered the press event. */ + pointerType: PointerType; + + /** The target element of the press event. */ + target: Element; + + /** Whether the shift keyboard modifier was held during the press event. */ + shiftKey: boolean; + + /** Whether the ctrl keyboard modifier was held during the press event. */ + ctrlKey: boolean; + + /** Whether the meta keyboard modifier was held during the press event. */ + metaKey: boolean; + + /** Whether the alt keyboard modifier was held during the press event. */ + altKey: boolean; +} +``` + ## Hover Interaction The `hover` interaction provides an API for consistent hover behavior across all browsers and devices, ignoring emulated mouse events on touch devices. @@ -292,7 +469,7 @@ type HoverConfig = HoverHandlers & { The `HoverConfig` object also includes handlers for all the different `HoverHandlers`. These are provided as a convenience, should you prefer to handle the events here rather than the custom `on:hover*` events dispatched by the element with the `hoverAction`. -Be aware that even if you use these handlers, the custom `on:hover*` events will still be dispatched, so be sure you aren't handling the same event twice. +Be aware that event if you use these handlers, the custom `on:hover*` events for whatever handlers you use will not be dispatched to the element. We only dispatch the events that aren't handled by the `HoverHandlers`. ```ts type HoverHandlers = { @@ -334,7 +511,7 @@ export type HoverResult = { ### Custom Events -When you apply the `hoverAction` to an element, it will dispatch custom `on:hover*` events. You can use these and/or the `HoverHandlers` to handle the various hover events. +When you apply the `hoverAction` to an element, it will dispatch custom `on:hover*` events. You can use these or the `HoverHandlers` to handle the various hover events. ```ts type HoverActionReturn = ActionReturn< diff --git a/src/lib/index.ts b/src/lib/index.ts index 6983190..6746771 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,2 +1 @@ -export * from './hover/index.js'; -export * from './press/index.js'; +export * from './interactions/index.js'; diff --git a/src/lib/hover/create.ts b/src/lib/interactions/hover/create.ts similarity index 96% rename from src/lib/hover/create.ts rename to src/lib/interactions/hover/create.ts index 5b6fc15..54f3a06 100644 --- a/src/lib/hover/create.ts +++ b/src/lib/interactions/hover/create.ts @@ -156,13 +156,15 @@ export function createHover(config?: HoverConfig): HoverResult { const event = new HoverEvent('hoverstart', pointerType, originalEvent); - onHoverStart?.({ - type: 'hoverstart', - target, - pointerType - }); - - dispatchHoverEvent(event); + if (onHoverStart) { + onHoverStart({ + type: 'hoverstart', + target, + pointerType + }); + } else { + dispatchHoverEvent(event); + } onHoverChange?.(true); @@ -178,12 +180,15 @@ export function createHover(config?: HoverConfig): HoverResult { state.update((curr) => ({ ...curr, isHovered: false })); const event = new HoverEvent('hoverend', pointerType, originalEvent); - onHoverEnd?.({ - type: 'hoverend', - target: currentTarget, - pointerType - }); - dispatchHoverEvent(event); + if (onHoverEnd) { + onHoverEnd({ + type: 'hoverend', + target: currentTarget, + pointerType + }); + } else { + dispatchHoverEvent(event); + } onHoverChange?.(false); diff --git a/src/lib/hover/events.ts b/src/lib/interactions/hover/events.ts similarity index 100% rename from src/lib/hover/events.ts rename to src/lib/interactions/hover/events.ts diff --git a/src/lib/hover/index.ts b/src/lib/interactions/hover/index.ts similarity index 100% rename from src/lib/hover/index.ts rename to src/lib/interactions/hover/index.ts diff --git a/src/lib/interactions/index.ts b/src/lib/interactions/index.ts new file mode 100644 index 0000000..308fba3 --- /dev/null +++ b/src/lib/interactions/index.ts @@ -0,0 +1,3 @@ +export * from './hover/index.js'; +export * from './press/index.js'; +export * from './long-press/index.js'; diff --git a/src/lib/interactions/long-press/create.ts b/src/lib/interactions/long-press/create.ts new file mode 100644 index 0000000..bab522d --- /dev/null +++ b/src/lib/interactions/long-press/create.ts @@ -0,0 +1,200 @@ +import type { ActionReturn } from 'svelte/action'; +import type { LongPressEvent as ILongPressEvent, LongPressHandlers } from './events.js'; +import type { PointerType } from '$lib/types/index.js'; +import { createPress, type PressEvent } from '$lib/interactions/press/index.js'; +import { createGlobalListeners } from '$lib/utils/globalListeners.js'; +import { effect } from '$lib/utils/effect.js'; +import { createDescription } from '$lib/utils/description.js'; +import { writable, type Writable } from 'svelte/store'; + +export type LongPressConfig = LongPressHandlers & { + /** + * Whether the long press events should be disabled + */ + isDisabled?: boolean; + + /** + * The amount of time (in milliseconds) to wait before + * triggering a long press event. + */ + threshold?: number; + + /** + * A description for assistive techology users indicating that a + * long press action is available, e.g. "Long press to open menu". + */ + accessibilityDescription?: string; +}; + +class LongPressEvent implements ILongPressEvent { + type: ILongPressEvent['type']; + pointerType: PointerType; + target: Element; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + #shouldStopPropagation = true; + + constructor( + type: ILongPressEvent['type'], + pointerType: PointerType, + pressEvent: Omit & { type: ILongPressEvent['type'] } + ) { + this.type = type; + this.pointerType = pointerType; + this.target = pressEvent.target; + this.shiftKey = pressEvent.shiftKey; + this.metaKey = pressEvent.metaKey; + this.ctrlKey = pressEvent.ctrlKey; + this.altKey = pressEvent.altKey; + } + + get shouldStopPropagation() { + return this.#shouldStopPropagation; + } +} + +type LongPressActionReturn = ActionReturn< + undefined, + { + 'on:longpress'?: (e: CustomEvent) => void; + 'on:longpressstart'?: (e: CustomEvent) => void; + 'on:longpressend'?: (e: CustomEvent) => void; + } +>; + +export type LongPressResult = { + /** + * A Svelte action which handles applying the event listeners + * and dispatching events to the element + */ + longPressAction: (node: HTMLElement | SVGElement) => LongPressActionReturn; + + /** + * A writable store to manage the accessible description for the long + * press action. It's initially populated with the value passed to the + * `accessibilityDescription` config option, but can be updated at any + * time by calling `description.set()`, and the new description will + * reflect in the DOM. + */ + accessibilityDescription: Writable; +}; + +const DEFAULT_THRESHOLD = 500; + +/** + * Handles long press interactions across mouse and touch devices. + * Supports a customizable time threshold,accessibility description, + * and normalizes behavior across browsers and devices. + */ +export function createLongPress(config?: LongPressConfig): LongPressResult { + const defaults = { + isDisabled: false, + threshold: DEFAULT_THRESHOLD + }; + const { + onLongPress, + onLongPressEnd, + onLongPressStart, + isDisabled, + threshold, + accessibilityDescription: accessibilityDescriptionProp + } = { + ...defaults, + ...config + }; + + let timeout: ReturnType | undefined = undefined; + let nodeEl: HTMLElement | SVGElement | null = null; + + const { addGlobalListener, removeGlobalListener } = createGlobalListeners(); + const accessibilityDescription = writable(accessibilityDescriptionProp); + + function dispatchLongPressEvent(longPressEvent: LongPressEvent) { + nodeEl?.dispatchEvent( + new CustomEvent(longPressEvent.type, { detail: longPressEvent }) + ); + } + + const { pressAction } = createPress({ + isDisabled, + onPressStart(e) { + e.continuePropagation(); + if (e.pointerType !== 'mouse' && e.pointerType !== 'touch') return; + + const startEvent = { ...e, type: 'longpressstart' as const }; + const event = new LongPressEvent('longpressstart', e.pointerType, startEvent); + onLongPressStart?.(startEvent); + dispatchLongPressEvent(event); + + timeout = setTimeout(() => { + // prevent other `press` handlers from handling this event + e.target.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true })); + const pressEvent = { ...e, type: 'longpress' as const }; + const event = new LongPressEvent('longpress', e.pointerType, pressEvent); + onLongPress?.(pressEvent); + dispatchLongPressEvent(event); + timeout = undefined; + }, threshold); + + // prevent context menu, which may open on long press on touch devices + if (e.pointerType !== 'touch') return; + const onContextMenu = (e: Event) => { + e.preventDefault(); + }; + + addGlobalListener(e.target, 'contextmenu', onContextMenu, { once: true }); + addGlobalListener( + window, + 'pointerup', + () => { + // If no contextmenu event is fired quickly after pointerup, + // remove the handler so future context menu events outside + // a long press are not prevented. + setTimeout(() => { + removeGlobalListener(e.target, 'contextmenu', onContextMenu); + }, 30); + }, + { once: true } + ); + }, + onPressEnd(e) { + if (timeout) { + clearTimeout(timeout); + } + + if (e.pointerType !== 'mouse' && e.pointerType !== 'touch') return; + + const endEvent = { ...e, type: 'longpressend' as const }; + const event = new LongPressEvent('longpressend', e.pointerType, endEvent); + onLongPressEnd?.(endEvent); + dispatchLongPressEvent(event); + } + }); + + const ariaDescribedBy = createDescription(accessibilityDescription); + + function longPressAction(node: HTMLElement | SVGElement): LongPressActionReturn { + nodeEl = node; + + const unsub = effect([ariaDescribedBy], ([$ariaDescribedBy]) => { + if (!$ariaDescribedBy) return; + node.setAttribute('aria-describedby', $ariaDescribedBy); + }); + + const { destroy } = pressAction(node); + + return { + destroy() { + unsub(); + destroy?.(); + } + }; + } + + return { + longPressAction, + accessibilityDescription + }; +} diff --git a/src/lib/interactions/long-press/events.ts b/src/lib/interactions/long-press/events.ts new file mode 100644 index 0000000..0f0db64 --- /dev/null +++ b/src/lib/interactions/long-press/events.ts @@ -0,0 +1,25 @@ +import type { PressEvent } from '$lib/interactions/press/events.js'; + +export interface LongPressEvent extends Omit { + /** The type of long press event being fired. */ + type: 'longpressstart' | 'longpressend' | 'longpress'; +} + +export type LongPressHandlers = { + /** + * Handler that is called when a long press interaction starts. + */ + onLongPressStart?: (e: LongPressEvent) => void; + + /** + * Handler that is called when a long press interaction ends, either + * over the target or when the pointer leaves the target. + */ + onLongPressEnd?: (e: LongPressEvent) => void; + + /** + * Handler that is called when the threshold time is met while + * the press is over the target. + */ + onLongPress?: (e: LongPressEvent) => void; +}; diff --git a/src/lib/press/index.ts b/src/lib/interactions/long-press/index.ts similarity index 100% rename from src/lib/press/index.ts rename to src/lib/interactions/long-press/index.ts diff --git a/src/lib/press/create.ts b/src/lib/interactions/press/create.ts similarity index 98% rename from src/lib/press/create.ts rename to src/lib/interactions/press/create.ts index 8c70a5c..f17e813 100644 --- a/src/lib/press/create.ts +++ b/src/lib/interactions/press/create.ts @@ -7,8 +7,8 @@ import type { ActionReturn } from 'svelte/action'; // prettier-ignore import { disableTextSelection, restoreTextSelection, focusWithoutScrolling, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, isVirtualPointerEvent, openLink, createGlobalListeners, toWritableStores, executeCallbacks, noop, addEventListener, isHTMLorSVGElement } from '$lib/utils/index.js'; -import type { PressEvent as IPressEvent, PointerType, PressHandlers } from './events.js'; -import type { FocusableElement } from '$lib/types/dom.js'; +import type { PressEvent as IPressEvent, PressHandlers } from './events.js'; +import type { FocusableElement, EventBase, PointerType } from '$lib/types/index.js'; export type PressConfig = PressHandlers & { /** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */ @@ -43,14 +43,6 @@ type PressState = { metaKeyEvents?: Map; }; -type EventBase = { - currentTarget: EventTarget | null; - shiftKey: boolean; - ctrlKey: boolean; - metaKey: boolean; - altKey: boolean; -}; - type PressActionReturn = ActionReturn< undefined, { @@ -158,8 +150,11 @@ export function createPress(config?: PressConfig): PressResult { state.update((curr) => ({ ...curr, isTriggeringEvent: true })); const event = new PressEvent('pressstart', pointerType, originalEvent); - onPressStart?.(event); - dispatchPressEvent(event); + if (onPressStart) { + onPressStart(event); + } else { + dispatchPressEvent(event); + } shouldStopPropagation = event.shouldStopPropagation; onPressChange?.(true); @@ -189,8 +184,11 @@ export function createPress(config?: PressConfig): PressResult { let shouldStopPropagation = true; const event = new PressEvent('pressend', pointerType, originalEvent); - onPressEnd?.(event); - dispatchPressEvent(event); + if (onPressEnd) { + onPressEnd(event); + } else { + dispatchPressEvent(event); + } shouldStopPropagation = event.shouldStopPropagation; onPressChange?.(false); @@ -199,8 +197,11 @@ export function createPress(config?: PressConfig): PressResult { const $isDisabled = get(opts.isDisabled); if (wasPressed && !$isDisabled) { const event = new PressEvent('press', pointerType, originalEvent); - onPress?.(event); - dispatchPressEvent(event); + if (onPress) { + onPress(event); + } else { + dispatchPressEvent(event); + } shouldStopPropagation &&= event.shouldStopPropagation; } @@ -219,8 +220,12 @@ export function createPress(config?: PressConfig): PressResult { state.update((curr) => ({ ...curr, isTriggeringEvent: true })); const event = new PressEvent('pressup', pointerType, originalEvent); - onPressUp?.(event); - dispatchPressEvent(event); + + if (onPressUp) { + onPressUp(event); + } else { + dispatchPressEvent(event); + } state.update((curr) => ({ ...curr, isTriggeringEvent: false })); return event.shouldStopPropagation; } diff --git a/src/lib/press/events.ts b/src/lib/interactions/press/events.ts similarity index 95% rename from src/lib/press/events.ts rename to src/lib/interactions/press/events.ts index 19b45e9..89b6533 100644 --- a/src/lib/press/events.ts +++ b/src/lib/interactions/press/events.ts @@ -1,7 +1,8 @@ // Portions of the code in this file are based on code from Adobe. // Original licensing for the following can be found in the NOTICE.txt // file in the root directory of this source tree. -export type PointerType = 'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual'; + +import type { PointerType } from '$lib/types/events.js'; export interface PressEvent { /** The type of press event being fired. */ diff --git a/src/lib/interactions/press/index.ts b/src/lib/interactions/press/index.ts new file mode 100644 index 0000000..6900ec5 --- /dev/null +++ b/src/lib/interactions/press/index.ts @@ -0,0 +1,2 @@ +export * from './create.js'; +export * from './events.js'; diff --git a/src/lib/types/dom.ts b/src/lib/types/dom.ts index 1a14ad7..2cc2d10 100644 --- a/src/lib/types/dom.ts +++ b/src/lib/types/dom.ts @@ -1,11 +1,2 @@ /** Any focusable element, including both HTML and SVG elements. */ export type FocusableElement = HTMLElement | SVGElement; - -/** - * A type alias for a general event listener function. - * - * @template E - The type of event to listen for - * @param evt - The event object - * @returns The return value of the event listener function - */ -export type GeneralEventListener = (evt: E) => unknown; diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 81b47c5..bd77e5a 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -1 +1,11 @@ export type GeneralEventListener = (evt: E) => unknown; + +export type EventBase = { + currentTarget: EventTarget | null; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; +}; + +export type PointerType = 'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual'; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 0000000..c34fd47 --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,2 @@ +export * from './dom.js'; +export * from './events.js'; diff --git a/src/lib/utils/browser.ts b/src/lib/utils/browser.ts new file mode 100644 index 0000000..9294730 --- /dev/null +++ b/src/lib/utils/browser.ts @@ -0,0 +1 @@ +export const isBrowser = typeof document !== 'undefined'; diff --git a/src/lib/utils/description.ts b/src/lib/utils/description.ts new file mode 100644 index 0000000..b44cb39 --- /dev/null +++ b/src/lib/utils/description.ts @@ -0,0 +1,51 @@ +// Portions of the code in this file are based on code from Adobe. +// Original licensing for the following can be found in the NOTICE.txt +// file in the root directory of this source tree. + +import { derived, writable, type Writable } from 'svelte/store'; +import { effect, isBrowser } from '$lib/utils/index.js'; + +let descriptionId = 0; + +type DescriptionNode = { nodeCount: number; element: Element }; +const descriptionNodes = new Map(); + +export function createDescription(description: Writable) { + const id = writable(undefined); + + effect([description], ([$description]) => { + if (!$description || !isBrowser) return; + + let desc = descriptionNodes.get($description); + if (!desc) { + const newId = `si-description-${descriptionId++}`; + id.set(newId); + + const node = document.createElement('div'); + node.id = newId; + node.style.display = 'none'; + node.textContent = $description; + document.body.appendChild(node); + desc = { nodeCount: 0, element: node }; + descriptionNodes.set($description, desc); + } else { + id.set(desc.element.id); + } + + desc.nodeCount++; + return () => { + if (!desc) return; + + if (--desc.nodeCount === 0) { + desc.element.remove(); + descriptionNodes.delete($description); + } + }; + }); + + const ariaDescribedBy = derived([id, description], ([$id, $description]) => { + return $description ? $id : undefined; + }); + + return ariaDescribedBy; +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 74fd307..ab154df 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -13,3 +13,5 @@ export * from './platform.js'; export * from './runAfterTransition.js'; export * from './textSelection.js'; export * from './toWritableStores.js'; +export * from './description.js'; +export * from './browser.js'; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 257f471..e438e90 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,12 +1,14 @@