├── .DS_Store ├── .gitignore ├── .vscode └── settings.json ├── .zed └── settings.json ├── MIGRATION.md ├── README.md ├── docs ├── .gitignore ├── .npmrc ├── README.md ├── package.json ├── postcss.config.cjs ├── src │ ├── app.d.ts │ ├── app.html │ ├── app.pcss │ ├── lib │ │ └── index.ts │ └── routes │ │ ├── +layout.svelte │ │ └── +page.svelte ├── static │ └── favicon.png ├── svelte.config.js ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.ts ├── eslint.config.js ├── package.json ├── packages ├── builders │ ├── dialog │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── package.json │ │ ├── src │ │ │ └── lib │ │ │ │ ├── create.svelte.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ ├── svelte.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── label │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── package.json │ │ ├── src │ │ │ └── lib │ │ │ │ ├── create.ts │ │ │ │ └── index.ts │ │ ├── svelte.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── slider │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── package.json │ │ ├── src │ │ │ └── lib │ │ │ │ ├── create.svelte.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ ├── svelte.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── toggle │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── package.json │ │ ├── src │ │ │ └── lib │ │ │ │ ├── create.svelte.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ ├── svelte.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── tooltip │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── package.json │ │ ├── src │ │ └── lib │ │ │ ├── create.svelte.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── svelte.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts └── helpers │ ├── .gitignore │ ├── .npmrc │ ├── package.json │ ├── src │ └── lib │ │ ├── attr.ts │ │ ├── box.svelte.ts │ │ ├── callbacks.ts │ │ ├── element.ts │ │ ├── event.ts │ │ ├── focus.ts │ │ ├── id.ts │ │ ├── index.ts │ │ ├── is.ts │ │ ├── keyboard.ts │ │ ├── lifecycle.svelte.ts │ │ ├── math.ts │ │ ├── platform.ts │ │ ├── polygon │ │ ├── hull.ts │ │ └── index.ts │ │ ├── portal.ts │ │ ├── scroll.ts │ │ ├── start-stop.svelte.ts │ │ ├── style.ts │ │ ├── transition.ts │ │ ├── types.ts │ │ ├── use-escape-keydown.svelte.ts │ │ ├── use-event-listener.svelte.ts │ │ ├── use-floating.svelte.ts │ │ ├── use-focus-trap.svelte.ts │ │ ├── use-interact-outside.ts │ │ ├── use-modal.svelte.ts │ │ └── use-portal.svelte.ts │ ├── svelte.config.js │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melt-ui/runes/d2e97e263ab170c9dc55288b1205e51d581e31ca/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | "eslint.experimental.useFlatConfig": true, 4 | // Disable the default formatter, use eslint instead 5 | "prettier.enable": false, 6 | "editor.formatOnSave": false, 7 | // Auto fix 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": "explicit", 10 | "source.organizeImports": "never" 11 | }, 12 | // Silent the stylistic rules in you IDE, but still auto fix them 13 | "eslint.rules.customizations": [ 14 | { 15 | "rule": "style/*", 16 | "severity": "off" 17 | }, 18 | { 19 | "rule": "format/*", 20 | "severity": "off" 21 | }, 22 | { 23 | "rule": "*-indent", 24 | "severity": "off" 25 | }, 26 | { 27 | "rule": "*-spacing", 28 | "severity": "off" 29 | }, 30 | { 31 | "rule": "*-spaces", 32 | "severity": "off" 33 | }, 34 | { 35 | "rule": "*-order", 36 | "severity": "off" 37 | }, 38 | { 39 | "rule": "*-dangle", 40 | "severity": "off" 41 | }, 42 | { 43 | "rule": "*-newline", 44 | "severity": "off" 45 | }, 46 | { 47 | "rule": "*quotes", 48 | "severity": "off" 49 | }, 50 | { 51 | "rule": "*semi", 52 | "severity": "off" 53 | } 54 | ], 55 | // Enable eslint for all supported languages 56 | "eslint.validate": [ 57 | "javascript", 58 | "javascriptreact", 59 | "typescript", 60 | "typescriptreact", 61 | "vue", 62 | "html", 63 | "markdown", 64 | "json", 65 | "jsonc", 66 | "yaml", 67 | "toml", 68 | "astro", 69 | "svelte" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "format_on_save": "off" 3 | } 4 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration plan 2 | 3 | ## Context 4 | 5 | There's a lot of work to be done in Melt in general. Tons of issues and PRs. 6 | 7 | But a lot of said work would be forgotten, or have to be rewritten, when migrating to Runes. 8 | This is time-consuming, and a bit exhausting, if I'm being honest. 9 | 10 | So I propose to start focusing fully on Runes, and migrating Melt to use them. 11 | 12 | That being said, we've accumulated a lot of debt, and there are several enhancements that we sith to add, which were a bit harder to do. 13 | 14 | But, since in this repo we can basically start "fresh", we should really take our time to make the first version of Melt w/ Runes as close to perfection as possible. 15 | 16 | ## Goals 17 | 18 | There are several goals that we aim to achieve with Melt as a whole. 19 | 20 | - [ ] Ease of use (enjoyable and intuitive DX) 21 | - [ ] Make it easy to get started 22 | - [ ] Developer ownership 23 | - [ ] Customizable (events, styles, overriding state and attributes) 24 | - [ ] Composable (avoiding too much duplication, while allowing to extend builders) 25 | - [ ] Package control 26 | - [ ] Comprehensive documentation 27 | - [ ] Straight-forward to contribute to 28 | - [ ] Extras 29 | - [ ] Animation support 30 | - [ ] Performance 31 | - [ ] Bundle size 32 | 33 | ### What goes into these goals? 34 | 35 | #### Ease of use 36 | 37 | We kind of have this covered. Runes makes it much easier to interact with Melt. Passing in controlled props is straight-forward, no weird helpers, no need for pre-processores or extra tooling. Just install Melt and use it. 38 | 39 | Nonetheless, Melt is still a bit more obtuse compared to something like Bits. Bits is a natural addition to Melt, and we've seen other libraries take similar approaches. React Aria offers both hooks and components, Zag js has Ark UI, and so on. 40 | 41 | However, this does have its downsides. It's a bit harder to maintain two different repositories that tackle the same problem, the community gets fragmented, tracking the origin of bugs is harder to do, and so on. 42 | 43 | Mostly though, it's about making sure that people who want to use Melt on its own, can do so without much hassle. 44 | 45 | I want to tackle this problem at the source. So I want to... 46 | 47 | #### Make it easy to get started 48 | 49 | This one is massive. Currently we have some problems. The examples are often outdated, or not representative of a real-world use-case. It also uses external dependencies, making it a chore to copy-paste and actually use. 50 | 51 | I have some ideas to mitigate this. For each builder, we may have several examples. However, they should all be _self-contained_, so that you can then use a CLI command to paste that example into your repository. 52 | 53 | Some of them may have peer-dependencies. E.g. one would depend on Tailwind, another on pure CSS but have icons, etc. The important bit is to have it be super simple to get started, without much thought. 54 | 55 | They also should be as close to a real-world use-case as possible. So it should work as a component would, with reactive props, and so on. 56 | 57 | We can have multiple examples per component, such as what Ariakit does. 58 | 59 | #### Developer ownership 60 | 61 | This is also something we partially have covered. Melt is pretty customizable. However, it's not as composable. It'd be amazing to reach React Aria levels of composability, while also allowing us to really override anything we want. 62 | 63 | #### Package control 64 | 65 | Monorepo, nuff said. 66 | 67 | #### Comprehensive documentation 68 | 69 | Huge pain point currently. Our API reference is outdated, our JSDocs are lacking, and it's so hard to add a new builder. 70 | 71 | We need: 72 | 73 | - Auto-generated API reference from JSDocs 74 | - JSDoc everywhere 75 | - A easy-to-use markdown system 76 | - Good folder structure 77 | 78 | We should also tackle how to deal with versioning in documentation. I wonder if we can integrate that with changesets somehow. Or maybe have our doc generation tool that infers from JSDoc read the current version from the package.json, and separate it into folderrs. That'd be amazing. 79 | 80 | #### Straight-forward to contribute to 81 | 82 | This just boils down to having a good folder structure, clear documentation, and a comprehensive contributing guide. 83 | 84 | #### Animation support 85 | 86 | We currently support Svelte transitions. We should support CSS animations, auto-detecting when an animation ends in some cases before giving scroll back to the user. Other niceties such as `data-side` which are already present, etc. 87 | 88 | #### Performance 89 | 90 | Nuff said 91 | 92 | #### Bundle size 93 | 94 | Having this stated in the docs could be a plus. Not a big one. Being a monorepo with several packages helps too. 95 | 96 | ## How should we approach it? 97 | 98 | Okay, so this is _a lot_ of work. If we just go in head-first without a plan, try and port 10 builders at once, we'll run over ourselves and make a mess out of it. 99 | 100 | We should perfect _one_ single builder. get everything working butter smooth, where everything feels amazing. From authoring, to using it, to extending it, finding things out, etc. 101 | 102 | We can then possibly gather feedback, see what people think about it. 103 | 104 | Then on to the next one. Always being really careful to make sure everything is just right. 105 | 106 | I'd also like to be extremly focused on clearing out bugs. Zag is amazing at this, they have a tight grip on issues and PRs. Taking it slow will help termendously with this. 107 | 108 | ## What about the current version of Melt? 109 | 110 | We still support it for now, with bug fixes, community support, etc. But no major refactors or builders. We should focus on Runes. 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Melt UI Runes Experiment 2 | 3 | ## Introduction 4 | 5 | This is an experiment that aims to migrate existing Melt UI builders to runes. 6 | 7 | EDIT: After experimenting with Runes, we're fairly confident in a API to move forward. See the [Migration plan](MIGRATION.md) for more details. 8 | 9 | ### Credits 10 | 11 | Most of the work has been done by [Abdelrahman](https://github.com/abdel-17)! 12 | 13 | ## New API 14 | 15 | The new API relies on classes instead of stores for creating builders. 16 | 17 | ```svelte 18 | 19 | 35 | 36 | 37 | 38 | {#if open} 39 |
40 |
41 |

Hello world!

42 |
43 | {/if} 44 | ``` 45 | 46 | ```svelte 47 | 48 | 58 | 59 | 60 | 61 | {#if open} 62 |
63 |
64 |

Hello world!

65 |
66 | {/if} 67 | ``` 68 | 69 | I hear you saying "Ugh, classes". Let me explain the reasoning behind this choice: 70 | 71 | 1. They are more performant, especially with runes. No need for getters and setters for every state. 72 | 2. Destructuring makes it hard to use multiple builders in the same page because you need to rename multiple variables. This is no longer the case with the new API. 73 | 74 | ## Usage 75 | 76 | Try out the new API yourself by cloning this repo. 77 | 78 | ```bash 79 | git clone https://github.com/melt-ui/runes-experiment.git 80 | ``` 81 | 82 | You'll find three builders have been migrated to runes. 83 | 84 | 1. Label 85 | 2. Toggle 86 | 3. Tooltip 87 | 88 | ```ts 89 | import { Label, Toggle, Tooltip } from "$lib/index.js"; 90 | ``` 91 | 92 | You'll also find an example for each builder under the `src/routes/playground` directory. 93 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | -------------------------------------------------------------------------------- /docs/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # create-svelte 2 | 3 | Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```bash 10 | # create a new project in the current directory 11 | npm create svelte@latest 12 | 13 | # create a new project in my-app 14 | npm create svelte@latest my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "sync": "svelte-kit sync" 13 | }, 14 | "devDependencies": { 15 | "@melt-ui/slider": "workspace:^", 16 | "@melt-ui/toggle": "workspace:^", 17 | "@sveltejs/adapter-auto": "^3.0.0", 18 | "@sveltejs/kit": "^2.0.0", 19 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 20 | "autoprefixer": "^10.4.16", 21 | "postcss": "^8.4.32", 22 | "postcss-load-config": "^5.0.2", 23 | "svelte": "^5.0.0-next.95", 24 | "svelte-check": "^3.6.0", 25 | "tailwindcss": "^3.3.6", 26 | "tslib": "^2.4.1", 27 | "typescript": "^5.0.0", 28 | "vite": "^5.0.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /docs/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const tailwindcss = require("tailwindcss"); 2 | const autoprefixer = require("autoprefixer"); 3 | 4 | const config = { 5 | plugins: [ 6 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind, 7 | tailwindcss(), 8 | //But others, like autoprefixer, need to run after, 9 | autoprefixer, 10 | ], 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /docs/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 | -------------------------------------------------------------------------------- /docs/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/src/app.pcss: -------------------------------------------------------------------------------- 1 | /* Write your global styles here, in PostCSS syntax */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | -------------------------------------------------------------------------------- /docs/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /docs/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
8 | -------------------------------------------------------------------------------- /docs/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |

Slider

15 |
16 |
17 |
18 |
19 | {#each slider.thumbs() as thumb} 20 |
24 | {/each} 25 |
26 |
27 |
28 |

Ticks

29 |
30 |
31 |
32 | {#each withTicks.ticks() as tick} 33 |
34 | {/each} 35 | {#each withTicks.thumbs() as thumb} 36 |
40 | {/each} 41 |
42 |
43 |
44 | -------------------------------------------------------------------------------- /docs/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melt-ui/runes/d2e97e263ab170c9dc55288b1205e51d581e31ca/docs/static/favicon.png -------------------------------------------------------------------------------- /docs/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: [vitePreprocess({})], 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | }, 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /docs/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config}*/ 2 | const config = { 3 | content: ["./src/**/*.{html,js,svelte,ts}"], 4 | 5 | theme: { 6 | extend: {}, 7 | }, 8 | 9 | plugins: [], 10 | }; 11 | 12 | module.exports = config; 13 | -------------------------------------------------------------------------------- /docs/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 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /docs/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 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import config from "@antfu/eslint-config"; 2 | 3 | export default config({ 4 | stylistic: { 5 | indent: "tab", 6 | quotes: "double", 7 | semi: true, 8 | overrides: { 9 | "antfu/if-newline": "off", 10 | "curly": ["error", "multi-line"], 11 | "style/brace-style": ["error", "1tbs", { allowSingleLine: true }], 12 | "style/arrow-parens": ["error", "always"], 13 | "ts/consistent-type-definitions": ["error", "type"], 14 | "ts/no-this-alias": "off", 15 | "ts/no-explicit-any": "error", 16 | }, 17 | }, 18 | ignores: [ 19 | ".DS_Store", 20 | "**/.DS_Store/**", 21 | "node_modules", 22 | "**/node_modules/**", 23 | "build", 24 | "build/**", 25 | "dist", 26 | "dist/**", 27 | ".svelte-kit", 28 | ".svelte-kit/**", 29 | "package", 30 | "package/**", 31 | ".env", 32 | "**/.env/**", 33 | ".env.*", 34 | "**/.env.*/**", 35 | "!.env.example", 36 | "!**/.env.example/**", 37 | "pnpm-lock.yaml", 38 | "**/pnpm-lock.yaml/**", 39 | "package-lock.json", 40 | "**/package-lock.json/**", 41 | "yarn.lock", 42 | "**/yarn.lock/**", 43 | ], 44 | svelte: true, 45 | typescript: true, 46 | componentExts: ["svelte"], 47 | }); 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@melt-ui/monorepo", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "pnpm sync && pnpm --parallel dev", 8 | "build": "pnpm -r build", 9 | "build:packages": "pnpm -F \"./packages/**\" --parallel build", 10 | "build:content": "pnpm -F \"./sites/**\" --parallel build:content", 11 | "ci:publish": "pnpm build:packages && changeset publish", 12 | "test": "pnpm -r test", 13 | "format": "prettier --write .", 14 | "check": "pnpm -r check", 15 | "sync": "pnpm --parallel sync", 16 | "postinstall": "pnpm -r sync", 17 | "lint": "eslint . --ignore-pattern 'packages/**' && pnpm -r lint", 18 | "lint:fix": "eslint . --fix --ignore-pattern 'packages/**' && pnpm -r lint:fix", 19 | "lint:inspect": "eslint --inspect-config" 20 | }, 21 | "devDependencies": { 22 | "@antfu/eslint-config": "^2.12.1", 23 | "@huntabyte/eslint-plugin": "^0.0.1", 24 | "@types/eslint": "8.56.7", 25 | "@typescript-eslint/eslint-plugin": "^7.5.0", 26 | "@typescript-eslint/parser": "^7.5.0", 27 | "eslint": "^9.0.0", 28 | "eslint-plugin-svelte": "2.36.0-next.13", 29 | "svelte-eslint-parser": "^0.33.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/builders/dialog/.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 | -------------------------------------------------------------------------------- /packages/builders/dialog/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/builders/dialog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@melt-ui/dialog", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "svelte": "./dist/index.js" 9 | } 10 | }, 11 | "types": "./dist/index.d.ts", 12 | "files": [ 13 | "!dist/**/*.spec.*", 14 | "!dist/**/*.test.*", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "dev": "pnpm sync && pnpm watch", 19 | "build": "pnpm run package", 20 | "package": "svelte-kit sync && svelte-package && publint", 21 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 22 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 23 | "lint": "eslint . --config ../../eslint.config.js", 24 | "lint:fix": "eslint . --fix --config ../../eslint.config.js", 25 | "watch": "svelte-kit sync && svelte-package --watch", 26 | "prepublishOnly": "pnpm run package", 27 | "sync": "svelte-kit sync" 28 | }, 29 | "peerDependencies": { 30 | "@melt-ui/helpers": "workspace:^", 31 | "svelte": "^4.0.0" 32 | }, 33 | "devDependencies": { 34 | "@sveltejs/adapter-auto": "^3.2.0", 35 | "@sveltejs/kit": "^2.5.5", 36 | "@sveltejs/package": "^2.3.1", 37 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 38 | "publint": "^0.2.7", 39 | "svelte": "5.0.0-next.95", 40 | "svelte-check": "^3.6.9", 41 | "tslib": "^2.6.2", 42 | "typescript": "^5.4.4", 43 | "vite": "^5.2.8" 44 | }, 45 | "svelte": "./dist/index.js" 46 | } 47 | -------------------------------------------------------------------------------- /packages/builders/dialog/src/lib/create.svelte.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type FocusProp, 3 | type HTMLElementEvent, 4 | type PortalTarget, 5 | type ReadableBox, 6 | type WritableBox, 7 | autoDestroyEffectRoot, 8 | element, 9 | generateId, 10 | getPortalDestination, 11 | handleFocus, 12 | kbd, 13 | portalAttr, 14 | readableBox, 15 | removeScroll, 16 | runAfterTransitionOutOrImmediate, 17 | styleToString, 18 | useEscapeKeydown, 19 | useFocusTrap, 20 | useModal, 21 | usePortal, 22 | writableBox, 23 | } from "@melt-ui/helpers"; 24 | import type { DialogProps, DialogRole } from "./types.js"; 25 | 26 | const elements = { 27 | TRIGGER: "dialog-trigger", 28 | OVERLAY: "dialog-overlay", 29 | CONTENT: "dialog-content", 30 | PORTALLED: "dialog-portalled", 31 | TITLE: "dialog-title", 32 | DESCRIPTION: "dialog-description", 33 | CLOSE: "dialog-close", 34 | } as const; 35 | 36 | export class Dialog { 37 | #openBox: WritableBox; 38 | #preventScrollBox: ReadableBox; 39 | #closeOnEscapeBox: ReadableBox; 40 | #closeOnOutsideClickBox: ReadableBox; 41 | #onOutsideClick?: (event: PointerEvent) => void; 42 | #roleBox: ReadableBox; 43 | #portalBox: ReadableBox; 44 | #openFocusBox: ReadableBox; 45 | #closeFocusBox: ReadableBox; 46 | #overlayIdBox: ReadableBox; 47 | #contentIdBox: ReadableBox; 48 | #portalledIdBox: ReadableBox; 49 | #titleIdBox: ReadableBox; 50 | #descriptionIdBox: ReadableBox; 51 | 52 | constructor(props: DialogProps = {}) { 53 | const { 54 | open = false, 55 | preventScroll = true, 56 | closeOnEscape = true, 57 | closeOnOutsideClick = true, 58 | onOutsideClick, 59 | role = "dialog", 60 | portal, 61 | openFocus, 62 | closeFocus, 63 | overlayId = generateId(), 64 | contentId = generateId(), 65 | portalledId = generateId(), 66 | titleId = generateId(), 67 | descriptionId = generateId(), 68 | } = props; 69 | 70 | this.#openBox = writableBox(open); 71 | this.#preventScrollBox = readableBox(preventScroll); 72 | this.#closeOnEscapeBox = readableBox(closeOnEscape); 73 | this.#closeOnOutsideClickBox = readableBox(closeOnOutsideClick); 74 | this.#onOutsideClick = onOutsideClick; 75 | this.#roleBox = readableBox(role); 76 | this.#portalBox = readableBox(portal); 77 | this.#openFocusBox = readableBox(openFocus); 78 | this.#closeFocusBox = readableBox(closeFocus); 79 | this.#overlayIdBox = readableBox(overlayId); 80 | this.#contentIdBox = readableBox(contentId); 81 | this.#portalledIdBox = readableBox(portalledId); 82 | this.#titleIdBox = readableBox(titleId); 83 | this.#descriptionIdBox = readableBox(descriptionId); 84 | } 85 | 86 | #activeTrigger: HTMLElement | null = null; 87 | 88 | get open() { 89 | return this.#openBox.value; 90 | } 91 | 92 | set open(value: boolean) { 93 | this.#openBox.value = value; 94 | } 95 | 96 | get preventScroll() { 97 | return this.#preventScrollBox.value; 98 | } 99 | 100 | get closeOnEscape() { 101 | return this.#closeOnEscapeBox.value; 102 | } 103 | 104 | get closeOnOutsideClick() { 105 | return this.#closeOnOutsideClickBox.value; 106 | } 107 | 108 | get role() { 109 | return this.#roleBox.value; 110 | } 111 | 112 | get portal() { 113 | return this.#portalBox.value; 114 | } 115 | 116 | get openFocus() { 117 | return this.#openFocusBox.value; 118 | } 119 | 120 | get closeFocus() { 121 | return this.#closeFocusBox.value; 122 | } 123 | 124 | get overlayId() { 125 | return this.#overlayIdBox.value; 126 | } 127 | 128 | get contentId() { 129 | return this.#contentIdBox.value; 130 | } 131 | 132 | get portalledId() { 133 | return this.#portalledIdBox.value; 134 | } 135 | 136 | get titleId() { 137 | return this.#titleIdBox.value; 138 | } 139 | 140 | get descriptionId() { 141 | return this.#descriptionIdBox.value; 142 | } 143 | 144 | // Helpers 145 | #open(event: HTMLElementEvent) { 146 | this.open = true; 147 | this.#activeTrigger = event.currentTarget; 148 | } 149 | 150 | #close() { 151 | this.open = false; 152 | handleFocus({ 153 | prop: this.closeFocus, 154 | defaultEl: this.#activeTrigger, 155 | }); 156 | } 157 | 158 | // Elements 159 | trigger() { 160 | const dialog = this; 161 | return element(elements.TRIGGER, { 162 | "aria-haspopup": "dialog", 163 | "type": "button", 164 | get "aria-expanded"() { 165 | return dialog.open; 166 | }, 167 | "onclick": dialog.#open.bind(dialog), 168 | onkeydown(event) { 169 | if (event.key !== kbd.ENTER && event.key !== kbd.SPACE) { 170 | return; 171 | } 172 | event.preventDefault(); 173 | dialog.#open(event); 174 | }, 175 | }); 176 | } 177 | 178 | overlay() { 179 | const dialog = this; 180 | return element(elements.OVERLAY, { 181 | "aria-hidden": true, 182 | "tabindex": -1, 183 | get "id"() { 184 | return dialog.overlayId; 185 | }, 186 | get "style"() { 187 | return styleToString({ 188 | display: !dialog.open ? "none" : undefined, 189 | }); 190 | }, 191 | get "data-state"() { 192 | return dialog.open ? "open" : "closed"; 193 | }, 194 | }); 195 | } 196 | 197 | content() { 198 | const dialog = this; 199 | return element(elements.CONTENT, { 200 | "aria-modal": "true", 201 | "tabindex": -1, 202 | get "id"() { 203 | return dialog.contentId; 204 | }, 205 | get "role"() { 206 | return dialog.role; 207 | }, 208 | get "aria-describedby"() { 209 | return dialog.descriptionId; 210 | }, 211 | get "aria-labelledby"() { 212 | return dialog.titleId; 213 | }, 214 | get "aria-hidden"() { 215 | return !dialog.open; 216 | }, 217 | get "style"() { 218 | return styleToString({ 219 | display: !dialog.open ? "none" : undefined, 220 | }); 221 | }, 222 | get "data-state"() { 223 | return dialog.open ? "open" : "closed"; 224 | }, 225 | }); 226 | } 227 | 228 | portalled() { 229 | const dialog = this; 230 | return element(elements.PORTALLED, { 231 | get "id"() { 232 | return dialog.portalledId; 233 | }, 234 | get "data-portal"() { 235 | return portalAttr(dialog.portal); 236 | }, 237 | }); 238 | } 239 | 240 | title() { 241 | const dialog = this; 242 | return element(elements.TITLE, { 243 | get id() { 244 | return dialog.titleId; 245 | }, 246 | }); 247 | } 248 | 249 | description() { 250 | const dialog = this; 251 | return element(elements.DESCRIPTION, { 252 | get id() { 253 | return dialog.descriptionId; 254 | }, 255 | }); 256 | } 257 | 258 | closeButton() { 259 | const dialog = this; 260 | return element(elements.CLOSE, { 261 | type: "button", 262 | onclick: dialog.#close.bind(dialog), 263 | onkeydown(event) { 264 | if (event.key !== kbd.ENTER && event.key !== kbd.SPACE) { 265 | return; 266 | } 267 | event.preventDefault(); 268 | dialog.#close(); 269 | }, 270 | }); 271 | } 272 | 273 | // Effects 274 | readonly destroy = autoDestroyEffectRoot(() => { 275 | $effect(() => { 276 | if (!this.closeOnEscape || !this.open) { 277 | return; 278 | } 279 | 280 | const overlayEl = document.getElementById(this.overlayId); 281 | if (overlayEl === null) { 282 | return; 283 | } 284 | 285 | useEscapeKeydown(overlayEl, { 286 | handler: this.#close.bind(this), 287 | }); 288 | }); 289 | 290 | $effect(() => { 291 | if (!this.closeOnEscape || !this.open) { 292 | return; 293 | } 294 | 295 | const contentEl = document.getElementById(this.contentId); 296 | if (contentEl === null) { 297 | return; 298 | } 299 | 300 | useEscapeKeydown(contentEl, { 301 | handler: this.#close.bind(this), 302 | }); 303 | }); 304 | 305 | $effect(() => { 306 | if (!this.open) { 307 | return; 308 | } 309 | 310 | const contentEl = document.getElementById(this.contentId); 311 | if (contentEl === null) { 312 | return; 313 | } 314 | 315 | useModal(contentEl, { 316 | closeOnInteractOutside: this.closeOnOutsideClick, 317 | onClose: this.#close.bind(this), 318 | shouldCloseOnInteractOutside: (event) => { 319 | this.#onOutsideClick?.(event); 320 | return !event.defaultPrevented; 321 | }, 322 | }); 323 | }); 324 | 325 | $effect(() => { 326 | if (!this.open) { 327 | return; 328 | } 329 | 330 | const contentEl = document.getElementById(this.contentId); 331 | if (contentEl === null) { 332 | return; 333 | } 334 | 335 | useFocusTrap(contentEl, { 336 | escapeDeactivates: true, 337 | clickOutsideDeactivates: true, 338 | returnFocusOnDeactivate: false, 339 | fallbackFocus: contentEl, 340 | }); 341 | }); 342 | 343 | $effect(() => { 344 | if (!this.open) { 345 | return; 346 | } 347 | 348 | const contentEl = document.getElementById(this.contentId); 349 | if (contentEl === null) { 350 | return; 351 | } 352 | 353 | handleFocus({ 354 | prop: this.openFocus, 355 | defaultEl: contentEl, 356 | }); 357 | }); 358 | 359 | $effect(() => { 360 | if (!this.preventScroll || !this.open) { 361 | return; 362 | } 363 | 364 | const contentEl = document.getElementById(this.contentId); 365 | if (contentEl === null) { 366 | return; 367 | } 368 | 369 | const cleanupScroll = removeScroll(); 370 | return () => { 371 | runAfterTransitionOutOrImmediate(contentEl, cleanupScroll); 372 | }; 373 | }); 374 | 375 | $effect(() => { 376 | if (this.portal === null) { 377 | return; 378 | } 379 | 380 | const portalledEl = document.getElementById(this.portalledId); 381 | if (portalledEl === null) { 382 | return; 383 | } 384 | 385 | const portalDestination = getPortalDestination(portalledEl, this.portal); 386 | const { destroy } = usePortal(portalledEl, portalDestination); 387 | return destroy; 388 | }); 389 | }); 390 | } 391 | -------------------------------------------------------------------------------- /packages/builders/dialog/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { Dialog } from "./create.svelte.js"; 2 | export type { DialogRole, DialogProps } from "./types.js"; 3 | -------------------------------------------------------------------------------- /packages/builders/dialog/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { FocusProp, PortalTarget, ReadableProp, WritableProp } from "@melt-ui/helpers"; 2 | 3 | export type DialogRole = "dialog" | "alertdialog"; 4 | 5 | export type DialogProps = { 6 | /** 7 | * Whether or not the dialog is open. 8 | * 9 | * @default false 10 | */ 11 | open?: WritableProp; 12 | 13 | /** 14 | * If `true`, the dialog will prevent scrolling on the body 15 | * when it is open. 16 | * 17 | * @default true 18 | */ 19 | preventScroll?: ReadableProp; 20 | 21 | /** 22 | * If `true`, the dialog will close when the user presses the escape key. 23 | * 24 | * @default true 25 | */ 26 | closeOnEscape?: ReadableProp; 27 | 28 | /** 29 | * If `true`, the dialog will close when the user clicks outside of it. 30 | * 31 | * @default true 32 | */ 33 | closeOnOutsideClick?: ReadableProp; 34 | 35 | /** 36 | * A custom event handler for the "outside click" event, which 37 | * is handled by the `document`. 38 | * 39 | * If `event.preventDefault()` is called within the function, 40 | * the dialog will not close when the user clicks outside of it. 41 | */ 42 | onOutsideClick?: (event: PointerEvent) => void; 43 | 44 | /** 45 | * The `role` attribute to apply to the dialog. 46 | * 47 | * @default 'dialog' 48 | */ 49 | role?: ReadableProp; 50 | 51 | /** 52 | * If not `undefined`, the dialog content will be rendered within the provided element or selector. 53 | */ 54 | portal?: ReadableProp; 55 | 56 | /** 57 | * Override the default autofocus behavior of the dialog 58 | * on open. 59 | */ 60 | openFocus?: ReadableProp; 61 | 62 | /** 63 | * Override the default autofocus behavior of the dialog 64 | * on close. 65 | */ 66 | closeFocus?: ReadableProp; 67 | 68 | /** 69 | * Optionally override the default id we assign to the overlay element. 70 | */ 71 | overlayId?: ReadableProp; 72 | 73 | /** 74 | * Optionally override the default id we assign to the content element. 75 | */ 76 | contentId?: ReadableProp; 77 | 78 | /** 79 | * Optionally override the default id we assign to the portalled element. 80 | */ 81 | portalledId?: ReadableProp; 82 | 83 | /** 84 | * Optionally override the default id we assign to the title element. 85 | */ 86 | titleId?: ReadableProp; 87 | 88 | /** 89 | * Optionally override the default id we assign to the description element. 90 | */ 91 | descriptionId?: ReadableProp; 92 | }; 93 | -------------------------------------------------------------------------------- /packages/builders/dialog/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 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 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/builders/dialog/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "resolveJsonModule": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "strict": true, 10 | "sourceMap": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "skipLibCheck": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/builders/dialog/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 | -------------------------------------------------------------------------------- /packages/builders/label/.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 | -------------------------------------------------------------------------------- /packages/builders/label/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/builders/label/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@melt-ui/label", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "svelte": "./dist/index.js" 9 | } 10 | }, 11 | "types": "./dist/index.d.ts", 12 | "files": [ 13 | "!dist/**/*.spec.*", 14 | "!dist/**/*.test.*", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "dev": "pnpm sync && pnpm watch", 19 | "build": "pnpm run package", 20 | "package": "svelte-kit sync && svelte-package && publint", 21 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 22 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 23 | "lint": "eslint . --config ../../eslint.config.js", 24 | "lint:fix": "eslint . --fix --config ../../eslint.config.js", 25 | "watch": "svelte-kit sync && svelte-package --watch", 26 | "prepublishOnly": "pnpm run package", 27 | "sync": "svelte-kit sync" 28 | }, 29 | "peerDependencies": { 30 | "@melt-ui/helpers": "workspace:^", 31 | "svelte": "^4.0.0" 32 | }, 33 | "devDependencies": { 34 | "@sveltejs/adapter-auto": "^3.2.0", 35 | "@sveltejs/kit": "^2.5.5", 36 | "@sveltejs/package": "^2.3.1", 37 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 38 | "publint": "^0.2.7", 39 | "svelte": "5.0.0-next.95", 40 | "svelte-check": "^3.6.9", 41 | "tslib": "^2.6.2", 42 | "typescript": "^5.4.4", 43 | "vite": "^5.2.8" 44 | }, 45 | "svelte": "./dist/index.js" 46 | } 47 | -------------------------------------------------------------------------------- /packages/builders/label/src/lib/create.ts: -------------------------------------------------------------------------------- 1 | import { element } from "@melt-ui/helpers"; 2 | 3 | export class Label { 4 | /** 5 | * Returns the root element's props. 6 | * 7 | * @example 8 | * ```svelte 9 | * 12 | * 13 | * 14 | * ``` 15 | */ 16 | root() { 17 | return element("label", { 18 | onmousedown(event) { 19 | if (!event.defaultPrevented && event.detail > 1) { 20 | event.preventDefault(); 21 | } 22 | }, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/builders/label/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { Label } from "./create.js"; 2 | -------------------------------------------------------------------------------- /packages/builders/label/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 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 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/builders/label/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "resolveJsonModule": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "strict": true, 10 | "sourceMap": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "skipLibCheck": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/builders/label/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 | -------------------------------------------------------------------------------- /packages/builders/slider/.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 | -------------------------------------------------------------------------------- /packages/builders/slider/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/builders/slider/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@melt-ui/slider", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "svelte": "./dist/index.js" 9 | } 10 | }, 11 | "types": "./dist/index.d.ts", 12 | "files": [ 13 | "!dist/**/*.spec.*", 14 | "!dist/**/*.test.*", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "dev": "pnpm sync && pnpm watch", 19 | "build": "pnpm run package", 20 | "package": "svelte-kit sync && svelte-package && publint", 21 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 22 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 23 | "lint": "eslint . --config ../../eslint.config.js", 24 | "lint:fix": "eslint . --fix --config ../../eslint.config.js", 25 | "watch": "svelte-kit sync && svelte-package --watch", 26 | "prepublishOnly": "pnpm run package", 27 | "sync": "svelte-kit sync" 28 | }, 29 | "peerDependencies": { 30 | "@melt-ui/helpers": "workspace:^", 31 | "svelte": "^4.0.0" 32 | }, 33 | "devDependencies": { 34 | "@melt-ui/helpers": "workspace:^", 35 | "@sveltejs/adapter-auto": "^3.2.0", 36 | "@sveltejs/kit": "^2.5.5", 37 | "@sveltejs/package": "^2.3.1", 38 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 39 | "publint": "^0.2.7", 40 | "svelte": "5.0.0-next.95", 41 | "svelte-check": "^3.6.9", 42 | "tslib": "^2.6.2", 43 | "typescript": "^5.4.4", 44 | "vite": "^5.2.8" 45 | }, 46 | "svelte": "./dist/index.js" 47 | } 48 | -------------------------------------------------------------------------------- /packages/builders/slider/src/lib/create.svelte.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ReadableBox, 3 | type StyleObject, 4 | addEventListener, 5 | autoDestroyEffectRoot, 6 | booleanAttr, 7 | dataMeltSelector, 8 | element, 9 | generateId, 10 | isHTMLElement, 11 | kbd, 12 | readableBox, 13 | snapValueToStep, 14 | styleToString, 15 | } from "@melt-ui/helpers"; 16 | import type { SliderDirection, SliderOrientation, SliderProps } from "./types.js"; 17 | 18 | const ELEMENTS = { 19 | root: "slider", 20 | range: "slider-range", 21 | thumb: "slider-thumb", 22 | tick: "slider-tick", 23 | } as const; 24 | 25 | export class Slider { 26 | #valueBox: ReadableBox; 27 | #minBox: ReadableBox; 28 | #maxBox: ReadableBox; 29 | #stepBox: ReadableBox; 30 | #orientationBox: ReadableBox; 31 | #dirBox: ReadableBox; 32 | #disabledBox: ReadableBox; 33 | #rootIdBox: ReadableBox; 34 | 35 | constructor(props: SliderProps = {}) { 36 | const { 37 | value = [], 38 | min = 0, 39 | max = 100, 40 | step = 1, 41 | orientation = "horizontal", 42 | dir = "ltr", 43 | disabled = false, 44 | rootId = generateId(), 45 | } = props; 46 | 47 | this.#valueBox = readableBox(value, { proxy: true }); 48 | this.#minBox = readableBox(min); 49 | this.#maxBox = readableBox(max); 50 | this.#stepBox = readableBox(step); 51 | this.#orientationBox = readableBox(orientation); 52 | this.#dirBox = readableBox(dir); 53 | this.#disabledBox = readableBox(disabled); 54 | this.#rootIdBox = readableBox(rootId); 55 | } 56 | 57 | #isActive = false; 58 | #activeThumb: { el: HTMLElement; index: number } | null = null; 59 | 60 | // States 61 | get value() { 62 | return this.#valueBox.value; 63 | } 64 | 65 | get min() { 66 | return this.#minBox.value; 67 | } 68 | 69 | get max() { 70 | return this.#maxBox.value; 71 | } 72 | 73 | get step() { 74 | return this.#stepBox.value; 75 | } 76 | 77 | get orientation() { 78 | return this.#orientationBox.value; 79 | } 80 | 81 | get dir() { 82 | return this.#dirBox.value; 83 | } 84 | 85 | get disabled() { 86 | return this.#disabledBox.value; 87 | } 88 | 89 | get rootId() { 90 | return this.#rootIdBox.value; 91 | } 92 | 93 | get horizontal() { 94 | return this.orientation === "horizontal"; 95 | } 96 | 97 | get vertical() { 98 | return this.orientation === "vertical"; 99 | } 100 | 101 | get ltr() { 102 | return this.dir === "ltr"; 103 | } 104 | 105 | get rtl() { 106 | return this.dir === "rtl"; 107 | } 108 | 109 | // Helpers 110 | #updatePosition(value: number, index: number) { 111 | if (this.value.length === 0) { 112 | this.value[index] = value; 113 | return; 114 | } 115 | 116 | const current = this.value[index]; 117 | if (current === undefined || current === value) return; 118 | 119 | const previous = this.value[index - 1]; 120 | if (previous !== undefined && value < current && value < previous) { 121 | this.#swap(value, index, previous, index - 1); 122 | return; 123 | } 124 | 125 | const next = this.value[index + 1]; 126 | if (next !== undefined && value > current && value > next) { 127 | this.#swap(value, index, next, index + 1); 128 | return; 129 | } 130 | 131 | this.value[index] = snapValueToStep(value, this.min, this.max, this.step); 132 | } 133 | 134 | #swap(value: number, index: number, otherValue: number, otherIndex: number) { 135 | this.value[index] = otherValue; 136 | this.value[otherIndex] = value; 137 | 138 | const thumbs = this.#getAllThumbs(); 139 | const thumb = thumbs[otherIndex]; 140 | if (thumb === undefined) return; 141 | 142 | thumb.focus(); 143 | this.#activeThumb = { el: thumb, index: otherIndex }; 144 | } 145 | 146 | #getAllThumbs() { 147 | const root = document.getElementById(this.rootId); 148 | if (!root) return []; 149 | 150 | const thumbs = root.querySelectorAll(dataMeltSelector(ELEMENTS.thumb)); 151 | return Array.from(thumbs).filter(isHTMLElement); 152 | } 153 | 154 | #getClosestThumb(e: PointerEvent) { 155 | const thumbs = this.#getAllThumbs(); 156 | if (thumbs.length === 0) return null; 157 | 158 | for (const thumb of thumbs) { 159 | thumb.blur(); 160 | } 161 | 162 | let minIndex = 0; 163 | let minDistance = this.#getThumbDistance(e, thumbs[0]!); 164 | for (let i = 1; i < thumbs.length; i++) { 165 | const distance = this.#getThumbDistance(e, thumbs[i]!); 166 | if (distance < minDistance) { 167 | minDistance = distance; 168 | minIndex = i; 169 | } 170 | } 171 | 172 | return { 173 | el: thumbs[minIndex]!, 174 | index: minIndex, 175 | }; 176 | } 177 | 178 | #getThumbDistance(e: PointerEvent, thumb: HTMLElement) { 179 | const { left, right, top, bottom } = thumb.getBoundingClientRect(); 180 | if (this.horizontal) { 181 | return Math.abs(e.clientX - (left + right) / 2); 182 | } 183 | return Math.abs(e.clientY - (top + bottom) / 2); 184 | } 185 | 186 | #getPosition(value: number) { 187 | return ((value - this.min) / (this.max - this.min)) * 100; 188 | } 189 | 190 | #applyPosition(clientXY: number, activeThumbIndex: number, start: number, end: number) { 191 | const percent = (clientXY - start) / (end - start); 192 | const value = percent * (this.max - this.min) + this.min; 193 | 194 | if (value < this.min) { 195 | this.#updatePosition(this.min, activeThumbIndex); 196 | } else if (value > this.max) { 197 | this.#updatePosition(this.max, activeThumbIndex); 198 | } else { 199 | const currentStep = Math.floor((value - this.min) / this.step); 200 | const midpointOfCurrentStep = this.min + currentStep * this.step + this.step / 2; 201 | const midpointOfNextStep = this.min + (currentStep + 1) * this.step + this.step / 2; 202 | const newValue 203 | = value >= midpointOfCurrentStep && value < midpointOfNextStep 204 | ? (currentStep + 1) * this.step + this.min 205 | : currentStep * this.step + this.min; 206 | 207 | if (newValue <= this.max) { 208 | this.#updatePosition(newValue, activeThumbIndex); 209 | } 210 | } 211 | } 212 | 213 | // Elements 214 | root() { 215 | const slider = this; 216 | return element(ELEMENTS.root, { 217 | get "id"() { 218 | return slider.rootId; 219 | }, 220 | get "dir"() { 221 | return slider.dir; 222 | }, 223 | get "disabled"() { 224 | return booleanAttr(slider.disabled); 225 | }, 226 | get "style"() { 227 | return styleToString({ 228 | "position": "relative", 229 | "touch-action": slider.disabled ? undefined : slider.horizontal ? "pan-y" : "pan-x", 230 | }); 231 | }, 232 | get "data-disabled"() { 233 | return booleanAttr(slider.disabled); 234 | }, 235 | get "data-orientation"() { 236 | return slider.orientation; 237 | }, 238 | }); 239 | } 240 | 241 | range() { 242 | const slider = this; 243 | return element(ELEMENTS.range, { 244 | get style() { 245 | const style: StyleObject = { 246 | position: "absolute", 247 | }; 248 | 249 | const start = slider.value.length > 1 ? slider.#getPosition(Math.min(...slider.value)) : 0; 250 | const end = 100 - slider.#getPosition(Math.max(...slider.value)); 251 | if (slider.horizontal) { 252 | style.left = slider.ltr ? `${start}%` : `${end}%`; 253 | style.right = slider.ltr ? `${end}%` : `${start}%`; 254 | } else { 255 | style.top = slider.ltr ? `${end}%` : `${start}%`; 256 | style.bottom = slider.ltr ? `${start}%` : `${end}%`; 257 | } 258 | 259 | return styleToString(style); 260 | }, 261 | }); 262 | } 263 | 264 | thumbs() { 265 | const count = this.value.length || 1; 266 | return Array(count) 267 | .fill(null) 268 | .map((_, i) => this.#thumb(i)); 269 | } 270 | 271 | #thumb(i: number) { 272 | const slider = this; 273 | const thumbValue = $derived(slider.value[i] || slider.min); 274 | return element(ELEMENTS.thumb, { 275 | "role": "slider", 276 | get "aria-valuemin"() { 277 | return slider.min; 278 | }, 279 | get "aria-valuemax"() { 280 | return slider.max; 281 | }, 282 | get "aria-valuenow"() { 283 | return thumbValue; 284 | }, 285 | get "aria-disabled"() { 286 | return slider.disabled; 287 | }, 288 | get "aria-orientation"() { 289 | return slider.orientation; 290 | }, 291 | get "tabindex"() { 292 | return slider.disabled ? -1 : 0; 293 | }, 294 | get "style"() { 295 | const style: StyleObject = { 296 | position: "absolute", 297 | }; 298 | 299 | const thumbPosition = slider.#getPosition(thumbValue); 300 | if (slider.horizontal) { 301 | const direction = slider.ltr ? "left" : "right"; 302 | style[direction] = `${thumbPosition}%`; 303 | style.translate = slider.ltr ? "-50% 0" : "50% 0"; 304 | } else { 305 | const direction = slider.ltr ? "bottom" : "top"; 306 | style[direction] = `${thumbPosition}%`; 307 | style.translate = slider.ltr ? "0 50%" : "0 -50%"; 308 | } 309 | 310 | return styleToString(style); 311 | }, 312 | get "data-value"() { 313 | return thumbValue; 314 | }, 315 | onkeydown(e) { 316 | if (slider.disabled) return; 317 | switch (e.key) { 318 | case kbd.HOME: { 319 | e.preventDefault(); 320 | slider.#updatePosition(slider.min, i); 321 | break; 322 | } 323 | case kbd.END: { 324 | e.preventDefault(); 325 | slider.#updatePosition(slider.max, i); 326 | break; 327 | } 328 | case kbd.ARROW_LEFT: { 329 | if (!slider.horizontal) break; 330 | 331 | e.preventDefault(); 332 | 333 | if (e.metaKey) { 334 | const newValue = slider.ltr ? slider.min : slider.max; 335 | slider.#updatePosition(newValue, i); 336 | } else if (slider.ltr && thumbValue > slider.min) { 337 | slider.#updatePosition(thumbValue - slider.step, i); 338 | } else if (slider.rtl && thumbValue < slider.max) { 339 | slider.#updatePosition(thumbValue + slider.step, i); 340 | } 341 | 342 | break; 343 | } 344 | case kbd.ARROW_RIGHT: { 345 | if (!slider.horizontal) break; 346 | 347 | e.preventDefault(); 348 | 349 | if (e.metaKey) { 350 | const newValue = slider.ltr ? slider.max : slider.min; 351 | slider.#updatePosition(newValue, i); 352 | } else if (slider.ltr && thumbValue < slider.max) { 353 | slider.#updatePosition(thumbValue + slider.step, i); 354 | } else if (slider.rtl && thumbValue > slider.min) { 355 | slider.#updatePosition(thumbValue - slider.step, i); 356 | } 357 | 358 | break; 359 | } 360 | case kbd.ARROW_UP: { 361 | e.preventDefault(); 362 | 363 | const topToBottom = slider.vertical && slider.rtl; 364 | if (e.metaKey) { 365 | const newValue = topToBottom ? slider.min : slider.max; 366 | slider.#updatePosition(newValue, i); 367 | } else if (topToBottom && thumbValue > slider.min) { 368 | slider.#updatePosition(thumbValue - slider.step, i); 369 | } else if (!topToBottom && thumbValue < slider.max) { 370 | slider.#updatePosition(thumbValue + slider.step, i); 371 | } 372 | 373 | break; 374 | } 375 | case kbd.ARROW_DOWN: { 376 | e.preventDefault(); 377 | 378 | const topToBottom = slider.vertical && slider.rtl; 379 | if (e.metaKey) { 380 | const newValue = topToBottom ? slider.max : slider.min; 381 | slider.#updatePosition(newValue, i); 382 | } else if (topToBottom && thumbValue < slider.max) { 383 | slider.#updatePosition(thumbValue + slider.step, i); 384 | } else if (!topToBottom && thumbValue > slider.min) { 385 | slider.#updatePosition(thumbValue - slider.step, i); 386 | } 387 | 388 | break; 389 | } 390 | } 391 | }, 392 | }); 393 | } 394 | 395 | ticks() { 396 | const difference = this.max - this.min; 397 | 398 | // min = 0, max = 8, step = 3: 399 | // ---------------------------- 400 | // 0, 3, 6 401 | // (8 - 0) / 3 = 2.666... = 3 ceiled 402 | let count = Math.ceil(difference / this.step); 403 | 404 | // min = 0, max = 9, step = 3: 405 | // --------------------------- 406 | // 0, 3, 6, 9 407 | // (9 - 0) / 3 = 3 408 | // We need to add 1 because `difference` is a multiple of `step`. 409 | if (difference % this.step === 0) { 410 | count++; 411 | } 412 | 413 | return Array(count) 414 | .fill(null) 415 | .map((_, i) => this.#tick(i, count)); 416 | } 417 | 418 | #tick(i: number, count: number) { 419 | const slider = this; 420 | const tickValue = $derived(slider.min + i * slider.step); 421 | return element(ELEMENTS.tick, { 422 | get "style"() { 423 | const style: StyleObject = { 424 | position: "absolute", 425 | }; 426 | 427 | // The track is divided into sections of ratio `step / (max - min)` 428 | const tickPosition = i * (slider.step / (slider.max - slider.min)) * 100; 429 | 430 | // Offset each tick by -50% to center it, except the first and last ticks. 431 | // The first tick is already positioned at the start of the slider. 432 | // The last tick is offset by -100% to prevent it from being rendered outside. 433 | const isFirst = i === 0; 434 | const isLast = i === count - 1; 435 | const offsetPercentage = isFirst ? 0 : isLast ? -100 : -50; 436 | 437 | if (slider.horizontal) { 438 | const direction = slider.ltr ? "left" : "right"; 439 | style[direction] = `${tickPosition}%`; 440 | style.translate = slider.ltr ? `${offsetPercentage}% 0` : `${-offsetPercentage}% 0`; 441 | } else { 442 | const direction = slider.ltr ? "bottom" : "top"; 443 | style[direction] = `${tickPosition}%`; 444 | style.translate = slider.ltr ? `0 ${-offsetPercentage}%` : `0 ${offsetPercentage}%`; 445 | } 446 | 447 | return styleToString(style); 448 | }, 449 | get "data-bounded"() { 450 | if (slider.value.length === 0) { 451 | return undefined; 452 | } 453 | if (slider.value.length === 1) { 454 | return booleanAttr(tickValue <= slider.value[0]!); 455 | } 456 | return booleanAttr(slider.value[0]! <= tickValue && tickValue <= slider.value.at(-1)!); 457 | }, 458 | get "data-value"() { 459 | return tickValue; 460 | }, 461 | }); 462 | } 463 | 464 | // Effects 465 | readonly destroy = autoDestroyEffectRoot(() => { 466 | $effect(() => { 467 | if (this.disabled) return; 468 | 469 | $effect(() => { 470 | return addEventListener( 471 | document, 472 | "pointermove", 473 | this.#handleDocumentPointerMove.bind(this), 474 | ); 475 | }); 476 | 477 | $effect(() => { 478 | return addEventListener(document, "pointerdown", (e) => { 479 | if (e.button !== 0) return; 480 | 481 | const sliderEl = document.getElementById(this.rootId); 482 | const closestThumb = this.#getClosestThumb(e); 483 | if (closestThumb === null || sliderEl === null) return; 484 | 485 | const target = e.target; 486 | if (!isHTMLElement(target) || !sliderEl.contains(target)) return; 487 | 488 | e.preventDefault(); 489 | 490 | this.#activeThumb = closestThumb; 491 | closestThumb.el.focus(); 492 | this.#isActive = true; 493 | 494 | this.#handleDocumentPointerMove(e); 495 | }); 496 | }); 497 | 498 | $effect(() => { 499 | return addEventListener(document, "pointerup", () => { 500 | this.#isActive = false; 501 | }); 502 | }); 503 | 504 | $effect(() => { 505 | return addEventListener(document, "pointerleave", () => { 506 | this.#isActive = false; 507 | }); 508 | }); 509 | }); 510 | 511 | $effect(() => { 512 | for (let i = 0; i < this.value.length; ++i) { 513 | const thumbValue = this.value[i]!; 514 | const snappedValue = snapValueToStep(thumbValue, this.min, this.max, this.step); 515 | if (snappedValue !== thumbValue) { 516 | this.value[i] = snappedValue; 517 | } 518 | } 519 | }); 520 | }); 521 | 522 | #handleDocumentPointerMove(e: PointerEvent) { 523 | if (!this.#isActive) return; 524 | 525 | e.preventDefault(); 526 | e.stopPropagation(); 527 | 528 | const sliderEl = document.getElementById(this.rootId); 529 | if (this.#activeThumb === null || sliderEl === null) return; 530 | 531 | this.#activeThumb.el.focus(); 532 | 533 | const { left, right, top, bottom } = sliderEl.getBoundingClientRect(); 534 | if (this.horizontal) { 535 | const start = this.ltr ? left : right; 536 | const end = this.ltr ? right : left; 537 | this.#applyPosition(e.clientX, this.#activeThumb.index, start, end); 538 | } else { 539 | const start = this.ltr ? bottom : top; 540 | const end = this.ltr ? top : bottom; 541 | this.#applyPosition(e.clientY, this.#activeThumb.index, start, end); 542 | } 543 | } 544 | } 545 | -------------------------------------------------------------------------------- /packages/builders/slider/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { Slider } from "./create.svelte.js"; 2 | export type { SliderProps, SliderDirection, SliderOrientation } from "./types.js"; 3 | -------------------------------------------------------------------------------- /packages/builders/slider/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReadableProp } from "@melt-ui/helpers"; 2 | 3 | export type SliderOrientation = "horizontal" | "vertical"; 4 | 5 | export type SliderDirection = "ltr" | "rtl"; 6 | 7 | export type SliderProps = { 8 | /** 9 | * The value of the slider. 10 | * 11 | * Pass in multiple values for multiple thumbs, 12 | * creating a range slider. 13 | * 14 | * @default [] 15 | */ 16 | value?: ReadableProp; 17 | 18 | /** 19 | * The minimum value of the slider. 20 | * 21 | * @default 0 22 | */ 23 | min?: ReadableProp; 24 | 25 | /** 26 | * The maximum value of the slider. 27 | * 28 | * @default 100 29 | */ 30 | max?: ReadableProp; 31 | 32 | /** 33 | * The amount to increment or decrement the value of the slider. 34 | * 35 | * @default 1 36 | */ 37 | step?: ReadableProp; 38 | 39 | /** 40 | * The orientation of the slider. 41 | * 42 | * @default 'horizontal' 43 | */ 44 | orientation?: ReadableProp; 45 | 46 | /** 47 | * The direction of the slider. 48 | * 49 | * For vertical sliders, setting `dir` to `rtl` 50 | * causes the slider to be start from the top. 51 | * 52 | * @default 'ltr' 53 | */ 54 | dir?: ReadableProp; 55 | 56 | /** 57 | * Whether or not the slider is disabled. 58 | * 59 | * @default false 60 | */ 61 | disabled?: ReadableProp; 62 | 63 | /** 64 | * Optionally override the default ids we assign to the root element. 65 | */ 66 | rootId?: ReadableProp; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/builders/slider/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 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 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/builders/slider/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "resolveJsonModule": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "strict": true, 10 | "sourceMap": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "skipLibCheck": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/builders/slider/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 | -------------------------------------------------------------------------------- /packages/builders/toggle/.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 | -------------------------------------------------------------------------------- /packages/builders/toggle/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/builders/toggle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@melt-ui/toggle", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "svelte": "./dist/index.js" 9 | } 10 | }, 11 | "types": "./dist/index.d.ts", 12 | "files": [ 13 | "!dist/**/*.spec.*", 14 | "!dist/**/*.test.*", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "dev": "pnpm sync && pnpm watch", 19 | "build": "pnpm run package", 20 | "package": "svelte-kit sync && svelte-package && publint", 21 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 22 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 23 | "lint": "eslint . --config ../../eslint.config.js", 24 | "lint:fix": "eslint . --fix --config ../../eslint.config.js", 25 | "watch": "svelte-kit sync && svelte-package --watch", 26 | "prepublishOnly": "pnpm run package", 27 | "sync": "svelte-kit sync" 28 | }, 29 | "peerDependencies": { 30 | "@melt-ui/helpers": "workspace:^", 31 | "svelte": "^4.0.0" 32 | }, 33 | "devDependencies": { 34 | "@sveltejs/adapter-auto": "^3.2.0", 35 | "@sveltejs/kit": "^2.5.5", 36 | "@sveltejs/package": "^2.3.1", 37 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 38 | "publint": "^0.2.7", 39 | "svelte": "5.0.0-next.95", 40 | "svelte-check": "^3.6.9", 41 | "tslib": "^2.6.2", 42 | "typescript": "^5.4.4", 43 | "vite": "^5.2.8" 44 | }, 45 | "svelte": "./dist/index.js" 46 | } 47 | -------------------------------------------------------------------------------- /packages/builders/toggle/src/lib/create.svelte.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ReadableBox, 3 | type WritableBox, 4 | booleanAttr, 5 | element, 6 | kbd, 7 | readableBox, 8 | writableBox, 9 | } from "@melt-ui/helpers"; 10 | import type { ToggleProps } from "./types.js"; 11 | 12 | export class Toggle { 13 | #pressedBox: WritableBox; 14 | #disabledBox: ReadableBox; 15 | 16 | constructor(props: ToggleProps = {}) { 17 | const { pressed = false, disabled = false } = props; 18 | this.#pressedBox = writableBox(pressed); 19 | this.#disabledBox = readableBox(disabled); 20 | } 21 | 22 | get pressed() { 23 | return this.#pressedBox.value; 24 | } 25 | 26 | set pressed(v: boolean) { 27 | this.#pressedBox.value = v; 28 | } 29 | 30 | get disabled() { 31 | return this.#disabledBox.value; 32 | } 33 | 34 | root() { 35 | const toggle = this; 36 | return element("toggle", { 37 | "type": "button", 38 | get "aria-pressed"() { 39 | return toggle.pressed; 40 | }, 41 | get "disabled"() { 42 | return booleanAttr(toggle.disabled); 43 | }, 44 | get "data-disabled"() { 45 | return booleanAttr(toggle.disabled); 46 | }, 47 | get "data-state"() { 48 | return toggle.pressed ? "on" : "off"; 49 | }, 50 | onclick() { 51 | if (toggle.disabled) { 52 | return; 53 | } 54 | toggle.pressed = !toggle.pressed; 55 | }, 56 | onkeydown(event) { 57 | if (event.key !== kbd.ENTER && event.key !== kbd.SPACE) { 58 | return; 59 | } 60 | event.preventDefault(); 61 | this.onclick(); 62 | }, 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/builders/toggle/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { Toggle } from "./create.svelte.js"; 2 | export type { ToggleProps } from "./types.js"; 3 | -------------------------------------------------------------------------------- /packages/builders/toggle/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReadableProp, WritableProp } from "@melt-ui/helpers"; 2 | 3 | export type ToggleProps = { 4 | /** 5 | * Whether or not the toggle is pressed. 6 | * 7 | * @default false 8 | */ 9 | pressed?: WritableProp; 10 | 11 | /** 12 | * Whether or not the toggle is disabled. 13 | * 14 | * @default false 15 | */ 16 | disabled?: ReadableProp; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/builders/toggle/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 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 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/builders/toggle/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "resolveJsonModule": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "strict": true, 10 | "sourceMap": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "skipLibCheck": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/builders/toggle/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 | -------------------------------------------------------------------------------- /packages/builders/tooltip/.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 | -------------------------------------------------------------------------------- /packages/builders/tooltip/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/builders/tooltip/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@melt-ui/tooltip", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "svelte": "./dist/index.js" 9 | } 10 | }, 11 | "types": "./dist/index.d.ts", 12 | "files": [ 13 | "!dist/**/*.spec.*", 14 | "!dist/**/*.test.*", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "dev": "pnpm sync && pnpm watch", 19 | "build": "pnpm run package", 20 | "package": "svelte-kit sync && svelte-package && publint", 21 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 22 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 23 | "lint": "eslint . --config ../../eslint.config.js", 24 | "lint:fix": "eslint . --fix --config ../../eslint.config.js", 25 | "watch": "svelte-kit sync && svelte-package --watch", 26 | "prepublishOnly": "pnpm run package", 27 | "sync": "svelte-kit sync" 28 | }, 29 | "peerDependencies": { 30 | "@melt-ui/helpers": "workspace:^", 31 | "svelte": "^4.0.0" 32 | }, 33 | "devDependencies": { 34 | "@sveltejs/adapter-auto": "^3.2.0", 35 | "@sveltejs/kit": "^2.5.5", 36 | "@sveltejs/package": "^2.3.1", 37 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 38 | "publint": "^0.2.7", 39 | "svelte": "5.0.0-next.95", 40 | "svelte-check": "^3.6.9", 41 | "tslib": "^2.6.2", 42 | "typescript": "^5.4.4", 43 | "vite": "^5.2.8" 44 | }, 45 | "svelte": "./dist/index.js" 46 | } 47 | -------------------------------------------------------------------------------- /packages/builders/tooltip/src/lib/create.svelte.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type FloatingConfig, 3 | type PortalTarget, 4 | type ReadableBox, 5 | type WritableBox, 6 | autoDestroyEffectRoot, 7 | booleanAttr, 8 | element, 9 | generateId, 10 | getPortalDestination, 11 | isTouch, 12 | kbd, 13 | makeHullFromElements, 14 | noop, 15 | pointInPolygon, 16 | portalAttr, 17 | readableBox, 18 | styleToString, 19 | useEventListener, 20 | useFloating, 21 | usePortal, 22 | writableBox, 23 | } from "@melt-ui/helpers"; 24 | import type { TooltipProps } from "./types.js"; 25 | 26 | // Store a global map to get the currently open tooltip in a given group. 27 | const openTooltips = new Map(); 28 | 29 | type OpenReason = "pointer" | "focus"; 30 | 31 | export class Tooltip { 32 | #openBox: WritableBox; 33 | #positioningBox: ReadableBox; 34 | #arrowSizeBox: ReadableBox; 35 | #openDelayBox: ReadableBox; 36 | #closeDelayBox: ReadableBox; 37 | #closeOnPointerDownBox: ReadableBox; 38 | #closeOnEscapeBox: ReadableBox; 39 | #forceVisibleBox: ReadableBox; 40 | #disableHoverableContentBox: ReadableBox; 41 | #groupBox: ReadableBox; 42 | #portalBox: ReadableBox; 43 | #triggerIdBox: ReadableBox; 44 | #contentIdBox: ReadableBox; 45 | 46 | constructor(props: TooltipProps = {}) { 47 | const { 48 | open = false, 49 | positioning = { placement: "bottom" }, 50 | arrowSize = 8, 51 | openDelay = 1000, 52 | closeDelay = 0, 53 | closeOnPointerDown = true, 54 | closeOnEscape = true, 55 | forceVisible = false, 56 | disableHoverableContent = false, 57 | group, 58 | portal, 59 | triggerId = generateId(), 60 | contentId = generateId(), 61 | } = props; 62 | 63 | this.#openBox = writableBox(open); 64 | this.#positioningBox = readableBox(positioning); 65 | this.#arrowSizeBox = readableBox(arrowSize); 66 | this.#openDelayBox = readableBox(openDelay); 67 | this.#closeDelayBox = readableBox(closeDelay); 68 | this.#closeOnPointerDownBox = readableBox(closeOnPointerDown); 69 | this.#closeOnEscapeBox = readableBox(closeOnEscape); 70 | this.#forceVisibleBox = readableBox(forceVisible); 71 | this.#disableHoverableContentBox = readableBox(disableHoverableContent); 72 | this.#groupBox = readableBox(group); 73 | this.#portalBox = readableBox(portal); 74 | this.#triggerIdBox = readableBox(triggerId); 75 | this.#contentIdBox = readableBox(contentId); 76 | } 77 | 78 | #openReason: OpenReason | null = null; 79 | #isMouseInTooltipArea = false; 80 | #clickedTrigger = false; 81 | #openTimeout: number | null = null; 82 | #closeTimeout: number | null = null; 83 | 84 | // States 85 | get open() { 86 | return this.#openBox.value; 87 | } 88 | 89 | set open(value) { 90 | this.#openBox.value = value; 91 | this.#clearOpenTimeout(); 92 | this.#clearCloseTimeout(); 93 | } 94 | 95 | get positioning() { 96 | return this.#positioningBox.value; 97 | } 98 | 99 | get arrowSize() { 100 | return this.#arrowSizeBox.value; 101 | } 102 | 103 | get openDelay() { 104 | return this.#openDelayBox.value; 105 | } 106 | 107 | get closeDelay() { 108 | return this.#closeDelayBox.value; 109 | } 110 | 111 | get closeOnPointerDown() { 112 | return this.#closeOnPointerDownBox.value; 113 | } 114 | 115 | get closeOnEscape() { 116 | return this.#closeOnEscapeBox.value; 117 | } 118 | 119 | get forceVisible() { 120 | return this.#forceVisibleBox.value; 121 | } 122 | 123 | get disableHoverableContent() { 124 | return this.#disableHoverableContentBox.value; 125 | } 126 | 127 | get group() { 128 | return this.#groupBox.value; 129 | } 130 | 131 | get portal() { 132 | return this.#portalBox.value; 133 | } 134 | 135 | get triggerId() { 136 | return this.#triggerIdBox.value; 137 | } 138 | 139 | get contentId() { 140 | return this.#contentIdBox.value; 141 | } 142 | 143 | get #hidden() { 144 | return !this.open && !this.forceVisible; 145 | } 146 | 147 | // Helpers 148 | #clearOpenTimeout() { 149 | if (this.#openTimeout === null) { 150 | return; 151 | } 152 | window.clearTimeout(this.#openTimeout); 153 | this.#openTimeout = null; 154 | } 155 | 156 | #clearCloseTimeout() { 157 | if (this.#closeTimeout === null) { 158 | return; 159 | } 160 | window.clearTimeout(this.#closeTimeout); 161 | this.#closeTimeout = null; 162 | } 163 | 164 | #open(reason: OpenReason) { 165 | this.#clearCloseTimeout(); 166 | 167 | if (this.#openTimeout !== null) { 168 | return; 169 | } 170 | 171 | this.#openTimeout = window.setTimeout(() => { 172 | this.open = true; 173 | // Don't override the reason if it's already set. 174 | this.#openReason ??= reason; 175 | }, this.openDelay); 176 | } 177 | 178 | #close(isBlur: boolean = false) { 179 | this.#clearOpenTimeout(); 180 | 181 | if (isBlur && this.#isMouseInTooltipArea) { 182 | // Normally when blurring the trigger, we want to close the tooltip. 183 | // The exception is when the mouse is still in the tooltip area. 184 | // In that case, we have to set the openReason to pointer, so that 185 | // it can close when the mouse leaves the tooltip area. 186 | this.#openReason = "pointer"; 187 | return; 188 | } 189 | 190 | if (this.#closeTimeout !== null) { 191 | return; 192 | } 193 | 194 | this.#closeTimeout = window.setTimeout(() => { 195 | this.open = false; 196 | this.#openReason = null; 197 | if (isBlur) { 198 | this.#clickedTrigger = false; 199 | } 200 | }, this.closeDelay); 201 | } 202 | 203 | // Elements 204 | trigger() { 205 | const tooltip = this; 206 | return element("tooltip-trigger", { 207 | get "id"() { 208 | return tooltip.triggerId; 209 | }, 210 | get "aria-describedby"() { 211 | return tooltip.contentId; 212 | }, 213 | onpointerdown() { 214 | if (tooltip.closeOnPointerDown) { 215 | tooltip.open = false; 216 | tooltip.#clickedTrigger = true; 217 | } 218 | }, 219 | onpointerenter(event) { 220 | if (isTouch(event)) { 221 | return; 222 | } 223 | tooltip.#open("pointer"); 224 | }, 225 | onpointerleave(event) { 226 | if (isTouch(event)) { 227 | return; 228 | } 229 | tooltip.#clearOpenTimeout(); 230 | }, 231 | onfocus() { 232 | if (tooltip.#clickedTrigger) { 233 | return; 234 | } 235 | tooltip.#open("focus"); 236 | }, 237 | onblur() { 238 | tooltip.#close(true); 239 | }, 240 | "onkeydown": tooltip.#handleKeyDown.bind(tooltip), 241 | }); 242 | } 243 | 244 | #handleKeyDown(event: KeyboardEvent) { 245 | if (this.closeOnEscape && event.key === kbd.ESCAPE) { 246 | this.open = false; 247 | } 248 | } 249 | 250 | content() { 251 | const tooltip = this; 252 | return element("tooltip-content", { 253 | "role": "tooltip", 254 | "tabindex": -1, 255 | get "id"() { 256 | return tooltip.contentId; 257 | }, 258 | get "hidden"() { 259 | return booleanAttr(tooltip.#hidden); 260 | }, 261 | get "style"() { 262 | return tooltip.#hidden ? "display: none;" : undefined; 263 | }, 264 | get "data-portal"() { 265 | return portalAttr(tooltip.portal); 266 | }, 267 | onpointerenter() { 268 | tooltip.#open("pointer"); 269 | }, 270 | onpointerdown() { 271 | tooltip.#open("pointer"); 272 | }, 273 | }); 274 | } 275 | 276 | arrow() { 277 | const tooltip = this; 278 | return element("tooltip-arrow", { 279 | "data-arrow": true, 280 | get "style"() { 281 | const size = `var(--arrow-size, ${tooltip.arrowSize}px)`; 282 | return styleToString({ 283 | position: "absolute", 284 | width: size, 285 | height: size, 286 | }); 287 | }, 288 | }); 289 | } 290 | 291 | // Effects 292 | readonly destroy = autoDestroyEffectRoot(() => { 293 | $effect(() => { 294 | const group = this.group; 295 | if (group === undefined || group === false || !this.open) { 296 | return; 297 | } 298 | 299 | // Close the currently open tooltip in the same group 300 | // and replace it with this one. 301 | const openTooltip = openTooltips.get(group); 302 | if (openTooltip !== undefined && openTooltip !== this) { 303 | openTooltip.open = false; 304 | } 305 | openTooltips.set(group, this); 306 | 307 | return () => { 308 | if (openTooltips.get(group) === this) { 309 | openTooltips.delete(group); 310 | } 311 | }; 312 | }); 313 | 314 | $effect(() => { 315 | if (!this.open) { 316 | return; 317 | } 318 | 319 | useEventListener(document, "mousemove", (event) => { 320 | const triggerEl = document.getElementById(this.triggerId); 321 | const contentEl = document.getElementById(this.contentId); 322 | if (triggerEl === null || contentEl === null) { 323 | return; 324 | } 325 | 326 | const polygonElements = this.disableHoverableContent ? [triggerEl] : [triggerEl, contentEl]; 327 | const polygon = makeHullFromElements(polygonElements); 328 | 329 | this.#isMouseInTooltipArea = pointInPolygon( 330 | { 331 | x: event.clientX, 332 | y: event.clientY, 333 | }, 334 | polygon, 335 | ); 336 | 337 | if (this.#openReason === "pointer" && !this.#isMouseInTooltipArea) { 338 | this.#close(); 339 | } 340 | }); 341 | }); 342 | 343 | useEventListener(document, "keydown", this.#handleKeyDown.bind(this)); 344 | 345 | let cleanupFloating = noop; 346 | let cleanupPortal = noop; 347 | 348 | $effect(() => { 349 | const triggerEl = document.getElementById(this.triggerId); 350 | const contentEl = document.getElementById(this.contentId); 351 | if (this.#hidden || triggerEl === null || contentEl === null) { 352 | cleanupFloating(); 353 | cleanupPortal(); 354 | cleanupFloating = cleanupPortal = noop; 355 | return; 356 | } 357 | 358 | const floatingReturn = useFloating(triggerEl, contentEl, this.positioning); 359 | cleanupFloating(); 360 | cleanupFloating = floatingReturn.destroy; 361 | 362 | if (this.portal === null) { 363 | cleanupPortal(); 364 | cleanupPortal = noop; 365 | return; 366 | } 367 | 368 | const portalDest = getPortalDestination(contentEl, this.portal); 369 | const portalReturn = usePortal(contentEl, portalDest); 370 | cleanupPortal = portalReturn.destroy; 371 | }); 372 | 373 | return () => { 374 | cleanupFloating(); 375 | cleanupPortal(); 376 | }; 377 | }); 378 | } 379 | -------------------------------------------------------------------------------- /packages/builders/tooltip/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { Tooltip } from "./create.svelte.js"; 2 | export type { TooltipProps } from "./types.js"; 3 | -------------------------------------------------------------------------------- /packages/builders/tooltip/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { FloatingConfig, PortalTarget, ReadableProp, WritableProp } from "@melt-ui/helpers"; 2 | 3 | export type TooltipProps = { 4 | /** 5 | * Whether or not the tooltip is open. 6 | * 7 | * @default false 8 | */ 9 | open?: WritableProp; 10 | 11 | /** 12 | * A configuration object which determines how the floating element 13 | * is positioned relative to the trigger. 14 | * 15 | * If `null`, the element won't use floating-ui. 16 | * 17 | * @default placement: "bottom" 18 | */ 19 | positioning?: ReadableProp; 20 | 21 | /** 22 | * The size of the arrow which points to the trigger in pixels. 23 | * 24 | * @default 8 25 | */ 26 | arrowSize?: ReadableProp; 27 | 28 | /** 29 | * The delay in milliseconds before the tooltip opens after a `pointerenter` event. 30 | * 31 | * @default 1000 32 | */ 33 | openDelay?: ReadableProp; 34 | 35 | /** 36 | * The delay in milliseconds before the tooltip closes after a `pointerleave` event. 37 | * 38 | * @default 0 39 | */ 40 | closeDelay?: ReadableProp; 41 | 42 | /** 43 | * Whether the tooltip closes when the pointer is down. 44 | * 45 | * @default true 46 | */ 47 | closeOnPointerDown?: ReadableProp; 48 | 49 | /** 50 | * Whether or not to close the tooltip when the escape key is pressed. 51 | * 52 | * @default true 53 | */ 54 | closeOnEscape?: ReadableProp; 55 | 56 | /** 57 | * Whether or not to force the tooltip to always be visible. 58 | * 59 | * This is useful for custom transitions and animations using conditional blocks. 60 | * 61 | * @default false 62 | */ 63 | forceVisible?: ReadableProp; 64 | 65 | /** 66 | * Prevents the tooltip content element from remaining open when hovered. 67 | * 68 | * If `true`, the tooltip will only be open when hovering the trigger element. 69 | * 70 | * @default false 71 | */ 72 | disableHoverableContent?: ReadableProp; 73 | 74 | /** 75 | * If set to `true` or a string, whenever you open this tooltip, 76 | * all other tooltips with the same `group` value will close. 77 | */ 78 | group?: ReadableProp; 79 | 80 | /** 81 | * If not `undefined`, the tooltip will be rendered within the provided element or selector. 82 | * 83 | * If `null`, the element won't portal. 84 | */ 85 | portal?: ReadableProp; 86 | 87 | /** 88 | * Optionally override the default id we assign to the trigger element. 89 | */ 90 | triggerId?: ReadableProp; 91 | 92 | /** 93 | * Optionally override the default id we assign to the content element. 94 | */ 95 | contentId?: ReadableProp; 96 | }; 97 | -------------------------------------------------------------------------------- /packages/builders/tooltip/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 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 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/builders/tooltip/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "resolveJsonModule": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "strict": true, 10 | "sourceMap": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "skipLibCheck": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/builders/tooltip/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 | -------------------------------------------------------------------------------- /packages/helpers/.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 | -------------------------------------------------------------------------------- /packages/helpers/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/helpers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@melt-ui/helpers", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "svelte": "./dist/index.js" 9 | } 10 | }, 11 | "types": "./dist/index.d.ts", 12 | "files": [ 13 | "!dist/**/*.spec.*", 14 | "!dist/**/*.test.*", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "dev": "pnpm sync && pnpm watch", 19 | "build": "pnpm run package", 20 | "package": "svelte-kit sync && svelte-package && publint", 21 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 22 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 23 | "lint": "eslint . --config ../../eslint.config.js", 24 | "lint:fix": "eslint . --fix --config ../../eslint.config.js", 25 | "watch": "svelte-kit sync && svelte-package --watch", 26 | "prepublishOnly": "pnpm run package", 27 | "sync": "svelte-kit sync" 28 | }, 29 | "peerDependencies": { 30 | "svelte": "^4.0.0" 31 | }, 32 | "dependencies": { 33 | "@floating-ui/core": "^1.6.0", 34 | "@floating-ui/dom": "^1.6.3", 35 | "csstype": "^3.1.3", 36 | "focus-trap": "^7.5.4", 37 | "nanoid": "^5.0.6" 38 | }, 39 | "devDependencies": { 40 | "@sveltejs/adapter-auto": "^3.0.0", 41 | "@sveltejs/kit": "^2.0.0", 42 | "@sveltejs/package": "^2.3.1", 43 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 44 | "publint": "^0.2.7", 45 | "svelte": "5.0.0-next.95", 46 | "svelte-check": "^3.6.0", 47 | "tslib": "^2.4.1", 48 | "typescript": "^5.4.4", 49 | "vite": "^5.2.8" 50 | }, 51 | "svelte": "./dist/index.js" 52 | } 53 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/attr.ts: -------------------------------------------------------------------------------- 1 | import type { PortalTarget } from "./use-portal.svelte.js"; 2 | 3 | /** 4 | * A helper for attributes that should be removed 5 | * if the value is `false`. 6 | */ 7 | export function booleanAttr(bool: boolean): true | undefined { 8 | return bool ? true : undefined; 9 | } 10 | 11 | export function portalAttr(portal: PortalTarget | null): "" | undefined { 12 | return portal !== null ? "" : undefined; 13 | } 14 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/box.svelte.ts: -------------------------------------------------------------------------------- 1 | export type ReadableBox = { readonly value: T }; 2 | export type WritableBox = { value: T }; 3 | 4 | export type Getter = () => T; 5 | export type Setter = (value: T) => void; 6 | 7 | export class DerivedBox implements ReadableBox { 8 | constructor( 9 | private readonly get: Getter, 10 | private readonly set?: Setter, 11 | ) {} 12 | 13 | get value() { 14 | return this.get(); 15 | } 16 | 17 | set value(v) { 18 | if (this.set === undefined) { 19 | throw new Error("Cannot set readonly value"); 20 | } 21 | this.set(v); 22 | } 23 | } 24 | 25 | export type ReadableProp = T | Getter; 26 | export type WritableProp = T | { get: Getter; set: Setter }; 27 | 28 | export type ReadableBoxConfig = { 29 | proxy?: boolean; 30 | }; 31 | 32 | export function readableBox( 33 | value: ReadableProp, 34 | { proxy = false }: ReadableBoxConfig = {}, 35 | ): ReadableBox { 36 | if (typeof value === "function") { 37 | return new DerivedBox(value as Getter); 38 | } 39 | 40 | if (proxy) { 41 | const boxed = $state({ value }); 42 | return boxed; 43 | } 44 | 45 | return { value }; 46 | } 47 | 48 | export function writableBox(value: WritableProp): WritableBox { 49 | if (value !== null && typeof value === "object" && "get" in value && "set" in value) { 50 | return new DerivedBox(value.get.bind(value), value.set.bind(value)); 51 | } 52 | const boxed = $state({ value }); 53 | return boxed; 54 | } 55 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/callbacks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A no operation function (does nothing) 3 | */ 4 | export function noop() { 5 | // 6 | } 7 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/element.ts: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes } from "svelte/elements"; 2 | import type { Prettify } from "./types.js"; 3 | 4 | export type HTMLElementAttributes = HTMLAttributes; 5 | 6 | export type HTMLElementEvent = TEvent & { 7 | currentTarget: HTMLElement; 8 | }; 9 | 10 | export function element( 11 | name: TName, 12 | props: TProps, 13 | ): Element; 14 | 15 | export function element(name: string, props: HTMLElementAttributes) { 16 | props[`data-melt-${name}`] = ""; 17 | return props; 18 | } 19 | 20 | export type Element = Prettify< 21 | Readonly & TProps> 22 | >; 23 | 24 | export type DataMeltProp = { 25 | [N in TName]: Record<`data-melt-${N}`, "">; 26 | }[TName]; 27 | 28 | export function dataMeltSelector(name: TName) { 29 | return `[data-melt-${name}]` as const; 30 | } 31 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/event.ts: -------------------------------------------------------------------------------- 1 | // Overloaded function signatures for addEventListener 2 | 3 | export function addEventListener( 4 | target: Window, 5 | event: TEvent, 6 | handler: (this: Window, event: WindowEventMap[TEvent]) => unknown, 7 | options?: boolean | AddEventListenerOptions, 8 | ): VoidFunction; 9 | 10 | export function addEventListener( 11 | target: Document, 12 | event: TEvent, 13 | handler: (this: Document, event: DocumentEventMap[TEvent]) => unknown, 14 | options?: boolean | AddEventListenerOptions, 15 | ): VoidFunction; 16 | 17 | export function addEventListener< 18 | TElement extends HTMLElement, 19 | TEvent extends keyof HTMLElementEventMap, 20 | >( 21 | target: TElement, 22 | event: TEvent, 23 | handler: (this: TElement, event: HTMLElementEventMap[TEvent]) => unknown, 24 | options?: boolean | AddEventListenerOptions, 25 | ): VoidFunction; 26 | 27 | export function addEventListener( 28 | target: EventTarget, 29 | event: string, 30 | handler: EventListenerOrEventListenerObject, 31 | options?: boolean | AddEventListenerOptions, 32 | ): VoidFunction; 33 | 34 | /** 35 | * Adds an event listener to the specified target element for the given event, 36 | * and returns a function to remove it. 37 | * 38 | * @param target The target element to add the event listener to. 39 | * @param event The event to listen for. 40 | * @param handler The function to be called when the event is triggered. 41 | * @param options An optional object that specifies characteristics about the event listener. 42 | * @returns A function that removes the event listener from the target element. 43 | */ 44 | export function addEventListener( 45 | target: EventTarget, 46 | event: string, 47 | handler: EventListenerOrEventListenerObject, 48 | options?: boolean | AddEventListenerOptions, 49 | ) { 50 | target.addEventListener(event, handler, options); 51 | return () => target.removeEventListener(event, handler, options); 52 | } 53 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/focus.ts: -------------------------------------------------------------------------------- 1 | import { tick } from "svelte"; 2 | import { isFunction, isHTMLElement } from "./is.js"; 3 | 4 | export type FocusTarget = string | HTMLElement | null | undefined; 5 | export type FocusProp = FocusTarget | ((defaultEl: HTMLElement | null) => FocusTarget); 6 | 7 | export type HandleFocusArgs = { 8 | prop: FocusProp; 9 | defaultEl: HTMLElement | null; 10 | }; 11 | 12 | export async function handleFocus(args: HandleFocusArgs): Promise { 13 | const { prop, defaultEl } = args; 14 | 15 | const returned = isFunction(prop) ? prop(defaultEl) : prop; 16 | if (returned === null) { 17 | return; 18 | } 19 | 20 | await tick(); 21 | 22 | if (returned === undefined) { 23 | defaultEl?.focus(); 24 | return; 25 | } 26 | 27 | const el = typeof returned === "string" ? document.querySelector(returned) : returned; 28 | if (isHTMLElement(el)) { 29 | el.focus(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/id.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid/non-secure"; 2 | 3 | /** 4 | * A function that generates a random id 5 | * @returns An id 6 | */ 7 | export function generateId(): string { 8 | return nanoid(10); 9 | } 10 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./attr.js"; 2 | export * from "./box.svelte.js"; 3 | export * from "./callbacks.js"; 4 | export * from "./element.js"; 5 | export * from "./event.js"; 6 | export * from "./focus.js"; 7 | export * from "./id.js"; 8 | export * from "./is.js"; 9 | export * from "./keyboard.js"; 10 | export * from "./lifecycle.svelte.js"; 11 | export * from "./math.js"; 12 | export * from "./platform.js"; 13 | export * from "./polygon/index.js"; 14 | export * from "./portal.js"; 15 | export * from "./scroll.js"; 16 | export * from "./start-stop.svelte.js"; 17 | export * from "./style.js"; 18 | export * from "./transition.js"; 19 | export * from "./types.js"; 20 | export * from "./use-escape-keydown.svelte.js"; 21 | export * from "./use-event-listener.svelte.js"; 22 | export * from "./use-floating.svelte.js"; 23 | export * from "./use-focus-trap.svelte.js"; 24 | export * from "./use-interact-outside.js"; 25 | export * from "./use-modal.svelte.js"; 26 | export * from "./use-portal.svelte.js"; 27 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/is.ts: -------------------------------------------------------------------------------- 1 | export function isFunction(value: unknown): value is Function { 2 | return typeof value === "function"; 3 | } 4 | 5 | export function isElement(element: unknown): element is Element { 6 | return element instanceof Element; 7 | } 8 | 9 | export function isHTMLElement(element: unknown): element is HTMLElement { 10 | return element instanceof HTMLElement; 11 | } 12 | 13 | export function isTouch(event: PointerEvent): boolean { 14 | return event.pointerType === "touch"; 15 | } 16 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/keyboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A constant object that maps commonly used keyboard keys to their corresponding string values. 3 | * This object can be used in other parts of the application to handle keyboard input and prevent 4 | * hard-coded strings throughout. 5 | */ 6 | export const kbd = { 7 | ALT: "Alt", 8 | ARROW_DOWN: "ArrowDown", 9 | ARROW_LEFT: "ArrowLeft", 10 | ARROW_RIGHT: "ArrowRight", 11 | ARROW_UP: "ArrowUp", 12 | BACKSPACE: "Backspace", 13 | CAPS_LOCK: "CapsLock", 14 | CONTROL: "Control", 15 | DELETE: "Delete", 16 | END: "End", 17 | ENTER: "Enter", 18 | ESCAPE: "Escape", 19 | F1: "F1", 20 | F10: "F10", 21 | F11: "F11", 22 | F12: "F12", 23 | F2: "F2", 24 | F3: "F3", 25 | F4: "F4", 26 | F5: "F5", 27 | F6: "F6", 28 | F7: "F7", 29 | F8: "F8", 30 | F9: "F9", 31 | HOME: "Home", 32 | META: "Meta", 33 | PAGE_DOWN: "PageDown", 34 | PAGE_UP: "PageUp", 35 | SHIFT: "Shift", 36 | SPACE: " ", 37 | TAB: "Tab", 38 | CTRL: "Control", 39 | ASTERISK: "*", 40 | A: "a", 41 | P: "p", 42 | }; 43 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/lifecycle.svelte.ts: -------------------------------------------------------------------------------- 1 | import { onDestroy } from "svelte"; 2 | 3 | /** 4 | * Behaves the same as `$effect.root`, but automatically 5 | * cleans up the effect inside Svelte components. 6 | * 7 | * @returns Cleanup function to manually cleanup the effect. 8 | */ 9 | export function autoDestroyEffectRoot(fn: () => void | VoidFunction) { 10 | let cleanup: VoidFunction | null = $effect.root(fn); 11 | 12 | function destroy() { 13 | if (cleanup === null) { 14 | return; 15 | } 16 | 17 | cleanup(); 18 | cleanup = null; 19 | } 20 | 21 | try { 22 | onDestroy(destroy); 23 | } catch { 24 | // Ignore the error. The user is responsible for manually 25 | // cleaning up builders created outside Svelte components. 26 | } 27 | 28 | return destroy; 29 | } 30 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/math.ts: -------------------------------------------------------------------------------- 1 | export function snapValueToStep(value: number, min: number, max: number, step: number): number { 2 | const remainder = (value - (Number.isNaN(min) ? 0 : min)) % step; 3 | let snappedValue 4 | = Math.abs(remainder) * 2 >= step 5 | ? value + Math.sign(remainder) * (step - Math.abs(remainder)) 6 | : value - remainder; 7 | 8 | if (!Number.isNaN(min)) { 9 | if (snappedValue < min) { 10 | snappedValue = min; 11 | } else if (!Number.isNaN(max) && snappedValue > max) { 12 | snappedValue = min + Math.floor((max - min) / step) * step; 13 | } 14 | } else if (!Number.isNaN(max) && snappedValue > max) { 15 | snappedValue = Math.floor(max / step) * step; 16 | } 17 | 18 | const string = step.toString(); 19 | const index = string.indexOf("."); 20 | const precision = index >= 0 ? string.length - index : 0; 21 | 22 | if (precision > 0) { 23 | const pow = 10 ** precision; 24 | snappedValue = Math.round(snappedValue * pow) / pow; 25 | } 26 | 27 | return snappedValue; 28 | } 29 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/platform.ts: -------------------------------------------------------------------------------- 1 | type NavigatorUAData = { 2 | platform: string; 3 | }; 4 | 5 | export function getPlatform() { 6 | if ("userAgentData" in navigator) { 7 | const { platform } = navigator.userAgentData as NavigatorUAData; 8 | return platform; 9 | } 10 | return navigator.platform; 11 | } 12 | 13 | function testPlatform(pattern: RegExp) { 14 | return pattern.test(getPlatform()); 15 | } 16 | 17 | function testUserAgent(pattern: RegExp) { 18 | return pattern.test(navigator.userAgent); 19 | } 20 | 21 | function testVendor(pattern: RegExp) { 22 | return pattern.test(navigator.vendor); 23 | } 24 | 25 | export function isTouchDevice() { 26 | return navigator.maxTouchPoints !== 0; 27 | } 28 | 29 | export function isMac() { 30 | return testPlatform(/^mac/i) && !isTouchDevice(); 31 | } 32 | 33 | export function isIPhone() { 34 | return testPlatform(/^iphone/i); 35 | } 36 | 37 | export function isSafari() { 38 | return isApple() && testVendor(/apple/i); 39 | } 40 | 41 | export function isFirefox() { 42 | return testUserAgent(/firefox\//i); 43 | } 44 | 45 | export function isApple() { 46 | return testPlatform(/mac|iphone|ipad|ipod/i); 47 | } 48 | 49 | export function isIos() { 50 | return isApple() && !isMac(); 51 | } 52 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/polygon/hull.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Convex hull algorithm - Library (TypeScript) 3 | * 4 | * Copyright (c) 2021 Project Nayuki 5 | * https://www.nayuki.io/page/convex-hull-algorithm 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Lesser General Public License as published by 9 | * the Free Software Foundation, either version 3 of the License, or 10 | * (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Lesser General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Lesser General Public License 18 | * along with this program (see COPYING.txt and COPYING.LESSER.txt). 19 | * If not, see . 20 | */ 21 | 22 | export type Point = { 23 | x: number; 24 | y: number; 25 | }; 26 | 27 | export type Polygon = Array; 28 | 29 | // Returns a new array of points representing the convex hull of 30 | // the given set of points. The convex hull excludes collinear points. 31 | // This algorithm runs in O(n log n) time. 32 | export function makeHull

(points: Readonly>): Array

{ 33 | const newPoints: Array

= points.slice(); 34 | newPoints.sort(POINT_COMPARATOR); 35 | return makeHullPresorted(newPoints); 36 | } 37 | 38 | // Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time. 39 | export function makeHullPresorted

(points: Readonly>): Array

{ 40 | if (points.length <= 1) { 41 | return points.slice(); 42 | } 43 | 44 | // Andrew's monotone chain algorithm. Positive y coordinates correspond to "up" 45 | // as per the mathematical convention, instead of "down" as per the computer 46 | // graphics convention. This doesn't affect the correctness of the result. 47 | 48 | const upperHull: Array

= []; 49 | for (let i = 0; i < points.length; i++) { 50 | const p: P = points[i]!; 51 | while (upperHull.length >= 2) { 52 | const q: P = upperHull[upperHull.length - 1]!; 53 | const r: P = upperHull[upperHull.length - 2]!; 54 | if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) { 55 | upperHull.pop(); 56 | } else { 57 | break; 58 | } 59 | } 60 | upperHull.push(p); 61 | } 62 | upperHull.pop(); 63 | 64 | const lowerHull: Array

= []; 65 | for (let i = points.length - 1; i >= 0; i--) { 66 | const p: P = points[i]!; 67 | while (lowerHull.length >= 2) { 68 | const q: P = lowerHull[lowerHull.length - 1]!; 69 | const r: P = lowerHull[lowerHull.length - 2]!; 70 | if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) { 71 | lowerHull.pop(); 72 | } else { 73 | break; 74 | } 75 | } 76 | lowerHull.push(p); 77 | } 78 | lowerHull.pop(); 79 | 80 | if ( 81 | upperHull.length === 1 82 | && lowerHull.length === 1 83 | && upperHull[0]!.x === lowerHull[0]!.x 84 | && upperHull[0]!.y === lowerHull[0]!.y 85 | ) { 86 | return upperHull; 87 | } else { 88 | return upperHull.concat(lowerHull); 89 | } 90 | } 91 | 92 | export function POINT_COMPARATOR(a: Point, b: Point): number { 93 | if (a.x < b.x) { 94 | return -1; 95 | } else if (a.x > b.x) { 96 | return +1; 97 | } else if (a.y < b.y) { 98 | return -1; 99 | } else if (a.y > b.y) { 100 | return +1; 101 | } else { 102 | return 0; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/polygon/index.ts: -------------------------------------------------------------------------------- 1 | import { type Point, type Polygon, makeHull } from "./hull.js"; 2 | 3 | export * from "./hull.js"; 4 | 5 | export function getPointsFromEl(el: HTMLElement): Array { 6 | const rect = el.getBoundingClientRect(); 7 | return [ 8 | { x: rect.left, y: rect.top }, 9 | { x: rect.right, y: rect.top }, 10 | { x: rect.right, y: rect.bottom }, 11 | { x: rect.left, y: rect.bottom }, 12 | ]; 13 | } 14 | 15 | export function makeHullFromElements(els: Array): Array { 16 | const points = els.flatMap((el) => getPointsFromEl(el)); 17 | return makeHull(points); 18 | } 19 | 20 | export function pointInPolygon(point: Point, polygon: Polygon) { 21 | let inside = false; 22 | for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { 23 | const xi = polygon[i]!.x; 24 | const yi = polygon[i]!.y; 25 | const xj = polygon[j]!.x; 26 | const yj = polygon[j]!.y; 27 | 28 | const intersect 29 | = (yi > point.y) !== (yj > point.y) && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi; 30 | 31 | if (intersect) { 32 | inside = !inside; 33 | } 34 | } 35 | return inside; 36 | } 37 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/portal.ts: -------------------------------------------------------------------------------- 1 | import type { PortalTarget } from "./use-portal.svelte.js"; 2 | import { isHTMLElement } from "./is.js"; 3 | 4 | /** 5 | * Get an element's ancestor which has a `data-portal` attribute. 6 | * 7 | * This is used to handle nested portals/overlays/dialogs/popovers. 8 | */ 9 | function getPortalParent(node: HTMLElement) { 10 | let parent = node.parentElement; 11 | while (isHTMLElement(parent) && !parent.hasAttribute("data-portal")) { 12 | parent = parent.parentElement; 13 | } 14 | 15 | return parent || document.body; 16 | } 17 | 18 | export function getPortalDestination(node: HTMLElement, portalProp: PortalTarget) { 19 | if (portalProp !== undefined) { 20 | return portalProp; 21 | } 22 | 23 | return getPortalParent(node); 24 | } 25 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/scroll.ts: -------------------------------------------------------------------------------- 1 | // Modified from @zag-js/remove-scroll v0.10.2 (2023-06-10) 2 | // Source: https://github.com/chakra-ui/zag 3 | // https://github.com/chakra-ui/zag/blob/main/packages/utilities/remove-scroll/src/index.ts 4 | 5 | import { noop } from "./callbacks.js"; 6 | import { isIos } from "./platform.js"; 7 | 8 | const DATA_LOCK_ATTR = "data-melt-scroll-lock"; 9 | 10 | function assignStyle(el: HTMLElement, style: Partial) { 11 | const previousStyle = el.style.cssText; 12 | Object.assign(el.style, style); 13 | return () => { 14 | el.style.cssText = previousStyle; 15 | }; 16 | } 17 | 18 | function setCSSProperty(el: HTMLElement, property: string, value: string) { 19 | const previousValue = el.style.getPropertyValue(property); 20 | el.style.setProperty(property, value); 21 | return () => { 22 | if (previousValue) { 23 | el.style.setProperty(property, previousValue); 24 | } else { 25 | el.style.removeProperty(property); 26 | } 27 | }; 28 | } 29 | 30 | function getPaddingProperty(documentElement: HTMLElement) { 31 | // RTL scrollbar 32 | const documentLeft = documentElement.getBoundingClientRect().left; 33 | const scrollbarX = Math.round(documentLeft) + documentElement.scrollLeft; 34 | return scrollbarX ? "paddingLeft" : "paddingRight"; 35 | } 36 | 37 | export function removeScroll(): () => void { 38 | const win = document.defaultView ?? window; 39 | const { documentElement, body } = document; 40 | 41 | const locked = body.hasAttribute(DATA_LOCK_ATTR); 42 | if (locked) { 43 | return noop; 44 | } 45 | 46 | body.setAttribute(DATA_LOCK_ATTR, ""); 47 | 48 | const scrollbarWidth = win.innerWidth - documentElement.clientWidth; 49 | const paddingProperty = getPaddingProperty(documentElement); 50 | const scrollbarSidePadding = win.getComputedStyle(body)[paddingProperty]; 51 | 52 | const restoreScrollbarWidth = setCSSProperty( 53 | documentElement, 54 | "--scrollbar-width", 55 | `${scrollbarWidth}px`, 56 | ); 57 | 58 | let restoreBodyStyle: () => void; 59 | if (isIos()) { 60 | // Only iOS doesn't respect `overflow: hidden` on document.body 61 | const { scrollX, scrollY, visualViewport } = win; 62 | 63 | // iOS 12 does not support `visualViewport`. 64 | const offsetLeft = visualViewport?.offsetLeft ?? 0; 65 | const offsetTop = visualViewport?.offsetTop ?? 0; 66 | 67 | const restoreStyle = assignStyle(body, { 68 | position: "fixed", 69 | overflow: "hidden", 70 | top: `${-(scrollY - Math.floor(offsetTop))}px`, 71 | left: `${-(scrollX - Math.floor(offsetLeft))}px`, 72 | right: "0", 73 | [paddingProperty]: `calc(${scrollbarSidePadding} + ${scrollbarWidth}px)`, 74 | }); 75 | 76 | restoreBodyStyle = () => { 77 | restoreStyle(); 78 | win.scrollTo(scrollX, scrollY); 79 | }; 80 | } else { 81 | restoreBodyStyle = assignStyle(body, { 82 | overflow: "hidden", 83 | [paddingProperty]: `calc(${scrollbarSidePadding} + ${scrollbarWidth}px)`, 84 | }); 85 | } 86 | 87 | return () => { 88 | restoreScrollbarWidth(); 89 | restoreBodyStyle(); 90 | body.removeAttribute(DATA_LOCK_ATTR); 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/start-stop.svelte.ts: -------------------------------------------------------------------------------- 1 | import { tick } from "svelte"; 2 | 3 | export type StartNotifier = (set: (value: TValue) => void) => VoidFunction; 4 | 5 | export class StartStop { 6 | #value = $state() as TValue; 7 | #start: StartNotifier; 8 | 9 | constructor(initialValue: TValue, start: StartNotifier) { 10 | this.#value = initialValue; 11 | this.#start = start; 12 | } 13 | 14 | #subscribers = 0; 15 | #stop: VoidFunction | null = null; 16 | 17 | get value(): TValue { 18 | if ($effect.active()) { 19 | $effect(() => { 20 | this.#subscribers++; 21 | if (this.#subscribers === 1) { 22 | this.#subscribe(); 23 | } 24 | 25 | return () => { 26 | tick().then(() => { 27 | this.#subscribers--; 28 | if (this.#subscribers === 0) { 29 | this.#unsubscribe(); 30 | } 31 | }); 32 | }; 33 | }); 34 | } 35 | 36 | return this.#value; 37 | } 38 | 39 | #subscribe() { 40 | this.#stop = this.#start((value) => { 41 | this.#value = value; 42 | }); 43 | } 44 | 45 | #unsubscribe() { 46 | if (this.#stop === null) { 47 | return; 48 | } 49 | 50 | this.#stop(); 51 | this.#stop = null; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/style.ts: -------------------------------------------------------------------------------- 1 | import type { PropertiesHyphen as StyleObject } from "csstype"; 2 | 3 | /** 4 | * A utility function that converts a style object to a string. 5 | * 6 | * @param style - The style object to convert 7 | * @returns The style object as a string 8 | */ 9 | export function styleToString(style: StyleObject): string { 10 | return Object.keys(style).reduce((str, key) => { 11 | const value = style[key as keyof StyleObject]; 12 | if (value === undefined) { 13 | return str; 14 | } 15 | return `${str}${key}:${value};`; 16 | }, ""); 17 | } 18 | 19 | export type { StyleObject }; 20 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/transition.ts: -------------------------------------------------------------------------------- 1 | import { addEventListener } from "./event.js"; 2 | 3 | /** 4 | * Runs the given function once after the `outrostart` event is dispatched. 5 | * 6 | * @returns A function that removes the event listener from the target element. 7 | */ 8 | export function afterTransitionOutStarts(target: EventTarget, fn: () => void) { 9 | return addEventListener(target, "outrostart", fn, { once: true }); 10 | } 11 | 12 | /** 13 | * Runs the given function once after the `outroend` event is dispatched. 14 | * 15 | * @returns A function that removes the event listener from the target element. 16 | */ 17 | export function afterTransitionOutEnds(target: EventTarget, fn: () => void) { 18 | return addEventListener(target, "outroend", fn, { once: true }); 19 | } 20 | 21 | /** 22 | * Runs the given function once after the `outroend` event is dispatched, 23 | * or immediately if the element is not transitioning out. 24 | */ 25 | export function runAfterTransitionOutOrImmediate(target: EventTarget, fn: () => void) { 26 | let transitioningOut = false; 27 | 28 | const cleanupListener = afterTransitionOutStarts(target, () => { 29 | transitioningOut = true; 30 | }); 31 | 32 | // Wait for the animation to begin. 33 | requestAnimationFrame(() => { 34 | if (transitioningOut) { 35 | afterTransitionOutEnds(target, fn); 36 | } else { 37 | cleanupListener(); 38 | fn(); 39 | } 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line ts/ban-types 2 | export type Prettify = { [K in keyof T]: T[K] } & {}; 3 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/use-escape-keydown.svelte.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, isHTMLElement } from "./is.js"; 2 | import { kbd } from "./keyboard.js"; 3 | import { StartStop } from "./start-stop.svelte.js"; 4 | 5 | export type EscapeKeydownConfig = { 6 | /** 7 | * Callback when user presses the escape key element. 8 | */ 9 | handler: (event: KeyboardEvent) => void; 10 | 11 | /** 12 | * A predicate function or a list of elements that should not trigger the event. 13 | */ 14 | ignore?: ((event: KeyboardEvent) => boolean) | Element[]; 15 | }; 16 | 17 | /** 18 | * Tracks the latest Escape keydown that occurred on the document. 19 | */ 20 | const documentEscapeKeyDown = new StartStop(null, (set) => { 21 | function handleKeyDown(event: KeyboardEvent) { 22 | if (event.key === kbd.ESCAPE) { 23 | set(event); 24 | } 25 | 26 | // Prevent new subscribers from triggering immediately. 27 | set(null); 28 | } 29 | 30 | document.addEventListener("keydown", handleKeyDown, { 31 | passive: false, 32 | }); 33 | 34 | return () => { 35 | document.removeEventListener("keydown", handleKeyDown); 36 | }; 37 | }); 38 | 39 | export function useEscapeKeydown(node: HTMLElement, config: EscapeKeydownConfig) { 40 | const { handler, ignore } = config; 41 | 42 | $effect(() => { 43 | const event = documentEscapeKeyDown.value; 44 | if (event === null) { 45 | return; 46 | } 47 | 48 | const target = event.target; 49 | if (!isHTMLElement(target) || target.closest("[data-escapee]") !== node) { 50 | return; 51 | } 52 | 53 | event.preventDefault(); 54 | 55 | // If an ignore function is passed, check if it returns true 56 | if (isFunction(ignore) && ignore(event)) { 57 | return; 58 | } 59 | 60 | // If an ignore array is passed, check if any elements in the array match the target 61 | if (Array.isArray(ignore) && ignore.includes(target)) { 62 | return; 63 | } 64 | 65 | // If none of the above conditions are met, call the handler 66 | handler(event); 67 | }); 68 | 69 | $effect(() => { 70 | node.dataset.escapee = ""; 71 | return () => { 72 | delete node.dataset.escapee; 73 | }; 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/use-event-listener.svelte.ts: -------------------------------------------------------------------------------- 1 | import { addEventListener } from "./event.js"; 2 | 3 | export function useEventListener( 4 | target: Window, 5 | event: TEvent, 6 | handler: (this: Window, event: WindowEventMap[TEvent]) => unknown, 7 | options?: boolean | AddEventListenerOptions, 8 | ): void; 9 | 10 | export function useEventListener( 11 | target: Document, 12 | event: TEvent, 13 | handler: (this: Document, event: DocumentEventMap[TEvent]) => unknown, 14 | options?: boolean | AddEventListenerOptions, 15 | ): void; 16 | 17 | export function useEventListener< 18 | TElement extends HTMLElement, 19 | TEvent extends keyof HTMLElementEventMap, 20 | >( 21 | target: TElement, 22 | event: TEvent, 23 | handler: (this: TElement, event: HTMLElementEventMap[TEvent]) => unknown, 24 | options?: boolean | AddEventListenerOptions, 25 | ): void; 26 | 27 | export function useEventListener( 28 | target: EventTarget, 29 | event: string, 30 | handler: EventListenerOrEventListenerObject, 31 | options?: boolean | AddEventListenerOptions, 32 | ): void; 33 | 34 | export function useEventListener( 35 | target: EventTarget, 36 | event: string, 37 | listener: EventListenerOrEventListenerObject, 38 | options?: boolean | AddEventListenerOptions, 39 | ) { 40 | $effect(() => { 41 | return addEventListener(target, event, listener, options); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/use-floating.svelte.ts: -------------------------------------------------------------------------------- 1 | // Modified from Grail UI v0.9.6 (2023-06-10) 2 | // Source: https://github.com/grail-ui/grail-ui 3 | // https://github.com/grail-ui/grail-ui/tree/master/packages/grail-ui/src/floating/placement.ts 4 | // https://github.com/grail-ui/grail-ui/tree/master/packages/grail-ui/src/floating/floating.types.ts 5 | 6 | import type { VirtualElement } from "@floating-ui/core"; 7 | import { 8 | type Boundary, 9 | type Middleware, 10 | arrow, 11 | autoUpdate, 12 | computePosition, 13 | flip, 14 | offset, 15 | shift, 16 | size, 17 | } from "@floating-ui/dom"; 18 | import { isHTMLElement } from "./is.js"; 19 | import { noop } from "./callbacks.js"; 20 | 21 | /** 22 | * The floating element configuration. 23 | * @see https://floating-ui.com/ 24 | */ 25 | export type FloatingConfig = { 26 | /** 27 | * The initial placement of the floating element. 28 | * @default "top" 29 | * 30 | * @see https://floating-ui.com/docs/computePosition#placement 31 | */ 32 | placement?: 33 | | "top" 34 | | "top-start" 35 | | "top-end" 36 | | "right" 37 | | "right-start" 38 | | "right-end" 39 | | "bottom" 40 | | "bottom-start" 41 | | "bottom-end" 42 | | "left" 43 | | "left-start" 44 | | "left-end"; 45 | 46 | /** 47 | * The strategy to use for positioning. 48 | * @default "absolute" 49 | * 50 | * @see https://floating-ui.com/docs/computePosition#placement 51 | */ 52 | strategy?: "absolute" | "fixed"; 53 | 54 | /** 55 | * The offset of the floating element. 56 | * 57 | * @see https://floating-ui.com/docs/offset#options 58 | */ 59 | offset?: { mainAxis?: number; crossAxis?: number }; 60 | 61 | /** 62 | * The main axis offset or gap between the reference and floating elements. 63 | * @default 5 64 | * 65 | * @see https://floating-ui.com/docs/offset#options 66 | */ 67 | gutter?: number; 68 | 69 | /** 70 | * The virtual padding around the viewport edges to check for overflow. 71 | * @default 8 72 | * 73 | * @see https://floating-ui.com/docs/detectOverflow#padding 74 | */ 75 | overflowPadding?: number; 76 | 77 | /** 78 | * Whether to flip the placement. 79 | * @default true 80 | * 81 | * @see https://floating-ui.com/docs/flip 82 | */ 83 | flip?: boolean; 84 | 85 | /** 86 | * Whether the floating element can overlap the reference element. 87 | * @default false 88 | * 89 | * @see https://floating-ui.com/docs/shift#options 90 | */ 91 | overlap?: boolean; 92 | 93 | /** 94 | * Whether to make the floating element same width as the reference element. 95 | * @default false 96 | * 97 | * @see https://floating-ui.com/docs/size 98 | */ 99 | sameWidth?: boolean; 100 | 101 | /** 102 | * Whether the floating element should fit the viewport. 103 | * @default false 104 | * 105 | * @see https://floating-ui.com/docs/size 106 | */ 107 | fitViewport?: boolean; 108 | 109 | /** 110 | * The overflow boundary of the reference element. 111 | * 112 | * @see https://floating-ui.com/docs/detectoverflow#boundary 113 | */ 114 | boundary?: Boundary; 115 | }; 116 | 117 | const ARROW_TRANSFORM = { 118 | bottom: "rotate(45deg)", 119 | left: "rotate(135deg)", 120 | top: "rotate(225deg)", 121 | right: "rotate(315deg)", 122 | }; 123 | 124 | export function useFloating( 125 | reference: HTMLElement | VirtualElement, 126 | floating: HTMLElement, 127 | config: FloatingConfig | null = {}, 128 | ) { 129 | if (config === null) { 130 | return { destroy: noop }; 131 | } 132 | 133 | const { 134 | placement = "top", 135 | strategy = "absolute", 136 | offset: floatingOffset, 137 | gutter = 5, 138 | overflowPadding = 8, 139 | flip: flipPlacement = true, 140 | overlap = false, 141 | sameWidth = false, 142 | fitViewport = false, 143 | boundary, 144 | } = config; 145 | 146 | const arrowEl = floating.querySelector("[data-arrow=true]"); 147 | const middleware: Middleware[] = []; 148 | 149 | if (flipPlacement) { 150 | middleware.push( 151 | flip({ 152 | boundary, 153 | padding: overflowPadding, 154 | }), 155 | ); 156 | } 157 | 158 | const arrowOffset = isHTMLElement(arrowEl) ? arrowEl.offsetHeight / 2 : 0; 159 | if (gutter || offset) { 160 | const data = gutter ? { mainAxis: gutter } : floatingOffset; 161 | if (data?.mainAxis != null) { 162 | data.mainAxis += arrowOffset; 163 | } 164 | 165 | middleware.push(offset(data)); 166 | } 167 | 168 | middleware.push( 169 | shift({ 170 | boundary, 171 | crossAxis: overlap, 172 | padding: overflowPadding, 173 | }), 174 | ); 175 | 176 | if (arrowEl) { 177 | middleware.push(arrow({ element: arrowEl, padding: 8 })); 178 | } 179 | 180 | middleware.push( 181 | size({ 182 | padding: overflowPadding, 183 | apply({ rects, availableHeight, availableWidth }) { 184 | if (sameWidth) { 185 | Object.assign(floating.style, { 186 | width: `${Math.round(rects.reference.width)}px`, 187 | minWidth: "unset", 188 | }); 189 | } 190 | 191 | if (fitViewport) { 192 | Object.assign(floating.style, { 193 | maxWidth: `${availableWidth}px`, 194 | maxHeight: `${availableHeight}px`, 195 | }); 196 | } 197 | }, 198 | }), 199 | ); 200 | 201 | function compute() { 202 | computePosition(reference, floating, { 203 | placement, 204 | middleware, 205 | strategy, 206 | }).then((data) => { 207 | const x = Math.round(data.x); 208 | const y = Math.round(data.y); 209 | 210 | Object.assign(floating.style, { 211 | position: strategy, 212 | top: `${y}px`, 213 | left: `${x}px`, 214 | }); 215 | 216 | if (isHTMLElement(arrowEl) && data.middlewareData.arrow) { 217 | const { x, y } = data.middlewareData.arrow; 218 | 219 | const dir = data.placement.split("-")[0] as "top" | "bottom" | "left" | "right"; 220 | 221 | Object.assign(arrowEl.style, { 222 | position: "absolute", 223 | left: x != null ? `${x}px` : "", 224 | top: y != null ? `${y}px` : "", 225 | [dir]: `calc(100% - ${arrowOffset}px)`, 226 | transform: ARROW_TRANSFORM[dir], 227 | backgroundColor: "inherit", 228 | zIndex: "inherit", 229 | }); 230 | } 231 | 232 | return data; 233 | }); 234 | } 235 | 236 | // Apply `position` to floating element prior to the computePosition() call. 237 | floating.style.position = strategy; 238 | 239 | const destroy = autoUpdate(reference, floating, compute); 240 | return { destroy }; 241 | } 242 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/use-focus-trap.svelte.ts: -------------------------------------------------------------------------------- 1 | import { type Options as FocusTrapOptions, createFocusTrap } from "focus-trap"; 2 | 3 | export function useFocusTrap(node: HTMLElement, options: FocusTrapOptions) { 4 | $effect(() => { 5 | const trap = createFocusTrap(node, options).activate(); 6 | return () => { 7 | trap.deactivate(); 8 | }; 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/use-interact-outside.ts: -------------------------------------------------------------------------------- 1 | import { isElement } from "./is.js"; 2 | import { useEventListener } from "./use-event-listener.svelte.js"; 3 | 4 | export type InteractOutsideConfig = { 5 | /** 6 | * Callback fired when an outside `pointerup` event completes. 7 | */ 8 | onInteractOutside?: (event: PointerEvent) => void; 9 | 10 | /** 11 | * Callback fired when an outside `pointerdown` event starts. 12 | * 13 | * This callback is useful when you want to know when the user 14 | * begins an outside interaction, but before the interaction 15 | * completes. 16 | */ 17 | onInteractOutsideStart?: (event: PointerEvent) => void; 18 | }; 19 | 20 | function isValidEvent(event: PointerEvent, node: HTMLElement): boolean { 21 | if (event.button > 0) { 22 | return false; 23 | } 24 | 25 | const target = event.target; 26 | if (!isElement(target)) { 27 | return false; 28 | } 29 | 30 | // if the target is no longer in the document (e.g. it was removed) ignore it 31 | const ownerDocument = target.ownerDocument; 32 | if (!ownerDocument || !ownerDocument.documentElement.contains(target)) { 33 | return false; 34 | } 35 | 36 | return !equalsOrContainsTarget(node, target); 37 | } 38 | 39 | function equalsOrContainsTarget(node: HTMLElement, target: Element) { 40 | return node === target || node.contains(target); 41 | } 42 | 43 | export function useInteractOutside(node: HTMLElement, config: InteractOutsideConfig) { 44 | const { onInteractOutside, onInteractOutsideStart } = config; 45 | 46 | let isPointerDown = false; 47 | let isPointerDownInside = false; 48 | 49 | function onPointerDown(event: PointerEvent) { 50 | if (onInteractOutside && isValidEvent(event, node)) { 51 | onInteractOutsideStart?.(event); 52 | } 53 | 54 | const target = event.target; 55 | if (isElement(target) && equalsOrContainsTarget(node, target)) { 56 | isPointerDownInside = true; 57 | } 58 | 59 | isPointerDown = true; 60 | } 61 | 62 | function onPointerUp(event: PointerEvent) { 63 | if (isPointerDown && !isPointerDownInside && isValidEvent(event, node)) { 64 | onInteractOutside?.(event); 65 | } 66 | 67 | isPointerDown = false; 68 | isPointerDownInside = false; 69 | } 70 | 71 | useEventListener(node.ownerDocument, "pointerdown", onPointerDown, true); 72 | useEventListener(node.ownerDocument, "pointerup", onPointerUp, true); 73 | } 74 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/use-modal.svelte.ts: -------------------------------------------------------------------------------- 1 | import { isElement } from "./is.js"; 2 | import { useInteractOutside } from "./use-interact-outside.js"; 3 | 4 | export type ModalConfig = { 5 | /** 6 | * Handler called when the overlay closes. 7 | */ 8 | onClose?: () => void; 9 | 10 | /** 11 | * Whether the modal is able to be closed by interacting outside of it. 12 | * If true, the `onClose` callback will be called when the user interacts 13 | * outside of the modal. 14 | * 15 | * @default true 16 | */ 17 | closeOnInteractOutside?: boolean; 18 | 19 | /** 20 | * If `closeOnInteractOutside` is `true` and this function is provided, 21 | * it will be called with the element that the outside interaction occurred 22 | * on. Whatever is returned from this function will determine whether the 23 | * modal actually closes or not. 24 | * 25 | * This is useful to filter out interactions with certain elements from 26 | * closing the modal. If `closeOnInteractOutside` is `false`, this function 27 | * will not be called. 28 | */ 29 | shouldCloseOnInteractOutside?: (event: PointerEvent) => boolean; 30 | }; 31 | 32 | const visibleModals: HTMLElement[] = []; 33 | 34 | function removeFromVisibleModals(node: HTMLElement) { 35 | const index = visibleModals.indexOf(node); 36 | if (index >= 0) { 37 | visibleModals.splice(index, 1); 38 | } 39 | } 40 | 41 | function isLastModal(node: HTMLElement) { 42 | return node === visibleModals.at(-1); 43 | } 44 | 45 | export function useModal(node: HTMLElement, config: ModalConfig) { 46 | const { onClose, closeOnInteractOutside = true, shouldCloseOnInteractOutside } = config; 47 | 48 | $effect(() => { 49 | visibleModals.push(node); 50 | return () => { 51 | removeFromVisibleModals(node); 52 | }; 53 | }); 54 | 55 | useInteractOutside(node, { 56 | onInteractOutsideStart(event) { 57 | const target = event.target; 58 | if (!isElement(target) || !isLastModal(node)) { 59 | return; 60 | } 61 | 62 | event.preventDefault(); 63 | event.stopPropagation(); 64 | event.stopImmediatePropagation(); 65 | }, 66 | onInteractOutside(event) { 67 | // We only want to call `onClose` if this is the topmost modal 68 | if (!closeOnInteractOutside || !isLastModal(node)) { 69 | return; 70 | } 71 | 72 | if (shouldCloseOnInteractOutside !== undefined && !shouldCloseOnInteractOutside(event)) { 73 | return; 74 | } 75 | 76 | event.preventDefault(); 77 | event.stopPropagation(); 78 | event.stopImmediatePropagation(); 79 | 80 | if (onClose !== undefined) { 81 | onClose(); 82 | visibleModals.pop(); 83 | } 84 | }, 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /packages/helpers/src/lib/use-portal.svelte.ts: -------------------------------------------------------------------------------- 1 | import { tick } from "svelte"; 2 | import type { ActionReturn } from "svelte/action"; 3 | import { isHTMLElement } from "./is.js"; 4 | 5 | export type PortalTarget = string | HTMLElement | undefined; 6 | 7 | // TODO: use `$effect` to automatically cleanup 8 | export function usePortal(el: HTMLElement, target?: PortalTarget) { 9 | async function run() { 10 | const targetEl = await getTargetEl(target); 11 | targetEl.appendChild(el); 12 | el.dataset.portal = ""; 13 | el.hidden = false; 14 | } 15 | 16 | run(); 17 | 18 | return { 19 | update(newTarget) { 20 | target = newTarget; 21 | run(); 22 | }, 23 | destroy() { 24 | el.remove(); 25 | }, 26 | } satisfies ActionReturn; 27 | } 28 | 29 | async function getTargetEl(target: PortalTarget) { 30 | if (target === undefined) { 31 | return document.body; 32 | } 33 | 34 | if (isHTMLElement(target)) { 35 | return target; 36 | } 37 | 38 | let targetEl = document.querySelector(target); 39 | if (targetEl !== null) { 40 | return targetEl; 41 | } 42 | 43 | await tick(); 44 | 45 | targetEl = document.querySelector(target); 46 | if (targetEl !== null) { 47 | return targetEl; 48 | } 49 | 50 | throw new Error(`No element found matching CSS selector: "${target}"`); 51 | } 52 | -------------------------------------------------------------------------------- /packages/helpers/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-auto"; 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 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/helpers/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "resolveJsonModule": true, 7 | "allowJs": true, 8 | "checkJs": true, 9 | "strict": true, 10 | "noUncheckedIndexedAccess": true, 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "skipLibCheck": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/helpers/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { svelte } from "@sveltejs/vite-plugin-svelte"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | plugins: [svelte()], 6 | }); 7 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/**/* 3 | - docs 4 | --------------------------------------------------------------------------------