├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── ci.yml │ ├── deploy.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .release-it.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── package.json ├── playwright-ct.config.ts ├── playwright ├── index.html └── index.ts ├── pnpm-lock.yaml ├── renovate.json ├── setupTest.ts ├── src ├── app.d.ts ├── app.html ├── lib │ ├── Modal.svelte │ ├── Modal.test.ts │ ├── ModalContainer.svelte │ ├── _Dummy.svelte │ ├── index.ts │ ├── modal.ts │ ├── service.test.ts │ ├── service.ts │ ├── style.css │ ├── testing.css │ ├── types.d.ts │ └── utils.ts └── routes │ ├── +layout.server.js │ ├── +page.svelte │ ├── BarComponent.svelte │ ├── FooComponent.svelte │ ├── SyntaxHighlight.svelte │ ├── app.css │ ├── assets │ ├── Crossedfingers.svg │ ├── Heartface.svg │ ├── Mainmatter.svg │ ├── Redheart.svg │ ├── Yellowheart.svg │ ├── fonts │ │ ├── CourierPrime-Bold.eot │ │ ├── CourierPrime-Bold.otf │ │ ├── CourierPrime-Bold.ttf │ │ ├── CourierPrime-Bold.woff │ │ ├── CourierPrime-Bold.woff2 │ │ ├── CourierPrime-Regular.eot │ │ ├── CourierPrime-Regular.otf │ │ ├── CourierPrime-Regular.ttf │ │ ├── CourierPrime-Regular.woff │ │ ├── CourierPrime-Regular.woff2 │ │ ├── LuckiestGuy.eot │ │ ├── LuckiestGuy.otf │ │ ├── LuckiestGuy.svg │ │ ├── LuckiestGuy.ttf │ │ ├── LuckiestGuy.woff │ │ └── LuckiestGuy.woff2 │ └── og-image.png │ └── prism-promisemodals.css ├── static ├── favicon.ico └── favicon.png ├── svelte.config.js ├── tests-ct ├── .gitkeep ├── OverlappingTestModal.svelte ├── TestApp.svelte ├── TestModal.svelte ├── Wrapper.svelte ├── basics.spec.js ├── focus-trap.spec.js ├── modal-context.spec.js └── overlapping-modals.spec.js ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.{diff,md}] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_BASE_URL= 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:svelte/recommended', 8 | 'plugin:prettier/recommended', 9 | ], 10 | plugins: ['@typescript-eslint', 'prettier', 'simple-import-sort'], 11 | ignorePatterns: ['*.cjs'], 12 | overrides: [ 13 | { 14 | files: ['*.svelte'], 15 | parser: 'svelte-eslint-parser', 16 | parserOptions: { 17 | parser: { 18 | ts: '@typescript-eslint/parser', 19 | js: 'espree', 20 | typescript: '@typescript-eslint/parser', 21 | }, 22 | }, 23 | }, 24 | ], 25 | settings: { 26 | ignoreWarnings: [], 27 | }, 28 | parserOptions: { 29 | sourceType: 'module', 30 | ecmaVersion: 2020, 31 | }, 32 | rules: { 33 | 'simple-import-sort/imports': 'error', 34 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 35 | '@typescript-eslint/indent': ['error', 2], 36 | 'prefer-const': 'off', 37 | 'no-undef': 'off', 38 | 39 | // TODO 40 | '@typescript-eslint/no-explicit-any': 'off', 41 | }, 42 | env: { 43 | browser: true, 44 | es2017: true, 45 | node: true, 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | 5 | env: 6 | NODE_VERSION: 20 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: wyvox/action-setup-pnpm@v3 16 | with: 17 | node-version: ${{ env.NODE_VERSION }} 18 | - name: Install Dependencies 19 | run: pnpm install 20 | - name: Run lint 21 | run: pnpm lint 22 | 23 | tests_e2e: 24 | name: Run end-to-end tests 25 | runs-on: ubuntu-latest 26 | needs: [lint] 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | # TODO: Fix tests for Svelte 5 31 | # svelte-version: ['4.2.12', '5.0.0'] 32 | svelte-version: ['4.2.12'] 33 | 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: wyvox/action-setup-pnpm@v3 37 | with: 38 | node-version: ${{ env.NODE_VERSION }} 39 | - name: Install Dependencies 40 | run: pnpm install 41 | - name: Install Svelte version ${{ matrix.svelte-version }} 42 | run: pnpm add -D svelte@${{ matrix.svelte-version }} 43 | - name: Install playwright browsers 44 | run: pnpm exec playwright install 45 | - name: Run tests 46 | run: pnpm test:ct 47 | 48 | tests_unit: 49 | name: Run unit tests 50 | runs-on: ubuntu-latest 51 | needs: [lint] 52 | strategy: 53 | fail-fast: false 54 | matrix: 55 | # TODO: Fix tests for Svelte 5 56 | # svelte-version: ['4.2.12', '5.0.0'] 57 | svelte-version: ['4.2.12'] 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | - uses: wyvox/action-setup-pnpm@v3 62 | with: 63 | node-version: ${{ env.NODE_VERSION }} 64 | - name: Install Dependencies 65 | run: pnpm install 66 | - name: Install Svelte version ${{ matrix.svelte-version }} 67 | run: pnpm add -D svelte@${{ matrix.svelte-version }} 68 | - name: Run unit tests 69 | run: pnpm test:unit 70 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | 9 | env: 10 | NODE_VERSION: 20 11 | 12 | jobs: 13 | build-and-deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 🛎️ 17 | uses: actions/checkout@v4.1.6 18 | 19 | - name: Setup 📦 20 | uses: wyvox/action-setup-pnpm@v3 21 | with: 22 | node-version: ${{ env.NODE_VERSION }} 23 | 24 | - name: Install and Build 🔧 25 | run: | 26 | pnpm install 27 | pnpm build 28 | env: 29 | PUBLIC_BASE_URL: https://svelte-promise-modals.com 30 | 31 | - name: Deploy 🚀 32 | uses: JamesIves/github-pages-deploy-action@v4.5.0 33 | with: 34 | branch: gh-pages 35 | folder: build 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | env: 9 | VOLTA_FEATURE_PNPM: 1 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: wyvox/action-setup-pnpm@v3 19 | with: 20 | node-version: 20 21 | 22 | - name: install dependencies 23 | run: pnpm install 24 | 25 | - name: build 26 | run: pnpm run build 27 | env: 28 | PUBLIC_BASE_URL: https://svelte-promise-modals.com 29 | 30 | - name: Set publishing config 31 | run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 34 | 35 | - name: publish to npm 36 | run: pnpm publish 37 | -------------------------------------------------------------------------------- /.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 | /test-results/ 13 | /playwright-report/ 14 | /playwright/.cache/ 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | 15 | # Auto-generated by lerna-changelog 16 | /CHANGELOG.md 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "printWidth": 100, 7 | "plugins": ["prettier-plugin-svelte"], 8 | "svelteSortOrder": "options-scripts-markup-styles", 9 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 10 | } 11 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "@release-it-plugins/lerna-changelog": { 4 | "infile": "CHANGELOG.md" 5 | } 6 | }, 7 | "git": { 8 | "commitMessage": "v${version}", 9 | "tagName": "v${version}" 10 | }, 11 | "github": { 12 | "release": true, 13 | "releaseName": "v${version}", 14 | "tokenRef": "GITHUB_AUTH" 15 | }, 16 | "npm": { 17 | "publish": false 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ## v0.1.5 (2025-01-31) 6 | 7 | #### :rocket: Enhancement 8 | * [#121](https://github.com/mainmatter/svelte-promise-modals/pull/121) Add Svelte 5 support ([@zeppelin](https://github.com/zeppelin)) 9 | 10 | #### :memo: Documentation 11 | * [#83](https://github.com/mainmatter/svelte-promise-modals/pull/83) Fix type import path in README ([@zeppelin](https://github.com/zeppelin)) 12 | 13 | #### Committers: 2 14 | - Gabor Babicz ([@zeppelin](https://github.com/zeppelin)) 15 | - Nora Burkhardt ([@yaigrie](https://github.com/yaigrie)) 16 | 17 | ## v0.1.4 (2024-02-16) 18 | 19 | #### :bug: Bug Fix 20 | * [#76](https://github.com/mainmatter/svelte-promise-modals/pull/76) Add missing `useModalContext` export ([@zeppelin](https://github.com/zeppelin)) 21 | 22 | #### Committers: 3 23 | - Gabor Babicz ([@zeppelin](https://github.com/zeppelin)) 24 | - Nora Burkhardt ([@yaigrie](https://github.com/yaigrie)) 25 | - Paolo Ricciuti ([@paoloricciuti](https://github.com/paoloricciuti)) 26 | 27 | ## v0.1.3 (2024-01-26) 28 | 29 | #### :memo: Documentation 30 | * [#65](https://github.com/mainmatter/svelte-promise-modals/pull/65) Make readme nicer ([@zeppelin](https://github.com/zeppelin)) 31 | 32 | #### Committers: 1 33 | - Gabor Babicz ([@zeppelin](https://github.com/zeppelin)) 34 | 35 | ## v0.1.2 (2024-01-26) 36 | 37 | ## v0.1.1 (2024-01-26) 38 | 39 | #### :bug: Bug Fix 40 | * [#64](https://github.com/mainmatter/svelte-promise-modals/pull/64) Fix pnpm release → npm publish ([@zeppelin](https://github.com/zeppelin)) 41 | 42 | #### Committers: 1 43 | - Gabor Babicz ([@zeppelin](https://github.com/zeppelin)) 44 | 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Promise Modals 2 | 3 | The better way to handle modals in your Svelte apps. Promised! 🤞 4 | 5 | > [!NOTE] 6 | > svelte-promise-modals was written and is maintained by [Mainmatter](https://mainmatter.com) and contributors. 7 | > We offer consulting, training, and team augmentation for Svelte & SvelteKit – check out our 8 | > [website](https://mainmatter.com/svelte-consulting/) to learn more! 9 | 10 | ## Compatibility 11 | 12 | - Svelte v3 or above 13 | - Node v16 or above 14 | 15 | ## Installation 16 | 17 | ``` 18 | npm install --save-dev svelte-promise-modals 19 | ``` 20 | 21 | ## Usage 22 | 23 | To use SPM in your project, add the target for the modals to your root template: 24 | 25 | ```svelte 26 | 27 | 31 | 32 | 33 | 34 | 35 | ``` 36 | 37 | Then you can import the `openModal` function wherever you need it and call with a component reference to 38 | render it as a modal. 39 | 40 | ```svelte 41 | 60 | 61 | 62 | ``` 63 | 64 | ### Passing data to the rendered component 65 | 66 | Passing data to the component rendered as a modal is done via props like so: 67 | 68 | ```js 69 | openModal(FilePreview, { 70 | fileUrl: 'http://example.com/some-file.pdf', 71 | }); 72 | ``` 73 | 74 | Each key of the object is just a regular prop: 75 | 76 | ```svelte 77 | 78 | 81 | 82 | 83 | ``` 84 | 85 | **NOTE:** By default, a `closeModal` function is passed in your rendered component, in order to trigger 86 | the "close modal" action. It can be called like so: 87 | 88 | ```svelte 89 | 90 | 93 | 94 | 95 | ``` 96 | 97 | ### TypeScript 98 | 99 | In order to make sure you don't pass something other to `closeModal` than expected, you can specify 100 | its value param type in your modal component using the `CloseModalFn` type, such as: 101 | 102 | ```svelte 103 | 104 | 112 | ``` 113 | 114 | Then when you open the modal, it'll correctly infer the type of the result: 115 | 116 | ```svelte 117 | 126 | ``` 127 | 128 | If you don't pass a type parameter to `CloseModalFn`, it means you won't be passing anything to 129 | `closeModal`, such as: 130 | 131 | ```typescript 132 | // This means you can only call `closeModal();` without params 133 | export let closeModal: CloseModalFn; 134 | ``` 135 | 136 | And the `result` will be `undefined`: 137 | 138 | ```typescript 139 | let result: undefined = await openModal(MyModal); 140 | ``` 141 | 142 | Last, but not least, you can omit `closeModal` entirely, but then you'll have to close the modal 143 | from when you opened it. 144 | 145 | ### Destroying the component 146 | 147 | It's worth noting that since modals are opened as a descendant of `ModalContainer`, and therefore 148 | likely placed at the root layout, when the component the modal was opened from gets destroyed, such 149 | as when navigating away from a route, the modal will continue to live on. To automatically destroy 150 | the modal in such cases, create a modal context first, then use its `openModal` function instead of 151 | the one exported from the package. Modal context's `openModal` function hooks into `onDestroy`, 152 | ensuring all modals opened from that specific component gets destroyed when the component is 153 | unrendered. 154 | 155 | ```svelte 156 | 166 | ``` 167 | 168 | ## Animation 169 | 170 | This addon uses CSS animations. You can either replace the [styles of this 171 | package](./src/lib/style.css) with your own or adjust the defaults using CSS custom properties in 172 | your `:root{}` declaration or in the CSS of any parent container of ``. 173 | 174 | Available properties and their defaults can be found in the `:root {}` block inside the package CSS. 175 | 176 | By default, the animations are dropped when `prefers-reduced-motion` is detected. 177 | 178 | ### Custom animations 179 | 180 | To override the animation for a specific modal, an `options` object containing 181 | a custom `className` can be handed to the `openModal()` method. 182 | 183 | ```js 184 | openModal( 185 | FilePreview, 186 | { 187 | fileUrl: 'http://example.com/some-file.pdf', 188 | }, 189 | { 190 | // custom class, see below for example 191 | className: 'custom-modal', 192 | // optional: a hook that is called when the closing animation of 193 | // the modal (so not the backdrop) has finished. 194 | onAnimationModalOutEnd: () => {}, 195 | } 196 | ); 197 | ``` 198 | 199 | ```css 200 | .custom-modal { 201 | animation: custom-animation-in 0.5s; 202 | opacity: 1; 203 | transform: translate(0, 0); 204 | } 205 | 206 | /* 207 | The `.spm-out` class is added to the parent of the modal when the modal 208 | should be closed, which triggers the animation 209 | */ 210 | .custom-modal.spm-out { 211 | animation: custom-animation-name-out 0.2s; /* default out animation is 2s */ 212 | opacity: 0; 213 | transform: translate(0, 100%); 214 | } 215 | 216 | /* 217 | animation name has to end in "-out" to be detected by the custom animationend 218 | event handler 219 | */ 220 | @keyframes custom-animation-name-out { 221 | 0% { 222 | opacity: 1; 223 | transform: translate(0, 0); 224 | } 225 | 100% { 226 | opacity: 0; 227 | transform: translate(0, 100%); 228 | } 229 | } 230 | ``` 231 | 232 | The CSS animations which are applied by the custom CSS class _must_ end in `-out` to make the 233 | animations trigger the modal removal. 234 | 235 | #### Examples 236 | 237 | Examples for custom animations and how to apply them can be found in the addons dummy application. 238 | 239 | See [index route](./src/routes/+page.svelte) for how the modals are openend in and look at 240 | [app.css](./src/routes/app.css) for the style definition of these custom animations. 241 | 242 | ### CSS Variables 243 | 244 | Use the below CSS variables to override the defaults: 245 | 246 | ```css 247 | --spm-animation-backdrop-in-duration; 248 | --spm-animation-backdrop-out-duration; 249 | --spm-animation-modal-in-duration; 250 | --spm-animation-modal-out-duration; 251 | --spm-animation-backdrop-in-delay; 252 | --spm-animation-backdrop-out-delay; 253 | --spm-animation-modal-in-delay; 254 | --spm-animation-modal-out-delay; 255 | --spm-animation-backdrop-in; 256 | --spm-animation-backdrop-out; 257 | --spm-animation-modal-in; 258 | --spm-animation-modal-out; 259 | --spm-backdrop-background; 260 | ``` 261 | 262 | ## Accessibility 263 | 264 | User can press the Esc key to close the modal. 265 | 266 | SPM uses [focus-trap](https://github.com/davidtheclark/focus-trap) internally to handle user focus. 267 | 268 | SPM will ensure to [focus the first "tabbable element" by default](https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal). 269 | If no focusable element is present, focus will be applied on the currently visible auto-generated 270 | container for the current modal. 271 | 272 | Focus Trap can be configured both on the `` component, and the individual modal 273 | level when calling `openModal()`. Global and local options are used in that order, which means that 274 | local config take precedence. 275 | 276 | To set global Focus Trap config that all modals inherit, provide the `focusTrapOptions` property to 277 | the `` component: 278 | 279 | ```svelte 280 | 287 | ``` 288 | 289 | Example for local Focus Trap option, when opening a specific modal: 290 | 291 | ```js 292 | openModal( 293 | FilePreview, 294 | { fileUrl: 'http://example.com/some-file.pdf' }, 295 | { 296 | focusTrapOptions: { 297 | clickOutsideDeactivates: false, 298 | }, 299 | } 300 | ); 301 | ``` 302 | 303 | To disable Focus Trap completely, set `focusTrapOptions` to `null` on the ``: 304 | 305 | ```svelte 306 | 311 | ``` 312 | 313 | Or when opening a modal: 314 | 315 | ```js 316 | openModal( 317 | FilePreview, 318 | { fileUrl: 'http://example.com/some-file.pdf' }, 319 | { 320 | focusTrapOptions: null, 321 | } 322 | ); 323 | ``` 324 | 325 | ⚠️ _We strongly advise against doing this. This will in most cases worsen the 326 | accessibility of modals for your users. Be very careful._ 327 | 328 | ## Testing 329 | 330 | In order to speed up modal in/out animations during testing, either: 331 | 332 | - Switch to reduced motion, for ex. in Playwright: 333 | `await page.emulateMedia({ reducedMotion: 'reduce' });` 334 | - Include the [testing.css](./src/lib/testing.css) into your app that will do the same thing 335 | 336 | ## Contributing 337 | 338 | Once you've cloned the project and installed dependencies with `pnpm install`, start a development server: 339 | 340 | ```bash 341 | npm run dev 342 | 343 | # or start the server and open the app in a new browser tab 344 | npm run dev -- --open 345 | ``` 346 | 347 | ## License 348 | 349 | svelte-promise-modals is developed by and © Mainmatter GmbH and contributors. It is released under 350 | the [MIT License](LICENSE.md). 351 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-promise-modals", 3 | "version": "0.1.5", 4 | "repository": "https://github.com/mainmatter/svelte-promise-modals", 5 | "scripts": { 6 | "prepare": "svelte-kit sync", 7 | "dev": "vite dev", 8 | "build": "vite build && npm run package", 9 | "preview": "vite preview", 10 | "package": "svelte-kit sync && svelte-package && publint", 11 | "release": "release-it", 12 | "prepublishOnly": "npm run package", 13 | "test:ct:debug": "PWDEBUG=1 playwright test -c playwright-ct.config.ts", 14 | "test:ct": "playwright test -c playwright-ct.config.ts", 15 | "changelog": "lerna-changelog", 16 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 17 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 18 | "test:unit": "vitest", 19 | "lint": "prettier --check . && eslint .", 20 | "format": "prettier --write . && eslint . --fix", 21 | "test:svelte4": "pnpm add -D svelte@4.2.12 && pnpm test:unit && pnpm test:ct", 22 | "test:svelte5": "pnpm add -D svelte@next && pnpm test:unit && pnpm test:ct", 23 | "test:all-versions": "pnpm test:svelte4 && pnpm test:svelte5" 24 | }, 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "svelte": "./dist/index.js" 29 | }, 30 | "./*.css": { 31 | "import": "./dist/*.css", 32 | "require": "./dist/*.css" 33 | } 34 | }, 35 | "files": [ 36 | "dist", 37 | "!dist/**/*.test.*", 38 | "!dist/**/_*.*" 39 | ], 40 | "engines": { 41 | "node": ">=16" 42 | }, 43 | "pnpm": { 44 | "packageManager": "pnpm@^9.7.0" 45 | }, 46 | "dependencies": { 47 | "deepmerge": "^4.3.0", 48 | "focus-trap": "^7.3.1" 49 | }, 50 | "peerDependencies": { 51 | "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" 52 | }, 53 | "devDependencies": { 54 | "@playwright/experimental-ct-svelte": "1.40.1", 55 | "@playwright/test": "1.40.1", 56 | "@release-it-plugins/lerna-changelog": "^6.1.0", 57 | "@sveltejs/adapter-auto": "^3.0.0", 58 | "@sveltejs/adapter-static": "^3.0.0", 59 | "@sveltejs/kit": "^2.0.0", 60 | "@sveltejs/package": "^2.0.0", 61 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 62 | "@testing-library/dom": "^9.0.0", 63 | "@testing-library/jest-dom": "^6.0.0", 64 | "@testing-library/svelte": "^5.2.6", 65 | "@testing-library/user-event": "^14.4.3", 66 | "@types/prismjs": "^1.26.3", 67 | "@types/testing-library__jest-dom": "^5.14.5", 68 | "@typescript-eslint/eslint-plugin": "^6.0.0", 69 | "@typescript-eslint/parser": "^6.0.0", 70 | "@vitest/coverage-c8": "^0.33.0", 71 | "dedent": "^1.5.1", 72 | "eslint": "^8.28.0", 73 | "eslint-config-prettier": "^9.0.0", 74 | "eslint-plugin-prettier": "^5.0.0", 75 | "eslint-plugin-simple-import-sort": "^12.0.0", 76 | "eslint-plugin-svelte": "^2.32.4", 77 | "jest": "^29.4.3", 78 | "jsdom": "^24.0.0", 79 | "prettier": "^3.0.0", 80 | "prettier-plugin-svelte": "^3.0.0", 81 | "prism-svelte": "^0.5.0", 82 | "prismjs": "^1.29.0", 83 | "publint": "^0.2.0", 84 | "release-it": "^17.0.1", 85 | "sinon": "^17.0.0", 86 | "svelte": "^4.0.5", 87 | "svelte-check": "^3.4.3", 88 | "tslib": "^2.4.1", 89 | "typescript": "^5.0.0", 90 | "vite": "^5.0.0", 91 | "vitest": "^1.0.0" 92 | }, 93 | "svelte": "./dist/index.js", 94 | "types": "./dist/index.d.ts", 95 | "type": "module" 96 | } 97 | -------------------------------------------------------------------------------- /playwright-ct.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | 3 | import { defineConfig, devices } from '@playwright/experimental-ct-svelte'; 4 | 5 | /** 6 | * See https://playwright.dev/docs/test-configuration. 7 | */ 8 | export default defineConfig({ 9 | testDir: './tests-ct/', 10 | /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */ 11 | snapshotDir: './__snapshots__', 12 | /* Maximum time one test can run for. */ 13 | timeout: 10 * 1000, 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'list', // or 'html', 'line', etc 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 27 | trace: 'on-first-retry', 28 | 29 | /* Port to use for Playwright component endpoint. */ 30 | ctPort: 3100, 31 | ctViteConfig: { 32 | resolve: { 33 | alias: { 34 | // Setup the built-in $lib alias in SvelteKit 35 | $lib: resolve('src/lib'), 36 | $app: resolve('src/lib'), 37 | }, 38 | }, 39 | }, 40 | }, 41 | 42 | /* Configure projects for major browsers */ 43 | projects: [ 44 | { 45 | name: 'chromium', 46 | use: { ...devices['Desktop Chrome'] }, 47 | }, 48 | // { 49 | // name: 'firefox', 50 | // use: { ...devices['Desktop Firefox'] }, 51 | // }, 52 | // { 53 | // name: 'webkit', 54 | // use: { ...devices['Desktop Safari'] }, 55 | // }, 56 | ], 57 | }); 58 | -------------------------------------------------------------------------------- /playwright/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Testing Page 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /playwright/index.ts: -------------------------------------------------------------------------------- 1 | // Import styles, initialize component theme here. 2 | // import '../src/common.css'; 3 | import '$lib/style.css'; 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"] 4 | } 5 | -------------------------------------------------------------------------------- /setupTest.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import '@testing-library/jest-dom'; 3 | import '@sveltejs/kit'; 4 | 5 | import * as matchers from '@testing-library/jest-dom/matchers'; 6 | import { expect, vi } from 'vitest'; 7 | 8 | import type * as environment from '$app/environment'; 9 | 10 | // Add custom jest matchers 11 | expect.extend(matchers); 12 | 13 | // Mock SvelteKit runtime module $app/environment 14 | vi.mock('$app/environment', (): typeof environment => ({ 15 | browser: false, 16 | dev: true, 17 | building: false, 18 | version: 'any', 19 | })); 20 | -------------------------------------------------------------------------------- /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 Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/Modal.svelte: -------------------------------------------------------------------------------- 1 | 130 | 131 | 133 | 134 | 142 | 143 | 153 | 154 | 182 | -------------------------------------------------------------------------------- /src/lib/Modal.test.ts: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/svelte'; 2 | import { afterEach, describe, expect, it } from 'vitest'; 3 | 4 | import _Dummy from './_Dummy.svelte'; 5 | import ModalContainer from './ModalContainer.svelte'; 6 | import { destroyModals, openModal } from './service'; 7 | 8 | describe('Modal', () => { 9 | afterEach(() => { 10 | destroyModals(); 11 | }); 12 | 13 | it('closeModal() should resolve only once', async () => { 14 | render(ModalContainer); 15 | 16 | let modal = openModal(_Dummy); 17 | let spy = vi.spyOn(modal, 'resolve'); 18 | 19 | await waitFor(() => { 20 | expect(screen.queryByTestId('backdrop')).toBeInTheDocument(); 21 | }); 22 | 23 | modal.close(); 24 | 25 | await waitFor(() => { 26 | expect(screen.queryByTestId('backdrop')).toHaveClass('spm-backdrop spm-out'); 27 | }); 28 | 29 | modal.close(); 30 | 31 | expect(spy).toHaveBeenCalledOnce(); 32 | }); 33 | 34 | it('passes the props to the component', async () => { 35 | render(ModalContainer); 36 | 37 | openModal(_Dummy, { foo: 'bar' }); 38 | 39 | await waitFor(() => { 40 | expect(screen.queryByTestId('foo-prop')).toHaveTextContent('bar'); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/lib/ModalContainer.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | {#each $stack as modal, _index} 30 | 31 | {/each} 32 | -------------------------------------------------------------------------------- /src/lib/_Dummy.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | {foo} 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ModalContainer } from './ModalContainer.svelte'; 2 | export { openModal, useModalContext } from './service'; 3 | export type { CloseModalFn } from './types'; 4 | -------------------------------------------------------------------------------- /src/lib/modal.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentProps, ComponentType, SvelteComponent } from 'svelte'; 2 | 3 | import type ModalComponent from './Modal.svelte'; 4 | import { removeFromStack } from './service'; 5 | import type { CloseModalFnValue, ModalOptions, PropsWithoutCloseModal } from './types'; 6 | import { defer, type Deferred } from './utils'; 7 | 8 | export class Modal { 9 | private deferred = defer>(); 10 | 11 | component: ComponentType; 12 | props: PropsWithoutCloseModal; 13 | options: ModalOptions; 14 | 15 | result?: CloseModalFnValue; 16 | deferredOutAnimation?: Deferred; 17 | componentInstance?: ModalComponent; 18 | 19 | constructor( 20 | component: ComponentType, 21 | props?: PropsWithoutCloseModal, 22 | options?: Partial 23 | ) { 24 | this.component = component; 25 | this.props = props ?? ({} as PropsWithoutCloseModal); 26 | this.options = { 27 | // eslint-disable-next-line @typescript-eslint/no-empty-function 28 | onAnimationModalOutEnd: (): void => {}, 29 | ...options, 30 | }; 31 | } 32 | 33 | resolve: ComponentProps['closeModal'] = (value: CloseModalFnValue): void => { 34 | if (this.deferredOutAnimation) { 35 | return; 36 | } 37 | 38 | this.deferredOutAnimation = defer(); 39 | if (this.options.onAnimationModalOutEnd) { 40 | this.deferredOutAnimation.promise 41 | .then(() => this.options.onAnimationModalOutEnd?.()) 42 | .catch(() => { 43 | // noop 44 | }); 45 | } 46 | 47 | this.result = value; 48 | this.deferred.resolve(value); 49 | }; 50 | 51 | destroy() { 52 | this.componentInstance?.destroyModal(); 53 | } 54 | 55 | close() { 56 | this.componentInstance?.closeModal(); 57 | } 58 | 59 | remove(): void { 60 | removeFromStack(this); 61 | this.deferredOutAnimation?.resolve(); 62 | } 63 | 64 | then( 65 | onFulfilled: (value: CloseModalFnValue) => void, 66 | onRejected?: (reason?: unknown) => void 67 | ): Promise> { 68 | return this.deferred.promise.then(onFulfilled, onRejected); 69 | } 70 | 71 | get isClosing(): boolean { 72 | return Boolean(this.deferredOutAnimation); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/service.test.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType, SvelteComponent } from 'svelte'; 2 | import { get } from 'svelte/store'; 3 | import { afterEach, describe, expect, it } from 'vitest'; 4 | 5 | import { count, openModal, stack, top } from './service'; 6 | 7 | describe('Service', () => { 8 | afterEach(() => { 9 | stack.set([]); 10 | }); 11 | 12 | // We don't need real components here, any object we can uniquely reference to will do 13 | let Component = {} as ComponentType void }>>; 14 | 15 | it('basics', () => { 16 | expect(get(count), '#count').toBe(0); 17 | expect(get(top), '#top').toBe(undefined); 18 | 19 | let modal1 = openModal(Component, { foo: 'bar' }); 20 | expect(get(count), '#count').toBe(1); 21 | expect(get(top), '#top').toBe(modal1); 22 | 23 | let modal2 = openModal(Component); 24 | expect(get(count), '#count').toBe(2); 25 | expect(get(top), '#top').toBe(modal2); 26 | 27 | modal2.remove(); 28 | expect(get(count), '#count').toBe(1); 29 | expect(get(top), '#top').toBe(modal1); 30 | 31 | modal1.remove(); 32 | expect(get(count), '#count').toBe(0); 33 | expect(get(top), '#top').toBe(undefined); 34 | }); 35 | 36 | it('modals can have results', () => { 37 | let modal = openModal(Component); 38 | expect(modal.result).toBe(undefined); 39 | 40 | modal.resolve('foo'); 41 | expect(modal.result).toBe('foo'); 42 | 43 | modal.remove(); 44 | }); 45 | 46 | it('modals are promises', async () => { 47 | let modal = openModal(Component); 48 | let steps: string[] = []; 49 | 50 | modal.then(() => { 51 | steps.push('then'); 52 | }); 53 | 54 | expect(steps).toMatchObject([]); 55 | 56 | modal.resolve('foo'); 57 | 58 | let result = await modal; 59 | 60 | expect(steps).toMatchObject(['then']); 61 | expect(result).toBe('foo'); 62 | }); 63 | 64 | it('modals do not show up in openCount when closing', () => { 65 | let modal = openModal(Component); 66 | 67 | expect(get(count)).toBe(1); 68 | 69 | modal.resolve(); 70 | 71 | expect(get(count)).toBe(0); 72 | 73 | modal.remove(); 74 | 75 | expect(get(count)).toBe(0); 76 | }); 77 | 78 | it('modals will call the optional onAnimationModalOutEnd hook when it is passed as an option', async () => { 79 | let steps = []; 80 | 81 | let modal = openModal( 82 | Component, 83 | {}, 84 | { 85 | onAnimationModalOutEnd: () => { 86 | steps.push('animation ended'); 87 | }, 88 | } 89 | ); 90 | 91 | steps.push('modal open'); 92 | 93 | modal.resolve(); 94 | steps.push('modal closing'); 95 | 96 | modal.remove(); 97 | steps.push('modal closed'); 98 | 99 | // we need to wait a tick for the closing animation promise to be resolved 100 | await new Promise((resolve) => { 101 | setTimeout(resolve, 0); 102 | }); 103 | 104 | expect(steps).toMatchObject(['modal open', 'modal closing', 'modal closed', 'animation ended']); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/lib/service.ts: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge'; 2 | import { type ComponentType, onDestroy, type SvelteComponent } from 'svelte'; 3 | import { derived, get, writable } from 'svelte/store'; 4 | 5 | import { Modal } from './modal'; 6 | import type { ModalOptions, PropsWithoutCloseModal } from './types'; 7 | 8 | export const animating = writable(false); 9 | export const stack = writable[]>([]); 10 | export const count = derived(stack, ($stack) => $stack.filter((modal) => !modal.isClosing).length); 11 | export const top = derived(stack, ($stack) => $stack.at(-1)); 12 | export const globalOptions = writable({ 13 | focusTrapOptions: { 14 | clickOutsideDeactivates: true, 15 | }, 16 | }); 17 | 18 | export { globalOptions as options }; 19 | 20 | export const updateOptions = (userOptions: Partial) => { 21 | if (userOptions && typeof userOptions === 'object') { 22 | globalOptions.update((defaultOptions) => { 23 | return deepmerge(defaultOptions, userOptions); 24 | }); 25 | } 26 | }; 27 | 28 | export const openModal = ( 29 | component: ComponentType, 30 | props?: PropsWithoutCloseModal | null, // `null` is a convenience for when you don't want to pass any props but do want to pass options 31 | options?: ModalOptions 32 | ): Modal => { 33 | let modal: Modal = new Modal(component, props ?? undefined, options); 34 | 35 | stack.update((modals) => [...modals, modal]); 36 | 37 | return modal; 38 | }; 39 | 40 | export function useModalContext() { 41 | let modals: Modal[] = []; 42 | 43 | onDestroy(() => { 44 | while (modals.length) { 45 | modals.pop()?.close(); 46 | } 47 | }); 48 | 49 | return { 50 | openModal( 51 | ...args: Parameters> 52 | ): ReturnType> { 53 | let modal = openModal(...args); 54 | 55 | modals.push(modal); 56 | 57 | modal.then((value) => { 58 | let modalIndex = modals.indexOf(modal); 59 | if (modalIndex > -1) { 60 | modals.splice(modalIndex, 1); 61 | } 62 | 63 | return value; 64 | }); 65 | 66 | return modal; 67 | }, 68 | }; 69 | } 70 | 71 | export const removeFromStack = (modal: unknown) => { 72 | stack.update(($stack) => $stack.filter((m) => m !== modal)); 73 | }; 74 | 75 | export function destroyModals() { 76 | get(stack).forEach((m) => m.destroy()); 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --spm-animation-backdrop-in-duration: 0.3s; 3 | --spm-animation-backdrop-out-duration: 0.18s; 4 | --spm-animation-modal-in-duration: 0.3s; 5 | --spm-animation-modal-out-duration: 0.18s; 6 | --spm-animation-backdrop-in-delay: 0s; 7 | --spm-animation-backdrop-out-delay: 0s; 8 | --spm-animation-modal-in-delay: 0s; 9 | --spm-animation-modal-out-delay: 0s; 10 | --spm-animation-backdrop-in: var(--spm-animation-backdrop-in-duration) ease 11 | var(--spm-animation-backdrop-in-delay) forwards spm-backdrop-in; 12 | --spm-animation-backdrop-out: var(--spm-animation-backdrop-out-duration) ease 13 | var(--spm-animation-backdrop-out-delay) forwards spm-backdrop-out; 14 | --spm-animation-modal-in: var(--spm-animation-modal-in-duration) ease-out 15 | var(--spm-animation-modal-in-delay) forwards spm-modal-in; 16 | --spm-animation-modal-out: var(--spm-animation-modal-out-duration) ease-out 17 | var(--spm-animation-modal-out-delay) forwards spm-modal-out; 18 | --spm-backdrop-background: #2d3748cd; 19 | } 20 | 21 | @media (prefers-reduced-motion: reduce) { 22 | :root { 23 | --spm-animation-backdrop-in-duration: 0.001s; 24 | --spm-animation-backdrop-out-duration: 0.001s; 25 | --spm-animation-modal-in-duration: 0.001s; 26 | --spm-animation-modal-out-duration: 0.001s; 27 | --spm-animation-backdrop-in-delay: 0.001s; 28 | --spm-animation-backdrop-out-delay: 0.001s; 29 | --spm-animation-modal-in-delay: 0.001s; 30 | --spm-animation-modal-out-delay: 0.001s; 31 | } 32 | } 33 | 34 | .spm-scrolling-disabled { 35 | overflow: hidden; 36 | } 37 | 38 | .spm-backdrop { 39 | background-color: var(--spm-backdrop-background); 40 | animation: var(--spm-animation-backdrop-in); 41 | animation-delay: var(--spm-animation-backdrop-in-delay); 42 | animation-duration: var(--spm-animation-backdrop-in-duration); 43 | } 44 | 45 | .spm-animating .spm-modal-container { 46 | overflow: unset; 47 | } 48 | 49 | .spm-modal { 50 | animation: var(--spm-animation-modal-in); 51 | animation-delay: var(--spm-animation-modal-in-delay); 52 | animation-duration: var(--spm-animation-modal-in-duration); 53 | } 54 | 55 | .spm-backdrop.spm-out { 56 | opacity: 1; 57 | animation: var(--spm-animation-backdrop-out); 58 | animation-delay: var(--spm-animation-backdrop-out-delay); 59 | animation-duration: var(--spm-animation-backdrop-out-duration); 60 | pointer-events: none; 61 | } 62 | 63 | .spm-modal.spm-out { 64 | transform: translate(0, 0) scale(1); 65 | opacity: 1; 66 | animation: var(--spm-animation-modal-out); 67 | animation-delay: var(--spm-animation-modal-out-delay); 68 | animation-duration: var(--spm-animation-modal-out-duration); 69 | pointer-events: none; 70 | } 71 | 72 | @keyframes spm-backdrop-in { 73 | 0% { 74 | opacity: 0; 75 | } 76 | 100% { 77 | opacity: 1; 78 | } 79 | } 80 | 81 | @keyframes spm-backdrop-out { 82 | 0% { 83 | opacity: 1; 84 | } 85 | 100% { 86 | opacity: 0; 87 | } 88 | } 89 | 90 | @keyframes spm-modal-in { 91 | 0% { 92 | transform: translate(0, -30vh) scale(1.1); 93 | opacity: 0; 94 | } 95 | 72% { 96 | transform: translate(0, 0) scale(0.99); 97 | opacity: 1; 98 | } 99 | 100% { 100 | transform: translate(0, 0) scale(1); 101 | opacity: 1; 102 | } 103 | } 104 | 105 | @keyframes spm-modal-out { 106 | 0% { 107 | transform: translate(0, 0) scale(1); 108 | opacity: 1; 109 | } 110 | 100% { 111 | transform: translate(0, -10vh) scale(0.8); 112 | opacity: 0; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/lib/testing.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --spm-animation-backdrop-in-duration: 0.001s; 3 | --spm-animation-backdrop-out-duration: 0.001s; 4 | --spm-animation-modal-in-duration: 0.001s; 5 | --spm-animation-modal-out-duration: 0.001s; 6 | --spm-animation-backdrop-in-delay: 0.001s; 7 | --spm-animation-backdrop-out-delay: 0.001s; 8 | --spm-animation-modal-in-delay: 0.001s; 9 | --spm-animation-modal-out-delay: 0.001s; 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Options as FocusTrapOptions } from 'focus-trap'; 2 | import type { ComponentProps, SvelteComponent } from 'svelte'; 3 | 4 | export type ModalOptions = { 5 | onAnimationModalOutEnd?(): void; 6 | focusTrapOptions?: FocusTrapOptions; 7 | className?: string; 8 | }; 9 | 10 | export type FocusTrapOptions = FocusTrapOptions; 11 | 12 | export type CloseModalFn = (value: T) => void; 13 | 14 | // prettier-ignore 15 | export type PropsWithoutCloseModal = Omit, 'closeModal'>; 16 | export type CloseModalValueParamType = T extends CloseModalFn ? Value : void; 17 | export type CloseModalFnValue< 18 | T, 19 | V = CloseModalValueParamType['closeModal']>, 20 | > = V extends void ? undefined : V; 21 | 22 | declare module 'focus-trap' { 23 | // FocusTrap will happily accept `null` or `false` for `onDeactivate` & `onPostDeactivate`, 24 | // despite what its type declaration is saying. 25 | // 26 | // https://github.com/focus-trap/focus-trap#trapdeactivate 27 | interface DeactivateOptions extends Pick { 28 | onDeactivate?: Options['onDeactivate'] | null | false; 29 | onPostDeactivate?: Options['onDeactivate'] | null | false; 30 | returnFocus?: boolean; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function defer() { 2 | return new Deferred(); 3 | } 4 | 5 | export class Deferred { 6 | resolve: (value: T | PromiseLike) => void = () => {}; 7 | reject: (reason?: any) => void = () => {}; 8 | promise: Promise; 9 | 10 | constructor() { 11 | this.promise = new Promise((resolve, reject) => { 12 | this.resolve = resolve; 13 | this.reject = reject; 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/+layout.server.js: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | Svelte Promise Modals 31 | 32 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 47 | 48 | 49 |
50 |
51 | An emoji showing crossed fingers 57 | svelte-promise-modals 58 |
59 |

60 | SveltePromiseModals 63 |

64 |
65 | 66 |
67 |
68 |

Modals in Svelte made easy. Promised.🤞

69 |
70 | 71 |
72 |

Example for the modal

73 | 80 |
81 | 82 | 83 | 86 | import { openModal } from 'svelte-promise-modals'; 87 | import FooComponent from './FooComponent.svelte'; 88 | 89 | async function openFooModal() { 90 | let result = await openModal(FooComponent); 91 | console.log(result); // Whatever the modal returned when it was closed 92 | } 93 | <` + 94 | dedent`/script> 95 | 96 | 99 | `} 100 | /> 101 | 102 | 103 |
104 |

Example for custom animations

105 | 108 | 111 |
112 | 113 |
114 |

115 | Code for the demonstrations shown here can be found in the demo application of the addon. 119 |

120 | 121 |

122 | See the README on GitHub for setup & further instructions. 127 |

128 |
129 | 130 |
131 | A red heart 132 |
133 | 134 |
135 |

136 | svelte-promise-modals is
made & sponsored with ❤️ by 137 | 138 | Mainmatter 139 | 140 |

141 |
142 | 143 |
144 | yellow heart 151 |
152 | 153 |
154 | smiley face surrounded by hearts 161 |
162 |
163 | 164 | 165 | -------------------------------------------------------------------------------- /src/routes/BarComponent.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | 20 | 41 | -------------------------------------------------------------------------------- /src/routes/FooComponent.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 53 | 54 | 75 | -------------------------------------------------------------------------------- /src/routes/SyntaxHighlight.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 |
{@html highlighted}
15 | 16 | 22 | -------------------------------------------------------------------------------- /src/routes/app.css: -------------------------------------------------------------------------------- 1 | /*** LOCAL FONTS **/ 2 | 3 | @font-face { 4 | font-family: 'LuckiestGuy'; 5 | src: url('./assets/fonts/LuckiestGuy.eot'); /* IE9 Compat Modes */ 6 | src: 7 | url('./assets/fonts/LuckiestGuy.eot?#iefix') format('embedded-opentype'), 8 | /* IE6-IE8 */ url('./assets/fonts/LuckiestGuy.woff2') format('woff2'), 9 | /* Super Modern Browsers */ url('./assets/fonts/LuckiestGuy.woff') format('woff'), 10 | /* Pretty Modern Browsers */ url('./assets/fonts/LuckiestGuy.ttf') format('truetype'); /* Safari, Android, iOS */ 11 | } 12 | 13 | @font-face { 14 | font-family: 'CourierPrime'; 15 | font-weight: 400; 16 | src: url('./assets/fonts/CourierPrime-Regular.eot'); /* IE9 Compat Modes */ 17 | src: 18 | url('./assets/fonts/CourierPrime-Regular.eot?#iefix') format('embedded-opentype'), 19 | /* IE6-IE8 */ url('./assets/fonts/CourierPrime-Regular.woff2') format('woff2'), 20 | /* Super Modern Browsers */ url('./assets/fonts/CourierPrime-Regular.woff') format('woff'), 21 | /* Pretty Modern Browsers */ url('./assets/fonts/CourierPrime-Regular.ttf') format('truetype'); /* Safari, Android, iOS */ 22 | } 23 | 24 | @font-face { 25 | font-family: 'CourierPrime'; 26 | font-weight: 800; 27 | src: url('./assets/fonts/CourierPrime-Bold.eot'); /* IE9 Compat Modes */ 28 | src: 29 | url('./assets/fonts/CourierPrime-Bold.eot?#iefix') format('embedded-opentype'), 30 | /* IE6-IE8 */ url('./assets/fonts/CourierPrime-Bold.woff2') format('woff2'), 31 | /* Super Modern Browsers */ url('./assets/fonts/CourierPrime-Bold.woff') format('woff'), 32 | /* Pretty Modern Browsers */ url('./assets/fonts/CourierPrime-Bold.ttf') format('truetype'); /* Safari, Android, iOS */ 33 | } 34 | 35 | :root { 36 | --primary-color: #ffcd4b; 37 | --secondary-color: #fb2752; 38 | --bg-color: #ff7676; 39 | --text-color: #0802a3; 40 | --font-default: 'CourierPrime'; 41 | --font-decorative: 'LuckiestGuy'; 42 | } 43 | 44 | /* Landing Page Styling */ 45 | 46 | html, 47 | body, 48 | * { 49 | margin: 0; 50 | padding: 0; 51 | box-sizing: border-box; 52 | } 53 | 54 | body { 55 | background-color: var(--bg-color); 56 | } 57 | 58 | header, 59 | main { 60 | max-width: 800px; 61 | margin: 0 auto; 62 | } 63 | 64 | @media (max-width: 840px) { 65 | header, 66 | main { 67 | max-width: 100%; 68 | padding: 0 5%; 69 | } 70 | } 71 | 72 | h1 { 73 | display: flex; 74 | flex-direction: column; 75 | gap: 0; 76 | font-family: var(--font-decorative); 77 | font-size: 8rem; 78 | font-weight: 400; 79 | color: var(--primary-color); 80 | text-align: center; 81 | } 82 | 83 | h1 .svelte { 84 | font-family: var(--font-default); 85 | font-size: 6rem; 86 | color: var(--secondary-color); 87 | text-transform: lowercase; 88 | transform: translateX(-5rem); 89 | } 90 | 91 | h1 .translatePos { 92 | transform: translateX(2.5rem); 93 | } 94 | 95 | h1 .translateNeg { 96 | transform: translateX(-2.5rem); 97 | } 98 | 99 | @media (max-width: 600px) { 100 | h1 { 101 | font-size: 4rem; 102 | } 103 | 104 | h1 .svelte { 105 | font-size: 3rem; 106 | transform: translateX(-1rem); 107 | } 108 | 109 | h1 .translatePos { 110 | transform: translateX(0.5rem); 111 | } 112 | 113 | h1 .translateNeg { 114 | transform: translateX(-0.5rem); 115 | } 116 | } 117 | 118 | h2 { 119 | font-family: var(--font-default); 120 | color: var(--text-color); 121 | text-align: center; 122 | font-weight: 400; 123 | font-size: 2rem; 124 | margin-bottom: 1rem; 125 | } 126 | 127 | @media (max-width: 600px) { 128 | h2 { 129 | font-size: 1.75rem; 130 | } 131 | } 132 | 133 | p { 134 | font-family: var(--font-default); 135 | color: var(--text-color); 136 | text-align: center; 137 | font-size: 1.25rem; 138 | line-height: 1.75rem; 139 | } 140 | 141 | main p { 142 | max-width: 45ch; 143 | } 144 | 145 | a { 146 | font-family: var(--font-default); 147 | color: var(--primary-color); 148 | font-weight: 800; 149 | } 150 | 151 | a:hover { 152 | color: var(--secondary-color); 153 | } 154 | 155 | a:has(h1) { 156 | text-decoration: none; 157 | } 158 | 159 | img { 160 | display: block; 161 | max-width: 100%; 162 | height: auto; 163 | margin: auto; 164 | } 165 | 166 | img[role='presentation'] { 167 | pointer-events: none; 168 | } 169 | 170 | [type='button'] { 171 | font-family: var(--font-default); 172 | background-color: var(--primary-color); 173 | color: var(--secondary-color); 174 | outline: 2px solid transparent; 175 | font-size: 1rem; 176 | outline: 0; 177 | border: none; 178 | padding: 1.25rem 3rem; 179 | margin: 1.5rem auto; 180 | border-radius: 2.5rem; 181 | text-align: center; 182 | cursor: pointer; 183 | } 184 | 185 | .mt-0 { 186 | margin: 0 auto 1.5rem; 187 | } 188 | 189 | [type='button']:hover { 190 | outline: var(--secondary-color) 2px solid; 191 | } 192 | 193 | [type='button']:focus { 194 | outline: var(--secondary-color) 2px solid; 195 | } 196 | 197 | [type='button']:active { 198 | outline: var(--secondary-color) 2px solid; 199 | background-color: var(--secondary-color); 200 | color: var(--primary-color); 201 | } 202 | 203 | .preview, 204 | .note { 205 | display: flex; 206 | flex-direction: column; 207 | align-items: center; 208 | margin-top: 6rem; 209 | } 210 | 211 | .note p { 212 | margin-bottom: 2rem; 213 | } 214 | 215 | .whitespace-nowrap { 216 | white-space: nowrap; 217 | } 218 | 219 | #crossedfingers, 220 | #redheart { 221 | max-width: 400px; 222 | height: auto; 223 | } 224 | 225 | @media (max-width: 600px) { 226 | #crossedfingers, 227 | #redheart { 228 | max-width: 240px; 229 | height: auto; 230 | } 231 | } 232 | 233 | #crossedfingers { 234 | transform: rotate(-5deg); 235 | margin-top: 6rem; 236 | } 237 | 238 | #mainmatter { 239 | margin-top: 0.5rem; 240 | } 241 | 242 | .floating { 243 | position: absolute; 244 | } 245 | 246 | #heartface { 247 | top: 900px; 248 | left: 3%; 249 | } 250 | 251 | @media (max-width: 860px) { 252 | #heartface { 253 | display: none; 254 | } 255 | } 256 | 257 | #yellowheart { 258 | top: 540px; 259 | right: 4%; 260 | } 261 | 262 | @media (max-width: 980px) { 263 | #yellowheart { 264 | display: none; 265 | } 266 | } 267 | 268 | .visually-hidden { 269 | position: absolute !important; 270 | width: 1px !important; 271 | height: 1px !important; 272 | padding: 0 !important; 273 | margin: -1px !important; 274 | overflow: hidden !important; 275 | clip: rect(0, 0, 0, 0) !important; 276 | white-space: nowrap !important; 277 | border: 0 !important; 278 | } 279 | 280 | /* === Basic styling for the demo modal */ 281 | 282 | .spm-modal { 283 | /* 284 | Inset so modals look like modals on mobile. 285 | */ 286 | padding: 0.5rem; 287 | } 288 | 289 | /* === Variation 1: Custom animation which animates the modal in from the bottom and back */ 290 | 291 | /* this className will be added to the modal when the modal should be animated in */ 292 | .from-bottom { 293 | opacity: 1; 294 | transform: translate(0, 0); 295 | animation: var(--spm-animation-modal-in-duration) ease-out var(--spm-animation-modal-in-delay) 296 | forwards from-bottom-in; 297 | } 298 | 299 | /* this className will be added to the modal when the modal should be animated out */ 300 | .from-bottom.spm-out { 301 | opacity: 0; 302 | transform: translate(0, calc(50vh + 50%)); 303 | animation: var(--spm-animation-modal-out-duration) ease-in var(--spm-animation-modal-out-delay) 304 | forwards from-bottom-out; 305 | } 306 | 307 | /* these keyframes describe the animation */ 308 | 309 | @keyframes from-bottom-in { 310 | 0% { 311 | opacity: 0; 312 | transform: translate(0, calc(50vh + 50%)); 313 | } 314 | 100% { 315 | opacity: 1; 316 | transform: translate(0, 0); 317 | } 318 | } 319 | 320 | @keyframes from-bottom-out { 321 | 0% { 322 | opacity: 1; 323 | transform: translate(0, 0); 324 | } 325 | 100% { 326 | opacity: 0; 327 | transform: translate(0, calc(50vh + 50%)); 328 | } 329 | } 330 | 331 | /* === Variation 2: Custom animation which animates the modal in from the top and back */ 332 | 333 | /* this className will be added to the modal when the modal should be animated in */ 334 | .from-top { 335 | opacity: 1; 336 | transform: translate(0, 0); 337 | animation: var(--spm-animation-modal-in-duration) ease-out var(--spm-animation-modal-in-delay) 338 | forwards from-top-in; 339 | } 340 | 341 | /* this className will be added to the modal when the modal should be animated out */ 342 | .from-top.spm-out { 343 | opacity: 0; 344 | transform: translate(0, calc(-50vh - 50%)); 345 | animation: var(--spm-animation-modal-out-duration) ease-in var(--spm-animation-modal-out-delay) 346 | forwards from-top-out; 347 | } 348 | 349 | @keyframes from-top-in { 350 | 0% { 351 | opacity: 0; 352 | transform: translate(0, calc(-50vh - 50%)); 353 | } 354 | 100% { 355 | opacity: 1; 356 | transform: translate(0, 0); 357 | } 358 | } 359 | 360 | @keyframes from-top-out { 361 | 0% { 362 | opacity: 1; 363 | transform: translate(0, 0); 364 | } 365 | 100% { 366 | opacity: 0; 367 | transform: translate(0, calc(-50vh - 50%)); 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/routes/assets/Crossedfingers.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | -------------------------------------------------------------------------------- /src/routes/assets/Mainmatter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Bold.eot -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Bold.otf -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Bold.ttf -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Bold.woff -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Bold.woff2 -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Regular.eot -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Regular.otf -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Regular.ttf -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Regular.woff -------------------------------------------------------------------------------- /src/routes/assets/fonts/CourierPrime-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/CourierPrime-Regular.woff2 -------------------------------------------------------------------------------- /src/routes/assets/fonts/LuckiestGuy.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/LuckiestGuy.eot -------------------------------------------------------------------------------- /src/routes/assets/fonts/LuckiestGuy.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/LuckiestGuy.otf -------------------------------------------------------------------------------- /src/routes/assets/fonts/LuckiestGuy.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/LuckiestGuy.ttf -------------------------------------------------------------------------------- /src/routes/assets/fonts/LuckiestGuy.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/LuckiestGuy.woff -------------------------------------------------------------------------------- /src/routes/assets/fonts/LuckiestGuy.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/fonts/LuckiestGuy.woff2 -------------------------------------------------------------------------------- /src/routes/assets/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/src/routes/assets/og-image.png -------------------------------------------------------------------------------- /src/routes/prism-promisemodals.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Synthwave '84 Theme originally by Robb Owen [@Robb0wen] for Visual Studio Code 3 | * Demo: https://marc.dev/demo/prism-synthwave84 4 | * 5 | * Ported for PrismJS by Marc Backes [@themarcba] 6 | * Adapted to use for Svelte Promise Modals 7 | */ 8 | 9 | code[class*='language-'], 10 | pre[class*='language-'] { 11 | color: #fb2752; 12 | background: none; 13 | font-family: 'CourierPrime', monospace; 14 | font-size: 1em; 15 | text-align: left; 16 | white-space: pre; 17 | word-spacing: normal; 18 | word-break: normal; 19 | word-wrap: normal; 20 | line-height: 1.5; 21 | 22 | -moz-tab-size: 4; 23 | -o-tab-size: 4; 24 | tab-size: 4; 25 | 26 | -webkit-hyphens: none; 27 | -moz-hyphens: none; 28 | -ms-hyphens: none; 29 | hyphens: none; 30 | } 31 | 32 | /* Code blocks */ 33 | pre[class*='language-'] { 34 | padding: 2em; 35 | margin: 2em 0; 36 | overflow: auto; 37 | } 38 | 39 | :not(pre) > code[class*='language-'], 40 | pre[class*='language-'] { 41 | background-color: #03014d; 42 | } 43 | 44 | /* Inline code */ 45 | :not(pre) > code[class*='language-'] { 46 | padding: 0.1em; 47 | border-radius: 0.5em; 48 | white-space: normal; 49 | } 50 | 51 | .token.prolog, 52 | .token.doctype, 53 | .token.cdata, 54 | .token.punctuation, 55 | .token.symbol, 56 | .token.operator, 57 | .token.boolean, 58 | .token.class-name, 59 | .token.important, 60 | .token.atrule, 61 | .token.keyword, 62 | .token.builtin { 63 | color: #ffcd4b; 64 | } 65 | 66 | .token.tag, 67 | .token.namespace, 68 | .token.number, 69 | .token.unit, 70 | .token.hexcode, 71 | .token.deleted, 72 | .token.string, 73 | .token.char, 74 | .token.comment, 75 | .token.block-comment { 76 | color: #ff7676; 77 | } 78 | 79 | .token.attr-name, 80 | .token.attr-value, 81 | .token.property, 82 | .token.selector, 83 | .token.function-name, 84 | .token.constant, 85 | .token.entity, 86 | .token.url, 87 | .token.inserted, 88 | .token.regex, 89 | .token.variable, 90 | .token.function, 91 | .token.selector .token.id, 92 | .token.selector .token.class { 93 | color: #fb2752; 94 | } 95 | 96 | .token.important, 97 | .token.bold { 98 | font-weight: bold; 99 | } 100 | 101 | .token.italic { 102 | font-style: italic; 103 | } 104 | 105 | .token.entity { 106 | cursor: help; 107 | } 108 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-static'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | appDir: 'app', 12 | adapter: adapter(), 13 | }, 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /tests-ct/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/svelte-promise-modals/95ce50291a312990be0e311c52b2cc39b6372784/tests-ct/.gitkeep -------------------------------------------------------------------------------- /tests-ct/OverlappingTestModal.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 25 | 26 | 43 | -------------------------------------------------------------------------------- /tests-ct/TestApp.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | 34 | 35 | {#if showWrapper} 36 | 37 | 44 | 45 | {/if} 46 | 47 | 48 | -------------------------------------------------------------------------------- /tests-ct/TestModal.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | {foo} 12 | 13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests-ct/Wrapper.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests-ct/basics.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/experimental-ct-svelte'; 2 | import sinon from 'sinon'; 3 | 4 | import TestApp from './TestApp.svelte'; 5 | 6 | test.describe('Basics', () => { 7 | test.beforeEach(async ({ page }) => { 8 | // Reduced motion will speed up animations which comes handy for testing 9 | await page.emulateMedia({ reducedMotion: 'reduce' }); 10 | }); 11 | 12 | test('clicking the backdrop closes the modal', async ({ mount, page }) => { 13 | await mount(TestApp); 14 | 15 | await expect(page.getByTestId('backdrop')).toBeHidden(); 16 | await expect(page.getByTestId('spm-modal')).toBeHidden(); 17 | 18 | await page.getByTestId('open-modal-button').click(); 19 | 20 | await expect(page.getByTestId('backdrop')).toBeVisible(); 21 | await expect(page.getByTestId('spm-modal')).toBeVisible(); 22 | 23 | await page.waitForFunction(async () => { 24 | let backdrop = document.querySelector('.spm-backdrop'); 25 | let { opacity } = window.getComputedStyle(backdrop); 26 | 27 | return opacity === '1'; 28 | }); 29 | 30 | let { pointerEvents } = await page 31 | .getByTestId('backdrop') 32 | .evaluate((element) => window.getComputedStyle(element)); 33 | 34 | expect(pointerEvents).toBe('auto'); 35 | 36 | // The backdrop isn't interactive (hence `force: true`), but it shouldn't really be, as it's only 37 | // a convenience for pointing device users and not the primary means of closing modals. 38 | await page.getByTestId('backdrop').click({ force: true, position: { x: 1, y: 1 } }); 39 | 40 | await expect(page.getByTestId('backdrop')).toBeHidden(); 41 | await expect(page.getByTestId('spm-modal')).toBeHidden(); 42 | }); 43 | 44 | test('clicking the backdrop does not close the modal if `clickOutsideDeactivates` is `false`', async ({ 45 | mount, 46 | page, 47 | }) => { 48 | await mount(TestApp, { 49 | props: { 50 | openModalOptions: { 51 | focusTrapOptions: { 52 | clickOutsideDeactivates: false, 53 | }, 54 | }, 55 | }, 56 | }); 57 | 58 | await page.getByText('Open Modal').click(); 59 | 60 | await expect(page.getByTestId('backdrop')).toBeVisible(); 61 | await expect(page.getByTestId('spm-modal')).toBeVisible(); 62 | 63 | // The backdrop isn't interactive (hence `force: true`), but it shouldn't really be, as it's only 64 | // a convenience for pointing device users and not the primary means of closing modals. 65 | await page.getByTestId('backdrop').click({ force: true, position: { x: 1, y: 1 } }); 66 | 67 | await expect(page.getByTestId('backdrop')).toBeVisible(); 68 | await expect(page.getByTestId('spm-modal')).toBeVisible(); 69 | }); 70 | 71 | test('opening a modal disables scrolling on the element', async ({ mount, page }) => { 72 | let getBodyStyle = () => { 73 | return page.locator('body').evaluate((element) => window.getComputedStyle(element)); 74 | }; 75 | 76 | await mount(TestApp); 77 | 78 | await expect((await getBodyStyle()).overflow).toBe('visible'); 79 | expect(page.getByTestId('backdrop')).toBeHidden(); 80 | 81 | await page.getByText('Open Modal').click(); 82 | 83 | await expect(page.getByTestId('backdrop')).toBeVisible(); 84 | expect((await getBodyStyle()).overflow).toBe('hidden'); 85 | 86 | await page.getByText('close').click(); 87 | 88 | await expect(page.getByTestId('backdrop')).toBeHidden(); 89 | expect((await getBodyStyle()).overflow).toBe('visible'); 90 | }); 91 | 92 | test('pressing the Escape keyboard button closes the modal', async ({ mount, page }) => { 93 | await mount(TestApp); 94 | 95 | await page.getByText('Open Modal').click(); 96 | 97 | await expect(page.getByTestId('spm-modal')).toBeVisible(); 98 | 99 | await page.keyboard.press('Escape'); 100 | 101 | await expect(page.getByTestId('spm-modal')).toBeHidden(); 102 | }); 103 | 104 | test('closing the modal via the close function returns passed values', async ({ 105 | mount, 106 | page, 107 | }) => { 108 | let resultCallback = sinon.fake(); 109 | 110 | await mount(TestApp, { 111 | props: { 112 | modalProps: { 113 | foo: 'bar', 114 | }, 115 | resultCallback, 116 | }, 117 | }); 118 | 119 | await page.getByText('Open Modal').click(); 120 | await page.getByText('close').click(); 121 | 122 | await expect(resultCallback.called).toBeTruthy(); 123 | await expect(resultCallback.lastCall.firstArg).toBe('bar'); 124 | }); 125 | 126 | test('opening and closing a modal both adds `.spm-animating` class to ', async ({ 127 | mount, 128 | page, 129 | }) => { 130 | await mount(TestApp); 131 | 132 | await page.waitForSelector('body:not(.spm-animating)'); 133 | 134 | await page.getByText('Open Modal').click(); 135 | 136 | await page.waitForSelector('body.spm-animating'); 137 | await page.waitForSelector('body:not(.spm-animating)'); 138 | 139 | await page.getByText('close').click(); 140 | 141 | await page.waitForSelector('body.spm-animating'); 142 | await page.waitForSelector('body:not(.spm-animating)'); 143 | }); 144 | 145 | test('passing `className` adds the passed class to the `.spm-modal` element', async ({ 146 | mount, 147 | page, 148 | }) => { 149 | await mount(TestApp, { 150 | props: { 151 | openModalOptions: { 152 | className: 'foo', 153 | }, 154 | }, 155 | }); 156 | 157 | await expect(page.getByTestId('spm-modal.foo')).toHaveCount(0); 158 | 159 | await page.getByText('Open Modal').click(); 160 | 161 | await page.waitForSelector('.spm-modal.foo'); 162 | await expect(page.getByTestId('spm-modal')).toBeVisible(); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /tests-ct/focus-trap.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/experimental-ct-svelte'; 2 | import sinon from 'sinon'; 3 | 4 | import TestApp from './TestApp.svelte'; 5 | 6 | test.describe('Focus trap', () => { 7 | test.beforeEach(async ({ page }) => { 8 | // Reduced motion will speed up animations which comes handy for testing 9 | await page.emulateMedia({ reducedMotion: 'reduce' }); 10 | }); 11 | 12 | test('focus trap is enabled by default', async ({ mount, page }) => { 13 | await mount(TestApp); 14 | 15 | await page.getByText('Open Modal').focus(); 16 | await expect(page.getByText('Open Modal')).toBeFocused(); 17 | 18 | await page.getByText('Open Modal').click(); 19 | await expect(page.getByText('Open Modal')).not.toBeFocused(); 20 | await expect(page.getByText('foo')).toBeFocused(); 21 | }); 22 | 23 | test('focus trap can be disabled', async ({ page, mount }) => { 24 | await mount(TestApp, { 25 | props: { 26 | modalContainerOptions: { 27 | focusTrapOptions: null, 28 | }, 29 | }, 30 | }); 31 | 32 | await page.getByText('Open Modal').focus(); 33 | await expect(page.getByText('Open Modal')).toBeFocused(); 34 | 35 | await page.getByText('Open Modal').click(); 36 | await expect(page.getByText('Open Modal')).toBeFocused(); 37 | await expect(page.getByText('foo')).not.toBeFocused(); 38 | }); 39 | 40 | test('global focus trap options', async ({ mount, page }) => { 41 | let onActivate = sinon.fake(); 42 | 43 | await mount(TestApp, { 44 | props: { 45 | modalContainerOptions: { 46 | focusTrapOptions: { 47 | onActivate, 48 | }, 49 | }, 50 | }, 51 | }); 52 | 53 | await page.getByText('Open Modal').click(); 54 | await expect(onActivate.called).toBeTruthy(); 55 | }); 56 | 57 | test('local focus trap options', async ({ mount, page }) => { 58 | let onActivate = sinon.fake(); 59 | 60 | await mount(TestApp, { 61 | props: { 62 | openModalOptions: { 63 | focusTrapOptions: { 64 | onActivate, 65 | }, 66 | }, 67 | }, 68 | }); 69 | 70 | await page.getByText('Open Modal').click(); 71 | await expect(onActivate.called).toBeTruthy(); 72 | }); 73 | 74 | test('focus trap is disabled locally', async ({ mount, page }) => { 75 | let onActivate = sinon.fake(); 76 | 77 | await mount(TestApp, { 78 | props: { 79 | modalContainerOptions: { 80 | focusTrapOptions: { 81 | onActivate, 82 | }, 83 | }, 84 | openModalOptions: { 85 | focusTrapOptions: null, 86 | }, 87 | }, 88 | }); 89 | 90 | await page.getByText('Open Modal').click(); 91 | await expect(onActivate.called).not.toBeTruthy(); 92 | }); 93 | 94 | test('globally disabled focusTrapOptions is overriden when local options are provided', async ({ 95 | mount, 96 | page, 97 | }) => { 98 | let onActivate = sinon.fake(); 99 | 100 | await mount(TestApp, { 101 | props: { 102 | modalContainerOptions: { 103 | focusTrapOptions: null, 104 | }, 105 | openModalOptions: { 106 | focusTrapOptions: { 107 | onActivate, 108 | }, 109 | }, 110 | }, 111 | }); 112 | 113 | await page.getByText('Open Modal').click(); 114 | await expect(onActivate.called).toBeTruthy(); 115 | }); 116 | 117 | test('onDeactivate is called when the modal is closed when the Escape key is pressed', async ({ 118 | mount, 119 | page, 120 | }) => { 121 | let onDeactivate = sinon.fake(); 122 | 123 | await mount(TestApp, { 124 | props: { 125 | modalContainerOptions: { 126 | focusTrapOptions: { 127 | onDeactivate, 128 | }, 129 | }, 130 | }, 131 | }); 132 | 133 | await page.getByText('Open Modal').click(); 134 | await page.keyboard.press('Escape'); 135 | await expect(onDeactivate.called).toBeTruthy(); 136 | }); 137 | 138 | test('onDeactivate is called when the modal is closed via the close action', async ({ 139 | mount, 140 | page, 141 | }) => { 142 | let onDeactivate = sinon.fake(); 143 | 144 | await mount(TestApp, { 145 | props: { 146 | modalContainerOptions: { 147 | focusTrapOptions: { 148 | onDeactivate, 149 | }, 150 | }, 151 | }, 152 | }); 153 | 154 | await page.getByText('Open Modal').click(); 155 | await page.getByText('close').click(); 156 | await expect(onDeactivate.called).toBeTruthy(); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /tests-ct/modal-context.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/experimental-ct-svelte'; 2 | 3 | import TestApp from './TestApp.svelte'; 4 | 5 | test.describe('Modal Context', () => { 6 | test.beforeEach(async ({ page }) => { 7 | // Reduced motion will speed up animations which comes handy for testing 8 | await page.emulateMedia({ reducedMotion: 'reduce' }); 9 | }); 10 | 11 | test('removing the parent component removes the modal as well', async ({ mount, page }) => { 12 | await mount(TestApp); 13 | 14 | await expect(page.getByTestId('backdrop')).toBeHidden(); 15 | await expect(page.getByTestId('spm-modal')).toBeHidden(); 16 | 17 | await page.getByText('Open Using Context').click(); 18 | 19 | await expect(page.getByTestId('backdrop')).toBeVisible(); 20 | await expect(page.getByTestId('spm-modal')).toBeVisible(); 21 | 22 | await page.evaluateHandle(() => window.handle.hideWrapper()); 23 | 24 | await expect(page.getByTestId('backdrop')).toBeHidden(); 25 | await expect(page.getByTestId('spm-modal')).toBeHidden(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests-ct/overlapping-modals.spec.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/experimental-ct-svelte'; 2 | 3 | import TestApp from './TestApp.svelte'; 4 | 5 | test.describe('Overlapping modals', () => { 6 | test.beforeEach(async ({ page }) => { 7 | // Reduced motion will speed up animations which comes handy for testing 8 | await page.emulateMedia({ reducedMotion: 'reduce' }); 9 | }); 10 | 11 | test('every modal should have a dedicated backdrop', async ({ mount, page }) => { 12 | await mount(TestApp); 13 | 14 | await expect(page.getByTestId('backdrop')).toBeHidden(); 15 | await expect(page.getByTestId('spm-modal')).toBeHidden(); 16 | 17 | await page.getByText('Open Modal').click(); 18 | 19 | await expect(page.locator('[data-testid="backdrop"]')).toHaveCount(1); 20 | 21 | await page.getByText('Open another modal').click(); 22 | 23 | await expect(page.locator('[data-testid="backdrop"]')).toHaveCount(2); 24 | 25 | await page.waitForSelector('body:not(.spm-animating)'); 26 | 27 | let backdropStyles; 28 | 29 | backdropStyles = await page 30 | .locator('[data-testid="backdrop"]') 31 | .nth(0) 32 | .evaluate((element) => window.getComputedStyle(element)); 33 | 34 | expect(backdropStyles.pointerEvents).toBe('auto'); 35 | expect(backdropStyles.opacity).toBe('1'); 36 | 37 | backdropStyles = await page 38 | .locator('[data-testid="backdrop"]') 39 | .nth(1) 40 | .evaluate((element) => window.getComputedStyle(element)); 41 | 42 | expect(backdropStyles.pointerEvents).toBe('auto'); 43 | expect(backdropStyles.opacity).toBe('1'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals", "@testing-library/jest-dom"], 5 | "allowJs": true, 6 | "checkJs": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "strict": true 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // 16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 17 | // from the referenced tsconfig.json - TypeScript does not merge them in 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig, type UserConfig } from 'vite'; 3 | import type { UserConfig as VitestConfig } from 'vitest/config'; 4 | 5 | const config: UserConfig & { test: VitestConfig['test'] } = defineConfig(({ mode }) => ({ 6 | plugins: [sveltekit()], 7 | define: { 8 | // Eliminate in-source test code 9 | 'import.meta.vitest': 'undefined', 10 | }, 11 | test: { 12 | // Can't patch `expect` otherwise 13 | globals: true, 14 | // Importing CSS only inside components won't do it in vitest 15 | css: { 16 | include: [/.+\.css/], 17 | }, 18 | environment: 'jsdom', 19 | // in-source testing 20 | includeSource: ['src/**/*.{js,ts,svelte}'], 21 | // Add @testing-library/jest-dom matchers & mocks of SvelteKit modules 22 | setupFiles: ['./setupTest.ts'], 23 | // Exclude files in c8 24 | coverage: { 25 | exclude: ['setupTest.ts'], 26 | }, 27 | include: ['src/**/*.test.{js,ts}'], 28 | }, 29 | resolve: { 30 | conditions: mode === 'test' ? ['browser'] : [], 31 | }, 32 | })); 33 | 34 | export default config; 35 | --------------------------------------------------------------------------------