├── .eslintignore ├── .eslintrc ├── .github ├── scripts │ └── publish-dev-build └── workflows │ ├── build.yml │ ├── dev-builds.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .node-version ├── .npmignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── assets ├── bc3-clipboard-ui.png └── logo.svg ├── docs ├── handbook │ ├── 00_the_origin_of_stimulus.md │ ├── 01_introduction.md │ ├── 02_hello_stimulus.md │ ├── 03_building_something_real.md │ ├── 04_designing_for_resilience.md │ ├── 05_managing_state.md │ ├── 06_working_with_external_resources.md │ └── 07_installing_stimulus.md └── reference │ ├── actions.md │ ├── controllers.md │ ├── css_classes.md │ ├── lifecycle_callbacks.md │ ├── outlets.md │ ├── targets.md │ ├── using_typescript.md │ └── values.md ├── examples ├── .babelrc ├── controllers │ ├── clipboard_controller.js │ ├── content_loader_controller.js │ ├── hello_controller.js │ ├── slideshow_controller.js │ └── tabs_controller.js ├── index.js ├── package.json ├── public │ ├── examples.css │ ├── favicon.ico │ ├── logo.svg │ └── main.css ├── server.js ├── views │ ├── clipboard.ejs │ ├── content-loader.ejs │ ├── hello.ejs │ ├── layout │ │ ├── head.ejs │ │ └── tail.ejs │ ├── slideshow.ejs │ └── tabs.ejs ├── webpack.config.js └── yarn.lock ├── karma.conf.cjs ├── package.json ├── packages └── stimulus │ ├── .gitignore │ ├── .npmignore │ ├── index.d.ts │ ├── index.js │ ├── package.json │ ├── rollup.config.js │ ├── webpack-helpers.d.ts │ ├── webpack-helpers.js │ └── yarn.lock ├── rollup.config.js ├── src ├── core │ ├── action.ts │ ├── action_descriptor.ts │ ├── action_event.ts │ ├── application.ts │ ├── binding.ts │ ├── binding_observer.ts │ ├── blessing.ts │ ├── class_map.ts │ ├── class_properties.ts │ ├── constructor.ts │ ├── context.ts │ ├── controller.ts │ ├── data_map.ts │ ├── definition.ts │ ├── dispatcher.ts │ ├── error_handler.ts │ ├── event_listener.ts │ ├── guide.ts │ ├── index.ts │ ├── inheritable_statics.ts │ ├── logger.ts │ ├── module.ts │ ├── outlet_observer.ts │ ├── outlet_properties.ts │ ├── outlet_set.ts │ ├── router.ts │ ├── schema.ts │ ├── scope.ts │ ├── scope_observer.ts │ ├── selectors.ts │ ├── string_helpers.ts │ ├── target_observer.ts │ ├── target_properties.ts │ ├── target_set.ts │ ├── utils.ts │ ├── value_observer.ts │ └── value_properties.ts ├── index.d.ts ├── index.js ├── index.ts ├── multimap │ ├── index.ts │ ├── indexed_multimap.ts │ ├── multimap.ts │ └── set_operations.ts ├── mutation-observers │ ├── attribute_observer.ts │ ├── element_observer.ts │ ├── index.ts │ ├── selector_observer.ts │ ├── string_map_observer.ts │ ├── token_list_observer.ts │ └── value_list_observer.ts └── tests │ ├── cases │ ├── application_test_case.ts │ ├── controller_test_case.ts │ ├── dom_test_case.ts │ ├── index.ts │ ├── log_controller_test_case.ts │ ├── observer_test_case.ts │ └── test_case.ts │ ├── controllers │ ├── class_controller.ts │ ├── default_value_controller.ts │ ├── log_controller.ts │ ├── outlet_controller.ts │ ├── target_controller.ts │ └── value_controller.ts │ ├── fixtures │ └── application_start │ │ ├── helpers.ts │ │ ├── index.html │ │ └── index.ts │ ├── index.ts │ └── modules │ ├── core │ ├── action_click_filter_tests.ts │ ├── action_keyboard_filter_tests.ts │ ├── action_ordering_tests.ts │ ├── action_params_case_insensitive_tests.ts │ ├── action_params_tests.ts │ ├── action_tests.ts │ ├── action_timing_tests.ts │ ├── application_start_tests.ts │ ├── application_tests.ts │ ├── class_tests.ts │ ├── data_tests.ts │ ├── default_value_tests.ts │ ├── error_handler_tests.ts │ ├── es6_tests.ts │ ├── event_options_tests.ts │ ├── extending_application_tests.ts │ ├── legacy_target_tests.ts │ ├── lifecycle_tests.ts │ ├── loading_tests.ts │ ├── memory_tests.ts │ ├── outlet_order_tests.ts │ ├── outlet_tests.ts │ ├── string_helpers_tests.ts │ ├── target_tests.ts │ ├── value_properties_tests.ts │ └── value_tests.ts │ └── mutation-observers │ ├── attribute_observer_tests.ts │ ├── selector_observer_tests.ts │ ├── token_list_observer_tests.ts │ └── value_list_observer_tests.ts ├── tsconfig.json ├── tsconfig.test.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "prettier" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier" 13 | ], 14 | "rules": { 15 | "prefer-rest-params": "off", 16 | "prettier/prettier": ["error"], 17 | "@typescript-eslint/no-explicit-any": "off", 18 | "@typescript-eslint/no-non-null-assertion": "off", 19 | "@typescript-eslint/no-empty-function": "off", 20 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 21 | "@typescript-eslint/ban-types": ["error", { 22 | "types": { 23 | "Function": false, 24 | "Object": false, 25 | "{}": false 26 | } 27 | }] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/scripts/publish-dev-build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | DEV_BUILD_REPO_NAME="hotwired/dev-builds" 5 | DEV_BUILD_ORIGIN_URL="https://${1}@github.com/${DEV_BUILD_REPO_NAME}.git" 6 | BUILD_PATH="$HOME/publish-dev-build" 7 | 8 | mkdir "$BUILD_PATH" 9 | 10 | cd "$GITHUB_WORKSPACE" 11 | package_name="$(jq -r .name package.json)" 12 | package_files=( dist package.json ) 13 | tag="${package_name}/${GITHUB_SHA:0:7}" 14 | 15 | name="$(git log -n 1 --format=format:%cn)" 16 | email="$(git log -n 1 --format=format:%ce)" 17 | subject="$(git log -n 1 --format=format:%s)" 18 | date="$(git log -n 1 --format=format:%ai)" 19 | url="https://github.com/${GITHUB_REPOSITORY}/tree/${GITHUB_SHA}" 20 | message="$tag $subject"$'\n\n'"$url" 21 | 22 | cp -R "${package_files[@]}" "$BUILD_PATH" 23 | 24 | cd "$BUILD_PATH" 25 | git init . 26 | git remote add origin "$DEV_BUILD_ORIGIN_URL" 27 | git symbolic-ref HEAD refs/heads/publish-dev-build 28 | git add "${package_files[@]}" 29 | 30 | GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ 31 | GIT_AUTHOR_NAME="$name" GIT_COMMITTER_NAME="$name" \ 32 | GIT_AUTHOR_EMAIL="$email" GIT_COMMITTER_EMAIL="$email" \ 33 | git commit -m "$message" 34 | 35 | git tag "$tag" 36 | [ "$GITHUB_REF" != "refs/heads/main" ] || git tag -f "${package_name}/latest" 37 | git push -f --tags 38 | 39 | echo done 40 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node: [18, 19, 20, 21] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Node v${{ matrix.node }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node }} 20 | cache: 'yarn' 21 | 22 | - name: Install Dependencies 23 | run: yarn install --frozen-lockfile 24 | 25 | - name: Build 26 | run: yarn build 27 | 28 | - name: Test Build 29 | run: yarn build:test 30 | -------------------------------------------------------------------------------- /.github/workflows/dev-builds.yml: -------------------------------------------------------------------------------- 1 | name: dev-builds 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - 'builds/**' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | cache: 'yarn' 19 | 20 | - run: yarn install 21 | - run: yarn build 22 | 23 | - name: Publish dev build 24 | run: .github/scripts/publish-dev-build '${{ secrets.DEV_BUILD_GITHUB_TOKEN }}' 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: Lint 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 21 16 | cache: 'yarn' 17 | 18 | - name: Install Dependencies 19 | run: yarn install --frozen-lockfile 20 | 21 | - name: Lint 22 | run: yarn lint 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: browser-actions/setup-chrome@v1 13 | - uses: browser-actions/setup-firefox@v1 14 | 15 | - name: Setup Node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | cache: 'yarn' 20 | 21 | - name: Install Dependencies 22 | run: yarn install --frozen-lockfile 23 | 24 | - name: Test 25 | run: yarn test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | dist/ 3 | node_modules/ 4 | docs/api/ 5 | *.log 6 | *.tsbuildinfo 7 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.11.0 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/tests/ 2 | dist/tests/ 3 | tsconfig* 4 | *.log 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 120, 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Please see [our GitHub "Releases" page](https://github.com/hotwired/stimulus/releases). 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright © 2021 Basecamp, LLC. 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 | # Stimulus Stimulus 2 | 3 | ### A modest JavaScript framework for the HTML you already have 4 | 5 | Stimulus is a JavaScript framework with modest ambitions. It doesn't seek to take over your entire front-end—in fact, it's not concerned with rendering HTML at all. Instead, it's designed to augment your HTML with just enough behavior to make it shine. Stimulus pairs beautifully with [Turbo](https://turbo.hotwired.dev) to provide a complete solution for fast, compelling applications with a minimal amount of effort. 6 | 7 | How does it work? Sprinkle your HTML with controller, target, and action attributes: 8 | 9 | ```html 10 |
11 | 12 | 13 | 14 | 15 | 16 |
17 | ``` 18 | 19 | Then write a compatible controller. Stimulus brings it to life automatically: 20 | 21 | ```js 22 | // hello_controller.js 23 | import { Controller } from "@hotwired/stimulus" 24 | 25 | export default class extends Controller { 26 | static targets = [ "name", "output" ] 27 | 28 | greet() { 29 | this.outputTarget.textContent = 30 | `Hello, ${this.nameTarget.value}!` 31 | } 32 | } 33 | ``` 34 | 35 | Stimulus continuously watches the page, kicking in as soon as attributes appear or disappear. It works with any update to the DOM, regardless of whether it comes from a full page load, a [Turbo](https://turbo.hotwired.dev) page change, or an Ajax request. Stimulus manages the whole lifecycle. 36 | 37 | You can write your first controller in five minutes by following along in the [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introduction). 38 | 39 | You can read more about why we created this new framework in [The Origin of Stimulus](https://stimulus.hotwired.dev/handbook/origin). 40 | 41 | ## Installing Stimulus 42 | 43 | You can use Stimulus with any asset packaging systems. And if you prefer no build step at all, just drop a ` 10 | 11 | 12 | 13 |
14 | 27 | 28 |
29 | -------------------------------------------------------------------------------- /examples/views/layout/tail.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/views/slideshow.ejs: -------------------------------------------------------------------------------- 1 | <%- include("layout/head") %> 2 | 3 |
4 | 5 | 6 | 7 |
🐵
8 |
🙈
9 |
🙉
10 |
🙊
11 |
12 | 13 | <%- include("layout/tail") %> 14 | -------------------------------------------------------------------------------- /examples/views/tabs.ejs: -------------------------------------------------------------------------------- 1 | <%- include("layout/head") %> 2 | 3 |
4 |

This tabbed interface is operated by focusing on a button and pressing the left and right keys.

5 |
6 | 14 | 22 |
23 | 24 |
🐵
32 |
🙈
40 |
41 | 42 | <%- include("layout/tail") %> 43 | -------------------------------------------------------------------------------- /examples/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | entry: { 5 | main: "./index.js" 6 | }, 7 | 8 | output: { 9 | filename: "[name].js" 10 | }, 11 | 12 | mode: "development", 13 | devtool: "inline-source-map", 14 | 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.ts$/, 19 | use: [ 20 | { loader: "ts-loader" } 21 | ] 22 | }, 23 | { 24 | test: /\.js$/, 25 | exclude: [ 26 | /node_modules/ 27 | ], 28 | use: [ 29 | { loader: "babel-loader" } 30 | ] 31 | } 32 | ] 33 | }, 34 | 35 | resolve: { 36 | extensions: [".ts", ".js"], 37 | modules: ["src", "node_modules"], 38 | alias: { 39 | "@hotwired/stimulus": path.resolve(__dirname, "../dist/stimulus.js"), 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /karma.conf.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | basePath: ".", 3 | 4 | browsers: ["ChromeHeadless", "FirefoxHeadless"], 5 | 6 | frameworks: ["qunit"], 7 | 8 | reporters: ["progress"], 9 | 10 | singleRun: true, 11 | 12 | autoWatch: false, 13 | 14 | files: [ 15 | "dist/tests/index.js", 16 | { pattern: "src/tests/fixtures/**/*", watched: true, served: true, included: false }, 17 | { pattern: "dist/tests/fixtures/**/*", watched: true, served: true, included: false }, 18 | ], 19 | 20 | preprocessors: { 21 | "dist/tests/**/*.js": ["webpack"], 22 | }, 23 | 24 | webpack: { 25 | mode: "development", 26 | resolve: { 27 | extensions: [".js"], 28 | }, 29 | }, 30 | 31 | client: { 32 | clearContext: false, 33 | qunit: { 34 | showUI: true, 35 | }, 36 | }, 37 | 38 | hostname: "0.0.0.0", 39 | 40 | captureTimeout: 180000, 41 | browserDisconnectTimeout: 180000, 42 | browserDisconnectTolerance: 3, 43 | browserNoActivityTimeout: 300000, 44 | } 45 | 46 | module.exports = function (karmaConfig) { 47 | karmaConfig.set(config) 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hotwired/stimulus", 3 | "version": "3.2.2", 4 | "license": "MIT", 5 | "description": "A modest JavaScript framework for the HTML you already have.", 6 | "author": "Basecamp, LLC", 7 | "contributors": [ 8 | "David Heinemeier Hansson ", 9 | "Javan Makhmali ", 10 | "Sam Stephenson " 11 | ], 12 | "homepage": "https://stimulus.hotwired.dev", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/hotwired/stimulus.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/hotwired/stimulus/issues" 19 | }, 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "module": "dist/stimulus.js", 24 | "main": "dist/stimulus.umd.js", 25 | "types": "dist/types/index.d.ts", 26 | "files": [ 27 | "dist/stimulus.js", 28 | "dist/stimulus.umd.js", 29 | "dist/types/**/*" 30 | ], 31 | "scripts": { 32 | "clean": "rm -fr dist", 33 | "types": "tsc --noEmit false --declaration true --emitDeclarationOnly true --outDir dist/types", 34 | "prebuild": "yarn build:test", 35 | "build": "yarn types && rollup -c", 36 | "build:test": "tsc -b tsconfig.test.json", 37 | "watch": "rollup -wc", 38 | "prerelease": "yarn clean && yarn build && yarn build:test && git --no-pager diff && echo && npm pack --dry-run", 39 | "release": "npm publish", 40 | "start": "concurrently \"npm:watch\" \"npm:start:examples\"", 41 | "start:examples": "cd examples && yarn install && node server.js", 42 | "test": "yarn build:test && karma start karma.conf.cjs", 43 | "test:watch": "yarn test --auto-watch --no-single-run", 44 | "lint": "eslint . --ext .ts", 45 | "format": "yarn lint --fix" 46 | }, 47 | "devDependencies": { 48 | "@rollup/plugin-node-resolve": "^16.0.1", 49 | "@rollup/plugin-typescript": "^11.1.1", 50 | "@types/qunit": "^2.19.10", 51 | "@types/webpack-env": "^1.14.0", 52 | "@typescript-eslint/eslint-plugin": "^5.59.11", 53 | "@typescript-eslint/parser": "^5.59.11", 54 | "concurrently": "^9.1.2", 55 | "eslint": "^8.43.0", 56 | "eslint-config-prettier": "^8.8.0", 57 | "eslint-plugin-prettier": "^4.2.1", 58 | "karma": "^6.4.4", 59 | "karma-chrome-launcher": "^3.2.0", 60 | "karma-firefox-launcher": "^2.1.3", 61 | "karma-qunit": "^4.2.1", 62 | "karma-webpack": "^4.0.2", 63 | "prettier": "^2.8.8", 64 | "qunit": "^2.20.0", 65 | "rollup": "^2.53", 66 | "rollup-plugin-terser": "^7.0.2", 67 | "ts-loader": "^9.4.3", 68 | "tslib": "^2.5.3", 69 | "typescript": "^5.1.3", 70 | "webpack": "^4.47.0" 71 | }, 72 | "resolutions": { 73 | "webdriverio": "^7.19.5" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/stimulus/.gitignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /packages/stimulus/.npmignore: -------------------------------------------------------------------------------- 1 | rollup.config.js 2 | *.log 3 | -------------------------------------------------------------------------------- /packages/stimulus/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "@hotwired/stimulus" 2 | export as namespace Stimulus 3 | -------------------------------------------------------------------------------- /packages/stimulus/index.js: -------------------------------------------------------------------------------- 1 | export * from "@hotwired/stimulus" 2 | -------------------------------------------------------------------------------- /packages/stimulus/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stimulus", 3 | "version": "3.2.2", 4 | "description": "Stimulus JavaScript framework", 5 | "homepage": "https://stimulus.hotwired.dev", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/hotwired/stimulus.git" 9 | }, 10 | "author": "Basecamp, LLC", 11 | "contributors": [ 12 | "David Heinemeier Hansson ", 13 | "Javan Makhmali ", 14 | "Sam Stephenson " 15 | ], 16 | "module": "./dist/stimulus.js", 17 | "main": "./dist/stimulus.umd.js", 18 | "types": "./index.d.ts", 19 | "exports": { 20 | ".": { 21 | "main": "./dist/stimulus.umd.js", 22 | "browser": "./dist/stimulus.js", 23 | "import": "./dist/stimulus.js", 24 | "module": "./dist/stimulus.js", 25 | "umd": "./dist/stimulus.umd.js", 26 | "types": "./index.d.ts" 27 | }, 28 | "./webpack-helpers": { 29 | "main": "./dist/webpack-helpers.umd.js", 30 | "browser": "./dist/webpack-helpers.js", 31 | "import": "./dist/webpack-helpers.js", 32 | "module": "./dist/webpack-helpers.js", 33 | "umd": "./dist/webpack-helpers.umd.js", 34 | "types": "./webpack-helpers.d.ts" 35 | } 36 | }, 37 | "files": [ 38 | "index.d.ts", 39 | "dist/stimulus.js", 40 | "dist/stimulus.umd.js", 41 | "webpack-helpers.js", 42 | "webpack-helpers.d.ts", 43 | "dist/webpack-helpers.js", 44 | "dist/webpack-helpers.umd.js", 45 | "README.md" 46 | ], 47 | "license": "MIT", 48 | "dependencies": { 49 | "@hotwired/stimulus": "^3.2.2", 50 | "@hotwired/stimulus-webpack-helpers": "^1.0.0" 51 | }, 52 | "devDependencies": { 53 | "@rollup/plugin-node-resolve": "^13.0.0", 54 | "@rollup/plugin-typescript": "^8.2.1", 55 | "rollup": "^2.53" 56 | }, 57 | "scripts": { 58 | "clean": "rm -rf dist", 59 | "build": "rollup --config rollup.config.js", 60 | "prerelease": "yarn build && git --no-pager diff && echo && npm pack --dry-run", 61 | "release": "npm publish" 62 | }, 63 | "publishConfig": { 64 | "access": "public" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/stimulus/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve" 2 | 3 | export default [ 4 | { 5 | input: "index.js", 6 | output: [ 7 | { 8 | name: "Stimulus", 9 | file: "dist/stimulus.umd.js", 10 | format: "umd" 11 | }, 12 | { 13 | file: "dist/stimulus.js", 14 | format: "es" 15 | }, 16 | ], 17 | context: "window", 18 | plugins: [ 19 | resolve() 20 | ] 21 | }, 22 | { 23 | input: "webpack-helpers.js", 24 | output: [ 25 | { 26 | name: "StimulusWebpackHelpers", 27 | file: "dist/webpack-helpers.umd.js", 28 | format: "umd" 29 | }, 30 | { 31 | file: "dist/webpack-helpers.js", 32 | format: "es" 33 | }, 34 | ], 35 | context: "window", 36 | plugins: [ 37 | resolve() 38 | ] 39 | } 40 | ] 41 | -------------------------------------------------------------------------------- /packages/stimulus/webpack-helpers.d.ts: -------------------------------------------------------------------------------- 1 | export * from "@hotwired/stimulus-webpack-helpers" 2 | export as namespace StimulusWebpackHelpers 3 | -------------------------------------------------------------------------------- /packages/stimulus/webpack-helpers.js: -------------------------------------------------------------------------------- 1 | export * from "@hotwired/stimulus-webpack-helpers" 2 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve" 2 | import typescript from "@rollup/plugin-typescript" 3 | import { terser } from "rollup-plugin-terser" 4 | import { version } from "./package.json" 5 | 6 | const year = new Date().getFullYear() 7 | const banner = `/*\nStimulus ${version}\nCopyright © ${year} Basecamp, LLC\n */` 8 | 9 | export default [ 10 | { 11 | input: "src/index.js", 12 | output: [ 13 | { 14 | name: "Stimulus", 15 | file: "dist/stimulus.umd.js", 16 | format: "umd", 17 | banner 18 | }, 19 | { 20 | file: "dist/stimulus.js", 21 | format: "es", 22 | banner 23 | }, 24 | ], 25 | context: "window", 26 | plugins: [ 27 | resolve(), 28 | typescript() 29 | ] 30 | }, 31 | { 32 | input: "src/index.js", 33 | output: { 34 | file: "dist/stimulus.min.js", 35 | format: "es", 36 | banner, 37 | sourcemap: true 38 | }, 39 | context: "window", 40 | plugins: [ 41 | resolve(), 42 | typescript(), 43 | terser({ 44 | mangle: true, 45 | compress: true 46 | }) 47 | ] 48 | } 49 | ] -------------------------------------------------------------------------------- /src/core/action_descriptor.ts: -------------------------------------------------------------------------------- 1 | import type { Controller } from "./controller" 2 | 3 | export type ActionDescriptorFilters = Record 4 | export type ActionDescriptorFilter = (options: ActionDescriptorFilterOptions) => boolean 5 | type ActionDescriptorFilterOptions = { 6 | name: string 7 | value: boolean 8 | event: Event 9 | element: Element 10 | controller: Controller 11 | } 12 | 13 | export const defaultActionDescriptorFilters: ActionDescriptorFilters = { 14 | stop({ event, value }) { 15 | if (value) event.stopPropagation() 16 | 17 | return true 18 | }, 19 | 20 | prevent({ event, value }) { 21 | if (value) event.preventDefault() 22 | 23 | return true 24 | }, 25 | 26 | self({ event, value, element }) { 27 | if (value) { 28 | return element === event.target 29 | } else { 30 | return true 31 | } 32 | }, 33 | } 34 | 35 | export interface ActionDescriptor { 36 | eventTarget: EventTarget 37 | eventOptions: AddEventListenerOptions 38 | eventName: string 39 | identifier: string 40 | methodName: string 41 | keyFilter: string 42 | } 43 | 44 | // capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6 7 7 45 | const descriptorPattern = /^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/ 46 | 47 | export function parseActionDescriptorString(descriptorString: string): Partial { 48 | const source = descriptorString.trim() 49 | const matches = source.match(descriptorPattern) || [] 50 | let eventName = matches[2] 51 | let keyFilter = matches[3] 52 | 53 | if (keyFilter && !["keydown", "keyup", "keypress"].includes(eventName)) { 54 | eventName += `.${keyFilter}` 55 | keyFilter = "" 56 | } 57 | 58 | return { 59 | eventTarget: parseEventTarget(matches[4]), 60 | eventName, 61 | eventOptions: matches[7] ? parseEventOptions(matches[7]) : {}, 62 | identifier: matches[5], 63 | methodName: matches[6], 64 | keyFilter: matches[1] || keyFilter, 65 | } 66 | } 67 | 68 | function parseEventTarget(eventTargetName: string): EventTarget | undefined { 69 | if (eventTargetName == "window") { 70 | return window 71 | } else if (eventTargetName == "document") { 72 | return document 73 | } 74 | } 75 | 76 | function parseEventOptions(eventOptions: string): AddEventListenerOptions { 77 | return eventOptions 78 | .split(":") 79 | .reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {}) 80 | } 81 | 82 | export function stringifyEventTarget(eventTarget: EventTarget) { 83 | if (eventTarget == window) { 84 | return "window" 85 | } else if (eventTarget == document) { 86 | return "document" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/core/action_event.ts: -------------------------------------------------------------------------------- 1 | export interface ActionEvent extends Event { 2 | params: { [key: string]: any } 3 | } 4 | -------------------------------------------------------------------------------- /src/core/application.ts: -------------------------------------------------------------------------------- 1 | import { Controller, ControllerConstructor } from "./controller" 2 | import { Definition } from "./definition" 3 | import { Dispatcher } from "./dispatcher" 4 | import { ErrorHandler } from "./error_handler" 5 | import { Logger } from "./logger" 6 | import { Router } from "./router" 7 | import { Schema, defaultSchema } from "./schema" 8 | import { ActionDescriptorFilter, ActionDescriptorFilters, defaultActionDescriptorFilters } from "./action_descriptor" 9 | 10 | export class Application implements ErrorHandler { 11 | readonly element: Element 12 | readonly schema: Schema 13 | readonly dispatcher: Dispatcher 14 | readonly router: Router 15 | readonly actionDescriptorFilters: ActionDescriptorFilters 16 | logger: Logger = console 17 | debug = false 18 | 19 | static start(element?: Element, schema?: Schema): Application { 20 | const application = new this(element, schema) 21 | application.start() 22 | return application 23 | } 24 | 25 | constructor(element: Element = document.documentElement, schema: Schema = defaultSchema) { 26 | this.element = element 27 | this.schema = schema 28 | this.dispatcher = new Dispatcher(this) 29 | this.router = new Router(this) 30 | this.actionDescriptorFilters = { ...defaultActionDescriptorFilters } 31 | } 32 | 33 | async start() { 34 | await domReady() 35 | this.logDebugActivity("application", "starting") 36 | this.dispatcher.start() 37 | this.router.start() 38 | this.logDebugActivity("application", "start") 39 | } 40 | 41 | stop() { 42 | this.logDebugActivity("application", "stopping") 43 | this.dispatcher.stop() 44 | this.router.stop() 45 | this.logDebugActivity("application", "stop") 46 | } 47 | 48 | register(identifier: string, controllerConstructor: ControllerConstructor) { 49 | this.load({ identifier, controllerConstructor }) 50 | } 51 | 52 | registerActionOption(name: string, filter: ActionDescriptorFilter) { 53 | this.actionDescriptorFilters[name] = filter 54 | } 55 | 56 | load(...definitions: Definition[]): void 57 | load(definitions: Definition[]): void 58 | load(head: Definition | Definition[], ...rest: Definition[]) { 59 | const definitions = Array.isArray(head) ? head : [head, ...rest] 60 | definitions.forEach((definition) => { 61 | if ((definition.controllerConstructor as any).shouldLoad) { 62 | this.router.loadDefinition(definition) 63 | } 64 | }) 65 | } 66 | 67 | unload(...identifiers: string[]): void 68 | unload(identifiers: string[]): void 69 | unload(head: string | string[], ...rest: string[]) { 70 | const identifiers = Array.isArray(head) ? head : [head, ...rest] 71 | identifiers.forEach((identifier) => this.router.unloadIdentifier(identifier)) 72 | } 73 | 74 | // Controllers 75 | 76 | get controllers(): Controller[] { 77 | return this.router.contexts.map((context) => context.controller) 78 | } 79 | 80 | getControllerForElementAndIdentifier(element: Element, identifier: string): Controller | null { 81 | const context = this.router.getContextForElementAndIdentifier(element, identifier) 82 | return context ? context.controller : null 83 | } 84 | 85 | // Error handling 86 | 87 | handleError(error: Error, message: string, detail: object) { 88 | this.logger.error(`%s\n\n%o\n\n%o`, message, error, detail) 89 | 90 | window.onerror?.(message, "", 0, 0, error) 91 | } 92 | 93 | // Debug logging 94 | 95 | logDebugActivity = (identifier: string, functionName: string, detail: object = {}): void => { 96 | if (this.debug) { 97 | this.logFormattedMessage(identifier, functionName, detail) 98 | } 99 | } 100 | 101 | private logFormattedMessage(identifier: string, functionName: string, detail: object = {}) { 102 | detail = Object.assign({ application: this }, detail) 103 | 104 | this.logger.groupCollapsed(`${identifier} #${functionName}`) 105 | this.logger.log("details:", { ...detail }) 106 | this.logger.groupEnd() 107 | } 108 | } 109 | 110 | function domReady() { 111 | return new Promise((resolve) => { 112 | if (document.readyState == "loading") { 113 | document.addEventListener("DOMContentLoaded", () => resolve()) 114 | } else { 115 | resolve() 116 | } 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /src/core/binding.ts: -------------------------------------------------------------------------------- 1 | import { Action } from "./action" 2 | import { ActionEvent } from "./action_event" 3 | import { Context } from "./context" 4 | import { Controller } from "./controller" 5 | import { Scope } from "./scope" 6 | export class Binding { 7 | readonly context: Context 8 | readonly action: Action 9 | 10 | constructor(context: Context, action: Action) { 11 | this.context = context 12 | this.action = action 13 | } 14 | 15 | get index(): number { 16 | return this.action.index 17 | } 18 | 19 | get eventTarget(): EventTarget { 20 | return this.action.eventTarget 21 | } 22 | 23 | get eventOptions(): AddEventListenerOptions { 24 | return this.action.eventOptions 25 | } 26 | 27 | get identifier(): string { 28 | return this.context.identifier 29 | } 30 | 31 | handleEvent(event: Event) { 32 | const actionEvent = this.prepareActionEvent(event) 33 | if (this.willBeInvokedByEvent(event) && this.applyEventModifiers(actionEvent)) { 34 | this.invokeWithEvent(actionEvent) 35 | } 36 | } 37 | 38 | get eventName(): string { 39 | return this.action.eventName 40 | } 41 | 42 | get method(): Function { 43 | const method = (this.controller as any)[this.methodName] 44 | if (typeof method == "function") { 45 | return method 46 | } 47 | throw new Error(`Action "${this.action}" references undefined method "${this.methodName}"`) 48 | } 49 | 50 | private applyEventModifiers(event: Event): boolean { 51 | const { element } = this.action 52 | const { actionDescriptorFilters } = this.context.application 53 | const { controller } = this.context 54 | 55 | let passes = true 56 | 57 | for (const [name, value] of Object.entries(this.eventOptions)) { 58 | if (name in actionDescriptorFilters) { 59 | const filter = actionDescriptorFilters[name] 60 | 61 | passes = passes && filter({ name, value, event, element, controller }) 62 | } else { 63 | continue 64 | } 65 | } 66 | 67 | return passes 68 | } 69 | 70 | private prepareActionEvent(event: Event): ActionEvent { 71 | return Object.assign(event, { params: this.action.params }) 72 | } 73 | 74 | private invokeWithEvent(event: ActionEvent) { 75 | const { target, currentTarget } = event 76 | try { 77 | this.method.call(this.controller, event) 78 | this.context.logDebugActivity(this.methodName, { event, target, currentTarget, action: this.methodName }) 79 | } catch (error: any) { 80 | const { identifier, controller, element, index } = this 81 | const detail = { identifier, controller, element, index, event } 82 | this.context.handleError(error, `invoking action "${this.action}"`, detail) 83 | } 84 | } 85 | 86 | private willBeInvokedByEvent(event: Event): boolean { 87 | const eventTarget = event.target 88 | 89 | if (event instanceof KeyboardEvent && this.action.shouldIgnoreKeyboardEvent(event)) { 90 | return false 91 | } 92 | 93 | if (event instanceof MouseEvent && this.action.shouldIgnoreMouseEvent(event)) { 94 | return false 95 | } 96 | 97 | if (this.element === eventTarget) { 98 | return true 99 | } else if (eventTarget instanceof Element && this.element.contains(eventTarget)) { 100 | return this.scope.containsElement(eventTarget) 101 | } else { 102 | return this.scope.containsElement(this.action.element) 103 | } 104 | } 105 | 106 | private get controller(): Controller { 107 | return this.context.controller 108 | } 109 | 110 | private get methodName(): string { 111 | return this.action.methodName 112 | } 113 | 114 | private get element(): Element { 115 | return this.scope.element 116 | } 117 | 118 | private get scope(): Scope { 119 | return this.context.scope 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/core/binding_observer.ts: -------------------------------------------------------------------------------- 1 | import { Action } from "./action" 2 | import { Binding } from "./binding" 3 | import { Context } from "./context" 4 | import { ErrorHandler } from "./error_handler" 5 | import { Schema } from "./schema" 6 | import { Token, ValueListObserver, ValueListObserverDelegate } from "../mutation-observers" 7 | 8 | export interface BindingObserverDelegate extends ErrorHandler { 9 | bindingConnected(binding: Binding): void 10 | bindingDisconnected(binding: Binding, clearEventListeners?: boolean): void 11 | } 12 | 13 | export class BindingObserver implements ValueListObserverDelegate { 14 | readonly context: Context 15 | private delegate: BindingObserverDelegate 16 | private valueListObserver?: ValueListObserver 17 | private bindingsByAction: Map 18 | 19 | constructor(context: Context, delegate: BindingObserverDelegate) { 20 | this.context = context 21 | this.delegate = delegate 22 | this.bindingsByAction = new Map() 23 | } 24 | 25 | start() { 26 | if (!this.valueListObserver) { 27 | this.valueListObserver = new ValueListObserver(this.element, this.actionAttribute, this) 28 | this.valueListObserver.start() 29 | } 30 | } 31 | 32 | stop() { 33 | if (this.valueListObserver) { 34 | this.valueListObserver.stop() 35 | delete this.valueListObserver 36 | this.disconnectAllActions() 37 | } 38 | } 39 | 40 | get element() { 41 | return this.context.element 42 | } 43 | 44 | get identifier() { 45 | return this.context.identifier 46 | } 47 | 48 | get actionAttribute() { 49 | return this.schema.actionAttribute 50 | } 51 | 52 | get schema(): Schema { 53 | return this.context.schema 54 | } 55 | 56 | get bindings(): Binding[] { 57 | return Array.from(this.bindingsByAction.values()) 58 | } 59 | 60 | private connectAction(action: Action) { 61 | const binding = new Binding(this.context, action) 62 | this.bindingsByAction.set(action, binding) 63 | this.delegate.bindingConnected(binding) 64 | } 65 | 66 | private disconnectAction(action: Action) { 67 | const binding = this.bindingsByAction.get(action) 68 | if (binding) { 69 | this.bindingsByAction.delete(action) 70 | this.delegate.bindingDisconnected(binding) 71 | } 72 | } 73 | 74 | private disconnectAllActions() { 75 | this.bindings.forEach((binding) => this.delegate.bindingDisconnected(binding, true)) 76 | this.bindingsByAction.clear() 77 | } 78 | 79 | // Value observer delegate 80 | 81 | parseValueForToken(token: Token): Action | undefined { 82 | const action = Action.forToken(token, this.schema) 83 | if (action.identifier == this.identifier) { 84 | return action 85 | } 86 | } 87 | 88 | elementMatchedValue(element: Element, action: Action) { 89 | this.connectAction(action) 90 | } 91 | 92 | elementUnmatchedValue(element: Element, action: Action) { 93 | this.disconnectAction(action) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/core/blessing.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from "./constructor" 2 | import { readInheritableStaticArrayValues } from "./inheritable_statics" 3 | 4 | export type Blessing = (constructor: Constructor) => PropertyDescriptorMap 5 | 6 | export interface Blessable extends Constructor { 7 | readonly blessings?: Blessing[] 8 | } 9 | 10 | export function bless(constructor: Blessable): Constructor { 11 | return shadow(constructor, getBlessedProperties(constructor)) 12 | } 13 | 14 | function shadow(constructor: Constructor, properties: PropertyDescriptorMap) { 15 | const shadowConstructor = extend(constructor) 16 | const shadowProperties = getShadowProperties(constructor.prototype, properties) 17 | Object.defineProperties(shadowConstructor.prototype, shadowProperties) 18 | return shadowConstructor 19 | } 20 | 21 | function getBlessedProperties(constructor: Constructor) { 22 | const blessings = readInheritableStaticArrayValues(constructor, "blessings") as Blessing[] 23 | return blessings.reduce((blessedProperties, blessing) => { 24 | const properties = blessing(constructor) 25 | for (const key in properties) { 26 | const descriptor = blessedProperties[key] || ({} as PropertyDescriptor) 27 | blessedProperties[key] = Object.assign(descriptor, properties[key]) 28 | } 29 | return blessedProperties 30 | }, {} as PropertyDescriptorMap) 31 | } 32 | 33 | function getShadowProperties(prototype: any, properties: PropertyDescriptorMap) { 34 | return getOwnKeys(properties).reduce((shadowProperties, key) => { 35 | const descriptor = getShadowedDescriptor(prototype, properties, key) 36 | if (descriptor) { 37 | Object.assign(shadowProperties, { [key]: descriptor }) 38 | } 39 | return shadowProperties 40 | }, {} as PropertyDescriptorMap) 41 | } 42 | 43 | function getShadowedDescriptor(prototype: any, properties: PropertyDescriptorMap, key: string | symbol) { 44 | const shadowingDescriptor = Object.getOwnPropertyDescriptor(prototype, key) 45 | const shadowedByValue = shadowingDescriptor && "value" in shadowingDescriptor 46 | if (!shadowedByValue) { 47 | const descriptor = Object.getOwnPropertyDescriptor(properties, key)!.value 48 | if (shadowingDescriptor) { 49 | descriptor.get = shadowingDescriptor.get || descriptor.get 50 | descriptor.set = shadowingDescriptor.set || descriptor.set 51 | } 52 | return descriptor 53 | } 54 | } 55 | 56 | const getOwnKeys = (() => { 57 | if (typeof Object.getOwnPropertySymbols == "function") { 58 | return (object: any) => [...Object.getOwnPropertyNames(object), ...Object.getOwnPropertySymbols(object)] 59 | } else { 60 | return Object.getOwnPropertyNames 61 | } 62 | })() 63 | 64 | const extend = (() => { 65 | function extendWithReflect>(constructor: T): T { 66 | function extended() { 67 | return Reflect.construct(constructor, arguments, new.target) 68 | } 69 | 70 | extended.prototype = Object.create(constructor.prototype, { 71 | constructor: { value: extended }, 72 | }) 73 | 74 | Reflect.setPrototypeOf(extended, constructor) 75 | return extended as any 76 | } 77 | 78 | function testReflectExtension() { 79 | const a = function (this: any) { 80 | this.a.call(this) 81 | } as any 82 | const b = extendWithReflect(a) 83 | b.prototype.a = function () {} 84 | return new b() 85 | } 86 | 87 | try { 88 | testReflectExtension() 89 | return extendWithReflect 90 | } catch (error: any) { 91 | return >(constructor: T) => class extended extends constructor {} 92 | } 93 | })() 94 | -------------------------------------------------------------------------------- /src/core/class_map.ts: -------------------------------------------------------------------------------- 1 | import { Scope } from "./scope" 2 | import { tokenize } from "./string_helpers" 3 | 4 | export class ClassMap { 5 | readonly scope: Scope 6 | 7 | constructor(scope: Scope) { 8 | this.scope = scope 9 | } 10 | 11 | has(name: string) { 12 | return this.data.has(this.getDataKey(name)) 13 | } 14 | 15 | get(name: string): string | undefined { 16 | return this.getAll(name)[0] 17 | } 18 | 19 | getAll(name: string) { 20 | const tokenString = this.data.get(this.getDataKey(name)) || "" 21 | return tokenize(tokenString) 22 | } 23 | 24 | getAttributeName(name: string) { 25 | return this.data.getAttributeNameForKey(this.getDataKey(name)) 26 | } 27 | 28 | getDataKey(name: string) { 29 | return `${name}-class` 30 | } 31 | 32 | get data() { 33 | return this.scope.data 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/core/class_properties.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from "./constructor" 2 | import { Controller } from "./controller" 3 | import { readInheritableStaticArrayValues } from "./inheritable_statics" 4 | import { capitalize } from "./string_helpers" 5 | 6 | export function ClassPropertiesBlessing(constructor: Constructor) { 7 | const classes = readInheritableStaticArrayValues(constructor, "classes") 8 | return classes.reduce((properties, classDefinition) => { 9 | return Object.assign(properties, propertiesForClassDefinition(classDefinition)) 10 | }, {} as PropertyDescriptorMap) 11 | } 12 | 13 | function propertiesForClassDefinition(key: string) { 14 | return { 15 | [`${key}Class`]: { 16 | get(this: Controller) { 17 | const { classes } = this 18 | if (classes.has(key)) { 19 | return classes.get(key) 20 | } else { 21 | const attribute = classes.getAttributeName(key) 22 | throw new Error(`Missing attribute "${attribute}"`) 23 | } 24 | }, 25 | }, 26 | 27 | [`${key}Classes`]: { 28 | get(this: Controller) { 29 | return this.classes.getAll(key) 30 | }, 31 | }, 32 | 33 | [`has${capitalize(key)}Class`]: { 34 | get(this: Controller) { 35 | return this.classes.has(key) 36 | }, 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/constructor.ts: -------------------------------------------------------------------------------- 1 | export type Constructor = new (...args: any[]) => T 2 | -------------------------------------------------------------------------------- /src/core/controller.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "./application" 2 | import { ClassPropertiesBlessing } from "./class_properties" 3 | import { Constructor } from "./constructor" 4 | import { Context } from "./context" 5 | import { OutletPropertiesBlessing } from "./outlet_properties" 6 | import { TargetPropertiesBlessing } from "./target_properties" 7 | import { ValuePropertiesBlessing, ValueDefinitionMap } from "./value_properties" 8 | 9 | export type ControllerConstructor = Constructor 10 | 11 | type DispatchOptions = Partial<{ 12 | target: Element | Window | Document 13 | detail: Object 14 | prefix: string 15 | bubbles: boolean 16 | cancelable: boolean 17 | }> 18 | 19 | export class Controller { 20 | static blessings = [ 21 | ClassPropertiesBlessing, 22 | TargetPropertiesBlessing, 23 | ValuePropertiesBlessing, 24 | OutletPropertiesBlessing, 25 | ] 26 | static targets: string[] = [] 27 | static outlets: string[] = [] 28 | static values: ValueDefinitionMap = {} 29 | 30 | static get shouldLoad() { 31 | return true 32 | } 33 | 34 | static afterLoad(_identifier: string, _application: Application) { 35 | return 36 | } 37 | 38 | readonly context: Context 39 | 40 | constructor(context: Context) { 41 | this.context = context 42 | } 43 | 44 | get application() { 45 | return this.context.application 46 | } 47 | 48 | get scope() { 49 | return this.context.scope 50 | } 51 | 52 | get element() { 53 | return this.scope.element as ElementType 54 | } 55 | 56 | get identifier() { 57 | return this.scope.identifier 58 | } 59 | 60 | get targets() { 61 | return this.scope.targets 62 | } 63 | 64 | get outlets() { 65 | return this.scope.outlets 66 | } 67 | 68 | get classes() { 69 | return this.scope.classes 70 | } 71 | 72 | get data() { 73 | return this.scope.data 74 | } 75 | 76 | initialize() { 77 | // Override in your subclass to set up initial controller state 78 | } 79 | 80 | connect() { 81 | // Override in your subclass to respond when the controller is connected to the DOM 82 | } 83 | 84 | disconnect() { 85 | // Override in your subclass to respond when the controller is disconnected from the DOM 86 | } 87 | 88 | dispatch( 89 | eventName: string, 90 | { 91 | target = this.element, 92 | detail = {}, 93 | prefix = this.identifier, 94 | bubbles = true, 95 | cancelable = true, 96 | }: DispatchOptions = {} 97 | ) { 98 | const type = prefix ? `${prefix}:${eventName}` : eventName 99 | const event = new CustomEvent(type, { detail, bubbles, cancelable }) 100 | target.dispatchEvent(event) 101 | return event 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/core/data_map.ts: -------------------------------------------------------------------------------- 1 | import { Scope } from "./scope" 2 | import { dasherize } from "./string_helpers" 3 | 4 | export class DataMap { 5 | readonly scope: Scope 6 | 7 | constructor(scope: Scope) { 8 | this.scope = scope 9 | } 10 | 11 | get element(): Element { 12 | return this.scope.element 13 | } 14 | 15 | get identifier(): string { 16 | return this.scope.identifier 17 | } 18 | 19 | get(key: string): string | null { 20 | const name = this.getAttributeNameForKey(key) 21 | return this.element.getAttribute(name) 22 | } 23 | 24 | set(key: string, value: string): string | null { 25 | const name = this.getAttributeNameForKey(key) 26 | this.element.setAttribute(name, value) 27 | return this.get(key) 28 | } 29 | 30 | has(key: string): boolean { 31 | const name = this.getAttributeNameForKey(key) 32 | return this.element.hasAttribute(name) 33 | } 34 | 35 | delete(key: string): boolean { 36 | if (this.has(key)) { 37 | const name = this.getAttributeNameForKey(key) 38 | this.element.removeAttribute(name) 39 | return true 40 | } else { 41 | return false 42 | } 43 | } 44 | 45 | getAttributeNameForKey(key: string): string { 46 | return `data-${this.identifier}-${dasherize(key)}` 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/core/definition.ts: -------------------------------------------------------------------------------- 1 | import { bless } from "./blessing" 2 | import { ControllerConstructor } from "./controller" 3 | 4 | export interface Definition { 5 | identifier: string 6 | controllerConstructor: ControllerConstructor 7 | } 8 | 9 | export function blessDefinition(definition: Definition): Definition { 10 | return { 11 | identifier: definition.identifier, 12 | controllerConstructor: bless(definition.controllerConstructor), 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/core/dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "./application" 2 | import { Binding } from "./binding" 3 | import { BindingObserverDelegate } from "./binding_observer" 4 | import { EventListener } from "./event_listener" 5 | 6 | export class Dispatcher implements BindingObserverDelegate { 7 | readonly application: Application 8 | private eventListenerMaps: Map> 9 | private started: boolean 10 | 11 | constructor(application: Application) { 12 | this.application = application 13 | this.eventListenerMaps = new Map() 14 | this.started = false 15 | } 16 | 17 | start() { 18 | if (!this.started) { 19 | this.started = true 20 | this.eventListeners.forEach((eventListener) => eventListener.connect()) 21 | } 22 | } 23 | 24 | stop() { 25 | if (this.started) { 26 | this.started = false 27 | this.eventListeners.forEach((eventListener) => eventListener.disconnect()) 28 | } 29 | } 30 | 31 | get eventListeners(): EventListener[] { 32 | return Array.from(this.eventListenerMaps.values()).reduce( 33 | (listeners, map) => listeners.concat(Array.from(map.values())), 34 | [] as EventListener[] 35 | ) 36 | } 37 | 38 | // Binding observer delegate 39 | 40 | bindingConnected(binding: Binding) { 41 | this.fetchEventListenerForBinding(binding).bindingConnected(binding) 42 | } 43 | 44 | bindingDisconnected(binding: Binding, clearEventListeners = false) { 45 | this.fetchEventListenerForBinding(binding).bindingDisconnected(binding) 46 | if (clearEventListeners) this.clearEventListenersForBinding(binding) 47 | } 48 | 49 | // Error handling 50 | 51 | handleError(error: Error, message: string, detail: object = {}) { 52 | this.application.handleError(error, `Error ${message}`, detail) 53 | } 54 | 55 | private clearEventListenersForBinding(binding: Binding) { 56 | const eventListener = this.fetchEventListenerForBinding(binding) 57 | if (!eventListener.hasBindings()) { 58 | eventListener.disconnect() 59 | this.removeMappedEventListenerFor(binding) 60 | } 61 | } 62 | 63 | private removeMappedEventListenerFor(binding: Binding) { 64 | const { eventTarget, eventName, eventOptions } = binding 65 | const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget) 66 | const cacheKey = this.cacheKey(eventName, eventOptions) 67 | 68 | eventListenerMap.delete(cacheKey) 69 | if (eventListenerMap.size == 0) this.eventListenerMaps.delete(eventTarget) 70 | } 71 | 72 | private fetchEventListenerForBinding(binding: Binding): EventListener { 73 | const { eventTarget, eventName, eventOptions } = binding 74 | return this.fetchEventListener(eventTarget, eventName, eventOptions) 75 | } 76 | 77 | private fetchEventListener( 78 | eventTarget: EventTarget, 79 | eventName: string, 80 | eventOptions: AddEventListenerOptions 81 | ): EventListener { 82 | const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget) 83 | const cacheKey = this.cacheKey(eventName, eventOptions) 84 | let eventListener = eventListenerMap.get(cacheKey) 85 | if (!eventListener) { 86 | eventListener = this.createEventListener(eventTarget, eventName, eventOptions) 87 | eventListenerMap.set(cacheKey, eventListener) 88 | } 89 | return eventListener 90 | } 91 | 92 | private createEventListener( 93 | eventTarget: EventTarget, 94 | eventName: string, 95 | eventOptions: AddEventListenerOptions 96 | ): EventListener { 97 | const eventListener = new EventListener(eventTarget, eventName, eventOptions) 98 | if (this.started) { 99 | eventListener.connect() 100 | } 101 | return eventListener 102 | } 103 | 104 | private fetchEventListenerMapForEventTarget(eventTarget: EventTarget): Map { 105 | let eventListenerMap = this.eventListenerMaps.get(eventTarget) 106 | if (!eventListenerMap) { 107 | eventListenerMap = new Map() 108 | this.eventListenerMaps.set(eventTarget, eventListenerMap) 109 | } 110 | return eventListenerMap 111 | } 112 | 113 | private cacheKey(eventName: string, eventOptions: any): string { 114 | const parts = [eventName] 115 | Object.keys(eventOptions) 116 | .sort() 117 | .forEach((key) => { 118 | parts.push(`${eventOptions[key] ? "" : "!"}${key}`) 119 | }) 120 | return parts.join(":") 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/core/error_handler.ts: -------------------------------------------------------------------------------- 1 | export interface ErrorHandler { 2 | handleError(error: Error, message: string, detail: object): void 3 | } 4 | -------------------------------------------------------------------------------- /src/core/event_listener.ts: -------------------------------------------------------------------------------- 1 | import { Binding } from "./binding" 2 | 3 | export class EventListener implements EventListenerObject { 4 | readonly eventTarget: EventTarget 5 | readonly eventName: string 6 | readonly eventOptions: AddEventListenerOptions 7 | private unorderedBindings: Set 8 | 9 | constructor(eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions) { 10 | this.eventTarget = eventTarget 11 | this.eventName = eventName 12 | this.eventOptions = eventOptions 13 | this.unorderedBindings = new Set() 14 | } 15 | 16 | connect() { 17 | this.eventTarget.addEventListener(this.eventName, this, this.eventOptions) 18 | } 19 | 20 | disconnect() { 21 | this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions) 22 | } 23 | 24 | // Binding observer delegate 25 | 26 | bindingConnected(binding: Binding) { 27 | this.unorderedBindings.add(binding) 28 | } 29 | 30 | bindingDisconnected(binding: Binding) { 31 | this.unorderedBindings.delete(binding) 32 | } 33 | 34 | handleEvent(event: Event) { 35 | // FIXME: Determine why TS won't recognize that the extended event has immediatePropagationStopped 36 | const extendedEvent = extendEvent(event) as any 37 | for (const binding of this.bindings) { 38 | if (extendedEvent.immediatePropagationStopped) { 39 | break 40 | } else { 41 | binding.handleEvent(extendedEvent) 42 | } 43 | } 44 | } 45 | 46 | hasBindings() { 47 | return this.unorderedBindings.size > 0 48 | } 49 | 50 | get bindings(): Binding[] { 51 | return Array.from(this.unorderedBindings).sort((left, right) => { 52 | const leftIndex = left.index, 53 | rightIndex = right.index 54 | return leftIndex < rightIndex ? -1 : leftIndex > rightIndex ? 1 : 0 55 | }) 56 | } 57 | } 58 | 59 | function extendEvent(event: Event) { 60 | if ("immediatePropagationStopped" in event) { 61 | return event 62 | } else { 63 | const { stopImmediatePropagation } = event 64 | return Object.assign(event, { 65 | immediatePropagationStopped: false, 66 | stopImmediatePropagation() { 67 | this.immediatePropagationStopped = true 68 | stopImmediatePropagation.call(this) 69 | }, 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/core/guide.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "./logger" 2 | 3 | export class Guide { 4 | readonly logger: Logger 5 | readonly warnedKeysByObject: WeakMap> = new WeakMap() 6 | 7 | constructor(logger: Logger) { 8 | this.logger = logger 9 | } 10 | 11 | warn(object: any, key: string, message: string) { 12 | let warnedKeys: Set | undefined = this.warnedKeysByObject.get(object) 13 | 14 | if (!warnedKeys) { 15 | warnedKeys = new Set() 16 | this.warnedKeysByObject.set(object, warnedKeys) 17 | } 18 | 19 | if (!warnedKeys.has(key)) { 20 | warnedKeys.add(key) 21 | this.logger.warn(message, object) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export { ActionEvent } from "./action_event" 2 | export { Application } from "./application" 3 | export { Context } from "./context" 4 | export { Controller, ControllerConstructor } from "./controller" 5 | export { Definition } from "./definition" 6 | export { Schema, defaultSchema } from "./schema" 7 | -------------------------------------------------------------------------------- /src/core/inheritable_statics.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from "./constructor" 2 | 3 | export function readInheritableStaticArrayValues(constructor: Constructor, propertyName: string) { 4 | const ancestors = getAncestorsForConstructor(constructor) 5 | return Array.from( 6 | ancestors.reduce((values, constructor) => { 7 | getOwnStaticArrayValues(constructor, propertyName).forEach((name) => values.add(name)) 8 | return values 9 | }, new Set() as Set) 10 | ) 11 | } 12 | 13 | export function readInheritableStaticObjectPairs(constructor: Constructor, propertyName: string) { 14 | const ancestors = getAncestorsForConstructor(constructor) 15 | return ancestors.reduce((pairs, constructor) => { 16 | pairs.push(...(getOwnStaticObjectPairs(constructor, propertyName) as any)) 17 | return pairs 18 | }, [] as [string, U][]) 19 | } 20 | 21 | function getAncestorsForConstructor(constructor: Constructor) { 22 | const ancestors: Constructor[] = [] 23 | while (constructor) { 24 | ancestors.push(constructor) 25 | constructor = Object.getPrototypeOf(constructor) 26 | } 27 | return ancestors.reverse() 28 | } 29 | 30 | function getOwnStaticArrayValues(constructor: Constructor, propertyName: string) { 31 | const definition = (constructor as any)[propertyName] 32 | return Array.isArray(definition) ? definition : [] 33 | } 34 | 35 | function getOwnStaticObjectPairs(constructor: Constructor, propertyName: string) { 36 | const definition = (constructor as any)[propertyName] 37 | return definition ? Object.keys(definition).map((key) => [key, definition[key]] as [string, U]) : [] 38 | } 39 | -------------------------------------------------------------------------------- /src/core/logger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | log(message: string, ...args: any[]): void 3 | warn(message: string, ...args: any[]): void 4 | error(message: string, ...args: any[]): void 5 | groupCollapsed(groupTitle?: string, ...args: any[]): void 6 | groupEnd(): void 7 | } 8 | -------------------------------------------------------------------------------- /src/core/module.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "./application" 2 | import { Context } from "./context" 3 | import { ControllerConstructor } from "./controller" 4 | import { Definition, blessDefinition } from "./definition" 5 | import { Scope } from "./scope" 6 | 7 | export class Module { 8 | readonly application: Application 9 | readonly definition: Definition 10 | private contextsByScope: WeakMap 11 | private connectedContexts: Set 12 | 13 | constructor(application: Application, definition: Definition) { 14 | this.application = application 15 | this.definition = blessDefinition(definition) 16 | this.contextsByScope = new WeakMap() 17 | this.connectedContexts = new Set() 18 | } 19 | 20 | get identifier(): string { 21 | return this.definition.identifier 22 | } 23 | 24 | get controllerConstructor(): ControllerConstructor { 25 | return this.definition.controllerConstructor 26 | } 27 | 28 | get contexts(): Context[] { 29 | return Array.from(this.connectedContexts) 30 | } 31 | 32 | connectContextForScope(scope: Scope) { 33 | const context = this.fetchContextForScope(scope) 34 | this.connectedContexts.add(context) 35 | context.connect() 36 | } 37 | 38 | disconnectContextForScope(scope: Scope) { 39 | const context = this.contextsByScope.get(scope) 40 | if (context) { 41 | this.connectedContexts.delete(context) 42 | context.disconnect() 43 | } 44 | } 45 | 46 | private fetchContextForScope(scope: Scope): Context { 47 | let context = this.contextsByScope.get(scope) 48 | if (!context) { 49 | context = new Context(this, scope) 50 | this.contextsByScope.set(scope, context) 51 | } 52 | return context 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/core/outlet_properties.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from "./constructor" 2 | import { Controller } from "./controller" 3 | import { readInheritableStaticArrayValues } from "./inheritable_statics" 4 | import { capitalize, namespaceCamelize } from "./string_helpers" 5 | 6 | export function OutletPropertiesBlessing(constructor: Constructor) { 7 | const outlets = readInheritableStaticArrayValues(constructor, "outlets") 8 | return outlets.reduce((properties: any, outletDefinition: any) => { 9 | return Object.assign(properties, propertiesForOutletDefinition(outletDefinition)) 10 | }, {} as PropertyDescriptorMap) 11 | } 12 | 13 | function getOutletController(controller: Controller, element: Element, identifier: string) { 14 | return controller.application.getControllerForElementAndIdentifier(element, identifier) 15 | } 16 | 17 | function getControllerAndEnsureConnectedScope(controller: Controller, element: Element, outletName: string) { 18 | let outletController = getOutletController(controller, element, outletName) 19 | if (outletController) return outletController 20 | 21 | controller.application.router.proposeToConnectScopeForElementAndIdentifier(element, outletName) 22 | 23 | outletController = getOutletController(controller, element, outletName) 24 | if (outletController) return outletController 25 | } 26 | 27 | function propertiesForOutletDefinition(name: string) { 28 | const camelizedName = namespaceCamelize(name) 29 | 30 | return { 31 | [`${camelizedName}Outlet`]: { 32 | get(this: Controller) { 33 | const outletElement = this.outlets.find(name) 34 | const selector = this.outlets.getSelectorForOutletName(name) 35 | 36 | if (outletElement) { 37 | const outletController = getControllerAndEnsureConnectedScope(this, outletElement, name) 38 | 39 | if (outletController) return outletController 40 | 41 | throw new Error( 42 | `The provided outlet element is missing an outlet controller "${name}" instance for host controller "${this.identifier}"` 43 | ) 44 | } 45 | 46 | throw new Error( 47 | `Missing outlet element "${name}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${selector}".` 48 | ) 49 | }, 50 | }, 51 | 52 | [`${camelizedName}Outlets`]: { 53 | get(this: Controller) { 54 | const outlets = this.outlets.findAll(name) 55 | 56 | if (outlets.length > 0) { 57 | return outlets 58 | .map((outletElement: Element) => { 59 | const outletController = getControllerAndEnsureConnectedScope(this, outletElement, name) 60 | 61 | if (outletController) return outletController 62 | 63 | console.warn( 64 | `The provided outlet element is missing an outlet controller "${name}" instance for host controller "${this.identifier}"`, 65 | outletElement 66 | ) 67 | }) 68 | .filter((controller) => controller) as Controller[] 69 | } 70 | 71 | return [] 72 | }, 73 | }, 74 | 75 | [`${camelizedName}OutletElement`]: { 76 | get(this: Controller) { 77 | const outletElement = this.outlets.find(name) 78 | const selector = this.outlets.getSelectorForOutletName(name) 79 | 80 | if (outletElement) { 81 | return outletElement 82 | } else { 83 | throw new Error( 84 | `Missing outlet element "${name}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${selector}".` 85 | ) 86 | } 87 | }, 88 | }, 89 | 90 | [`${camelizedName}OutletElements`]: { 91 | get(this: Controller) { 92 | return this.outlets.findAll(name) 93 | }, 94 | }, 95 | 96 | [`has${capitalize(camelizedName)}Outlet`]: { 97 | get(this: Controller) { 98 | return this.outlets.has(name) 99 | }, 100 | }, 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/core/outlet_set.ts: -------------------------------------------------------------------------------- 1 | import { Scope } from "./scope" 2 | 3 | export class OutletSet { 4 | readonly scope: Scope 5 | readonly controllerElement: Element 6 | 7 | constructor(scope: Scope, controllerElement: Element) { 8 | this.scope = scope 9 | this.controllerElement = controllerElement 10 | } 11 | 12 | get element() { 13 | return this.scope.element 14 | } 15 | 16 | get identifier() { 17 | return this.scope.identifier 18 | } 19 | 20 | get schema() { 21 | return this.scope.schema 22 | } 23 | 24 | has(outletName: string) { 25 | return this.find(outletName) != null 26 | } 27 | 28 | find(...outletNames: string[]) { 29 | return outletNames.reduce( 30 | (outlet, outletName) => outlet || this.findOutlet(outletName), 31 | undefined as Element | undefined 32 | ) 33 | } 34 | 35 | findAll(...outletNames: string[]) { 36 | return outletNames.reduce( 37 | (outlets, outletName) => [...outlets, ...this.findAllOutlets(outletName)], 38 | [] as Element[] 39 | ) 40 | } 41 | 42 | getSelectorForOutletName(outletName: string) { 43 | const attributeName = this.schema.outletAttributeForScope(this.identifier, outletName) 44 | return this.controllerElement.getAttribute(attributeName) 45 | } 46 | 47 | private findOutlet(outletName: string) { 48 | const selector = this.getSelectorForOutletName(outletName) 49 | if (selector) return this.findElement(selector, outletName) 50 | } 51 | 52 | private findAllOutlets(outletName: string) { 53 | const selector = this.getSelectorForOutletName(outletName) 54 | return selector ? this.findAllElements(selector, outletName) : [] 55 | } 56 | 57 | private findElement(selector: string, outletName: string): Element | undefined { 58 | const elements = this.scope.queryElements(selector) 59 | return elements.filter((element) => this.matchesElement(element, selector, outletName))[0] 60 | } 61 | 62 | private findAllElements(selector: string, outletName: string): Element[] { 63 | const elements = this.scope.queryElements(selector) 64 | return elements.filter((element) => this.matchesElement(element, selector, outletName)) 65 | } 66 | 67 | private matchesElement(element: Element, selector: string, outletName: string): boolean { 68 | const controllerAttribute = element.getAttribute(this.scope.schema.controllerAttribute) || "" 69 | return element.matches(selector) && controllerAttribute.split(" ").includes(outletName) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/core/router.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "./application" 2 | import { Context } from "./context" 3 | import { Definition } from "./definition" 4 | import { Module } from "./module" 5 | import { Multimap } from "../multimap" 6 | import { Scope } from "./scope" 7 | import { ScopeObserver, ScopeObserverDelegate } from "./scope_observer" 8 | 9 | export class Router implements ScopeObserverDelegate { 10 | readonly application: Application 11 | private scopeObserver: ScopeObserver 12 | private scopesByIdentifier: Multimap 13 | private modulesByIdentifier: Map 14 | 15 | constructor(application: Application) { 16 | this.application = application 17 | this.scopeObserver = new ScopeObserver(this.element, this.schema, this) 18 | this.scopesByIdentifier = new Multimap() 19 | this.modulesByIdentifier = new Map() 20 | } 21 | 22 | get element() { 23 | return this.application.element 24 | } 25 | 26 | get schema() { 27 | return this.application.schema 28 | } 29 | 30 | get logger() { 31 | return this.application.logger 32 | } 33 | 34 | get controllerAttribute(): string { 35 | return this.schema.controllerAttribute 36 | } 37 | 38 | get modules() { 39 | return Array.from(this.modulesByIdentifier.values()) 40 | } 41 | 42 | get contexts() { 43 | return this.modules.reduce((contexts, module) => contexts.concat(module.contexts), [] as Context[]) 44 | } 45 | 46 | start() { 47 | this.scopeObserver.start() 48 | } 49 | 50 | stop() { 51 | this.scopeObserver.stop() 52 | } 53 | 54 | loadDefinition(definition: Definition) { 55 | this.unloadIdentifier(definition.identifier) 56 | const module = new Module(this.application, definition) 57 | this.connectModule(module) 58 | const afterLoad = (definition.controllerConstructor as any).afterLoad 59 | if (afterLoad) { 60 | afterLoad.call(definition.controllerConstructor, definition.identifier, this.application) 61 | } 62 | } 63 | 64 | unloadIdentifier(identifier: string) { 65 | const module = this.modulesByIdentifier.get(identifier) 66 | if (module) { 67 | this.disconnectModule(module) 68 | } 69 | } 70 | 71 | getContextForElementAndIdentifier(element: Element, identifier: string) { 72 | const module = this.modulesByIdentifier.get(identifier) 73 | if (module) { 74 | return module.contexts.find((context) => context.element == element) 75 | } 76 | } 77 | 78 | proposeToConnectScopeForElementAndIdentifier(element: Element, identifier: string) { 79 | const scope = this.scopeObserver.parseValueForElementAndIdentifier(element, identifier) 80 | 81 | if (scope) { 82 | this.scopeObserver.elementMatchedValue(scope.element, scope) 83 | } else { 84 | console.error(`Couldn't find or create scope for identifier: "${identifier}" and element:`, element) 85 | } 86 | } 87 | 88 | // Error handler delegate 89 | 90 | handleError(error: Error, message: string, detail: any) { 91 | this.application.handleError(error, message, detail) 92 | } 93 | 94 | // Scope observer delegate 95 | 96 | createScopeForElementAndIdentifier(element: Element, identifier: string) { 97 | return new Scope(this.schema, element, identifier, this.logger) 98 | } 99 | 100 | scopeConnected(scope: Scope) { 101 | this.scopesByIdentifier.add(scope.identifier, scope) 102 | const module = this.modulesByIdentifier.get(scope.identifier) 103 | if (module) { 104 | module.connectContextForScope(scope) 105 | } 106 | } 107 | 108 | scopeDisconnected(scope: Scope) { 109 | this.scopesByIdentifier.delete(scope.identifier, scope) 110 | const module = this.modulesByIdentifier.get(scope.identifier) 111 | if (module) { 112 | module.disconnectContextForScope(scope) 113 | } 114 | } 115 | 116 | // Modules 117 | 118 | private connectModule(module: Module) { 119 | this.modulesByIdentifier.set(module.identifier, module) 120 | const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier) 121 | scopes.forEach((scope) => module.connectContextForScope(scope)) 122 | } 123 | 124 | private disconnectModule(module: Module) { 125 | this.modulesByIdentifier.delete(module.identifier) 126 | const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier) 127 | scopes.forEach((scope) => module.disconnectContextForScope(scope)) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/core/schema.ts: -------------------------------------------------------------------------------- 1 | export interface Schema { 2 | controllerAttribute: string 3 | actionAttribute: string 4 | targetAttribute: string 5 | targetAttributeForScope(identifier: string): string 6 | outletAttributeForScope(identifier: string, outlet: string): string 7 | keyMappings: { [key: string]: string } 8 | } 9 | 10 | export const defaultSchema: Schema = { 11 | controllerAttribute: "data-controller", 12 | actionAttribute: "data-action", 13 | targetAttribute: "data-target", 14 | targetAttributeForScope: (identifier) => `data-${identifier}-target`, 15 | outletAttributeForScope: (identifier, outlet) => `data-${identifier}-${outlet}-outlet`, 16 | keyMappings: { 17 | enter: "Enter", 18 | tab: "Tab", 19 | esc: "Escape", 20 | space: " ", 21 | up: "ArrowUp", 22 | down: "ArrowDown", 23 | left: "ArrowLeft", 24 | right: "ArrowRight", 25 | home: "Home", 26 | end: "End", 27 | page_up: "PageUp", 28 | page_down: "PageDown", 29 | // [a-z] 30 | ...objectFromEntries("abcdefghijklmnopqrstuvwxyz".split("").map((c) => [c, c])), 31 | // [0-9] 32 | ...objectFromEntries("0123456789".split("").map((n) => [n, n])), 33 | }, 34 | } 35 | 36 | function objectFromEntries(array: [string, any][]): object { 37 | // polyfill 38 | return array.reduce((memo, [k, v]) => ({ ...memo, [k]: v }), {}) 39 | } 40 | -------------------------------------------------------------------------------- /src/core/scope.ts: -------------------------------------------------------------------------------- 1 | import { ClassMap } from "./class_map" 2 | import { DataMap } from "./data_map" 3 | import { Guide } from "./guide" 4 | import { Logger } from "./logger" 5 | import { Schema } from "./schema" 6 | import { attributeValueContainsToken } from "./selectors" 7 | import { TargetSet } from "./target_set" 8 | import { OutletSet } from "./outlet_set" 9 | 10 | export class Scope { 11 | readonly schema: Schema 12 | readonly element: Element 13 | readonly identifier: string 14 | readonly guide: Guide 15 | readonly outlets: OutletSet 16 | readonly targets = new TargetSet(this) 17 | readonly classes = new ClassMap(this) 18 | readonly data = new DataMap(this) 19 | 20 | constructor(schema: Schema, element: Element, identifier: string, logger: Logger) { 21 | this.schema = schema 22 | this.element = element 23 | this.identifier = identifier 24 | this.guide = new Guide(logger) 25 | this.outlets = new OutletSet(this.documentScope, element) 26 | } 27 | 28 | findElement(selector: string): Element | undefined { 29 | return this.element.matches(selector) ? this.element : this.queryElements(selector).find(this.containsElement) 30 | } 31 | 32 | findAllElements(selector: string): Element[] { 33 | return [ 34 | ...(this.element.matches(selector) ? [this.element] : []), 35 | ...this.queryElements(selector).filter(this.containsElement), 36 | ] 37 | } 38 | 39 | containsElement = (element: Element): boolean => { 40 | return element.closest(this.controllerSelector) === this.element 41 | } 42 | 43 | queryElements(selector: string): Element[] { 44 | return Array.from(this.element.querySelectorAll(selector)) 45 | } 46 | 47 | private get controllerSelector(): string { 48 | return attributeValueContainsToken(this.schema.controllerAttribute, this.identifier) 49 | } 50 | 51 | private get isDocumentScope() { 52 | return this.element === document.documentElement 53 | } 54 | 55 | private get documentScope(): Scope { 56 | return this.isDocumentScope 57 | ? this 58 | : new Scope(this.schema, document.documentElement, this.identifier, this.guide.logger) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/core/scope_observer.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler } from "./error_handler" 2 | import { Schema } from "./schema" 3 | import { Scope } from "./scope" 4 | import { Token, ValueListObserver, ValueListObserverDelegate } from "../mutation-observers" 5 | 6 | export interface ScopeObserverDelegate extends ErrorHandler { 7 | createScopeForElementAndIdentifier(element: Element, identifier: string): Scope 8 | scopeConnected(scope: Scope): void 9 | scopeDisconnected(scope: Scope): void 10 | } 11 | 12 | export class ScopeObserver implements ValueListObserverDelegate { 13 | readonly element: Element 14 | readonly schema: Schema 15 | private delegate: ScopeObserverDelegate 16 | private valueListObserver: ValueListObserver 17 | private scopesByIdentifierByElement: WeakMap> 18 | private scopeReferenceCounts: WeakMap 19 | 20 | constructor(element: Element, schema: Schema, delegate: ScopeObserverDelegate) { 21 | this.element = element 22 | this.schema = schema 23 | this.delegate = delegate 24 | this.valueListObserver = new ValueListObserver(this.element, this.controllerAttribute, this) 25 | this.scopesByIdentifierByElement = new WeakMap() 26 | this.scopeReferenceCounts = new WeakMap() 27 | } 28 | 29 | start() { 30 | this.valueListObserver.start() 31 | } 32 | 33 | stop() { 34 | this.valueListObserver.stop() 35 | } 36 | 37 | get controllerAttribute() { 38 | return this.schema.controllerAttribute 39 | } 40 | 41 | // Value observer delegate 42 | 43 | parseValueForToken(token: Token): Scope | undefined { 44 | const { element, content: identifier } = token 45 | return this.parseValueForElementAndIdentifier(element, identifier) 46 | } 47 | 48 | parseValueForElementAndIdentifier(element: Element, identifier: string): Scope | undefined { 49 | const scopesByIdentifier = this.fetchScopesByIdentifierForElement(element) 50 | 51 | let scope = scopesByIdentifier.get(identifier) 52 | if (!scope) { 53 | scope = this.delegate.createScopeForElementAndIdentifier(element, identifier) 54 | scopesByIdentifier.set(identifier, scope) 55 | } 56 | 57 | return scope 58 | } 59 | 60 | elementMatchedValue(element: Element, value: Scope) { 61 | const referenceCount = (this.scopeReferenceCounts.get(value) || 0) + 1 62 | this.scopeReferenceCounts.set(value, referenceCount) 63 | if (referenceCount == 1) { 64 | this.delegate.scopeConnected(value) 65 | } 66 | } 67 | 68 | elementUnmatchedValue(element: Element, value: Scope) { 69 | const referenceCount = this.scopeReferenceCounts.get(value) 70 | if (referenceCount) { 71 | this.scopeReferenceCounts.set(value, referenceCount - 1) 72 | if (referenceCount == 1) { 73 | this.delegate.scopeDisconnected(value) 74 | } 75 | } 76 | } 77 | 78 | private fetchScopesByIdentifierForElement(element: Element) { 79 | let scopesByIdentifier = this.scopesByIdentifierByElement.get(element) 80 | if (!scopesByIdentifier) { 81 | scopesByIdentifier = new Map() 82 | this.scopesByIdentifierByElement.set(element, scopesByIdentifier) 83 | } 84 | return scopesByIdentifier 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/core/selectors.ts: -------------------------------------------------------------------------------- 1 | export function attributeValueContainsToken(attributeName: string, token: string) { 2 | return `[${attributeName}~="${token}"]` 3 | } 4 | -------------------------------------------------------------------------------- /src/core/string_helpers.ts: -------------------------------------------------------------------------------- 1 | export function camelize(value: string) { 2 | return value.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase()) 3 | } 4 | 5 | export function namespaceCamelize(value: string) { 6 | return camelize(value.replace(/--/g, "-").replace(/__/g, "_")) 7 | } 8 | 9 | export function capitalize(value: string) { 10 | return value.charAt(0).toUpperCase() + value.slice(1) 11 | } 12 | 13 | export function dasherize(value: string) { 14 | return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`) 15 | } 16 | 17 | export function tokenize(value: string) { 18 | return value.match(/[^\s]+/g) || [] 19 | } 20 | -------------------------------------------------------------------------------- /src/core/target_observer.ts: -------------------------------------------------------------------------------- 1 | import { Multimap } from "../multimap" 2 | import { Token, TokenListObserver, TokenListObserverDelegate } from "../mutation-observers" 3 | import { Context } from "./context" 4 | 5 | export interface TargetObserverDelegate { 6 | targetConnected(element: Element, name: string): void 7 | targetDisconnected(element: Element, name: string): void 8 | } 9 | 10 | export class TargetObserver implements TokenListObserverDelegate { 11 | readonly context: Context 12 | readonly delegate: TargetObserverDelegate 13 | readonly targetsByName: Multimap 14 | private tokenListObserver?: TokenListObserver 15 | 16 | constructor(context: Context, delegate: TargetObserverDelegate) { 17 | this.context = context 18 | this.delegate = delegate 19 | this.targetsByName = new Multimap() 20 | } 21 | 22 | start() { 23 | if (!this.tokenListObserver) { 24 | this.tokenListObserver = new TokenListObserver(this.element, this.attributeName, this) 25 | this.tokenListObserver.start() 26 | } 27 | } 28 | 29 | stop() { 30 | if (this.tokenListObserver) { 31 | this.disconnectAllTargets() 32 | this.tokenListObserver.stop() 33 | delete this.tokenListObserver 34 | } 35 | } 36 | 37 | // Token list observer delegate 38 | 39 | tokenMatched({ element, content: name }: Token) { 40 | if (this.scope.containsElement(element)) { 41 | this.connectTarget(element, name) 42 | } 43 | } 44 | 45 | tokenUnmatched({ element, content: name }: Token) { 46 | this.disconnectTarget(element, name) 47 | } 48 | 49 | // Target management 50 | 51 | connectTarget(element: Element, name: string) { 52 | if (!this.targetsByName.has(name, element)) { 53 | this.targetsByName.add(name, element) 54 | this.tokenListObserver?.pause(() => this.delegate.targetConnected(element, name)) 55 | } 56 | } 57 | 58 | disconnectTarget(element: Element, name: string) { 59 | if (this.targetsByName.has(name, element)) { 60 | this.targetsByName.delete(name, element) 61 | this.tokenListObserver?.pause(() => this.delegate.targetDisconnected(element, name)) 62 | } 63 | } 64 | 65 | disconnectAllTargets() { 66 | for (const name of this.targetsByName.keys) { 67 | for (const element of this.targetsByName.getValuesForKey(name)) { 68 | this.disconnectTarget(element, name) 69 | } 70 | } 71 | } 72 | 73 | // Private 74 | 75 | private get attributeName() { 76 | return `data-${this.context.identifier}-target` 77 | } 78 | 79 | private get element() { 80 | return this.context.element 81 | } 82 | 83 | private get scope() { 84 | return this.context.scope 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/core/target_properties.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from "./constructor" 2 | import { Controller } from "./controller" 3 | import { readInheritableStaticArrayValues } from "./inheritable_statics" 4 | import { capitalize } from "./string_helpers" 5 | 6 | export function TargetPropertiesBlessing(constructor: Constructor) { 7 | const targets = readInheritableStaticArrayValues(constructor, "targets") 8 | return targets.reduce((properties, targetDefinition) => { 9 | return Object.assign(properties, propertiesForTargetDefinition(targetDefinition)) 10 | }, {} as PropertyDescriptorMap) 11 | } 12 | 13 | function propertiesForTargetDefinition(name: string) { 14 | return { 15 | [`${name}Target`]: { 16 | get(this: Controller) { 17 | const target = this.targets.find(name) 18 | if (target) { 19 | return target 20 | } else { 21 | throw new Error(`Missing target element "${name}" for "${this.identifier}" controller`) 22 | } 23 | }, 24 | }, 25 | 26 | [`${name}Targets`]: { 27 | get(this: Controller) { 28 | return this.targets.findAll(name) 29 | }, 30 | }, 31 | 32 | [`has${capitalize(name)}Target`]: { 33 | get(this: Controller) { 34 | return this.targets.has(name) 35 | }, 36 | }, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/core/target_set.ts: -------------------------------------------------------------------------------- 1 | import { Scope } from "./scope" 2 | import { attributeValueContainsToken } from "./selectors" 3 | 4 | export class TargetSet { 5 | readonly scope: Scope 6 | 7 | constructor(scope: Scope) { 8 | this.scope = scope 9 | } 10 | 11 | get element() { 12 | return this.scope.element 13 | } 14 | 15 | get identifier() { 16 | return this.scope.identifier 17 | } 18 | 19 | get schema() { 20 | return this.scope.schema 21 | } 22 | 23 | has(targetName: string) { 24 | return this.find(targetName) != null 25 | } 26 | 27 | find(...targetNames: string[]) { 28 | return targetNames.reduce( 29 | (target, targetName) => target || this.findTarget(targetName) || this.findLegacyTarget(targetName), 30 | undefined as Element | undefined 31 | ) 32 | } 33 | 34 | findAll(...targetNames: string[]) { 35 | return targetNames.reduce( 36 | (targets, targetName) => [ 37 | ...targets, 38 | ...this.findAllTargets(targetName), 39 | ...this.findAllLegacyTargets(targetName), 40 | ], 41 | [] as Element[] 42 | ) 43 | } 44 | 45 | private findTarget(targetName: string) { 46 | const selector = this.getSelectorForTargetName(targetName) 47 | return this.scope.findElement(selector) 48 | } 49 | 50 | private findAllTargets(targetName: string) { 51 | const selector = this.getSelectorForTargetName(targetName) 52 | return this.scope.findAllElements(selector) 53 | } 54 | 55 | private getSelectorForTargetName(targetName: string) { 56 | const attributeName = this.schema.targetAttributeForScope(this.identifier) 57 | return attributeValueContainsToken(attributeName, targetName) 58 | } 59 | 60 | private findLegacyTarget(targetName: string) { 61 | const selector = this.getLegacySelectorForTargetName(targetName) 62 | return this.deprecate(this.scope.findElement(selector), targetName) 63 | } 64 | 65 | private findAllLegacyTargets(targetName: string) { 66 | const selector = this.getLegacySelectorForTargetName(targetName) 67 | return this.scope.findAllElements(selector).map((element) => this.deprecate(element, targetName)) 68 | } 69 | 70 | private getLegacySelectorForTargetName(targetName: string) { 71 | const targetDescriptor = `${this.identifier}.${targetName}` 72 | return attributeValueContainsToken(this.schema.targetAttribute, targetDescriptor) 73 | } 74 | 75 | private deprecate(element: T, targetName: string) { 76 | if (element) { 77 | const { identifier } = this 78 | const attributeName = this.schema.targetAttribute 79 | const revisedAttributeName = this.schema.targetAttributeForScope(identifier) 80 | this.guide.warn( 81 | element, 82 | `target:${targetName}`, 83 | `Please replace ${attributeName}="${identifier}.${targetName}" with ${revisedAttributeName}="${targetName}". ` + 84 | `The ${attributeName} attribute is deprecated and will be removed in a future version of Stimulus.` 85 | ) 86 | } 87 | return element 88 | } 89 | 90 | private get guide() { 91 | return this.scope.guide 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/core/utils.ts: -------------------------------------------------------------------------------- 1 | export function isSomething(object: any): boolean { 2 | return object !== null && object !== undefined 3 | } 4 | 5 | export function hasProperty(object: any, property: string): boolean { 6 | return Object.prototype.hasOwnProperty.call(object, property) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/value_observer.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "./context" 2 | import { StringMapObserver, StringMapObserverDelegate } from "../mutation-observers" 3 | import { ValueDescriptor } from "./value_properties" 4 | import { capitalize } from "./string_helpers" 5 | 6 | export class ValueObserver implements StringMapObserverDelegate { 7 | readonly context: Context 8 | readonly receiver: any 9 | private stringMapObserver: StringMapObserver 10 | private valueDescriptorMap: { [attributeName: string]: ValueDescriptor } 11 | 12 | constructor(context: Context, receiver: any) { 13 | this.context = context 14 | this.receiver = receiver 15 | this.stringMapObserver = new StringMapObserver(this.element, this) 16 | this.valueDescriptorMap = (this.controller as any).valueDescriptorMap 17 | } 18 | 19 | start() { 20 | this.stringMapObserver.start() 21 | this.invokeChangedCallbacksForDefaultValues() 22 | } 23 | 24 | stop() { 25 | this.stringMapObserver.stop() 26 | } 27 | 28 | get element() { 29 | return this.context.element 30 | } 31 | 32 | get controller() { 33 | return this.context.controller 34 | } 35 | 36 | // String map observer delegate 37 | 38 | getStringMapKeyForAttribute(attributeName: string) { 39 | if (attributeName in this.valueDescriptorMap) { 40 | return this.valueDescriptorMap[attributeName].name 41 | } 42 | } 43 | 44 | stringMapKeyAdded(key: string, attributeName: string) { 45 | const descriptor = this.valueDescriptorMap[attributeName] 46 | 47 | if (!this.hasValue(key)) { 48 | this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), descriptor.writer(descriptor.defaultValue)) 49 | } 50 | } 51 | 52 | stringMapValueChanged(value: string, name: string, oldValue: string) { 53 | const descriptor = this.valueDescriptorNameMap[name] 54 | 55 | if (value === null) return 56 | 57 | if (oldValue === null) { 58 | oldValue = descriptor.writer(descriptor.defaultValue) 59 | } 60 | 61 | this.invokeChangedCallback(name, value, oldValue) 62 | } 63 | 64 | stringMapKeyRemoved(key: string, attributeName: string, oldValue: string) { 65 | const descriptor = this.valueDescriptorNameMap[key] 66 | 67 | if (this.hasValue(key)) { 68 | this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), oldValue) 69 | } else { 70 | this.invokeChangedCallback(key, descriptor.writer(descriptor.defaultValue), oldValue) 71 | } 72 | } 73 | 74 | private invokeChangedCallbacksForDefaultValues() { 75 | for (const { key, name, defaultValue, writer } of this.valueDescriptors) { 76 | if (defaultValue != undefined && !this.controller.data.has(key)) { 77 | this.invokeChangedCallback(name, writer(defaultValue), undefined) 78 | } 79 | } 80 | } 81 | 82 | private invokeChangedCallback(name: string, rawValue: string, rawOldValue: string | undefined) { 83 | const changedMethodName = `${name}Changed` 84 | const changedMethod = this.receiver[changedMethodName] 85 | 86 | if (typeof changedMethod == "function") { 87 | const descriptor = this.valueDescriptorNameMap[name] 88 | 89 | try { 90 | const value = descriptor.reader(rawValue) 91 | let oldValue = rawOldValue 92 | 93 | if (rawOldValue) { 94 | oldValue = descriptor.reader(rawOldValue) 95 | } 96 | 97 | changedMethod.call(this.receiver, value, oldValue) 98 | } catch (error) { 99 | if (error instanceof TypeError) { 100 | error.message = `Stimulus Value "${this.context.identifier}.${descriptor.name}" - ${error.message}` 101 | } 102 | 103 | throw error 104 | } 105 | } 106 | } 107 | 108 | private get valueDescriptors() { 109 | const { valueDescriptorMap } = this 110 | return Object.keys(valueDescriptorMap).map((key) => valueDescriptorMap[key]) 111 | } 112 | 113 | private get valueDescriptorNameMap() { 114 | const descriptors: { [type: string]: ValueDescriptor } = {} 115 | 116 | Object.keys(this.valueDescriptorMap).forEach((key) => { 117 | const descriptor = this.valueDescriptorMap[key] 118 | descriptors[descriptor.name] = descriptor 119 | }) 120 | 121 | return descriptors 122 | } 123 | 124 | private hasValue(attributeName: string) { 125 | const descriptor = this.valueDescriptorNameMap[attributeName] 126 | const hasMethodName = `has${capitalize(descriptor.name)}` 127 | 128 | return this.receiver[hasMethodName] 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./core" 2 | export as namespace Stimulus 3 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from "./core" 2 | export * from "./multimap" 3 | export * from "./mutation-observers" 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core" 2 | export * from "./multimap" 3 | export * from "./mutation-observers" 4 | -------------------------------------------------------------------------------- /src/multimap/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./indexed_multimap" 2 | export * from "./multimap" 3 | export * from "./set_operations" 4 | -------------------------------------------------------------------------------- /src/multimap/indexed_multimap.ts: -------------------------------------------------------------------------------- 1 | import { Multimap } from "./multimap" 2 | import { add, del } from "./set_operations" 3 | 4 | export class IndexedMultimap extends Multimap { 5 | private keysByValue: Map> 6 | 7 | constructor() { 8 | super() 9 | this.keysByValue = new Map() 10 | } 11 | 12 | get values(): V[] { 13 | return Array.from(this.keysByValue.keys()) 14 | } 15 | 16 | add(key: K, value: V) { 17 | super.add(key, value) 18 | add(this.keysByValue, value, key) 19 | } 20 | 21 | delete(key: K, value: V) { 22 | super.delete(key, value) 23 | del(this.keysByValue, value, key) 24 | } 25 | 26 | hasValue(value: V): boolean { 27 | return this.keysByValue.has(value) 28 | } 29 | 30 | getKeysForValue(value: V): K[] { 31 | const set = this.keysByValue.get(value) 32 | return set ? Array.from(set) : [] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/multimap/multimap.ts: -------------------------------------------------------------------------------- 1 | import { add, del } from "./set_operations" 2 | 3 | export class Multimap { 4 | private valuesByKey: Map> 5 | 6 | constructor() { 7 | this.valuesByKey = new Map>() 8 | } 9 | 10 | get keys() { 11 | return Array.from(this.valuesByKey.keys()) 12 | } 13 | 14 | get values(): V[] { 15 | const sets = Array.from(this.valuesByKey.values()) 16 | return sets.reduce((values, set) => values.concat(Array.from(set)), []) 17 | } 18 | 19 | get size(): number { 20 | const sets = Array.from(this.valuesByKey.values()) 21 | return sets.reduce((size, set) => size + set.size, 0) 22 | } 23 | 24 | add(key: K, value: V) { 25 | add(this.valuesByKey, key, value) 26 | } 27 | 28 | delete(key: K, value: V) { 29 | del(this.valuesByKey, key, value) 30 | } 31 | 32 | has(key: K, value: V): boolean { 33 | const values = this.valuesByKey.get(key) 34 | return values != null && values.has(value) 35 | } 36 | 37 | hasKey(key: K): boolean { 38 | return this.valuesByKey.has(key) 39 | } 40 | 41 | hasValue(value: V): boolean { 42 | const sets = Array.from(this.valuesByKey.values()) 43 | return sets.some((set) => set.has(value)) 44 | } 45 | 46 | getValuesForKey(key: K): V[] { 47 | const values = this.valuesByKey.get(key) 48 | return values ? Array.from(values) : [] 49 | } 50 | 51 | getKeysForValue(value: V): K[] { 52 | return Array.from(this.valuesByKey) 53 | .filter(([_key, values]) => values.has(value)) 54 | .map(([key, _values]) => key) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/multimap/set_operations.ts: -------------------------------------------------------------------------------- 1 | export function add(map: Map>, key: K, value: V) { 2 | fetch(map, key).add(value) 3 | } 4 | 5 | export function del(map: Map>, key: K, value: V) { 6 | fetch(map, key).delete(value) 7 | prune(map, key) 8 | } 9 | 10 | export function fetch(map: Map>, key: K): Set { 11 | let values = map.get(key) 12 | if (!values) { 13 | values = new Set() 14 | map.set(key, values) 15 | } 16 | return values 17 | } 18 | 19 | export function prune(map: Map>, key: K) { 20 | const values = map.get(key) 21 | if (values != null && values.size == 0) { 22 | map.delete(key) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/mutation-observers/attribute_observer.ts: -------------------------------------------------------------------------------- 1 | import { ElementObserver, ElementObserverDelegate } from "./element_observer" 2 | 3 | export interface AttributeObserverDelegate { 4 | elementMatchedAttribute?(element: Element, attributeName: string): void 5 | elementAttributeValueChanged?(element: Element, attributeName: string): void 6 | elementUnmatchedAttribute?(element: Element, attributeName: string): void 7 | } 8 | 9 | export class AttributeObserver implements ElementObserverDelegate { 10 | attributeName: string 11 | private delegate: AttributeObserverDelegate 12 | 13 | private elementObserver: ElementObserver 14 | 15 | constructor(element: Element, attributeName: string, delegate: AttributeObserverDelegate) { 16 | this.attributeName = attributeName 17 | this.delegate = delegate 18 | 19 | this.elementObserver = new ElementObserver(element, this) 20 | } 21 | 22 | get element(): Element { 23 | return this.elementObserver.element 24 | } 25 | 26 | get selector(): string { 27 | return `[${this.attributeName}]` 28 | } 29 | 30 | start() { 31 | this.elementObserver.start() 32 | } 33 | 34 | pause(callback: () => void) { 35 | this.elementObserver.pause(callback) 36 | } 37 | 38 | stop() { 39 | this.elementObserver.stop() 40 | } 41 | 42 | refresh() { 43 | this.elementObserver.refresh() 44 | } 45 | 46 | get started(): boolean { 47 | return this.elementObserver.started 48 | } 49 | 50 | // Element observer delegate 51 | 52 | matchElement(element: Element): boolean { 53 | return element.hasAttribute(this.attributeName) 54 | } 55 | 56 | matchElementsInTree(tree: Element): Element[] { 57 | const match = this.matchElement(tree) ? [tree] : [] 58 | const matches = Array.from(tree.querySelectorAll(this.selector)) 59 | return match.concat(matches) 60 | } 61 | 62 | elementMatched(element: Element) { 63 | if (this.delegate.elementMatchedAttribute) { 64 | this.delegate.elementMatchedAttribute(element, this.attributeName) 65 | } 66 | } 67 | 68 | elementUnmatched(element: Element) { 69 | if (this.delegate.elementUnmatchedAttribute) { 70 | this.delegate.elementUnmatchedAttribute(element, this.attributeName) 71 | } 72 | } 73 | 74 | elementAttributeChanged(element: Element, attributeName: string) { 75 | if (this.delegate.elementAttributeValueChanged && this.attributeName == attributeName) { 76 | this.delegate.elementAttributeValueChanged(element, attributeName) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/mutation-observers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./attribute_observer" 2 | export * from "./element_observer" 3 | export * from "./selector_observer" 4 | export * from "./string_map_observer" 5 | export * from "./token_list_observer" 6 | export * from "./value_list_observer" 7 | -------------------------------------------------------------------------------- /src/mutation-observers/selector_observer.ts: -------------------------------------------------------------------------------- 1 | import { ElementObserver, ElementObserverDelegate } from "./element_observer" 2 | import { Multimap } from "../multimap" 3 | 4 | export interface SelectorObserverDelegate { 5 | selectorMatched(element: Element, selector: string, details: object): void 6 | selectorUnmatched(element: Element, selector: string, details: object): void 7 | selectorMatchElement?(element: Element, details: object): boolean 8 | } 9 | 10 | export class SelectorObserver implements ElementObserverDelegate { 11 | private readonly elementObserver: ElementObserver 12 | private readonly delegate: SelectorObserverDelegate 13 | private readonly matchesByElement: Multimap 14 | private readonly details: object 15 | _selector: string | null 16 | 17 | constructor(element: Element, selector: string, delegate: SelectorObserverDelegate, details: object) { 18 | this._selector = selector 19 | this.details = details 20 | this.elementObserver = new ElementObserver(element, this) 21 | this.delegate = delegate 22 | this.matchesByElement = new Multimap() 23 | } 24 | 25 | get started(): boolean { 26 | return this.elementObserver.started 27 | } 28 | 29 | get selector() { 30 | return this._selector 31 | } 32 | 33 | set selector(selector: string | null) { 34 | this._selector = selector 35 | this.refresh() 36 | } 37 | 38 | start() { 39 | this.elementObserver.start() 40 | } 41 | 42 | pause(callback: () => void) { 43 | this.elementObserver.pause(callback) 44 | } 45 | 46 | stop() { 47 | this.elementObserver.stop() 48 | } 49 | 50 | refresh() { 51 | this.elementObserver.refresh() 52 | } 53 | 54 | get element(): Element { 55 | return this.elementObserver.element 56 | } 57 | 58 | // Element observer delegate 59 | 60 | matchElement(element: Element): boolean { 61 | const { selector } = this 62 | 63 | if (selector) { 64 | const matches = element.matches(selector) 65 | 66 | if (this.delegate.selectorMatchElement) { 67 | return matches && this.delegate.selectorMatchElement(element, this.details) 68 | } 69 | 70 | return matches 71 | } else { 72 | return false 73 | } 74 | } 75 | 76 | matchElementsInTree(tree: Element): Element[] { 77 | const { selector } = this 78 | 79 | if (selector) { 80 | const match = this.matchElement(tree) ? [tree] : [] 81 | const matches = Array.from(tree.querySelectorAll(selector)).filter((match) => this.matchElement(match)) 82 | return match.concat(matches) 83 | } else { 84 | return [] 85 | } 86 | } 87 | 88 | elementMatched(element: Element) { 89 | const { selector } = this 90 | 91 | if (selector) { 92 | this.selectorMatched(element, selector) 93 | } 94 | } 95 | 96 | elementUnmatched(element: Element) { 97 | const selectors = this.matchesByElement.getKeysForValue(element) 98 | 99 | for (const selector of selectors) { 100 | this.selectorUnmatched(element, selector) 101 | } 102 | } 103 | 104 | elementAttributeChanged(element: Element, _attributeName: string) { 105 | const { selector } = this 106 | 107 | if (selector) { 108 | const matches = this.matchElement(element) 109 | const matchedBefore = this.matchesByElement.has(selector, element) 110 | 111 | if (matches && !matchedBefore) { 112 | this.selectorMatched(element, selector) 113 | } else if (!matches && matchedBefore) { 114 | this.selectorUnmatched(element, selector) 115 | } 116 | } 117 | } 118 | 119 | // Selector management 120 | 121 | private selectorMatched(element: Element, selector: string) { 122 | this.delegate.selectorMatched(element, selector, this.details) 123 | this.matchesByElement.add(selector, element) 124 | } 125 | 126 | private selectorUnmatched(element: Element, selector: string) { 127 | this.delegate.selectorUnmatched(element, selector, this.details) 128 | this.matchesByElement.delete(selector, element) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/mutation-observers/string_map_observer.ts: -------------------------------------------------------------------------------- 1 | export interface StringMapObserverDelegate { 2 | getStringMapKeyForAttribute(attributeName: string): string | undefined 3 | stringMapKeyAdded?(key: string, attributeName: string): void 4 | stringMapValueChanged?(value: string | null, key: string, oldValue: string | null): void 5 | stringMapKeyRemoved?(key: string, attributeName: string, oldValue: string | null): void 6 | } 7 | 8 | export class StringMapObserver { 9 | readonly element: Element 10 | readonly delegate: StringMapObserverDelegate 11 | private started: boolean 12 | private stringMap: Map 13 | private mutationObserver: MutationObserver 14 | 15 | constructor(element: Element, delegate: StringMapObserverDelegate) { 16 | this.element = element 17 | this.delegate = delegate 18 | this.started = false 19 | this.stringMap = new Map() 20 | this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations)) 21 | } 22 | 23 | start() { 24 | if (!this.started) { 25 | this.started = true 26 | this.mutationObserver.observe(this.element, { attributes: true, attributeOldValue: true }) 27 | this.refresh() 28 | } 29 | } 30 | 31 | stop() { 32 | if (this.started) { 33 | this.mutationObserver.takeRecords() 34 | this.mutationObserver.disconnect() 35 | this.started = false 36 | } 37 | } 38 | 39 | refresh() { 40 | if (this.started) { 41 | for (const attributeName of this.knownAttributeNames) { 42 | this.refreshAttribute(attributeName, null) 43 | } 44 | } 45 | } 46 | 47 | // Mutation record processing 48 | 49 | private processMutations(mutations: MutationRecord[]) { 50 | if (this.started) { 51 | for (const mutation of mutations) { 52 | this.processMutation(mutation) 53 | } 54 | } 55 | } 56 | 57 | private processMutation(mutation: MutationRecord) { 58 | const attributeName = mutation.attributeName 59 | if (attributeName) { 60 | this.refreshAttribute(attributeName, mutation.oldValue) 61 | } 62 | } 63 | 64 | // State tracking 65 | 66 | private refreshAttribute(attributeName: string, oldValue: string | null) { 67 | const key = this.delegate.getStringMapKeyForAttribute(attributeName) 68 | if (key != null) { 69 | if (!this.stringMap.has(attributeName)) { 70 | this.stringMapKeyAdded(key, attributeName) 71 | } 72 | 73 | const value = this.element.getAttribute(attributeName) 74 | if (this.stringMap.get(attributeName) != value) { 75 | this.stringMapValueChanged(value, key, oldValue) 76 | } 77 | 78 | if (value == null) { 79 | const oldValue = this.stringMap.get(attributeName) 80 | this.stringMap.delete(attributeName) 81 | if (oldValue) this.stringMapKeyRemoved(key, attributeName, oldValue) 82 | } else { 83 | this.stringMap.set(attributeName, value) 84 | } 85 | } 86 | } 87 | 88 | private stringMapKeyAdded(key: string, attributeName: string) { 89 | if (this.delegate.stringMapKeyAdded) { 90 | this.delegate.stringMapKeyAdded(key, attributeName) 91 | } 92 | } 93 | 94 | private stringMapValueChanged(value: string | null, key: string, oldValue: string | null) { 95 | if (this.delegate.stringMapValueChanged) { 96 | this.delegate.stringMapValueChanged(value, key, oldValue) 97 | } 98 | } 99 | 100 | private stringMapKeyRemoved(key: string, attributeName: string, oldValue: string | null) { 101 | if (this.delegate.stringMapKeyRemoved) { 102 | this.delegate.stringMapKeyRemoved(key, attributeName, oldValue) 103 | } 104 | } 105 | 106 | private get knownAttributeNames() { 107 | return Array.from(new Set(this.currentAttributeNames.concat(this.recordedAttributeNames))) 108 | } 109 | 110 | private get currentAttributeNames() { 111 | return Array.from(this.element.attributes).map((attribute) => attribute.name) 112 | } 113 | 114 | private get recordedAttributeNames() { 115 | return Array.from(this.stringMap.keys()) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/mutation-observers/token_list_observer.ts: -------------------------------------------------------------------------------- 1 | import { AttributeObserver, AttributeObserverDelegate } from "./attribute_observer" 2 | import { Multimap } from "../multimap" 3 | 4 | export interface Token { 5 | element: Element 6 | attributeName: string 7 | index: number 8 | content: string 9 | } 10 | 11 | export interface TokenListObserverDelegate { 12 | tokenMatched(token: Token): void 13 | tokenUnmatched(token: Token): void 14 | } 15 | 16 | export class TokenListObserver implements AttributeObserverDelegate { 17 | private attributeObserver: AttributeObserver 18 | private delegate: TokenListObserverDelegate 19 | private tokensByElement: Multimap 20 | 21 | constructor(element: Element, attributeName: string, delegate: TokenListObserverDelegate) { 22 | this.attributeObserver = new AttributeObserver(element, attributeName, this) 23 | this.delegate = delegate 24 | this.tokensByElement = new Multimap() 25 | } 26 | 27 | get started(): boolean { 28 | return this.attributeObserver.started 29 | } 30 | 31 | start() { 32 | this.attributeObserver.start() 33 | } 34 | 35 | pause(callback: () => void) { 36 | this.attributeObserver.pause(callback) 37 | } 38 | 39 | stop() { 40 | this.attributeObserver.stop() 41 | } 42 | 43 | refresh() { 44 | this.attributeObserver.refresh() 45 | } 46 | 47 | get element(): Element { 48 | return this.attributeObserver.element 49 | } 50 | 51 | get attributeName(): string { 52 | return this.attributeObserver.attributeName 53 | } 54 | 55 | // Attribute observer delegate 56 | 57 | elementMatchedAttribute(element: Element) { 58 | this.tokensMatched(this.readTokensForElement(element)) 59 | } 60 | 61 | elementAttributeValueChanged(element: Element) { 62 | const [unmatchedTokens, matchedTokens] = this.refreshTokensForElement(element) 63 | this.tokensUnmatched(unmatchedTokens) 64 | this.tokensMatched(matchedTokens) 65 | } 66 | 67 | elementUnmatchedAttribute(element: Element) { 68 | this.tokensUnmatched(this.tokensByElement.getValuesForKey(element)) 69 | } 70 | 71 | private tokensMatched(tokens: Token[]) { 72 | tokens.forEach((token) => this.tokenMatched(token)) 73 | } 74 | 75 | private tokensUnmatched(tokens: Token[]) { 76 | tokens.forEach((token) => this.tokenUnmatched(token)) 77 | } 78 | 79 | private tokenMatched(token: Token) { 80 | this.delegate.tokenMatched(token) 81 | this.tokensByElement.add(token.element, token) 82 | } 83 | 84 | private tokenUnmatched(token: Token) { 85 | this.delegate.tokenUnmatched(token) 86 | this.tokensByElement.delete(token.element, token) 87 | } 88 | 89 | private refreshTokensForElement(element: Element): [Token[], Token[]] { 90 | const previousTokens = this.tokensByElement.getValuesForKey(element) 91 | const currentTokens = this.readTokensForElement(element) 92 | const firstDifferingIndex = zip(previousTokens, currentTokens).findIndex( 93 | ([previousToken, currentToken]) => !tokensAreEqual(previousToken, currentToken) 94 | ) 95 | 96 | if (firstDifferingIndex == -1) { 97 | return [[], []] 98 | } else { 99 | return [previousTokens.slice(firstDifferingIndex), currentTokens.slice(firstDifferingIndex)] 100 | } 101 | } 102 | 103 | private readTokensForElement(element: Element): Token[] { 104 | const attributeName = this.attributeName 105 | const tokenString = element.getAttribute(attributeName) || "" 106 | return parseTokenString(tokenString, element, attributeName) 107 | } 108 | } 109 | 110 | function parseTokenString(tokenString: string, element: Element, attributeName: string): Token[] { 111 | return tokenString 112 | .trim() 113 | .split(/\s+/) 114 | .filter((content) => content.length) 115 | .map((content, index) => ({ element, attributeName, content, index })) 116 | } 117 | 118 | function zip(left: L[], right: R[]): [L | undefined, R | undefined][] { 119 | const length = Math.max(left.length, right.length) 120 | return Array.from({ length }, (_, index) => [left[index], right[index]] as [L, R]) 121 | } 122 | 123 | function tokensAreEqual(left?: Token, right?: Token) { 124 | return left && right && left.index == right.index && left.content == right.content 125 | } 126 | -------------------------------------------------------------------------------- /src/mutation-observers/value_list_observer.ts: -------------------------------------------------------------------------------- 1 | import { Token, TokenListObserver, TokenListObserverDelegate } from "./token_list_observer" 2 | 3 | export interface ValueListObserverDelegate { 4 | parseValueForToken(token: Token): T | undefined 5 | elementMatchedValue(element: Element, value: T): void 6 | elementUnmatchedValue(element: Element, value: T): void 7 | } 8 | 9 | interface ParseResult { 10 | value?: T 11 | error?: Error 12 | } 13 | 14 | export class ValueListObserver implements TokenListObserverDelegate { 15 | private tokenListObserver: TokenListObserver 16 | private delegate: ValueListObserverDelegate 17 | private parseResultsByToken: WeakMap> 18 | private valuesByTokenByElement: WeakMap> 19 | 20 | constructor(element: Element, attributeName: string, delegate: ValueListObserverDelegate) { 21 | this.tokenListObserver = new TokenListObserver(element, attributeName, this) 22 | this.delegate = delegate 23 | this.parseResultsByToken = new WeakMap() 24 | this.valuesByTokenByElement = new WeakMap() 25 | } 26 | 27 | get started(): boolean { 28 | return this.tokenListObserver.started 29 | } 30 | 31 | start() { 32 | this.tokenListObserver.start() 33 | } 34 | 35 | stop() { 36 | this.tokenListObserver.stop() 37 | } 38 | 39 | refresh() { 40 | this.tokenListObserver.refresh() 41 | } 42 | 43 | get element(): Element { 44 | return this.tokenListObserver.element 45 | } 46 | 47 | get attributeName(): string { 48 | return this.tokenListObserver.attributeName 49 | } 50 | 51 | tokenMatched(token: Token) { 52 | const { element } = token 53 | const { value } = this.fetchParseResultForToken(token) 54 | if (value) { 55 | this.fetchValuesByTokenForElement(element).set(token, value) 56 | this.delegate.elementMatchedValue(element, value) 57 | } 58 | } 59 | 60 | tokenUnmatched(token: Token) { 61 | const { element } = token 62 | const { value } = this.fetchParseResultForToken(token) 63 | if (value) { 64 | this.fetchValuesByTokenForElement(element).delete(token) 65 | this.delegate.elementUnmatchedValue(element, value) 66 | } 67 | } 68 | 69 | private fetchParseResultForToken(token: Token) { 70 | let parseResult = this.parseResultsByToken.get(token) 71 | if (!parseResult) { 72 | parseResult = this.parseToken(token) 73 | this.parseResultsByToken.set(token, parseResult) 74 | } 75 | return parseResult 76 | } 77 | 78 | private fetchValuesByTokenForElement(element: Element) { 79 | let valuesByToken = this.valuesByTokenByElement.get(element) 80 | if (!valuesByToken) { 81 | valuesByToken = new Map() 82 | this.valuesByTokenByElement.set(element, valuesByToken) 83 | } 84 | return valuesByToken 85 | } 86 | 87 | private parseToken(token: Token): ParseResult { 88 | try { 89 | const value = this.delegate.parseValueForToken(token) 90 | return { value } 91 | } catch (error: any) { 92 | return { error } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/tests/cases/application_test_case.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "../../core/application" 2 | import { DOMTestCase } from "./dom_test_case" 3 | import { Schema, defaultSchema } from "../../core/schema" 4 | 5 | export class TestApplication extends Application { 6 | handleError(error: Error, _message: string, _detail: object) { 7 | throw error 8 | } 9 | } 10 | 11 | export class ApplicationTestCase extends DOMTestCase { 12 | schema: Schema = defaultSchema 13 | application!: Application 14 | 15 | async runTest(testName: string) { 16 | try { 17 | this.application = new TestApplication(this.fixtureElement, this.schema) 18 | this.setupApplication() 19 | this.application.start() 20 | await super.runTest(testName) 21 | } finally { 22 | this.application.stop() 23 | } 24 | } 25 | 26 | setupApplication() { 27 | // Override in subclasses to register controllers 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/tests/cases/controller_test_case.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationTestCase } from "./application_test_case" 2 | import { Constructor } from "../../core/constructor" 3 | import { Controller, ControllerConstructor } from "../../core/controller" 4 | 5 | export class ControllerTests extends ApplicationTestCase { 6 | identifier: string | string[] = "test" 7 | controllerConstructor!: ControllerConstructor 8 | fixtureHTML = `
` 9 | 10 | setupApplication() { 11 | this.identifiers.forEach((identifier) => { 12 | this.application.register(identifier, this.controllerConstructor) 13 | }) 14 | } 15 | 16 | get controller(): T { 17 | const controller = this.controllers[0] 18 | if (controller) { 19 | return controller 20 | } else { 21 | throw new Error("no controller connected") 22 | } 23 | } 24 | 25 | get identifiers(): string[] { 26 | if (typeof this.identifier == "string") { 27 | return [this.identifier] 28 | } else { 29 | return this.identifier 30 | } 31 | } 32 | 33 | get controllers(): T[] { 34 | return this.application.controllers as any as T[] 35 | } 36 | } 37 | 38 | export function ControllerTestCase(): Constructor> 39 | export function ControllerTestCase(constructor: Constructor): Constructor> 40 | export function ControllerTestCase( 41 | constructor?: Constructor 42 | ): Constructor> { 43 | return class extends ControllerTests { 44 | controllerConstructor = constructor || (Controller as any) 45 | } as any 46 | } 47 | -------------------------------------------------------------------------------- /src/tests/cases/dom_test_case.ts: -------------------------------------------------------------------------------- 1 | import { TestCase } from "./test_case" 2 | 3 | interface TriggerEventOptions { 4 | bubbles?: boolean 5 | setDefaultPrevented?: boolean 6 | } 7 | 8 | const defaultTriggerEventOptions: TriggerEventOptions = { 9 | bubbles: true, 10 | setDefaultPrevented: true, 11 | } 12 | 13 | export class DOMTestCase extends TestCase { 14 | fixtureSelector = "#qunit-fixture" 15 | fixtureHTML = "" 16 | 17 | async runTest(testName: string) { 18 | await this.renderFixture() 19 | await super.runTest(testName) 20 | } 21 | 22 | async renderFixture(fixtureHTML = this.fixtureHTML) { 23 | this.fixtureElement.innerHTML = fixtureHTML 24 | return this.nextFrame 25 | } 26 | 27 | get fixtureElement(): Element { 28 | const element = document.querySelector(this.fixtureSelector) 29 | if (element) { 30 | return element 31 | } else { 32 | throw new Error(`missing fixture element "${this.fixtureSelector}"`) 33 | } 34 | } 35 | 36 | async triggerEvent(selectorOrTarget: string | EventTarget, type: string, options: TriggerEventOptions = {}) { 37 | const { bubbles, setDefaultPrevented } = { ...defaultTriggerEventOptions, ...options } 38 | const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget 39 | const event = document.createEvent("Events") 40 | event.initEvent(type, bubbles, true) 41 | 42 | // IE <= 11 does not set `defaultPrevented` when `preventDefault()` is called on synthetic events 43 | if (setDefaultPrevented) { 44 | event.preventDefault = function () { 45 | Object.defineProperty(this, "defaultPrevented", { get: () => true, configurable: true }) 46 | } 47 | } 48 | 49 | eventTarget.dispatchEvent(event) 50 | await this.nextFrame 51 | return event 52 | } 53 | 54 | async triggerMouseEvent(selectorOrTarget: string | EventTarget, type: string, options: MouseEventInit = {}) { 55 | const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget 56 | const event = new MouseEvent(type, options) 57 | 58 | eventTarget.dispatchEvent(event) 59 | await this.nextFrame 60 | return event 61 | } 62 | 63 | async triggerKeyboardEvent(selectorOrTarget: string | EventTarget, type: string, options: KeyboardEventInit = {}) { 64 | const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget 65 | const event = new KeyboardEvent(type, options) 66 | 67 | eventTarget.dispatchEvent(event) 68 | await this.nextFrame 69 | return event 70 | } 71 | 72 | async setAttribute(selectorOrElement: string | Element, name: string, value: string) { 73 | const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement 74 | 75 | element.setAttribute(name, value) 76 | await this.nextFrame 77 | } 78 | 79 | async removeAttribute(selectorOrElement: string | Element, name: string) { 80 | const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement 81 | 82 | element.removeAttribute(name) 83 | await this.nextFrame 84 | } 85 | 86 | async appendChild(selectorOrElement: T | string, child: T) { 87 | const parent = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement 88 | 89 | parent.appendChild(child) 90 | await this.nextFrame 91 | } 92 | 93 | async remove(selectorOrElement: Element | string) { 94 | const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement 95 | 96 | element.remove() 97 | await this.nextFrame 98 | } 99 | 100 | findElement(selector: string) { 101 | const element = this.fixtureElement.querySelector(selector) 102 | if (element) { 103 | return element 104 | } else { 105 | throw new Error(`couldn't find element "${selector}"`) 106 | } 107 | } 108 | 109 | findElements(...selectors: string[]) { 110 | return selectors.map((selector) => this.findElement(selector)) 111 | } 112 | 113 | get nextFrame(): Promise { 114 | return new Promise((resolve) => requestAnimationFrame(resolve)) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/tests/cases/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./application_test_case" 2 | export * from "./controller_test_case" 3 | export * from "./dom_test_case" 4 | export * from "./log_controller_test_case" 5 | export * from "./observer_test_case" 6 | export * from "./test_case" 7 | -------------------------------------------------------------------------------- /src/tests/cases/log_controller_test_case.ts: -------------------------------------------------------------------------------- 1 | import { ControllerTestCase } from "./controller_test_case" 2 | import { LogController, ActionLogEntry } from "../controllers/log_controller" 3 | import { ControllerConstructor } from "../../core/controller" 4 | 5 | export class LogControllerTestCase extends ControllerTestCase(LogController) { 6 | controllerConstructor!: ControllerConstructor & { actionLog: ActionLogEntry[] } 7 | 8 | async setup() { 9 | this.controllerConstructor.actionLog = [] 10 | await super.setup() 11 | } 12 | 13 | assertActions(...actions: any[]) { 14 | this.assert.equal(this.actionLog.length, actions.length) 15 | 16 | actions.forEach((expected, index) => { 17 | const keys = Object.keys(expected) 18 | const actual = slice(this.actionLog[index] || {}, keys) 19 | const result = keys.every((key) => deepEqual(expected[key], actual[key])) 20 | this.assert.pushResult({ result, actual, expected, message: "" }) 21 | }) 22 | } 23 | 24 | assertNoActions() { 25 | this.assert.equal(this.actionLog.length, 0) 26 | } 27 | 28 | get actionLog(): ActionLogEntry[] { 29 | return this.controllerConstructor.actionLog 30 | } 31 | } 32 | 33 | function slice(object: any, keys: string[]): any { 34 | return keys.reduce((result: any, key: string) => ((result[key] = object[key]), result), {}) 35 | } 36 | 37 | function deepEqual(obj1: any, obj2: any): boolean { 38 | if (obj1 === obj2) { 39 | return true 40 | } else if (typeof obj1 === "object" && typeof obj2 === "object") { 41 | if (Object.keys(obj1).length !== Object.keys(obj2).length) { 42 | return false 43 | } 44 | for (const prop in obj1) { 45 | if (!deepEqual(obj1[prop], obj2[prop])) { 46 | return false 47 | } 48 | } 49 | return true 50 | } else { 51 | return false 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/tests/cases/observer_test_case.ts: -------------------------------------------------------------------------------- 1 | import { DOMTestCase } from "./dom_test_case" 2 | 3 | export interface Observer { 4 | start(): void 5 | stop(): void 6 | } 7 | 8 | export class ObserverTestCase extends DOMTestCase { 9 | observer!: Observer 10 | calls: any[][] = [] 11 | private setupCallCount = 0 12 | 13 | async setup() { 14 | this.observer.start() 15 | await this.nextFrame 16 | this.setupCallCount = this.calls.length 17 | } 18 | 19 | async teardown() { 20 | this.observer.stop() 21 | } 22 | 23 | get testCalls() { 24 | return this.calls.slice(this.setupCallCount) 25 | } 26 | 27 | recordCall(methodName: string, ...args: any[]) { 28 | this.calls.push([methodName, ...args]) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/tests/cases/test_case.ts: -------------------------------------------------------------------------------- 1 | export class TestCase { 2 | readonly assert: Assert 3 | 4 | static defineModule(moduleName: string = this.name, qUnit: QUnit = QUnit) { 5 | qUnit.module(moduleName, (_hooks) => { 6 | this.manifest.forEach(([type, name]) => { 7 | type = this.shouldSkipTest(name) ? "skip" : type 8 | const method = (qUnit as any)[type] as Function 9 | const test = this.getTest(name) 10 | method.call(qUnit, name, test) 11 | }) 12 | }) 13 | } 14 | 15 | static getTest(testName: string) { 16 | return async (assert: Assert) => this.runTest(testName, assert) 17 | } 18 | 19 | static runTest(testName: string, assert: Assert) { 20 | const testCase = new this(assert) 21 | return testCase.runTest(testName) 22 | } 23 | 24 | static shouldSkipTest(_testName: string): boolean { 25 | return false 26 | } 27 | 28 | static get manifest() { 29 | return this.testPropertyNames.map((name) => [name.slice(0, 4), name.slice(5)]) 30 | } 31 | 32 | static get testNames(): string[] { 33 | return this.manifest.map(([_type, name]) => name) 34 | } 35 | 36 | static get testPropertyNames(): string[] { 37 | return Object.keys(this.prototype).filter((name) => name.match(/^(skip|test|todo) /)) 38 | } 39 | 40 | constructor(assert: Assert) { 41 | this.assert = assert 42 | } 43 | 44 | async runTest(testName: string) { 45 | try { 46 | await this.setup() 47 | await this.runTestBody(testName) 48 | } finally { 49 | await this.teardown() 50 | } 51 | } 52 | 53 | async runTestBody(testName: string) { 54 | const testCase = (this as any)[`test ${testName}`] || (this as any)[`todo ${testName}`] 55 | if (typeof testCase == "function") { 56 | return testCase.call(this) 57 | } else { 58 | return Promise.reject(`test not found: "${testName}"`) 59 | } 60 | } 61 | 62 | async setup() { 63 | // Override this method in your subclass to prepare your test environment 64 | } 65 | 66 | async teardown() { 67 | // Override this method in your subclass to clean up your test environment 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/tests/controllers/class_controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "../../core/controller" 2 | 3 | class BaseClassController extends Controller { 4 | static classes = ["active"] 5 | 6 | readonly activeClass!: string 7 | readonly activeClasses!: string[] 8 | readonly hasActiveClass!: boolean 9 | } 10 | 11 | export class ClassController extends BaseClassController { 12 | static classes = ["enabled", "loading", "success"] 13 | 14 | readonly hasEnabledClass!: boolean 15 | readonly enabledClass!: string 16 | readonly enabledClasses!: string[] 17 | readonly loadingClass!: string 18 | readonly successClass!: string 19 | readonly successClasses!: string[] 20 | } 21 | -------------------------------------------------------------------------------- /src/tests/controllers/default_value_controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "../../core/controller" 2 | import { ValueDefinitionMap, ValueDescriptorMap } from "../../core/value_properties" 3 | 4 | export class DefaultValueController extends Controller { 5 | static values: ValueDefinitionMap = { 6 | defaultBoolean: false, 7 | defaultBooleanTrue: { type: Boolean, default: true }, 8 | defaultBooleanFalse: { type: Boolean, default: false }, 9 | defaultBooleanOverride: true, 10 | 11 | defaultString: "", 12 | defaultStringHello: { type: String, default: "Hello" }, 13 | defaultStringOverride: "Override me", 14 | 15 | defaultNumber: 0, 16 | defaultNumberThousand: { type: Number, default: 1000 }, 17 | defaultNumberZero: { type: Number, default: 0 }, 18 | defaultNumberOverride: 9999, 19 | 20 | defaultArray: [], 21 | defaultArrayFilled: { type: Array, default: [1, 2, 3] }, 22 | defaultArrayOverride: [9, 9, 9], 23 | 24 | defaultObject: {}, 25 | defaultObjectPerson: { type: Object, default: { name: "David" } }, 26 | defaultObjectOverride: { override: "me" }, 27 | } 28 | 29 | valueDescriptorMap!: ValueDescriptorMap 30 | 31 | defaultBooleanValue!: boolean 32 | hasDefaultBooleanValue!: boolean 33 | defaultBooleanTrueValue!: boolean 34 | defaultBooleanFalseValue!: boolean 35 | hasDefaultBooleanTrueValue!: boolean 36 | hasDefaultBooleanFalseValue!: boolean 37 | defaultBooleanOverrideValue!: boolean 38 | hasDefaultBooleanOverrideValue!: boolean 39 | 40 | defaultStringValue!: string 41 | hasDefaultStringValue!: boolean 42 | defaultStringHelloValue!: string 43 | hasDefaultStringHelloValue!: boolean 44 | defaultStringOverrideValue!: string 45 | hasDefaultStringOverrideValue!: boolean 46 | 47 | defaultNumberValue!: number 48 | hasDefaultNumberValue!: boolean 49 | defaultNumberThousandValue!: number 50 | hasDefaultNumberThousandValue!: boolean 51 | defaultNumberZeroValue!: number 52 | hasDefaultNumberZeroValue!: boolean 53 | defaultNumberOverrideValue!: number 54 | hasDefaultNumberOverrideValue!: boolean 55 | 56 | defaultArrayValue!: any[] 57 | hasDefaultArrayValue!: boolean 58 | defaultArrayFilledValue!: { [key: string]: any } 59 | hasDefaultArrayFilledValue!: boolean 60 | defaultArrayOverrideValue!: { [key: string]: any } 61 | hasDefaultArrayOverrideValue!: boolean 62 | 63 | defaultObjectValue!: object 64 | hasDefaultObjectValue!: boolean 65 | defaultObjectPersonValue!: object 66 | hasDefaultObjectPersonValue!: boolean 67 | defaultObjectOverrideValue!: object 68 | hasDefaultObjectOverrideValue!: boolean 69 | lifecycleCallbacks: string[] = [] 70 | 71 | initialize() { 72 | this.lifecycleCallbacks.push("initialize") 73 | } 74 | 75 | connect() { 76 | this.lifecycleCallbacks.push("connect") 77 | } 78 | 79 | defaultBooleanValueChanged() { 80 | this.lifecycleCallbacks.push("defaultBooleanValueChanged") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/tests/controllers/log_controller.ts: -------------------------------------------------------------------------------- 1 | import { ActionEvent } from "../../core/action_event" 2 | import { Controller } from "../../core/controller" 3 | 4 | export type ActionLogEntry = { 5 | name: string 6 | controller: Controller 7 | identifier: string 8 | eventType: string 9 | currentTarget: EventTarget | null 10 | params: { [key: string]: any } 11 | defaultPrevented: boolean 12 | passive: boolean 13 | } 14 | 15 | export class LogController extends Controller { 16 | static actionLog: ActionLogEntry[] = [] 17 | initializeCount = 0 18 | connectCount = 0 19 | disconnectCount = 0 20 | 21 | initialize() { 22 | this.initializeCount++ 23 | } 24 | 25 | connect() { 26 | this.connectCount++ 27 | } 28 | 29 | disconnect() { 30 | this.disconnectCount++ 31 | } 32 | 33 | log(event: ActionEvent) { 34 | this.recordAction("log", event) 35 | } 36 | 37 | log2(event: ActionEvent) { 38 | this.recordAction("log2", event) 39 | } 40 | 41 | log3(event: ActionEvent) { 42 | this.recordAction("log3", event) 43 | } 44 | 45 | logPassive(event: ActionEvent) { 46 | event.preventDefault() 47 | if (event.defaultPrevented) { 48 | this.recordAction("logPassive", event, false) 49 | } else { 50 | this.recordAction("logPassive", event, true) 51 | } 52 | } 53 | 54 | stop(event: ActionEvent) { 55 | this.recordAction("stop", event) 56 | event.stopImmediatePropagation() 57 | } 58 | 59 | get actionLog() { 60 | return (this.constructor as typeof LogController).actionLog 61 | } 62 | 63 | private recordAction(name: string, event: ActionEvent, passive?: boolean) { 64 | this.actionLog.push({ 65 | name, 66 | controller: this, 67 | identifier: this.identifier, 68 | eventType: event.type, 69 | currentTarget: event.currentTarget, 70 | params: event.params, 71 | defaultPrevented: event.defaultPrevented, 72 | passive: passive || false, 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/tests/controllers/outlet_controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "../../core/controller" 2 | 3 | class BaseOutletController extends Controller { 4 | static outlets = ["alpha"] 5 | 6 | alphaOutlet!: Controller | null 7 | alphaOutlets!: Controller[] 8 | alphaOutletElement!: Element | null 9 | alphaOutletElements!: Element[] 10 | hasAlphaOutlet!: boolean 11 | } 12 | 13 | export class OutletController extends BaseOutletController { 14 | static classes = ["connected", "disconnected"] 15 | static outlets = ["beta", "gamma", "delta", "omega", "namespaced--epsilon"] 16 | 17 | static values = { 18 | alphaOutletConnectedCallCount: Number, 19 | alphaOutletDisconnectedCallCount: Number, 20 | betaOutletConnectedCallCount: Number, 21 | betaOutletDisconnectedCallCount: Number, 22 | betaOutletsInConnect: Number, 23 | gammaOutletConnectedCallCount: Number, 24 | gammaOutletDisconnectedCallCount: Number, 25 | namespacedEpsilonOutletConnectedCallCount: Number, 26 | namespacedEpsilonOutletDisconnectedCallCount: Number, 27 | } 28 | 29 | betaOutlet!: Controller | null 30 | betaOutlets!: Controller[] 31 | betaOutletElement!: Element | null 32 | betaOutletElements!: Element[] 33 | hasBetaOutlet!: boolean 34 | 35 | namespacedEpsilonOutlet!: Controller | null 36 | namespacedEpsilonOutlets!: Controller[] 37 | namespacedEpsilonOutletElement!: Element | null 38 | namespacedEpsilonOutletElements!: Element[] 39 | hasNamespacedEpsilonOutlet!: boolean 40 | 41 | hasConnectedClass!: boolean 42 | hasDisconnectedClass!: boolean 43 | connectedClass!: string 44 | disconnectedClass!: string 45 | 46 | alphaOutletConnectedCallCountValue = 0 47 | alphaOutletDisconnectedCallCountValue = 0 48 | betaOutletConnectedCallCountValue = 0 49 | betaOutletDisconnectedCallCountValue = 0 50 | betaOutletsInConnectValue = 0 51 | gammaOutletConnectedCallCountValue = 0 52 | gammaOutletDisconnectedCallCountValue = 0 53 | namespacedEpsilonOutletConnectedCallCountValue = 0 54 | namespacedEpsilonOutletDisconnectedCallCountValue = 0 55 | 56 | connect() { 57 | this.betaOutletsInConnectValue = this.betaOutlets.length 58 | } 59 | 60 | alphaOutletConnected(_outlet: Controller, element: Element) { 61 | if (this.hasConnectedClass) element.classList.add(this.connectedClass) 62 | this.alphaOutletConnectedCallCountValue++ 63 | } 64 | 65 | alphaOutletDisconnected(_outlet: Controller, element: Element) { 66 | if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) 67 | this.alphaOutletDisconnectedCallCountValue++ 68 | } 69 | 70 | betaOutletConnected(_outlet: Controller, element: Element) { 71 | if (this.hasConnectedClass) element.classList.add(this.connectedClass) 72 | this.betaOutletConnectedCallCountValue++ 73 | } 74 | 75 | betaOutletDisconnected(_outlet: Controller, element: Element) { 76 | if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) 77 | this.betaOutletDisconnectedCallCountValue++ 78 | } 79 | 80 | gammaOutletConnected(_outlet: Controller, element: Element) { 81 | if (this.hasConnectedClass) element.classList.add(this.connectedClass) 82 | this.gammaOutletConnectedCallCountValue++ 83 | } 84 | 85 | namespacedEpsilonOutletConnected(_outlet: Controller, element: Element) { 86 | if (this.hasConnectedClass) element.classList.add(this.connectedClass) 87 | this.namespacedEpsilonOutletConnectedCallCountValue++ 88 | } 89 | 90 | namespacedEpsilonOutletDisconnected(_outlet: Controller, element: Element) { 91 | if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) 92 | this.namespacedEpsilonOutletDisconnectedCallCountValue++ 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/tests/controllers/target_controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "../../core/controller" 2 | 3 | class BaseTargetController extends Controller { 4 | static targets = ["alpha"] 5 | 6 | alphaTarget!: Element | null 7 | alphaTargets!: Element[] 8 | hasAlphaTarget!: boolean 9 | } 10 | 11 | export class TargetController extends BaseTargetController { 12 | static classes = ["connected", "disconnected"] 13 | static targets = ["beta", "input", "recursive"] 14 | static values = { 15 | inputTargetConnectedCallCount: Number, 16 | inputTargetDisconnectedCallCount: Number, 17 | recursiveTargetConnectedCallCount: Number, 18 | recursiveTargetDisconnectedCallCount: Number, 19 | } 20 | 21 | betaTarget!: Element | null 22 | betaTargets!: Element[] 23 | hasBetaTarget!: boolean 24 | 25 | inputTarget!: Element | null 26 | inputTargets!: Element[] 27 | hasInputTarget!: boolean 28 | 29 | hasConnectedClass!: boolean 30 | hasDisconnectedClass!: boolean 31 | connectedClass!: string 32 | disconnectedClass!: string 33 | 34 | inputTargetConnectedCallCountValue = 0 35 | inputTargetDisconnectedCallCountValue = 0 36 | recursiveTargetConnectedCallCountValue = 0 37 | recursiveTargetDisconnectedCallCountValue = 0 38 | 39 | inputTargetConnected(element: Element) { 40 | if (this.hasConnectedClass) element.classList.add(this.connectedClass) 41 | this.inputTargetConnectedCallCountValue++ 42 | } 43 | 44 | inputTargetDisconnected(element: Element) { 45 | if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) 46 | this.inputTargetDisconnectedCallCountValue++ 47 | } 48 | 49 | recursiveTargetConnected(element: Element) { 50 | element.remove() 51 | 52 | this.recursiveTargetConnectedCallCountValue++ 53 | this.element.append(element) 54 | } 55 | 56 | recursiveTargetDisconnected(_element: Element) { 57 | this.recursiveTargetDisconnectedCallCountValue++ 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/tests/controllers/value_controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "../../core/controller" 2 | import { ValueDefinitionMap, ValueDescriptorMap } from "../../core/value_properties" 3 | 4 | class BaseValueController extends Controller { 5 | static values: ValueDefinitionMap = { 6 | shadowedBoolean: String, 7 | string: String, 8 | numeric: Number, 9 | } 10 | 11 | valueDescriptorMap!: ValueDescriptorMap 12 | stringValue!: string 13 | numericValue!: number 14 | } 15 | 16 | export class ValueController extends BaseValueController { 17 | static values: ValueDefinitionMap = { 18 | shadowedBoolean: Boolean, 19 | missingString: String, 20 | ids: Array, 21 | options: Object, 22 | "time-24hr": Boolean, 23 | } 24 | 25 | shadowedBooleanValue!: boolean 26 | missingStringValue!: string 27 | idsValue!: any[] 28 | optionsValue!: { [key: string]: any } 29 | time24hrValue!: boolean 30 | 31 | loggedNumericValues: number[] = [] 32 | oldLoggedNumericValues: any[] = [] 33 | numericValueChanged(value: number, oldValue: any) { 34 | this.loggedNumericValues.push(value) 35 | this.oldLoggedNumericValues.push(oldValue) 36 | } 37 | 38 | loggedMissingStringValues: string[] = [] 39 | oldLoggedMissingStringValues: any[] = [] 40 | missingStringValueChanged(value: string, oldValue: any) { 41 | this.loggedMissingStringValues.push(value) 42 | this.oldLoggedMissingStringValues.push(oldValue) 43 | } 44 | 45 | optionsValues: Object[] = [] 46 | oldOptionsValues: any[] = [] 47 | optionsValueChanged(value: Object, oldValue: any) { 48 | this.optionsValues.push(value) 49 | this.oldOptionsValues.push(oldValue) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/tests/fixtures/application_start/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Application, Controller } from "../../../core" 2 | 3 | export function startApplication() { 4 | const startState = document.readyState 5 | 6 | class PostMessageController extends Controller { 7 | itemTargets!: Element[] 8 | 9 | static targets = ["item"] 10 | 11 | connect() { 12 | const connectState = document.readyState 13 | const targetCount = this.itemTargets.length 14 | const message = JSON.stringify({ startState, connectState, targetCount }) 15 | parent.postMessage(message, location.origin) 16 | } 17 | } 18 | 19 | const application = Application.start() 20 | application.register("a", PostMessageController) 21 | } 22 | -------------------------------------------------------------------------------- /src/tests/fixtures/application_start/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/tests/fixtures/application_start/index.ts: -------------------------------------------------------------------------------- 1 | import { startApplication } from "./helpers" 2 | 3 | startApplication() 4 | addEventListener("DOMContentLoaded", startApplication) 5 | addEventListener("load", startApplication) 6 | -------------------------------------------------------------------------------- /src/tests/index.ts: -------------------------------------------------------------------------------- 1 | const context = require.context("./modules", true, /\.js$/) 2 | const modules = context.keys().map((key) => context(key).default) 3 | modules.forEach((constructor) => constructor.defineModule()) 4 | -------------------------------------------------------------------------------- /src/tests/modules/core/action_click_filter_tests.ts: -------------------------------------------------------------------------------- 1 | import { LogControllerTestCase } from "../../cases/log_controller_test_case" 2 | 3 | export default class ActionClickFilterTests extends LogControllerTestCase { 4 | identifier = ["a"] 5 | 6 | fixtureHTML = ` 7 |
8 | 9 |
10 | ` 11 | 12 | async "test ignoring clicks with unmatched modifier"() { 13 | const button = this.findElement("#ctrl") 14 | await this.triggerMouseEvent(button, "click", { ctrlKey: true }) 15 | await this.nextFrame 16 | this.assertActions( 17 | { name: "log", identifier: "a", eventType: "click", currentTarget: button }, 18 | { name: "log2", identifier: "a", eventType: "click", currentTarget: button } 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/tests/modules/core/action_params_case_insensitive_tests.ts: -------------------------------------------------------------------------------- 1 | import ActionParamsTests from "./action_params_tests" 2 | 3 | export default class ActionParamsCaseInsensitiveTests extends ActionParamsTests { 4 | identifier = ["CamelCase", "AnotherOne"] 5 | fixtureHTML = ` 6 |
7 | 18 |
19 |
20 | ` 21 | expectedParamsForCamelCase = { 22 | id: 123, 23 | multiWordExample: "/path", 24 | payload: { 25 | value: 1, 26 | }, 27 | active: true, 28 | empty: "", 29 | inactive: false, 30 | } 31 | 32 | async "test clicking on the element does return its params"() { 33 | this.actionValue = "click->CamelCase#log" 34 | await this.nextFrame 35 | await this.triggerEvent(this.buttonElement, "click") 36 | 37 | this.assertActions({ identifier: "CamelCase", params: this.expectedParamsForCamelCase }) 38 | } 39 | 40 | async "test global event return element params where the action is defined"() { 41 | this.actionValue = "keydown@window->CamelCase#log" 42 | await this.nextFrame 43 | await this.triggerEvent("#outside", "keydown") 44 | 45 | this.assertActions({ identifier: "CamelCase", params: this.expectedParamsForCamelCase }) 46 | } 47 | 48 | async "test passing params to namespaced controller"() { 49 | this.actionValue = "click->CamelCase#log click->AnotherOne#log2" 50 | await this.nextFrame 51 | await this.triggerEvent(this.buttonElement, "click") 52 | 53 | this.assertActions( 54 | { identifier: "CamelCase", params: this.expectedParamsForCamelCase }, 55 | { identifier: "AnotherOne", params: { id: 234 } } 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/tests/modules/core/action_params_tests.ts: -------------------------------------------------------------------------------- 1 | import { LogControllerTestCase } from "../../cases/log_controller_test_case" 2 | 3 | export default class ActionParamsTests extends LogControllerTestCase { 4 | identifier = ["c", "d"] 5 | fixtureHTML = ` 6 |
7 | 18 |
19 |
20 | ` 21 | expectedParamsForC = { 22 | id: 123, 23 | multiWordExample: "/path", 24 | payload: { 25 | value: 1, 26 | }, 27 | active: true, 28 | empty: "", 29 | inactive: false, 30 | } 31 | 32 | async "test clicking on the element does return its params"() { 33 | this.actionValue = "click->c#log" 34 | await this.nextFrame 35 | await this.triggerEvent(this.buttonElement, "click") 36 | 37 | this.assertActions({ identifier: "c", params: this.expectedParamsForC }) 38 | } 39 | 40 | async "test global event return element params where the action is defined"() { 41 | this.actionValue = "keydown@window->c#log" 42 | await this.nextFrame 43 | await this.triggerEvent("#outside", "keydown") 44 | 45 | this.assertActions({ identifier: "c", params: this.expectedParamsForC }) 46 | } 47 | 48 | async "test passing params to namespaced controller"() { 49 | this.actionValue = "click->c#log click->d#log2" 50 | await this.nextFrame 51 | await this.triggerEvent(this.buttonElement, "click") 52 | 53 | this.assertActions({ identifier: "c", params: this.expectedParamsForC }, { identifier: "d", params: { id: 234 } }) 54 | } 55 | 56 | async "test updating manually the params values"() { 57 | this.actionValue = "click->c#log" 58 | await this.nextFrame 59 | await this.triggerEvent(this.buttonElement, "click") 60 | 61 | this.assertActions({ identifier: "c", params: this.expectedParamsForC }) 62 | 63 | this.buttonElement.setAttribute("data-c-id-param", "234") 64 | this.buttonElement.setAttribute("data-c-new-param", "new") 65 | this.buttonElement.removeAttribute("data-c-payload-param") 66 | this.triggerEvent(this.buttonElement, "click") 67 | 68 | this.assertActions( 69 | { identifier: "c", params: this.expectedParamsForC }, 70 | { 71 | identifier: "c", 72 | params: { 73 | id: 234, 74 | new: "new", 75 | multiWordExample: "/path", 76 | active: true, 77 | empty: "", 78 | inactive: false, 79 | }, 80 | } 81 | ) 82 | } 83 | 84 | async "test clicking on a nested element does return the params of the actionable element"() { 85 | this.actionValue = "click->c#log" 86 | await this.nextFrame 87 | await this.triggerEvent(this.nestedElement, "click") 88 | 89 | this.assertActions({ identifier: "c", params: this.expectedParamsForC }) 90 | } 91 | 92 | set actionValue(value: string) { 93 | this.buttonElement.setAttribute("data-action", value) 94 | } 95 | 96 | get element() { 97 | return this.findElement("div") 98 | } 99 | 100 | get buttonElement() { 101 | return this.findElement("button") 102 | } 103 | 104 | get nestedElement() { 105 | return this.findElement("#nested") 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/tests/modules/core/action_tests.ts: -------------------------------------------------------------------------------- 1 | import { LogControllerTestCase } from "../../cases/log_controller_test_case" 2 | 3 | export default class ActionTests extends LogControllerTestCase { 4 | identifier = "c" 5 | fixtureHTML = ` 6 |
7 | 8 |
9 |
10 |
11 |
12 |
13 |
14 | 15 | 16 | 17 | ` 18 | 19 | async "test default event"() { 20 | await this.triggerEvent("button", "click") 21 | this.assertActions({ name: "log", eventType: "click" }) 22 | } 23 | 24 | async "test bubbling events"() { 25 | await this.triggerEvent("span", "click") 26 | this.assertActions({ eventType: "click", currentTarget: this.findElement("button") }) 27 | } 28 | 29 | async "test non-bubbling events"() { 30 | await this.triggerEvent("span", "click", { bubbles: false }) 31 | this.assertNoActions() 32 | await this.triggerEvent("button", "click", { bubbles: false }) 33 | this.assertActions({ eventType: "click" }) 34 | } 35 | 36 | async "test nested actions"() { 37 | const innerController = this.controllers[1] 38 | await this.triggerEvent("#inner", "click") 39 | this.assert.ok(true) 40 | this.assertActions({ controller: innerController, eventType: "click" }) 41 | } 42 | 43 | async "test global actions"() { 44 | await this.triggerEvent("#outside", "keydown") 45 | this.assertActions({ name: "log", eventType: "keydown" }) 46 | } 47 | 48 | async "test nested global actions"() { 49 | const innerController = this.controllers[1] 50 | await this.triggerEvent("#outside", "keyup") 51 | this.assertActions({ controller: innerController, eventType: "keyup" }) 52 | } 53 | 54 | async "test multiple actions"() { 55 | await this.triggerEvent("#multiple", "mousedown") 56 | await this.triggerEvent("#multiple", "click") 57 | this.assertActions( 58 | { name: "log", eventType: "mousedown" }, 59 | { name: "log", eventType: "click" }, 60 | { name: "log2", eventType: "click" } 61 | ) 62 | } 63 | 64 | async "test actions on svg elements"() { 65 | await this.triggerEvent("#svgRoot", "click") 66 | await this.triggerEvent("#svgChild", "mousedown") 67 | this.assertActions({ name: "log", eventType: "click" }, { name: "log", eventType: "mousedown" }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/tests/modules/core/action_timing_tests.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "../../../core/controller" 2 | import { ControllerTestCase } from "../../cases/controller_test_case" 3 | 4 | class ActionTimingController extends Controller { 5 | static targets = ["button"] 6 | buttonTarget!: HTMLButtonElement 7 | event?: Event 8 | 9 | connect() { 10 | this.buttonTarget.click() 11 | } 12 | 13 | record(event: Event) { 14 | this.event = event 15 | } 16 | } 17 | 18 | export default class ActionTimingTests extends ControllerTestCase(ActionTimingController) { 19 | controllerConstructor = ActionTimingController 20 | identifier = "c" 21 | fixtureHTML = ` 22 |
23 | 24 |
25 | ` 26 | 27 | async "test triggering an action on connect"() { 28 | const { event } = this.controller 29 | this.assert.ok(event) 30 | this.assert.equal(event && event.type, "click") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/tests/modules/core/application_start_tests.ts: -------------------------------------------------------------------------------- 1 | import { DOMTestCase } from "../../cases" 2 | 3 | export default class ApplicationStartTests extends DOMTestCase { 4 | iframe!: HTMLIFrameElement 5 | 6 | async setup() { 7 | this.iframe = document.createElement("iframe") 8 | this.iframe.src = "/base/src/tests/fixtures/application_start/index.html" 9 | this.fixtureElement.appendChild(this.iframe) 10 | } 11 | 12 | async "test starting an application when the document is loading"() { 13 | const message = await this.messageFromStartState("loading") 14 | this.assertIn(message.connectState, ["interactive", "complete"]) 15 | this.assert.equal(message.targetCount, 3) 16 | } 17 | 18 | async "test starting an application when the document is interactive"() { 19 | const message = await this.messageFromStartState("interactive") 20 | this.assertIn(message.connectState, ["interactive", "complete"]) 21 | this.assert.equal(message.targetCount, 3) 22 | } 23 | 24 | async "test starting an application when the document is complete"() { 25 | const message = await this.messageFromStartState("complete") 26 | this.assertIn(message.connectState, ["complete"]) 27 | this.assert.equal(message.targetCount, 3) 28 | } 29 | 30 | private messageFromStartState(startState: string): Promise { 31 | return new Promise((resolve) => { 32 | const receiveMessage = (event: MessageEvent) => { 33 | if (event.source == this.iframe.contentWindow) { 34 | const message = JSON.parse(event.data) 35 | if (message.startState == startState) { 36 | removeEventListener("message", receiveMessage) 37 | resolve(message) 38 | } 39 | } 40 | } 41 | addEventListener("message", receiveMessage) 42 | }) 43 | } 44 | 45 | private assertIn(actual: any, expected: any[]) { 46 | const state = expected.indexOf(actual) > -1 47 | const message = `${JSON.stringify(actual)} is not in ${JSON.stringify(expected)}` 48 | this.assert.ok(state, message) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/tests/modules/core/application_tests.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationTestCase } from "../../cases/application_test_case" 2 | import { LogController } from "../../controllers/log_controller" 3 | 4 | class AController extends LogController {} 5 | class BController extends LogController {} 6 | 7 | export default class ApplicationTests extends ApplicationTestCase { 8 | fixtureHTML = `
` 9 | private definitions = [ 10 | { controllerConstructor: AController, identifier: "a" }, 11 | { controllerConstructor: BController, identifier: "b" }, 12 | ] 13 | 14 | async "test Application#register"() { 15 | this.assert.equal(this.controllers.length, 0) 16 | this.application.register("log", LogController) 17 | await this.renderFixture(`
`) 18 | 19 | this.assert.equal(this.controllers[0].initializeCount, 1) 20 | this.assert.equal(this.controllers[0].connectCount, 1) 21 | } 22 | 23 | "test Application#load"() { 24 | this.assert.equal(this.controllers.length, 0) 25 | this.application.load(this.definitions) 26 | this.assert.equal(this.controllers.length, 2) 27 | 28 | this.assert.ok(this.controllers[0] instanceof AController) 29 | this.assert.equal(this.controllers[0].initializeCount, 1) 30 | this.assert.equal(this.controllers[0].connectCount, 1) 31 | 32 | this.assert.ok(this.controllers[1] instanceof BController) 33 | this.assert.equal(this.controllers[1].initializeCount, 1) 34 | this.assert.equal(this.controllers[1].connectCount, 1) 35 | } 36 | 37 | "test Application#unload"() { 38 | this.application.load(this.definitions) 39 | const originalControllers = this.controllers 40 | 41 | this.application.unload("a") 42 | this.assert.equal(originalControllers[0].disconnectCount, 1) 43 | 44 | this.assert.equal(this.controllers.length, 1) 45 | this.assert.ok(this.controllers[0] instanceof BController) 46 | } 47 | 48 | get controllers() { 49 | return this.application.controllers as LogController[] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/tests/modules/core/class_tests.ts: -------------------------------------------------------------------------------- 1 | import { ControllerTestCase } from "../../cases/controller_test_case" 2 | import { ClassController } from "../../controllers/class_controller" 3 | 4 | export default class ClassTests extends ControllerTestCase(ClassController) { 5 | fixtureHTML = ` 6 |
12 | ` 13 | 14 | "test accessing a class property"() { 15 | this.assert.ok(this.controller.hasActiveClass) 16 | this.assert.equal(this.controller.activeClass, "test--active") 17 | this.assert.deepEqual(this.controller.activeClasses, ["test--active"]) 18 | } 19 | 20 | "test accessing a missing class property throws an error"() { 21 | this.assert.notOk(this.controller.hasEnabledClass) 22 | this.assert.raises(() => this.controller.enabledClass) 23 | this.assert.equal(this.controller.enabledClasses.length, 0) 24 | } 25 | 26 | "test classes must be scoped by identifier"() { 27 | this.assert.equal(this.controller.loadingClass, "busy") 28 | } 29 | 30 | "test multiple classes map to array"() { 31 | this.assert.deepEqual(this.controller.successClasses, ["bg-green-400", "border", "border-green-600"]) 32 | } 33 | 34 | "test accessing a class property returns first class if multiple classes are used"() { 35 | this.assert.equal(this.controller.successClass, "bg-green-400") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/tests/modules/core/data_tests.ts: -------------------------------------------------------------------------------- 1 | import { ControllerTestCase } from "../../cases/controller_test_case" 2 | 3 | export default class DataTests extends ControllerTestCase() { 4 | fixtureHTML = ` 5 |
8 |
9 | ` 10 | 11 | "test DataSet#get"() { 12 | this.assert.equal(this.controller.data.get("alpha"), "hello world") 13 | this.assert.equal(this.controller.data.get("betaGamma"), "123") 14 | this.assert.equal(this.controller.data.get("nonexistent"), null) 15 | } 16 | 17 | "test DataSet#set"() { 18 | this.assert.equal(this.controller.data.set("alpha", "ok"), "ok") 19 | this.assert.equal(this.controller.data.get("alpha"), "ok") 20 | this.assert.equal(this.findElement("div").getAttribute(`data-${this.identifier}-alpha`), "ok") 21 | } 22 | 23 | "test DataSet#has"() { 24 | this.assert.ok(this.controller.data.has("alpha")) 25 | this.assert.ok(this.controller.data.has("betaGamma")) 26 | this.assert.notOk(this.controller.data.has("nonexistent")) 27 | } 28 | 29 | "test DataSet#delete"() { 30 | this.controller.data.delete("alpha") 31 | this.assert.equal(this.controller.data.get("alpha"), null) 32 | this.assert.notOk(this.controller.data.has("alpha")) 33 | this.assert.notOk(this.findElement("div").hasAttribute(`data-${this.identifier}-alpha`)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/tests/modules/core/error_handler_tests.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "../../../core/controller" 2 | import { Application } from "../../../core/application" 3 | import { ControllerTestCase } from "../../cases/controller_test_case" 4 | 5 | class MockLogger { 6 | errors: any[] = [] 7 | logs: any[] = [] 8 | warns: any[] = [] 9 | 10 | log(event: any) { 11 | this.logs.push(event) 12 | } 13 | 14 | error(event: any) { 15 | this.errors.push(event) 16 | } 17 | 18 | warn(event: any) { 19 | this.warns.push(event) 20 | } 21 | 22 | groupCollapsed() {} 23 | groupEnd() {} 24 | } 25 | 26 | class ErrorWhileConnectingController extends Controller { 27 | connect() { 28 | throw new Error("bad!") 29 | } 30 | } 31 | 32 | class TestApplicationWithDefaultErrorBehavior extends Application {} 33 | 34 | export default class ErrorHandlerTests extends ControllerTestCase(ErrorWhileConnectingController) { 35 | controllerConstructor = ErrorWhileConnectingController 36 | 37 | async setupApplication() { 38 | const logger = new MockLogger() 39 | 40 | this.application = new TestApplicationWithDefaultErrorBehavior(this.fixtureElement, this.schema) 41 | this.application.logger = logger 42 | 43 | window.onerror = function (message, source, lineno, colno, _error) { 44 | logger.log( 45 | `error from window.onerror. message = ${message}, source = ${source}, lineno = ${lineno}, colno = ${colno}` 46 | ) 47 | } 48 | 49 | super.setupApplication() 50 | } 51 | 52 | async "test errors in connect are thrown and handled by built in logger"() { 53 | const mockLogger: any = this.application.logger 54 | 55 | // when `ErrorWhileConnectingController#connect` throws, the controller's application's logger's `error` function 56 | // is called; in this case that's `MockLogger#error`. 57 | this.assert.equal(1, mockLogger.errors.length) 58 | } 59 | 60 | async "test errors in connect are thrown and handled by window.onerror"() { 61 | const mockLogger: any = this.application.logger 62 | 63 | this.assert.equal(1, mockLogger.logs.length) 64 | this.assert.equal( 65 | "error from window.onerror. message = Error connecting controller, source = , lineno = 0, colno = 0", 66 | mockLogger.logs[0] 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/tests/modules/core/es6_tests.ts: -------------------------------------------------------------------------------- 1 | import { LogController } from "../../controllers/log_controller" 2 | import { LogControllerTestCase } from "../../cases/log_controller_test_case" 3 | 4 | export default class ES6Tests extends LogControllerTestCase { 5 | static shouldSkipTest(_testName: string) { 6 | return !(supportsES6Classes() && supportsReflectConstruct()) 7 | } 8 | 9 | fixtureHTML = ` 10 |
11 | 12 |
13 | ` 14 | 15 | fixtureScript = ` 16 | _stimulus.application.register("es6", class extends _stimulus.LogController {}) 17 | ` 18 | 19 | async renderFixture() { 20 | ;(window as any)["_stimulus"] = { LogController, application: this.application } 21 | await super.renderFixture() 22 | 23 | const scriptElement = document.createElement("script") 24 | scriptElement.textContent = this.fixtureScript 25 | this.fixtureElement.appendChild(scriptElement) 26 | await this.nextFrame 27 | } 28 | 29 | async teardown() { 30 | this.application.unload("test") 31 | delete (window as any)["_stimulus"] 32 | } 33 | 34 | async "test ES6 controller classes"() { 35 | await this.triggerEvent("button", "click") 36 | this.assertActions({ eventType: "click", currentTarget: this.findElement("button") }) 37 | } 38 | } 39 | 40 | function supportsES6Classes() { 41 | try { 42 | return eval("(class {}), true") 43 | } catch (error) { 44 | return false 45 | } 46 | } 47 | 48 | function supportsReflectConstruct() { 49 | return typeof Reflect == "object" && typeof Reflect.construct == "function" 50 | } 51 | -------------------------------------------------------------------------------- /src/tests/modules/core/extending_application_tests.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "../../../core/application" 2 | import { DOMTestCase } from "../../cases/dom_test_case" 3 | import { ActionDescriptorFilter } from "src/core/action_descriptor" 4 | 5 | const mockCallback: { (label: string): void; lastCall: string | null } = (label: string) => { 6 | mockCallback.lastCall = label 7 | } 8 | mockCallback.lastCall = null 9 | 10 | class TestApplicationWithCustomBehavior extends Application { 11 | registerActionOption(name: string, filter: ActionDescriptorFilter): void { 12 | mockCallback(`registerActionOption:${name}`) 13 | super.registerActionOption(name, filter) 14 | } 15 | } 16 | 17 | export default class ExtendingApplicationTests extends DOMTestCase { 18 | application!: Application 19 | 20 | async runTest(testName: string) { 21 | try { 22 | // use the documented way to start & reference only the returned application instance 23 | this.application = TestApplicationWithCustomBehavior.start(this.fixtureElement) 24 | await super.runTest(testName) 25 | } finally { 26 | this.application.stop() 27 | } 28 | } 29 | 30 | async setup() { 31 | mockCallback.lastCall = null 32 | } 33 | 34 | async teardown() { 35 | mockCallback.lastCall = null 36 | } 37 | 38 | async "test extended class method is supported when using MyApplication.start()"() { 39 | this.assert.equal(mockCallback.lastCall, null) 40 | 41 | const mockTrue = () => true 42 | this.application.registerActionOption("kbd", mockTrue) 43 | this.assert.equal(this.application.actionDescriptorFilters["kbd"], mockTrue) 44 | this.assert.equal(mockCallback.lastCall, "registerActionOption:kbd") 45 | 46 | const mockFalse = () => false 47 | this.application.registerActionOption("xyz", mockFalse) 48 | this.assert.equal(this.application.actionDescriptorFilters["xyz"], mockFalse) 49 | this.assert.equal(mockCallback.lastCall, "registerActionOption:xyz") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/tests/modules/core/legacy_target_tests.ts: -------------------------------------------------------------------------------- 1 | import { ControllerTestCase } from "../../cases/controller_test_case" 2 | import { TargetController } from "../../controllers/target_controller" 3 | 4 | export default class LegacyTargetTests extends ControllerTestCase(TargetController) { 5 | fixtureHTML = ` 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | 16 |
17 | ` 18 | 19 | warningCount = 0 20 | 21 | async setupApplication() { 22 | super.setupApplication() 23 | this.application.logger = Object.create(console, { 24 | warn: { 25 | value: () => this.warningCount++, 26 | }, 27 | }) 28 | } 29 | 30 | "test TargetSet#find"() { 31 | this.assert.equal(this.controller.targets.find("alpha"), this.findElement("#alpha1")) 32 | this.assert.equal(this.warningCount, 1) 33 | } 34 | 35 | "test TargetSet#find prefers scoped target attributes"() { 36 | this.assert.equal(this.controller.targets.find("gamma"), this.findElement("#beta1")) 37 | this.assert.equal(this.warningCount, 0) 38 | } 39 | 40 | "test TargetSet#findAll"() { 41 | this.assert.deepEqual(this.controller.targets.findAll("alpha"), this.findElements("#alpha1", "#alpha2")) 42 | this.assert.equal(this.warningCount, 2) 43 | } 44 | 45 | "test TargetSet#findAll prioritizes scoped target attributes"() { 46 | this.assert.deepEqual(this.controller.targets.findAll("gamma"), this.findElements("#beta1", "#gamma1")) 47 | this.assert.equal(this.warningCount, 1) 48 | } 49 | 50 | "test TargetSet#findAll with multiple arguments"() { 51 | this.assert.deepEqual( 52 | this.controller.targets.findAll("alpha", "beta"), 53 | this.findElements("#alpha1", "#alpha2", "#beta1") 54 | ) 55 | this.assert.equal(this.warningCount, 3) 56 | } 57 | 58 | "test TargetSet#has"() { 59 | this.assert.equal(this.controller.targets.has("gamma"), true) 60 | this.assert.equal(this.controller.targets.has("delta"), false) 61 | this.assert.equal(this.warningCount, 0) 62 | } 63 | 64 | "test TargetSet#find ignores child controller targets"() { 65 | this.assert.equal(this.controller.targets.find("delta"), null) 66 | this.findElement("#child").removeAttribute("data-controller") 67 | this.assert.equal(this.controller.targets.find("delta"), this.findElement("#delta1")) 68 | this.assert.equal(this.warningCount, 1) 69 | } 70 | 71 | "test linked target properties"() { 72 | this.assert.equal(this.controller.betaTarget, this.findElement("#beta1")) 73 | this.assert.deepEqual(this.controller.betaTargets, this.findElements("#beta1")) 74 | this.assert.equal(this.controller.hasBetaTarget, true) 75 | this.assert.equal(this.warningCount, 1) 76 | } 77 | 78 | "test inherited linked target properties"() { 79 | this.assert.equal(this.controller.alphaTarget, this.findElement("#alpha1")) 80 | this.assert.deepEqual(this.controller.alphaTargets, this.findElements("#alpha1", "#alpha2")) 81 | this.assert.equal(this.warningCount, 2) 82 | } 83 | 84 | "test singular linked target property throws an error when no target is found"() { 85 | this.findElement("#beta1").removeAttribute("data-target") 86 | this.assert.equal(this.controller.hasBetaTarget, false) 87 | this.assert.equal(this.controller.betaTargets.length, 0) 88 | this.assert.throws(() => this.controller.betaTarget) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/tests/modules/core/lifecycle_tests.ts: -------------------------------------------------------------------------------- 1 | import { LogControllerTestCase } from "../../cases/log_controller_test_case" 2 | 3 | export default class LifecycleTests extends LogControllerTestCase { 4 | controllerElement!: Element 5 | 6 | async setup() { 7 | this.controllerElement = this.controller.element 8 | } 9 | 10 | async "test Controller#initialize"() { 11 | const controller = this.controller 12 | this.assert.equal(controller.initializeCount, 1) 13 | await this.reconnectControllerElement() 14 | this.assert.equal(this.controller, controller) 15 | this.assert.equal(controller.initializeCount, 1) 16 | } 17 | 18 | async "test Controller#connect"() { 19 | this.assert.equal(this.controller.connectCount, 1) 20 | await this.reconnectControllerElement() 21 | this.assert.equal(this.controller.connectCount, 2) 22 | } 23 | 24 | async "test Controller#disconnect"() { 25 | const controller = this.controller 26 | this.assert.equal(controller.disconnectCount, 0) 27 | await this.disconnectControllerElement() 28 | this.assert.equal(controller.disconnectCount, 1) 29 | } 30 | 31 | async reconnectControllerElement() { 32 | await this.disconnectControllerElement() 33 | await this.connectControllerElement() 34 | } 35 | 36 | async connectControllerElement() { 37 | this.fixtureElement.appendChild(this.controllerElement) 38 | await this.nextFrame 39 | } 40 | 41 | async disconnectControllerElement() { 42 | this.fixtureElement.removeChild(this.controllerElement) 43 | await this.nextFrame 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/tests/modules/core/loading_tests.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationTestCase } from "../../cases/application_test_case" 2 | import { LogController } from "../../controllers/log_controller" 3 | 4 | class UnloadableController extends LogController { 5 | static get shouldLoad() { 6 | return false 7 | } 8 | } 9 | class LoadableController extends LogController { 10 | static get shouldLoad() { 11 | return true 12 | } 13 | } 14 | 15 | class AfterLoadController extends LogController { 16 | static values = { 17 | example: { default: "demo", type: String }, 18 | } 19 | 20 | static afterLoad(identifier: string, application: any) { 21 | const newElement = document.createElement("div") 22 | newElement.classList.add("after-load-test") 23 | newElement.setAttribute(application.schema.controllerAttribute, identifier) 24 | application.element.append(newElement) 25 | document.dispatchEvent( 26 | new CustomEvent("test", { 27 | detail: { identifier, application, exampleDefault: this.values.example.default, controller: this }, 28 | }) 29 | ) 30 | } 31 | } 32 | 33 | export default class ApplicationTests extends ApplicationTestCase { 34 | fixtureHTML = `
` 35 | 36 | "test module with false shouldLoad should not load when registering"() { 37 | this.application.register("unloadable", UnloadableController) 38 | this.assert.equal(this.controllers.length, 0) 39 | } 40 | 41 | "test module with true shouldLoad should load when registering"() { 42 | this.application.register("loadable", LoadableController) 43 | this.assert.equal(this.controllers.length, 1) 44 | } 45 | 46 | "test module with afterLoad method should be triggered when registered"() { 47 | // set up an event listener to track the params passed into the AfterLoadController 48 | let data: { application?: any; identifier?: string; exampleDefault?: string; controller?: any } = {} 49 | document.addEventListener("test", (({ detail }: CustomEvent) => { 50 | data = detail 51 | }) as EventListener) 52 | 53 | this.assert.equal(data.application, undefined) 54 | this.assert.equal(data.controller, undefined) 55 | this.assert.equal(data.exampleDefault, undefined) 56 | this.assert.equal(data.identifier, undefined) 57 | 58 | this.application.register("after-load", AfterLoadController) 59 | 60 | // check the DOM element has been added based on params provided 61 | this.assert.equal(this.findElements('[data-controller="after-load"]').length, 1) 62 | 63 | // check that static method was correctly called with the params 64 | this.assert.equal(data.application, this.application) 65 | this.assert.equal(data.controller, AfterLoadController) 66 | this.assert.equal(data.exampleDefault, "demo") 67 | this.assert.equal(data.identifier, "after-load") 68 | } 69 | 70 | get controllers() { 71 | return this.application.controllers as LogController[] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/tests/modules/core/memory_tests.ts: -------------------------------------------------------------------------------- 1 | import { ControllerTestCase } from "../../cases/controller_test_case" 2 | 3 | export default class MemoryTests extends ControllerTestCase() { 4 | controllerElement!: Element 5 | 6 | async setup() { 7 | this.controllerElement = this.controller.element 8 | } 9 | 10 | fixtureHTML = ` 11 |
12 | 13 | 14 |
15 | ` 16 | 17 | async "test removing a controller clears dangling eventListeners"() { 18 | this.assert.equal(this.application.dispatcher.eventListeners.length, 2) 19 | await this.fixtureElement.removeChild(this.controllerElement) 20 | this.assert.equal(this.application.dispatcher.eventListeners.length, 0) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/tests/modules/core/outlet_order_tests.ts: -------------------------------------------------------------------------------- 1 | import { ControllerTestCase } from "../../cases/controller_test_case" 2 | import { OutletController } from "../../controllers/outlet_controller" 3 | 4 | const connectOrder: string[] = [] 5 | 6 | class OutletOrderController extends OutletController { 7 | connect() { 8 | connectOrder.push(`${this.identifier}-${this.element.id}-start`) 9 | super.connect() 10 | connectOrder.push(`${this.identifier}-${this.element.id}-end`) 11 | } 12 | } 13 | 14 | export default class OutletOrderTests extends ControllerTestCase(OutletOrderController) { 15 | fixtureHTML = ` 16 |
Search
17 |
Beta
18 |
Beta
19 |
Beta
20 | ` 21 | 22 | get identifiers() { 23 | return ["alpha", "beta"] 24 | } 25 | 26 | async "test can access outlets in connect() even if they are referenced before they are connected"() { 27 | this.assert.equal(this.controller.betaOutletsInConnectValue, 3) 28 | 29 | this.controller.betaOutlets.forEach((outlet) => { 30 | this.assert.equal(outlet.identifier, "beta") 31 | this.assert.equal(Array.from(outlet.element.classList.values()), "beta") 32 | }) 33 | 34 | this.assert.deepEqual(connectOrder, [ 35 | "alpha-alpha1-start", 36 | "beta-beta-1-start", 37 | "beta-beta-1-end", 38 | "beta-beta-2-start", 39 | "beta-beta-2-end", 40 | "beta-beta-3-start", 41 | "beta-beta-3-end", 42 | "alpha-alpha1-end", 43 | ]) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/tests/modules/core/string_helpers_tests.ts: -------------------------------------------------------------------------------- 1 | import { TestCase } from "../../cases/test_case" 2 | import * as helpers from "../../../core/string_helpers" 3 | 4 | export default class StringHelpersTests extends TestCase { 5 | "test should camelize strings"() { 6 | this.assert.equal(helpers.camelize("underscore_value"), "underscoreValue") 7 | this.assert.equal(helpers.camelize("Underscore_value"), "UnderscoreValue") 8 | this.assert.equal(helpers.camelize("underscore_Value"), "underscore_Value") 9 | this.assert.equal(helpers.camelize("Underscore_Value"), "Underscore_Value") 10 | this.assert.equal(helpers.camelize("multi_underscore_value"), "multiUnderscoreValue") 11 | 12 | this.assert.equal(helpers.camelize("dash-value"), "dashValue") 13 | this.assert.equal(helpers.camelize("Dash-value"), "DashValue") 14 | this.assert.equal(helpers.camelize("dash-Value"), "dash-Value") 15 | this.assert.equal(helpers.camelize("Dash-Value"), "Dash-Value") 16 | this.assert.equal(helpers.camelize("multi-dash-value"), "multiDashValue") 17 | } 18 | 19 | "test should namespace camelize strings"() { 20 | this.assert.equal(helpers.namespaceCamelize("underscore__value"), "underscoreValue") 21 | this.assert.equal(helpers.namespaceCamelize("Underscore__value"), "UnderscoreValue") 22 | this.assert.equal(helpers.namespaceCamelize("underscore__Value"), "underscore_Value") 23 | this.assert.equal(helpers.namespaceCamelize("Underscore__Value"), "Underscore_Value") 24 | this.assert.equal(helpers.namespaceCamelize("multi__underscore__value"), "multiUnderscoreValue") 25 | 26 | this.assert.equal(helpers.namespaceCamelize("dash--value"), "dashValue") 27 | this.assert.equal(helpers.namespaceCamelize("Dash--value"), "DashValue") 28 | this.assert.equal(helpers.namespaceCamelize("dash--Value"), "dash-Value") 29 | this.assert.equal(helpers.namespaceCamelize("Dash--Value"), "Dash-Value") 30 | this.assert.equal(helpers.namespaceCamelize("multi--dash--value"), "multiDashValue") 31 | } 32 | 33 | "test should dasherize strings"() { 34 | this.assert.equal(helpers.dasherize("camelizedValue"), "camelized-value") 35 | this.assert.equal(helpers.dasherize("longCamelizedValue"), "long-camelized-value") 36 | } 37 | 38 | "test should capitalize strings"() { 39 | this.assert.equal(helpers.capitalize("lowercase"), "Lowercase") 40 | this.assert.equal(helpers.capitalize("Uppercase"), "Uppercase") 41 | } 42 | 43 | "test should tokenize strings"() { 44 | this.assert.deepEqual(helpers.tokenize(""), []) 45 | this.assert.deepEqual(helpers.tokenize("one"), ["one"]) 46 | this.assert.deepEqual(helpers.tokenize("two words"), ["two", "words"]) 47 | this.assert.deepEqual(helpers.tokenize("a_lot of-words with special--chars mixed__in"), [ 48 | "a_lot", 49 | "of-words", 50 | "with", 51 | "special--chars", 52 | "mixed__in", 53 | ]) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/tests/modules/mutation-observers/attribute_observer_tests.ts: -------------------------------------------------------------------------------- 1 | import { AttributeObserver, AttributeObserverDelegate } from "../../../mutation-observers/attribute_observer" 2 | import { ObserverTestCase } from "../../cases/observer_test_case" 3 | 4 | export default class AttributeObserverTests extends ObserverTestCase implements AttributeObserverDelegate { 5 | attributeName = "data-test" 6 | fixtureHTML = `
` 7 | observer = new AttributeObserver(this.fixtureElement, this.attributeName, this) 8 | 9 | async "test elementMatchedAttribute"() { 10 | this.assert.deepEqual(this.calls, [["elementMatchedAttribute", this.outerElement, this.attributeName]]) 11 | } 12 | 13 | async "test elementAttributeValueChanged"() { 14 | this.outerElement.setAttribute(this.attributeName, "hello") 15 | await this.nextFrame 16 | 17 | this.assert.deepEqual(this.calls, [ 18 | ["elementMatchedAttribute", this.outerElement, this.attributeName], 19 | ["elementAttributeValueChanged", this.outerElement, this.attributeName], 20 | ]) 21 | } 22 | 23 | async "test elementUnmatchedAttribute"() { 24 | this.outerElement.removeAttribute(this.attributeName) 25 | await this.nextFrame 26 | 27 | this.assert.deepEqual(this.calls, [ 28 | ["elementMatchedAttribute", this.outerElement, this.attributeName], 29 | ["elementUnmatchedAttribute", this.outerElement, this.attributeName], 30 | ]) 31 | } 32 | 33 | async "test observes attribute changes to child elements"() { 34 | this.innerElement.setAttribute(this.attributeName, "hello") 35 | await this.nextFrame 36 | 37 | this.assert.deepEqual(this.calls, [ 38 | ["elementMatchedAttribute", this.outerElement, this.attributeName], 39 | ["elementMatchedAttribute", this.innerElement, this.attributeName], 40 | ]) 41 | } 42 | 43 | async "test ignores other attributes"() { 44 | this.outerElement.setAttribute(this.attributeName + "-x", "hello") 45 | await this.nextFrame 46 | 47 | this.assert.deepEqual(this.calls, [["elementMatchedAttribute", this.outerElement, this.attributeName]]) 48 | } 49 | 50 | async "test observes removal of nested matched element HTML"() { 51 | const { innerElement, outerElement } = this 52 | 53 | innerElement.setAttribute(this.attributeName, "") 54 | await this.nextFrame 55 | 56 | this.fixtureElement.innerHTML = "" 57 | await this.nextFrame 58 | 59 | this.assert.deepEqual(this.calls, [ 60 | ["elementMatchedAttribute", outerElement, this.attributeName], 61 | ["elementMatchedAttribute", innerElement, this.attributeName], 62 | ["elementUnmatchedAttribute", outerElement, this.attributeName], 63 | ["elementUnmatchedAttribute", innerElement, this.attributeName], 64 | ]) 65 | } 66 | 67 | async "test ignores synchronously disconnected elements"() { 68 | const { innerElement, outerElement } = this 69 | 70 | outerElement.removeChild(innerElement) 71 | innerElement.setAttribute(this.attributeName, "") 72 | await this.nextFrame 73 | 74 | this.assert.deepEqual(this.calls, [["elementMatchedAttribute", outerElement, this.attributeName]]) 75 | } 76 | 77 | async "test ignores synchronously moved elements"() { 78 | const { innerElement, outerElement } = this 79 | 80 | document.body.appendChild(innerElement) 81 | innerElement.setAttribute(this.attributeName, "") 82 | await this.nextFrame 83 | 84 | this.assert.deepEqual(this.calls, [["elementMatchedAttribute", outerElement, this.attributeName]]) 85 | 86 | document.body.removeChild(innerElement) 87 | } 88 | 89 | get outerElement() { 90 | return this.findElement("#outer") 91 | } 92 | 93 | get innerElement() { 94 | return this.findElement("#inner") 95 | } 96 | 97 | // Attribute observer delegate 98 | 99 | elementMatchedAttribute(element: Element, attributeName: string) { 100 | this.recordCall("elementMatchedAttribute", element, attributeName) 101 | } 102 | 103 | elementAttributeValueChanged(element: Element, attributeName: string) { 104 | this.recordCall("elementAttributeValueChanged", element, attributeName) 105 | } 106 | 107 | elementUnmatchedAttribute(element: Element, attributeName: string) { 108 | this.recordCall("elementUnmatchedAttribute", element, attributeName) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/tests/modules/mutation-observers/token_list_observer_tests.ts: -------------------------------------------------------------------------------- 1 | import { Token, TokenListObserver, TokenListObserverDelegate } from "../../../mutation-observers/token_list_observer" 2 | import { ObserverTestCase } from "../../cases/observer_test_case" 3 | 4 | export default class TokenListObserverTests extends ObserverTestCase implements TokenListObserverDelegate { 5 | attributeName = "data-test" 6 | fixtureHTML = `
` 7 | observer = new TokenListObserver(this.fixtureElement, this.attributeName, this) 8 | 9 | async "test tokenMatched"() { 10 | this.assert.deepEqual(this.calls, [ 11 | ["tokenMatched", this.element, this.attributeName, "one", 0], 12 | ["tokenMatched", this.element, this.attributeName, "two", 1], 13 | ]) 14 | } 15 | 16 | async "test adding a token to the right"() { 17 | this.tokenString = "one two three" 18 | await this.nextFrame 19 | 20 | this.assert.deepEqual(this.testCalls, [["tokenMatched", this.element, this.attributeName, "three", 2]]) 21 | } 22 | 23 | async "test inserting a token in the middle"() { 24 | this.tokenString = "one three two" 25 | await this.nextFrame 26 | 27 | this.assert.deepEqual(this.testCalls, [ 28 | ["tokenUnmatched", this.element, this.attributeName, "two", 1], 29 | ["tokenMatched", this.element, this.attributeName, "three", 1], 30 | ["tokenMatched", this.element, this.attributeName, "two", 2], 31 | ]) 32 | } 33 | 34 | async "test removing the leftmost token"() { 35 | this.tokenString = "two" 36 | await this.nextFrame 37 | 38 | this.assert.deepEqual(this.testCalls, [ 39 | ["tokenUnmatched", this.element, this.attributeName, "one", 0], 40 | ["tokenUnmatched", this.element, this.attributeName, "two", 1], 41 | ["tokenMatched", this.element, this.attributeName, "two", 0], 42 | ]) 43 | } 44 | 45 | async "test removing the rightmost token"() { 46 | this.tokenString = "one" 47 | await this.nextFrame 48 | 49 | this.assert.deepEqual(this.testCalls, [["tokenUnmatched", this.element, this.attributeName, "two", 1]]) 50 | } 51 | 52 | async "test removing the only token"() { 53 | this.tokenString = "one" 54 | await this.nextFrame 55 | this.tokenString = "" 56 | await this.nextFrame 57 | 58 | this.assert.deepEqual(this.testCalls, [ 59 | ["tokenUnmatched", this.element, this.attributeName, "two", 1], 60 | ["tokenUnmatched", this.element, this.attributeName, "one", 0], 61 | ]) 62 | } 63 | 64 | get element(): Element { 65 | return this.findElement("div") 66 | } 67 | 68 | set tokenString(value: string) { 69 | this.element.setAttribute(this.attributeName, value) 70 | } 71 | 72 | // Token observer delegate 73 | 74 | tokenMatched(token: Token) { 75 | this.recordCall("tokenMatched", token.element, token.attributeName, token.content, token.index) 76 | } 77 | 78 | tokenUnmatched(token: Token) { 79 | this.recordCall("tokenUnmatched", token.element, token.attributeName, token.content, token.index) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/tests/modules/mutation-observers/value_list_observer_tests.ts: -------------------------------------------------------------------------------- 1 | import { Token, ValueListObserver, ValueListObserverDelegate } from "../../../mutation-observers" 2 | import { ObserverTestCase } from "../../cases/observer_test_case" 3 | 4 | export interface Value { 5 | id: number 6 | token: Token 7 | } 8 | 9 | export default class ValueListObserverTests extends ObserverTestCase implements ValueListObserverDelegate { 10 | attributeName = "data-test" 11 | fixtureHTML = `
` 12 | observer = new ValueListObserver(this.fixtureElement, this.attributeName, this) 13 | lastValueId = 0 14 | 15 | async "test elementMatchedValue"() { 16 | this.assert.deepEqual(this.calls, [["elementMatchedValue", this.element, 1, "one"]]) 17 | } 18 | 19 | async "test adding a token to the right"() { 20 | this.valueString = "one two" 21 | await this.nextFrame 22 | 23 | this.assert.deepEqual(this.testCalls, [["elementMatchedValue", this.element, 2, "two"]]) 24 | } 25 | 26 | async "test adding a token to the left"() { 27 | this.valueString = "two one" 28 | await this.nextFrame 29 | 30 | this.assert.deepEqual(this.testCalls, [ 31 | ["elementUnmatchedValue", this.element, 1, "one"], 32 | ["elementMatchedValue", this.element, 2, "two"], 33 | ["elementMatchedValue", this.element, 3, "one"], 34 | ]) 35 | } 36 | 37 | async "test removing a token from the right"() { 38 | this.valueString = "one two" 39 | await this.nextFrame 40 | this.valueString = "one" 41 | await this.nextFrame 42 | 43 | this.assert.deepEqual(this.testCalls, [ 44 | ["elementMatchedValue", this.element, 2, "two"], 45 | ["elementUnmatchedValue", this.element, 2, "two"], 46 | ]) 47 | } 48 | 49 | async "test removing a token from the left"() { 50 | this.valueString = "one two" 51 | await this.nextFrame 52 | this.valueString = "two" 53 | await this.nextFrame 54 | 55 | this.assert.deepEqual(this.testCalls, [ 56 | ["elementMatchedValue", this.element, 2, "two"], 57 | ["elementUnmatchedValue", this.element, 1, "one"], 58 | ["elementUnmatchedValue", this.element, 2, "two"], 59 | ["elementMatchedValue", this.element, 3, "two"], 60 | ]) 61 | } 62 | 63 | async "test removing the only token"() { 64 | this.valueString = "" 65 | await this.nextFrame 66 | 67 | this.assert.deepEqual(this.testCalls, [["elementUnmatchedValue", this.element, 1, "one"]]) 68 | } 69 | 70 | async "test removing and re-adding a token produces a new value"() { 71 | this.valueString = "" 72 | await this.nextFrame 73 | this.valueString = "one" 74 | await this.nextFrame 75 | 76 | this.assert.deepEqual(this.testCalls, [ 77 | ["elementUnmatchedValue", this.element, 1, "one"], 78 | ["elementMatchedValue", this.element, 2, "one"], 79 | ]) 80 | } 81 | 82 | get element() { 83 | return this.findElement("div") 84 | } 85 | 86 | set valueString(value: string) { 87 | this.element.setAttribute(this.attributeName, value) 88 | } 89 | 90 | // Value observer delegate 91 | 92 | parseValueForToken(token: Token) { 93 | return { id: ++this.lastValueId, token } 94 | } 95 | 96 | elementMatchedValue(element: Element, value: Value) { 97 | this.recordCall("elementMatchedValue", element, value.id, value.token.content) 98 | } 99 | 100 | elementUnmatchedValue(element: Element, value: Value) { 101 | this.recordCall("elementUnmatchedValue", element, value.id, value.token.content) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ "dom", "dom.iterable", "es2015", "scripthost" ], 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "noUnusedLocals": true, 7 | "rootDir": "src", 8 | "strict": true, 9 | "target": "es2017", 10 | "removeComments": true, 11 | "outDir": "dist", 12 | "baseUrl": ".", 13 | "noEmit": false, 14 | "declaration": false 15 | }, 16 | "exclude": [ 17 | "dist" 18 | ], 19 | "include": [ 20 | "src/**/*" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5" 5 | } 6 | } 7 | --------------------------------------------------------------------------------