├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── bun.lockb ├── changelog ├── 0.1.0.md ├── 0.1.1.md ├── 0.1.2.md ├── 0.1.3.md ├── 0.1.4.md ├── 0.1.5.md ├── 0.2.0.md ├── 0.2.1.md ├── 0.3.0.md ├── 0.3.1.md ├── 0.3.10.md ├── 0.3.11.md ├── 0.3.12.md ├── 0.3.2.md ├── 0.3.3.md ├── 0.3.4.md ├── 0.3.5.md ├── 0.3.6.md ├── 0.3.7.md ├── 0.3.8.md └── 0.3.9.md ├── docs ├── action │ ├── active.md │ ├── clickoutside.md │ ├── copy.md │ ├── download.md │ ├── draggable.md │ ├── focus.md │ ├── focustrap.md │ ├── keydown.md │ ├── keyup.md │ ├── onclose.md │ ├── paste.md │ ├── portal.md │ ├── press.md │ ├── resize.md │ ├── select.md │ ├── themetoggle.md │ ├── timedclick.md │ └── viewport.md ├── app │ └── log.md ├── client │ ├── media.md │ ├── mouse.md │ ├── theme.md │ └── window.md ├── components │ └── onmount.md ├── store │ ├── localstore.md │ ├── mediaquery.md │ ├── resettable.md │ └── sessionstore.md └── transition │ ├── slide.md │ └── typewriter.md ├── eslint.config.js ├── package.json ├── src ├── app.d.ts ├── app.html ├── lib │ ├── action │ │ ├── active.ts │ │ ├── clickoutside.ts │ │ ├── clipboard.ts │ │ ├── download.ts │ │ ├── draggable.ts │ │ ├── dropzone.ts │ │ ├── focus.ts │ │ ├── focustrap.ts │ │ ├── index.ts │ │ ├── keyboard.ts │ │ ├── onclose.ts │ │ ├── portal.ts │ │ ├── press.ts │ │ ├── resizable.ts │ │ ├── resize.ts │ │ ├── rotatable.ts │ │ ├── scalable.ts │ │ ├── select.ts │ │ ├── themetoggle.ts │ │ ├── timedclick.ts │ │ └── viewport.ts │ ├── app │ │ ├── index.ts │ │ └── log.ts │ ├── client │ │ ├── index.ts │ │ ├── media.ts │ │ ├── mouse.ts │ │ ├── os.ts │ │ ├── theme.ts │ │ └── window.ts │ ├── components │ │ ├── OnMount.svelte │ │ └── index.ts │ ├── index.ts │ ├── meta │ │ ├── clone.ts │ │ ├── date.ts │ │ ├── debounce.ts │ │ ├── element.ts │ │ ├── env.ts │ │ ├── event.ts │ │ ├── fn.ts │ │ ├── history.d.ts │ │ ├── history.ts │ │ ├── index.ts │ │ ├── json.ts │ │ ├── math.ts │ │ ├── random.ts │ │ ├── range.ts │ │ ├── string.ts │ │ ├── time.ts │ │ └── types.ts │ ├── store │ │ ├── index.ts │ │ ├── localstore.ts │ │ ├── mediaquery.ts │ │ ├── resettable.ts │ │ └── sessionstore.ts │ └── transition │ │ ├── index.ts │ │ ├── slide.ts │ │ └── typewriter.ts └── routes │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ ├── [category] │ ├── +page.server.ts │ ├── +page.svelte │ └── [slug] │ │ ├── +page.server.ts │ │ └── +page.svelte │ └── _docs │ ├── base.css │ ├── reset.css │ └── server.ts ├── static └── favicon.png ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | bunx lint-staged 2 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts,svelte,css,postcss,md,json}": ["prettier --write", "prettier --check"], 3 | "*.{js,ts,svelte}": "eslint" 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore files for PNPM, NPM and YARN 2 | pnpm-lock.yaml 3 | package-lock.json 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2022 nikolai-cc 3 | 4 | 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: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | 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. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # /svu 2 | 3 | Svelte development, supercharged. 4 | 5 | svu started out as a collection of svelte-related utilities I copied from project to project. svu is currently in alpha, while the API settles. If you run into any issues, or have any questions or suggestions, feel free to open an issue on GitHub. 6 | 7 | ## Features 8 | 9 | Check out the various parts of the docs: 10 | 11 | - [`/action`](http://svu.vercel.app/action): A huge collection of svelte:actions. 12 | 13 | - [`/app`](http://svu.vercel.app/app): App related utilities. 14 | 15 | - [`/client`](http://svu.vercel.app/client): Client related stores and utils. 16 | 17 | - [`/components`](http://svu.vercel.app/components): Useful components. 18 | 19 | - [`/store`](http://svu.vercel.app/store): Custom stores. 20 | 21 | - [`/transition`](http://svu.vercel.app/transition): Custom transition functions. 22 | 23 | ## Getting Started 24 | 25 | 1. Install from npm: 26 | 27 | ```bash 28 | npm i -D svu 29 | ``` 30 | 31 | 2. For SvelteKit: add this to your vite.config.js: 32 | 33 | This tells Vite to treat this package as part of our application code. We need this because for some SvelteKit `/svu`'s are using SvelteKit utilities like the `$app` syntax. 34 | 35 | ```js 36 | optimizeDeps: { 37 | exclude: ['svu', 'svu/*'], 38 | }, 39 | ssr: { 40 | noExternal: ['svu', 'svu/*'], 41 | }, 42 | ``` 43 | 44 | 3. Import only what you need. 45 | 46 | For SvelteKit: 47 | 48 | ```svelte 49 | 52 | 53 |

Be Happy

54 | ``` 55 | 56 | For Svelte: 57 | 58 | ```svelte 59 | 62 | 63 |

Be Happy

64 | ``` 65 | 66 | Find out wether you need one of our [actions](http://svu.vercel.app/action), [custom stores](http://svu.vercel.app/stores), [app-related stores](http://svu.vercel.app/app), [client-related stores](http://svu.vercel.app/client) and [components](http://svu.vercel.app/components). 67 | 68 | 4.

Be happy!

69 | 70 | ## Status 71 | 72 | The API is in flux and may change without warning in 0.X.0 updates. 73 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolai-cc/svu/74d927ead77a8042c09dc133da447fb68aa4941d/bun.lockb -------------------------------------------------------------------------------- /changelog/0.1.0.md: -------------------------------------------------------------------------------- 1 | # 0.1.0 2 | 3 | ## Changes 4 | 5 | Initial public version. Renamed package from '@nikolai-cc/svutil' to 'svu' Contains a collection of 27 svus (most of which are actions) with documentation. 6 | -------------------------------------------------------------------------------- /changelog/0.1.1.md: -------------------------------------------------------------------------------- 1 | # 0.1.1 2 | 3 | ## Changes 4 | 5 | Rename package in Docs and README. Fix path exports. 6 | -------------------------------------------------------------------------------- /changelog/0.1.2.md: -------------------------------------------------------------------------------- 1 | # 0.1.2 2 | 3 | ## Changes 4 | 5 | Undo path export change from 0.1.1. Turns out [this](https://github.com/sveltejs/kit/issues/2040) was the culprit. 6 | -------------------------------------------------------------------------------- /changelog/0.1.3.md: -------------------------------------------------------------------------------- 1 | # 0.1.3 2 | 3 | ## Changes 4 | 5 | Fix Vite config 6 | -------------------------------------------------------------------------------- /changelog/0.1.4.md: -------------------------------------------------------------------------------- 1 | # 0.1.4 2 | 3 | ## Changes 4 | 5 | - Fix exports 6 | - Add initial set of plain-svelte exports (e.g. for use in REPL). 7 | - Fix reactivity on use:active option (you can now pass in classes as variables). 8 | - Add some better types (a lot more work needs to be done here). 9 | - Add modifier support to keydown and keyup 10 | - Add mediaquery store 11 | - Add media /client stores 12 | - Add os and modKey /client stores 13 | - Docs content + fixes 14 | -------------------------------------------------------------------------------- /changelog/0.1.5.md: -------------------------------------------------------------------------------- 1 | # 0.1.5 2 | 3 | ## Changes 4 | 5 | - FIX: patch error in copy/paste action on textarea 6 | - FIX: patch error passing in target by reference in copy/paste action 7 | -------------------------------------------------------------------------------- /changelog/0.2.0.md: -------------------------------------------------------------------------------- 1 | # 0.2.0 2 | 3 | ## Changes 4 | 5 | - [BREAKING]: Feat: Svu now is no longer dependant on SvelteKit, so should work in the Svelte REPL (or fallback gracefully). This means the svu/svelte export will be removed. 6 | - [BREAKING]: FIX: type KeyMap is now capitalised. 7 | - [BREAKING]: Scoped the default class for use:active. It changed from 'active' to 'svu-active'. 8 | - FIX: localstore was not always working flawlessly 9 | - FEAT: localstore is based on resettable and contains a flag showing wether or not storage api is available 10 | - FEAT: added sessionstore with the same interface as localstore. 11 | - FIX: Fixes a bug in portal where passing in a HTMLElement didn't work. 12 | - DOCS: Various small docstring improvements 13 | -------------------------------------------------------------------------------- /changelog/0.2.1.md: -------------------------------------------------------------------------------- 1 | # 0.2.1 2 | 3 | ## Changes 4 | 5 | - FIX: Remove SvelteKit dependency for svu/dev 6 | -------------------------------------------------------------------------------- /changelog/0.3.0.md: -------------------------------------------------------------------------------- 1 | # 0.3.0 2 | 3 | ## Changes 4 | 5 | ### meta 6 | 7 | - first preparation for allowing svu/meta for end users 8 | - move meta/async and meta/timeout to meta/time module. 9 | - add meta/date module 10 | - add meta/element module 11 | - rewrite meta/json from skipping errors to std. behaviour + handling dates 12 | - removed meta/memoize 13 | - add meta/types for often-used types 14 | - rewritten/improved various other meta modules 15 | 16 | ### action 17 | 18 | - removed declared types in favour of ActionReturn types 19 | - better docstrings 20 | - rewritten/improved various action modules 21 | 22 | ### others 23 | 24 | - stubbed out new, cleaner, more usable docs layout 25 | - improved local/sessionstore behaviour 26 | - removed svu/svelte entrypoint since it shouldn't be nessecary anymore 27 | - clean up unused tests for now 28 | - improved changelog layout 29 | - tested out bun 30 | -------------------------------------------------------------------------------- /changelog/0.3.1.md: -------------------------------------------------------------------------------- 1 | # 0.3.1 2 | 3 | ## Changes 4 | 5 | - fix export paths 6 | -------------------------------------------------------------------------------- /changelog/0.3.10.md: -------------------------------------------------------------------------------- 1 | # 0.3.10 2 | 3 | ## Changes 4 | 5 | - Feat: add dropzone action 6 | -------------------------------------------------------------------------------- /changelog/0.3.11.md: -------------------------------------------------------------------------------- 1 | # 0.3.11 2 | 3 | ## Changes 4 | 5 | - Fix: make sure dropzone is actually available 6 | -------------------------------------------------------------------------------- /changelog/0.3.12.md: -------------------------------------------------------------------------------- 1 | # 0.3.12 2 | 3 | ## Changes 4 | 5 | - Fix: dropzone event detail type (changed from FileList to File[]) 6 | -------------------------------------------------------------------------------- /changelog/0.3.2.md: -------------------------------------------------------------------------------- 1 | # 0.3.2 2 | 3 | ## Changes 4 | 5 | - fix export paths (removed trailing slashes) 6 | -------------------------------------------------------------------------------- /changelog/0.3.3.md: -------------------------------------------------------------------------------- 1 | # 0.3.3 2 | 3 | ## Changes 4 | 5 | - add window aspect to /client 6 | - fix export paths (removed trailing slashes) 7 | -------------------------------------------------------------------------------- /changelog/0.3.4.md: -------------------------------------------------------------------------------- 1 | # 0.3.4 2 | 3 | ## Changes 4 | 5 | - improve draggable bounds calculation 6 | -------------------------------------------------------------------------------- /changelog/0.3.5.md: -------------------------------------------------------------------------------- 1 | # 0.3.5 2 | 3 | ## Changes 4 | 5 | - fix draggable handle recalculation 6 | -------------------------------------------------------------------------------- /changelog/0.3.6.md: -------------------------------------------------------------------------------- 1 | # 0.3.6 2 | 3 | ## Changes 4 | 5 | - Adds bottom and right properties to meta/getDomRect 6 | - Adds meta/getTransformCoords to get current transorm 7 | - Add getTransformCoords to detect outside changes to position in draggable 8 | - Add action/resizable for resizable node 9 | -------------------------------------------------------------------------------- /changelog/0.3.7.md: -------------------------------------------------------------------------------- 1 | # 0.3.7 2 | 3 | ## Changes 4 | 5 | - Remove console statements in draggable action 6 | - Fix draggable controlled behaviour. 7 | -------------------------------------------------------------------------------- /changelog/0.3.8.md: -------------------------------------------------------------------------------- 1 | # 0.3.8 2 | 3 | ## Changes 4 | 5 | - New action: scaleable. Similar to resizable but operates on scale rather than width and height. 6 | - New action: rotatable. Similar to resizable but operates on rotation. 7 | - Feat: actions operating on translation, scale and rotation can interop by respecting other transformation values 8 | -------------------------------------------------------------------------------- /changelog/0.3.9.md: -------------------------------------------------------------------------------- 1 | # 0.3.9 2 | 3 | ## Changes 4 | 5 | - Fix: Correctly calculate scalable/resizable sensor values on mobile 6 | -------------------------------------------------------------------------------- /docs/action/active.md: -------------------------------------------------------------------------------- 1 | # Active 2 | 3 | This action adds a class to an element when the URL of the page matches a given pattern. When used on an element with a `href` attribute, it uses it's value by default. 4 | 5 | ## Usage 6 | 7 | A common usecase is to add a different styling to the link of the currently active page in a navigation component. This is how it's used in the navigation in these. 8 | 9 | ```svelte 10 | 13 | 14 | 19 | 20 | 25 | ``` 26 | 27 | ## Options 28 | 29 | Pass in options with `` 30 | 31 | `options` is an object with the following parameters: 32 | 33 | ### `className` 34 | 35 | Name of the class that is added to the active element. 36 | 37 | - Optional: yes 38 | - Type: `string` 39 | - Default value: `'active'` 40 | 41 | ### `includeDescendants` 42 | 43 | If this is set to false, the className is added on an exact match to the path. If it's set to true, it's added on routes that include the path or any of its descendants. 44 | 45 | - Optional: yes 46 | - Type: `boolean` 47 | - Default value: `false` 48 | 49 | ### `path` 50 | 51 | The path to match the URL to. 52 | 53 | - Optional: yes 54 | - Type: string 55 | - Default: same as `href` attribute if present, otherwise `'/'` 56 | 57 | ## Events 58 | 59 | This action dispatches no events. 60 | 61 | ## Caveats 62 | 63 | Since the class is added programatically, the svelte compiler does not know about it and it is not scoped to the component. 64 | The styling has to be done in a global css file or with the `:global(.className)` selector. 65 | You'll have to make sure to pick a `className` that does not interfere with any other styles in your app. 66 | 67 | The current matcher for the `includeDescendants` route is a simple string comparison to the `url.pathname`, which can result in unwanted behaviour when there are multiple pages with the same name. For example: When we have both a link to `/foo` and to `/bar/foo/baz` in our nav, the link to `/foo` will be set to active when we're on the `/bar/foo/baz` route. 68 | -------------------------------------------------------------------------------- /docs/action/clickoutside.md: -------------------------------------------------------------------------------- 1 | # Clickoutside 2 | 3 | This action dispatches an clickoutside Custom Event and executes an optional handler when the user clicks on any part of the website other than the element itself. 4 | 5 | ## Usage 6 | 7 | A common usage is to close modals by clicking on the background. It's used to collapse the sidebar on this site. 8 | 9 | ```svelte 10 | 15 | 16 | {#if modal} 17 |
(modal = false)}> 18 | 19 |
20 | {/if} 21 | ``` 22 | 23 | ## Options 24 | 25 | ### `handler` 26 | 27 | You can pass in an event handler that is executed every time the clickoutside event is triggered. This will not prevent the clickoutside event to fire, so they can be used in conjunction with each other. 28 | 29 | - Optional: yes 30 | - Type: `Function` 31 | - Default value: `null` 32 | 33 | ## Events 34 | 35 | This action dispatches one event: 36 | 37 | ### `clickoutside` 38 | 39 | Emitted every time the user clicks on any part of the website other than the element itself. 40 | -------------------------------------------------------------------------------- /docs/action/copy.md: -------------------------------------------------------------------------------- 1 | # Copy 2 | 3 | This action copies the `textContent` of the element (or the `value` in case of a `HTMLInputElement`) to the clipboard. It copies the element itself by default, but another element can be passed in. 4 | 5 | - See also [use:paste](/action/paste) 6 | 7 | ## Usage 8 | 9 | A common use case is to add a _click to copy_ button to a code block. You can see it in action in the code blocks in these docs. 10 | 11 | ```svelte 12 | 15 | 16 | 17 | 18 |
19 |     
20 | 
21 | ``` 22 | 23 | ## Options 24 | 25 | ### `target` 26 | 27 | - optional: yes 28 | - type: `HTMLElement | string` 29 | - default value: the element itself 30 | 31 | ## Events 32 | 33 | This action does not dispatch any events. 34 | 35 | ## Caveats 36 | 37 | If the target is not a `HTMLInputElement`, this action copies the textContent of the element. This means its content is not parsed as HTML when copying elements with children, so `

` and `
` tags (and associated line breaks) are not preserved. 38 | 39 | This does not (yet) work on mobile devices. 40 | -------------------------------------------------------------------------------- /docs/action/download.md: -------------------------------------------------------------------------------- 1 | # Download 2 | 3 | Downloads the textContent of an element to a .txt file on click. Uses the element itself by default, but another element can be specified. 4 | 5 | ## Usage 6 | 7 | A common usecase is to add a download button to your page. 8 | 9 | ```svelte 10 | 14 | 15 |

This text will be downloaded

16 | 17 | 18 | ``` 19 | 20 | ## Options 21 | 22 | You can either pass in the `target` option (as seen below) as an `HTMLElement`, or an `options` object with the following properties: 23 | 24 | ### `target` 25 | 26 | changes the behaviour of this thing 27 | 28 | - Optional: yes 29 | - Type: `HTMLElement` 30 | - Default value: the element itself 31 | 32 | ### `name` 33 | 34 | The name of the downloaded file (including extension). 35 | 36 | - Optional: yes 37 | - Type: `string` 38 | - Default value: `'download.txt'` 39 | 40 | ## Events 41 | 42 | This action does not emit any events. 43 | 44 | ## Caveats 45 | 46 | This currently only supports the 'text/plain' MIME type. 47 | 48 | You can not yet pass in the target by query selector, unlike most other svus. 49 | -------------------------------------------------------------------------------- /docs/action/draggable.md: -------------------------------------------------------------------------------- 1 | # Draggable 2 | 3 | Allows positioning of an element by dragging it with the pointer. Drag any part of the element by default, but optionally enter a handle element to restrict drag to that element. Update the `pos` property from anywhere to programmatically reposition the element. 4 | 5 | Uses `translate3d` to improve performance. 6 | 7 | ## Usage 8 | 9 | A common usecase is to add fun! 10 | 11 | ```svelte 12 | 15 | 16 |
Drag me around!
17 | ``` 18 | 19 | ## Options 20 | 21 | You can pass in an `options` object with the following parameters: 22 | 23 | ### `pos` 24 | 25 | The initial position of the element. You can change the position programmatically by updating the `pos` property. 26 | 27 | - Optional: yes 28 | - Type: `{ x: number, y: number }` 29 | - Default value: `{ x: 0, y: 0 }` 30 | 31 | ### `handle` 32 | 33 | ## Events 34 | 35 | This action dispatches three events: 36 | 37 | ### `drag:start` 38 | 39 | Emitted when the drag event is initialised. 40 | 41 | ### `drag:update` 42 | 43 | Emitted when the element is dragged. The event object contains the current position of the element. When updating the position of the element programmatically, you can use this event to update the position of the element while it's dragged. 44 | 45 | ### `drag:end` 46 | 47 | Emitted when the element is released. The event object contains the current position of the element. When updating the position of the element programmatically, you can use this event to update the position of the element after it's dragged. 48 | -------------------------------------------------------------------------------- /docs/action/focus.md: -------------------------------------------------------------------------------- 1 | # Focus 2 | 3 | Focuses the element as soon as it's mounted. Only works on focusable elements. 4 | 5 | ## Usage 6 | 7 | A common usecase is to focus the username field on loading of the login page. 8 | 9 | ```svelte 10 | 13 | 14 | 15 | ``` 16 | 17 | ## Options 18 | 19 | This action has no options. 20 | 21 | ## Events 22 | 23 | This action does not dispatch any events. 24 | 25 | ## Caveats 26 | 27 | Keep accessibility in mind when using this. Suddenly changing focus to an unexpected element can lead to frustrated users. 28 | -------------------------------------------------------------------------------- /docs/action/focustrap.md: -------------------------------------------------------------------------------- 1 | # Focustrap 2 | 3 | Traps focus within an element. Only works on focusable elements. Releases focus when the element is unmounted or the `escape` key is pressed. 4 | 5 | ## Usage 6 | 7 | A common usecase is to trap focus within a modal dialog. 8 | 9 | ```svelte 10 | 13 | ``` 14 | 15 | ## Options 16 | 17 | this has the following options: 18 | 19 | ### `option` 20 | 21 | changes the behaviour of this thing 22 | 23 | - Optional: yes 24 | - Type: `any` 25 | - Default value: `'default` 26 | 27 | ## Events 28 | 29 | this dispatches one event: 30 | 31 | ### `event` 32 | 33 | emitted every time something happens 34 | -------------------------------------------------------------------------------- /docs/action/keydown.md: -------------------------------------------------------------------------------- 1 | # Keydown 2 | 3 | Execute a function on `keydown`. Pass in a map of key names to functions to execute the function on key. 4 | 5 | - See also: [use:keyup](/action/keyup) 6 | 7 | ## Usage 8 | 9 | A common use case is to add keyboard shortcuts, e.g. to close a modal with the escape key. 10 | 11 | ```svelte 12 | 17 | 18 |
19 | 20 |
21 | ``` 22 | 23 | ## Options 24 | 25 | This action has one option: 26 | 27 | ### `keys` 28 | 29 | A map of key strings to functions to execute. 30 | 31 | The key string is a valid Key string like `'a'` `'Shift` or `'Escape'`. It supports modifiers separated with a `+` sign. The key to test for always comes last. We sanitise modifiers (e.g. change `cmd`, `Win`, or `Super` to `Meta`), if we are unable to match an invalid modifier, it is ignored. 32 | 33 | - `Cmd+Shift+A` 34 | 35 | Is a valid key string. (`Cmd` will be interpreted as `Meta`) 36 | 37 | - `Shift+A+B` 38 | 39 | Is not a valid key string. This will be interpreted as `Shift+B` (since `A` is treated as an invalid modifier and we are unable to sanitise it into a known value). 40 | 41 | - `A+Shift` 42 | 43 | Is not a valid key string. The key to test for should come last. This will be interpreted as `Shift` (since `A` is treated as an invalid modifier and we are unable to sanitise it into a known value). 44 | 45 | . 46 | 47 | - Optional: no 48 | - Type: `{ [key: string]: Function }` 49 | - Default value: none 50 | 51 | ## Events 52 | 53 | This action does not dispatch any events. 54 | 55 | ## Caveats 56 | 57 | keyboard modifiers (shift, ctrl, alt, meta) are not yet supported. 58 | 59 | If you need to handle a function on keyup see [use:keyup](/action/keyup). If you need something to happen while the key is pressed down you can use keydown and keyup in conjunction with each other. 60 | -------------------------------------------------------------------------------- /docs/action/keyup.md: -------------------------------------------------------------------------------- 1 | # Keyup 2 | 3 | Execute a function on `keyup`. Pass in a map of key names to functions to execute the function on key. 4 | 5 | - See also: [use:keydown](/action/keydown) 6 | 7 | ## Usage 8 | 9 | A common use case is to add keyboard shortcuts, e.g. to close a modal with the escape key. 10 | 11 | ```svelte 12 | 17 | 18 |
19 | 20 |
21 | ``` 22 | 23 | ## Options 24 | 25 | This action has one option: 26 | 27 | ### `keys` 28 | 29 | A map of key strings to functions to execute. 30 | 31 | The key string is a valid Key string like `'a'` `'Shift` or `'Escape'`. It supports modifiers separated with a `+` sign. The key to test for always comes last. We sanitise modifiers (e.g. change `cmd`, `Win`, or `Super` to `Meta`), if we are unable to match an invalid modifier, it is ignored. 32 | 33 | - `Cmd+Shift+A` 34 | 35 | Is a valid key string. (`Cmd` will be interpreted as `Meta`) 36 | 37 | - `Shift+A+B` 38 | 39 | Is not a valid key string. This will be interpreted as `Shift+B` (since `A` is treated as an invalid modifier and we are unable to sanitise it into a known value). 40 | 41 | - `A+Shift` 42 | 43 | Is not a valid key string. The key to test for should come last. This will be interpreted as `Shift` (since `A` is treated as an invalid modifier and we are unable to sanitise it into a known value). 44 | 45 | . 46 | 47 | - Optional: no 48 | - Type: `{ [key: string]: Function }` 49 | - Default value: none 50 | 51 | ## Events 52 | 53 | This action does not dispatch any events. 54 | 55 | ## Caveats 56 | 57 | keyboard modifiers (shift, ctrl, alt, meta) are not yet supported. 58 | 59 | If you need to handle a function on keydown see [use:keydown](/action/keydown). If you need something to happen while the key is pressed down you can use keydown and keyup in conjunction with each other. 60 | -------------------------------------------------------------------------------- /docs/action/onclose.md: -------------------------------------------------------------------------------- 1 | # Onclose 2 | 3 | Executes an optional function on the window's beforunload event, and displays an _'are you sure?'_ dialog. You can pass in a condition to only execute when the condition is met. 4 | 5 | ## Usage 6 | 7 | A common usecase is to display a confirmation dialog when the user tries to close the window when there are unsaved changes. 8 | 9 | ```svelte 10 | 13 | ``` 14 | 15 | ## Options 16 | 17 | this has the following options: 18 | 19 | ### `option` 20 | 21 | changes the behaviour of this thing 22 | 23 | - Optional: yes 24 | - Type: `any` 25 | - Default value: `'default` 26 | 27 | ## Events 28 | 29 | this dispatches one event: 30 | 31 | ### `event` 32 | 33 | emitted every time something happens 34 | -------------------------------------------------------------------------------- /docs/action/paste.md: -------------------------------------------------------------------------------- 1 | # Paste 2 | 3 | This action pastes the text content of the clipboard into the `textContent` of the element (or the `value` in case of an `HTMLInputElement`). It pastes into the element itself by default, but another element can be passed in. 4 | 5 | - See also [use:copy](/action/copy). 6 | 7 | ## Usage 8 | 9 | A common use case is to add a _paste_ button in a form or editor. 10 | 11 | ```svelte 12 | 16 | 17 | 18 | 19 | 20 | ``` 21 | 22 | ## Options 23 | 24 | ### `target` 25 | 26 | - optional: yes 27 | - type: `HTMLElement | string` 28 | - default value: the element itself 29 | 30 | ## Events 31 | 32 | This action does not dispatch any events. 33 | 34 | ## Caveats 35 | 36 | If the target is not a `HTMLInputElement`, this action pastes into the textContent of the element. This means you cannot use it to paste content that should be parsed as HTML. You probably won't want to do this (because that opens up the possibility of XSS attacks) anyway. 37 | 38 | This does not (yet) work on mobile devices. 39 | -------------------------------------------------------------------------------- /docs/action/portal.md: -------------------------------------------------------------------------------- 1 | # Portal 2 | 3 | Mount a component elsewhere in the DOM. Pass in the DOM node to mount it to. 4 | 5 | ## Usage 6 | 7 | A common usecase is to mount a modal dialog to a top level element while keeping the modal logic in the relevant component. 8 | 9 | ```svelte 10 | 13 | 14 |
15 | 16 |
17 | ``` 18 | 19 | ## Options 20 | 21 | This action has one option: 22 | 23 | ### `target` 24 | 25 | The DOMnode to mount the component to. Can be passed in by Element or query selector. 26 | 27 | - Optional: no 28 | - Type: `HTMLElement | string` 29 | - Default value: none 30 | 31 | ## Events 32 | 33 | This action does not emit any events. 34 | -------------------------------------------------------------------------------- /docs/action/press.md: -------------------------------------------------------------------------------- 1 | # Press 2 | 3 | Dispatches an event and calls an optional handler if an element is _pressed down_ for a certain amount of time. Can be added multiple times in order to have different things happen after different amounts of time. 4 | 5 | - See also: [use:timedclick](/action/timedclick) 6 | 7 | ## Usage 8 | 9 | Most commonly used on button elements. 10 | 11 | ```svelte 12 | 17 | 18 | 21 | ``` 22 | 23 | ## Options 24 | 25 | You can pass in an `options` object with the following parameters: 26 | You can also pass in a number, which will be interpreted as the duration. 27 | 28 | ### `duration` 29 | 30 | The time in milliseconds how long the element needs to be pressed down for. 31 | 32 | - Optional: no 33 | - Type: `number` 34 | - Default value: none 35 | 36 | ### `handler` 37 | 38 | The function to call when the element is pressed down for the specified amount of time. 39 | 40 | - Optional: yes 41 | - Type: `function` 42 | - Default value: `noop` 43 | 44 | ## Events 45 | 46 | This action dispatches one event: 47 | 48 | ### `press` 49 | 50 | Emitted when the button has been pressed for `duration` milliseconds. The event payload is the duration of the press. This can be used to distinguish between different events if multiple press actions are added. 51 | 52 | ## Caveats 53 | 54 | This action is fired while the button is pressed down. If you want to release a button after or within a certain time, you will need the [timedclick](/action/timedclick) action. 55 | 56 | In order to have a complicated interaction on the button element, you can use the `press` and `timedclick` actions in conjunction with each other. You can also add multiple `press` actions to the same element. The `press` action can also be used with any standard events such as `on:pointerdown` and `on:pointerup`. 57 | -------------------------------------------------------------------------------- /docs/action/resize.md: -------------------------------------------------------------------------------- 1 | # Resize 2 | 3 | Action that adds a `resize` event and optionally calls a handler function when an element is resized. This can be when the size is changed by the user, but also when children are added or removed. Uses ResizeObserver under the hood. 4 | 5 | Want to track the window size? Go for the `window` store in `svu/client` or use the window.onresize event. 6 | 7 | - See also [window](/client/window) 8 | 9 | ## Usage 10 | 11 | It's commonly used to track the size of an element, but I guess you've figured that out by now. 12 | 13 | ```svelte 14 | 18 | 19 | 20 | 21 |
console.log('do something')} style:height="{h}px"> 22 | I will fire an event when I change size. 23 |
24 | ``` 25 | 26 | ## Options 27 | 28 | This action takes an options object with the following parameters: 29 | 30 | ### `handler` 31 | 32 | The handler to call when the element is resized. 33 | 34 | - Optional: yes 35 | - Type: `Function` 36 | - Default value: `noop` 37 | 38 | ## Events 39 | 40 | This action dispatches one event: 41 | 42 | ### `resize` 43 | 44 | Emitted every time the node is resized. Contains a reference to the node in it's detail, which you can use to get the new size (using `node.getBoundingClientRect()` or similar). 45 | 46 | ## Caveats 47 | 48 | Each action generates a new ResizeObserver, which _might_ lead to degraded performance when used in really large numbers, but this is considered an edge case and is as of yet untested. 49 | -------------------------------------------------------------------------------- /docs/action/select.md: -------------------------------------------------------------------------------- 1 | # Select 2 | 3 | Selects the content of the element (or a specified target) on click. If the element is an input element, it selects the value, otherwise it selects all childNodes (text and elements). 4 | 5 | ## Usage 6 | 7 | A common usecase is to select the content of a `` element. 8 | 9 | ```svelte 10 | 14 | 15 | 16 | ``` 17 | 18 | ## Options 19 | 20 | This action has one option: 21 | 22 | ### `target` 23 | 24 | The element whose content should be selected. If not specified, the element itself is used. 25 | 26 | - Optional: yes 27 | - Type: `HTMLElement` 28 | - Default value: The node itself. 29 | 30 | ## Events 31 | 32 | This action does not dispatch any events. 33 | 34 | ## Caveats 35 | 36 | This action does not yet support passing in a target using a query selector, unlike many other svus. 37 | -------------------------------------------------------------------------------- /docs/action/themetoggle.md: -------------------------------------------------------------------------------- 1 | # Themetoggle 2 | 3 | Sets the `data-theme` attribute of the `html` element to a theme or the next of a list of themes on click. 4 | 5 | - see also: [theme](/client/theme) 6 | 7 | ## Usage 8 | 9 | It's commonly used to toggle the theme of a site between a predefined list. It's used in the theme toggle button at the top of this page. 10 | 11 | ```svelte 12 | 15 | 16 | 19 | 20 | 23 | 24 | 33 | ``` 34 | 35 | ## Options 36 | 37 | This takes one option, which can be either a theme or a list of themes. If it's a theme it sets the theme on click, if it's a list it loops through the list. 38 | 39 | - Optional: yes 40 | - Type: `string | string[]` 41 | - Default value: `['light', 'dark']` 42 | -------------------------------------------------------------------------------- /docs/action/timedclick.md: -------------------------------------------------------------------------------- 1 | # Timedclick 2 | 3 | Dispatches an event and calls an optional handler if an element is _pressed down_ and _released_ within a certain amount of time. Starts checking after an optional `delay` so can be used for fairly precise timings. 4 | 5 | - See also: [use:press](/action/press) 6 | 7 | ## Usage 8 | 9 | Most commonly used on button elements. 10 | 11 | ```svelte 12 | 16 | 17 |

Release the button between 500ms and 1s to trigger the handler

18 | 26 | ``` 27 | 28 | ## Options 29 | 30 | You can pass in an `options` object with the following parameters: 31 | You can also pass in a number, which will be interpreted as the `duration` (and a `delay` of 0). 32 | 33 | ### `duration` 34 | 35 | The time in milliseconds how long the element needs to be pressed down for. The duration starts counting after the delay has passed. With a delay of 500ms and duration of 500ms, the element needs to be released between 500ms and 1000ms after being pressed down. 36 | 37 | - Optional: no 38 | - Type: `number` 39 | - Default value: none 40 | 41 | ### `delay` 42 | 43 | The time in milliseconds after the element has been pressed down before the duration starts counting. 44 | 45 | - Optional: yes 46 | - Type: `number` 47 | - Default value: `0` 48 | 49 | ## Events 50 | 51 | This action dispatches three events: 52 | 53 | ### `timedclick:armed` 54 | 55 | Emitted when the 'delay' has passed. Can be used to signal that this is the correct time to release the button. 56 | 57 | ### `timedclick` 58 | 59 | Emitted after the `delay` has passed and before the `duration` is over. 60 | 61 | ### `timedclick:canceled` 62 | 63 | Emitted when the button has been pressed for `duration` milliseconds. The event payload is the duration of the press. This can be used to distinguish between different events if multiple press actions are added. 64 | 65 | ## Caveats 66 | 67 | This action used to handle a timed click event. If you simply want to handle a button that is pressed down fow a certain time, you can better use the [press](/action/press) action. 68 | 69 | In order to have a complicated interaction on the button element, you can use the `press` and `timedclick` actions in conjunction with each other. The `timedclick` action can also be used with any standard events such as `on:pointerdown` and `on:pointerup`. 70 | -------------------------------------------------------------------------------- /docs/action/viewport.md: -------------------------------------------------------------------------------- 1 | # Viewport 2 | 3 | Dispatches events when the element enters or leaves the viewport. Uses `IntersectionObserver`. 4 | 5 | ## Usage 6 | 7 | Use it to check wether an element is inside the viewport. 8 | 9 | ```svelte 10 | 13 | ``` 14 | 15 | ## Options 16 | 17 | You can pass in an optional `options` object with the following parameters: 18 | 19 | ### `root` 20 | 21 | Which root element to observe. Defaults to the browser viewport when not specified. 22 | 23 | - Optional: yes 24 | - Type: `HTMLElement` 25 | - Default value: the browser viewport 26 | 27 | ### `rootMargin` 28 | 29 | Optional margin around the root to consider when checking for intersection. Can have the same values as the CSS `margin` property. 30 | 31 | - Optional: yes 32 | - Type: `string` 33 | - Default value: `'0px'` 34 | 35 | ### `threshold` 36 | 37 | Enter a number between 0 and 1 or an array of numbers between 0 and 1. The `viewport:enter` event will as soon as the visible percentage of the element is past the threshold. The `viewport:leave` event will as soon as the visible percentage of the element is below the threshold. By default the value is `0`, which means that as soon as 1 pixel of the element is visible, the `viewport:enter` will be triggered, and once the last pixel of the element is hidden, the `viewport:leave` event fires. 38 | 39 | - Optional: yes 40 | - Type: `number` 41 | - Default value: `0` 42 | 43 | ## Events 44 | 45 | This action dispatches two events: 46 | 47 | ### `viewport:enter` 48 | 49 | Emitted every time the element enters the viewport. Returns the `InterSectionObserverEntry` in the `detail` property. 50 | 51 | ### `viewport:leave` 52 | 53 | Emitted every time the element leaves the viewport. Returns the `InterSectionObserverEntry` in the `detail` property. 54 | 55 | ## Caveats 56 | 57 | This is a very basic implementation that does not (yet) share IntersectionObservers, so you may issue slight performance issues if you have a lot of elements using this action. 58 | 59 | It also doesn't yet support a `number[]` threshold for multiple events based on a visible percentage. 60 | 61 | Both the above caveats are planned for a future version. 62 | -------------------------------------------------------------------------------- /docs/app/log.md: -------------------------------------------------------------------------------- 1 | # Log 2 | 3 | Various logging utilities that are only used in the dev environment. 4 | Supports all `console.*` functions (log, trace, warn, error). 5 | 6 | ## Usage 7 | 8 | Alternative logging functions, or drop-in replacement for `console.log`, but without having to remove them before moving to prod. 9 | 10 | ```svelte 11 | 15 | 16 | 17 | 18 | 19 | ``` 20 | 21 | ## Functions 22 | 23 | - `log()` - wraps `console.log()` 24 | - `trace()` - wraps `console.trace()` 25 | - `debug()` - wraps `console.debug()` 26 | - `info()` - wraps `console.info()` 27 | - `warn()` - wraps `console.warn()` 28 | - `error()` - wraps `console.error()` 29 | - `assert()` - wraps `console.assert()` 30 | - `count()` - wraps `console.count()` 31 | - `countReset()` - wraps `console.countReset()` 32 | - `dir()` - wraps `console.dir()` 33 | - `dirxml()` - wraps `console.dirxml()` 34 | - `clear()` - wraps `console.clear()` 35 | 36 | ## Caveats 37 | 38 | When using the drop-in replacement, currently some console functions (`group*`, `profile*`, `screenshot`, `time*`, `takeHeapSnapshot`) are not available. 39 | -------------------------------------------------------------------------------- /docs/client/media.md: -------------------------------------------------------------------------------- 1 | # Media 2 | 3 | A collecion of mediaquery based stores. 4 | 5 | ## Motion 6 | 7 | ### motionOK 8 | 9 | `$motionOK` is true when `prefers-reduced-motion` is `no-preference`. 10 | 11 | ### reduceMotion 12 | 13 | `$reduceMotion` is true when `prefers-reduced-motion` is `reduce`. 14 | 15 | ## Media 16 | 17 | ### print 18 | 19 | `$print` is true when the page is (pre)viewed for printing. 20 | 21 | ### screen 22 | 23 | `$screen` is true when the page is viewed on a screen; 24 | 25 | ## Orientation 26 | 27 | ### landscape 28 | 29 | `$landscape` is true when the viewport is wider than it is tall; 30 | 31 | ### portrait 32 | 33 | `$portrait` is true when the viewport is taller than it is wide; 34 | 35 | ## Color Scheme 36 | 37 | ### dark 38 | 39 | `$dark` is true when the user has `prefers-color-scheme` set to `dark`. 40 | 41 | ### light 42 | 43 | `$light` is true when the user has `prefers-color-scheme` set to `light`. 44 | 45 | ## Contrast 46 | 47 | ### highContrast 48 | 49 | `$highContrast` is true when the user has `prefers-contrast` set to `high`. 50 | 51 | ### lowContrast 52 | 53 | `$lowContrast` is true when the user has `prefers-contrast` set to `low`. 54 | 55 | ### defaultContrast 56 | 57 | `$defaultContrast` is true when the user has `prefers-contrast` set to `default`. 58 | 59 | ## Pointer 60 | 61 | ### hover 62 | 63 | `$hover` is true when the device can display a hover state. (You can do so with a mouse, but not with a finger + touchscreen.) 64 | 65 | ### noPointer 66 | 67 | `$noPointer` is true the client does not have a pointer available. 68 | 69 | ### coarsePointer 70 | 71 | `$coarsePointer` is true when the client has a coarse pointer (e.g. a finger) available. 72 | 73 | ### finePointer 74 | 75 | `$finePointer` is true when the client has a fine pointer (e.g. a mouse) available. 76 | -------------------------------------------------------------------------------- /docs/client/mouse.md: -------------------------------------------------------------------------------- 1 | # Mouse 2 | 3 | Mouse is a collection of stores that contain the mouse position. You can either get the x position through `mx` and y position through `my`, or access the `mouse` store which is an object in the form of `{x, y}`. 4 | 5 | - see also: [window](/client/window) 6 | 7 | ## Usage 8 | 9 | This can be used for anything that needs the mouse position. Here is a purple square following the mouse: 10 | 11 | ```svelte 12 | 16 | 17 |
18 | 19 | 30 | ``` 31 | 32 | ## Caveats 33 | 34 | It's a readable store, thus cannot be used to change the mouse position programmatically. (Moving the mouse using JavaScript is impossible. If you think you want it, you don't.) 35 | -------------------------------------------------------------------------------- /docs/client/theme.md: -------------------------------------------------------------------------------- 1 | # Theme 2 | 3 | Store to track and change the root element's `data-theme` attribute. It works well with the themetoggle action, but will work equally well with any other theme-change library that sets the `data-theme` attribute on the root element. It's powered by a MutationObserver that checks for any changes to the element. 4 | 5 | - See also: [themetoggle](/action/themetoggle) 6 | 7 | ## Usage 8 | 9 | A common usecase is to change the text on the themetoggle button based on the currently active theme. It's used in the theme toggle button at the top of this page. 10 | 11 | ```svelte 12 | 16 | 17 | 20 | ``` 21 | 22 | ## Options 23 | 24 | this has the following options: 25 | 26 | ### `option` 27 | 28 | changes the behaviour of this thing 29 | 30 | - Optional: yes 31 | - Type: `any` 32 | - Default value: `'default` 33 | 34 | ## Events 35 | 36 | this dispatches one event: 37 | 38 | ### `event` 39 | 40 | emitted every time something happens 41 | 42 | ## Caveats 43 | 44 | caveats 45 | 46 | ## Alternatives 47 | 48 | – 49 | -------------------------------------------------------------------------------- /docs/client/window.md: -------------------------------------------------------------------------------- 1 | # Window 2 | 3 | Window is a collection of stores that store the window size and scroll position. 4 | 5 | You can either get the window size in the form of `wx` and `wh` or acces the `windowSize` store which is an object in the form of `{ w, h }`. 6 | 7 | You can get the scroll position in the form of `sx` and `sy` or access the `scroll` store which is an object in the form of `{ x, y }`. 8 | 9 | ## Usage 10 | 11 | It can be used in any place you would need to add `` instead. 12 | 13 | ```svelte 14 | 17 | 18 |

19 | width: {$ww}, height: {$wh}, scrollX: {$sx}, scrollY: {$sy} 20 |

21 | ``` 22 | -------------------------------------------------------------------------------- /docs/components/onmount.md: -------------------------------------------------------------------------------- 1 | # OnMount 2 | 3 | The `` component is a component that renders its contents when the page is loaded. It's a shorthand for setting a variable 'mounted' to true in the `onMount()` lifecycle function. 4 | 5 | ```svelte 6 | 9 | 10 | 11 |

I am not prerendered!

12 |
13 | ``` 14 | -------------------------------------------------------------------------------- /docs/store/localstore.md: -------------------------------------------------------------------------------- 1 | # Localstore 2 | 3 | A writable store that is synced with localstorage. On mount it will read from localstorage and on set it will write to localstorage. It falls back to a standard resettable store 4 | 5 | - See also: [resettable](/store/resettable) and [sessionstore](/store/sessionstore) 6 | 7 | ```svelte 8 | 12 | 13 | 14 | 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/store/mediaquery.md: -------------------------------------------------------------------------------- 1 | # Mediaquery 2 | 3 | A store that syncs to media query changes. 4 | Add in a `media` + `value` pair or a single 'media' value. 5 | Uses window.matchMedia under the hood. 6 | 7 | There are many predefined queries available from [svu/client](/client/media). 8 | 9 | - See also: [media](/client/media) 10 | 11 | ```svelte 12 | 16 | 17 | {#if darkMode} 18 | I see a window and I want to paint it black. 19 | {/if} 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/store/resettable.md: -------------------------------------------------------------------------------- 1 | # Resettable 2 | 3 | The resettable store is an extended svelte writable store that can be reset to its initial value through `store.reset()`. It works on any values including objects. 4 | 5 | ```svelte 6 | 10 | 11 | 12 | 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/store/sessionstore.md: -------------------------------------------------------------------------------- 1 | # Sessionstore 2 | 3 | A writable store that is synced with localstorage. On mount it will read from localstorage and on set it will write to localstorage. It falls back to a standard resettable store 4 | 5 | - See also: [resettable](/store/resettable) and [localstore](/store/localstore) 6 | 7 | ```svelte 8 | 12 | 13 | 14 | 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/transition/slide.md: -------------------------------------------------------------------------------- 1 | # Slide 2 | 3 | This is a drop-in replacement for Svelte's built-in `slide` transition with one difference: it supports horizontally sliding transitions. It transitions horizontally by default, so changing `import { slide } from 'svelte/transition'` to `import { slide } from 'svu/transition'` will change the direction of your transition. You can change the `axis` option from `'x'` to `'y'` to change the direction back to vertical. 4 | 5 | ## Usage 6 | 7 | It's used on this site to slide out the navigation when the user (that would be you) moves between docs pages. 8 | 9 | ```svelte 10 | 13 | 14 | {#if condition} 15 |
18 | {/if} 19 | ``` 20 | 21 | ## Options 22 | 23 | The slide function accepts an option object with the following parameters: 24 | 25 | ### `delay` 26 | 27 | Milliseconds before starting the transition. 28 | 29 | - Optional: yes 30 | - Type: `number` 31 | - Default value: `0` 32 | 33 | ### `duration` 34 | 35 | Transition length in milliseconds. 36 | 37 | - Optional: yes 38 | - Type: `number` 39 | - Default value: `400` 40 | 41 | ### `easing` 42 | 43 | The [Svelte easing function](https://svelte.dev/docs#run-time-svelte-easing) to apply. 44 | 45 | - Optional: yes 46 | - Type: `function` 47 | - Default value: `cubicOut` – _[more info](https://svelte.dev/examples/easing)_ 48 | 49 | ### `axis` 50 | 51 | The direction to slide the element in. 52 | 53 | - Optional: yes 54 | - Type: `'x' | 'y'` 55 | - Default value: `'x'` 56 | -------------------------------------------------------------------------------- /docs/transition/typewriter.md: -------------------------------------------------------------------------------- 1 | # Typewriter 2 | 3 | This is the typewriter effect from the [Svelte Tutorial](https://svelte.dev/tutorial/custom-js-transitions). 4 | 5 | ## Usage 6 | 7 | It's used on this site to give the `/svu` text on the home page a typed effect. 8 | 9 | ```svelte 10 | 13 | 14 | {#if condition} 15 |
This text will be typed.
16 | {/if} 17 | ``` 18 | 19 | ## Options 20 | 21 | The typewriter transition accepts an options object with the following parameters: 22 | 23 | ### `delay` 24 | 25 | Milliseconds before starting the transition. 26 | 27 | - Optional: yes 28 | - Type: `number` 29 | - Default value: `0` 30 | 31 | ### `speed` 32 | 33 | Milliseconds per typed letter. Higher values give a lower type speed. 34 | 35 | - Optional: yes 36 | - Type: `number` 37 | - Default value: `100` 38 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import svelte_config from '@sveltejs/eslint-config'; 2 | 3 | // eslint.config.js 4 | export default [ 5 | ...svelte_config, 6 | { 7 | rules: { 8 | 'no-duplicate-imports': 'off', // Allows importing types and code separately 9 | 'svelte/no-at-html-tags': 'off', // For markdown 10 | 'no-undef': 'off', // Seems to error with $stores and use:actions in .svelte files 11 | '@typescript-eslint/no-unused-vars': [ 12 | 'error', 13 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } 14 | ] 15 | } 16 | }, 17 | { 18 | ignores: ['build/', '.svelte-kit/', 'dist/*', '**/*.d.ts'] 19 | } 20 | ]; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svu", 3 | "description": "Svelte development, supercharged.", 4 | "version": "0.3.12", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/nikolai-cc/svu.git" 8 | }, 9 | "author": "nikolai-cc (https://nikolai.cc)", 10 | "license": "MIT", 11 | "homepage": "svu.vercel.app", 12 | "bugs": { 13 | "url": "https://github.com/nikolai-cc/svu/issues" 14 | }, 15 | "scripts": { 16 | "dev": "vite dev", 17 | "build": "vite build && npm run package", 18 | "preview": "vite preview", 19 | "package": "svelte-kit sync && svelte-package && publint", 20 | "prepublishOnly": "npm run package", 21 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 22 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 23 | "lint": "prettier --check . && eslint .", 24 | "format": "prettier --write .", 25 | "prepare": "husky" 26 | }, 27 | "exports": { 28 | ".": { 29 | "types": "./dist/index.d.ts", 30 | "svelte": "./dist/index.js" 31 | }, 32 | "./action": { 33 | "types": "./dist/action/index.d.ts", 34 | "svelte": "./dist/action/index.js" 35 | }, 36 | "./app": { 37 | "types": "./dist/app/index.d.ts", 38 | "svelte": "./dist/app/index.js" 39 | }, 40 | "./client": { 41 | "types": "./dist/client/index.d.ts", 42 | "svelte": "./dist/client/index.js" 43 | }, 44 | "./components": { 45 | "types": "./dist/components/index.d.ts", 46 | "svelte": "./dist/components/index.js" 47 | }, 48 | "./store": { 49 | "types": "./dist/store/index.d.ts", 50 | "svelte": "./dist/store/index.js" 51 | }, 52 | "./transition": { 53 | "types": "./dist/transition/index.d.ts", 54 | "svelte": "./dist/transition/index.js" 55 | } 56 | }, 57 | "files": [ 58 | "dist" 59 | ], 60 | "peerDependencies": { 61 | "svelte": "^4.0.0" 62 | }, 63 | "devDependencies": { 64 | "@fontsource-variable/recursive": "^5.0.17", 65 | "@sveltejs/adapter-static": "^3.0.1", 66 | "@sveltejs/eslint-config": "^7.0.1", 67 | "@sveltejs/kit": "^2.5.5", 68 | "@sveltejs/package": "^2.3.1", 69 | "@sveltejs/vite-plugin-svelte": "^3.1.0", 70 | "@types/eslint": "8.56.8", 71 | "@typescript-eslint/eslint-plugin": "^7.6.0", 72 | "@typescript-eslint/parser": "^7.6.0", 73 | "changesets": "^1.0.2", 74 | "eslint": "^9.0.0", 75 | "eslint-config-prettier": "^9.1.0", 76 | "eslint-plugin-svelte": "^2.36.0", 77 | "husky": "^9.0.11", 78 | "lint-staged": "^15.2.7", 79 | "marked": "^12.0.1", 80 | "prettier": "^3.2.5", 81 | "prettier-plugin-svelte": "^3.2.2", 82 | "publint": "^0.2.7", 83 | "svelte": "^4.2.13", 84 | "svelte-check": "^3.6.9", 85 | "tslib": "^2.6.2", 86 | "typescript": "^5.4.5", 87 | "vite": "^5.2.8" 88 | }, 89 | "svelte": "./dist/index.js", 90 | "types": "./dist/index.d.ts", 91 | "type": "module" 92 | } 93 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/action/active.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '../meta/event.js'; 2 | import { patchHistoryAPI } from '../meta/history.js'; 3 | 4 | export interface UseActiveOptions { 5 | class?: string; 6 | subpaths?: boolean; 7 | path?: string; 8 | hash?: string; 9 | } 10 | 11 | /** 12 | * Adds a class (`svu-active` by default) to the element when the current path matches `option.path`. 13 | * When used on a link, it extracts the path from the `href` attribute by default. 14 | * Set `option.hash` to `true` to compare to `location.hash` instead of `location.pathname`. 15 | * Set `option.subpaths` to `true` to include children of `option.path` (e.g. `option.path` with value `/about` will match on the page `/about/me`). 16 | * Use a 'scoped global' style to add component-specific styling (see example below). 17 | * 18 | * Example: 19 | * ```svelte 20 | *
24 | * 25 | * 28 | * ``` 29 | */ 30 | export function active(node: HTMLElement, options?: UseActiveOptions) { 31 | let { subpaths = false, path = node.getAttribute('href') ?? '/', hash = false } = options || {}; 32 | 33 | let className = options?.class || 'svu-active'; 34 | 35 | function setClass(pathName: string) { 36 | if ((subpaths && pathName.startsWith(path)) || pathName === path) { 37 | node.classList.add(className); 38 | } else { 39 | node.classList.remove(className); 40 | } 41 | } 42 | 43 | patchHistoryAPI(); 44 | 45 | // Listens for native `popstate` event. 46 | const unlistenPopState = listen(window, 'popstate', () => { 47 | setClass(hash ? window.location.hash : window.location.pathname); 48 | }); 49 | 50 | // Listens for custom `!replacestate` event (see meta/history.ts) 51 | const unlistenReplaceState = listen(window, '!replacestate', () => { 52 | setClass(hash ? window.location.hash : window.location.pathname); 53 | }); 54 | 55 | // Listens for custom `!pushstate` event (see meta/history.ts) 56 | const unlistenPushState = listen(window, '!pushstate', () => { 57 | setClass(hash ? window.location.hash : window.location.pathname); 58 | }); 59 | 60 | setClass(hash ? window.location.hash : window.location.pathname); 61 | 62 | return { 63 | update(options: UseActiveOptions) { 64 | className = options.class || 'svu-active'; 65 | subpaths = options.subpaths ?? subpaths; 66 | path = options.path ?? path; 67 | hash = options.hash ?? hash; 68 | setClass(hash ? window.location.hash : window.location.pathname); 69 | }, 70 | destroy() { 71 | node.classList.remove(className); 72 | unlistenPopState(); 73 | unlistenReplaceState(); 74 | unlistenPushState(); 75 | } 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/action/clickoutside.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | import { listen } from '../meta/event.js'; 3 | 4 | import type { Fn } from '../meta/fn.js'; 5 | import type { ActionReturn } from 'svelte/action'; 6 | 7 | interface Attributes { 8 | 'on:!clickoutside'?: (event: CustomEvent) => void; 9 | } 10 | 11 | /** 12 | * Calls `handler` when a click event occurs outside of `node`. The event is forwarded to `handler`. 13 | * Also dispatches a `!clickoutside` event on 'node' with the original event as `detail`. 14 | * 15 | * Example: 16 | * ```svelte 17 | * 18 | * 19 | * ``` 20 | */ 21 | export function clickoutside( 22 | node: HTMLElement, 23 | handler: Fn<[Event]> = noop 24 | ): ActionReturn, Attributes> { 25 | let handle = handler; 26 | 27 | function handleClick(event: Event) { 28 | if (!node.contains(event.target as Node)) { 29 | handle(event); 30 | node.dispatchEvent(new CustomEvent('!clickoutside', { detail: event })); 31 | } 32 | } 33 | 34 | const unlisten = listen(document, 'click', handleClick); 35 | 36 | return { 37 | update: (handler: Fn<[Event]>) => { 38 | handle = handler; 39 | }, 40 | destroy: unlisten 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/action/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '../meta/event.js'; 2 | import { getElement, getTextContent, setTextContent } from '../meta/element.js'; 3 | 4 | import type { ActionReturn } from 'svelte/action'; 5 | import type { ElementOrSelector } from '../meta/element.js'; 6 | 7 | interface CopyAttributes { 8 | 'on:!copy'?: (event: CustomEvent) => void; 9 | } 10 | 11 | interface CutAttributes { 12 | 'on:!cut'?: (event: CustomEvent) => void; 13 | } 14 | 15 | interface PasteAttributes { 16 | 'on:!paste'?: (event: CustomEvent) => void; 17 | } 18 | 19 | /** 20 | * Copies the text content of `target` to the clipboard when `node` is clicked. 21 | * If `target` is not provided, `node`'s textContent is copied. When `target` is a string, it is used as a query selector. 22 | * Also dispatches a `!copy` event on 'node' with the copied text as `detail`. 23 | * When `target` is an input or textarea, its value is copied. Otherwise, textContent is copied. 24 | * 25 | * Example: 26 | * ```svelte 27 | * 28 | * 19 | */ 20 | export function download(node: HTMLElement, options?: UseDownloadOptions) { 21 | let { name = 'download.txt', type, data = '' } = options || {}; 22 | 23 | type = type || typeof data === 'object' ? 'application/json' : 'text/plain'; 24 | let spaces = options?.formatted ? 2 : 0; 25 | 26 | function downloadFile() { 27 | let blob; 28 | if (typeof data === 'object') { 29 | blob = new Blob([JSON.stringify(data, null, spaces)], { type }); 30 | } else { 31 | blob = new Blob([data], { type }); 32 | } 33 | const url = URL.createObjectURL(blob); 34 | console.log({ url }); 35 | const a = document.createElement('a'); 36 | a.href = url; 37 | a.download = name; 38 | a.click(); 39 | URL.revokeObjectURL(url); 40 | } 41 | 42 | const unlisten = listen(node, 'click', downloadFile); 43 | 44 | return { 45 | update: (options: UseDownloadOptions) => { 46 | name = options.name || 'download'; 47 | type = options.type || 'text/plain'; 48 | data = options.data || ''; 49 | spaces = options.formatted ? 2 : 0; 50 | }, 51 | destroy: unlisten 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/action/draggable.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from '../meta/math.js'; 2 | import { listen } from '../meta/event.js'; 3 | import { getElement, getDomRect, getTransformCoords, transform } from '../meta/element.js'; 4 | 5 | import type { Coords } from '../meta/types.js'; 6 | import type { ActionReturn } from 'svelte/action'; 7 | import type { ElementOrSelector } from '../meta/element.js'; 8 | 9 | export interface UseDraggableOptions { 10 | position?: Coords; 11 | handle?: ElementOrSelector; 12 | axis?: 'x' | 'y' | 'xy'; 13 | container?: ElementOrSelector; 14 | class?: string; 15 | } 16 | 17 | interface Attributes { 18 | 'on:!dragstart'?: (event: CustomEvent) => void; 19 | 'on:!drag'?: (event: CustomEvent) => void; 20 | 'on:!dragend'?: (event: CustomEvent) => void; 21 | } 22 | 23 | /** 24 | * Returns the minimum and maximum bounds for the draggable element based on an optional container element. 25 | */ 26 | function setBounds(node: HTMLElement, position: Coords, container: HTMLElement | undefined) { 27 | const min = { x: -Infinity, y: -Infinity }; 28 | const max = { x: Infinity, y: Infinity }; 29 | 30 | if (container) { 31 | const containerRect = getDomRect(container); 32 | const nodeRect = getDomRect(node); 33 | 34 | min.x = containerRect.left - nodeRect.left + position.x; 35 | min.y = containerRect.top - nodeRect.top + position.y; 36 | max.x = containerRect.right - nodeRect.right + position.x; 37 | max.y = containerRect.bottom - nodeRect.bottom + position.y; 38 | } 39 | 40 | return { min, max }; 41 | } 42 | 43 | /** 44 | * Allows positioning of an element by dragging it from the element or an optional handle defined with `options.handle`. 45 | * Emits `!drag:start`, `!drag` and `!drag:end` events on the element. Update the `options.position` property to programmatically move the element. 46 | * Use the `options.axis` property to limit movement to a single axis (`x` or `y`). Use the `options.container` property to limit movement to inside a container element. 47 | * When dragging the dragged element gets `options.class` (`svu-dragging` by default). Use a 'scoped global' style to add component-specific styling (see example below). 48 | * 49 | * Example: 50 | * ```svelte 51 | *
52 | * 53 | * 56 | * ``` 57 | */ 58 | export function draggable( 59 | node: HTMLElement, 60 | options?: UseDraggableOptions 61 | ): ActionReturn { 62 | let { position = { x: 0, y: 0 }, axis = 'xy' } = options || {}; 63 | 64 | let className = options?.class || 'svu-dragging'; 65 | let handle = getElement(options?.handle, node); 66 | let container = getElement(options?.container); 67 | let origin = position; 68 | 69 | if (options && options.position) draw(); 70 | 71 | function handlePointerDown(event: PointerEvent) { 72 | event.preventDefault(); 73 | event.stopPropagation(); 74 | node.setPointerCapture(event.pointerId); 75 | node.classList.add(className); 76 | 77 | origin = { 78 | x: event.clientX, 79 | y: event.clientY 80 | }; 81 | 82 | position = getTransformCoords(node); 83 | 84 | const { min, max } = setBounds(node, position, container); 85 | 86 | node.dispatchEvent(new CustomEvent('!dragstart', { detail: position })); 87 | 88 | function handlePointerMove(event: PointerEvent) { 89 | event.preventDefault(); 90 | event.stopPropagation(); 91 | 92 | const containerRect = container ? getDomRect(container) : undefined; 93 | 94 | let moveX = axis.includes('x'); 95 | let moveY = axis.includes('y'); 96 | 97 | if (containerRect) { 98 | moveX = 99 | event.clientX >= containerRect.left && event.clientX <= containerRect.right && moveX; 100 | moveY = 101 | event.clientY >= containerRect.top && event.clientY <= containerRect.bottom && moveY; 102 | } 103 | 104 | position.x = moveX ? position.x + event.clientX - origin.x : position.x; 105 | position.y = moveY ? position.y + event.clientY - origin.y : position.y; 106 | 107 | position.x = clamp(position.x, min.x, max.x); 108 | position.y = clamp(position.y, min.y, max.y); 109 | 110 | draw(); 111 | 112 | origin = { 113 | x: event.clientX, 114 | y: event.clientY 115 | }; 116 | 117 | node.dispatchEvent(new CustomEvent('!drag', { detail: position })); 118 | } 119 | 120 | function handlePointerUp(event: PointerEvent) { 121 | event.preventDefault(); 122 | event.stopPropagation(); 123 | node.releasePointerCapture(event.pointerId); 124 | node.classList.remove(className); 125 | node.dispatchEvent(new CustomEvent('!dragend', { detail: position })); 126 | 127 | unlistenPointerMove(); 128 | unlistenPointerUp(); 129 | } 130 | 131 | const unlistenPointerMove = listen(window, 'pointermove', handlePointerMove as EventListener); 132 | const unlistenPointerUp = listen(window, 'pointerup', handlePointerUp as EventListener); 133 | } 134 | 135 | function draw() { 136 | transform(node, position); 137 | } 138 | 139 | let unlistenPointerDown = listen(handle, 'pointerdown', handlePointerDown as EventListener); 140 | let unlistenTouchStart = listen(handle, 'touchstart', (e) => e.preventDefault()); 141 | 142 | return { 143 | update(options: UseDraggableOptions) { 144 | position = options.position || position; 145 | className = options.class || 'svu-dragging'; 146 | axis = options.axis || axis; 147 | container = getElement(options.container) || container; 148 | 149 | if (options.position) draw(); 150 | 151 | if (options.handle !== handle) { 152 | handle = getElement(options.handle, handle); 153 | 154 | unlistenPointerDown(); 155 | unlistenTouchStart(); 156 | 157 | unlistenPointerDown = listen(handle, 'pointerdown', handlePointerDown as EventListener); 158 | unlistenTouchStart = listen(handle, 'touchstart', (e) => e.preventDefault()); 159 | } 160 | }, 161 | destroy() { 162 | unlistenPointerDown(); 163 | unlistenTouchStart(); 164 | } 165 | }; 166 | } 167 | -------------------------------------------------------------------------------- /src/lib/action/dropzone.ts: -------------------------------------------------------------------------------- 1 | import { runAll, type Fn } from '$lib/meta/fn.js'; 2 | import { listen } from '../meta/event.js'; 3 | import type { ActionReturn } from 'svelte/action'; 4 | import { getElement, type ElementOrSelector } from '$lib/meta/element.js'; 5 | 6 | export type DropZoneError = 7 | | 'too-many-files' 8 | | 'invalid-mime-type' 9 | | 'no-files-dropped' 10 | | 'file-too-large'; 11 | 12 | export interface UseDropzoneOptions { 13 | files?: File[]; 14 | mime?: string; 15 | limit?: number; 16 | replace?: boolean; 17 | maxFileSize?: number; 18 | uploadButton?: ElementOrSelector; 19 | resetButton?: ElementOrSelector; 20 | dragClass?: string; 21 | dropClass?: string; 22 | limitClass?: string; 23 | invalidClass?: string; 24 | } 25 | 26 | export interface Attributes { 27 | 'on:!drop'?: (event: CustomEvent) => void; 28 | 'on:!drop:invalid'?: (event: CustomEvent) => void; 29 | 'on:!drop:limit'?: (event: CustomEvent) => void; 30 | } 31 | 32 | function setupButton(node: HTMLElement, action: EventListener) { 33 | node.style.cursor = 'pointer'; 34 | node.setAttribute('tabindex', '0'); 35 | node.setAttribute('role', 'button'); 36 | 37 | const clickEvent = listen(node, 'click', action); 38 | const keydownEvent = listen(node, 'keydown', action); 39 | 40 | return [clickEvent, keydownEvent]; 41 | } 42 | 43 | function removeButtonMeta(node: HTMLElement, unlisteners: Fn[]) { 44 | node.style.cursor = ''; 45 | node.removeAttribute('tabindex'); 46 | node.removeAttribute('role'); 47 | runAll(unlisteners); 48 | } 49 | 50 | /** 51 | * Dropzone for files. Emits events when files are dropped or when the drop fails. 52 | * 53 | * Pass an object to `options.files` to change the files programmatically. Setting `options.files` to an empty array will reset the dropzone. 54 | * 55 | * Set `options.mime` to limit the accepted file types. 56 | * Set `options.limit` to limit the number of files that can be dropped. Set to 0 for no limit. 57 | * Set `options.replace` to `false` to add files when new files are dropped instead of replacing them. This option is only used when `options.limit` is set to anything other than 1. 58 | * Set `options.maxFileSize` to limit the size of the files that can be dropped. The value is per file and is in bytes. 59 | * Pass an element or a selector string to `options.uploadButton` to trigger the file browser when clicked. When no element is passed, the dropzone itself will be clickable. 60 | * Pass an element or a selector string to `options.resetButton` to reset the dropzone when clicked. 61 | * It is recommended to pass in button elements for both `options.uploadButton` and `options.resetButton`, though dropzone will attempt to set appropriate attributes for accessibility. 62 | * 63 | * Emits a `!drop` event with the `FileList` when files are dropped. 64 | * Emits a `!drop:invalid` event with the error message when the drop fails (e.g. too many files). 65 | * Emits a `!drop:limit` event with the `FileList` when the dropzone has reached the file limit (`options.limit`). 66 | * 67 | * The dropzone will add the class `options.dragClass` (`svu-dragover` by default) while files are dragged over the dropzone. 68 | * The dropzone will add the class `options.dropClass` (`svu-dropped` by default) when files are dropped. 69 | * The dropzone will add the class `options.limitClass` (`svu-droplimit` by default) when the dropzone has reached the file limit (`options.limit`). 70 | * The dropzone will add the class `options.invalidClass` (`svu-invalid` by default) when the drop fails. 71 | * 72 | * Example: 73 | * ```svelte 74 | *
75 | * Drop files here 76 | *
77 | * ``` 78 | */ 79 | export function dropzone( 80 | node: HTMLElement, 81 | options?: UseDropzoneOptions 82 | ): ActionReturn { 83 | let files = options?.files ? options.files : []; 84 | let mime = options?.mime || ''; 85 | let limit = options?.limit || 0; 86 | let replace = options?.replace !== undefined ? options.replace : true; 87 | let maxFileSize = options?.maxFileSize || Infinity; 88 | let uploadButton = getElement(options?.uploadButton, node); 89 | let resetButton = getElement(options?.resetButton); 90 | let dragClass = options?.dragClass || 'svu-dragover'; 91 | let dropClass = options?.dropClass || 'svu-dropped'; 92 | let limitClass = options?.limitClass || 'svu-droplimit'; 93 | let invalidClass = options?.invalidClass || 'svu-invalid'; 94 | 95 | function reset() { 96 | node.classList.remove(dragClass, dropClass, invalidClass, limitClass); 97 | files = []; 98 | } 99 | 100 | function invalid(e: DropZoneError) { 101 | node.classList.add(invalidClass); 102 | node.dispatchEvent(new CustomEvent('!drop:invalid', { detail: e })); 103 | } 104 | 105 | function handleFileList(list: FileList | null) { 106 | // no files dropped 107 | if (!list || list.length === 0) { 108 | return invalid('no-files-dropped'); 109 | } 110 | 111 | // too many files 112 | if (limit && list.length > limit) { 113 | return invalid('too-many-files'); 114 | } 115 | 116 | // too many files 117 | if (limit && !replace && files.length + list.length > limit) { 118 | return invalid('too-many-files'); 119 | } 120 | 121 | // change FileList to Array so we can use Array methods 122 | const newFiles = Array.from(list); 123 | 124 | // file too large 125 | if (newFiles.some((file) => file.size > maxFileSize)) { 126 | return invalid('file-too-large'); 127 | } 128 | 129 | // invalid file type 130 | if (mime && !newFiles.every((file) => file.type.match(mime))) { 131 | return invalid('invalid-mime-type'); 132 | } 133 | 134 | // add new files 135 | files = replace ? newFiles : [...files, ...newFiles]; 136 | 137 | node.classList.add(dropClass); 138 | node.dispatchEvent(new CustomEvent('!drop', { detail: files })); 139 | 140 | if (limit && files.length >= limit) { 141 | node.classList.add(limitClass); 142 | node.dispatchEvent(new CustomEvent('!drop:limit', { detail: files })); 143 | } 144 | } 145 | 146 | function fileBrowser(event: PointerEvent | KeyboardEvent) { 147 | if (event instanceof KeyboardEvent && event.key !== 'Enter') return; 148 | event.preventDefault(); 149 | 150 | const input = document.createElement('input'); 151 | input.type = 'file'; 152 | input.accept = mime; 153 | input.multiple = limit !== 1; 154 | 155 | const unlistenFileChange = listen(input, 'change', () => { 156 | handleFileList(input.files); 157 | unlistenFileChange(); 158 | input.remove(); 159 | }); 160 | 161 | input.click(); 162 | } 163 | 164 | function dropFiles(event: DragEvent) { 165 | event.preventDefault(); 166 | node.classList.remove(dragClass); 167 | 168 | // check if there are files to add 169 | if (event.dataTransfer && event.dataTransfer.files) { 170 | handleFileList(event.dataTransfer.files); 171 | } else { 172 | invalid('no-files-dropped'); 173 | } 174 | } 175 | 176 | function dragOver(event: DragEvent) { 177 | event.preventDefault(); 178 | node.classList.add(dragClass); 179 | } 180 | 181 | function dragLeave(event: DragEvent) { 182 | event.preventDefault(); 183 | node.classList.remove(dragClass); 184 | } 185 | 186 | const unlistenDrop = listen(node, 'drop', dropFiles as EventListener); 187 | const unlistenDragOver = listen(node, 'dragover', dragOver as EventListener); 188 | const unlistenDragLeave = listen(node, 'dragleave', dragLeave as EventListener); 189 | 190 | let uploadEvents = setupButton(uploadButton, fileBrowser as EventListener); 191 | let resetEvents = resetButton ? setupButton(resetButton, reset) : []; 192 | 193 | return { 194 | update: (options: UseDropzoneOptions) => { 195 | mime = options.mime || mime; 196 | limit = options.limit || limit; 197 | replace = options.replace !== undefined ? options.replace : replace; 198 | maxFileSize = options.maxFileSize || maxFileSize; 199 | 200 | dragClass = options.dragClass || dragClass; 201 | dropClass = options.dropClass || dropClass; 202 | limitClass = options.limitClass || limitClass; 203 | invalidClass = options.invalidClass || invalidClass; 204 | 205 | if (options.files === undefined || options.files.length === 0) { 206 | reset(); 207 | } else if (options.files !== files) { 208 | files = options.files; 209 | 210 | node.classList.add(dropClass); 211 | 212 | if (limit && files.length >= limit) { 213 | node.classList.add(limitClass); 214 | } else { 215 | node.classList.remove(limitClass); 216 | } 217 | } 218 | 219 | if (options.resetButton !== resetButton) { 220 | resetButton && removeButtonMeta(resetButton, resetEvents); 221 | resetButton = getElement(options.resetButton, node); 222 | resetEvents = setupButton(resetButton, reset); 223 | } 224 | 225 | if (options.uploadButton !== uploadButton) { 226 | removeButtonMeta(uploadButton, uploadEvents); 227 | uploadButton = getElement(options.uploadButton, node); 228 | uploadEvents = setupButton(uploadButton, fileBrowser as EventListener); 229 | } 230 | }, 231 | destroy() { 232 | unlistenDrop(); 233 | unlistenDragOver(); 234 | unlistenDragLeave(); 235 | removeButtonMeta(uploadButton, uploadEvents); 236 | resetButton && removeButtonMeta(resetButton, resetEvents); 237 | } 238 | }; 239 | } 240 | -------------------------------------------------------------------------------- /src/lib/action/focus.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | import { isFocusable } from '../meta/element.js'; 3 | 4 | /** 5 | * Focuses element when it mounts. Only works on focusable elements. 6 | * 7 | * Example: 8 | * ```svelte 9 | * 10 | * ``` 11 | */ 12 | export function focus(node: HTMLElement) { 13 | if (isFocusable(node)) node.focus(); 14 | 15 | return { 16 | update: noop, 17 | destroy: noop 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/action/focustrap.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | import { listen } from '../meta/event.js'; 3 | import { getFocusableChildren } from '../meta/element.js'; 4 | 5 | /** 6 | * Traps focus within an element on mount. Pressing `Tab` cycles through focusable children. Pressing `Escape` (or unmounting the element) cancels the trap. 7 | * Only works on focusable elements. 8 | * 9 | * Example: 10 | * ```svelte 11 | * 12 | * ``` 13 | */ 14 | export function focustrap(node: HTMLElement) { 15 | const focusable = getFocusableChildren(node); 16 | 17 | focusable[0].focus(); 18 | 19 | function reFocus(e: FocusEvent) { 20 | !focusable.includes(e.relatedTarget as HTMLElement) && (e.target as HTMLElement).focus(); 21 | } 22 | 23 | function handleKeyDown(e: KeyboardEvent) { 24 | if (e.key === 'Tab') { 25 | e.shiftKey 26 | ? (e.preventDefault(), 27 | focusable[ 28 | (focusable.indexOf(e.target as HTMLElement) - 1 + focusable.length) % focusable.length 29 | ].focus()) 30 | : (e.preventDefault(), 31 | focusable[(focusable.indexOf(e.target as HTMLElement) + 1) % focusable.length].focus()); 32 | } 33 | if (e.key === 'Escape') { 34 | cancel(); 35 | } 36 | } 37 | 38 | const unlistenFocusOut = listen(node, 'focusout', reFocus as EventListener); 39 | const unlistenKeyDown = listen(window, 'keydown', handleKeyDown as EventListener); 40 | 41 | const cancel = () => { 42 | blur(); 43 | unlistenFocusOut(); 44 | unlistenKeyDown(); 45 | }; 46 | 47 | return { 48 | update: noop, 49 | destroy: cancel 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/action/index.ts: -------------------------------------------------------------------------------- 1 | export { active } from './active.js'; 2 | export { clickoutside } from './clickoutside.js'; 3 | export { copy, cut, paste } from './clipboard.js'; 4 | export { download } from './download.js'; 5 | export { draggable } from './draggable.js'; 6 | export { dropzone } from './dropzone.js'; 7 | export { focus } from './focus.js'; 8 | export { focustrap } from './focustrap.js'; 9 | export { keydown, keyup } from './keyboard.js'; 10 | export { onclose } from './onclose.js'; 11 | export { portal } from './portal.js'; 12 | export { press } from './press.js'; 13 | export { resize } from './resize.js'; 14 | export { resizable } from './resizable.js'; 15 | export { rotatable } from './rotatable.js'; 16 | export { scalable } from './scalable.js'; 17 | export { select } from './select.js'; 18 | export { themetoggle } from './themetoggle.js'; 19 | export { timedclick } from './timedclick.js'; 20 | export { viewport } from './viewport.js'; 21 | -------------------------------------------------------------------------------- /src/lib/action/keyboard.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '../meta/event.js'; 2 | import { capitalise } from '../meta/string.js'; 3 | 4 | export type KeyMap = { [key: string]: (e: KeyboardEvent) => void }; 5 | 6 | /** 7 | * Takes an `keyboard shortcut` string: e.g. `'shift+cmd+a'` and returns a string that normalises `meta`/`cmd`/`win` to `'Super'`, 8 | * returns modifiers capitalised and in alphabetical order, and returns the key in capitalised form. 9 | * Keys and modifiers should be separated by a `+`, and the key to test for comes last. 10 | * Invalid modifer keys will be ignored. 11 | */ 12 | function sanitise(keyString: string) { 13 | const keys = keyString.split('+').map((k) => capitalise(k)); 14 | const key = keys.pop(); 15 | const alt = keys.includes('Alt') ? 'Alt+' : ''; 16 | const ctrl = keys.includes('Control') || keys.includes('Ctrl') ? 'Control+' : ''; 17 | const meta = 18 | keys.includes('Meta') || 19 | keys.includes('Super') || 20 | keys.includes('Command') || 21 | keys.includes('Cmd') || 22 | keys.includes('Win') 23 | ? 'Meta+' 24 | : ''; 25 | const shift = keys.includes('Shift') ? 'Shift+' : ''; 26 | return alt + ctrl + meta + shift + key; 27 | } 28 | 29 | /** 30 | * Takes a keyMap and returns it with its key strings sanitised. 31 | */ 32 | function sanitizeKeyMap(keyMap: KeyMap) { 33 | const shortcuts: KeyMap = {}; 34 | for (const [key, fn] of Object.entries(keyMap)) { 35 | shortcuts[sanitise(key)] = fn; 36 | } 37 | return shortcuts; 38 | } 39 | 40 | /** 41 | * Takes a sanitised key string and returns an object that matches the key and modifiers of the keyboardEvent. 42 | * 43 | * (currently unused) 44 | */ 45 | // function decode(keyString: string) { 46 | // const keys = keyString.split('+'); 47 | // return { 48 | // key: keys.pop(), 49 | // altKey: keys.includes('Alt'), 50 | // ctrlKey: keys.includes('Ctrl'), 51 | // metaKey: keys.includes('Meta'), 52 | // shiftKey: keys.includes('Shift') 53 | // }; 54 | // } 55 | 56 | /** 57 | * Takes a KeyboardEvent and returns a key string that matches a `keyboard shortcut` string after sanitisation. 58 | */ 59 | function encode(e: KeyboardEvent) { 60 | const alt = e.key !== 'Alt' && e.altKey ? 'Alt+' : ''; 61 | const ctrl = e.key !== 'Control' && e.ctrlKey ? 'Control+' : ''; 62 | const meta = e.key !== 'Meta' && e.metaKey ? 'Meta+' : ''; 63 | const shift = e.key !== 'Shift' && e.shiftKey ? 'Shift+' : ''; 64 | return alt + ctrl + meta + shift + capitalise(e.key); 65 | } 66 | 67 | /** 68 | * Executes functions on keydown. Pass in a map of key names to functions. 69 | * Pass in modifier keys with the + symbol. The key to test for always comes last. 70 | * 71 | * Modifiers are sanitised (e.g. change `cmd` to `Meta`). If a modifier can't be matched it is ignored. 72 | * The action is fully reactive, so shortcuts and handlers can be variables. 73 | * 74 | * Example: 75 | * ```svelte 76 | * 77 | * 78 | * ``` 79 | */ 80 | export function keydown(node: HTMLElement, keys: KeyMap) { 81 | let shortcuts: KeyMap = sanitizeKeyMap(keys); 82 | 83 | function execute(e: KeyboardEvent) { 84 | shortcuts[encode(e)]?.(e); 85 | } 86 | 87 | const unlisten = listen(node, 'keydown', execute as EventListener); 88 | 89 | return { 90 | update: (keys: KeyMap) => { 91 | shortcuts = sanitizeKeyMap(keys); 92 | }, 93 | destroy: unlisten 94 | }; 95 | } 96 | 97 | /** 98 | * Executes functions on keydown. Pass in a map of key names to functions. 99 | * Pass in modifier keys with the + symbol. The key to test for always comes last. 100 | * 101 | * Modifiers are sanitised (e.g. change `cmd` to `Meta`). If a modifier can't be matched it is ignored. 102 | * The action is fully reactive, so shortcuts and handlers can be variables. 103 | * 104 | * Example: 105 | * ```svelte 106 | * 107 | * 108 | * ``` 109 | */ 110 | export function keyup(node: HTMLElement, keys: KeyMap) { 111 | let shortcuts = sanitizeKeyMap(keys); 112 | 113 | function execute(e: KeyboardEvent) { 114 | shortcuts[encode(e)]?.(e); 115 | } 116 | 117 | const unlisten = listen(node, 'keyup', execute as EventListener); 118 | 119 | return { 120 | update: (keys: KeyMap) => { 121 | shortcuts = sanitizeKeyMap(keys); 122 | }, 123 | destroy: unlisten 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /src/lib/action/onclose.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | import { listen } from '../meta/event.js'; 3 | 4 | import type { Fn } from '../meta/fn.js'; 5 | 6 | interface OnCloseOptions { 7 | handler: Fn; 8 | condition?: boolean; 9 | } 10 | 11 | /** 12 | * Executes a passed in fuction on the window's onclose event. 13 | * Pass in a condition to only execute when that condition is met. 14 | * 15 | * Example: 16 | * ```svelte 17 | * 18 | * 19 | * ``` 20 | */ 21 | export function onclose(_node: HTMLElement, options: OnCloseOptions) { 22 | let handler = options.handler; 23 | let condition = options.condition ?? true; 24 | 25 | function confirm(e: BeforeUnloadEvent) { 26 | if (!condition) return; 27 | e.preventDefault(); 28 | handler(e); 29 | } 30 | 31 | const unlisten = listen(window, 'beforeunload', confirm) || noop; 32 | 33 | return { 34 | update: (options: OnCloseOptions) => { 35 | handler = options.handler; 36 | condition = options.condition ?? true; 37 | }, 38 | destroy: () => unlisten() 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/action/portal.ts: -------------------------------------------------------------------------------- 1 | import { getElement } from '../meta/element.js'; 2 | 3 | import type { ElementOrSelector } from '../meta/element.js'; 4 | 5 | /** 6 | * Mounts a component elsewhere in the DOM. 7 | * Pass in the target parent by reference or selector. 8 | * 9 | * Example: 10 | * ```svelte 11 | * (target is querySelector) 12 | * (target is HTMLelement) 13 | * ``` 14 | */ 15 | export function portal(node: HTMLElement, target: ElementOrSelector) { 16 | let targetElement = getElement(target); 17 | targetElement && targetElement.appendChild(node); 18 | 19 | return { 20 | update: (target: ElementOrSelector) => { 21 | targetElement = getElement(target); 22 | targetElement && targetElement.appendChild(node); 23 | }, 24 | destroy: () => node.parentElement?.removeChild(node) 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/action/press.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | import { listen } from '../meta/event.js'; 3 | import { timeout } from '../meta/time.js'; 4 | 5 | import type { Fn } from '../meta/fn.js'; 6 | import type { ActionReturn } from 'svelte/action'; 7 | 8 | interface PressOptions { 9 | duration: number; 10 | handler?: Fn; 11 | } 12 | 13 | interface Attributes { 14 | 'on:!press'?: (event: CustomEvent) => void; 15 | } 16 | 17 | /** 18 | * Dispatches a press event or calls a handler if pressed down for duration milliseconds. 19 | * 20 | * Example: 21 | * ```svelte 22 | * 23 | * 24 | * ``` 25 | */ 26 | export function press( 27 | node: HTMLElement, 28 | options: PressOptions | number 29 | ): ActionReturn { 30 | let duration = typeof options === 'number' ? options : options.duration; 31 | let handler = typeof options === 'number' ? noop : options.handler || noop; 32 | 33 | const start = () => { 34 | function dispatch() { 35 | handler(); 36 | node.dispatchEvent(new CustomEvent('!press', { detail: duration })); 37 | 38 | unlistenUp(); 39 | unlistenOut(); 40 | } 41 | 42 | const clear = timeout(dispatch, duration); 43 | const unlistenUp = listen(node, 'pointerup', clear); 44 | const unlistenOut = listen(node, 'pointerout', clear); 45 | }; 46 | 47 | const unlisten = listen(node, 'pointerdown', start); 48 | 49 | return { 50 | update: (options: PressOptions | number) => { 51 | duration = typeof options === 'number' ? options : options.duration; 52 | handler = typeof options === 'number' ? noop : options.handler || noop; 53 | }, 54 | destroy: unlisten 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/action/resizable.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '../meta/event.js'; 2 | import { 3 | getDomRect, 4 | getTransformCoords, 5 | getBorderCursor, 6 | getBorderSensor, 7 | transform 8 | } from '../meta/element.js'; 9 | 10 | import type { BorderSensor } from '../meta/element.js'; 11 | import type { Size, Coords } from '../meta/types.js'; 12 | import type { ActionReturn } from 'svelte/action'; 13 | 14 | export interface UseDraggableOptions { 15 | size?: Size; 16 | position?: Coords; 17 | margin?: number; 18 | class?: string; 19 | } 20 | 21 | interface Attributes { 22 | 'on:!resizestart'?: (event: CustomEvent) => void; 23 | 'on:!resize'?: (event: CustomEvent) => void; 24 | 'on:!resizeend'?: (event: CustomEvent) => void; 25 | } 26 | 27 | /** 28 | * Allows resizing an element by dragging it from one of its edges or corners. 29 | * Emits `!resize:start`, `!resize` and `!resize:end` events on the element. Update the `options.size` property to programmatically resize the element (values are in pixels). 30 | * Use the `options.margin` property to define the size of the hitbox around the edges and corners (in pixels). 31 | * When dragging the dragged element gets `options.class` (`svu-resizing` by default). Use a 'scoped global' style to add component-specific styling (see example below). 32 | * 33 | * Example: 34 | * ```svelte 35 | *
36 | * 37 | * 40 | * ``` 41 | */ 42 | export function resizable( 43 | node: HTMLElement, 44 | options?: UseDraggableOptions 45 | ): ActionReturn { 46 | const nodeRect = getDomRect(node); 47 | 48 | let position = options?.position || { x: 0, y: 0 }; 49 | 50 | let size = options?.size || { width: nodeRect.width, height: nodeRect.height }; 51 | let margin = options?.margin || 10; 52 | let className = options?.class || 'svu-resizing'; 53 | 54 | let borders = { 55 | top: 0, 56 | right: 0, 57 | bottom: 0, 58 | left: 0 59 | }; 60 | 61 | let sensor: BorderSensor = { 62 | top: false, 63 | right: false, 64 | bottom: false, 65 | left: false 66 | }; 67 | 68 | let origin = { 69 | x: 0, 70 | y: 0 71 | }; 72 | 73 | function checkBorderSensor(event: PointerEvent) { 74 | const { clientX, clientY } = event; 75 | borders = getDomRect(node); 76 | sensor = getBorderSensor(borders, margin, { x: clientX, y: clientY }); 77 | node.style.cursor = getBorderCursor(sensor); 78 | } 79 | 80 | function draw() { 81 | node.style.width = size.width + 'px'; 82 | node.style.height = size.height + 'px'; 83 | transform(node, position); 84 | } 85 | 86 | function handlePointerDown(event: PointerEvent) { 87 | // make sure sensor is up to date 88 | checkBorderSensor(event); 89 | // continue only if sensor is active 90 | if (!Object.values(sensor).some((value) => value)) return; 91 | 92 | event.preventDefault(); 93 | event.stopPropagation(); 94 | node.setPointerCapture(event.pointerId); 95 | 96 | origin = { 97 | x: event.clientX, 98 | y: event.clientY 99 | }; 100 | 101 | position = getTransformCoords(node); 102 | 103 | node.classList.add(className); 104 | node.dispatchEvent(new CustomEvent('!resize:start', { detail: size })); 105 | 106 | function handlePointerMove(event: PointerEvent) { 107 | event.preventDefault(); 108 | event.stopPropagation(); 109 | 110 | const { clientX, clientY } = event; 111 | 112 | if (sensor.top) { 113 | size.height += origin.y - clientY; 114 | position.y -= origin.y - clientY; 115 | } 116 | 117 | if (sensor.right) { 118 | size.width += clientX - origin.x; 119 | } 120 | 121 | if (sensor.bottom) { 122 | size.height += clientY - origin.y; 123 | } 124 | 125 | if (sensor.left) { 126 | size.width += origin.x - clientX; 127 | position.x -= origin.x - clientX; 128 | } 129 | 130 | origin = { x: clientX, y: clientY }; 131 | 132 | node.dispatchEvent(new CustomEvent('!resize', { detail: size })); 133 | 134 | draw(); 135 | } 136 | 137 | function handlePointerUp(event: PointerEvent) { 138 | event.preventDefault(); 139 | event.stopPropagation(); 140 | node.releasePointerCapture(event.pointerId); 141 | 142 | node.classList.remove(className); 143 | node.dispatchEvent(new CustomEvent('!resize:end', { detail: size })); 144 | 145 | unlistenPointerMove(); 146 | unlistenPointerUp(); 147 | 148 | unlistenSensor = listen(node, 'pointermove', checkBorderSensor as EventListener); 149 | } 150 | 151 | unlistenSensor(); 152 | 153 | const unlistenPointerMove = listen(window, 'pointermove', handlePointerMove as EventListener); 154 | const unlistenPointerUp = listen(window, 'pointerup', handlePointerUp as EventListener); 155 | } 156 | 157 | let unlistenSensor = listen(node, 'pointermove', checkBorderSensor as EventListener); 158 | const unlistenPointerDown = listen(node, 'pointerdown', handlePointerDown as EventListener); 159 | 160 | return { 161 | update(options: UseDraggableOptions) { 162 | className = options.class || className; 163 | size = options.size || size; 164 | margin = options.margin || margin; 165 | position = options.position || position; 166 | }, 167 | destroy() { 168 | unlistenSensor(); 169 | unlistenPointerDown(); 170 | } 171 | }; 172 | } 173 | -------------------------------------------------------------------------------- /src/lib/action/resize.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | 3 | import type { Fn } from '../meta/fn.js'; 4 | import type { ActionReturn } from 'svelte/action'; 5 | 6 | interface Attributes { 7 | 'on:!resize'?: (event: CustomEvent) => void; 8 | } 9 | 10 | /** 11 | * Dispatches an event or calls a handler if an element is resized. 12 | * 13 | * Usage: 14 | * 15 | * 16 | * 17 | * For tracking size it's best to use the window store from svu/client. 18 | */ 19 | export function resize(node: HTMLElement, handler: Fn = noop): ActionReturn { 20 | let handle = handler; 21 | 22 | function handleResize(entries: ResizeObserverEntry[]) { 23 | entries.forEach((e) => { 24 | if (e.target !== node) return; 25 | handle(); 26 | node.dispatchEvent(new CustomEvent('!resize', { detail: e.target })); 27 | }); 28 | } 29 | 30 | const observer = new ResizeObserver(handleResize); 31 | observer.observe(node); 32 | 33 | return { 34 | update: (handler: Fn = noop) => { 35 | handle = handler; 36 | }, 37 | destroy: () => observer.disconnect() 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/action/rotatable.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '../meta/event.js'; 2 | import { getDomRect, transform, getElement } from '../meta/element.js'; 3 | 4 | import type { ElementOrSelector } from '../meta/element.js'; 5 | import type { ActionReturn } from 'svelte/action'; 6 | 7 | export interface UseRotatableOptions { 8 | handle: ElementOrSelector; 9 | rotation?: number; 10 | class?: string; 11 | } 12 | 13 | interface Attributes { 14 | 'on:!rotate:start'?: (event: CustomEvent) => void; 15 | 'on:!rotate'?: (event: CustomEvent) => void; 16 | 'on:!rotate:end'?: (event: CustomEvent) => void; 17 | } 18 | 19 | /** 20 | * Allows rotating an element by dragging it from an optional handle specified using `options.handle`. 21 | * Emits `!rotate:start`, `!rotate` and `!rotate:end` events on the element. Update the `options.rotation` property to programmatically rotate the element (value is in degrees). 22 | * When rotating the rotated element gets `options.class` (`svu-rotating` by default). Use a 'scoped global' style to add component-specific styling (see example below). 23 | * 24 | * Example: 25 | * ```svelte 26 | *
27 | * 28 | * 31 | * ``` 32 | */ 33 | export function rotatable( 34 | node: HTMLElement, 35 | options?: UseRotatableOptions 36 | ): ActionReturn { 37 | let handle = getElement(options?.handle, node); 38 | let className = options?.class || 'svu-rotating'; 39 | let rotation = options?.rotation || 0; 40 | 41 | function draw() { 42 | transform(node, { rotate: rotation }); 43 | } 44 | 45 | function handlePointerDown(event: PointerEvent) { 46 | event.preventDefault(); 47 | event.stopPropagation(); 48 | handle.setPointerCapture(event.pointerId); 49 | 50 | const { left, top, width, height } = getDomRect(node); 51 | const cx = left + width / 2; 52 | const cy = top + height / 2; 53 | 54 | const { clientX, clientY } = event; 55 | const origin = { x: clientX, y: clientY }; 56 | const initialRotation = rotation; 57 | 58 | node.classList.add(className); 59 | node.dispatchEvent(new CustomEvent('!resize:start', { detail: rotation })); 60 | 61 | function handlePointerMove(event: PointerEvent) { 62 | event.preventDefault(); 63 | event.stopPropagation(); 64 | 65 | const { clientX, clientY } = event; 66 | 67 | const angle = 68 | Math.atan2(clientY - cy, clientX - cx) - Math.atan2(origin.y - cy, origin.x - cx); 69 | 70 | rotation = initialRotation + (angle * 180) / Math.PI; 71 | 72 | node.dispatchEvent(new CustomEvent('!rotate', { detail: rotation })); 73 | 74 | draw(); 75 | } 76 | 77 | function handlePointerUp(event: PointerEvent) { 78 | event.preventDefault(); 79 | event.stopPropagation(); 80 | 81 | handle.releasePointerCapture(event.pointerId); 82 | 83 | node.classList.remove(className); 84 | node.dispatchEvent(new CustomEvent('!rotate:end', { detail: rotation })); 85 | 86 | unlistenPointerMove(); 87 | unlistenPointerUp(); 88 | } 89 | 90 | const unlistenPointerMove = listen(window, 'pointermove', handlePointerMove as EventListener); 91 | const unlistenPointerUp = listen(window, 'pointerup', handlePointerUp as EventListener); 92 | } 93 | 94 | let unlistenPointerDown = listen(handle, 'pointerdown', handlePointerDown as EventListener); 95 | let unlistenTouchStart = listen(handle, 'touchstart', (e) => e.preventDefault()); 96 | 97 | return { 98 | update(options) { 99 | className = options.class || className; 100 | rotation = options.rotation || rotation; 101 | 102 | if (options.handle !== handle) { 103 | unlistenPointerDown(); 104 | handle = getElement(options.handle, handle); 105 | unlistenPointerDown = listen(handle, 'pointerdown', handlePointerDown as EventListener); 106 | unlistenTouchStart = listen(handle, 'touchstart', (e) => e.preventDefault()); 107 | } 108 | }, 109 | destroy() { 110 | unlistenPointerDown(); 111 | unlistenTouchStart(); 112 | } 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /src/lib/action/scalable.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '../meta/event.js'; 2 | import { getDomRect, getBorderCursor, getBorderSensor, transform } from '../meta/element.js'; 3 | 4 | import type { BorderSensor } from '../meta/element.js'; 5 | import type { Scale } from '../meta/types.js'; 6 | import type { ActionReturn } from 'svelte/action'; 7 | import { clamp } from '$lib/meta/math.js'; 8 | 9 | export interface UseScalableOptions { 10 | scale?: Scale; 11 | min?: Scale; 12 | max?: Scale; 13 | margin?: number; 14 | aspect?: boolean; 15 | class?: string; 16 | } 17 | 18 | interface Attributes { 19 | 'on:!scale:start'?: (event: CustomEvent) => void; 20 | 'on:!scale'?: (event: CustomEvent) => void; 21 | 'on:!scale:end'?: (event: CustomEvent) => void; 22 | } 23 | 24 | /** 25 | * Allows scaling an element by dragging it from one of its edges or corners. 26 | * Emits `!scale:start`, `!scale` and `!scale:end` events on the element. Update the `options.size` property to programmatically scale the element (value is relative). 27 | * Use the `options.aspect` property to lock the aspect ratio of the element (false by default). 28 | * Use the `options.margin` property to define the size of the hitbox around the edges and corners (in pixels). 29 | * When dragging the dragged element gets `options.class` (`svu-scaling` by default). Use a 'scoped global' style to add component-specific styling (see example below). 30 | * 31 | * Example: 32 | * ```svelte 33 | *
34 | * 35 | * 38 | * ``` 39 | */ 40 | export function scalable( 41 | node: HTMLElement, 42 | options?: UseScalableOptions 43 | ): ActionReturn { 44 | let margin = options?.margin || 10; 45 | let scale = options?.scale || { scaleX: 1, scaleY: 1 }; 46 | let className = options?.class || 'svu-scaling'; 47 | let aspect = options?.aspect || false; 48 | 49 | let min = options?.min || { scaleX: 0.5, scaleY: 0.5 }; 50 | let max = options?.max || { scaleX: 3, scaleY: 3 }; 51 | 52 | let borders = { 53 | top: 0, 54 | right: 0, 55 | bottom: 0, 56 | left: 0 57 | }; 58 | 59 | let sensor: BorderSensor = { 60 | top: false, 61 | right: false, 62 | bottom: false, 63 | left: false 64 | }; 65 | 66 | function checkBorderSensor(event: PointerEvent) { 67 | const { clientX, clientY } = event; 68 | borders = getDomRect(node); 69 | sensor = getBorderSensor(borders, margin, { x: clientX, y: clientY }); 70 | node.style.cursor = getBorderCursor(sensor); 71 | } 72 | 73 | function draw() { 74 | transform(node, scale); 75 | } 76 | 77 | const handlePointerDown = (event: PointerEvent) => { 78 | // make sure sensor is up to date 79 | checkBorderSensor(event); 80 | // continue only if sensor is active 81 | if (!Object.values(sensor).some((value) => value)) return; 82 | 83 | const origin = { ...scale }; 84 | 85 | const handlePointerMove = (event: PointerEvent) => { 86 | event.preventDefault(); 87 | event.stopPropagation(); 88 | 89 | const { clientX, clientY } = event; 90 | 91 | if (sensor.top) { 92 | scale.scaleY = origin.scaleY - 2 * ((clientY - borders.top) / node.clientHeight); 93 | } 94 | 95 | if (sensor.right) { 96 | scale.scaleX = origin.scaleX + 2 * ((clientX - borders.right) / node.clientWidth); 97 | } 98 | 99 | if (sensor.bottom) { 100 | scale.scaleY = origin.scaleY + 2 * ((clientY - borders.bottom) / node.clientHeight); 101 | } 102 | 103 | if (sensor.left) { 104 | scale.scaleX = origin.scaleX - 2 * ((clientX - borders.left) / node.clientWidth); 105 | } 106 | 107 | scale.scaleY = clamp(scale.scaleY, min.scaleY, max.scaleY); 108 | scale.scaleX = clamp(scale.scaleX, min.scaleX, max.scaleX); 109 | 110 | if (aspect) { 111 | scale.scaleX = scale.scaleY = Math.max(scale.scaleX, scale.scaleY); 112 | } 113 | 114 | node.dispatchEvent(new CustomEvent('!scale', { detail: scale })); 115 | 116 | draw(); 117 | }; 118 | 119 | const handlePointerUp = (event: PointerEvent) => { 120 | event.preventDefault(); 121 | event.stopPropagation(); 122 | 123 | node.classList.remove(className); 124 | node.dispatchEvent(new CustomEvent('!scaleend', { detail: scale })); 125 | 126 | unlistenPointerMove(); 127 | unlistenPointerUp(); 128 | 129 | unlistenSensor = listen(node, 'pointermove', checkBorderSensor as EventListener); 130 | }; 131 | 132 | unlistenSensor(); 133 | 134 | const unlistenPointerMove = listen(window, 'pointermove', handlePointerMove as EventListener); 135 | const unlistenPointerUp = listen(window, 'pointerup', handlePointerUp as EventListener); 136 | }; 137 | 138 | const unlistenPointerDown = listen(node, 'pointerdown', handlePointerDown as EventListener); 139 | let unlistenSensor = listen(node, 'pointermove', checkBorderSensor as EventListener); 140 | 141 | return { 142 | update(options: UseScalableOptions) { 143 | scale = options.scale || scale; 144 | min = options.min || min; 145 | max = options.max || max; 146 | margin = options.margin || margin; 147 | aspect = options.aspect || aspect; 148 | className = options.class || className; 149 | }, 150 | destroy() { 151 | unlistenPointerDown(); 152 | unlistenSensor(); 153 | } 154 | }; 155 | } 156 | -------------------------------------------------------------------------------- /src/lib/action/select.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '../meta/event.js'; 2 | 3 | /** 4 | * Selects the content of the element (or a specified target) on click. 5 | * If the element is an input element, it selects the value, otherwise it selects all child nodes. 6 | * 7 | * Example: 8 | * ```svelte 9 | * 10 | * 11 | * ``` 12 | */ 13 | export function select(node: HTMLElement, target?: HTMLElement) { 14 | let object = target ?? node; 15 | 16 | const selectObject = () => { 17 | // Check if the element is an input element 18 | if (object instanceof HTMLInputElement) return object.select(); 19 | 20 | // Otherwise select all child nodes if they exist. 21 | if (!window.getSelection()) return; 22 | if (!object.hasChildNodes()) return; 23 | 24 | const range = document.createRange(); 25 | const selection = window.getSelection() as Selection; 26 | const firstChild = object.firstChild as Node; 27 | const lastChild = object.lastChild as Node; 28 | 29 | range.setStart(firstChild, 0); 30 | range.setEndAfter(lastChild); 31 | selection.removeAllRanges(); 32 | selection.addRange(range); 33 | object.focus(); 34 | }; 35 | 36 | const unlisten = listen(node, 'click', selectObject); 37 | 38 | return { 39 | update: (target: HTMLElement) => (object = target ?? node), 40 | destroy: unlisten 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/action/themetoggle.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | import { listen } from '../meta/event.js'; 3 | 4 | /** 5 | * Theme Toggler. 6 | * Sets the 'data-theme' attribute on the html to a theme or the next of a list of themes on click. 7 | * 8 | * Example: 9 | * ```svelte 10 | * toggles between dark and light 11 | * sets theme to dark 12 | * ``` 13 | */ 14 | export function themetoggle(node: HTMLElement, themes?: string | string[]) { 15 | themes = themes ?? ['light', 'dark']; 16 | const target = document.documentElement; 17 | let theme = 0; 18 | 19 | function toggle(themes: string[]) { 20 | theme = (theme + 1) % themes.length; 21 | target.setAttribute('data-theme', themes[theme]); 22 | } 23 | 24 | function set(themes: string) { 25 | target.setAttribute('data-theme', themes); 26 | } 27 | 28 | const unlisten = listen( 29 | node, 30 | 'click', 31 | typeof themes === 'string' ? () => set(themes) : () => toggle(themes) 32 | ); 33 | 34 | return { 35 | update: noop, 36 | destroy: unlisten 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/action/timedclick.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | import { listen } from '../meta/event.js'; 3 | import { timeout } from '../meta/time.js'; 4 | 5 | import type { Fn } from '../meta/fn.js'; 6 | import type { ActionReturn } from 'svelte/action'; 7 | 8 | interface TimedClickOptions { 9 | duration: number; 10 | delay: number; 11 | handler?: Fn; 12 | } 13 | 14 | interface Attributes { 15 | 'on:!timedclick'?: (event: CustomEvent<{ delay: number; duration: number }>) => void; 16 | 'on:!timedclick:armed'?: (event: CustomEvent<{ delay: number; duration: number }>) => void; 17 | 'on:!timedclick:aborted'?: (event: CustomEvent<{ delay: number; duration: number }>) => void; 18 | } 19 | 20 | /** 21 | * Dispatches an event or calls a handler if released within 'duration' milliseconds. 22 | * Starts checking after optional 'delay' milliseconds. 23 | * 24 | * Example: 25 | * ```svelte 26 | * 27 | * 28 | * ``` 29 | */ 30 | export function timedclick( 31 | node: HTMLElement, 32 | options: TimedClickOptions | number 33 | ): ActionReturn { 34 | let duration = typeof options === 'number' ? options : options.duration; 35 | let delay = typeof options === 'number' ? 0 : options.delay || 0; 36 | let fn = typeof options === 'number' ? noop : options.handler || noop; 37 | 38 | function start() { 39 | function dispatch() { 40 | fn(); 41 | node.dispatchEvent(new CustomEvent('!timedclick', { detail: { delay, duration } })); 42 | 43 | unlistenUp(); 44 | unlistenOut(); 45 | clearAbort(); 46 | } 47 | 48 | function abort() { 49 | node.dispatchEvent(new CustomEvent('!timedclick:aborted', { detail: { delay, duration } })); 50 | unlistenUp(); 51 | unlistenOut(); 52 | clearCheck(); 53 | clearAbort(); 54 | } 55 | 56 | function check() { 57 | node.dispatchEvent(new CustomEvent('!timedclick:armed', { detail: { delay, duration } })); 58 | unlistenUp(); 59 | unlistenOut(); 60 | unlistenUp = listen(node, 'pointerup', dispatch); 61 | } 62 | 63 | const clearCheck = timeout(check, delay); 64 | const clearAbort = timeout(abort, delay + duration); 65 | 66 | let unlistenUp = listen(node, 'pointerup', abort); 67 | const unlistenOut = listen(node, 'pointerout', abort); 68 | } 69 | 70 | const unlisten = listen(node, 'pointerdown', start); 71 | 72 | return { 73 | update: (options: TimedClickOptions | number) => { 74 | duration = typeof options === 'number' ? options : options.duration; 75 | delay = typeof options === 'number' ? 0 : options.delay || 0; 76 | fn = typeof options === 'number' ? noop : options.handler || noop; 77 | }, 78 | destroy: unlisten 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /src/lib/action/viewport.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '../meta/fn.js'; 2 | 3 | import type { Fn } from '../meta/fn.js'; 4 | import type { ActionReturn } from 'svelte/action'; 5 | 6 | interface ViewportOptions { 7 | root?: HTMLElement; 8 | rootMargin?: string; 9 | threshold?: number; 10 | handleEnter?: Fn; 11 | handleLeave?: Fn; 12 | } 13 | 14 | interface Attributes { 15 | 'on:!viewport:enter'?: (event: CustomEvent) => void; 16 | 'on:!viewport:leave'?: (event: CustomEvent) => void; 17 | } 18 | 19 | /** 20 | * Dispatches '!viewport:enter' and '!viewport:leave' events when the element enters or leaves the viewport. 21 | * Pass in handleEnter and handleLeave handlers to execute when the element enters or leaves the viewport. 22 | * 23 | * Optionally pass in root element, rootMargin, and threshold for the IntersectionObserver. 24 | * The default rootMargin is '0px' and the default threshold is 0. 25 | * 26 | * Example: 27 | * ```svelte 28 | * 29 | * 30 | * 31 | * ``` 32 | */ 33 | export function viewport( 34 | node: HTMLElement, 35 | options?: ViewportOptions 36 | ): ActionReturn { 37 | let root = options?.root; 38 | let rootMargin = options?.rootMargin ?? '0px'; 39 | let threshold = options?.threshold ?? 0; 40 | 41 | let handleEnter = options?.handleEnter ?? noop; 42 | let handleLeave = options?.handleLeave ?? noop; 43 | 44 | const callback = (entries: IntersectionObserverEntry[]) => { 45 | entries.forEach((entry) => { 46 | if (entry.isIntersecting) { 47 | handleEnter(); 48 | node.dispatchEvent(new CustomEvent('!viewport:enter', { detail: entry })); 49 | } else { 50 | handleLeave(); 51 | node.dispatchEvent(new CustomEvent('!viewport:leave', { detail: entry })); 52 | } 53 | }); 54 | }; 55 | 56 | let observer = new IntersectionObserver(callback, { 57 | root, 58 | rootMargin, 59 | threshold 60 | }); 61 | 62 | observer.observe(node); 63 | 64 | return { 65 | update: (options: ViewportOptions) => { 66 | observer.disconnect(); 67 | 68 | root = options.root; 69 | rootMargin = options.rootMargin ?? '0px'; 70 | threshold = options.threshold ?? 0; 71 | 72 | handleEnter = options.handleEnter ?? noop; 73 | handleLeave = options.handleLeave ?? noop; 74 | 75 | observer = new IntersectionObserver(callback, { 76 | root, 77 | rootMargin, 78 | threshold 79 | }); 80 | }, 81 | destroy: () => observer.disconnect() 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/app/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | log, 3 | trace, 4 | debug, 5 | info, 6 | warn, 7 | error, 8 | assert, 9 | count, 10 | countReset, 11 | dir, 12 | dirxml, 13 | clear 14 | } from './log.js'; 15 | -------------------------------------------------------------------------------- /src/lib/app/log.ts: -------------------------------------------------------------------------------- 1 | /** Logging utilities */ 2 | 3 | import { dev } from '../meta/env.js'; 4 | 5 | /** 6 | * A set of wrapper functions that only logs to the console when SvelteKit is in development mode. 7 | * Supports all console.* functions (log, trace, warn, error, etc.) 8 | */ 9 | export function log(...args: T[]): void { 10 | dev && console.log(...args); 11 | } 12 | export function trace(...args: T[]): void { 13 | dev && console.trace(...args); 14 | } 15 | export function debug(...args: T[]): void { 16 | dev && console.debug(...args); 17 | } 18 | export function info(...args: T[]): void { 19 | dev && console.info(...args); 20 | } 21 | export function warn(...args: T[]): void { 22 | dev && console.warn(...args); 23 | } 24 | export function error(...args: T[]): void { 25 | dev && console.error(...args); 26 | } 27 | export function assert(value: boolean, ...args: T[]): void { 28 | dev && console.assert(value, ...args); 29 | } 30 | export function count(label?: string): void { 31 | dev && console.count(label); 32 | } 33 | export function countReset(label?: string): void { 34 | dev && console.countReset(label); 35 | } 36 | export function dir(item?: T, options?: unknown): void { 37 | dev && console.dir(item, options); 38 | } 39 | export function dirxml(...data: T[]): void { 40 | dev && console.dirxml(...data); 41 | } 42 | 43 | export function clear(): void { 44 | dev && console.clear(); 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/client/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | motionOK, 3 | reduceMotion, 4 | print, 5 | screen, 6 | landscape, 7 | portrait, 8 | dark, 9 | light, 10 | highContrast, 11 | lowContrast, 12 | defaultContrast, 13 | hover, 14 | noPointer, 15 | coarsePointer, 16 | finePointer 17 | } from './media.js'; 18 | export { mx, my, mouse } from './mouse.js'; 19 | export { os, modKey } from './os.js'; 20 | export { theme } from './theme.js'; 21 | export { ww, wh, aspect, windowSize, sx, sy, scroll } from './window.js'; 22 | -------------------------------------------------------------------------------- /src/lib/client/media.ts: -------------------------------------------------------------------------------- 1 | import { mediaquery } from '../store/mediaquery.js'; 2 | 3 | /** 4 | * Is true when `prefers-reduced-motion` is set to `no-preference`. 5 | */ 6 | export const motionOK = mediaquery('prefers-reduced-motion', 'no-preference'); 7 | 8 | /** 9 | * Is true when `prefers-reduced-motion` is set to `reduce`. 10 | */ 11 | export const reduceMotion = mediaquery('prefers-reduced-motion', 'reduce'); 12 | 13 | /** 14 | * Is true when the page is (pre)viewed for printing. 15 | */ 16 | export const print = mediaquery('print'); 17 | 18 | /** 19 | * Is true when the page is viewed on a screen; 20 | */ 21 | export const screen = mediaquery('screen'); 22 | 23 | /** 24 | * Is true when the viewport is wider than it is tall; 25 | */ 26 | export const landscape = mediaquery('orientation', 'landscape'); 27 | 28 | /** 29 | * Is true when the viewport is taller than it is wide; 30 | */ 31 | export const portrait = mediaquery('orientation', 'portrait'); 32 | 33 | /** 34 | * Is true when the client prefers a dark color scheme. 35 | */ 36 | export const dark = mediaquery('prefers-color-scheme', 'dark'); 37 | 38 | /** 39 | * Is true when the client prefers light color scheme. 40 | */ 41 | export const light = mediaquery('prefers-color-scheme', 'light'); 42 | 43 | /** 44 | * Is true when the client prefers a high contrast color scheme. 45 | */ 46 | export const highContrast = mediaquery('prefers-contrast', 'more'); 47 | 48 | /** 49 | * Is true when the client prefers a low contrast color scheme. 50 | */ 51 | export const lowContrast = mediaquery('prefers-contrast', 'less'); 52 | 53 | /** 54 | * Is true when the has no contrast preference. 55 | */ 56 | export const defaultContrast = mediaquery('prefers-contrast', 'no-preference'); 57 | 58 | /** 59 | * Is true when the device can display a hover state. (You can do so with a mouse, but not with a finger + touchscreen.) 60 | */ 61 | export const hover = mediaquery('hover', 'hover'); 62 | 63 | /** 64 | * Is true when the client does not have a pointer available. 65 | */ 66 | export const noPointer = mediaquery('pointer', 'none'); 67 | 68 | /** 69 | * Is true when the client has a coarse pointer available. 70 | */ 71 | export const coarsePointer = mediaquery('pointer', 'coarse'); 72 | 73 | /** 74 | * Is true when the client has a fine pointer available. 75 | */ 76 | export const finePointer = mediaquery('pointer', 'fine'); 77 | -------------------------------------------------------------------------------- /src/lib/client/mouse.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '../meta/env.js'; 2 | import { listen } from '../meta/event.js'; 3 | import { derived, writable } from 'svelte/store'; 4 | 5 | function create(prop: 'clientX' | 'clientY') { 6 | const { subscribe, set } = writable(0); 7 | if (!browser) return { subscribe }; 8 | const unlisten = listen(window, 'mousemove', (e: Event) => set((e)[prop])); 9 | return { subscribe, unsubscribe: unlisten }; 10 | } 11 | 12 | /** 13 | * Svelte store that tracks the mouse x position. 14 | */ 15 | export const mx = create('clientX'); 16 | 17 | /** 18 | * Svelte store that tracks the mouse y position. 19 | */ 20 | export const my = create('clientY'); 21 | 22 | /** 23 | * Svelte store that tracks the mouse position. 24 | */ 25 | export const mouse = derived([mx, my], ([$mx, $my]) => ({ x: $mx, y: $my })); 26 | -------------------------------------------------------------------------------- /src/lib/client/os.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '../meta/index.js'; 2 | 3 | // A map of OS to regex User Agent string match. 4 | const osMap = { 5 | iOS: /(iPhone|iPad|iPod)/, // string also contains Mac 6 | Android: /(Android)/, // string also contains Linux 7 | 'Chrome OS': /CrOS/, // string also contains X11 8 | Windows: /(Win)/, 9 | macOS: /Mac/, 10 | Linux: /(Linux|X11)/ 11 | }; 12 | 13 | // Function that checks the User Agent string and returns OS name. 14 | function extractOS() { 15 | const ua = window.navigator.userAgent; 16 | if (ua.match(osMap['macOS'])) return 'macOS'; 17 | if (ua.match(osMap['Android'])) return 'Android'; 18 | if (ua.match(osMap['Chrome OS'])) return 'CrOS'; 19 | if (ua.match(osMap['iOS'])) return 'iOS'; 20 | if (ua.match(osMap['Windows'])) return 'Windows'; 21 | if (ua.match(osMap['Linux'])) return 'Linux'; 22 | return 'Unknown'; 23 | } 24 | 25 | /** 26 | * The OS name. 27 | * It's mainly used to determine the correct modifier key. 28 | * In general, don't use it for feature detection (why? see [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent)) 29 | */ 30 | export const os = browser ? extractOS() : 'Unknown'; 31 | 32 | /** 33 | * The 'Modifier' key is the key that's generally used for shorcuts. 34 | * On macOS (and iOS with external keyboards) this is the command key. 35 | * On other platforms this is the control key. 36 | * You can use it in conjunction with the use:keydown action. 37 | */ 38 | export const modKey = os === 'macOS' || os === 'iOS' ? 'Command' : 'Control'; 39 | -------------------------------------------------------------------------------- /src/lib/client/theme.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '../meta/index.js'; 2 | import { writable } from 'svelte/store'; 3 | 4 | function create() { 5 | const { subscribe, set: setStore } = writable(''); 6 | 7 | if (!browser) return { subscribe, set: setStore }; 8 | const target = document.documentElement; 9 | 10 | const set = (val: string) => { 11 | target.setAttribute('data-theme', val); 12 | setStore(val); 13 | }; 14 | 15 | const handleChange = (mutationList: MutationRecord[]) => { 16 | mutationList.forEach((m) => { 17 | const el = m.target as HTMLElement; 18 | setStore(el.getAttribute('data-theme') ?? ''); 19 | }); 20 | }; 21 | 22 | const observer = new MutationObserver(handleChange); 23 | observer.observe(target, { attributes: true, attributeFilter: ['data-theme'] }); 24 | 25 | return { subscribe, set }; 26 | } 27 | 28 | /** 29 | * A store that holds the current theme. It detects changes to the `data-theme` attribute on the `html` element. 30 | * Changing the theme will also change the `data-theme` attribute on the `html` element. 31 | * Changing the `data-theme` attribute on the `html` element will be reflected to $theme. 32 | * 33 | * ```svelte 34 | * 35 | * 36 | * ``` 37 | */ 38 | export const theme = create(); 39 | -------------------------------------------------------------------------------- /src/lib/client/window.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '../meta/index.js'; 2 | import { browser } from '../meta/index.js'; 3 | import { derived, writable } from 'svelte/store'; 4 | 5 | function createSize(prop: 'innerWidth' | 'innerHeight') { 6 | const { subscribe, set } = writable(0); 7 | if (!browser) return { subscribe }; 8 | set(window[prop]); 9 | listen(window, 'resize', () => set(window[prop])); 10 | return { subscribe }; 11 | } 12 | 13 | /** 14 | * Svelte store that tracks the window width. 15 | */ 16 | export const ww = createSize('innerWidth'); 17 | 18 | /** 19 | * Svelte store that tracks the window height. 20 | */ 21 | export const wh = createSize('innerHeight'); 22 | 23 | /** 24 | * Svelte store that tracks the window aspect ratio. 25 | */ 26 | export const aspect = derived([ww, wh], ([$ww, $wh]) => $ww / $wh); 27 | 28 | /** 29 | * Svelte store that tracks the window size. 30 | */ 31 | export const windowSize = derived([ww, wh], ([$ww, $wh]) => ({ w: $ww, h: $wh })); 32 | 33 | function createScroll(prop: 'scrollX' | 'scrollY') { 34 | const { subscribe, set } = writable(0); 35 | if (!browser) return { subscribe }; 36 | set(window[prop]); 37 | listen(window, 'scroll', () => set(window[prop])); 38 | return { subscribe }; 39 | } 40 | 41 | /** 42 | * Svelte store that tracks the scroll x position. 43 | */ 44 | export const sx = createScroll('scrollX'); 45 | 46 | /** 47 | * Svelte store that tracks the scroll y position. 48 | */ 49 | export const sy = createScroll('scrollY'); 50 | 51 | /** 52 | * Svelte store that tracks the scroll position. 53 | */ 54 | export const scroll = derived([sx, sy], ([$sx, $sy]) => ({ x: $sx, y: $sy })); 55 | -------------------------------------------------------------------------------- /src/lib/components/OnMount.svelte: -------------------------------------------------------------------------------- 1 | 4 | 11 | 12 | {#if mounted} 13 | 14 | {/if} 15 | -------------------------------------------------------------------------------- /src/lib/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OnMount } from './OnMount.svelte'; 2 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * as action from './action/index.js'; 2 | export * as app from './app/index.js'; 3 | export * as client from './client/index.js'; 4 | export * as components from './components/index.js'; 5 | export * as meta from './meta/index.js'; 6 | export * as store from './store/index.js'; 7 | export * as transition from './transition/index.js'; 8 | -------------------------------------------------------------------------------- /src/lib/meta/clone.ts: -------------------------------------------------------------------------------- 1 | /** Check wether passed in value is an object */ 2 | function isObject(obj: unknown): obj is object { 3 | return typeof obj === 'object' && obj !== null; 4 | } 5 | 6 | /** Clones a date object */ 7 | function cloneDate(date: Date): Date { 8 | return new Date(date.getTime()); 9 | } 10 | 11 | /** Clones a regular expression */ 12 | function cloneRegExp(regexp: RegExp): RegExp { 13 | return new RegExp(regexp.source, regexp.flags); 14 | } 15 | 16 | /** Clones a map */ 17 | function cloneMap( 18 | map: Map, 19 | cloneFn: (input: unknown, depth: number) => T, 20 | depth: number 21 | ): Map { 22 | const clonedMap = new Map(); 23 | map.forEach((value, key) => { 24 | clonedMap.set(key, cloneFn(value, depth - 1)); 25 | }); 26 | return clonedMap; 27 | } 28 | 29 | /** Clones a set */ 30 | function cloneSet( 31 | set: Set, 32 | cloneFn: (input: unknown, depth: number) => T, 33 | depth: number 34 | ): Set { 35 | const clonedSet = new Set(); 36 | set.forEach((value) => { 37 | clonedSet.add(cloneFn(value, depth - 1)); 38 | }); 39 | return clonedSet; 40 | } 41 | 42 | /** Clones an array */ 43 | function cloneArray( 44 | array: unknown[], 45 | cloneFn: (input: unknown, depth: number) => unknown, 46 | depth: number 47 | ): unknown[] { 48 | return array.map((value) => cloneFn(value, depth - 1)); 49 | } 50 | 51 | /** Clones a Record */ 52 | function cloneRecord( 53 | obj: Record, 54 | cloneFn: (input: unknown, depth: number) => T, 55 | depth: number 56 | ): Record { 57 | const clonedObj: Record = Object.create(Object.getPrototypeOf(obj)); 58 | for (const key in obj) { 59 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 60 | clonedObj[key] = cloneFn(obj[key], depth - 1); 61 | } 62 | } 63 | return clonedObj; 64 | } 65 | 66 | /** 67 | * Deep clones an object up to a maximum recursion depth. 68 | */ 69 | export function clone(item: T, depth: number = Infinity): T { 70 | if (!isObject(item) || depth <= 0) { 71 | return item; 72 | } 73 | 74 | const seen = new Map(); 75 | 76 | function _clone(item: unknown, depth: number) { 77 | if (!isObject(item) || depth <= 0) { 78 | return item; 79 | } 80 | 81 | if (seen.has(item)) { 82 | return seen.get(item); 83 | } 84 | 85 | let clonedItem: object; 86 | if (item instanceof Date) { 87 | clonedItem = cloneDate(item); 88 | } else if (item instanceof RegExp) { 89 | clonedItem = cloneRegExp(item); 90 | } else if (item instanceof Map) { 91 | clonedItem = cloneMap(item, _clone, depth); 92 | } else if (item instanceof Set) { 93 | clonedItem = cloneSet(item, _clone, depth); 94 | } else if (Array.isArray(item)) { 95 | clonedItem = cloneArray(item, _clone, depth); 96 | } else { 97 | // At this point, item must be a Record 98 | clonedItem = cloneRecord(item as Record, _clone, depth); 99 | } 100 | seen.set(item, clonedItem); 101 | return clonedItem; 102 | } 103 | 104 | return _clone(item, depth) as T; 105 | } 106 | -------------------------------------------------------------------------------- /src/lib/meta/date.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the value is a Date object. 3 | */ 4 | export function isDate(value: unknown): value is Date { 5 | return value instanceof Date; 6 | } 7 | 8 | /** 9 | * Check if the value is a string that represents an ISO date string. 10 | */ 11 | export function isISODateString(value: string): boolean { 12 | return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d{3})?(Z|[+-]\d{2}:\d{2})?$/.test(value); 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/meta/debounce.ts: -------------------------------------------------------------------------------- 1 | import type { Fn } from './fn.js'; 2 | import type { Timer } from './time.js'; 3 | 4 | /** 5 | * A function that executes the passed in function max once every interval milliseconds 6 | * Set leading to true to execute the function on the leading edge of the interval. 7 | */ 8 | export function debounce( 9 | fn: Fn, 10 | interval: number, 11 | leading: boolean = false 12 | ) { 13 | let timer: Timer | null = null; 14 | return (...args: Params) => { 15 | if (timer) { 16 | clearTimeout(timer); 17 | } 18 | if (leading) { 19 | fn(...args); 20 | } 21 | timer = setTimeout(() => { 22 | fn(...args); 23 | }, interval); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/meta/element.ts: -------------------------------------------------------------------------------- 1 | import { browser } from './env.js'; 2 | 3 | export type ElementOrSelector = HTMLElement | string | undefined; 4 | 5 | /** 6 | * Returns the target node from the provided target or the fallback node. 7 | */ 8 | export function getElement(target: ElementOrSelector, fallback: HTMLElement): HTMLElement; 9 | export function getElement(target: ElementOrSelector): HTMLElement | undefined; 10 | export function getElement(target: ElementOrSelector, fallback?: HTMLElement) { 11 | return (typeof target === 'string' ? document.querySelector(target) : target) || fallback; 12 | } 13 | 14 | /** 15 | * Returns the text content of the target node. If the target is an input or textarea, its value is returned. Otherwise, textContent is returned. 16 | */ 17 | export function getTextContent(target: Element) { 18 | return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement 19 | ? target.value 20 | : target.textContent || ''; 21 | } 22 | 23 | /** 24 | * Sets the text content of the target node. If the target is an input or textarea, its value is set. Otherwise, textContent is set. 25 | */ 26 | export function setTextContent(target: Element, text: string) { 27 | if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { 28 | target.value = text; 29 | } else { 30 | target.textContent = text; 31 | } 32 | } 33 | 34 | /** 35 | * Returns the DOMRect of the target node relative to the document. Want the rect relative to the viewport? Use the native `getBoundingClientRect` instead. 36 | */ 37 | export function getDomRect(node: HTMLElement) { 38 | if (!browser) return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 }; 39 | 40 | const rect = node.getBoundingClientRect(); 41 | return { 42 | left: rect.left + window.scrollX, 43 | top: rect.top + window.scrollY, 44 | right: rect.left + window.scrollX + rect.width, 45 | bottom: rect.top + window.scrollY + rect.height, 46 | width: rect.width, 47 | height: rect.height 48 | }; 49 | } 50 | 51 | /** 52 | * Returns the computed transform coordinates of the target node. 53 | */ 54 | export function getTransformCoords(node: HTMLElement) { 55 | const style = window.getComputedStyle(node); 56 | const transform = style.transform; 57 | 58 | if (transform === 'none') return { x: 0, y: 0 }; 59 | 60 | const matrix = transform.match(/^matrix\((.+)\)$/); 61 | if (!matrix) return { x: 0, y: 0 }; 62 | 63 | const [x, y] = matrix[1].split(',').slice(4).map(parseFloat); 64 | 65 | return { x, y }; 66 | } 67 | 68 | /** 69 | * Returns all transform values (translation, scale, rotation) of the target node. 70 | */ 71 | export function getTransform(node: HTMLElement): Transform { 72 | const style = window.getComputedStyle(node); 73 | const transform = style.transform; 74 | 75 | if (transform === 'none') return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0 }; 76 | 77 | const matrix = transform.match(/^matrix\((.+)\)$/); 78 | if (!matrix) return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0 }; 79 | 80 | const [a, b, c, d, x, y] = matrix[1].split(',').map(parseFloat); 81 | const scaleX = Math.sqrt(a * a + b * b); 82 | const scaleY = (a * d - b * c) / scaleX; 83 | const rotate = Math.round(Math.atan2(b, a) * (180 / Math.PI)); 84 | 85 | return { x, y, scaleX, scaleY, rotate }; 86 | } 87 | 88 | /** 89 | * Transforms the target node (translation, scale, rotation) without affecting other transforms. 90 | */ 91 | type Transform = { x?: number; y?: number; scaleX?: number; scaleY?: number; rotate?: number }; 92 | 93 | export function transform(node: HTMLElement, transform: Transform) { 94 | const current = getTransform(node); 95 | const { 96 | x = current.x, 97 | y = current.y, 98 | scaleX = current.scaleX, 99 | scaleY = current.scaleY, 100 | rotate = current.rotate 101 | } = transform; 102 | 103 | node.style.transform = `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY}) rotate(${rotate}deg)`; 104 | } 105 | 106 | // This list originates from: https://stackoverflow.com/a/30753870 107 | const focusableElements = ` 108 | a[href]:not([tabindex='-1']), 109 | area[href]:not([tabindex='-1']), 110 | input:not([disabled]):not([tabindex='-1']), 111 | select:not([disabled]):not([tabindex='-1']), 112 | textarea:not([disabled]):not([tabindex='-1']), 113 | button:not([disabled]):not([tabindex='-1']), 114 | iframe:not([tabindex='-1']), 115 | [tabindex]:not([tabindex='-1']), 116 | [contentEditable=true]:not([tabindex='-1']) 117 | `; 118 | 119 | /** 120 | * Returns true if the node is focusable. 121 | */ 122 | export function isFocusable(node: HTMLElement) { 123 | return node && node.matches(focusableElements); 124 | } 125 | 126 | /** 127 | * Returns an array with all focusable children of the node. 128 | */ 129 | export function getFocusableChildren(node: HTMLElement): HTMLElement[] { 130 | return node ? Array.from(node.querySelectorAll(focusableElements)) : []; 131 | } 132 | 133 | /** 134 | * Element border sensor. Used to detect the cursor position relative to the element's borders. 135 | */ 136 | export type BorderSensor = { top: boolean; right: boolean; bottom: boolean; left: boolean }; 137 | 138 | /** 139 | * Element border sensor. Returns sensor values based on the cursor position and the element's borders. 140 | */ 141 | export function getBorderSensor( 142 | borders: { top: number; right: number; bottom: number; left: number }, 143 | margin: number, 144 | coords: { x: number; y: number } 145 | ) { 146 | const { top, right, bottom, left } = borders; 147 | const { x, y } = coords; 148 | 149 | return { 150 | top: top < y && y < top + margin, 151 | right: right - margin < x && x < right, 152 | bottom: bottom - margin < y && y < bottom, 153 | left: left < x && x < left + margin 154 | }; 155 | } 156 | 157 | /** 158 | * Returns the cursor "𝑥-resize" name based on BorderSensor values. 159 | */ 160 | export function getBorderCursor(sensor: BorderSensor) { 161 | if (sensor.top && sensor.left) return 'nwse-resize'; 162 | if (sensor.top && sensor.right) return 'nesw-resize'; 163 | if (sensor.bottom && sensor.left) return 'nesw-resize'; 164 | if (sensor.bottom && sensor.right) return 'nwse-resize'; 165 | if (sensor.top) return 'ns-resize'; 166 | if (sensor.right) return 'ew-resize'; 167 | if (sensor.bottom) return 'ns-resize'; 168 | if (sensor.left) return 'ew-resize'; 169 | return 'default'; 170 | } 171 | -------------------------------------------------------------------------------- /src/lib/meta/env.ts: -------------------------------------------------------------------------------- 1 | /** Returns true in browser, false when prerendering / running in node. */ 2 | export const browser = typeof window !== 'undefined' && window.document; 3 | 4 | declare const __SVELTEKIT_DEV__: boolean | undefined; 5 | declare const __SVU_DEV__: boolean | undefined; 6 | 7 | function checkDev() { 8 | if (typeof __SVELTEKIT_DEV__ !== 'undefined') return __SVELTEKIT_DEV__; 9 | if (typeof __SVU_DEV__ !== 'undefined') return __SVU_DEV__; 10 | if (typeof process.env.NODE_ENV !== 'undefined') return process.env.NODE_ENV === 'development'; 11 | return false; 12 | } 13 | 14 | /** 15 | * Returns true in development mode, false in production. This works automagically when using SvelteKit. 16 | * When not using SvelteKit, you can set the __SVU_DEV__ global variable to true in dev mode. 17 | * Using vite, you can use [config.define](https://vitejs.dev/config/shared-options.html#define) to achieve this, your . 18 | */ 19 | export const dev = checkDev(); 20 | -------------------------------------------------------------------------------- /src/lib/meta/event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds an event listener and returns a function that removes it. 3 | * 4 | * Usage: 5 | * ```ts 6 | * const unlisten = listen(window, 'resize', () => console.log('resized')); 7 | * // Later (e.g. in destroy phase): 8 | * unlisten(); 9 | * ``` 10 | */ 11 | export function listen( 12 | node: EventTarget, 13 | type: string, 14 | listener: EventListenerOrEventListenerObject, 15 | options?: boolean | EventListenerOptions 16 | ) { 17 | node.addEventListener(type, listener, options); 18 | return () => node.removeEventListener(type, listener, options); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/meta/fn.ts: -------------------------------------------------------------------------------- 1 | /** Do nothing. */ 2 | export const noop = () => {}; 3 | 4 | /** Executes passed in function immediately. */ 5 | export const run = (fn: Fn) => fn(); 6 | 7 | /** Executes passed in functions immediately. */ 8 | export const runAll = (fns: Fn[]) => fns.forEach(run); 9 | 10 | /** Unknown function type. Use `Fn` to specify types. */ 11 | export type Fn = ( 12 | ...params: Params 13 | ) => Return; 14 | -------------------------------------------------------------------------------- /src/lib/meta/history.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'svelte/elements' { 2 | export interface SvelteWindowAttributes { 3 | 'on:!pushstate'?: (event: CustomEvent) => void; 4 | 'on:!replacestate'?: (event: CustomEvent) => void; 5 | } 6 | } 7 | 8 | export {}; // ensure this is not an ambient module, else types will be overridden instead of augmented 9 | -------------------------------------------------------------------------------- /src/lib/meta/history.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This function is used to monkey-patch the history API in order to dispatch 3 | * events when the history state changes. This is necessary because the 4 | * `popstate` event is only dispatched when the user presses the back button. 5 | * 6 | * Since even though it's 2022 and we have drones and AR sunglasses, we still have no way to detect URL changes, 7 | * there are three 'workarounds' to reliably change the active class when the URL is changed: 8 | * 9 | * - Use the svelte 'page' store, which would not allow use:active to be used in vanilla Svelte projects, does not work when the user calls `history.pushState()` or `history.replaceState()`. 10 | * - Use polling, which is inefficient, and uglier than monkey-patching. 11 | * - Monkey-patch the history API, which is the approach I've taken here. 12 | * 13 | * An additional benefit is that this approach makes use:active compatible with any router, not just SvelteKit. 14 | * 15 | * I am open to suggestions for a better approach. 16 | * Please open an issue in the 'svu' repo if you an idea, but keep the above rationale in mind. 17 | */ 18 | export function patchHistoryAPI() { 19 | if (!history.replaceState.toString().includes('!replacestate')) { 20 | const rs = history.replaceState; 21 | history.replaceState = function (...args) { 22 | rs(...args); 23 | window.dispatchEvent(new CustomEvent('!replacestate')); 24 | }; 25 | } 26 | if (!history.pushState.toString().includes('!pushstate')) { 27 | const ps = history.pushState; 28 | history.pushState = function (...args) { 29 | ps(...args); 30 | window.dispatchEvent(new CustomEvent('!pushstate')); 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/meta/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clone.js'; 2 | export * from './date.js'; 3 | export * from './debounce.js'; 4 | export * from './element.js'; 5 | export * from './env.js'; 6 | export * from './event.js'; 7 | export * from './fn.js'; 8 | export * from './history.js'; 9 | export * from './json.js'; 10 | export * from './math.js'; 11 | export * from './random.js'; 12 | export * from './range.js'; 13 | export * from './string.js'; 14 | export * from './time.js'; 15 | 16 | export * from './types.js'; 17 | -------------------------------------------------------------------------------- /src/lib/meta/json.ts: -------------------------------------------------------------------------------- 1 | import { isDate, isISODateString } from './date.js'; 2 | 3 | export type JSONSerialisable = 4 | | string 5 | | number 6 | | boolean 7 | | null 8 | | JSONSerialisable[] 9 | | { [key: string]: JSONSerialisable } 10 | | Date; 11 | 12 | /** 13 | * Serialise to JSON, extends JSON.stringify to handle dates. Dates are serialised to ISO strings. 14 | */ 15 | export function serialise(value: JSONSerialisable, space: number = 0) { 16 | function replacer(this: { [key: string]: unknown }, key: string, value: unknown) { 17 | // value is the object after calling object.prototype.toJSON(), there is a slim chance that value is a Date object 18 | if (isDate(value)) return value.toISOString(); 19 | // this is the object that contains the key, this[key] is the value BEFORE calling object.prototype.toJSON() 20 | if (isDate(this[key])) return (this[key] as Date).toISOString(); 21 | return value; 22 | } 23 | return JSON.stringify(value, replacer, space); 24 | } 25 | 26 | /** 27 | * Parse JSON, extends JSON.parse to handle dates. Dates are expected to be in ISO string format. 28 | */ 29 | export function deserialise(value: string): JSONSerialisable { 30 | function reviver(key: string, value: T) { 31 | if (typeof value !== 'string') return value; 32 | if (isISODateString(value)) return new Date(value); 33 | return value; 34 | } 35 | return JSON.parse(value, reviver); 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/meta/math.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Limits value to min and max. 3 | */ 4 | export function clamp(value: number, min: number, max: number) { 5 | return Math.min(Math.max(value, min), max); 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/meta/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a pseudorandom integer between min (inclusive) and max (inclusive). 3 | * Passing in a single parameter returns a random integer between 0 and the provided value. 4 | * Throws an error if min or max are not integers, or if min is greater than max. 5 | */ 6 | export function random(min: number, max?: number) { 7 | if (!Number.isInteger(min) || (max !== undefined && !Number.isInteger(max))) { 8 | throw new Error('Min and max values must be integers'); 9 | } 10 | 11 | const _min = max !== undefined ? Math.min(min, max) : 0; 12 | const _max = max !== undefined ? Math.max(min, max) : min; 13 | 14 | if (_min === _max) { 15 | return _min; 16 | } 17 | 18 | return Math.floor(Math.random() * (_max - _min + 1)) + _min; 19 | } 20 | 21 | /** Flip a coin, returns true or false */ 22 | export const coin = () => random(0, 1) === 1; 23 | 24 | /** Throw a four-sided dice */ 25 | export const d4 = () => random(1, 6); 26 | /** Throw a six-sided dice */ 27 | export const d6 = () => random(1, 6); 28 | /** Throw a eight-sided dice */ 29 | export const d8 = () => random(1, 8); 30 | /** Throw a ten-sided dice */ 31 | export const d10 = () => random(1, 10); 32 | /** Throw a twelve-sided dice */ 33 | export const d12 = () => random(1, 12); 34 | /** Throw a twenty-sided dice */ 35 | export const d20 = () => random(1, 20); 36 | /** Throw a hundred-sided dice */ 37 | export const d100 = () => random(1, 100); 38 | -------------------------------------------------------------------------------- /src/lib/meta/range.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A forgiving port of the Python range function. Returns an array from min to max, inclusive. 3 | * You can optionally provide a step size. When you provide a single parameter, it will return a range from 0 to max. 4 | */ 5 | export function range(start: number, end?: number, step?: number) { 6 | const _start = end !== undefined ? start : 0; 7 | const _end = end !== undefined ? end : start; 8 | const _step = step !== undefined ? Math.abs(step) : 1; 9 | 10 | if (_step === 0) return []; 11 | 12 | const size = Math.floor((_end - _start) / _step) + 1; 13 | const length = size > 0 ? size : 0; 14 | 15 | return Array.from({ length }, (_, i) => _start + i * _step); 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/meta/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a capitalised version of the input string. 3 | */ 4 | export function capitalise(string: string) { 5 | return string.slice(0, 1).toUpperCase() + string.slice(1).toLowerCase(); 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/meta/time.ts: -------------------------------------------------------------------------------- 1 | import type { Fn } from './fn.js'; 2 | 3 | /** 4 | * Set a timeout to execute a function. Returns a function that clears the timeout. 5 | */ 6 | export function timeout(handler: Fn, delay_ms: number, ...args: unknown[]) { 7 | const timer = setTimeout(handler, delay_ms, ...args); 8 | return () => clearTimeout(timer); 9 | } 10 | 11 | /** 12 | * Lets you asynchronously wait for a given time. 13 | */ 14 | export function wait(delay_ms: number): Promise { 15 | return new Promise((resolve) => setTimeout(resolve, delay_ms)); 16 | } 17 | 18 | /** 19 | * Type for a timer returned by setTimeout (from node.js or browser). 20 | */ 21 | export type Timer = ReturnType; 22 | -------------------------------------------------------------------------------- /src/lib/meta/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Commonly used types. 3 | */ 4 | 5 | export interface Coords { 6 | x: number; 7 | y: number; 8 | } 9 | 10 | export interface Coords3D extends Coords { 11 | z: number; 12 | } 13 | 14 | export interface Size { 15 | width: number; 16 | height: number; 17 | } 18 | 19 | export interface Size3D extends Size { 20 | depth: number; 21 | } 22 | 23 | export interface Scale { 24 | scaleX: number; 25 | scaleY: number; 26 | } 27 | 28 | export interface Scale3D extends Scale { 29 | scaleZ: number; 30 | } 31 | 32 | export interface Rect extends Coords, Size {} 33 | 34 | export interface Cuboid extends Coords3D, Size3D {} 35 | -------------------------------------------------------------------------------- /src/lib/store/index.ts: -------------------------------------------------------------------------------- 1 | export { localstore } from './localstore.js'; 2 | export { mediaquery } from './mediaquery.js'; 3 | export { resettable } from './resettable.js'; 4 | export { sessionstore } from './sessionstore.js'; 5 | -------------------------------------------------------------------------------- /src/lib/store/localstore.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '$lib/meta/fn.js'; 2 | import { browser } from '../meta/env.js'; 3 | import { listen } from '../meta/event.js'; 4 | import { resettable } from './resettable.js'; 5 | import { serialise, deserialise } from '$lib/meta/json.js'; 6 | 7 | import type { Updater } from 'svelte/store'; 8 | import type { JSONSerialisable } from '../meta/json.js'; 9 | 10 | /** 11 | * A resettable store that is synced with localstorage. 12 | * If localstorage is not available, the store will fallback to a resettable store. 13 | * In this case the store.available flag will be false. (Keep in mind that store.available is not reactive) 14 | * 15 | * Example: 16 | * ```svelte 17 | * let store = localstore('key', 0); // will retreive from localstore 18 | * 19 | * 20 | * // resets to 0 21 | * // clears localstore and disconnects 22 | * // disconnects from localstore (but leaves the stored value as is) 23 | * // reconnects to localstore (retreives the stored value) 24 | * // reconnects to localstore and overrides the stored value with the current value 25 | */ 26 | export function localstore(key: string, value: T) { 27 | if (!browser || !available) return fallback(value); 28 | 29 | let connected = false; 30 | let unlisten = noop; 31 | 32 | const { subscribe, set: setStore, reset: resetStore, update: updateStore } = resettable(value); 33 | 34 | /** Sets the store to new value, saves to localstorage */ 35 | function set(newValue: T) { 36 | connected && setItem(key, newValue); 37 | setStore(newValue); 38 | } 39 | 40 | /** Resets the store to the initial value, saves to localstorage. */ 41 | function reset() { 42 | connected && setItem(key, value); 43 | resetStore(); 44 | } 45 | 46 | /** Clears the store value from localstorage and disconnects the store */ 47 | function clear() { 48 | connected && disconnect(); 49 | removeItem(key); 50 | } 51 | 52 | /** Disconnects store value from localstorage */ 53 | function disconnect() { 54 | connected = false; 55 | unlisten(); 56 | } 57 | 58 | function update(updater: Updater) { 59 | updateStore((value) => { 60 | const newValue = updater(value); 61 | connected && setItem(key, newValue); 62 | return newValue; 63 | }); 64 | } 65 | 66 | /** Updates store value from localstorage */ 67 | function handleStorageEvent(e: StorageEvent) { 68 | const item = getItem(key); 69 | e.key === key && item !== null && setStore(deserialise(item) as T); //TODO: add type validation and remove assertion 70 | } 71 | 72 | /** Connects store value to localstorage. Pass true to override the stored value with the current value. */ 73 | function connect(override = false) { 74 | connected = true; 75 | 76 | unlisten(); // disconnect if connected 77 | unlisten = listen(window, 'storage', (e) => handleStorageEvent(e as StorageEvent)); 78 | 79 | // the store will be updated from localstorage unless override is true or the associated localstorage key is empty 80 | const item = getItem(key); 81 | override || item === null ? update((v) => v) : set(deserialise(item) as T); //TODO: add type validation and remove assertion 82 | } 83 | 84 | connect(); 85 | 86 | return { 87 | available: true, 88 | subscribe, 89 | set, 90 | update, 91 | reset, 92 | clear, 93 | connect, 94 | disconnect 95 | }; 96 | } 97 | 98 | /** This takes in a key and value and stores the stringified value under the key. */ 99 | function setItem(key: string, value: JSONSerialisable) { 100 | localStorage.setItem(key, serialise(value)); 101 | } 102 | 103 | function getItem(key: string) { 104 | return localStorage.getItem(key) ?? null; 105 | } 106 | 107 | function removeItem(key: string) { 108 | localStorage.removeItem(key); 109 | } 110 | 111 | function fallback(value: T) { 112 | const { subscribe, set, reset } = resettable(value); 113 | return { 114 | available: false, 115 | subscribe, 116 | set, 117 | reset, 118 | clear: noop, 119 | connect: noop, 120 | disconnect: noop 121 | }; 122 | } 123 | 124 | function checkAvailability() { 125 | const test = '__svu_test_localstore__'; 126 | try { 127 | setItem(test, test); 128 | removeItem(test); 129 | return true; 130 | } catch (_) { 131 | return false; 132 | } 133 | } 134 | 135 | const available = checkAvailability(); 136 | -------------------------------------------------------------------------------- /src/lib/store/mediaquery.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import { browser } from '../meta/env.js'; 3 | import { listen } from '../meta/event.js'; 4 | 5 | /** 6 | * This is a readable store that syncs to the state of a media query. 7 | * It uses window.MatchMedia under the hood. 8 | * 9 | * Initialise the query with `let query = mediaquery(media, value)`, then track the state of the query by subscribing to the store using `$query`. 10 | * 11 | * There are many built-in stores available in `svu/store`! 12 | * 13 | * Example: 14 | * ```svelte 15 | * let darkMode = mediaquery('prefers-color-scheme', 'dark'); 16 | * let prefersReducedMotion = mediaquery('prefers-reduced-motion'); 17 | * 18 | * {#if $darkMode} 19 | *

Dark mode is enabled!

20 | * {/if} 21 | * 22 | * {#if $prefersReducedMotion} 23 | *

Reduced motion is enabled!

24 | * {/if} 25 | * ``` 26 | */ 27 | export function mediaquery(media: string, value?: string) { 28 | media = value ? `(${media}: ${value})` : media; 29 | 30 | const { subscribe, set } = writable(); 31 | 32 | if (!browser) return { subscribe }; 33 | 34 | function update(e: MediaQueryListEvent) { 35 | set(e.matches); 36 | } 37 | 38 | const query = window.matchMedia(media); 39 | const unlisten = listen(query, 'change', update as EventListener); 40 | 41 | set(query.matches); 42 | 43 | const unsubscribe = () => unlisten(); 44 | 45 | return { subscribe, unsubscribe }; 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/store/resettable.ts: -------------------------------------------------------------------------------- 1 | import { clone } from '../meta/clone.js'; 2 | import { writable } from 'svelte/store'; 3 | 4 | import type { StartStopNotifier } from 'svelte/store'; 5 | 6 | /** 7 | * Creates a resettable Svelte store that can revert to its initial state. 8 | * If an object is passed, it will be cloned to prevent shared references, unless cloneDepth is set to 0. 9 | * 10 | * Example: 11 | * ```svelte 12 | * let count = resettable(0); 13 | * 14 | * 15 | * 16 | * ``` 17 | */ 18 | export function resettable(value: T, start?: StartStopNotifier, cloneDepth = Infinity) { 19 | const initial = clone(value, cloneDepth); 20 | 21 | const { subscribe, update, set } = writable(initial, start); 22 | const reset = () => set(clone(initial, cloneDepth)); 23 | 24 | return { subscribe, update, set, reset }; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/store/sessionstore.ts: -------------------------------------------------------------------------------- 1 | import { noop } from '$lib/meta/fn.js'; 2 | import { browser } from '../meta/env.js'; 3 | import { listen } from '../meta/event.js'; 4 | import { resettable } from './resettable.js'; 5 | import { serialise, deserialise } from '$lib/meta/json.js'; 6 | 7 | import type { Updater } from 'svelte/store'; 8 | import type { JSONSerialisable } from '../meta/json.js'; 9 | 10 | /** 11 | * A resettable store that is synced with sessionstorage. 12 | * If sessionstorage is not available, the store will fallback to a resettable store. 13 | * In this case the store.available flag will be false. (Keep in mind that store.available is not reactive) 14 | * 15 | * Example: 16 | * ```svelte 17 | * let store = sessionstore('key', 0); // will retreive from sessionstore 18 | * 19 | * 20 | * // resets to 0 21 | * // clears sessionstore and disconnects 22 | * // disconnects from sessionstore (but leaves the stored value as is) 23 | * // reconnects to sessionstore (retreives the stored value) 24 | * // reconnects to sessionstore and overrides the stored value with the current value 25 | */ 26 | export function sessionstore(key: string, value: T) { 27 | if (!browser || !available) return fallback(value); 28 | 29 | let connected = false; 30 | let unlisten = noop; 31 | 32 | const { subscribe, set: setStore, reset: resetStore, update: updateStore } = resettable(value); 33 | 34 | /** Sets the store to new value, saves to sessionstorage */ 35 | function set(newValue: T) { 36 | connected && setItem(key, newValue); 37 | setStore(newValue); 38 | } 39 | 40 | /** Resets the store to the initial value, saves to sessionstorage. */ 41 | function reset() { 42 | connected && setItem(key, value); 43 | resetStore(); 44 | } 45 | 46 | /** Clears the store value from sessionstorage and disconnects the store */ 47 | function clear() { 48 | connected && disconnect(); 49 | removeItem(key); 50 | } 51 | 52 | /** Disconnects store value from sessionstorage */ 53 | function disconnect() { 54 | connected = false; 55 | unlisten(); 56 | } 57 | 58 | function update(updater: Updater) { 59 | updateStore((value) => { 60 | const newValue = updater(value); 61 | connected && setItem(key, newValue); 62 | return newValue; 63 | }); 64 | } 65 | 66 | /** Updates store value from sessionstorage */ 67 | function handleStorageEvent(e: StorageEvent) { 68 | const item = getItem(key); 69 | e.key === key && item !== null && setStore(deserialise(item) as T); //TODO: add type validation and remove assertion 70 | } 71 | 72 | /** Connects store value to sessionstorage. Pass true to override the stored value with the current value. */ 73 | function connect(override = false) { 74 | connected = true; 75 | 76 | unlisten(); // disconnect if connected 77 | unlisten = listen(window, 'storage', (e) => handleStorageEvent(e as StorageEvent)); 78 | 79 | // the store will be updated from sessionstorage unless override is true or the associated sessionstorage key is empty 80 | const item = getItem(key); 81 | override || item === null ? update((v) => v) : set(deserialise(item) as T); //TODO: add type validation and remove assertion 82 | } 83 | 84 | connect(); 85 | 86 | return { 87 | available: true, 88 | subscribe, 89 | set, 90 | update, 91 | reset, 92 | clear, 93 | connect, 94 | disconnect 95 | }; 96 | } 97 | 98 | /** This takes in a key and value and stores the stringified value under the key. */ 99 | function setItem(key: string, value: JSONSerialisable) { 100 | sessionStorage.setItem(key, serialise(value)); 101 | } 102 | 103 | function getItem(key: string) { 104 | return sessionStorage.getItem(key) ?? null; 105 | } 106 | 107 | function removeItem(key: string) { 108 | sessionStorage.removeItem(key); 109 | } 110 | 111 | function fallback(value: T) { 112 | const { subscribe, set, reset } = resettable(value); 113 | return { 114 | available: false, 115 | subscribe, 116 | set, 117 | reset, 118 | clear: noop, 119 | connect: noop, 120 | disconnect: noop 121 | }; 122 | } 123 | 124 | function checkAvailability() { 125 | const test = '__svu_test_sessionstore__'; 126 | try { 127 | setItem(test, test); 128 | removeItem(test); 129 | return true; 130 | } catch (_) { 131 | return false; 132 | } 133 | } 134 | 135 | const available = checkAvailability(); 136 | -------------------------------------------------------------------------------- /src/lib/transition/index.ts: -------------------------------------------------------------------------------- 1 | export { slide } from './slide.js'; 2 | export { typewriter } from './typewriter.js'; 3 | -------------------------------------------------------------------------------- /src/lib/transition/slide.ts: -------------------------------------------------------------------------------- 1 | import { cubicOut } from 'svelte/easing'; 2 | import type { EasingFunction } from 'svelte/transition'; 3 | 4 | /** 5 | * Slide transition that supports multiple directions. 6 | * Based on the original slide transition from 'svelte/transition'. 7 | */ 8 | export function slide( 9 | node: Element, 10 | options: { delay?: number; duration?: number; direction?: 'x' | 'y'; easing?: EasingFunction } 11 | ) { 12 | const { delay = 0, duration = 400, direction = 'x', easing = cubicOut } = options ?? {}; 13 | 14 | const style = getComputedStyle(node); 15 | const opacity = +style.opacity; 16 | const width = parseFloat(style.width); 17 | const height = parseFloat(style.height); 18 | const paddingTop = parseFloat(style.paddingTop); 19 | const paddingBottom = parseFloat(style.paddingBottom); 20 | const marginTop = parseFloat(style.marginTop); 21 | const marginBottom = parseFloat(style.marginBottom); 22 | const borderTopWidth = parseFloat(style.borderTopWidth); 23 | const borderBottomWidth = parseFloat(style.borderBottomWidth); 24 | 25 | const prop = direction === 'x' ? 'width' : 'height'; 26 | const value = direction === 'x' ? width : height; 27 | 28 | return { 29 | delay, 30 | duration, 31 | easing, 32 | css: (t: number) => 33 | 'overflow: hidden;' + 34 | `opacity: ${Math.min(t * 20, 1) * opacity};` + 35 | `${prop}: ${t * value}px;` + 36 | `padding-top: ${t * paddingTop}px;` + 37 | `padding-bottom: ${t * paddingBottom}px;` + 38 | `margin-top: ${t * marginTop}px;` + 39 | `margin-bottom: ${t * marginBottom}px;` + 40 | `border-top-width: ${t * borderTopWidth}px;` + 41 | `border-bottom-width: ${t * borderBottomWidth}px;` 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/transition/typewriter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple typewriter transition. Based on the svelte tutorial typewriter transition. 3 | */ 4 | export function typewriter(node: Element, options: { speed?: number; delay?: number }) { 5 | const { speed = 100, delay = 0 } = options ?? {}; 6 | 7 | const valid = node.childNodes.length === 1 && node.childNodes[0].nodeType === 3; 8 | if (!valid) return {}; 9 | 10 | const text = node.textContent || ''; 11 | const duration = text.length * speed; 12 | 13 | return { 14 | duration, 15 | delay, 16 | tick: (t: number) => { 17 | const i = Math.trunc(text.length * t); 18 | node.textContent = text.slice(0, i); 19 | } 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Svu: Svelte development, supercharged. 12 | 13 | 14 | 15 | 16 |
17 | ⚠️ The content of the docs have not yet been updated for the current version of svu. 18 |
19 | 20 |
21 |
22 | 23 |
24 | https:// 25 | 26 | svu 27 | 28 | {#if $page.params.category} 29 | / 30 | 31 | 32 | {$page.params.category} 33 | 34 | 35 | {/if} 36 | {#if $page.params.slug} 37 | / 38 | 39 | 40 | {$page.params.slug} 41 | 42 | 43 | {/if} 44 |
45 | 52 |
53 |
54 | 55 |
56 |
57 | 58 | 168 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getCategories } from '$docs/server.js'; 2 | 3 | export async function load() { 4 | return { 5 | categories: getCategories() 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

/svu

9 |

Svelte development, supercharged.

10 |

11 | svu started out as a collection of svelte-related utilities I copied from project to project. 12 | svu is currently in alpha, while the API settles. If you run into any issues, or have any 13 | questions or suggestions, feel free to open an issue 14 | on GitHub. 15 |

16 |

Choose a Category:

17 |
    18 | {#each categories as category} 19 |
  • 20 | {category} 21 |
  • 22 | {/each} 23 |
24 |
25 | 26 | 31 | -------------------------------------------------------------------------------- /src/routes/[category]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@sveltejs/kit'; 2 | import { isCategory, getPages } from '$docs/server.js'; 3 | 4 | export async function load({ params }) { 5 | if (isCategory(params.category)) { 6 | return { 7 | pages: getPages(params.category) 8 | }; 9 | } 10 | 11 | return error(404, 'Not found'); 12 | } 13 | -------------------------------------------------------------------------------- /src/routes/[category]/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |

Category: {$page.params.category}

11 |

Choose a module for more information:

12 |
    13 | {#each pages as slug} 14 |
  • 15 | {slug} 16 |
  • 17 | {/each} 18 |
19 |
20 | 21 | 26 | -------------------------------------------------------------------------------- /src/routes/[category]/[slug]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@sveltejs/kit'; 2 | import { isPage, getPage } from '$docs/server.js'; 3 | 4 | export async function load({ params }) { 5 | if (isPage(params.category, params.slug)) { 6 | return { 7 | doc: getPage(params.category, params.slug) 8 | }; 9 | } 10 | 11 | return error(404, 'Not found'); 12 | } 13 | -------------------------------------------------------------------------------- /src/routes/[category]/[slug]/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 | {@html content} 11 |
12 | 13 | 18 | -------------------------------------------------------------------------------- /src/routes/_docs/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: 'Recursive Variable', system-ui, sans-serif; 3 | 4 | --c: rgb(55, 165, 20); 5 | } 6 | 7 | html, 8 | body { 9 | overflow: hidden; 10 | background-color: #e0e0e0; 11 | background-image: radial-gradient(circle, #bcbcbc 9%, transparent 9%); 12 | background-position: center center; 13 | background-size: 32px 32px; 14 | background-repeat: repeat; 15 | animation: bg 10s linear infinite; 16 | width: 100%; 17 | height: 100%; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | } 23 | 24 | @keyframes bg { 25 | 0% { 26 | background-position: 0 0; 27 | } 28 | 100% { 29 | background-position: -32px -32px; 30 | } 31 | } 32 | 33 | h1 { 34 | font-size: 1.5rem; 35 | margin: 0 0 0.5rem; 36 | padding: 0; 37 | 38 | font-weight: bold; 39 | font-variation-settings: 'slnt' -7; 40 | 41 | text-decoration: underline; 42 | text-decoration-color: var(--c); 43 | text-decoration-thickness: 0.25rem; 44 | text-underline-offset: 0.25rem; 45 | } 46 | 47 | h2 { 48 | font-size: 1.25rem; 49 | margin: 0 0 0.5rem; 50 | padding: 0; 51 | 52 | font-variation-settings: 'slnt' -3; 53 | font-weight: bold; 54 | } 55 | 56 | h3 { 57 | font-size: 1rem; 58 | margin: 0 0 0.5rem; 59 | padding: 0; 60 | 61 | font-variation-settings: 'slnt' -3; 62 | font-weight: bold; 63 | } 64 | 65 | h4 { 66 | font-size: 0.875rem; 67 | margin: 0 0 0.5rem; 68 | padding: 0; 69 | 70 | font-variation-settings: 'slnt' -3; 71 | font-weight: bold; 72 | } 73 | 74 | h5 { 75 | font-size: 0.75rem; 76 | margin: 0 0 0.5rem; 77 | padding: 0; 78 | 79 | font-variation-settings: 'slnt' -3; 80 | font-weight: bold; 81 | } 82 | 83 | article * + * { 84 | margin-top: 1rem; 85 | } 86 | 87 | pre { 88 | font-family: 'Recursive Variable', system-ui, sans-serif; 89 | font-variation-settings: 'MONO' 1; 90 | font-size: 0.875rem; 91 | background-color: #f0f0f0; 92 | padding: 1rem; 93 | border-radius: 0.5rem; 94 | overflow-x: auto; 95 | } 96 | 97 | li::before { 98 | content: '> '; 99 | color: var(--c); 100 | } 101 | 102 | a { 103 | font-variation-settings: 104 | 'slnt' -7, 105 | 'CASL' 0.1; 106 | 107 | text-decoration: underline; 108 | text-decoration-color: var(--c); 109 | text-decoration-thickness: 0rem; 110 | text-underline-offset: 0rem; 111 | 112 | transition: 113 | font-variation-settings 0.25s ease-in-out, 114 | text-decoration-thickness 0.5s ease-in-out, 115 | text-underline-offset 0.5s ease-in-out; 116 | } 117 | 118 | a:hover { 119 | font-variation-settings: 120 | 'slnt' 0, 121 | 'CASL' 1; 122 | 123 | text-decoration-thickness: 0.125rem; 124 | text-underline-offset: 0.25rem; 125 | 126 | transition: 127 | font-variation-settings 0.25s ease-in-out, 128 | text-decoration-thickness 0.5s ease-in-out, 129 | text-underline-offset 0.5s ease-in-out; 130 | } 131 | -------------------------------------------------------------------------------- /src/routes/_docs/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | CSS reset 3 | Based on https://elad2412.github.io/the-new-css-reset/ 4 | */ 5 | 6 | *:where(:not(iframe, pre, canvas, img, svg, video):not(svg *, symbol *)) { 7 | all: unset; 8 | display: revert; 9 | } 10 | 11 | *, 12 | *::before, 13 | *::after { 14 | box-sizing: border-box; 15 | } 16 | 17 | ol, 18 | ul { 19 | list-style: none; 20 | } 21 | 22 | img { 23 | max-width: 100%; 24 | } 25 | 26 | table { 27 | border-collapse: collapse; 28 | } 29 | 30 | :where([contenteditable]) { 31 | -webkit-user-modify: read-write; 32 | overflow-wrap: break-word; 33 | line-break: after-white-space; 34 | -webkit-line-break: after-white-space; 35 | } 36 | 37 | button { 38 | cursor: pointer; 39 | text-align: center; 40 | } 41 | -------------------------------------------------------------------------------- /src/routes/_docs/server.ts: -------------------------------------------------------------------------------- 1 | import { read } from '$app/server'; 2 | 3 | const markdown = import.meta.glob('./../../../docs/*/*.md', { 4 | query: '?url', 5 | import: 'default', 6 | eager: true 7 | }); 8 | 9 | const docs: { [key: string]: { category: string; slug: string; content: string } } = {}; 10 | 11 | // after basepath, the path looks like [category]/[slug].md 12 | for (const [file, asset] of Object.entries(markdown)) { 13 | const match = file.match(/\/docs\/(.*)\.md/); 14 | if (match) { 15 | const [, path] = match; 16 | const [category, slug] = path.split('/'); 17 | const content = await read(asset as string).text(); 18 | 19 | docs[path] = { category, slug, content }; 20 | } 21 | } 22 | 23 | export function isPage(category: string, slug: string) { 24 | return `${category}/${slug}` in docs; 25 | } 26 | 27 | export function isCategory(category: string) { 28 | return Object.values(docs).some((doc) => doc.category === category); 29 | } 30 | 31 | export function getPage(category: string, slug: string) { 32 | return docs[`${category}/${slug}`]; 33 | } 34 | 35 | export function getCategories() { 36 | const categories = new Set(); 37 | for (const path in docs) { 38 | categories.add(docs[path].category); 39 | } 40 | return Array.from(categories); 41 | } 42 | 43 | export function getPages(category: string) { 44 | const pages = []; 45 | for (const path in docs) { 46 | if (docs[path].category === category) { 47 | pages.push(docs[path].slug); 48 | } 49 | } 50 | return pages; 51 | } 52 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolai-cc/svu/74d927ead77a8042c09dc133da447fb68aa4941d/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-static'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | preprocess: vitePreprocess(), 7 | kit: { 8 | adapter: adapter(), 9 | alias: { 10 | $docs: 'src/routes/_docs/' 11 | } 12 | } 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "module": "NodeNext", 13 | "moduleResolution": "NodeNext" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | --------------------------------------------------------------------------------