Implement custom lightweight modal #230

Introduce a brand new lightweight and efficient modal component. It is
designed to be visually similar to the previous one to not introduce a
change in feel of the application in a patch release, but behind the
scenes it features:

- Enhanced application speed and reduced bundle size.
- New flexbox-driven layout, eliminating JS calculations.
- Composition API ready for Vue 3.0 #230.

Other changes:

- Adopt idiomatic Vue via `v-modal` binding.
- Add unit tests for both the modal and dialog.
- Remove `vue-js-modal` dependency in favor of the new implementation.
- Adjust modal shadow color to better match theme.
- Add `@vue/test-utils` for unit testing.
This commit is contained in:
undergroundwires
2023-08-11 19:35:26 +02:00
parent 986ba078a6
commit 9e5491fdbf
28 changed files with 2126 additions and 171 deletions

View File

@@ -50,9 +50,9 @@ This project uses a singleton instance of the application state, making it avail
The decision to not use third-party state management libraries like [`vuex`](https://web.archive.org/web/20230801191617/https://vuex.vuejs.org/) or [`pinia`](https://web.archive.org/web/20230801191743/https://pinia.vuejs.org/) was made to promote code independence and enhance portability. The decision to not use third-party state management libraries like [`vuex`](https://web.archive.org/web/20230801191617/https://vuex.vuejs.org/) or [`pinia`](https://web.archive.org/web/20230801191743/https://pinia.vuejs.org/) was made to promote code independence and enhance portability.
Stateful components can mutate and/or react to state changes (e.g., user selection, search queries) in the [ApplicationContext](./../src/application/Context/ApplicationContext.ts). Vue components import [`CollectionState.ts`](./../src/presentation/components/Shared/CollectionState.ts) to access both the application context and the state. Stateful components can mutate and/or react to state changes (e.g., user selection, search queries) in the [ApplicationContext](./../src/application/Context/ApplicationContext.ts). Vue components import [`CollectionState.ts`](./../src/presentation/components/Shared/Hooks/UseCollectionState.ts) to access both the application context and the state.
[`CollectionState.ts`](./../src/presentation/components/Shared/CollectionState.ts) provides several functionalities including: [`UseCollectionState.ts`](./../src/presentation/components/Shared/Hooks/UseCollectionState.ts) provides several functionalities including:
- **Singleton State Instance**: It creates a singleton instance of the state, which is shared across the presentation layer. The singleton instance ensures that there's a single source of truth for the application's state. - **Singleton State Instance**: It creates a singleton instance of the state, which is shared across the presentation layer. The singleton instance ensures that there's a single source of truth for the application's state.
- **State Change Callback and Lifecycle Management**: It offers a mechanism to register callbacks, which will be invoked when the state initializes or mutates. It ensures that components unsubscribe from state events when they are no longer in use or when [ApplicationContext](./../src/application/Context/ApplicationContext.ts) switches the active [collection](./collection-files.md). - **State Change Callback and Lifecycle Management**: It offers a mechanism to register callbacks, which will be invoked when the state initializes or mutates. It ensures that components unsubscribe from state events when they are no longer in use or when [ApplicationContext](./../src/application/Context/ApplicationContext.ts) switches the active [collection](./collection-files.md).
@@ -63,16 +63,7 @@ Stateful components can mutate and/or react to state changes (e.g., user selecti
## Modals ## Modals
[ModalDialog.vue](./../src/presentation/components/Shared/ModalDialog.vue) is a shared component utilized for rendering modal windows. - [ModalDialog.vue](./../src/presentation/components/Shared/Modal/ModalDialog.vue) is a shared component utilized for rendering modal windows.
Use the component by wrapping the desired content within its slot and calling the .show() function on its reference, as shown below:
```html
<ModalDialog ref="testDialog">
<div>Hello world</div>
</ModalDialog>
<div @click="$refs.testDialog.show()">Show dialog</div>
```
## Sass naming convention ## Sass naming convention

529
package-lock.json generated
View File

@@ -25,8 +25,7 @@
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"npm": "^9.8.1", "npm": "^9.8.1",
"v-tooltip": "2.1.3", "v-tooltip": "2.1.3",
"vue": "^2.7.14", "vue": "^2.7.14"
"vue-js-modal": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
@@ -44,6 +43,7 @@
"@vue/cli-service": "~5.0.8", "@vue/cli-service": "~5.0.8",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0", "@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^1.3.6",
"chai": "^4.3.7", "chai": "^4.3.7",
"cypress": "^12.17.2", "cypress": "^12.17.2",
"electron": "^25.3.2", "electron": "^25.3.2",
@@ -3311,6 +3311,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
} }
}, },
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
"dev": true
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4826,6 +4832,21 @@
} }
} }
}, },
"node_modules/@vue/test-utils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.3.6.tgz",
"integrity": "sha512-udMmmF1ts3zwxUJEIAj5ziioR900reDrt6C9H3XpWPsLBx2lpHKoA4BTdd9HNIYbkGltWw+JjWJ+5O6QBwiyEw==",
"dev": true,
"dependencies": {
"dom-event-types": "^1.0.0",
"lodash": "^4.17.15",
"pretty": "^2.0.0"
},
"peerDependencies": {
"vue": "2.x",
"vue-template-compiler": "^2.x"
}
},
"node_modules/@vue/vue-loader-v15": { "node_modules/@vue/vue-loader-v15": {
"name": "vue-loader", "name": "vue-loader",
"version": "15.10.0", "version": "15.10.0",
@@ -5043,6 +5064,12 @@
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
"dev": true "dev": true
}, },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -7053,6 +7080,36 @@
"typedarray": "^0.0.6" "typedarray": "^0.0.6"
} }
}, },
"node_modules/condense-newlines": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz",
"integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==",
"dev": true,
"dependencies": {
"extend-shallow": "^2.0.1",
"is-whitespace": "^0.3.0",
"kind-of": "^3.0.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"dev": true,
"dependencies": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"node_modules/config-chain/node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true
},
"node_modules/config-file-ts": { "node_modules/config-file-ts": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz",
@@ -7834,6 +7891,13 @@
"integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==", "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==",
"dev": true "dev": true
}, },
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true,
"peer": true
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -8419,6 +8483,12 @@
"utila": "~0.4" "utila": "~0.4"
} }
}, },
"node_modules/dom-event-types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/dom-event-types/-/dom-event-types-1.1.0.tgz",
"integrity": "sha512-jNCX+uNJ3v38BKvPbpki6j5ItVlnSqVV6vDWGS6rExzCMjsc39frLjm1n91o6YaKK6AZl0wLloItW6C6mr61BQ==",
"dev": true
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@@ -8554,6 +8624,57 @@
"safer-buffer": "^2.1.0" "safer-buffer": "^2.1.0"
} }
}, },
"node_modules/editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"dev": true,
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "9.0.1",
"semver": "^7.5.3"
},
"bin": {
"editorconfig": "bin/editorconfig"
},
"engines": {
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/editorconfig/node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"dev": true,
"engines": {
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -10374,6 +10495,18 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
}, },
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"dev": true,
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extract-zip": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -12156,6 +12289,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
"dev": true
},
"node_modules/is-callable": { "node_modules/is-callable": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -12228,6 +12367,15 @@
"integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==", "integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==",
"dev": true "dev": true
}, },
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -12575,6 +12723,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-whitespace": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz",
"integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-wsl": { "node_modules/is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -12808,6 +12965,66 @@
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"dev": true "dev": true
}, },
"node_modules/js-beautify": {
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.9.tgz",
"integrity": "sha512-coM7xq1syLcMyuVGyToxcj2AlzhkDjmfklL8r0JgJ7A76wyGMpJ1oA35mr4APdYNO/o/4YY8H54NQIJzhMbhBg==",
"dev": true,
"dependencies": {
"config-chain": "^1.1.13",
"editorconfig": "^1.0.3",
"glob": "^8.1.0",
"nopt": "^6.0.0"
},
"bin": {
"css-beautify": "js/bin/css-beautify.js",
"html-beautify": "js/bin/html-beautify.js",
"js-beautify": "js/bin/js-beautify.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/js-beautify/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/js-beautify/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/js-beautify/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/js-message": { "node_modules/js-message": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",
@@ -13158,6 +13375,18 @@
"json-buffer": "3.0.0" "json-buffer": "3.0.0"
} }
}, },
"node_modules/kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
"dev": true,
"dependencies": {
"is-buffer": "^1.1.5"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/klaw": { "node_modules/klaw": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz",
@@ -15637,6 +15866,21 @@
"dev": true, "dev": true,
"hasInstallScript": true "hasInstallScript": true
}, },
"node_modules/nopt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
"integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==",
"dev": true,
"dependencies": {
"abbrev": "^1.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/normalize-package-data": { "node_modules/normalize-package-data": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -20360,6 +20604,20 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/pretty": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz",
"integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==",
"dev": true,
"dependencies": {
"condense-newlines": "^0.2.1",
"extend-shallow": "^2.0.1",
"js-beautify": "^1.6.12"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pretty-bytes": { "node_modules/pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -20580,6 +20838,12 @@
"levenshtein-edit-distance": "^1.0.0" "levenshtein-edit-distance": "^1.0.0"
} }
}, },
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -22259,11 +22523,6 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true "dev": true
}, },
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.1", "version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -25767,17 +26026,6 @@
"integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
"dev": true "dev": true
}, },
"node_modules/vue-js-modal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/vue-js-modal/-/vue-js-modal-2.0.1.tgz",
"integrity": "sha512-5FUwsH2zoxRKX4a7wkFAqX0eITCcIMunJDEfIxzHs2bHw9o20+Iqm+uQvBcg1jkzyo1+tVgThR/7NGU8djbD8Q==",
"dependencies": {
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"vue": "^2.6.11"
}
},
"node_modules/vue-loader": { "node_modules/vue-loader": {
"version": "17.0.0", "version": "17.0.0",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz",
@@ -25903,6 +26151,17 @@
"integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==", "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==",
"dev": true "dev": true
}, },
"node_modules/vue-template-compiler": {
"version": "2.7.14",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz",
"integrity": "sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==",
"dev": true,
"peer": true,
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/vue-template-es2015-compiler": { "node_modules/vue-template-es2015-compiler": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz", "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
@@ -29399,6 +29658,12 @@
"integrity": "sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==", "integrity": "sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==",
"dev": true "dev": true
}, },
"@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
"dev": true
},
"@pkgjs/parseargs": { "@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -30605,6 +30870,17 @@
"vue-eslint-parser": "^9.1.1" "vue-eslint-parser": "^9.1.1"
} }
}, },
"@vue/test-utils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.3.6.tgz",
"integrity": "sha512-udMmmF1ts3zwxUJEIAj5ziioR900reDrt6C9H3XpWPsLBx2lpHKoA4BTdd9HNIYbkGltWw+JjWJ+5O6QBwiyEw==",
"dev": true,
"requires": {
"dom-event-types": "^1.0.0",
"lodash": "^4.17.15",
"pretty": "^2.0.0"
}
},
"@vue/vue-loader-v15": { "@vue/vue-loader-v15": {
"version": "npm:vue-loader@15.10.0", "version": "npm:vue-loader@15.10.0",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.10.0.tgz", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.10.0.tgz",
@@ -30808,6 +31084,12 @@
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
"dev": true "dev": true
}, },
"abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true
},
"accepts": { "accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -32322,6 +32604,35 @@
"typedarray": "^0.0.6" "typedarray": "^0.0.6"
} }
}, },
"condense-newlines": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz",
"integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==",
"dev": true,
"requires": {
"extend-shallow": "^2.0.1",
"is-whitespace": "^0.3.0",
"kind-of": "^3.0.2"
}
},
"config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"dev": true,
"requires": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
},
"dependencies": {
"ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true
}
}
},
"config-file-ts": { "config-file-ts": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz",
@@ -32894,6 +33205,13 @@
"integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==", "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==",
"dev": true "dev": true
}, },
"de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true,
"peer": true
},
"debug": { "debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -33305,6 +33623,12 @@
"utila": "~0.4" "utila": "~0.4"
} }
}, },
"dom-event-types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/dom-event-types/-/dom-event-types-1.1.0.tgz",
"integrity": "sha512-jNCX+uNJ3v38BKvPbpki6j5ItVlnSqVV6vDWGS6rExzCMjsc39frLjm1n91o6YaKK6AZl0wLloItW6C6mr61BQ==",
"dev": true
},
"dom-serializer": { "dom-serializer": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@@ -33413,6 +33737,44 @@
"safer-buffer": "^2.1.0" "safer-buffer": "^2.1.0"
} }
}, },
"editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"dev": true,
"requires": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "9.0.1",
"semver": "^7.5.3"
},
"dependencies": {
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
}
},
"commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"dev": true
},
"minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"requires": {
"brace-expansion": "^2.0.1"
}
}
}
},
"ee-first": { "ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -34795,6 +35157,15 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
}, },
"extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"dev": true,
"requires": {
"is-extendable": "^0.1.0"
}
},
"extract-zip": { "extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -36104,6 +36475,12 @@
"has-tostringtag": "^1.0.0" "has-tostringtag": "^1.0.0"
} }
}, },
"is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
"dev": true
},
"is-callable": { "is-callable": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -36149,6 +36526,12 @@
"integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==", "integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==",
"dev": true "dev": true
}, },
"is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"dev": true
},
"is-extglob": { "is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -36381,6 +36764,12 @@
"call-bind": "^1.0.2" "call-bind": "^1.0.2"
} }
}, },
"is-whitespace": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz",
"integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==",
"dev": true
},
"is-wsl": { "is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -36559,6 +36948,51 @@
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"dev": true "dev": true
}, },
"js-beautify": {
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.9.tgz",
"integrity": "sha512-coM7xq1syLcMyuVGyToxcj2AlzhkDjmfklL8r0JgJ7A76wyGMpJ1oA35mr4APdYNO/o/4YY8H54NQIJzhMbhBg==",
"dev": true,
"requires": {
"config-chain": "^1.1.13",
"editorconfig": "^1.0.3",
"glob": "^8.1.0",
"nopt": "^6.0.0"
},
"dependencies": {
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
}
},
"glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
}
},
"minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"requires": {
"brace-expansion": "^2.0.1"
}
}
}
},
"js-message": { "js-message": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",
@@ -36863,6 +37297,15 @@
"json-buffer": "3.0.0" "json-buffer": "3.0.0"
} }
}, },
"kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
"dev": true,
"requires": {
"is-buffer": "^1.1.5"
}
},
"klaw": { "klaw": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz",
@@ -38685,6 +39128,15 @@
"integrity": "sha512-7Ws63oC+215smeKJQCxzrK21VFVlCFBkwl0MOObt0HOpVQXs3u483sAmtkF33nNqZ5rSOQjB76fgyPBmAUrtCA==", "integrity": "sha512-7Ws63oC+215smeKJQCxzrK21VFVlCFBkwl0MOObt0HOpVQXs3u483sAmtkF33nNqZ5rSOQjB76fgyPBmAUrtCA==",
"dev": true "dev": true
}, },
"nopt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
"integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==",
"dev": true,
"requires": {
"abbrev": "^1.0.0"
}
},
"normalize-package-data": { "normalize-package-data": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -41856,6 +42308,17 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"pretty": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz",
"integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==",
"dev": true,
"requires": {
"condense-newlines": "^0.2.1",
"extend-shallow": "^2.0.1",
"js-beautify": "^1.6.12"
}
},
"pretty-bytes": { "pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -42024,6 +42487,12 @@
"levenshtein-edit-distance": "^1.0.0" "levenshtein-edit-distance": "^1.0.0"
} }
}, },
"proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true
},
"proxy-addr": { "proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -43324,11 +43793,6 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true "dev": true
}, },
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"resolve": { "resolve": {
"version": "1.22.1", "version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -45949,14 +46413,6 @@
"integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
"dev": true "dev": true
}, },
"vue-js-modal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/vue-js-modal/-/vue-js-modal-2.0.1.tgz",
"integrity": "sha512-5FUwsH2zoxRKX4a7wkFAqX0eITCcIMunJDEfIxzHs2bHw9o20+Iqm+uQvBcg1jkzyo1+tVgThR/7NGU8djbD8Q==",
"requires": {
"resize-observer-polyfill": "^1.5.1"
}
},
"vue-loader": { "vue-loader": {
"version": "17.0.0", "version": "17.0.0",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz",
@@ -46056,6 +46512,17 @@
} }
} }
}, },
"vue-template-compiler": {
"version": "2.7.14",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz",
"integrity": "sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==",
"dev": true,
"peer": true,
"requires": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"vue-template-es2015-compiler": { "vue-template-es2015-compiler": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz", "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",

View File

@@ -8,7 +8,7 @@
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit", "test:unit": "vue-cli-service test:unit --include ./tests/bootstrap/setup.ts",
"test:e2e": "vue-cli-service test:e2e", "test:e2e": "vue-cli-service test:e2e",
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml", "lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml",
"create-icons": "node img/logo-update.mjs", "create-icons": "node img/logo-update.mjs",
@@ -21,7 +21,7 @@
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml", "lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps", "postuninstall": "electron-builder install-app-deps",
"test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\"" "test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\" --include ./tests/bootstrap/setup.ts"
}, },
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
@@ -41,8 +41,7 @@
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"npm": "^9.8.1", "npm": "^9.8.1",
"v-tooltip": "2.1.3", "v-tooltip": "2.1.3",
"vue": "^2.7.14", "vue": "^2.7.14"
"vue-js-modal": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
@@ -60,6 +59,7 @@
"@vue/cli-service": "~5.0.8", "@vue/cli-service": "~5.0.8",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0", "@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^1.3.6",
"chai": "^4.3.7", "chai": "^4.3.7",
"cypress": "^12.17.2", "cypress": "^12.17.2",
"electron": "^25.3.2", "electron": "^25.3.2",

View File

@@ -27,3 +27,21 @@
*/ */
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
@mixin fade-slide-transition($name, $duration, $offset-upward: null) {
.#{$name}-enter-active,
.#{$name}-leave-active {
transition: all $duration;
}
.#{$name}-leave-active,
.#{$name}-enter, // Vue 2.X compatibility
.#{$name}-enter-from // Vue 3.X compatibility
{
opacity: 0;
@if $offset-upward {
transform: translateY($offset-upward);
}
}
}

View File

@@ -1,4 +1,3 @@
import { VModalBootstrapper } from './Modules/VModalBootstrapper';
import { TreeBootstrapper } from './Modules/TreeBootstrapper'; import { TreeBootstrapper } from './Modules/TreeBootstrapper';
import { IconBootstrapper } from './Modules/IconBootstrapper'; import { IconBootstrapper } from './Modules/IconBootstrapper';
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper'; import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
@@ -19,7 +18,6 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
new TreeBootstrapper(), new TreeBootstrapper(),
new VueBootstrapper(), new VueBootstrapper(),
new TooltipBootstrapper(), new TooltipBootstrapper(),
new VModalBootstrapper(),
]; ];
} }
} }

View File

@@ -1,8 +0,0 @@
import VModal from 'vue-js-modal';
import { VueConstructor, IVueBootstrapper } from '../IVueBootstrapper';
export class VModalBootstrapper implements IVueBootstrapper {
public bootstrap(vue: VueConstructor): void {
vue.use(VModal, { dynamic: true, injectModalsContainer: true });
}
}

View File

@@ -19,7 +19,7 @@
icon-prefix="fas" icon-prefix="fas"
icon-name="copy" icon-name="copy"
/> />
<ModalDialog v-if="instructions" ref="instructionsDialog"> <ModalDialog v-if="instructions" v-model="areInstructionsVisible">
<InstructionList :data="instructions" /> <InstructionList :data="instructions" />
</ModalDialog> </ModalDialog>
</div> </div>
@@ -30,7 +30,7 @@ import { defineComponent, ref, computed } from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState'; import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog'; import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard'; import { Clipboard } from '@/infrastructure/Clipboard';
import ModalDialog from '@/presentation/components/Shared/ModalDialog.vue'; import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { Environment } from '@/application/Environment/Environment'; import { Environment } from '@/application/Environment/Environment';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
@@ -57,7 +57,7 @@ export default defineComponent({
currentState, currentContext, onStateChange, events, currentState, currentContext, onStateChange, events,
} = useCollectionState(); } = useCollectionState();
const instructionsDialog = ref<typeof ModalDialog>(); const areInstructionsVisible = ref(false);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os)); const canRun = computed<boolean>(() => getCanRunState(currentState.value.os));
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting)); const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const hasCode = ref(false); const hasCode = ref(false);
@@ -73,7 +73,7 @@ export default defineComponent({
function saveCode() { function saveCode() {
saveCodeToDisk(fileName.value, currentState.value); saveCodeToDisk(fileName.value, currentState.value);
instructionsDialog.value?.show(); areInstructionsVisible.value = true;
} }
async function executeCode() { async function executeCode() {
@@ -103,7 +103,7 @@ export default defineComponent({
hasCode, hasCode,
instructions, instructions,
fileName, fileName,
instructionsDialog, areInstructionsVisible,
copyCode, copyCode,
saveCode, saveCode,
executeCode, executeCode,

View File

@@ -0,0 +1,37 @@
import { Ref, computed, watch } from 'vue';
/**
* This function monitors a set of conditions (represented as refs) and
* maintains a composite status based on all conditions.
*/
export function useAllTrueWatcher(
...conditions: Ref<boolean>[]
) {
const allMetCallbacks = new Array<() => void>();
const areAllConditionsMet = computed(() => conditions.every((condition) => condition.value));
watch(areAllConditionsMet, (areMet) => {
if (areMet) {
allMetCallbacks.forEach((action) => action());
}
});
function resetAllConditions() {
conditions.forEach((condition) => {
condition.value = false;
});
}
function onAllConditionsMet(callback: () => void) {
allMetCallbacks.push(callback);
if (areAllConditionsMet.value) {
callback();
}
}
return {
resetAllConditions,
onAllConditionsMet,
};
}

View File

@@ -0,0 +1,23 @@
import { Ref, watchEffect } from 'vue';
/**
* Manages focus transitions, ensuring good usability and accessibility.
*/
export function useCurrentFocusToggle(shouldDisableFocus: Ref<boolean>) {
let previouslyFocusedElement: HTMLElement | undefined;
watchEffect(() => {
if (shouldDisableFocus.value) {
previouslyFocusedElement = document.activeElement as HTMLElement | null;
previouslyFocusedElement?.blur();
} else {
if (!previouslyFocusedElement || previouslyFocusedElement.tagName === 'BODY') {
// It doesn't make sense to return focus to the body after the modal is
// closed because the body itself doesn't offer meaningful interactivity
return;
}
previouslyFocusedElement.focus();
previouslyFocusedElement = undefined;
}
});
}

View File

@@ -0,0 +1,17 @@
import { onBeforeMount, onBeforeUnmount } from 'vue';
export function useEscapeKeyListener(callback: () => void) {
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
callback();
}
};
onBeforeMount(() => {
window.addEventListener('keyup', onKeyUp);
});
onBeforeUnmount(() => {
window.removeEventListener('keyup', onKeyUp);
});
}

View File

@@ -0,0 +1,37 @@
import { Ref, watch, onBeforeUnmount } from 'vue';
/*
It blocks background scrolling.
Designed to be used by modals, overlays etc.
*/
export function useLockBodyBackgroundScroll(isActive: Ref<boolean>) {
const originalStyles = {
overflow: document.body.style.overflow,
width: document.body.style.width,
};
const block = () => {
originalStyles.overflow = document.body.style.overflow;
originalStyles.width = document.body.style.width;
document.body.style.overflow = 'hidden';
document.body.style.width = '100vw';
};
const unblock = () => {
document.body.style.overflow = originalStyles.overflow;
document.body.style.width = originalStyles.width;
};
watch(isActive, (shouldBlock) => {
if (shouldBlock) {
block();
} else {
unblock();
}
}, { immediate: true });
onBeforeUnmount(() => {
unblock();
});
}

View File

@@ -0,0 +1,150 @@
<template>
<div
v-if="isRendered"
class="modal-container"
>
<ModalOverlay
@transitionedOut="onOverlayTransitionedOut"
@click="onBackgroundOverlayClick"
:show="isOpen"
/>
<ModalContent
class="modal-content"
:show="isOpen"
@transitionedOut="onContentTransitionedOut"
>
<slot />
</ModalContent>
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, watchEffect, nextTick,
} from 'vue';
import ModalOverlay from './ModalOverlay.vue';
import ModalContent from './ModalContent.vue';
import { useLockBodyBackgroundScroll } from './Hooks/UseLockBodyBackgroundScroll';
import { useCurrentFocusToggle } from './Hooks/UseCurrentFocusToggle';
import { useEscapeKeyListener } from './Hooks/UseEscapeKeyListener';
import { useAllTrueWatcher } from './Hooks/UseAllTrueWatcher';
export default defineComponent({
components: {
ModalOverlay,
ModalContent,
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
input: (isOpen: boolean) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
props: {
value: {
type: Boolean,
required: true,
},
closeOnOutsideClick: {
type: Boolean,
default: true,
},
},
setup(props, { emit }) {
const isRendered = ref(false);
const isOpen = ref(false);
const overlayTransitionedOut = ref(false);
const contentTransitionedOut = ref(false);
useLockBodyBackgroundScroll(isOpen);
useCurrentFocusToggle(isOpen);
useEscapeKeyListener(() => handleEscapeKeyUp());
const {
onAllConditionsMet: onModalFullyTransitionedOut,
resetAllConditions: resetTransitionStatus,
} = useAllTrueWatcher(overlayTransitionedOut, contentTransitionedOut);
onModalFullyTransitionedOut(() => {
isRendered.value = false;
resetTransitionStatus();
if (props.value) {
emit('input', false);
}
});
watchEffect(() => {
if (props.value) {
open();
} else {
close();
}
});
function onOverlayTransitionedOut() {
overlayTransitionedOut.value = true;
}
function onContentTransitionedOut() {
contentTransitionedOut.value = true;
}
function handleEscapeKeyUp() {
close();
}
function close() {
if (!isRendered.value) {
return;
}
isOpen.value = false;
if (props.value) {
emit('input', false);
}
}
function open() {
if (isRendered.value) {
return;
}
isRendered.value = true;
nextTick(() => { // Let the modal render first
isOpen.value = true;
});
if (!props.value) {
emit('input', true);
}
}
function onBackgroundOverlayClick() {
if (props.closeOnOutsideClick) {
close();
}
}
return {
isRendered,
onBackgroundOverlayClick,
onOverlayTransitionedOut,
onContentTransitionedOut,
isOpen,
};
},
});
</script>
<style scoped lang="scss">
.modal-container {
position: fixed;
box-sizing: border-box;
left: 0;
top: 0;
width: 100%;
height: 100vh;
z-index: 999;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<transition
name="modal-content-transition"
@after-leave="onAfterTransitionLeave"
>
<div v-if="show" class="modal-content-wrapper">
<div
ref="modalElement"
class="modal-content-content"
role="dialog"
aria-expanded="true"
aria-modal="true"
>
<slot />
</div>
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
export default defineComponent({
props: {
show: {
type: Boolean,
default: false,
},
},
emits: [
'transitionedOut',
],
setup(_, { emit }) {
const modalElement = ref<HTMLElement>();
function onAfterTransitionLeave() {
emit('transitionedOut');
}
return {
onAfterTransitionLeave,
modalElement,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
$modal-content-transition-duration: 400ms;
$modal-content-color-shadow: $color-on-surface;
$modal-content-color-background: $color-surface;
$modal-content-offset-upward: 20px;
@mixin scrollable() {
overflow-y: auto;
max-height: 90vh;
}
.modal-content-wrapper {
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.modal-content-content {
position: relative;
overflow: hidden;
box-sizing: border-box;
pointer-events: auto;
width: auto;
height: auto;
max-width: 600px;
background-color: $color-surface;
border-radius: 3px;
box-shadow: 0 20px 60px -2px $color-on-surface;
@include scrollable;
}
@include fade-slide-transition('modal-content-transition', $modal-content-transition-duration, $modal-content-offset-upward);
</style>

View File

@@ -0,0 +1,87 @@
<template>
<ModalContainer
v-model="showDialog"
>
<div class="dialog">
<div class="dialog__content">
<slot />
</div>
<div
class="dialog__close-button"
@click="hide"
>
<font-awesome-icon
:icon="['fas', 'times']"
/>
</div>
</div>
</ModalContainer>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import ModalContainer from './ModalContainer.vue';
export default defineComponent({
components: {
ModalContainer,
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
input: (isOpen: boolean) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
props: {
value: {
type: Boolean,
required: true,
},
},
setup(props, { emit }) {
const showDialog = computed({
get: () => props.value,
set: (value) => {
if (value !== props.value) {
emit('input', value);
}
},
});
function hide() {
showDialog.value = false;
}
return {
showDialog,
hide,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.dialog {
margin-bottom: 10px;
display: flex;
flex-direction: row;
&__content {
margin: 5%;
}
&__close-button {
color: $color-primary-dark;
width: auto;
font-size: 1.5em;
margin-right: 0.25em;
align-self: flex-start;
@include clickable;
@include hover-or-touch {
color: $color-primary;
}
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<transition
name="modal-overlay-transition"
@after-leave="onAfterTransitionLeave"
>
<div
v-if="show"
class="modal-overlay-background"
aria-expanded="true"
@click.self.stop="onClick"
/>
</transition>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
show: {
type: Boolean,
default: false,
},
},
emits: [
'click',
'transitionedOut',
],
setup(_, { emit }) {
function onAfterTransitionLeave() {
emit('transitionedOut');
}
function onClick() {
emit('click');
}
return {
onAfterTransitionLeave,
onClick,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
$modal-overlay-transition-duration: 50ms;
$modal-overlay-color-background: $color-on-surface;
.modal-overlay-background {
position: fixed;
box-sizing: border-box;
left: 0;
top: 0;
width: 100%;
height: 100vh;
background: rgba($modal-overlay-color-background, 0.3);
opacity: 1;
}
@include fade-slide-transition('modal-overlay-transition', $modal-overlay-transition-duration);
</style>

View File

@@ -1,92 +0,0 @@
<template>
<modal
:name="name"
:adaptive="true"
height="auto">
<div class="dialog">
<div class="dialog__content">
<slot />
</div>
<div class="dialog__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
@click="hide"
/>
</div>
</div>
</modal>
</template>
<script lang="ts">
import { defineComponent, onMounted } from 'vue';
let idCounter = 0;
export default defineComponent({
setup() {
const name = (++idCounter).toString();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let modal: any;
onMounted(async () => {
// Hack until Vue 3, so we can use vue-js-modal
const main = await import('@/presentation/main');
const { getVue } = main;
modal = getVue().$modal;
});
function show(): void {
modal.show(name);
}
function hide(): void {
modal.hide();
}
return {
name,
modal,
hide,
show,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
@mixin scrollable() {
overflow-y: auto;
max-height: 100vh;
}
.dialog {
color: $color-surface;
font-family: $font-normal;
margin-bottom: 10px;
display: flex;
flex-direction: row;
@include scrollable;
&__content {
color: $color-on-surface;
width: 100%;
margin: 5%;
}
&__close-button {
color: $color-primary-dark;
width: auto;
font-size: 1.5em;
margin-right: 0.25em;
align-self: flex-start;
@include clickable;
@include hover-or-touch {
color: $color-primary;
}
}
}
</style>

View File

@@ -33,11 +33,11 @@
</div> </div>
<div class="footer__section__item"> <div class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'user-secret']" /> <font-awesome-icon class="icon" :icon="['fas', 'user-secret']" />
<a @click="privacyDialog.show()">Privacy</a> <a @click="showPrivacyDialog()">Privacy</a>
</div> </div>
</div> </div>
</div> </div>
<ModalDialog ref="privacyDialog"> <ModalDialog v-model="isPrivacyDialogVisible">
<PrivacyPolicy /> <PrivacyPolicy />
</ModalDialog> </ModalDialog>
</div> </div>
@@ -46,7 +46,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, computed } from 'vue'; import { defineComponent, ref, computed } from 'vue';
import { Environment } from '@/application/Environment/Environment'; import { Environment } from '@/application/Environment/Environment';
import ModalDialog from '@/presentation/components/Shared/ModalDialog.vue'; import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication'; import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import DownloadUrlList from './DownloadUrlList.vue'; import DownloadUrlList from './DownloadUrlList.vue';
import PrivacyPolicy from './PrivacyPolicy.vue'; import PrivacyPolicy from './PrivacyPolicy.vue';
@@ -62,7 +62,7 @@ export default defineComponent({
setup() { setup() {
const { info } = useApplication(); const { info } = useApplication();
const privacyDialog = ref<typeof ModalDialog>(); const isPrivacyDialogVisible = ref(false);
const version = computed<string>(() => info.version.toString()); const version = computed<string>(() => info.version.toString());
@@ -74,9 +74,14 @@ export default defineComponent({
const feedbackUrl = computed<string>(() => info.feedbackUrl); const feedbackUrl = computed<string>(() => info.feedbackUrl);
function showPrivacyDialog() {
isPrivacyDialogVisible.value = true;
}
return { return {
isDesktop, isDesktop,
privacyDialog, isPrivacyDialogVisible,
showPrivacyDialog,
version, version,
homepageUrl, homepageUrl,
repositoryUrl, repositoryUrl,

View File

@@ -1,5 +1,7 @@
import Vue from 'vue'; /* eslint-disable */
declare module '*.vue' { declare module '*.vue' {
export default Vue; import { DefineComponent } from 'vue';
const component: DefineComponent;
export default component;
} }

4
tests/bootstrap/setup.ts Normal file
View File

@@ -0,0 +1,4 @@
import 'mocha';
import { enableAutoDestroy } from '@vue/test-utils';
enableAutoDestroy(afterEach);

View File

@@ -8,17 +8,18 @@ describe('NonCollapsingDirective', () => {
describe('NonCollapsing', () => { describe('NonCollapsing', () => {
it('adds expected attribute to the element when inserted', () => { it('adds expected attribute to the element when inserted', () => {
// arrange // arrange
const element = getElementMock(); const element = createElementMock();
// act // act
NonCollapsing.inserted(element, undefined, undefined, undefined); NonCollapsing.inserted(element, undefined, undefined, undefined);
// assert // assert
expect(element.hasAttribute(expectedAttributeName)); expect(element.hasAttribute(expectedAttributeName));
}); });
}); });
describe('hasDirective', () => { describe('hasDirective', () => {
it('returns true if the element has expected attribute', () => { it('returns true if the element has expected attribute', () => {
// arrange // arrange
const element = getElementMock(); const element = createElementMock();
element.setAttribute(expectedAttributeName, undefined); element.setAttribute(expectedAttributeName, undefined);
// act // act
const actual = hasDirective(element); const actual = hasDirective(element);
@@ -27,8 +28,8 @@ describe('NonCollapsingDirective', () => {
}); });
it('returns true if the element has a parent with expected attribute', () => { it('returns true if the element has a parent with expected attribute', () => {
// arrange // arrange
const parent = getElementMock(); const parent = createElementMock();
const element = getElementMock(); const element = createElementMock();
parent.appendChild(element); parent.appendChild(element);
element.setAttribute(expectedAttributeName, undefined); element.setAttribute(expectedAttributeName, undefined);
// act // act
@@ -38,16 +39,15 @@ describe('NonCollapsingDirective', () => {
}); });
it('returns false if nor the element or its parent has expected attribute', () => { it('returns false if nor the element or its parent has expected attribute', () => {
// arrange // arrange
const element = getElementMock(); const element = createElementMock();
// act // act
const actual = hasDirective(element); const actual = hasDirective(element);
// assert // assert
expect(actual).to.equal(false); expect(actual).to.equal(false);
}); });
}); });
});
function getElementMock(): HTMLElement { function createElementMock(): HTMLElement {
const element = document.createElement('div'); return document.createElement('div');
return element; }
} });

View File

@@ -0,0 +1,164 @@
import 'mocha';
import { ref, nextTick } from 'vue';
import { expect } from 'chai';
import { useAllTrueWatcher } from '@/presentation/components/Shared/Modal/Hooks/UseAllTrueWatcher';
describe('useAllTrueWatcher', () => {
describe('onAllConditionsMet', () => {
it('triggers the callback when all conditions turn true', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
onAllConditionsMet(() => {
callbackCalled = true;
});
condition1.value = true;
condition2.value = true;
await nextTick();
// assert
expect(callbackCalled).to.equal(true);
});
it('instantly triggers the callback if conditions are true on callback registration', async () => {
// arrange
const condition1 = ref(true);
const condition2 = ref(true);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
onAllConditionsMet(() => {
callbackCalled = true;
});
await nextTick();
// assert
expect(callbackCalled).to.equal(true);
});
it('does not trigger the callback unless all conditions are met', async () => {
// arrange
const condition1 = ref(true);
const condition2 = ref(false);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
onAllConditionsMet(() => {
callbackCalled = true;
});
await nextTick();
// assert
expect(callbackCalled).to.be.equal(false);
});
it('triggers all registered callbacks once all conditions are satisfied', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCount = 0;
// act
onAllConditionsMet(() => callbackCount++);
onAllConditionsMet(() => callbackCount++);
condition1.value = true;
condition2.value = true;
await nextTick();
// assert
expect(callbackCount).to.equal(2);
});
it('ensures each callback is invoked only once for a single condition set', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet } = useAllTrueWatcher(condition1, condition2);
let callbackCount = 0;
// act
onAllConditionsMet(() => callbackCount++);
condition1.value = true;
condition2.value = true;
condition1.value = false;
condition1.value = true;
await nextTick();
// assert
expect(callbackCount).to.equal(1);
});
it('triggers the callback after conditions are sequentially met post-reset', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet, resetAllConditions } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
onAllConditionsMet(() => {
callbackCalled = true;
});
condition1.value = true;
resetAllConditions();
condition1.value = true;
condition2.value = true;
await nextTick();
// assert
expect(callbackCalled).to.equal(true);
});
it('avoids triggering the callback for single condition post-reset', async () => {
// arrange
const condition1 = ref(false);
const condition2 = ref(false);
const { onAllConditionsMet, resetAllConditions } = useAllTrueWatcher(condition1, condition2);
let callbackCalled = false;
// act
condition1.value = true;
condition2.value = true;
resetAllConditions();
onAllConditionsMet(() => {
callbackCalled = true;
});
condition1.value = true;
await nextTick();
// assert
expect(callbackCalled).to.equal(false);
});
});
describe('resetAllConditions', () => {
it('returns all conditions to their default false state', async () => {
// arrange
const condition1 = ref(true);
const condition2 = ref(true);
const { resetAllConditions } = useAllTrueWatcher(condition1, condition2);
// act
resetAllConditions();
await nextTick();
// assert
expect(condition1.value).to.be.equal(false);
expect(condition2.value).to.be.equal(false);
});
});
});

View File

@@ -0,0 +1,164 @@
import 'mocha';
import { ref, nextTick } from 'vue';
import { expect } from 'chai';
import { useCurrentFocusToggle } from '@/presentation/components/Shared/Modal/Hooks/UseCurrentFocusToggle';
describe('useCurrentFocusToggle', () => {
describe('initialization', () => {
it('blurs active element when initialized with disabled focus', async () => {
// arrange
const shouldDisableFocus = ref(true);
const testElement = createElementInBody('input');
testElement.focus();
// act
useCurrentFocusToggle(shouldDisableFocus);
await nextTick();
// assert
expect(!isFocused(testElement));
});
it('doesn\'t blur active element when initialized with enabled focus', async () => {
// arrange
const isCurrentFocusDisabled = ref(false);
const testElement = createElementInBody('input');
// act
testElement.focus();
useCurrentFocusToggle(isCurrentFocusDisabled);
await nextTick();
// assert
expect(isFocused(testElement));
});
});
describe('focus toggling', () => {
it('blurs when focus disabled programmatically', async () => {
// arrange
const shouldDisableFocus = ref(false);
const testElement = createElementInBody('input');
testElement.focus();
// act
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
// assert
expect(!isFocused(testElement));
});
it('restores focus when re-enabled', async () => {
// arrange
const isCurrentFocusDisabled = ref(true);
const testElement = createElementInBody('input');
// act
useCurrentFocusToggle(isCurrentFocusDisabled);
testElement.focus();
isCurrentFocusDisabled.value = true;
await nextTick();
isCurrentFocusDisabled.value = false;
await nextTick();
// assert
expect(isFocused(testElement));
});
it('maintains focus if not disabled', async () => {
// arrange
const isCurrentFocusDisabled = ref(false);
const testElement = createElementInBody('input');
// act
testElement.focus();
useCurrentFocusToggle(isCurrentFocusDisabled);
isCurrentFocusDisabled.value = false;
await nextTick();
// assert
expect(isFocused(testElement));
});
it('handles multiple toggles correctly', async () => {
// arrange
const shouldDisableFocus = ref(false);
const testElement = createElementInBody('input');
testElement.focus();
// act
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
shouldDisableFocus.value = false;
await nextTick();
shouldDisableFocus.value = true;
await nextTick();
// assert
expect(!isFocused(testElement));
});
});
describe('document.body handling', () => {
it('blurs body when focus is disabled while body is active', async () => {
// arrange
document.body.focus();
const shouldDisableFocus = ref(false);
// act
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
// assert
expect(!isFocused(document.body));
});
it('doesn\'t restore focus to document body once focus is re-enabled', async () => {
// arrange
document.body.focus();
const shouldDisableFocus = ref(false);
// act
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
shouldDisableFocus.value = false;
await nextTick();
// assert
expect(!isFocused(document.body));
});
});
it('handles removal of a previously focused element gracefully', async () => {
// arrange
const shouldDisableFocus = ref(true);
const testElement = createElementInBody('input');
testElement.focus();
useCurrentFocusToggle(shouldDisableFocus);
shouldDisableFocus.value = true;
await nextTick();
testElement.remove();
shouldDisableFocus.value = false;
await nextTick();
// assert
expect(!isFocused(testElement));
});
function createElementInBody(tagName: keyof HTMLElementTagNameMap): HTMLElement {
const element = document.createElement(tagName);
document.body.appendChild(element);
return element;
}
});
function isFocused(element: HTMLElement): boolean {
return document.activeElement === element;
}

View File

@@ -0,0 +1,119 @@
import 'mocha';
import { shallowMount } from '@vue/test-utils';
import { expect } from 'chai';
import { nextTick, defineComponent } from 'vue';
import { useEscapeKeyListener } from '@/presentation/components/Shared/Modal/Hooks/UseEscapeKeyListener';
describe('useEscapeKeyListener', () => {
it('executes the callback when the Escape key is pressed', async () => {
// arrange
let callbackCalled = false;
const callback = () => {
callbackCalled = true;
};
createComponent(callback);
// act
const event = new KeyboardEvent('keyup', { key: 'Escape' });
window.dispatchEvent(event);
await nextTick();
// assert
expect(callbackCalled).to.equal(true);
});
it('does not execute the callback for other key presses', async () => {
// arrange
let callbackCalled = false;
const callback = () => {
callbackCalled = true;
};
createComponent(callback);
// act
const event = new KeyboardEvent('keyup', { key: 'Enter' });
window.dispatchEvent(event);
await nextTick();
// assert
expect(callbackCalled).to.equal(false);
});
it('adds an event listener on component mount', () => {
// arrange
const { restore, isAddEventCalled } = createWindowEventSpies();
// act
createComponent();
// assert
expect(isAddEventCalled()).to.equal(true);
restore();
});
it('removes the event listener on component unmount', async () => {
// arrange
const { restore, isRemoveEventCalled } = createWindowEventSpies();
// act
const wrapper = createComponent();
wrapper.destroy();
await nextTick();
// assert
expect(isRemoveEventCalled()).to.equal(true);
restore();
});
});
function createComponent(callback = () => {}) {
return shallowMount(defineComponent({
setup() {
useEscapeKeyListener(callback);
},
template: '<div></div>',
}));
}
function createWindowEventSpies() {
let addEventCalled = false;
let removeEventCalled = false;
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void => {
if (type === 'keyup' && typeof listener === 'function') {
addEventCalled = true;
}
originalAddEventListener(type, listener, options);
};
window.removeEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void => {
if (type === 'keyup' && typeof listener === 'function') {
removeEventCalled = true;
}
originalRemoveEventListener(type, listener, options);
};
return {
restore: () => {
window.addEventListener = originalAddEventListener;
window.removeEventListener = originalRemoveEventListener;
},
isAddEventCalled() {
return addEventCalled;
},
isRemoveEventCalled() {
return removeEventCalled;
},
};
}

View File

@@ -0,0 +1,113 @@
import 'mocha';
import { shallowMount } from '@vue/test-utils';
import { expect } from 'chai';
import { ref, nextTick, defineComponent } from 'vue';
import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/UseLockBodyBackgroundScroll';
describe('useLockBodyBackgroundScroll', () => {
afterEach(() => {
document.body.style.overflow = '';
document.body.style.width = '';
});
describe('initialization', () => {
it('blocks scroll if initially active', async () => {
// arrange
createComponent(true);
// act
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('hidden');
expect(document.body.style.width).to.equal('100vw');
});
it('preserves initial styles if inactive', async () => {
// arrange
const originalOverflow = 'scroll';
const originalWidth = '90vw';
document.body.style.overflow = originalOverflow;
document.body.style.width = originalWidth;
// act
createComponent(false);
await nextTick();
// assert
expect(document.body.style.overflow).to.equal(originalOverflow);
expect(document.body.style.width).to.equal(originalWidth);
});
});
describe('toggling', () => {
it('blocks scroll when activated', async () => {
// arrange
const { isActive } = createComponent(false);
// act
isActive.value = true;
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('hidden');
expect(document.body.style.width).to.equal('100vw');
});
it('unblocks scroll when deactivated', async () => {
// arrange
const { isActive } = createComponent(true);
// act
isActive.value = false;
await nextTick();
// assert
expect(document.body.style.overflow).not.to.equal('hidden');
expect(document.body.style.width).not.to.equal('100vw');
});
});
describe('unmounting', () => {
it('restores original styles on unmount', async () => {
// arrange
const originalOverflow = 'scroll';
const originalWidth = '90vw';
document.body.style.overflow = originalOverflow;
document.body.style.width = originalWidth;
// act
const { component } = createComponent(true);
component.destroy();
await nextTick();
// assert
expect(document.body.style.overflow).to.equal(originalOverflow);
expect(document.body.style.width).to.equal(originalWidth);
});
it('resets styles on unmount', async () => {
// arrange
const { component } = createComponent(true);
// act
component.destroy();
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('');
expect(document.body.style.width).to.equal('');
});
});
});
function createComponent(initialIsActiveValue: boolean) {
const isActive = ref(initialIsActiveValue);
const component = shallowMount(defineComponent({
setup() {
useLockBodyBackgroundScroll(isActive);
},
template: '<div></div>',
}));
return { component, isActive };
}

View File

@@ -0,0 +1,206 @@
import 'mocha';
import { shallowMount } from '@vue/test-utils';
import { expect } from 'chai';
import ModalContainer from '@/presentation/components/Shared/Modal/ModalContainer.vue';
const DOM_MODAL_CONTAINER_SELECTOR = '.modal-container';
const COMPONENT_MODAL_OVERLAY_NAME = 'ModalOverlay';
const COMPONENT_MODAL_CONTENT_NAME = 'ModalContent';
describe('ModalContainer.vue', () => {
describe('rendering based on model prop', () => {
it('does not render when model prop is absent or false', () => {
// arrange
const wrapper = mountComponent({ modelValue: false });
// act
const modalContainer = wrapper.find(DOM_MODAL_CONTAINER_SELECTOR);
// assert
expect(modalContainer.exists()).to.equal(false);
});
it('renders modal container when model prop is true', () => {
// arrange
const wrapper = mountComponent({ modelValue: true });
// act
const modalContainer = wrapper.find(DOM_MODAL_CONTAINER_SELECTOR);
// assert
expect(modalContainer.exists()).to.equal(true);
});
});
describe('modal open/close', () => {
it('opens when model prop changes from false to true', async () => {
// arrange
const wrapper = mountComponent({ modelValue: false });
// act
await wrapper.setProps({ value: true });
// assert after updating props
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).isRendered).to.equal(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).isOpen).to.equal(true);
});
it('closes when model prop changes from true to false', async () => {
// arrange
const wrapper = mountComponent({ modelValue: true });
// act
await wrapper.setProps({ value: false });
// assert after updating props
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).isOpen).to.equal(false);
// isRendered will not be true directly due to transition
});
it('closes on pressing ESC key', async () => {
// arrange
const { triggerKeyUp, restore } = createWindowEventSpies();
const wrapper = mountComponent({ modelValue: true });
// act
const escapeEvent = new KeyboardEvent('keyup', { key: 'Escape' });
triggerKeyUp(escapeEvent);
await wrapper.vm.$nextTick();
// assert
expect(wrapper.emitted().input[0]).to.deep.equal([false]);
restore();
});
it('emit false value after overlay and content transitions out and model prop is true', async () => {
// arrange
const wrapper = mountComponent({ modelValue: true });
const overlayMock = wrapper.findComponent({ name: COMPONENT_MODAL_OVERLAY_NAME });
const contentMock = wrapper.findComponent({ name: COMPONENT_MODAL_CONTENT_NAME });
// act
overlayMock.vm.$emit('transitionedOut');
contentMock.vm.$emit('transitionedOut');
await wrapper.vm.$nextTick();
// assert
expect(wrapper.emitted().input[0]).to.deep.equal([false]);
});
});
it('renders provided slot content', () => {
// arrange
const expectedText = 'Slot content';
const slotContentClass = 'slot-content';
// act
const wrapper = mountComponent({
modelValue: true,
slotHtml: `<div class="${slotContentClass}">${expectedText}</div>`,
});
// assert
const slotWrapper = wrapper.find(`.${slotContentClass}`);
const slotText = slotWrapper.text();
expect(slotText).to.equal(expectedText);
});
describe('closeOnOutsideClick', () => {
it('does not close on overlay click if prop is false', async () => {
// arrange
const wrapper = mountComponent({ modelValue: true, closeOnOutsideClick: false });
// act
const overlayMock = wrapper.findComponent({ name: COMPONENT_MODAL_OVERLAY_NAME });
overlayMock.vm.$emit('click');
await wrapper.vm.$nextTick();
// assert
expect(wrapper.emitted().input).to.equal(undefined);
});
it('closes on overlay click if prop is true', async () => {
// arrange
const wrapper = mountComponent({ modelValue: true, closeOnOutsideClick: true });
// act
const overlayMock = wrapper.findComponent({ name: COMPONENT_MODAL_OVERLAY_NAME });
overlayMock.vm.$emit('click');
await wrapper.vm.$nextTick();
// assert
expect(wrapper.emitted().input[0]).to.deep.equal([false]);
});
});
});
function mountComponent(options: {
readonly modelValue: boolean,
readonly closeOnOutsideClick?: boolean,
readonly slotHtml?: string,
readonly attachToDocument?: boolean,
}) {
return shallowMount(ModalContainer as unknown, {
propsData: {
value: options.modelValue,
...(options.closeOnOutsideClick !== undefined ? {
closeOnOutsideClick: options.closeOnOutsideClick,
} : {}),
},
slots: options.slotHtml !== undefined ? { default: options.slotHtml } : undefined,
stubs: {
[COMPONENT_MODAL_OVERLAY_NAME]: {
name: COMPONENT_MODAL_OVERLAY_NAME,
template: '<div />',
},
[COMPONENT_MODAL_CONTENT_NAME]: {
name: COMPONENT_MODAL_CONTENT_NAME,
template: '<slot />',
},
},
});
}
function createWindowEventSpies() {
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
let savedListener: EventListenerOrEventListenerObject | null = null;
window.addEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void => {
if (type === 'keyup' && typeof listener === 'function') {
savedListener = listener;
}
originalAddEventListener.call(window, type, listener, options);
};
window.removeEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void => {
if (type === 'keyup' && typeof listener === 'function') {
savedListener = null;
}
originalRemoveEventListener.call(window, type, listener, options);
};
return {
triggerKeyUp: (event: KeyboardEvent) => {
if (savedListener) {
(savedListener as EventListener)(event);
}
},
restore: () => {
window.addEventListener = originalAddEventListener;
window.removeEventListener = originalRemoveEventListener;
},
};
}

View File

@@ -0,0 +1,104 @@
import 'mocha';
import { shallowMount } from '@vue/test-utils';
import { expect } from 'chai';
import ModalContent from '@/presentation/components/Shared/Modal/ModalContent.vue';
const DOM_MODAL_CONTENT_SELECTOR = '.modal-content-content';
const DOM_MODAL_CONTENT_WRAPPER_SELECTOR = '.modal-content-wrapper';
describe('ModalContent.vue', () => {
describe('rendering based on `show` prop', () => {
it('renders modal content when `show` prop is true', () => {
// arrange
const wrapper = mountComponent({ showProperty: true });
// act
const modalContentWrapper = wrapper.find(DOM_MODAL_CONTENT_WRAPPER_SELECTOR);
// assert
expect(modalContentWrapper.exists()).to.equal(true);
});
it('does not render modal content when `show` prop is false', () => {
// arrange
const wrapper = mountComponent({ showProperty: false });
// act
const modalContentWrapper = wrapper.find(DOM_MODAL_CONTENT_WRAPPER_SELECTOR);
// assert
expect(modalContentWrapper.exists()).to.equal(false);
});
it('does not render modal content by default', () => {
// arrange & act
const wrapper = mountComponent();
// assert
const modalContentWrapper = wrapper.find(DOM_MODAL_CONTENT_WRAPPER_SELECTOR);
expect(modalContentWrapper.exists()).to.equal(false);
});
});
it('renders slot content when provided', () => {
// arrange
const expectedText = 'Slot content';
const slotContentClass = 'slot-content';
// act
const wrapper = mountComponent({
showProperty: true,
slotHtml: `<div class="${slotContentClass}">${expectedText}</div>`,
});
// assert
const slotWrapper = wrapper.find(`.${slotContentClass}`);
const slotText = slotWrapper.text();
expect(slotText).to.equal(expectedText);
});
describe('aria attributes', () => {
it('sets aria-expanded to `true` when visible', () => {
// arrange & act
const wrapper = mountComponent({ showProperty: true });
// assert
const modalContent = wrapper.find(DOM_MODAL_CONTENT_SELECTOR);
expect(modalContent.attributes('aria-expanded')).to.equal('true');
});
it('always sets aria-modal to true for the modal content', () => {
// arrange & act
const wrapper = mountComponent({ showProperty: true });
// assert
const modalContent = wrapper.find(DOM_MODAL_CONTENT_SELECTOR);
expect(modalContent.attributes('aria-modal')).to.equal('true');
});
});
it('emits `transitionedOut` event after the transition leave', async () => {
// arrange
const wrapper = mountComponent({ showProperty: true });
// act
await wrapper.vm.$nextTick(); // Ensure the component reflects initial prop
wrapper.setProps({ show: false }); // Trigger the transition
await wrapper.vm.$nextTick(); // Allow the component to update
const transitionWrapper = wrapper.findComponent({ name: 'transition' });
transitionWrapper.vm.$emit('after-leave'); // Simulate the after-leave lifecycle hook of the transition
// assert
expect(wrapper.emitted().transitionedOut).to.have.length(1);
});
});
function mountComponent(options?: {
readonly showProperty?: boolean,
readonly slotHtml?: string,
}) {
return shallowMount(ModalContent as unknown, {
propsData: options?.showProperty !== undefined ? { show: options?.showProperty } : undefined,
slots: options?.slotHtml !== undefined ? { default: options?.slotHtml } : undefined,
});
}

View File

@@ -0,0 +1,83 @@
import 'mocha';
import { shallowMount, mount } from '@vue/test-utils';
import { expect } from 'chai';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import ModalContainer from '@/presentation/components/Shared/Modal/ModalContainer.vue';
const DOM_CLOSE_BUTTON_SELECTOR = '.dialog__close-button';
const MODAL_CONTAINER_COMPONENT_NAME = 'ModalContainer';
describe('ModalDialog.vue', () => {
it(`renders ${MODAL_CONTAINER_COMPONENT_NAME}`, () => {
// arrange & act
const wrapper = mountComponent();
// assert
const modalContainerWrapper = wrapper.findComponent({ name: MODAL_CONTAINER_COMPONENT_NAME });
expect(modalContainerWrapper.exists()).to.equal(true);
});
describe(`binds the visibility flag ${MODAL_CONTAINER_COMPONENT_NAME}`, () => {
it('given true', () => {
// arrange & act
const wrapper = mountComponent({ modelValue: true, deepMount: true });
// assert
const modalContainerWrapper = wrapper.findComponent(ModalContainer);
expect(modalContainerWrapper.props('value')).to.equal(true);
});
it('given false', () => {
// arrange & act
const wrapper = mountComponent({ modelValue: false, deepMount: true });
// assert
const modalContainerWrapper = wrapper.findComponent(ModalContainer);
expect(modalContainerWrapper.props('value')).to.equal(false);
});
});
describe('close button', () => {
it('renders the close button', async () => {
// arrange
const wrapper = mountComponent({ modelValue: true });
// act
const closeButton = wrapper.find(DOM_CLOSE_BUTTON_SELECTOR);
// assert
expect(closeButton.exists()).to.equal(true);
});
it('closes the modal when close button is clicked', async () => {
// arrange
const wrapper = mountComponent({ modelValue: true });
// act
const closeButton = wrapper.find(DOM_CLOSE_BUTTON_SELECTOR);
await closeButton.trigger('click');
await wrapper.vm.$nextTick();
// assert
expect(wrapper.emitted().input[0]).to.deep.equal([false]);
});
});
});
function mountComponent(options?: {
readonly modelValue?: boolean,
readonly slotHtml?: string,
readonly deepMount?: boolean,
}) {
const mountFunction = options?.deepMount === true ? mount : shallowMount;
const wrapper = mountFunction(ModalDialog as unknown, {
propsData: options?.modelValue !== undefined ? { value: options?.modelValue } : undefined,
slots: options?.slotHtml !== undefined ? { default: options?.slotHtml } : undefined,
stubs: options?.deepMount === true ? undefined : {
[MODAL_CONTAINER_COMPONENT_NAME]: {
name: MODAL_CONTAINER_COMPONENT_NAME,
template: '<slot />',
},
},
});
return wrapper;
}

View File

@@ -0,0 +1,106 @@
import 'mocha';
import { shallowMount } from '@vue/test-utils';
import { expect } from 'chai';
import ModalOverlay from '@/presentation/components/Shared/Modal/ModalOverlay.vue';
const DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR = '.modal-overlay-background';
describe('ModalOverlay.vue', () => {
describe('show', () => {
it('renders when prop is true', () => {
// arrange
const wrapper = mountComponent({ showProperty: true });
// act
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
// assert
expect(modalOverlayBackground.exists()).to.equal(true);
});
it('does not render prop is false', () => {
// arrange
const wrapper = mountComponent({ showProperty: false });
// act
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
// assert
expect(modalOverlayBackground.exists()).to.equal(false);
});
it('sets aria-expanded to `true` prop is true', () => {
// arrange & act
const wrapper = mountComponent({ showProperty: true });
// assert
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
expect(modalOverlayBackground.attributes('aria-expanded')).to.equal('true');
});
describe('on modification', () => {
it('does not render when initially visible then turned invisible', async () => {
// arrange
const wrapper = mountComponent({ showProperty: true });
await wrapper.vm.$nextTick();
// act
wrapper.setProps({ show: false });
await wrapper.vm.$nextTick();
// assert
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
expect(modalOverlayBackground.exists()).to.equal(false);
});
it('renders when initially invisible then turned visible', async () => {
// arrange
const wrapper = mountComponent({ showProperty: false });
await wrapper.vm.$nextTick();
// act
wrapper.setProps({ show: true });
await wrapper.vm.$nextTick();
// assert
const modalOverlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
expect(modalOverlayBackground.exists()).to.equal(true);
});
});
});
describe('event emission', () => {
it('emits `click` event when clicked', async () => {
// arrange
const wrapper = mountComponent({ showProperty: true });
// act
const overlayBackground = wrapper.find(DOM_MODAL_OVERLAY_BACKGROUND_SELECTOR);
await overlayBackground.trigger('click.self.stop');
// assert
expect(wrapper.emitted().click).to.have.length(1);
});
it('emits `transitionedOut` event after leaving transition', async () => {
// arrange
const wrapper = mountComponent({ showProperty: true });
await wrapper.vm.$nextTick();
// act
wrapper.setProps({ show: false });
await wrapper.vm.$nextTick();
const transitionWrapper = wrapper.findComponent({ name: 'transition' });
transitionWrapper.vm.$emit('after-leave');
// assert
expect(wrapper.emitted().transitionedOut).to.have.length(1);
});
});
});
function mountComponent(options?: { readonly showProperty?: boolean }) {
return shallowMount(ModalOverlay as unknown, {
propsData: options?.showProperty !== undefined ? { show: options?.showProperty } : undefined,
});
}