Skip to content

Commit

Permalink
feat: onLongPress directive (#31)
Browse files Browse the repository at this point in the history
Adds the `onLongPress` directive.

For example:

```ts
const fn = () => {
  console.log('i was long pressed');
};

html`
  <div ${onLongPress(fn)}>
    Long press me!
  </div>
`;
```
  • Loading branch information
43081j authored Jun 12, 2024
1 parent fcc2cb1 commit fd1a039
Show file tree
Hide file tree
Showing 6 changed files with 388 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The following is the full list of available utilities:
- [keyBinding](./keyBinding.md) - key bindings (shortcuts) manager
- [localStorage](./localStorage.md) - items in [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)
- [markdown](./markdown.md) - markdown processing via [marked](https://github.com/markedjs/marked)
- [onLongPress](./onLongPress.md) - fire callback on long press
- [permissions](./permissions.md) - track the state of a browser [permission](https://developer.mozilla.org/en-US/docs/Web/API/Permissions/query)
- [propertyHistory](./propertyHistory.md) - track the history of a property with undo/redo
- [sessionStorage](./sessionStorage.md) - items in [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)
Expand Down
30 changes: 30 additions & 0 deletions docs/onLongPress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# onLongPress

`onLongPress` allows you to bind a callback to a long press occurring (i.e.
a pointer being held down for a specified amount of time).

## Usage

```ts
class MyElement extends LitElement {
render() {
return html`
<div ${onLongPress(this._onLongPress)}>
Long press me!
</div>
`;
}
}
```

This will call the `_onLongPress` method when the user holds their pointer
down for 1 second (the default).

The parameters (`onLongPress(fn, time)`) are as follows:

- `fn` - a function to call when the pointer has been held long enough
- `time` - the time in milliseconds to consider a press being 'long'

## Options

N/A
200 changes: 200 additions & 0 deletions src/directives/onLongPress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import {ElementPart, noChange} from 'lit';
import {AsyncDirective, directive, PartType} from 'lit/async-directive.js';
import type {
DirectiveParameters,
DirectiveClass,
DirectiveResult,
PartInfo
} from 'lit/async-directive.js';

export type LongPressCallback = (event: PointerEvent) => void;

const DEFAULT_LONG_PRESS_TIMEOUT_MS: number = 1000;

/**
* Calls a callback when the pointer has been held down for a specified
* duration
*/
class LongPressDirective extends AsyncDirective {
/** Element of the directive. */
private __element?: Element;

/**
* Long press timeout.
* This timeout initiates when the pointer is pressed on the
* element. It calls user's callback unless cancel events occur
* before time's out.
*/
private __longPressTimer?: number;

/** Time before the timeout runs out. */
private __longPressTimeoutMs: number = DEFAULT_LONG_PRESS_TIMEOUT_MS;

/** User-defined callback for long-press event */
private __longPressCallback?: LongPressCallback;

/** @inheritdoc */
public constructor(partInfo: PartInfo) {
super(partInfo);

if (partInfo.type !== PartType.ELEMENT) {
throw new Error(
'The `onLongPress` directive must be used in an element binding'
);
}
}

/** @inheritdoc */
public render(
_callback: LongPressCallback,
_callbackTimeoutMs?: number
): unknown {
return noChange;
}

/** @inheritdoc */
public override update(
part: ElementPart,
[callback, callbackTimeoutMs]: DirectiveParameters<this>
): unknown {
if (part.element !== this.__element) {
this.__setElement(part.element);
}

this.__longPressCallback = callback;
this.__longPressTimeoutMs =
callbackTimeoutMs ?? DEFAULT_LONG_PRESS_TIMEOUT_MS;

return this.render(callback, callbackTimeoutMs);
}

/**
* Sets the element and its handlers
* @param {Element} element Element to set
* @return {void}
*/
private __setElement(element: Element) {
// Detach events from previous element
if (this.__element) {
this.__removeListenersFromElement(this.__element);
}
this.__element = element;
this.__addListenersToElement(element);
}

/**
* Removes any associated listeners from the given element
* @param {Element} node Element to remove listeners from
* @return {void}
*/
private __removeListenersFromElement(node: Element): void {
// cast to get strongly typed events (sadtimes)
const element = node as HTMLElement;
element.removeEventListener('pointerdown', this.__onPointerDown);
element.removeEventListener('pointerup', this.__onPointerUp);
element.removeEventListener('pointerleave', this.__onPointerLeave);
}

/**
* Adds any associated listeners to the given element
* @param {Element} node Element to add listeners to
* @return {void}
*/
private __addListenersToElement(node: Element): void {
// cast to get strongly typed events (sadtimes)
const element = node as HTMLElement;
element.addEventListener('pointerdown', this.__onPointerDown);
element.addEventListener('pointerup', this.__onPointerUp);
element.addEventListener('pointerleave', this.__onPointerLeave);
}

/**
* Fired when the pointer is down/pressed
* @param {PointerEvent} e Event fired
* @return {void}
*/
private __onPointerDown = (e: PointerEvent): void => {
// TODO: When the mouse is released and long press event
// was accepted, we should find a way to cancel the @click
// event listener if it exists.
this.__initiateTimer(e);
};

/**
* Fired when the pointer is up/released
* @return {void}
*/
private __onPointerUp = (): void => {
this.__clearTimer();
};

/**
* Fired when the pointer leaves the host
* @return {void}
*/
private __onPointerLeave = (): void => {
this.__clearTimer();
};

/**
* Start the long press timeout.
* @returns {void}
*/
private __initiateTimer(e: PointerEvent): void {
this.__longPressTimer = setTimeout(() => {
if (this.__longPressCallback) {
this.__longPressCallback(e);
}
}, this.__longPressTimeoutMs);
}

/**
* Cancel the long press timeout.
* This function is called when the user releases the mouse
* or when the mouse leaves the element.
* @return {void}
*/
private __clearTimer(): void {
clearTimeout(this.__longPressTimer);
}

/** @inheritdoc */
public override reconnected(): void {
if (this.__element) {
this.__addListenersToElement(this.__element);
}
}

/** @inheritdoc */
public override disconnected(): void {
if (this.__element) {
this.__removeListenersFromElement(this.__element);
}
}
}

const onLongPressDirective = directive(LongPressDirective);

/**
* Calls the `callback` function when the user has held their pointer down
* for the specified duration (default 1s).
*
* For example:
*
* ```ts
* html`
* <div ${onLongPress(fn)}>Long press me!</div>
* `;
* ```
*
* @param {LongPressCallback} callback Function to call on long press
* @param {number=} callbackTimeoutMs Time to wait before considering the event
* to be a long press
* @return {DirectiveResult}
*/
export function onLongPress(
callback: LongPressCallback,
callbackTimeoutMs?: number
): DirectiveResult<DirectiveClass> {
return onLongPressDirective(callback, callbackTimeoutMs);
}
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './controllers/sessionStorage.js';
export * from './controllers/slot.js';
export * from './controllers/windowScroll.js';
export * from './directives/bindInput.js';
export * from './directives/onLongPress.js';
152 changes: 152 additions & 0 deletions src/test/directives/onLongPress_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import '../util.js';

import {html} from 'lit';
import * as assert from 'uvu/assert';
import {onLongPress} from '../../main.js';
import * as hanbi from 'hanbi';
import {TestElement, delay} from '../util.js';

suite('onLongPress directive', () => {
let element: TestElement;

setup(async () => {
element = document.createElement('test-element');
document.body.appendChild(element);
});

teardown(() => {
element.remove();
hanbi.restore();
});

test('throws on non-element binding', async () => {
try {
const callback = (): void => {
return;
};
element.template = () => html`
<div>${onLongPress(callback)}</div>
`;
await element.updateComplete;
assert.unreachable();
} catch (err) {
assert.is(
(err as Error).message,
'The `onLongPress` directive must be used in an element binding'
);
}
});

test('calls callback after timeout', async () => {
const callback = hanbi.spy();
element.template = () => html`
<div ${onLongPress(callback.handler, 10)}></div>
`;
await element.updateComplete;

const div = element.shadowRoot!.querySelector('div')!;
const ev = new PointerEvent('pointerdown');

div.dispatchEvent(ev);
await delay(12);

assert.equal(callback.called, true);
assert.equal(callback.firstCall!.args, [ev]);
});

test('does not call callback if pointer up before timer', async () => {
const callback = hanbi.spy();
element.template = () => html`
<div ${onLongPress(callback.handler, 10)}></div>
`;
await element.updateComplete;

const div = element.shadowRoot!.querySelector('div')!;
const pointerDown = new PointerEvent('pointerdown');
const pointerUp = new PointerEvent('pointerup');

div.dispatchEvent(pointerDown);
await delay(5);
div.dispatchEvent(pointerUp);
await delay(10);

assert.equal(callback.called, false);
});

test('does not call callback if pointer leaves before timer', async () => {
const callback = hanbi.spy();
element.template = () => html`
<div ${onLongPress(callback.handler, 10)}></div>
`;
await element.updateComplete;

const div = element.shadowRoot!.querySelector('div')!;
const pointerDown = new PointerEvent('pointerdown');
const pointerLeave = new PointerEvent('pointerleave');

div.dispatchEvent(pointerDown);
await delay(5);
div.dispatchEvent(pointerLeave);
await delay(10);

assert.equal(callback.called, false);
});

test('does not call callback if disconnected', async () => {
const callback = hanbi.spy();
element.template = () => html`
<div ${onLongPress(callback.handler, 10)}></div>
`;
await element.updateComplete;

const div = element.shadowRoot!.querySelector('div')!;
const ev = new PointerEvent('pointerdown');

element.remove();

div.dispatchEvent(ev);
await delay(12);

assert.equal(callback.called, false);
});

test('survives reconnection to dom', async () => {
const callback = hanbi.spy();
element.template = () => html`
<div ${onLongPress(callback.handler, 10)}></div>
`;
await element.updateComplete;

const div = element.shadowRoot!.querySelector('div')!;
const ev = new PointerEvent('pointerdown');

element.remove();
document.body.appendChild(element);
await element.updateComplete;

div.dispatchEvent(ev);
await delay(12);

assert.equal(callback.called, true);
});

test('applies default timeout if none set', async () => {
const callback = hanbi.spy();
element.template = () => html`
<div ${onLongPress(callback.handler)}></div>
`;
await element.updateComplete;

const div = element.shadowRoot!.querySelector('div')!;
const ev = new PointerEvent('pointerdown');

div.dispatchEvent(ev);
await delay(500);

assert.equal(callback.called, false);

await delay(600);

assert.equal(callback.called, true);
});
});
Loading

0 comments on commit fd1a039

Please sign in to comment.