initial commit

This commit is contained in:
Hunter Johnston
2024-01-05 22:31:25 -05:00
commit 9c089b1630
34 changed files with 5017 additions and 0 deletions

13
.eslintignore Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

31
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,31 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.DS_Store
node_modules
/build
/dist
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

200
NOTICE.txt Normal file
View File

@@ -0,0 +1,200 @@
svelte-press-events
=================
The following is a list of sources from which code was used/modified in this codebase.
-------------------------------------------------------------------------------
This codebase contains a modified portion of code from Adobe which can be obtained at:
* SOURCE:
* https://www.github.com/adobe/react-spectrum/
* LICENSE:
* https://github.com/adobe/react-spectrum/blob/main/LICENSE
* SOURCE:
* https://www.npmjs.com/package/@adobe/react-spectrum-ui
* LICENSE:
* https://unpkg.com/@adobe/react-spectrum-ui@1.0.1/LICENSE
* SOURCE:
* https://www.npmjs.com/package/@adobe/react-spectrum-workflow
* LICENSE:
* https://unpkg.com/@adobe/react-spectrum-workflow@1.0.1/LICENSE
* SOURCE:
* https://www.npmjs.com/package/@adobe/react-spectrum-workflow-color
* LICENSE:
* https://unpkg.com/@adobe/react-spectrum-workflow-color@1.0.1/LICENSE
-------------------------------------------------------------------------------
This codebase contains a portion of code from react which can be obtained at:
* SOURCE:
* https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
* LICENSE:
MIT License
Copyright (c) Facebook, Inc. and its affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-------------------------------------------------------------------------------
This codebase contains a modified portion of code from react-window which can be obtained at:
* SOURCE:
* https://github.com/bvaughn/react-window/blob/master/src/createGridComponent.js
* LICENSE:
The MIT License (MIT)
Copyright (c) 2018 Brian Vaughn
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------------
This codebase contains a modified portion of code from focus-options-polyfill which can be obtained at:
* SOURCE:
* https://github.com/calvellido/focus-options-polyfill
* LICENSE:
MIT License
Copyright (c) 2018 Juan Valencia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-------------------------------------------------------------------------------
This codebase contains a portion of code that vuejs adapted from jest-dom which can be obtained at:
* SOURCE:
* https://github.com/vuejs/vue-test-utils-next/blob/master/src/utils/isElementVisible.ts
* LICENSE:
* https://github.com/vuejs/vue-test-utils-next/blob/master/LICENSE
* SOURCE:
* https://github.com/testing-library/jest-dom/blob/main/src/to-be-visible.js
* LICENSE:
* https://github.com/testing-library/jest-dom/blob/main/LICENSE
------------------------------------------------------------------------------
This codebase contains a modified portion of code from ICU which can be obtained at:
* SOURCE:
* https://github.com/unicode-org/icu
* LICENSE:
COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later)
Copyright © 1991-2020 Unicode, Inc. All rights reserved.
Distributed under the Terms of Use in https://www.unicode.org/copyright.html.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Unicode data files and any associated documentation
(the "Data Files") or Unicode software and any associated documentation
(the "Software") to deal in the Data Files or Software
without restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, and/or sell copies of
the Data Files or Software, and to permit persons to whom the Data Files
or Software are furnished to do so, provided that either
(a) this copyright and permission notice appear with all copies
of the Data Files or Software, or
(b) this copyright and permission notice appear in associated
Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT OF THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS
NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL
DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THE DATA FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder
shall not be used in advertising or otherwise to promote the sale,
use or other dealings in these Data Files or Software without prior
written authorization of the copyright holder.
-------------------------------------------------------------------------------
This codebase contains a modified portion of code from the TC39 Temporal proposal which can be obtained at:
* SOURCE:
* https://github.com/tc39/proposal-temporal
* LICENSE:
Copyright (c) 2017, 2018, 2019, 2020
Ecma International. All rights reserved.
All Software contained in this document ("Software") is protected by copyright
and is being made available under the "BSD License", included below.
This Software may be subject to third party rights (rights from parties other
than Ecma International), including patent rights, and no licenses under such
third party rights are granted under this license even if the third party
concerned is a member of Ecma International.
SEE THE ECMA CODE OF CONDUCT IN PATENT MATTERS AVAILABLE AT
https://ecma-international.org/memento/codeofconduct.htm
FOR INFORMATION REGARDING THE LICENSING OF PATENT CLAIMS THAT ARE REQUIRED TO
IMPLEMENT ECMA INTERNATIONAL STANDARDS.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the authors nor Ecma International may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE ECMA INTERNATIONAL "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL ECMA INTERNATIONAL BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.

200
README.md Normal file
View File

@@ -0,0 +1,200 @@
# Svelte Interactions
At surface level, interactions may seem like a simple concept, but once you start peeling back the onion they are quite complex. For something as simple as a button to behave properly across all browsers and devices, you need more than just a `click` event handler.
If you aren't convinced, it's highly recommended to read [this three-part blog post](https://react-spectrum.adobe.com/blog/building-a-button-part-1.html), which goes into detail about the complexities of interactions.
This project is heavily inspired by that article and contains code derived from [React Aria's](https://react-spectrum.adobe.com) Interactions packages. It aims to provide a similar API for Svelte, in the form of [Svelte Actions](https://svelte.dev/docs/svelte-action) and eventually spreadable event attributes (once Svelte 5 is released).
## Press Interaction
The `press` interaction is used to implement buttons, links, and other pressable elements. It handles mouse, touch, and keyboard interactions, and ensures that the element is accessible to screen readers and keyboard users.
No more having to wrangle all those event handlers yourself! Just and use the `press` action along with the different `PressEvents` to provide a consistent experience across all browsers and devices.
#### Basic Usage
```svelte
<script lang="ts">
import { initPress } from 'svelte-interactions';
const { pressAction } = initPress();
</script>
<button
use:pressAction
on:press={(e) => {
console.log('you just pressed a button!', e);
}}
>
Press Me
</button>
```
### initPress
Creates a new `press` 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
<script lang="ts">
import { initPress } from 'svelte-interactions';
const { pressAction: pressOne } = initPress();
const { pressAction: pressTwo } = initPress();
</script>
<button use:pressOne on:press> Button One </button>
<button use:pressTwo on:press> Button Two </button>
```
#### PressConfig
`initPress` takes in an optional `PressConfig` object, which can be used to customize the interaction.
```ts
type PressConfig = PressEvents {
/**
* Whether the target is in a controlled press state
* (e.g. an overlay it triggers is open).
*
* @default false
*/
isPressed?: boolean;
/**
* Whether the press events should be disabled.
*
* @default false
*/
isDisabled?: boolean;
/**
* Whether the target should not receive focus on press.
*
* @default false
*/
preventFocusOnPress?: boolean;
/**
* Whether press events should be canceled when the pointer
* leaves the target while pressed. By default, this is
* `false`, which means if the pointer returns back over
* the target while pressed, `pressstart`/`onPressStart`
* will be fired again. If set to `true`, the press is
* canceled when the pointer leaves the target and
* `pressstart`/`onPressStart` will not be fired if the
* pointer returns.
*
* @default false
*/
shouldCancelOnPointerExit?: boolean;
/**
* Whether text selection should be enabled on the pressable element.
*/
allowTextSelectionOnPress?: boolean;
};
```
The `PressConfig` object also includes handlers for all the different `PressEvents`. 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 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.
```ts
type PressEvents = {
/**
* Handler that is called when the press is released
* over the target.
*/
onPress?: (e: PressEvent) => void;
/**
* Handler that is called when a press interaction starts.
*/
onPressStart?: (e: PressEvent) => void;
/**
* Handler that is called when a press interaction ends,
* either over the target or when the pointer leaves the target.
*/
onPressEnd?: (e: PressEvent) => void;
/**
* Handler that is called when the press state changes.
*/
onPressChange?: (isPressed: boolean) => void;
/**
* Handler that is called when a press is released over the
* target, regardless of whether it started on the target or
* not.
*/
onPressUp?: (e: PressEvent) => void;
};
```
### Custom Events
When you apply the `pressAction` to an element, it will dispatch custom `on:press*` events. You can use either these or the `PressEvents` handlers provided by `initPress` to handle the different press events.
```ts
type CustomEvents = {
/**
* Event dispatched when the press is released over the target.
*/
'on:press'?: (e: CustomEvent<PressEvent>) => void;
/**
* Event dispatched when a press interaction starts.
*/
'on:pressstart'?: (e: CustomEvent<PressEvent>) => void;
/**
* Event dispatched when a press interaction ends,
* either over the target or when the pointer leaves the target.
*/
'on:pressend'?: (e: CustomEvent<PressEvent>) => void;
/**
* Event dispatched when a press is released over the target,
* regardless of whether it started on the target or not.
*/
'on:pressup'?: (e: CustomEvent<PressEvent>) => void;
};
```
#### PressEvent
```ts
type PointerType = 'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual';
export interface PressEvent {
/** The type of press event being fired. */
type: 'pressstart' | 'pressend' | 'pressup' | 'press';
/** 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;
/**
* By default, press events stop propagation to parent elements.
* In cases where a handler decides not to handle a specific event,
* it can call `continuePropagation()` to allow a parent to handle it.
*/
continuePropagation(): void;
}
```

56
package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "svelte-interactions",
"version": "0.0.0",
"scripts": {
"dev": "vite dev",
"build": "vite build && npm run package",
"preview": "vite preview",
"package": "svelte-kit sync && svelte-package && publint",
"prepublishOnly": "npm run package",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
}
},
"files": [
"dist",
"!dist/**/*.test.*",
"!dist/**/*.spec.*"
],
"peerDependencies": {
"svelte": "^4.0.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/package": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "8.56.0",
"@types/node": "^20.10.6",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"publint": "^0.1.9",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"user-agent-data-types": "^0.4.2",
"vite": "^5.0.3",
"vitest": "^1.0.0"
},
"svelte": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module"
}

2586
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div>%sveltekit.body%</div>
</body>
</html>

7
src/index.test.ts Normal file
View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

4
src/lib/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { initPress, type PressConfig } from './press.js';
import type { PressEvent } from './types/events.js';
export { initPress, type PressEvent, type PressConfig };

1017
src/lib/press.ts Normal file

File diff suppressed because it is too large Load Diff

2
src/lib/types/dom.ts Normal file
View File

@@ -0,0 +1,2 @@
/** Any focusable element, including both HTML and SVG elements. */
export type FocusableElement = HTMLElement | SVGElement;

146
src/lib/types/events.ts Normal file
View File

@@ -0,0 +1,146 @@
// Portions of the code in this file are based on code from Adobe.
// Original licensing for the following can be found:
// See https://github.com/adobe/react-spectrum
export type PointerType = 'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual';
export interface PressEvent {
/** The type of press event being fired. */
type: 'pressstart' | 'pressend' | 'pressup' | 'press';
/** 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;
/**
* By default, press events stop propagation to parent elements.
* In cases where a handler decides not to handle a specific event,
* it can call `continuePropagation()` to allow a parent to handle it.
*/
continuePropagation(): void;
}
export interface LongPressEvent extends Omit<PressEvent, 'type' | 'continuePropagation'> {
/** The type of long press event being fired. */
type: 'longpressstart' | 'longpressend' | 'longpress';
}
export interface HoverEvent {
/** The type of hover event being fired. */
type: 'hoverstart' | 'hoverend';
/** The pointer type that triggered the hover event. */
pointerType: 'mouse' | 'pen';
/** The target element of the hover event. */
target: HTMLElement;
}
export interface KeyboardEvents {
/** Handler that is called when a key is pressed. */
onKeyDown?: (e: KeyboardEvent) => void;
/** Handler that is called when a key is released. */
onKeyUp?: (e: KeyboardEvent) => void;
}
export interface FocusEvents {
/** Handler that is called when the element receives focus. */
onFocus?: (e: FocusEvent) => void;
/** Handler that is called when the element loses focus. */
onBlur?: (e: FocusEvent) => void;
/** Handler that is called when the element's focus status changes. */
onFocusChange?: (isFocused: boolean) => void;
}
export interface HoverEvents {
/** Handler that is called when a hover interaction starts. */
onHoverStart?: (e: HoverEvent) => void;
/** Handler that is called when a hover interaction ends. */
onHoverEnd?: (e: HoverEvent) => void;
/** Handler that is called when the hover state changes. */
onHoverChange?: (isHovering: boolean) => void;
}
export type PressEvents = {
/** Handler that is called when the press is released over the target. */
onPress?: (e: PressEvent) => void;
/** Handler that is called when a press interaction starts. */
onPressStart?: (e: PressEvent) => void;
/**
* Handler that is called when a press interaction ends, either
* over the target or when the pointer leaves the target.
*/
onPressEnd?: (e: PressEvent) => void;
/** Handler that is called when the press state changes. */
onPressChange?: (isPressed: boolean) => void;
/**
* Handler that is called when a press is released over the target, regardless of
* whether it started on the target or not.
*/
onPressUp?: (e: PressEvent) => void;
};
export interface FocusableProps extends FocusEvents, KeyboardEvents {
/** Whether the element should receive focus on render. */
autoFocus?: boolean;
}
interface BaseMoveEvent {
/** The pointer type that triggered the move event. */
pointerType: PointerType;
/** Whether the shift keyboard modifier was held during the move event. */
shiftKey: boolean;
/** Whether the ctrl keyboard modifier was held during the move event. */
ctrlKey: boolean;
/** Whether the meta keyboard modifier was held during the move event. */
metaKey: boolean;
/** Whether the alt keyboard modifier was held during the move event. */
altKey: boolean;
}
export interface MoveStartEvent extends BaseMoveEvent {
/** The type of move event being fired. */
type: 'movestart';
}
export interface MoveMoveEvent extends BaseMoveEvent {
/** The type of move event being fired. */
type: 'move';
/** The amount moved in the X direction since the last event. */
deltaX: number;
/** The amount moved in the Y direction since the last event. */
deltaY: number;
}
export interface MoveEndEvent extends BaseMoveEvent {
/** The type of move event being fired. */
type: 'moveend';
}
export type MoveEvent = MoveStartEvent | MoveMoveEvent | MoveEndEvent;
export interface MoveEvents {
/** Handler that is called when a move interaction starts. */
onMoveStart?: (e: MoveStartEvent) => void;
/** Handler that is called when the element is moved. */
onMove?: (e: MoveMoveEvent) => void;
/** Handler that is called when a move interaction ends. */
onMoveEnd?: (e: MoveEndEvent) => void;
}
export interface ScrollEvent {
/** The amount moved in the X direction since the last event. */
deltaX: number;
/** The amount moved in the Y direction since the last event. */
deltaY: number;
}
export interface ScrollEvents {
/** Handler that is called when the scroll wheel moves. */
onScroll?: (e: ScrollEvent) => void;
}

View File

@@ -0,0 +1,32 @@
type NonEmptyArray<T> = [T, ...T[]];
/**
* A callback function that takes an array of arguments of type `T` and returns `void`.
* @template T The types of the arguments that the callback function takes.
*/
export type Callback<T extends unknown[] = unknown[]> = (...args: T) => void;
/**
* Executes an array of callback functions with the same arguments.
* @template T The types of the arguments that the callback functions take.
* @param n array of callback functions to execute.
* @returns A new function that executes all of the original callback functions with the same arguments.
*/
export function executeCallbacks<T extends unknown[]>(
...callbacks: NonEmptyArray<Callback<T>>
): (...args: T) => void {
return (...args) => {
for (const callback of callbacks) {
if (typeof callback === 'function') {
callback(...args);
}
}
};
}
/**
* A no operation function (does nothing)
*/
export function noop() {
//
}

View File

@@ -0,0 +1,56 @@
/**
* 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<E = Event> = (evt: E) => unknown;
/**
* Overloaded function signatures for addEventListener
*/
export function addEventListener<E extends keyof HTMLElementEventMap>(
target: Window,
event: E,
handler: (this: Window, ev: HTMLElementEventMap[E]) => unknown,
options?: boolean | AddEventListenerOptions
): VoidFunction;
export function addEventListener<E extends keyof HTMLElementEventMap>(
target: Document,
event: E,
handler: (this: Document, ev: HTMLElementEventMap[E]) => unknown,
options?: boolean | AddEventListenerOptions
): VoidFunction;
export function addEventListener<E extends keyof HTMLElementEventMap>(
target: EventTarget,
event: E,
handler: GeneralEventListener<HTMLElementEventMap[E]>,
options?: boolean | AddEventListenerOptions
): VoidFunction;
/**
* Adds an event listener to the specified target element(s) for the given event(s), and returns a function to remove it.
* @param target The target element(s) to add the event listener to.
* @param event The event(s) to listen for.
* @param handler The function to be called when the event is triggered.
* @param options An optional object that specifies characteristics about the event listener.
* @returns A function that removes the event listener from the target element(s).
*/
export function addEventListener(
target: Window | Document | EventTarget,
event: string | string[],
handler: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) {
const events = Array.isArray(event) ? event : [event];
// Add the event listener to each specified event for the target element(s).
events.forEach((_event) => target.addEventListener(_event, handler, options));
// Return a function that removes the event listener from the target element(s).
return () => {
events.forEach((_event) => target.removeEventListener(_event, handler, options));
};
}

View File

@@ -0,0 +1,82 @@
// Portions of the code in this file are based on code from Adobe.
// Original licensing for the following can be found:
// See https://github.com/adobe/react-spectrum/blob/main/LICENSE
import type { FocusableElement } from '../types/dom.js';
// This is a polyfill for element.focus({preventScroll: true});
// Currently necessary for Safari and old Edge:
// https://caniuse.com/#feat=mdn-api_htmlelement_focus_preventscroll_option
// See https://bugs.webkit.org/show_bug.cgi?id=178583
//
interface ScrollableElement {
element: HTMLElement;
scrollTop: number;
scrollLeft: number;
}
export function focusWithoutScrolling(element: FocusableElement) {
if (supportsPreventScroll()) {
element.focus({ preventScroll: true });
} else {
const scrollableElements = getScrollableElements(element);
element.focus();
restoreScrollPosition(scrollableElements);
}
}
let supportsPreventScrollCached: boolean | null = null;
function supportsPreventScroll() {
if (supportsPreventScrollCached == null) {
supportsPreventScrollCached = false;
try {
const focusElem = document.createElement('div');
focusElem.focus({
get preventScroll() {
supportsPreventScrollCached = true;
return true;
}
});
} catch (e) {
// Ignore
}
}
return supportsPreventScrollCached;
}
function getScrollableElements(element: FocusableElement): ScrollableElement[] {
let parent = element.parentNode;
const scrollableElements: ScrollableElement[] = [];
const rootScrollingElement = document.scrollingElement || document.documentElement;
while (parent instanceof HTMLElement && parent !== rootScrollingElement) {
if (parent.offsetHeight < parent.scrollHeight || parent.offsetWidth < parent.scrollWidth) {
scrollableElements.push({
element: parent,
scrollTop: parent.scrollTop,
scrollLeft: parent.scrollLeft
});
}
parent = parent.parentNode;
}
if (rootScrollingElement instanceof HTMLElement) {
scrollableElements.push({
element: rootScrollingElement,
scrollTop: rootScrollingElement.scrollTop,
scrollLeft: rootScrollingElement.scrollLeft
});
}
return scrollableElements;
}
function restoreScrollPosition(scrollableElements: ScrollableElement[]) {
for (const { element, scrollTop, scrollLeft } of scrollableElements) {
element.scrollTop = scrollTop;
element.scrollLeft = scrollLeft;
}
}

View File

@@ -0,0 +1,14 @@
export const getOwnerDocument = (el: Element | null | undefined): Document => {
return el?.ownerDocument ?? document;
};
export const getOwnerWindow = (
el: (Window & typeof global) | Element | null | undefined
): Window & typeof global => {
if (el && 'window' in el && el.window === el) {
return el;
}
const doc = getOwnerDocument(el as Element | null | undefined);
return doc.defaultView || window;
};

View File

@@ -0,0 +1,95 @@
import { safeOnDestroy } from './lifecycle.js';
/**
* 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<E = Event> = (evt: E) => unknown;
interface GlobalListeners {
addGlobalListener<E extends keyof HTMLElementEventMap>(
target: Window,
event: E,
handler: (this: Window, ev: HTMLElementEventMap[E]) => unknown,
options?: boolean | AddEventListenerOptions
): void;
addGlobalListener<E extends keyof HTMLElementEventMap>(
target: Document,
event: E,
handler: (this: Document, ev: HTMLElementEventMap[E]) => unknown,
options?: boolean | AddEventListenerOptions
): void;
addGlobalListener<E extends keyof HTMLElementEventMap>(
target: EventTarget,
event: E,
handler: GeneralEventListener<HTMLElementEventMap[E]>,
options?: boolean | AddEventListenerOptions
): void;
removeGlobalListener<E extends keyof HTMLElementEventMap>(
target: Window,
event: E,
handler: (this: Window, ev: HTMLElementEventMap[E]) => unknown,
options?: boolean | AddEventListenerOptions
): void;
removeGlobalListener<E extends keyof HTMLElementEventMap>(
target: Document,
event: E,
handler: (this: Document, ev: HTMLElementEventMap[E]) => unknown,
options?: boolean | AddEventListenerOptions
): void;
removeGlobalListener<E extends keyof HTMLElementEventMap>(
target: EventTarget,
event: E,
handler: GeneralEventListener<HTMLElementEventMap[E]>,
options?: boolean | AddEventListenerOptions
): void;
removeAllGlobalListeners(): void;
}
export function createGlobalListeners(): GlobalListeners {
let globalListeners = new Map();
function addGlobalListener(
target: Window | Document | EventTarget,
event: string,
handler: EventListener,
options?: AddEventListenerOptions
) {
// Make sure we remove the listener after it is called with the `once` option.
const fn = options?.once
? (...args: Parameters<typeof handler>) => {
globalListeners.delete(handler);
handler(...args);
}
: handler;
globalListeners.set(handler, { event, target, fn, options });
target.addEventListener(event, handler, options);
}
function removeGlobalListener(
target: Window | Document | EventTarget,
event: string,
handler: EventListener,
options?: AddEventListenerOptions
) {
const fn = globalListeners.get(handler)?.fn || handler;
target.removeEventListener(event, fn, options);
globalListeners.delete(handler);
}
function removeAllGlobalListeners() {
globalListeners.forEach((value, key) => {
removeGlobalListener(value.target, value.event, key, value.options);
});
globalListeners = new Map();
}
safeOnDestroy(removeAllGlobalListeners);
return { addGlobalListener, removeGlobalListener, removeAllGlobalListeners };
}

View File

@@ -0,0 +1,46 @@
import { isAndroid } from './platform.js';
// Original licensing for the following method can be found in the
// NOTICE file in the root directory of this source tree.
// See https://github.com/facebook/react/blob/3c713d513195a53788b3f8bb4b70279d68b15bcc/packages/react-interactions/events/src/dom/shared/index.js#L74-L87
// Keyboards, Assistive Technologies, and element.click() all produce a "virtual"
// click event. This is a method of inferring such clicks. Every browser except
// IE 11 only sets a zero value of "detail" for click events that are "virtual".
// However, IE 11 uses a zero value for all click events. For IE 11 we rely on
// the quirk that it produces click events that are of type PointerEvent, and
// where only the "virtual" click lacks a pointerType field.
export function isVirtualClick(event: MouseEvent | PointerEvent): boolean {
// JAWS/NVDA with Firefox.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((event as any).mozInputSource === 0 && event.isTrusted) {
return true;
}
// Android TalkBack's detail value varies depending on the event listener providing the event so we have specific logic here instead
// If pointerType is defined, event is from a click listener. For events from mousedown listener, detail === 0 is a sufficient check
// to detect TalkBack virtual clicks.
if (isAndroid() && (event as PointerEvent).pointerType) {
return event.type === 'click' && event.buttons === 1;
}
return event.detail === 0 && !(event as PointerEvent).pointerType;
}
export function isVirtualPointerEvent(event: PointerEvent) {
// If the pointer size is zero, then we assume it's from a screen reader.
// Android TalkBack double tap will sometimes return a event with width and height of 1
// and pointerType === 'mouse' so we need to check for a specific combination of event attributes.
// Cannot use "event.pressure === 0" as the sole check due to Safari pointer events always returning pressure === 0
// instead of .5, see https://bugs.webkit.org/show_bug.cgi?id=206216. event.pointerType === 'mouse' is to distingush
// Talkback double tap from Windows Firefox touch screen press
return (
(!isAndroid() && event.width === 0 && event.height === 0) ||
(event.width === 1 &&
event.height === 1 &&
event.pressure === 0 &&
event.detail === 0 &&
event.pointerType === 'mouse')
);
}

View File

@@ -0,0 +1,17 @@
import { onDestroy, onMount } from 'svelte';
export const safeOnMount = (fn: (...args: unknown[]) => unknown) => {
try {
onMount(fn);
} catch {
return fn();
}
};
export const safeOnDestroy = (fn: (...args: unknown[]) => unknown) => {
try {
onDestroy(fn);
} catch {
return fn();
}
};

View File

@@ -0,0 +1,48 @@
import { focusWithoutScrolling } from './focus-without-scroll.js';
import { isFirefox, isIPad, isMac, isWebKit } from './platform.js';
interface Modifiers {
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
}
export function openLink(target: HTMLAnchorElement, modifiers: Modifiers, setOpening = true) {
let { metaKey, ctrlKey } = modifiers;
const { altKey, shiftKey } = modifiers;
// Firefox does not recognize keyboard events as a user action by default, and the popup blocker
// will prevent links with target="_blank" from opening. However, it does allow the event if the
// Command/Control key is held, which opens the link in a background tab. This seems like the best we can do.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=257870 and https://bugzilla.mozilla.org/show_bug.cgi?id=746640.
if (isFirefox() && window.event?.type?.startsWith('key') && target.target === '_blank') {
if (isMac()) {
metaKey = true;
} else {
ctrlKey = true;
}
}
// WebKit does not support firing click events with modifier keys, but does support keyboard events.
// https://github.com/WebKit/WebKit/blob/c03d0ac6e6db178f90923a0a63080b5ca210d25f/Source/WebCore/html/HTMLAnchorElement.cpp#L184
const event =
isWebKit() && isMac() && !isIPad() && process.env.NODE_ENV !== 'test'
? // @ts-expect-error - keyIdentifier is a non-standard property, but it's what webkit expects
new KeyboardEvent('keydown', { keyIdentifier: 'Enter', metaKey, ctrlKey, altKey, shiftKey })
: new MouseEvent('click', {
metaKey,
ctrlKey,
altKey,
shiftKey,
bubbles: true,
cancelable: true
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(openLink as any).isOpening = setOpening;
focusWithoutScrolling(target);
target.dispatchEvent(event);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(openLink as any).isOpening = false;
}

56
src/lib/utils/platform.ts Normal file
View File

@@ -0,0 +1,56 @@
function testUserAgent(re: RegExp) {
if (typeof window === 'undefined' || window.navigator == null) {
return false;
}
return (
window.navigator['userAgentData']?.brands.some((brand: { brand: string; version: string }) =>
re.test(brand.brand)
) || re.test(window.navigator.userAgent)
);
}
function testPlatform(re: RegExp) {
return typeof window !== 'undefined' && window.navigator != null
? re.test(window.navigator['userAgentData']?.platform || window.navigator.platform)
: false;
}
export function isMac() {
return testPlatform(/^Mac/i);
}
export function isIPhone() {
return testPlatform(/^iPhone/i);
}
export function isIPad() {
return (
testPlatform(/^iPad/i) ||
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
(isMac() && navigator.maxTouchPoints > 1)
);
}
export function isIOS() {
return isIPhone() || isIPad();
}
export function isAppleDevice() {
return isMac() || isIOS();
}
export function isWebKit() {
return testUserAgent(/AppleWebKit/i) && !isChrome();
}
export function isChrome() {
return testUserAgent(/Chrome/i);
}
export function isAndroid() {
return testUserAgent(/Android/i);
}
export function isFirefox() {
return testUserAgent(/Firefox/i);
}

View File

@@ -0,0 +1,83 @@
// We store a global list of elements that are currently transitioning,
// mapped to a set of CSS properties that are transitioning for that element.
// This is necessary rather than a simple count of transitions because of browser
// bugs, e.g. Chrome sometimes fires both transitionend and transitioncancel rather
// than one or the other. So we need to track what's actually transitioning so that
// we can ignore these duplicate events.
const transitionsByElement = new Map<EventTarget, Set<string>>();
// A list of callbacks to call once there are no transitioning elements.
const transitionCallbacks = new Set<() => void>();
function setupGlobalEvents() {
if (typeof window === 'undefined') {
return;
}
function onTransitionStart(e: TransitionEvent) {
// Add the transitioning property to the list for this element.
if (!e.target) return;
let transitions = transitionsByElement.get(e.target);
if (!transitions) {
transitions = new Set();
transitionsByElement.set(e.target, transitions);
// The transitioncancel event must be registered on the element itself, rather than as a global
// event. This enables us to handle when the node is deleted from the document while it is transitioning.
// In that case, the cancel event would have nowhere to bubble to so we need to handle it directly.
e.target.addEventListener('transitioncancel', onTransitionEnd as EventListener);
}
transitions.add(e.propertyName);
}
function onTransitionEnd(e: TransitionEvent) {
// Remove property from list of transitioning properties.
if (!e.target) return;
const properties = transitionsByElement.get(e.target);
if (!properties) {
return;
}
properties.delete(e.propertyName);
// If empty, remove transitioncancel event, and remove the element from the list of transitioning elements.
if (properties.size === 0) {
e.target.removeEventListener('transitioncancel', onTransitionEnd as EventListener);
transitionsByElement.delete(e.target);
}
// If no transitioning elements, call all of the queued callbacks.
if (transitionsByElement.size === 0) {
for (const cb of transitionCallbacks) {
cb();
}
transitionCallbacks.clear();
}
}
document.body.addEventListener('transitionrun', onTransitionStart);
document.body.addEventListener('transitionend', onTransitionEnd);
}
if (typeof document !== 'undefined') {
if (document.readyState !== 'loading') {
setupGlobalEvents();
} else {
document.addEventListener('DOMContentLoaded', setupGlobalEvents);
}
}
export function runAfterTransition(fn: () => void) {
// Wait one frame to see if an animation starts, e.g. a transition on mount.
requestAnimationFrame(() => {
// If no transitions are running, call the function immediately.
// Otherwise, add it to a list of callbacks to run at the end of the animation.
if (transitionsByElement.size === 0) {
fn();
} else {
transitionCallbacks.add(fn);
}
});
}

View File

@@ -0,0 +1,88 @@
import { getOwnerDocument } from './get-owner.js';
import { isIOS } from './platform.js';
import { runAfterTransition } from './run-after-transition.js';
// Safari on iOS starts selecting text on long press. The only way to avoid this, it seems,
// is to add user-select: none to the entire page. Adding it to the pressable element prevents
// that element from being selected, but nearby elements may still receive selection. We add
// user-select: none on touch start, and remove it again on touch end to prevent this.
// This must be implemented using global state to avoid race conditions between multiple elements.
// There are three possible states due to the delay before removing user-select: none after
// pointer up. The 'default' state always transitions to the 'disabled' state, which transitions
// to 'restoring'. The 'restoring' state can either transition back to 'disabled' or 'default'.
// For non-iOS devices, we apply user-select: none to the pressed element instead to avoid possible
// performance issues that arise from applying and removing user-select: none to the entire page
type State = 'default' | 'disabled' | 'restoring';
// Note that state only matters here for iOS. Non-iOS gets user-select: none applied to the target element
// rather than at the document level so we just need to apply/remove user-select: none for each pressed element individually
let state: State = 'default';
let savedUserSelect = '';
const modifiedElementMap = new WeakMap<Element, string>();
export function disableTextSelection(target?: Element) {
if (isIOS()) {
if (state === 'default') {
// eslint-disable-next-line no-restricted-globals
const documentObject = getOwnerDocument(target);
savedUserSelect = documentObject.documentElement.style.webkitUserSelect;
documentObject.documentElement.style.webkitUserSelect = 'none';
}
state = 'disabled';
} else if (target instanceof HTMLElement || target instanceof SVGElement) {
// If not iOS, store the target's original user-select and change to user-select: none
// Ignore state since it doesn't apply for non iOS
modifiedElementMap.set(target, target.style.userSelect);
target.style.userSelect = 'none';
}
}
export function restoreTextSelection(target?: Element) {
if (isIOS()) {
// If the state is already default, there's nothing to do.
// If it is restoring, then there's no need to queue a second restore.
if (state !== 'disabled') {
return;
}
state = 'restoring';
// There appears to be a delay on iOS where selection still might occur
// after pointer up, so wait a bit before removing user-select.
setTimeout(() => {
// Wait for any CSS transitions to complete so we don't recompute style
// for the whole page in the middle of the animation and cause jank.
runAfterTransition(() => {
// Avoid race conditions
if (state === 'restoring') {
// eslint-disable-next-line no-restricted-globals
const documentObject = getOwnerDocument(target);
if (documentObject.documentElement.style.webkitUserSelect === 'none') {
documentObject.documentElement.style.webkitUserSelect = savedUserSelect || '';
}
savedUserSelect = '';
state = 'default';
}
});
}, 300);
} else if (target instanceof HTMLElement || target instanceof SVGElement) {
// If not iOS, restore the target's original user-select if any
// Ignore state since it doesn't apply for non iOS
if (target && modifiedElementMap.has(target)) {
const targetOldUserSelect = modifiedElementMap.get(target) as string;
if (target.style.userSelect === 'none') {
target.style.userSelect = targetOldUserSelect;
}
if (target.getAttribute('style') === '') {
target.removeAttribute('style');
}
modifiedElementMap.delete(target);
}
}
}

View File

@@ -0,0 +1,23 @@
import { type Writable, writable } from 'svelte/store';
export type ToWritableStores<T extends Record<string, unknown>> = {
[K in keyof T]: Writable<T[K]>;
};
/**
* Given an object of properties, returns an object of writable stores
* with the same properties and values.
*/
export function toWritableStores<T extends Record<string, unknown>>(
properties: T
): ToWritableStores<T> {
const result = {} as { [K in keyof T]: Writable<T[K]> };
Object.keys(properties).forEach((key) => {
const propertyKey = key as keyof T;
const value = properties[propertyKey];
result[propertyKey] = writable(value);
});
return result;
}

23
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { initPress } from '$lib/press.js';
const { pressAction } = initPress();
</script>
<button
use:pressAction
on:press={() => {
console.log('press');
}}
on:pressend={() => {
console.log('pressend');
}}
on:pressstart={() => {
console.log('pressstart');
}}
on:pressup={() => {
console.log('pressup');
}}
>
Hello World
</button>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["./node_modules/user-agent-data-types"]
}
}

9
vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});