mirror of
https://github.com/LukeHagar/svelte-interactions.git
synced 2025-12-06 04:21:32 +00:00
initial commit
This commit is contained in:
13
.eslintignore
Normal file
13
.eslintignore
Normal 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
31
.eslintrc.cjs
Normal 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
11
.gitignore
vendored
Normal 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-*
|
||||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Ignore files for PNPM, NPM and YARN
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
8
.prettierrc
Normal file
8
.prettierrc
Normal 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
200
NOTICE.txt
Normal 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
200
README.md
Normal 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
56
package.json
Normal 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
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
13
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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
7
src/index.test.ts
Normal 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
4
src/lib/index.ts
Normal 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
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
2
src/lib/types/dom.ts
Normal 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
146
src/lib/types/events.ts
Normal 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;
|
||||||
|
}
|
||||||
32
src/lib/utils/callbacks.ts
Normal file
32
src/lib/utils/callbacks.ts
Normal 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() {
|
||||||
|
//
|
||||||
|
}
|
||||||
56
src/lib/utils/event-listeners.ts
Normal file
56
src/lib/utils/event-listeners.ts
Normal 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));
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/lib/utils/focus-without-scroll.ts
Normal file
82
src/lib/utils/focus-without-scroll.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/lib/utils/get-owner.ts
Normal file
14
src/lib/utils/get-owner.ts
Normal 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;
|
||||||
|
};
|
||||||
95
src/lib/utils/global-listeners.ts
Normal file
95
src/lib/utils/global-listeners.ts
Normal 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 };
|
||||||
|
}
|
||||||
46
src/lib/utils/is-virtual-event.ts
Normal file
46
src/lib/utils/is-virtual-event.ts
Normal 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')
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/lib/utils/lifecycle.ts
Normal file
17
src/lib/utils/lifecycle.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
48
src/lib/utils/open-link.ts
Normal file
48
src/lib/utils/open-link.ts
Normal 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
56
src/lib/utils/platform.ts
Normal 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);
|
||||||
|
}
|
||||||
83
src/lib/utils/run-after-transition.ts
Normal file
83
src/lib/utils/run-after-transition.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
88
src/lib/utils/text-selection.ts
Normal file
88
src/lib/utils/text-selection.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/lib/utils/to-writable-stores.ts
Normal file
23
src/lib/utils/to-writable-stores.ts
Normal 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
23
src/routes/+page.svelte
Normal 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
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
18
svelte.config.js
Normal file
18
svelte.config.js
Normal 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
16
tsconfig.json
Normal 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
9
vite.config.ts
Normal 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}']
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user