Repository: hotwired/stimulus Branch: main Commit: 422eb81fa649 Files: 163 Total size: 379.6 KB Directory structure: gitextract_o806b5yi/ ├── .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 ├── 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 │ │ └── main.css │ ├── server.js │ ├── views/ │ │ ├── clipboard.ejs │ │ ├── content-loader.ejs │ │ ├── hello.ejs │ │ ├── layout/ │ │ │ ├── head.ejs │ │ │ └── tail.ejs │ │ ├── slideshow.ejs │ │ └── tabs.ejs │ └── webpack.config.js ├── 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 ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ dist/ node_modules/ ================================================ FILE: .eslintrc ================================================ { "root": true, "parser": "@typescript-eslint/parser", "plugins": [ "@typescript-eslint", "prettier" ], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "prettier" ], "rules": { "prefer-rest-params": "off", "prettier/prettier": ["error"], "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "@typescript-eslint/ban-types": ["error", { "types": { "Function": false, "Object": false, "{}": false } }] } } ================================================ FILE: .github/scripts/publish-dev-build ================================================ #!/usr/bin/env bash set -eux DEV_BUILD_REPO_NAME="hotwired/dev-builds" DEV_BUILD_ORIGIN_URL="https://${1}@github.com/${DEV_BUILD_REPO_NAME}.git" BUILD_PATH="$HOME/publish-dev-build" mkdir "$BUILD_PATH" cd "$GITHUB_WORKSPACE" package_name="$(jq -r .name package.json)" package_files=( dist package.json ) tag="${package_name}/${GITHUB_SHA:0:7}" name="$(git log -n 1 --format=format:%cn)" email="$(git log -n 1 --format=format:%ce)" subject="$(git log -n 1 --format=format:%s)" date="$(git log -n 1 --format=format:%ai)" url="https://github.com/${GITHUB_REPOSITORY}/tree/${GITHUB_SHA}" message="$tag $subject"$'\n\n'"$url" cp -R "${package_files[@]}" "$BUILD_PATH" cd "$BUILD_PATH" git init . git remote add origin "$DEV_BUILD_ORIGIN_URL" git symbolic-ref HEAD refs/heads/publish-dev-build git add "${package_files[@]}" GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ GIT_AUTHOR_NAME="$name" GIT_COMMITTER_NAME="$name" \ GIT_AUTHOR_EMAIL="$email" GIT_COMMITTER_EMAIL="$email" \ git commit -m "$message" git tag "$tag" [ "$GITHUB_REF" != "refs/heads/main" ] || git tag -f "${package_name}/latest" git push -f --tags echo done ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: [push, pull_request] jobs: build: name: Build runs-on: ubuntu-latest strategy: matrix: node: [18, 19, 20, 21] steps: - uses: actions/checkout@v4 - name: Setup Node v${{ matrix.node }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: 'yarn' - name: Install Dependencies run: yarn install --frozen-lockfile - name: Build run: yarn build - name: Test Build run: yarn build:test ================================================ FILE: .github/workflows/dev-builds.yml ================================================ name: dev-builds on: workflow_dispatch: push: branches: - main - 'builds/**' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'yarn' - run: yarn install - run: yarn build - name: Publish dev build run: .github/scripts/publish-dev-build '${{ secrets.DEV_BUILD_GITHUB_TOKEN }}' ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: [push, pull_request] jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 21 cache: 'yarn' - name: Install Dependencies run: yarn install --frozen-lockfile - name: Lint run: yarn lint ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: [push, pull_request] jobs: test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: browser-actions/setup-chrome@v1 - uses: browser-actions/setup-firefox@v1 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 22 cache: 'yarn' - name: Install Dependencies run: yarn install --frozen-lockfile - name: Test run: yarn test ================================================ FILE: .gitignore ================================================ coverage/ dist/ node_modules/ docs/api/ *.log *.tsbuildinfo ================================================ FILE: .node-version ================================================ 20.11.0 ================================================ FILE: .npmignore ================================================ src/tests/ dist/tests/ tsconfig* *.log ================================================ FILE: .prettierignore ================================================ dist/ node_modules/ ================================================ FILE: .prettierrc.json ================================================ { "singleQuote": false, "printWidth": 120, "semi": false } ================================================ FILE: CHANGELOG.md ================================================ # Changelog Please see [our GitHub "Releases" page](https://github.com/hotwired/stimulus/releases). ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org ================================================ FILE: LICENSE.md ================================================ # MIT License Copyright © 2021 Basecamp, LLC. 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: README.md ================================================ # Stimulus Stimulus ### A modest JavaScript framework for the HTML you already have 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. How does it work? Sprinkle your HTML with controller, target, and action attributes: ```html
``` Then write a compatible controller. Stimulus brings it to life automatically: ```js // hello_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "name", "output" ] greet() { this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!` } } ``` 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. You can write your first controller in five minutes by following along in the [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introduction). You can read more about why we created this new framework in [The Origin of Stimulus](https://stimulus.hotwired.dev/handbook/origin). ## Installing Stimulus You can use Stimulus with any asset packaging systems. And if you prefer no build step at all, just drop a `
``` ## Overriding Attribute Defaults In case Stimulus `data-*` attributes conflict with another library in your project, they can be overridden when creating the Stimulus `Application`. - `data-controller` - `data-action` - `data-target` These core Stimulus attributes can be overridden (see: [schema.ts](https://github.com/hotwired/stimulus/blob/main/src/core/schema.ts)): ```js // src/application.js import { Application, defaultSchema } from "@hotwired/stimulus" const customSchema = { ...defaultSchema, actionAttribute: 'data-stimulus-action' } window.Stimulus = Application.start(document.documentElement, customSchema); ``` ## Error handling All calls from Stimulus to your application's code are wrapped in a `try ... catch` block. If your code throws an error, it will be caught by Stimulus and logged to the browser console, including extra detail such as the controller name and event or lifecycle function being called. If you use an error tracking system that defines `window.onerror`, Stimulus will also pass the error on to it. You can override how Stimulus handles errors by defining `Application#handleError`: ```js // src/application.js import { Application } from "@hotwired/stimulus" window.Stimulus = Application.start() Stimulus.handleError = (error, message, detail) => { console.warn(message, detail) ErrorTrackingSystem.captureException(error) } ``` ## Debugging If you've assigned your Stimulus application to `window.Stimulus`, you can turn on [debugging mode](https://github.com/hotwired/stimulus/pull/354) from the console with `Stimulus.debug = true`. You can also set this flag when you're configuring your application instance in the source code. ## Browser Support Stimulus supports all evergreen, self-updating desktop and mobile browsers out of the box. Stimulus 3+ does not support Internet Explorer 11 (but you can use Stimulus 2 with the @stimulus/polyfills for that). ================================================ FILE: docs/reference/actions.md ================================================ --- permalink: /reference/actions.html order: 02 --- # Actions _Actions_ are how you handle DOM events in your controllers. ```html
``` ```js // controllers/gallery_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { next(event) { // … } } ``` An action is a connection between: * a controller method * the controller's element * a DOM event listener ## Descriptors The `data-action` value `click->gallery#next` is called an _action descriptor_. In this descriptor: * `click` is the name of the DOM event to listen for * `gallery` is the controller identifier * `next` is the name of the method to invoke ### Event Shorthand Stimulus lets you shorten the action descriptors for some common element/event pairs, such as the button/click pair above, by omitting the event name: ```html ``` The full set of these shorthand pairs is as follows: Element | Default Event ----------------- | ------------- a | click button | click details | toggle form | submit input | input input type=submit | click select | change textarea | input ## KeyboardEvent Filter There may be cases where [KeyboardEvent](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent) Actions should only call the Controller method when certain keystrokes are used. You can install an event listener that responds only to the `Escape` key by adding `.esc` to the event name of the action descriptor, as in the following example. ```html
``` This will only work if the event being fired is a keyboard event. The correspondence between these filter and keys is shown below. Filter | Key Name -------- | -------- enter | Enter tab | Tab esc | Escape space | " " up | ArrowUp down | ArrowDown left | ArrowLeft right | ArrowRight home | Home end | End page_up | PageUp page_down | PageDown [a-z] | [a-z] [0-9] | [0-9] If you need to support other keys, you can customize the modifiers using a custom schema. ```javascript import { Application, defaultSchema } from "@hotwired/stimulus" const customSchema = { ...defaultSchema, keyMappings: { ...defaultSchema.keyMappings, at: "@" }, } const app = Application.start(document.documentElement, customSchema) ``` If you want to subscribe to a compound filter using a modifier key, you can write it like `ctrl+a`. ```html
...
``` The list of supported modifier keys is shown below. | Modifier | Notes | | -------- | ------------------ | | `alt` | `option` on MacOS | | `ctrl` | | | `meta` | Command key on MacOS | | `shift` | | ### Global Events Sometimes a controller needs to listen for events dispatched on the global `window` or `document` objects. You can append `@window` or `@document` to the event name (along with any filter modifier) in an action descriptor to install the event listener on `window` or `document`, respectively, as in the following example: ```html
``` ### Options You can append one or more _action options_ to an action descriptor if you need to specify [DOM event listener options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Parameters). ```html
``` Stimulus supports the following action options: Action option | DOM event listener option ------------- | ------------------------- `:capture` | `{ capture: true }` `:once` | `{ once: true }` `:passive` | `{ passive: true }` `:!passive` | `{ passive: false }` On top of that, Stimulus also supports the following action options which are not natively supported by the DOM event listener options: Custom action option | Description -------------------- | ----------- `:stop` | calls `.stopPropagation()` on the event before invoking the method `:prevent` | calls `.preventDefault()` on the event before invoking the method `:self` | only invokes the method if the event was fired by the element itself You can register your own action options with the `Application.registerActionOption` method. For example, consider that a `
` element will dispatch a [toggle][] event whenever it's toggled. A custom `:open` action option would help to route events whenever the element is toggled _open_: ```javascript import { Application } from "@hotwired/stimulus" const application = Application.start() application.registerActionOption("open", ({ event }) => { if (event.type == "toggle") { return event.target.open == true } else { return true } }) ``` Similarly, a custom `:!open` action option could route events whenever the element is toggled _closed_. Declaring the action descriptor option with a `!` prefix will yield a `value` argument set to `false` in the callback: ```javascript import { Application } from "@hotwired/stimulus" const application = Application.start() application.registerActionOption("open", ({ event, value }) => { if (event.type == "toggle") { return event.target.open == value } else { return true } }) ``` In order to prevent the event from being routed to the controller action, the `registerActionOption` callback function must return `false`. Otherwise, to route the event to the controller action, return `true`. The callback accepts a single object argument with the following keys: | Name | Description | | ---------- | ----------------------------------------------------------------------------------------------------- | | name | String: The option's name (`"open"` in the example above) | | value | Boolean: The value of the option (`:open` would yield `true`, `:!open` would yield `false`) | | event | [Event][]: The event instance, including with the `params` action parameters on the submitter element | | element | [Element]: The element where the action descriptor is declared | | controller | The `Controller` instance which would receive the method call | [toggle]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDetailsElement/toggle_event [Event]: https://developer.mozilla.org/en-US/docs/web/api/event [Element]: https://developer.mozilla.org/en-US/docs/Web/API/element ## Event Objects An _action method_ is the method in a controller which serves as an action's event listener. The first argument to an action method is the DOM _event object_. You may want access to the event for a number of reasons, including: * to read the key code from a keyboard event * to read the coordinates of a mouse event * to read data from an input event * to read params from the action submitter element * to prevent the browser's default behavior for an event * to find out which element dispatched an event before it bubbled up to this action The following basic properties are common to all events: Event Property | Value ------------------- | ----- event.type | The name of the event (e.g. `"click"`) event.target | The target that dispatched the event (i.e. the innermost element that was clicked) event.currentTarget | The target on which the event listener is installed (either the element with the `data-action` attribute, or `document` or `window`) event.params | The action params passed by the action submitter element
The following event methods give you more control over how events are handled: Event Method | Result ----------------------- | ------ event.preventDefault() | Cancels the event's default behavior (e.g. following a link or submitting a form) event.stopPropagation() | Stops the event before it bubbles up to other listeners on parent elements ## Multiple Actions The `data-action` attribute's value is a space-separated list of action descriptors. It's common for any given element to have many actions. For example, the following input element calls a `field` controller's `highlight()` method when it gains focus, and a `search` controller's `update()` method every time the element's value changes: ```html ``` When an element has more than one action for the same event, Stimulus invokes the actions from left to right in the order that their descriptors appear. The action chain can be stopped at any point by calling `Event#stopImmediatePropagation()` within an action. Any additional actions to the right will be ignored: ```javascript highlight(event) { event.stopImmediatePropagation() // ... } ``` ## Naming Conventions Always use camelCase to specify action names, since they map directly to methods on your controller. Avoid action names that simply repeat the event's name, such as `click`, `onClick`, or `handleClick`: ```html ``` Instead, name your action methods based on what will happen when they're called: ```html ``` This will help you reason about the behavior of a block of HTML without having to look at the controller source. ## Action Parameters Actions can have parameters that are be passed from the submitter element. They follow the format of `data-[identifier]-[param-name]-param`. Parameters must be specified on the same element as the action they intend to be passed to is declared. All parameters are automatically typecast to either a `Number`, `String`, `Object`, or `Boolean`, inferred by their value: Data attribute | Param | Type ----------------------------------------------- | -------------------- | -------- `data-item-id-param="12345"` | `12345` | Number `data-item-url-param="/votes"` | `"/votes"` | String `data-item-payload-param='{"value":"1234567"}'` | `{ value: 1234567 }` | Object `data-item-active-param="true"` | `true` | Boolean
Consider this setup: ```html
``` It will call both `ItemController#upvote` and `SpinnerController#start`, but only the former will have any parameters passed to it: ```js // ItemController upvote(event) { // { id: 12345, url: "/votes", active: true, payload: { value: 1234567 } } console.log(event.params) } // SpinnerController start(event) { // {} console.log(event.params) } ``` If we don't need anything else from the event, we can destruct the params: ```js upvote({ params }) { // { id: 12345, url: "/votes", active: true, payload: { value: 1234567 } } console.log(params) } ``` Or destruct only the params we need, in case multiple actions on the same controller share the same submitter element: ```js upvote({ params: { id, url } }) { console.log(id) // 12345 console.log(url) // "/votes" } ``` ================================================ FILE: docs/reference/controllers.md ================================================ --- permalink: /reference/controllers.html order: 00 --- # Controllers A _controller_ is the basic organizational unit of a Stimulus application. ```js import { Controller } from "@hotwired/stimulus" export default class extends Controller { // … } ``` Controllers are instances of JavaScript classes that you define in your application. Each controller class inherits from the `Controller` base class exported by the `@hotwired/stimulus` module. ## Properties Every controller belongs to a Stimulus `Application` instance and is associated with an HTML element. Within a controller class, you can access the controller's: * application, via the `this.application` property * HTML element, via the `this.element` property * identifier, via the `this.identifier` property ## Modules Define your controller classes in JavaScript modules, one per file. Export each controller class as the module's default object, as in the example above. Place these modules in the `controllers/` directory. Name the files `[identifier]_controller.js`, where `[identifier]` corresponds to each controller's identifier. ## Identifiers An _identifier_ is the name you use to reference a controller class in HTML. When you add a `data-controller` attribute to an element, Stimulus reads the identifier from the attribute's value and creates a new instance of the corresponding controller class. For example, this element has a controller which is an instance of the class defined in `controllers/reference_controller.js`: ```html
``` The following is an example of how Stimulus will generate identifiers for controllers in its require context: If your controller file is named… | its identifier will be… --------------------------------- | ----------------------- clipboard_controller.js | clipboard date_picker_controller.js | date-picker users/list_item_controller.js | users\-\-list-item local-time-controller.js | local-time ## Scopes When Stimulus connects a controller to an element, that element and all of its children make up the controller's _scope_. For example, the `
` and `

` below are part of the controller's scope, but the surrounding `
` element is not. ```html

Reference

``` ## Nested Scopes When nested, each controller is only aware of its own scope excluding the scope of any controllers nested within. For example, the `#parent` controller below is only aware of the `item` targets directly within its scope, but not any targets of the `#child` controller. ```html
  • One
  • Two
    • I am
    • a nested list
``` ## Multiple Controllers The `data-controller` attribute's value is a space-separated list of identifiers: ```html
``` It's common for any given element on the page to have many controllers. In the example above, the `
` has two connected controllers, `clipboard` and `list-item`. Similarly, it's common for multiple elements on the page to reference the same controller class: ```html
  • One
  • Two
  • Three
``` Here, each `
  • ` has its own instance of the `list-item` controller. ## Naming Conventions Always use camelCase for method and property names in a controller class. When an identifier is composed of more than one word, write the words in kebab-case (i.e., by using dashes: `date-picker`, `list-item`). In filenames, separate multiple words using either underscores or dashes (snake_case or kebab-case: `controllers/date_picker_controller.js`, `controllers/list-item-controller.js`). ## Registration If you use Stimulus for Rails with an import map or Webpack together with the `@hotwired/stimulus-webpack-helpers` package, your application will automatically load and register controller classes following the conventions above. If not, your application must manually load and register each controller class. ### Registering Controllers Manually To manually register a controller class with an identifier, first import the class, then call the `Application#register` method on your application object: ```js import ReferenceController from "./controllers/reference_controller" application.register("reference", ReferenceController) ``` You can also register a controller class inline instead of importing it from a module: ```js import { Controller } from "@hotwired/stimulus" application.register("reference", class extends Controller { // … }) ``` ### Preventing Registration Based On Environmental Factors If you only want a controller registered and loaded if certain environmental factors are met – such a given user agent – you can overwrite the static `shouldLoad` method: ```js class UnloadableController extends ApplicationController { static get shouldLoad() { return false } } // This controller will not be loaded application.register("unloadable", UnloadableController) ``` ### Trigger Behaviour When A Controller Is Registered If you want to trigger some behaviour once a controller has been registered you can add a static `afterLoad` method: ```js class SpinnerButton extends Controller { static legacySelector = ".legacy-spinner-button" static afterLoad(identifier, application) { // use the application instance to read the configured 'data-controller' attribute const { controllerAttribute } = application.schema // update any legacy buttons with the controller's registered identifier const updateLegacySpinners = () => { document.querySelector(this.legacySelector).forEach((element) => { element.setAttribute(controllerAttribute, identifier) }) } // called as soon as registered so DOM may not have loaded yet if (document.readyState == "loading") { document.addEventListener("DOMContentLoaded", updateLegacySpinners) } else { updateLegacySpinners() } } } // This controller will update any legacy spinner buttons to use the controller application.register("spinner-button", SpinnerButton) ``` The `afterLoad` method will get called as soon as the controller has been registered, even if no controlled elements exist in the DOM. The function will be called bound to the original controller constructor along with two arguments; the `identifier` that was used when registering the controller and the Stimulus application instance. ## Cross-Controller Coordination With Events If you need controllers to communicate with each other, you should use events. The `Controller` class has a convenience method called `dispatch` that makes this easier. It takes an `eventName` as the first argument, which is then automatically prefixed with the name of the controller separated by a colon. The payload is held in `detail`. It works like this: ```js class ClipboardController extends Controller { static targets = [ "source" ] copy() { this.dispatch("copy", { detail: { content: this.sourceTarget.value } }) navigator.clipboard.writeText(this.sourceTarget.value) } } ``` And this event can then be routed to an action on another controller: ```html
    PIN:
    ``` So when the `Clipboard#copy` action is invoked, the `Effects#flash` action will be too: ```js class EffectsController extends Controller { flash({ detail: { content } }) { console.log(content) // 1234 } } ``` If the two controllers don't belong to the same HTML element, the `data-action` attribute needs to be added to the *receiving* controller's element. And if the receiving controller's element is not a parent (or same) of the emitting controller's element, you need to add `@window` to the event: ```html
    ``` `dispatch` accepts additional options as the second parameter as follows: option | default | notes -------------|--------------------|---------------------------------------------------------------------------------------------- `detail` | `{}` empty object | See [CustomEvent.detail](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail) `target` | `this.element` | See [Event.target](https://developer.mozilla.org/en-US/docs/Web/API/Event/target) `prefix` | `this.identifier` | If the prefix is falsey (e.g. `null` or `false`), only the `eventName` will be used. If you provide a string value the `eventName` will be prepended with the provided string and a colon. `bubbles` | `true` | See [Event.bubbles](https://developer.mozilla.org/en-US/docs/Web/API/Event/bubbles) `cancelable` | `true` | See [Event.cancelable](https://developer.mozilla.org/en-US/docs/Web/API/Event/cancelable) `dispatch` will return the generated [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent), you can use this to provide a way for the event to be cancelled by any other listeners as follows: ```js class ClipboardController extends Controller { static targets = [ "source" ] copy() { const event = this.dispatch("copy", { cancelable: true }) if (event.defaultPrevented) return navigator.clipboard.writeText(this.sourceTarget.value) } } ``` ```js class EffectsController extends Controller { flash(event) { // this will prevent the default behaviour as determined by the dispatched event event.preventDefault() } } ``` ## Directly Invoking Other Controllers If for some reason it is not possible to use events to communicate between controllers, you can reach a controller instance via the `getControllerForElementAndIdentifier` method from the application. This should only be used if you have a unique problem that cannot be solved through the more general way of using events, but if you must, this is how: ```js class MyController extends Controller { static targets = [ "other" ] copy() { const otherController = this.application.getControllerForElementAndIdentifier(this.otherTarget, 'other') otherController.otherMethod() } } ``` ================================================ FILE: docs/reference/css_classes.md ================================================ --- permalink: /reference/css-classes.html order: 06 --- # CSS Classes In HTML, a _CSS class_ defines a set of styles which can be applied to elements using the `class` attribute. CSS classes are a convenient tool for changing styles and playing animations programmatically. For example, a Stimulus controller might add a "loading" class to an element when it is performing an operation in the background, and then style that class in CSS to display a progress indicator: ```html
    ``` ```css .search--busy { background-image: url(throbber.svg) no-repeat; } ``` As an alternative to hard-coding classes with JavaScript strings, Stimulus lets you refer to CSS classes by _logical name_ using a combination of data attributes and controller properties. ## Definitions Define CSS classes by logical name in your controller using the `static classes` array: ```js // controllers/search_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static classes = [ "loading" ] // … } ``` ## Attributes The logical names defined in the controller's `static classes` array map to _CSS class attributes_ on the controller's element. ```html
    ``` Construct a CSS class attribute by joining together the controller identifier and logical name in the format `data-[identifier]-[logical-name]-class`. The attribute's value can be a single CSS class name or a list of multiple class names. **Note:** CSS class attributes must be specified on the same element as the `data-controller` attribute. If you want to specify multiple CSS classes for a logical name, separate the classes with spaces: ```html
    ``` ## Properties For each logical name defined in the `static classes` array, Stimulus adds the following _CSS class properties_ to your controller: Kind | Name | Value ----------- | ---------------------------- | ----- Singular | `this.[logicalName]Class` | The value of the CSS class attribute corresponding to `logicalName` Plural | `this.[logicalName]Classes` | An array of all classes in the corresponding CSS class attribute, split by spaces Existential | `this.has[LogicalName]Class` | A boolean indicating whether or not the CSS class attribute is present
    Use these properties to apply CSS classes to elements with the `add()` and `remove()` methods of the [DOM `classList` API](https://developer.mozilla.org/en-US/docs/Web/API/Element/classList). For example, to display a loading indicator on the `search` controller's element before fetching results, you might implement the `loadResults` action like so: ```js export default class extends Controller { static classes = [ "loading" ] loadResults() { this.element.classList.add(this.loadingClass) fetch(/* … */) } } ``` If a CSS class attribute contains a list of class names, its singular CSS class property returns the first class in the list. Use the plural CSS class property to access all class names as an array. Combine this with [spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) to apply multiple classes at once: ```js export default class extends Controller { static classes = [ "loading" ] loadResults() { this.element.classList.add(...this.loadingClasses) fetch(/* … */) } } ``` **Note:** Stimulus will throw an error if you attempt to access a CSS class property when a matching CSS class attribute is not present. ## Naming Conventions Use camelCase to specify logical names in CSS class definitions. Logical names map to camelCase CSS class properties: ```js export default class extends Controller { static classes = [ "loading", "noResults" ] loadResults() { // … if (results.length == 0) { this.element.classList.add(this.noResultsClass) } } } ``` In HTML, write CSS class attributes in kebab-case: ```html
    ``` When constructing CSS class attributes, follow the conventions for identifiers as described in [Controllers: Naming Conventions](controllers#naming-conventions). ================================================ FILE: docs/reference/lifecycle_callbacks.md ================================================ --- permalink: /reference/lifecycle-callbacks.html order: 01 --- # Lifecycle Callbacks Special methods called _lifecycle callbacks_ allow you to respond whenever a controller or certain targets connects to and disconnects from the document. ```js import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { // … } } ``` ## Methods You may define any of the following methods in your controller: Method | Invoked by Stimulus… ------------ | -------------------- initialize() | Once, when the controller is first instantiated [name]TargetConnected(target: Element) | Anytime a target is connected to the DOM connect() | Anytime the controller is connected to the DOM [name]TargetDisconnected(target: Element) | Anytime a target is disconnected from the DOM disconnect() | Anytime the controller is disconnected from the DOM ## Connection A controller is _connected_ to the document when both of the following conditions are true: * its element is present in the document (i.e., a descendant of `document.documentElement`, the `` element) * its identifier is present in the element's `data-controller` attribute When a controller becomes connected, Stimulus calls its `connect()` method. ### Targets A target is _connected_ to the document when both of the following conditions are true: * its element is present in the document as a descendant of its corresponding controller's element * its identifier is present in the element's `data-{identifier}-target` attribute When a target becomes connected, Stimulus calls its controller's `[name]TargetConnected()` method, passing the target element as a parameter. The `[name]TargetConnected()` lifecycle callbacks will fire *before* the controller's `connect()` callback. ## Disconnection A connected controller will later become _disconnected_ when either of the preceding conditions becomes false, such as under any of the following scenarios: * the element is explicitly removed from the document with `Node#removeChild()` or `ChildNode#remove()` * one of the element's parent elements is removed from the document * one of the element's parent elements has its contents replaced by `Element#innerHTML=` * the element's `data-controller` attribute is removed or modified * the document installs a new `` element, such as during a Turbo page change When a controller becomes disconnected, Stimulus calls its `disconnect()` method. ### Targets A connected target will later become _disconnected_ when either of the preceding conditions becomes false, such as under any of the following scenarios: * the element is explicitly removed from the document with `Node#removeChild()` or `ChildNode#remove()` * one of the element's parent elements is removed from the document * one of the element's parent elements has its contents replaced by `Element#innerHTML=` * the element's `data-{identifier}-target` attribute is removed or modified * the document installs a new `` element, such as during a Turbo page change When a target becomes disconnected, Stimulus calls its controller's `[name]TargetDisconnected()` method, passing the target element as a parameter. The `[name]TargetDisconnected()` lifecycle callbacks will fire *after* the controller's `disconnect()` callback. ## Reconnection A disconnected controller may become connected again at a later time. When this happens, such as after removing the controller's element from the document and then re-attaching it, Stimulus will reuse the element's previous controller instance, calling its `connect()` method multiple times. Similarly, a disconnected target may be connected again at a later time. Stimulus will invoke its controller's `[name]TargetConnected()` method multiple times. ## Order and Timing Stimulus watches the page for changes asynchronously using the [DOM `MutationObserver` API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver). This means that Stimulus calls your controller's lifecycle methods asynchronously after changes are made to the document, in the next [microtask](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) following each change. Lifecycle methods still run in the order they occur, so two calls to a controller's `connect()` method will always be separated by one call to `disconnect()`. Similarly, two calls to a controller's `[name]TargetConnected()` for a given target will always be separated by one call to `[name]TargetDisconnected()` for that same target. ================================================ FILE: docs/reference/outlets.md ================================================ --- permalink: /reference/outlets.html order: 04 --- # Outlets _Outlets_ let you reference Stimulus _controller instances_ and their _controller element_ from within another Stimulus Controller by using CSS selectors. The use of Outlets helps with cross-controller communication and coordination as an alternative to dispatching custom events on controller elements. They are conceptually similar to [Stimulus Targets](https://stimulus.hotwired.dev/reference/targets) but with the difference that they reference a Stimulus controller instance plus its associated controller element. ```html
    ...
    ...
    ...
    ...
    ...
    ``` While a **target** is a specifically marked element **within the scope** of its own controller element, an **outlet** can be located **anywhere on the page** and doesn't necessarily have to be within the controller scope. ## Attributes and Names The `data-chat-user-status-outlet` attribute is called an _outlet attribute_, and its value is a [CSS selector](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors) which you can use to refer to other controller elements which should be available as outlets on the _host controller_. The outlet identifier in the host controller **must be the same** as the target controller's identifier. If not, it will throw an error message that outlet does not exist. ```html data-[identifier]-[outlet]-outlet="[selector]" ``` ```html
    ``` ## Definitions Define controller identifiers in your controller class using the `static outlets` array. This array declares which other controller identifiers can be used as outlets on this controller: ```js // chat_controller.js export default class extends Controller { static outlets = [ "user-status" ] connect () { this.userStatusOutlets.forEach(status => ...) } } ``` ## Properties For each outlet defined in the `static outlets` array, Stimulus adds five properties to your controller, where `[name]` corresponds to the outlet's controller identifier: | Kind | Property name | Return Type | Effect | ---- | ------------- | ----------- | ----------- | Existential | `has[Name]Outlet` | `Boolean` | Tests for presence of a `[name]` outlet | Singular | `[name]Outlet` | `Controller` | Returns the `Controller` instance of the first `[name]` outlet or throws an exception if none is present | Plural | `[name]Outlets` | `Array` | Returns the `Controller` instances of all `[name]` outlets | Singular | `[name]OutletElement` | `Element` | Returns the Controller `Element` of the first `[name]` outlet or throws an exception if none is present | Plural | `[name]OutletElements` | `Array` | Returns the Controller `Element`'s of all `[name]` outlets **Note:** For nested Stimulus controller properties, make sure to omit namespace delimiters in order to correctly access the referenced outlet: ```js // chat_controller.js export default class extends Controller { static outlets = [ "admin--user-status" ] selectAll(event) { // returns undefined this.admin__UserStatusOutlets // returns controller reference this.adminUserStatusOutlets } } ``` ## Accessing Controllers and Elements Since you get back a `Controller` instance from the `[name]Outlet` and `[name]Outlets` properties you are also able to access the Values, Classes, Targets and all of the other properties and functions that controller instance defines: ```js this.userStatusOutlet.idValue this.userStatusOutlet.imageTarget this.userStatusOutlet.activeClasses ``` You are also able to invoke any function the outlet controller may define: ```js // user_status_controller.js export default class extends Controller { markAsSelected(event) { // ... } } // chat_controller.js export default class extends Controller { static outlets = [ "user-status" ] selectAll(event) { this.userStatusOutlets.forEach(status => status.markAsSelected(event)) } } ``` Similarly with the Outlet Element, it allows you to call any function or property on [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element): ```js this.userStatusOutletElement.dataset.value this.userStatusOutletElement.getAttribute("id") this.userStatusOutletElements.map(status => status.hasAttribute("selected")) ``` ## Outlet Callbacks Outlet callbacks are specially named functions called by Stimulus to let you respond to whenever an outlet is added or removed from the page. To observe outlet changes, define a function named `[name]OutletConnected()` or `[name]OutletDisconnected()`. ```js // chat_controller.js export default class extends Controller { static outlets = [ "user-status" ] userStatusOutletConnected(outlet, element) { // ... } userStatusOutletDisconnected(outlet, element) { // ... } } ``` ### Outlets are Assumed to be Present When you access an Outlet property in a Controller, you assert that at least one corresponding Outlet is present. If the declaration is missing and no matching outlet is found Stimulus will throw an exception: ```html Missing outlet element "user-status" for "chat" controller ``` ### Optional outlets If an Outlet is optional or you want to assert that at least Outlet is present, you must first check the presence of the Outlet using the existential property: ```js if (this.hasUserStatusOutlet) { this.userStatusOutlet.safelyCallSomethingOnTheOutlet() } ``` ### Referencing Non-Controller Elements Stimulus will throw an exception if you try to declare an element as an outlet which doesn't have a corresponding `data-controller` and identifier on it: ```html
    ``` Would result in: ```html Missing "data-controller=user-status" attribute on outlet element for "chat" controller` ``` ================================================ FILE: docs/reference/targets.md ================================================ --- permalink: /reference/targets.html order: 03 --- # Targets _Targets_ let you reference important elements by name. ```html
    ``` ## Attributes and Names The `data-search-target` attribute is called a _target attribute_, and its value is a space-separated list of _target names_ which you can use to refer to the element in the `search` controller. ```html
    ``` ## Definitions Define target names in your controller class using the `static targets` array: ```js // controllers/search_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "query", "errorMessage", "results" ] // … } ``` ## Properties For each target name defined in the `static targets` array, Stimulus adds the following properties to your controller, where `[name]` corresponds to the target's name: Kind | Name | Value ----------- | ---------------------- | ----- Singular | `this.[name]Target` | The first matching target in scope Plural | `this.[name]Targets` | An array of all matching targets in scope Existential | `this.has[Name]Target` | A boolean indicating whether there is a matching target in scope
    **Note:** Accessing the singular target property will throw an error when there is no matching element. ## Shared Targets Elements can have more than one target attribute, and it's common for targets to be shared by multiple controllers. ```html … ``` In the example above, the checkboxes are accessible inside the `search` controller as `this.projectsTarget` and `this.messagesTarget`, respectively. Inside the `checkbox` controller, `this.inputTargets` returns an array with both checkboxes. ## Optional Targets If your controller needs to work with a target which may or may not be present, condition your code based on the value of the existential target property: ```js if (this.hasResultsTarget) { this.resultsTarget.innerHTML = "…" } ``` ## Connected and Disconnected Callbacks Target _element callbacks_ let you respond whenever a target element is added or removed within the controller's element. Define a method `[name]TargetConnected` or `[name]TargetDisconnected` in the controller, where `[name]` is the name of the target you want to observe for additions or removals. The method receives the element as the first argument. Stimulus invokes each element callback any time its target elements are added or removed. When the controller is connected or disconnected from the document, these callbacks are invoked *before* `connect()` and *after* `disconnect()` lifecycle hooks. ```js export default class extends Controller { static targets = [ "item" ] itemTargetConnected(element) { this.sortElements(this.itemTargets) } itemTargetDisconnected(element) { this.sortElements(this.itemTargets) } // Private sortElements(itemTargets) { /* ... */ } } ``` **Note** During the execution of `[name]TargetConnected` and `[name]TargetDisconnected` callbacks, the `MutationObserver` instances behind the scenes are paused. This means that if a callback add or removes a target with a matching name, the corresponding callback _will not_ be invoked again. ## Naming Conventions Always use camelCase to specify target names, since they map directly to properties on your controller: ```html ``` ```js export default class extends Controller { static targets = [ "camelCase" ] } ``` ================================================ FILE: docs/reference/using_typescript.md ================================================ --- permalink: /reference/using-typescript.html order: 07 --- # Using Typescript Stimulus itself is written in [TypeScript](https://www.typescriptlang.org/) and provides types directly over its package. The following documentation shows how to define types for Stimulus properties. ## Define Controller Element Type By default, the `element` of the controller is of type `Element`. You can override the type of the controller element by specifiying it as a [Generic Type](https://www.typescriptlang.org/docs/handbook/2/generics.html). For example, if the element type is expected to be a `HTMLFormElement`: ```ts import { Controller } from "@hotwired/stimulus" export default class MyController extends Controller { submit() { new FormData(this.element) } } ``` ## Define Value Properties You can define the properties of configured values using the TypeScript `declare` keyword. You just need to define the properties if you are making use of them within the controller. ```ts import { Controller } from "@hotwired/stimulus" export default class MyController extends Controller { static values = { code: String } declare codeValue: string declare readonly hasCodeValue: boolean } ``` > The `declare` keyword avoids overriding the existing Stimulus property, and just defines the type for TypeScript. ## Define Target Properties You can define the properties of configured targets using the TypeScript `declare` keyword. You just need to define the properties if you are making use of them within the controller. The return types of the `[name]Target` and `[name]Targets` properties can be any inheriting from the `Element` type. Choose the best type which fits your needs. Pick either `Element` or `HTMLElement` if you want to define it as a generic HTML element. ```ts import { Controller } from "@hotwired/stimulus" export default class MyController extends Controller { static targets = [ "input" ] declare readonly hasInputTarget: boolean declare readonly inputTarget: HTMLInputElement declare readonly inputTargets: HTMLInputElement[] } ``` > The `declare` keyword avoids overriding the existing Stimulus property, and just defines the type for TypeScript. ## Custom properties and methods Other custom properties can be defined the TypeScript way on the controller class: ```ts import { Controller } from "@hotwired/stimulus" export default class MyController extends Controller { container: HTMLElement } ``` Read more in the [TypeScript Documentation](https://www.typescriptlang.org/docs/handbook/intro.html). ================================================ FILE: docs/reference/values.md ================================================ --- permalink: /reference/values.html order: 05 --- # Values You can read and write [HTML data attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*) on controller elements as typed _values_ using special controller properties. ```html
    ``` As per the given HTML snippet, remember to place the data attributes for values on the same element as the `data-controller` attribute. ```js // controllers/loader_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { url: String } connect() { fetch(this.urlValue).then(/* … */) } } ``` ## Definitions Define values in a controller using the `static values` object. Put each value's _name_ on the left and its _type_ on the right. ```js export default class extends Controller { static values = { url: String, interval: Number, params: Object } // … } ``` ## Types A value's type is one of `Array`, `Boolean`, `Number`, `Object`, or `String`. The type determines how the value is transcoded between JavaScript and HTML. | Type | Encoded as… | Decoded as… | | ------- | ------------------------ | --------------------------------------- | | Array | `JSON.stringify(array)` | `JSON.parse(value)` | | Boolean | `boolean.toString()` | `!(value == "0" \|\| value == "false")` | | Number | `number.toString()` | `Number(value.replace(/_/g, ""))` | | Object | `JSON.stringify(object)` | `JSON.parse(value)` | | String | Itself | Itself | ## Properties and Attributes Stimulus automatically generates getter, setter, and existential properties for each value defined in a controller. These properties are linked to data attributes on the controller's element: Kind | Property name | Effect ---- | ------------- | ------ Getter | `this.[name]Value` | Reads `data-[identifier]-[name]-value` Setter | `this.[name]Value=` | Writes `data-[identifier]-[name]-value` Existential | `this.has[Name]Value` | Tests for `data-[identifier]-[name]-value` ### Getters The getter for a value decodes the associated data attribute into an instance of the value's type. If the data attribute is missing from the controller's element, the getter returns a _default value_, depending on the value's type: Type | Default value ---- | ------------- Array | `[]` Boolean | `false` Number | `0` Object | `{}` String | `""` ### Setters The setter for a value sets the associated data attribute on the controller's element. To remove the data attribute from the controller's element, assign `undefined` to the value. ### Existential Properties The existential property for a value evaluates to `true` when the associated data attribute is present on the controller's element and `false` when it is absent. ## Change Callbacks Value _change callbacks_ let you respond whenever a value's data attribute changes. Define a method `[name]ValueChanged` in the controller, where `[name]` is the name of the value you want to observe for changes. The method receives its decoded value as the first argument and the decoded previous value as the second argument. Stimulus invokes each change callback after the controller is initialized and again any time its associated data attribute changes. This includes changes as a result of assignment to the value's setter. ```js export default class extends Controller { static values = { url: String } urlValueChanged() { fetch(this.urlValue).then(/* … */) } } ``` ### Previous Values You can access the previous value of a `[name]ValueChanged` callback by defining the callback method with two arguments in your controller. ```js export default class extends Controller { static values = { url: String } urlValueChanged(value, previousValue) { /* … */ } } ``` The two arguments can be named as you like. You could also use `urlValueChanged(current, old)`. ## Default Values Values that have not been specified on the controller element can be set by defaults specified in the controller definition: ```js export default class extends Controller { static values = { url: { type: String, default: '/bill' }, interval: { type: Number, default: 5 }, clicked: Boolean } } ``` When a default is used, the expanded form of `{ type, default }` is used. This form can be mixed with the regular form that does not use a default. ## Naming Conventions Write value names as camelCase in JavaScript and kebab-case in HTML. For example, a value named `contentType` in the `loader` controller will have the associated data attribute `data-loader-content-type-value`. ================================================ FILE: examples/.babelrc ================================================ { "presets": [ ["@babel/preset-env"] ], "plugins": [ ["@babel/plugin-proposal-class-properties"], ["@babel/plugin-transform-runtime"] ] } ================================================ FILE: examples/controllers/clipboard_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "source" ] static classes = [ "supported" ] initialize() { if (document.queryCommandSupported("copy")) { this.element.classList.add(this.supportedClass) } } copy() { navigator.clipboard.writeText(this.sourceTarget.value) } } ================================================ FILE: examples/controllers/content_loader_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["item"] static values = { url: String, refreshInterval: Number } connect() { this.load() if (this.hasRefreshIntervalValue) { this.startRefreshing() } } itemTargetConnected(target) { console.log("itemTargetConnected:", target) } itemTargetDisconnected(target) { console.log("itemTargetDisconnected:", target) } disconnect() { this.stopRefreshing() } load() { fetch(this.urlValue) .then(response => response.text()) .then(html => { this.element.innerHTML = html }) } startRefreshing() { this.refreshTimer = setInterval(() => { this.load() }, this.refreshIntervalValue) } stopRefreshing() { if (this.refreshTimer) { clearInterval(this.refreshTimer) } } } ================================================ FILE: examples/controllers/hello_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["name"] greet() { alert(`Hello, ${this.name}!`) } get name() { return this.nameTarget.value } } ================================================ FILE: examples/controllers/slideshow_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "slide" ] static classes = [ "currentSlide" ] static values = { index: Number } next() { if (this.indexValue < this.lastIndex) { this.indexValue++ } } previous() { if (this.indexValue > 0) { this.indexValue-- } } indexValueChanged() { this.render() } render() { this.slideTargets.forEach((element, index) => { element.classList.toggle(this.currentSlideClass, index == this.indexValue) }) } get lastIndex() { return this.slideTargets.length - 1 } } ================================================ FILE: examples/controllers/tabs_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "tab", "tabpanel" ] static classes = [ "current" ] static values = { index: { default: 0, type: Number } } next() { if (this.indexValue < this.lastIndex) { this.indexValue++ return } this.indexValue = 0 } previous() { if (this.indexValue > 0) { this.indexValue-- return } this.indexValue = this.lastIndex } open(evt) { this.indexValue = this.tabTargets.indexOf(evt.currentTarget) } get lastIndex() { return this.tabTargets.length - 1 } indexValueChanged(current, old) { let panels = this.tabpanelTargets let tabs = this.tabTargets if (old != null) { panels[old].classList.remove(...this.currentClasses) tabs[old].tabIndex = -1 } panels[current].classList.add(...this.currentClasses) tabs[current].tabIndex = 0 tabs[current].focus() } } ================================================ FILE: examples/index.js ================================================ import { Application } from "@hotwired/stimulus" import "@hotwired/turbo" const application = Application.start() import ClipboardController from "./controllers/clipboard_controller" application.register("clipboard", ClipboardController) import ContentLoaderController from "./controllers/content_loader_controller" application.register("content-loader", ContentLoaderController) import HelloController from "./controllers/hello_controller" application.register("hello", HelloController) import SlideshowController from "./controllers/slideshow_controller" application.register("slideshow", SlideshowController) import TabsController from "./controllers/tabs_controller" application.register("tabs", TabsController) ================================================ FILE: examples/package.json ================================================ { "name": "@hotwired/stimulus-examples", "version": "3.1.0", "private": true, "dependencies": { "@babel/core": "^7.5.5", "@babel/plugin-proposal-class-properties": "^7.5.5", "@babel/plugin-transform-runtime": "^7.14.5", "@babel/preset-env": "^7.5.5", "@hotwired/turbo": "^7.2.4", "babel-loader": "^8.0.6", "ejs": "^3.1.10", "express": "^4.20.0", "webpack": "^4.39.1", "webpack-dev-middleware": "^5.3.4" }, "scripts": { "start": "node server.js" } } ================================================ FILE: examples/public/examples.css ================================================ body { background: rgb(251, 247, 240); } main { display: flex; flex-flow: row; align-items: flex-start; justify-content: flex-start; } .logo { width: 6ex; height: 6ex; margin: 0 0 1ex; } .sidebar { width: 10em; margin-top: 3ex; margin-left: 2em; } .nav { white-space: nowrap; } .nav li { list-style: none; } .nav a { display: block; position: relative; text-decoration: none; } .nav a.active { font-weight: bold; color: #000; } .nav a.active::before { content: "►"; display: inline-block; position: absolute; left: -1em; font-size: 1em; } .container { flex-grow: 0; background-color: #fff; border: 0.25ex solid #333; border-radius: 1ex; box-shadow: 1ex 1ex 0 0 rgba(0, 0, 0, 0.25); margin: 7ex 3em 0 0; padding: 2.5ex 2em; display: flex; flex-direction: column; } .clipboard-button { display: none; } .clipboard--supported .clipboard-button { display: initial; } .clipboard-source { background-color: transparent; border: none; box-shadow: none; color: inherit; font-size: inherit; } .clipboard-paste { min-width: 20em; min-height: 8ex; } .slide { display: none; font-size: 6rem; } .slide--current { display: block; } .container--content-loader { min-width: 16em; } .tabpanel { border: 1px solid #dedede; display: none; margin-top: .4rem; padding: 0.8rem; font-size: 6rem; } .tabpanel--current { display: block; } ================================================ FILE: examples/public/main.css ================================================ * { box-sizing: border-box; margin: 0; padding: 0; } *:not(:active) { color: #333; } a:active { color: #333; } body { background: #fff; font-family: -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif; font-size: 16px; padding: 2ex 0; line-height: 1.5; } p { margin-bottom: 2ex; } textarea { resize: none; } button, input, textarea { background: #fff; border: 0.25ex solid #333; border-radius: 0.5em; margin: 0.5ex 0.5em; padding: 0.5ex 0.5em; font-family: inherit; font-size: 16px; box-shadow: 0 0 0 0.5ex rgba(0, 0, 0, 0.25); vertical-align: middle; } button { background: #ccc; border-left-color: #fff; border-top-color: #fff; } button:active { border-left-color: #333; border-top-color: #333; border-bottom-color: #ccc; border-right-color: #ccc; color: #333; padding: calc(0.5ex + 1px) calc(0.5em - 1px) calc(0.5ex - 1px) calc(0.5em + 1px); margin: calc(0.5ex - 1px) calc(0.5em + 1px) calc(0.5ex + 1px) calc(0.5em - 1px); transform: translate(1px, 1px); } button:active, button:focus { outline: none; } button:focus-visible, input:focus-visible, textarea:focus-visible { outline: none; box-shadow: 0 0 0 0.5ex rgba(0, 51, 255, 0.5); } input, textarea { border-bottom-color: #ccc; border-right-color: #ccc; } ================================================ FILE: examples/server.js ================================================ const fs = require("fs") const path = require("path") const express = require("express") const webpack = require("webpack") const webpackMiddleware = require("webpack-dev-middleware") const webpackConfig = require("./webpack.config") const app = express() const port = 9000 const publicPath = path.join(__dirname, "public") const viewPath = path.join(__dirname, "views") const viewEngine = "ejs" app.set("views", viewPath) app.set("view engine", viewEngine) app.use(express.static(publicPath)) app.use(webpackMiddleware(webpack(webpackConfig))) const pages = [ { path: "/hello", title: "Hello" }, { path: "/clipboard", title: "Clipboard" }, { path: "/slideshow", title: "Slideshow" }, { path: "/content-loader", title: "Content Loader" }, { path: "/tabs", title: "Tabs" }, ] app.get("/", (req, res) => { res.redirect(pages[0].path) }) app.get("/uptime", (req, res, next) => { res.send(`${process.uptime().toString()}`) }) app.get("/:page", (req, res, next) => { const currentPage = pages.find(page => page.path == req.path) res.render(req.params.page, { pages, currentPage }) }) app.listen(port, () => { console.log(`Listening on http://localhost:${port}/`) }) ================================================ FILE: examples/views/clipboard.ejs ================================================ <%- include("layout/head") %>


    <%- include("layout/tail") %> ================================================ FILE: examples/views/content-loader.ejs ================================================ <%- include("layout/head") %>

    <%- include("layout/tail") %> ================================================ FILE: examples/views/hello.ejs ================================================ <%- include("layout/head") %>

    <%- include("layout/tail") %> ================================================ FILE: examples/views/layout/head.ejs ================================================ Stimulus: <%= currentPage.title %> Example
    ================================================ FILE: examples/views/layout/tail.ejs ================================================
    ================================================ FILE: examples/views/slideshow.ejs ================================================ <%- include("layout/head") %>
    🐵
    🙈
    🙉
    🙊
    <%- include("layout/tail") %> ================================================ FILE: examples/views/tabs.ejs ================================================ <%- include("layout/head") %>

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

    🐵
    🙈
    <%- include("layout/tail") %> ================================================ FILE: examples/webpack.config.js ================================================ const path = require("path") module.exports = { entry: { main: "./index.js" }, output: { filename: "[name].js" }, mode: "development", devtool: "inline-source-map", module: { rules: [ { test: /\.ts$/, use: [ { loader: "ts-loader" } ] }, { test: /\.js$/, exclude: [ /node_modules/ ], use: [ { loader: "babel-loader" } ] } ] }, resolve: { extensions: [".ts", ".js"], modules: ["src", "node_modules"], alias: { "@hotwired/stimulus": path.resolve(__dirname, "../dist/stimulus.js"), } } } ================================================ FILE: karma.conf.cjs ================================================ const config = { basePath: ".", browsers: ["ChromeHeadless", "FirefoxHeadless"], frameworks: ["qunit"], reporters: ["progress"], singleRun: true, autoWatch: false, files: [ "dist/tests/index.js", { pattern: "src/tests/fixtures/**/*", watched: true, served: true, included: false }, { pattern: "dist/tests/fixtures/**/*", watched: true, served: true, included: false }, ], preprocessors: { "dist/tests/**/*.js": ["webpack"], }, webpack: { mode: "development", resolve: { extensions: [".js"], }, }, client: { clearContext: false, qunit: { showUI: true, }, }, hostname: "0.0.0.0", captureTimeout: 180000, browserDisconnectTimeout: 180000, browserDisconnectTolerance: 3, browserNoActivityTimeout: 300000, } module.exports = function (karmaConfig) { karmaConfig.set(config) } ================================================ FILE: package.json ================================================ { "name": "@hotwired/stimulus", "version": "3.2.2", "license": "MIT", "description": "A modest JavaScript framework for the HTML you already have.", "author": "Basecamp, LLC", "contributors": [ "David Heinemeier Hansson ", "Javan Makhmali ", "Sam Stephenson " ], "homepage": "https://stimulus.hotwired.dev", "repository": { "type": "git", "url": "git+https://github.com/hotwired/stimulus.git" }, "bugs": { "url": "https://github.com/hotwired/stimulus/issues" }, "publishConfig": { "access": "public" }, "module": "dist/stimulus.js", "main": "dist/stimulus.umd.js", "types": "dist/types/index.d.ts", "files": [ "dist/stimulus.js", "dist/stimulus.umd.js", "dist/types/**/*" ], "scripts": { "clean": "rm -fr dist", "types": "tsc --noEmit false --declaration true --emitDeclarationOnly true --outDir dist/types", "prebuild": "yarn build:test", "build": "yarn types && rollup -c", "build:test": "tsc -b tsconfig.test.json", "watch": "rollup -wc", "prerelease": "yarn clean && yarn build && yarn build:test && git --no-pager diff && echo && npm pack --dry-run", "release": "npm publish", "start": "concurrently \"npm:watch\" \"npm:start:examples\"", "start:examples": "cd examples && yarn install && node server.js", "test": "yarn build:test && karma start karma.conf.cjs", "test:watch": "yarn test --auto-watch --no-single-run", "lint": "eslint . --ext .ts", "format": "yarn lint --fix" }, "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-typescript": "^11.1.1", "@types/qunit": "^2.19.10", "@types/webpack-env": "^1.14.0", "@typescript-eslint/eslint-plugin": "^5.59.11", "@typescript-eslint/parser": "^5.59.11", "concurrently": "^9.1.2", "eslint": "^8.43.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", "karma": "^6.4.4", "karma-chrome-launcher": "^3.2.0", "karma-firefox-launcher": "^2.1.3", "karma-qunit": "^4.2.1", "karma-webpack": "^4.0.2", "prettier": "^2.8.8", "qunit": "^2.20.0", "rollup": "^2.53", "rollup-plugin-terser": "^7.0.2", "ts-loader": "^9.4.3", "tslib": "^2.5.3", "typescript": "^5.1.3", "webpack": "^4.47.0" }, "resolutions": { "webdriverio": "^7.19.5" } } ================================================ FILE: packages/stimulus/.gitignore ================================================ README.md ================================================ FILE: packages/stimulus/.npmignore ================================================ rollup.config.js *.log ================================================ FILE: packages/stimulus/index.d.ts ================================================ export * from "@hotwired/stimulus" export as namespace Stimulus ================================================ FILE: packages/stimulus/index.js ================================================ export * from "@hotwired/stimulus" ================================================ FILE: packages/stimulus/package.json ================================================ { "name": "stimulus", "version": "3.2.2", "description": "Stimulus JavaScript framework", "homepage": "https://stimulus.hotwired.dev", "repository": { "type": "git", "url": "git+https://github.com/hotwired/stimulus.git" }, "author": "Basecamp, LLC", "contributors": [ "David Heinemeier Hansson ", "Javan Makhmali ", "Sam Stephenson " ], "module": "./dist/stimulus.js", "main": "./dist/stimulus.umd.js", "types": "./index.d.ts", "exports": { ".": { "main": "./dist/stimulus.umd.js", "browser": "./dist/stimulus.js", "import": "./dist/stimulus.js", "module": "./dist/stimulus.js", "umd": "./dist/stimulus.umd.js", "types": "./index.d.ts" }, "./webpack-helpers": { "main": "./dist/webpack-helpers.umd.js", "browser": "./dist/webpack-helpers.js", "import": "./dist/webpack-helpers.js", "module": "./dist/webpack-helpers.js", "umd": "./dist/webpack-helpers.umd.js", "types": "./webpack-helpers.d.ts" } }, "files": [ "index.d.ts", "dist/stimulus.js", "dist/stimulus.umd.js", "webpack-helpers.js", "webpack-helpers.d.ts", "dist/webpack-helpers.js", "dist/webpack-helpers.umd.js", "README.md" ], "license": "MIT", "dependencies": { "@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus-webpack-helpers": "^1.0.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-typescript": "^8.2.1", "rollup": "^2.53" }, "scripts": { "clean": "rm -rf dist", "build": "rollup --config rollup.config.js", "prerelease": "yarn build && git --no-pager diff && echo && npm pack --dry-run", "release": "npm publish" }, "publishConfig": { "access": "public" } } ================================================ FILE: packages/stimulus/rollup.config.js ================================================ import resolve from "@rollup/plugin-node-resolve" export default [ { input: "index.js", output: [ { name: "Stimulus", file: "dist/stimulus.umd.js", format: "umd" }, { file: "dist/stimulus.js", format: "es" }, ], context: "window", plugins: [ resolve() ] }, { input: "webpack-helpers.js", output: [ { name: "StimulusWebpackHelpers", file: "dist/webpack-helpers.umd.js", format: "umd" }, { file: "dist/webpack-helpers.js", format: "es" }, ], context: "window", plugins: [ resolve() ] } ] ================================================ FILE: packages/stimulus/webpack-helpers.d.ts ================================================ export * from "@hotwired/stimulus-webpack-helpers" export as namespace StimulusWebpackHelpers ================================================ FILE: packages/stimulus/webpack-helpers.js ================================================ export * from "@hotwired/stimulus-webpack-helpers" ================================================ FILE: rollup.config.js ================================================ import resolve from "@rollup/plugin-node-resolve" import typescript from "@rollup/plugin-typescript" import { terser } from "rollup-plugin-terser" import { version } from "./package.json" const year = new Date().getFullYear() const banner = `/*\nStimulus ${version}\nCopyright © ${year} Basecamp, LLC\n */` export default [ { input: "src/index.js", output: [ { name: "Stimulus", file: "dist/stimulus.umd.js", format: "umd", banner }, { file: "dist/stimulus.js", format: "es", banner }, ], context: "window", plugins: [ resolve(), typescript() ] }, { input: "src/index.js", output: { file: "dist/stimulus.min.js", format: "es", banner, sourcemap: true }, context: "window", plugins: [ resolve(), typescript(), terser({ mangle: true, compress: true }) ] } ] ================================================ FILE: src/core/action.ts ================================================ import { ActionDescriptor, parseActionDescriptorString, stringifyEventTarget } from "./action_descriptor" import { Token } from "../mutation-observers" import { Schema } from "./schema" import { camelize } from "./string_helpers" import { hasProperty } from "./utils" const allModifiers = ["meta", "ctrl", "alt", "shift"] export class Action { readonly element: Element readonly index: number readonly eventTarget: EventTarget readonly eventName: string readonly eventOptions: AddEventListenerOptions readonly identifier: string readonly methodName: string readonly keyFilter: string readonly schema: Schema static forToken(token: Token, schema: Schema) { return new this(token.element, token.index, parseActionDescriptorString(token.content), schema) } constructor(element: Element, index: number, descriptor: Partial, schema: Schema) { this.element = element this.index = index this.eventTarget = descriptor.eventTarget || element this.eventName = descriptor.eventName || getDefaultEventNameForElement(element) || error("missing event name") this.eventOptions = descriptor.eventOptions || {} this.identifier = descriptor.identifier || error("missing identifier") this.methodName = descriptor.methodName || error("missing method name") this.keyFilter = descriptor.keyFilter || "" this.schema = schema } toString() { const eventFilter = this.keyFilter ? `.${this.keyFilter}` : "" const eventTarget = this.eventTargetName ? `@${this.eventTargetName}` : "" return `${this.eventName}${eventFilter}${eventTarget}->${this.identifier}#${this.methodName}` } shouldIgnoreKeyboardEvent(event: KeyboardEvent): boolean { if (!this.keyFilter) { return false } const filters = this.keyFilter.split("+") if (this.keyFilterDissatisfied(event, filters)) { return true } const standardFilter = filters.filter((key) => !allModifiers.includes(key))[0] if (!standardFilter) { // missing non modifier key return false } if (!hasProperty(this.keyMappings, standardFilter)) { error(`contains unknown key filter: ${this.keyFilter}`) } return this.keyMappings[standardFilter].toLowerCase() !== event.key.toLowerCase() } shouldIgnoreMouseEvent(event: MouseEvent): boolean { if (!this.keyFilter) { return false } const filters = [this.keyFilter] if (this.keyFilterDissatisfied(event, filters)) { return true } return false } get params() { const params: { [key: string]: any } = {} const pattern = new RegExp(`^data-${this.identifier}-(.+)-param$`, "i") for (const { name, value } of Array.from(this.element.attributes)) { const match = name.match(pattern) const key = match && match[1] if (key) { params[camelize(key)] = typecast(value) } } return params } private get eventTargetName() { return stringifyEventTarget(this.eventTarget) } private get keyMappings() { return this.schema.keyMappings } private keyFilterDissatisfied(event: KeyboardEvent | MouseEvent, filters: Array): boolean { const [meta, ctrl, alt, shift] = allModifiers.map((modifier) => filters.includes(modifier)) return event.metaKey !== meta || event.ctrlKey !== ctrl || event.altKey !== alt || event.shiftKey !== shift } } const defaultEventNames: { [tagName: string]: (element: Element) => string } = { a: () => "click", button: () => "click", form: () => "submit", details: () => "toggle", input: (e) => (e.getAttribute("type") == "submit" ? "click" : "input"), select: () => "change", textarea: () => "input", } export function getDefaultEventNameForElement(element: Element): string | undefined { const tagName = element.tagName.toLowerCase() if (tagName in defaultEventNames) { return defaultEventNames[tagName](element) } } function error(message: string): never { throw new Error(message) } function typecast(value: any): any { try { return JSON.parse(value) } catch (o_O) { return value } } ================================================ FILE: src/core/action_descriptor.ts ================================================ import type { Controller } from "./controller" export type ActionDescriptorFilters = Record export type ActionDescriptorFilter = (options: ActionDescriptorFilterOptions) => boolean type ActionDescriptorFilterOptions = { name: string value: boolean event: Event element: Element controller: Controller } export const defaultActionDescriptorFilters: ActionDescriptorFilters = { stop({ event, value }) { if (value) event.stopPropagation() return true }, prevent({ event, value }) { if (value) event.preventDefault() return true }, self({ event, value, element }) { if (value) { return element === event.target } else { return true } }, } export interface ActionDescriptor { eventTarget: EventTarget eventOptions: AddEventListenerOptions eventName: string identifier: string methodName: string keyFilter: string } // capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6 7 7 const descriptorPattern = /^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/ export function parseActionDescriptorString(descriptorString: string): Partial { const source = descriptorString.trim() const matches = source.match(descriptorPattern) || [] let eventName = matches[2] let keyFilter = matches[3] if (keyFilter && !["keydown", "keyup", "keypress"].includes(eventName)) { eventName += `.${keyFilter}` keyFilter = "" } return { eventTarget: parseEventTarget(matches[4]), eventName, eventOptions: matches[7] ? parseEventOptions(matches[7]) : {}, identifier: matches[5], methodName: matches[6], keyFilter: matches[1] || keyFilter, } } function parseEventTarget(eventTargetName: string): EventTarget | undefined { if (eventTargetName == "window") { return window } else if (eventTargetName == "document") { return document } } function parseEventOptions(eventOptions: string): AddEventListenerOptions { return eventOptions .split(":") .reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {}) } export function stringifyEventTarget(eventTarget: EventTarget) { if (eventTarget == window) { return "window" } else if (eventTarget == document) { return "document" } } ================================================ FILE: src/core/action_event.ts ================================================ export interface ActionEvent extends Event { params: { [key: string]: any } } ================================================ FILE: src/core/application.ts ================================================ import { Controller, ControllerConstructor } from "./controller" import { Definition } from "./definition" import { Dispatcher } from "./dispatcher" import { ErrorHandler } from "./error_handler" import { Logger } from "./logger" import { Router } from "./router" import { Schema, defaultSchema } from "./schema" import { ActionDescriptorFilter, ActionDescriptorFilters, defaultActionDescriptorFilters } from "./action_descriptor" export class Application implements ErrorHandler { readonly element: Element readonly schema: Schema readonly dispatcher: Dispatcher readonly router: Router readonly actionDescriptorFilters: ActionDescriptorFilters logger: Logger = console debug = false static start(element?: Element, schema?: Schema): Application { const application = new this(element, schema) application.start() return application } constructor(element: Element = document.documentElement, schema: Schema = defaultSchema) { this.element = element this.schema = schema this.dispatcher = new Dispatcher(this) this.router = new Router(this) this.actionDescriptorFilters = { ...defaultActionDescriptorFilters } } async start() { await domReady() this.logDebugActivity("application", "starting") this.dispatcher.start() this.router.start() this.logDebugActivity("application", "start") } stop() { this.logDebugActivity("application", "stopping") this.dispatcher.stop() this.router.stop() this.logDebugActivity("application", "stop") } register(identifier: string, controllerConstructor: ControllerConstructor) { this.load({ identifier, controllerConstructor }) } registerActionOption(name: string, filter: ActionDescriptorFilter) { this.actionDescriptorFilters[name] = filter } load(...definitions: Definition[]): void load(definitions: Definition[]): void load(head: Definition | Definition[], ...rest: Definition[]) { const definitions = Array.isArray(head) ? head : [head, ...rest] definitions.forEach((definition) => { if ((definition.controllerConstructor as any).shouldLoad) { this.router.loadDefinition(definition) } }) } unload(...identifiers: string[]): void unload(identifiers: string[]): void unload(head: string | string[], ...rest: string[]) { const identifiers = Array.isArray(head) ? head : [head, ...rest] identifiers.forEach((identifier) => this.router.unloadIdentifier(identifier)) } // Controllers get controllers(): Controller[] { return this.router.contexts.map((context) => context.controller) } getControllerForElementAndIdentifier(element: Element, identifier: string): Controller | null { const context = this.router.getContextForElementAndIdentifier(element, identifier) return context ? context.controller : null } // Error handling handleError(error: Error, message: string, detail: object) { this.logger.error(`%s\n\n%o\n\n%o`, message, error, detail) window.onerror?.(message, "", 0, 0, error) } // Debug logging logDebugActivity = (identifier: string, functionName: string, detail: object = {}): void => { if (this.debug) { this.logFormattedMessage(identifier, functionName, detail) } } private logFormattedMessage(identifier: string, functionName: string, detail: object = {}) { detail = Object.assign({ application: this }, detail) this.logger.groupCollapsed(`${identifier} #${functionName}`) this.logger.log("details:", { ...detail }) this.logger.groupEnd() } } function domReady() { return new Promise((resolve) => { if (document.readyState == "loading") { document.addEventListener("DOMContentLoaded", () => resolve()) } else { resolve() } }) } ================================================ FILE: src/core/binding.ts ================================================ import { Action } from "./action" import { ActionEvent } from "./action_event" import { Context } from "./context" import { Controller } from "./controller" import { Scope } from "./scope" export class Binding { readonly context: Context readonly action: Action constructor(context: Context, action: Action) { this.context = context this.action = action } get index(): number { return this.action.index } get eventTarget(): EventTarget { return this.action.eventTarget } get eventOptions(): AddEventListenerOptions { return this.action.eventOptions } get identifier(): string { return this.context.identifier } handleEvent(event: Event) { const actionEvent = this.prepareActionEvent(event) if (this.willBeInvokedByEvent(event) && this.applyEventModifiers(actionEvent)) { this.invokeWithEvent(actionEvent) } } get eventName(): string { return this.action.eventName } get method(): Function { const method = (this.controller as any)[this.methodName] if (typeof method == "function") { return method } throw new Error(`Action "${this.action}" references undefined method "${this.methodName}"`) } private applyEventModifiers(event: Event): boolean { const { element } = this.action const { actionDescriptorFilters } = this.context.application const { controller } = this.context let passes = true for (const [name, value] of Object.entries(this.eventOptions)) { if (name in actionDescriptorFilters) { const filter = actionDescriptorFilters[name] passes = passes && filter({ name, value, event, element, controller }) } else { continue } } return passes } private prepareActionEvent(event: Event): ActionEvent { return Object.assign(event, { params: this.action.params }) } private invokeWithEvent(event: ActionEvent) { const { target, currentTarget } = event try { this.method.call(this.controller, event) this.context.logDebugActivity(this.methodName, { event, target, currentTarget, action: this.methodName }) } catch (error: any) { const { identifier, controller, element, index } = this const detail = { identifier, controller, element, index, event } this.context.handleError(error, `invoking action "${this.action}"`, detail) } } private willBeInvokedByEvent(event: Event): boolean { const eventTarget = event.target if (event instanceof KeyboardEvent && this.action.shouldIgnoreKeyboardEvent(event)) { return false } if (event instanceof MouseEvent && this.action.shouldIgnoreMouseEvent(event)) { return false } if (this.element === eventTarget) { return true } else if (eventTarget instanceof Element && this.element.contains(eventTarget)) { return this.scope.containsElement(eventTarget) } else { return this.scope.containsElement(this.action.element) } } private get controller(): Controller { return this.context.controller } private get methodName(): string { return this.action.methodName } private get element(): Element { return this.scope.element } private get scope(): Scope { return this.context.scope } } ================================================ FILE: src/core/binding_observer.ts ================================================ import { Action } from "./action" import { Binding } from "./binding" import { Context } from "./context" import { ErrorHandler } from "./error_handler" import { Schema } from "./schema" import { Token, ValueListObserver, ValueListObserverDelegate } from "../mutation-observers" export interface BindingObserverDelegate extends ErrorHandler { bindingConnected(binding: Binding): void bindingDisconnected(binding: Binding, clearEventListeners?: boolean): void } export class BindingObserver implements ValueListObserverDelegate { readonly context: Context private delegate: BindingObserverDelegate private valueListObserver?: ValueListObserver private bindingsByAction: Map constructor(context: Context, delegate: BindingObserverDelegate) { this.context = context this.delegate = delegate this.bindingsByAction = new Map() } start() { if (!this.valueListObserver) { this.valueListObserver = new ValueListObserver(this.element, this.actionAttribute, this) this.valueListObserver.start() } } stop() { if (this.valueListObserver) { this.valueListObserver.stop() delete this.valueListObserver this.disconnectAllActions() } } get element() { return this.context.element } get identifier() { return this.context.identifier } get actionAttribute() { return this.schema.actionAttribute } get schema(): Schema { return this.context.schema } get bindings(): Binding[] { return Array.from(this.bindingsByAction.values()) } private connectAction(action: Action) { const binding = new Binding(this.context, action) this.bindingsByAction.set(action, binding) this.delegate.bindingConnected(binding) } private disconnectAction(action: Action) { const binding = this.bindingsByAction.get(action) if (binding) { this.bindingsByAction.delete(action) this.delegate.bindingDisconnected(binding) } } private disconnectAllActions() { this.bindings.forEach((binding) => this.delegate.bindingDisconnected(binding, true)) this.bindingsByAction.clear() } // Value observer delegate parseValueForToken(token: Token): Action | undefined { const action = Action.forToken(token, this.schema) if (action.identifier == this.identifier) { return action } } elementMatchedValue(element: Element, action: Action) { this.connectAction(action) } elementUnmatchedValue(element: Element, action: Action) { this.disconnectAction(action) } } ================================================ FILE: src/core/blessing.ts ================================================ import { Constructor } from "./constructor" import { readInheritableStaticArrayValues } from "./inheritable_statics" export type Blessing = (constructor: Constructor) => PropertyDescriptorMap export interface Blessable extends Constructor { readonly blessings?: Blessing[] } export function bless(constructor: Blessable): Constructor { return shadow(constructor, getBlessedProperties(constructor)) } function shadow(constructor: Constructor, properties: PropertyDescriptorMap) { const shadowConstructor = extend(constructor) const shadowProperties = getShadowProperties(constructor.prototype, properties) Object.defineProperties(shadowConstructor.prototype, shadowProperties) return shadowConstructor } function getBlessedProperties(constructor: Constructor) { const blessings = readInheritableStaticArrayValues(constructor, "blessings") as Blessing[] return blessings.reduce((blessedProperties, blessing) => { const properties = blessing(constructor) for (const key in properties) { const descriptor = blessedProperties[key] || ({} as PropertyDescriptor) blessedProperties[key] = Object.assign(descriptor, properties[key]) } return blessedProperties }, {} as PropertyDescriptorMap) } function getShadowProperties(prototype: any, properties: PropertyDescriptorMap) { return getOwnKeys(properties).reduce((shadowProperties, key) => { const descriptor = getShadowedDescriptor(prototype, properties, key) if (descriptor) { Object.assign(shadowProperties, { [key]: descriptor }) } return shadowProperties }, {} as PropertyDescriptorMap) } function getShadowedDescriptor(prototype: any, properties: PropertyDescriptorMap, key: string | symbol) { const shadowingDescriptor = Object.getOwnPropertyDescriptor(prototype, key) const shadowedByValue = shadowingDescriptor && "value" in shadowingDescriptor if (!shadowedByValue) { const descriptor = Object.getOwnPropertyDescriptor(properties, key)!.value if (shadowingDescriptor) { descriptor.get = shadowingDescriptor.get || descriptor.get descriptor.set = shadowingDescriptor.set || descriptor.set } return descriptor } } const getOwnKeys = (() => { if (typeof Object.getOwnPropertySymbols == "function") { return (object: any) => [...Object.getOwnPropertyNames(object), ...Object.getOwnPropertySymbols(object)] } else { return Object.getOwnPropertyNames } })() const extend = (() => { function extendWithReflect>(constructor: T): T { function extended() { return Reflect.construct(constructor, arguments, new.target) } extended.prototype = Object.create(constructor.prototype, { constructor: { value: extended }, }) Reflect.setPrototypeOf(extended, constructor) return extended as any } function testReflectExtension() { const a = function (this: any) { this.a.call(this) } as any const b = extendWithReflect(a) b.prototype.a = function () {} return new b() } try { testReflectExtension() return extendWithReflect } catch (error: any) { return >(constructor: T) => class extended extends constructor {} } })() ================================================ FILE: src/core/class_map.ts ================================================ import { Scope } from "./scope" import { tokenize } from "./string_helpers" export class ClassMap { readonly scope: Scope constructor(scope: Scope) { this.scope = scope } has(name: string) { return this.data.has(this.getDataKey(name)) } get(name: string): string | undefined { return this.getAll(name)[0] } getAll(name: string) { const tokenString = this.data.get(this.getDataKey(name)) || "" return tokenize(tokenString) } getAttributeName(name: string) { return this.data.getAttributeNameForKey(this.getDataKey(name)) } getDataKey(name: string) { return `${name}-class` } get data() { return this.scope.data } } ================================================ FILE: src/core/class_properties.ts ================================================ import { Constructor } from "./constructor" import { Controller } from "./controller" import { readInheritableStaticArrayValues } from "./inheritable_statics" import { capitalize } from "./string_helpers" export function ClassPropertiesBlessing(constructor: Constructor) { const classes = readInheritableStaticArrayValues(constructor, "classes") return classes.reduce((properties, classDefinition) => { return Object.assign(properties, propertiesForClassDefinition(classDefinition)) }, {} as PropertyDescriptorMap) } function propertiesForClassDefinition(key: string) { return { [`${key}Class`]: { get(this: Controller) { const { classes } = this if (classes.has(key)) { return classes.get(key) } else { const attribute = classes.getAttributeName(key) throw new Error(`Missing attribute "${attribute}"`) } }, }, [`${key}Classes`]: { get(this: Controller) { return this.classes.getAll(key) }, }, [`has${capitalize(key)}Class`]: { get(this: Controller) { return this.classes.has(key) }, }, } } ================================================ FILE: src/core/constructor.ts ================================================ export type Constructor = new (...args: any[]) => T ================================================ FILE: src/core/context.ts ================================================ import { Application } from "./application" import { BindingObserver } from "./binding_observer" import { Controller } from "./controller" import { Dispatcher } from "./dispatcher" import { ErrorHandler } from "./error_handler" import { Module } from "./module" import { Schema } from "./schema" import { Scope } from "./scope" import { ValueObserver } from "./value_observer" import { TargetObserver, TargetObserverDelegate } from "./target_observer" import { OutletObserver, OutletObserverDelegate } from "./outlet_observer" import { namespaceCamelize } from "./string_helpers" export class Context implements ErrorHandler, TargetObserverDelegate, OutletObserverDelegate { readonly module: Module readonly scope: Scope readonly controller: Controller private bindingObserver: BindingObserver private valueObserver: ValueObserver private targetObserver: TargetObserver private outletObserver: OutletObserver constructor(module: Module, scope: Scope) { this.module = module this.scope = scope this.controller = new module.controllerConstructor(this) this.bindingObserver = new BindingObserver(this, this.dispatcher) this.valueObserver = new ValueObserver(this, this.controller) this.targetObserver = new TargetObserver(this, this) this.outletObserver = new OutletObserver(this, this) try { this.controller.initialize() this.logDebugActivity("initialize") } catch (error: any) { this.handleError(error, "initializing controller") } } connect() { this.bindingObserver.start() this.valueObserver.start() this.targetObserver.start() this.outletObserver.start() try { this.controller.connect() this.logDebugActivity("connect") } catch (error: any) { this.handleError(error, "connecting controller") } } refresh() { this.outletObserver.refresh() } disconnect() { try { this.controller.disconnect() this.logDebugActivity("disconnect") } catch (error: any) { this.handleError(error, "disconnecting controller") } this.outletObserver.stop() this.targetObserver.stop() this.valueObserver.stop() this.bindingObserver.stop() } get application(): Application { return this.module.application } get identifier(): string { return this.module.identifier } get schema(): Schema { return this.application.schema } get dispatcher(): Dispatcher { return this.application.dispatcher } get element(): Element { return this.scope.element } get parentElement(): Element | null { return this.element.parentElement } // Error handling handleError(error: Error, message: string, detail: object = {}) { const { identifier, controller, element } = this detail = Object.assign({ identifier, controller, element }, detail) this.application.handleError(error, `Error ${message}`, detail) } // Debug logging logDebugActivity = (functionName: string, detail: object = {}): void => { const { identifier, controller, element } = this detail = Object.assign({ identifier, controller, element }, detail) this.application.logDebugActivity(this.identifier, functionName, detail) } // Target observer delegate targetConnected(element: Element, name: string) { this.invokeControllerMethod(`${name}TargetConnected`, element) } targetDisconnected(element: Element, name: string) { this.invokeControllerMethod(`${name}TargetDisconnected`, element) } // Outlet observer delegate outletConnected(outlet: Controller, element: Element, name: string) { this.invokeControllerMethod(`${namespaceCamelize(name)}OutletConnected`, outlet, element) } outletDisconnected(outlet: Controller, element: Element, name: string) { this.invokeControllerMethod(`${namespaceCamelize(name)}OutletDisconnected`, outlet, element) } // Private invokeControllerMethod(methodName: string, ...args: any[]) { const controller: any = this.controller if (typeof controller[methodName] == "function") { controller[methodName](...args) } } } ================================================ FILE: src/core/controller.ts ================================================ import { Application } from "./application" import { ClassPropertiesBlessing } from "./class_properties" import { Constructor } from "./constructor" import { Context } from "./context" import { OutletPropertiesBlessing } from "./outlet_properties" import { TargetPropertiesBlessing } from "./target_properties" import { ValuePropertiesBlessing, ValueDefinitionMap } from "./value_properties" export type ControllerConstructor = Constructor type DispatchOptions = Partial<{ target: Element | Window | Document detail: Object prefix: string bubbles: boolean cancelable: boolean }> export class Controller { static blessings = [ ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing, OutletPropertiesBlessing, ] static targets: string[] = [] static outlets: string[] = [] static values: ValueDefinitionMap = {} static get shouldLoad() { return true } static afterLoad(_identifier: string, _application: Application) { return } readonly context: Context constructor(context: Context) { this.context = context } get application() { return this.context.application } get scope() { return this.context.scope } get element() { return this.scope.element as ElementType } get identifier() { return this.scope.identifier } get targets() { return this.scope.targets } get outlets() { return this.scope.outlets } get classes() { return this.scope.classes } get data() { return this.scope.data } initialize() { // Override in your subclass to set up initial controller state } connect() { // Override in your subclass to respond when the controller is connected to the DOM } disconnect() { // Override in your subclass to respond when the controller is disconnected from the DOM } dispatch( eventName: string, { target = this.element, detail = {}, prefix = this.identifier, bubbles = true, cancelable = true, }: DispatchOptions = {} ) { const type = prefix ? `${prefix}:${eventName}` : eventName const event = new CustomEvent(type, { detail, bubbles, cancelable }) target.dispatchEvent(event) return event } } ================================================ FILE: src/core/data_map.ts ================================================ import { Scope } from "./scope" import { dasherize } from "./string_helpers" export class DataMap { readonly scope: Scope constructor(scope: Scope) { this.scope = scope } get element(): Element { return this.scope.element } get identifier(): string { return this.scope.identifier } get(key: string): string | null { const name = this.getAttributeNameForKey(key) return this.element.getAttribute(name) } set(key: string, value: string): string | null { const name = this.getAttributeNameForKey(key) this.element.setAttribute(name, value) return this.get(key) } has(key: string): boolean { const name = this.getAttributeNameForKey(key) return this.element.hasAttribute(name) } delete(key: string): boolean { if (this.has(key)) { const name = this.getAttributeNameForKey(key) this.element.removeAttribute(name) return true } else { return false } } getAttributeNameForKey(key: string): string { return `data-${this.identifier}-${dasherize(key)}` } } ================================================ FILE: src/core/definition.ts ================================================ import { bless } from "./blessing" import { ControllerConstructor } from "./controller" export interface Definition { identifier: string controllerConstructor: ControllerConstructor } export function blessDefinition(definition: Definition): Definition { return { identifier: definition.identifier, controllerConstructor: bless(definition.controllerConstructor), } } ================================================ FILE: src/core/dispatcher.ts ================================================ import { Application } from "./application" import { Binding } from "./binding" import { BindingObserverDelegate } from "./binding_observer" import { EventListener } from "./event_listener" export class Dispatcher implements BindingObserverDelegate { readonly application: Application private eventListenerMaps: Map> private started: boolean constructor(application: Application) { this.application = application this.eventListenerMaps = new Map() this.started = false } start() { if (!this.started) { this.started = true this.eventListeners.forEach((eventListener) => eventListener.connect()) } } stop() { if (this.started) { this.started = false this.eventListeners.forEach((eventListener) => eventListener.disconnect()) } } get eventListeners(): EventListener[] { return Array.from(this.eventListenerMaps.values()).reduce( (listeners, map) => listeners.concat(Array.from(map.values())), [] as EventListener[] ) } // Binding observer delegate bindingConnected(binding: Binding) { this.fetchEventListenerForBinding(binding).bindingConnected(binding) } bindingDisconnected(binding: Binding, clearEventListeners = false) { this.fetchEventListenerForBinding(binding).bindingDisconnected(binding) if (clearEventListeners) this.clearEventListenersForBinding(binding) } // Error handling handleError(error: Error, message: string, detail: object = {}) { this.application.handleError(error, `Error ${message}`, detail) } private clearEventListenersForBinding(binding: Binding) { const eventListener = this.fetchEventListenerForBinding(binding) if (!eventListener.hasBindings()) { eventListener.disconnect() this.removeMappedEventListenerFor(binding) } } private removeMappedEventListenerFor(binding: Binding) { const { eventTarget, eventName, eventOptions } = binding const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget) const cacheKey = this.cacheKey(eventName, eventOptions) eventListenerMap.delete(cacheKey) if (eventListenerMap.size == 0) this.eventListenerMaps.delete(eventTarget) } private fetchEventListenerForBinding(binding: Binding): EventListener { const { eventTarget, eventName, eventOptions } = binding return this.fetchEventListener(eventTarget, eventName, eventOptions) } private fetchEventListener( eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions ): EventListener { const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget) const cacheKey = this.cacheKey(eventName, eventOptions) let eventListener = eventListenerMap.get(cacheKey) if (!eventListener) { eventListener = this.createEventListener(eventTarget, eventName, eventOptions) eventListenerMap.set(cacheKey, eventListener) } return eventListener } private createEventListener( eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions ): EventListener { const eventListener = new EventListener(eventTarget, eventName, eventOptions) if (this.started) { eventListener.connect() } return eventListener } private fetchEventListenerMapForEventTarget(eventTarget: EventTarget): Map { let eventListenerMap = this.eventListenerMaps.get(eventTarget) if (!eventListenerMap) { eventListenerMap = new Map() this.eventListenerMaps.set(eventTarget, eventListenerMap) } return eventListenerMap } private cacheKey(eventName: string, eventOptions: any): string { const parts = [eventName] Object.keys(eventOptions) .sort() .forEach((key) => { parts.push(`${eventOptions[key] ? "" : "!"}${key}`) }) return parts.join(":") } } ================================================ FILE: src/core/error_handler.ts ================================================ export interface ErrorHandler { handleError(error: Error, message: string, detail: object): void } ================================================ FILE: src/core/event_listener.ts ================================================ import { Binding } from "./binding" export class EventListener implements EventListenerObject { readonly eventTarget: EventTarget readonly eventName: string readonly eventOptions: AddEventListenerOptions private unorderedBindings: Set constructor(eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions) { this.eventTarget = eventTarget this.eventName = eventName this.eventOptions = eventOptions this.unorderedBindings = new Set() } connect() { this.eventTarget.addEventListener(this.eventName, this, this.eventOptions) } disconnect() { this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions) } // Binding observer delegate bindingConnected(binding: Binding) { this.unorderedBindings.add(binding) } bindingDisconnected(binding: Binding) { this.unorderedBindings.delete(binding) } handleEvent(event: Event) { // FIXME: Determine why TS won't recognize that the extended event has immediatePropagationStopped const extendedEvent = extendEvent(event) as any for (const binding of this.bindings) { if (extendedEvent.immediatePropagationStopped) { break } else { binding.handleEvent(extendedEvent) } } } hasBindings() { return this.unorderedBindings.size > 0 } get bindings(): Binding[] { return Array.from(this.unorderedBindings).sort((left, right) => { const leftIndex = left.index, rightIndex = right.index return leftIndex < rightIndex ? -1 : leftIndex > rightIndex ? 1 : 0 }) } } function extendEvent(event: Event) { if ("immediatePropagationStopped" in event) { return event } else { const { stopImmediatePropagation } = event return Object.assign(event, { immediatePropagationStopped: false, stopImmediatePropagation() { this.immediatePropagationStopped = true stopImmediatePropagation.call(this) }, }) } } ================================================ FILE: src/core/guide.ts ================================================ import { Logger } from "./logger" export class Guide { readonly logger: Logger readonly warnedKeysByObject: WeakMap> = new WeakMap() constructor(logger: Logger) { this.logger = logger } warn(object: any, key: string, message: string) { let warnedKeys: Set | undefined = this.warnedKeysByObject.get(object) if (!warnedKeys) { warnedKeys = new Set() this.warnedKeysByObject.set(object, warnedKeys) } if (!warnedKeys.has(key)) { warnedKeys.add(key) this.logger.warn(message, object) } } } ================================================ FILE: src/core/index.ts ================================================ export { ActionEvent } from "./action_event" export { Application } from "./application" export { Context } from "./context" export { Controller, ControllerConstructor } from "./controller" export { Definition } from "./definition" export { Schema, defaultSchema } from "./schema" ================================================ FILE: src/core/inheritable_statics.ts ================================================ import { Constructor } from "./constructor" export function readInheritableStaticArrayValues(constructor: Constructor, propertyName: string) { const ancestors = getAncestorsForConstructor(constructor) return Array.from( ancestors.reduce((values, constructor) => { getOwnStaticArrayValues(constructor, propertyName).forEach((name) => values.add(name)) return values }, new Set() as Set) ) } export function readInheritableStaticObjectPairs(constructor: Constructor, propertyName: string) { const ancestors = getAncestorsForConstructor(constructor) return ancestors.reduce((pairs, constructor) => { pairs.push(...(getOwnStaticObjectPairs(constructor, propertyName) as any)) return pairs }, [] as [string, U][]) } function getAncestorsForConstructor(constructor: Constructor) { const ancestors: Constructor[] = [] while (constructor) { ancestors.push(constructor) constructor = Object.getPrototypeOf(constructor) } return ancestors.reverse() } function getOwnStaticArrayValues(constructor: Constructor, propertyName: string) { const definition = (constructor as any)[propertyName] return Array.isArray(definition) ? definition : [] } function getOwnStaticObjectPairs(constructor: Constructor, propertyName: string) { const definition = (constructor as any)[propertyName] return definition ? Object.keys(definition).map((key) => [key, definition[key]] as [string, U]) : [] } ================================================ FILE: src/core/logger.ts ================================================ export interface Logger { log(message: string, ...args: any[]): void warn(message: string, ...args: any[]): void error(message: string, ...args: any[]): void groupCollapsed(groupTitle?: string, ...args: any[]): void groupEnd(): void } ================================================ FILE: src/core/module.ts ================================================ import { Application } from "./application" import { Context } from "./context" import { ControllerConstructor } from "./controller" import { Definition, blessDefinition } from "./definition" import { Scope } from "./scope" export class Module { readonly application: Application readonly definition: Definition private contextsByScope: WeakMap private connectedContexts: Set constructor(application: Application, definition: Definition) { this.application = application this.definition = blessDefinition(definition) this.contextsByScope = new WeakMap() this.connectedContexts = new Set() } get identifier(): string { return this.definition.identifier } get controllerConstructor(): ControllerConstructor { return this.definition.controllerConstructor } get contexts(): Context[] { return Array.from(this.connectedContexts) } connectContextForScope(scope: Scope) { const context = this.fetchContextForScope(scope) this.connectedContexts.add(context) context.connect() } disconnectContextForScope(scope: Scope) { const context = this.contextsByScope.get(scope) if (context) { this.connectedContexts.delete(context) context.disconnect() } } private fetchContextForScope(scope: Scope): Context { let context = this.contextsByScope.get(scope) if (!context) { context = new Context(this, scope) this.contextsByScope.set(scope, context) } return context } } ================================================ FILE: src/core/outlet_observer.ts ================================================ import { Multimap } from "../multimap" import { AttributeObserver, AttributeObserverDelegate } from "../mutation-observers" import { SelectorObserver, SelectorObserverDelegate } from "../mutation-observers" import { Context } from "./context" import { Controller } from "./controller" import { readInheritableStaticArrayValues } from "./inheritable_statics" type OutletObserverDetails = { outletName: string } export interface OutletObserverDelegate { outletConnected(outlet: Controller, element: Element, outletName: string): void outletDisconnected(outlet: Controller, element: Element, outletName: string): void } export class OutletObserver implements AttributeObserverDelegate, SelectorObserverDelegate { started: boolean readonly context: Context readonly delegate: OutletObserverDelegate readonly outletsByName: Multimap readonly outletElementsByName: Multimap private selectorObserverMap: Map private attributeObserverMap: Map constructor(context: Context, delegate: OutletObserverDelegate) { this.started = false this.context = context this.delegate = delegate this.outletsByName = new Multimap() this.outletElementsByName = new Multimap() this.selectorObserverMap = new Map() this.attributeObserverMap = new Map() } start() { if (!this.started) { this.outletDefinitions.forEach((outletName) => { this.setupSelectorObserverForOutlet(outletName) this.setupAttributeObserverForOutlet(outletName) }) this.started = true this.dependentContexts.forEach((context) => context.refresh()) } } refresh() { this.selectorObserverMap.forEach((observer) => observer.refresh()) this.attributeObserverMap.forEach((observer) => observer.refresh()) } stop() { if (this.started) { this.started = false this.disconnectAllOutlets() this.stopSelectorObservers() this.stopAttributeObservers() } } stopSelectorObservers() { if (this.selectorObserverMap.size > 0) { this.selectorObserverMap.forEach((observer) => observer.stop()) this.selectorObserverMap.clear() } } stopAttributeObservers() { if (this.attributeObserverMap.size > 0) { this.attributeObserverMap.forEach((observer) => observer.stop()) this.attributeObserverMap.clear() } } // Selector observer delegate selectorMatched(element: Element, _selector: string, { outletName }: OutletObserverDetails) { const outlet = this.getOutlet(element, outletName) if (outlet) { this.connectOutlet(outlet, element, outletName) } } selectorUnmatched(element: Element, _selector: string, { outletName }: OutletObserverDetails) { const outlet = this.getOutletFromMap(element, outletName) if (outlet) { this.disconnectOutlet(outlet, element, outletName) } } selectorMatchElement(element: Element, { outletName }: OutletObserverDetails) { const selector = this.selector(outletName) const hasOutlet = this.hasOutlet(element, outletName) const hasOutletController = element.matches(`[${this.schema.controllerAttribute}~=${outletName}]`) if (selector) { return hasOutlet && hasOutletController && element.matches(selector) } else { return false } } // Attribute observer delegate elementMatchedAttribute(_element: Element, attributeName: string) { const outletName = this.getOutletNameFromOutletAttributeName(attributeName) if (outletName) { this.updateSelectorObserverForOutlet(outletName) } } elementAttributeValueChanged(_element: Element, attributeName: string) { const outletName = this.getOutletNameFromOutletAttributeName(attributeName) if (outletName) { this.updateSelectorObserverForOutlet(outletName) } } elementUnmatchedAttribute(_element: Element, attributeName: string) { const outletName = this.getOutletNameFromOutletAttributeName(attributeName) if (outletName) { this.updateSelectorObserverForOutlet(outletName) } } // Outlet management connectOutlet(outlet: Controller, element: Element, outletName: string) { if (!this.outletElementsByName.has(outletName, element)) { this.outletsByName.add(outletName, outlet) this.outletElementsByName.add(outletName, element) this.selectorObserverMap.get(outletName)?.pause(() => this.delegate.outletConnected(outlet, element, outletName)) } } disconnectOutlet(outlet: Controller, element: Element, outletName: string) { if (this.outletElementsByName.has(outletName, element)) { this.outletsByName.delete(outletName, outlet) this.outletElementsByName.delete(outletName, element) this.selectorObserverMap .get(outletName) ?.pause(() => this.delegate.outletDisconnected(outlet, element, outletName)) } } disconnectAllOutlets() { for (const outletName of this.outletElementsByName.keys) { for (const element of this.outletElementsByName.getValuesForKey(outletName)) { for (const outlet of this.outletsByName.getValuesForKey(outletName)) { this.disconnectOutlet(outlet, element, outletName) } } } } // Observer management private updateSelectorObserverForOutlet(outletName: string) { const observer = this.selectorObserverMap.get(outletName) if (observer) { observer.selector = this.selector(outletName) } } private setupSelectorObserverForOutlet(outletName: string) { const selector = this.selector(outletName) const selectorObserver = new SelectorObserver(document.body, selector!, this, { outletName }) this.selectorObserverMap.set(outletName, selectorObserver) selectorObserver.start() } private setupAttributeObserverForOutlet(outletName: string) { const attributeName = this.attributeNameForOutletName(outletName) const attributeObserver = new AttributeObserver(this.scope.element, attributeName, this) this.attributeObserverMap.set(outletName, attributeObserver) attributeObserver.start() } // Private private selector(outletName: string) { return this.scope.outlets.getSelectorForOutletName(outletName) } private attributeNameForOutletName(outletName: string) { return this.scope.schema.outletAttributeForScope(this.identifier, outletName) } private getOutletNameFromOutletAttributeName(attributeName: string) { return this.outletDefinitions.find((outletName) => this.attributeNameForOutletName(outletName) === attributeName) } private get outletDependencies() { const dependencies = new Multimap() this.router.modules.forEach((module) => { const constructor = module.definition.controllerConstructor const outlets = readInheritableStaticArrayValues(constructor, "outlets") outlets.forEach((outlet) => dependencies.add(outlet, module.identifier)) }) return dependencies } private get outletDefinitions() { return this.outletDependencies.getKeysForValue(this.identifier) } private get dependentControllerIdentifiers() { return this.outletDependencies.getValuesForKey(this.identifier) } private get dependentContexts() { const identifiers = this.dependentControllerIdentifiers return this.router.contexts.filter((context) => identifiers.includes(context.identifier)) } private hasOutlet(element: Element, outletName: string) { return !!this.getOutlet(element, outletName) || !!this.getOutletFromMap(element, outletName) } private getOutlet(element: Element, outletName: string) { return this.application.getControllerForElementAndIdentifier(element, outletName) } private getOutletFromMap(element: Element, outletName: string) { return this.outletsByName.getValuesForKey(outletName).find((outlet) => outlet.element === element) } private get scope() { return this.context.scope } private get schema() { return this.context.schema } private get identifier() { return this.context.identifier } private get application() { return this.context.application } private get router() { return this.application.router } } ================================================ FILE: src/core/outlet_properties.ts ================================================ import { Constructor } from "./constructor" import { Controller } from "./controller" import { readInheritableStaticArrayValues } from "./inheritable_statics" import { capitalize, namespaceCamelize } from "./string_helpers" export function OutletPropertiesBlessing(constructor: Constructor) { const outlets = readInheritableStaticArrayValues(constructor, "outlets") return outlets.reduce((properties: any, outletDefinition: any) => { return Object.assign(properties, propertiesForOutletDefinition(outletDefinition)) }, {} as PropertyDescriptorMap) } function getOutletController(controller: Controller, element: Element, identifier: string) { return controller.application.getControllerForElementAndIdentifier(element, identifier) } function getControllerAndEnsureConnectedScope(controller: Controller, element: Element, outletName: string) { let outletController = getOutletController(controller, element, outletName) if (outletController) return outletController controller.application.router.proposeToConnectScopeForElementAndIdentifier(element, outletName) outletController = getOutletController(controller, element, outletName) if (outletController) return outletController } function propertiesForOutletDefinition(name: string) { const camelizedName = namespaceCamelize(name) return { [`${camelizedName}Outlet`]: { get(this: Controller) { const outletElement = this.outlets.find(name) const selector = this.outlets.getSelectorForOutletName(name) if (outletElement) { const outletController = getControllerAndEnsureConnectedScope(this, outletElement, name) if (outletController) return outletController throw new Error( `The provided outlet element is missing an outlet controller "${name}" instance for host controller "${this.identifier}"` ) } throw new Error( `Missing outlet element "${name}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${selector}".` ) }, }, [`${camelizedName}Outlets`]: { get(this: Controller) { const outlets = this.outlets.findAll(name) if (outlets.length > 0) { return outlets .map((outletElement: Element) => { const outletController = getControllerAndEnsureConnectedScope(this, outletElement, name) if (outletController) return outletController console.warn( `The provided outlet element is missing an outlet controller "${name}" instance for host controller "${this.identifier}"`, outletElement ) }) .filter((controller) => controller) as Controller[] } return [] }, }, [`${camelizedName}OutletElement`]: { get(this: Controller) { const outletElement = this.outlets.find(name) const selector = this.outlets.getSelectorForOutletName(name) if (outletElement) { return outletElement } else { throw new Error( `Missing outlet element "${name}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${selector}".` ) } }, }, [`${camelizedName}OutletElements`]: { get(this: Controller) { return this.outlets.findAll(name) }, }, [`has${capitalize(camelizedName)}Outlet`]: { get(this: Controller) { return this.outlets.has(name) }, }, } } ================================================ FILE: src/core/outlet_set.ts ================================================ import { Scope } from "./scope" export class OutletSet { readonly scope: Scope readonly controllerElement: Element constructor(scope: Scope, controllerElement: Element) { this.scope = scope this.controllerElement = controllerElement } get element() { return this.scope.element } get identifier() { return this.scope.identifier } get schema() { return this.scope.schema } has(outletName: string) { return this.find(outletName) != null } find(...outletNames: string[]) { return outletNames.reduce( (outlet, outletName) => outlet || this.findOutlet(outletName), undefined as Element | undefined ) } findAll(...outletNames: string[]) { return outletNames.reduce( (outlets, outletName) => [...outlets, ...this.findAllOutlets(outletName)], [] as Element[] ) } getSelectorForOutletName(outletName: string) { const attributeName = this.schema.outletAttributeForScope(this.identifier, outletName) return this.controllerElement.getAttribute(attributeName) } private findOutlet(outletName: string) { const selector = this.getSelectorForOutletName(outletName) if (selector) return this.findElement(selector, outletName) } private findAllOutlets(outletName: string) { const selector = this.getSelectorForOutletName(outletName) return selector ? this.findAllElements(selector, outletName) : [] } private findElement(selector: string, outletName: string): Element | undefined { const elements = this.scope.queryElements(selector) return elements.filter((element) => this.matchesElement(element, selector, outletName))[0] } private findAllElements(selector: string, outletName: string): Element[] { const elements = this.scope.queryElements(selector) return elements.filter((element) => this.matchesElement(element, selector, outletName)) } private matchesElement(element: Element, selector: string, outletName: string): boolean { const controllerAttribute = element.getAttribute(this.scope.schema.controllerAttribute) || "" return element.matches(selector) && controllerAttribute.split(" ").includes(outletName) } } ================================================ FILE: src/core/router.ts ================================================ import { Application } from "./application" import { Context } from "./context" import { Definition } from "./definition" import { Module } from "./module" import { Multimap } from "../multimap" import { Scope } from "./scope" import { ScopeObserver, ScopeObserverDelegate } from "./scope_observer" export class Router implements ScopeObserverDelegate { readonly application: Application private scopeObserver: ScopeObserver private scopesByIdentifier: Multimap private modulesByIdentifier: Map constructor(application: Application) { this.application = application this.scopeObserver = new ScopeObserver(this.element, this.schema, this) this.scopesByIdentifier = new Multimap() this.modulesByIdentifier = new Map() } get element() { return this.application.element } get schema() { return this.application.schema } get logger() { return this.application.logger } get controllerAttribute(): string { return this.schema.controllerAttribute } get modules() { return Array.from(this.modulesByIdentifier.values()) } get contexts() { return this.modules.reduce((contexts, module) => contexts.concat(module.contexts), [] as Context[]) } start() { this.scopeObserver.start() } stop() { this.scopeObserver.stop() } loadDefinition(definition: Definition) { this.unloadIdentifier(definition.identifier) const module = new Module(this.application, definition) this.connectModule(module) const afterLoad = (definition.controllerConstructor as any).afterLoad if (afterLoad) { afterLoad.call(definition.controllerConstructor, definition.identifier, this.application) } } unloadIdentifier(identifier: string) { const module = this.modulesByIdentifier.get(identifier) if (module) { this.disconnectModule(module) } } getContextForElementAndIdentifier(element: Element, identifier: string) { const module = this.modulesByIdentifier.get(identifier) if (module) { return module.contexts.find((context) => context.element == element) } } proposeToConnectScopeForElementAndIdentifier(element: Element, identifier: string) { const scope = this.scopeObserver.parseValueForElementAndIdentifier(element, identifier) if (scope) { this.scopeObserver.elementMatchedValue(scope.element, scope) } else { console.error(`Couldn't find or create scope for identifier: "${identifier}" and element:`, element) } } // Error handler delegate handleError(error: Error, message: string, detail: any) { this.application.handleError(error, message, detail) } // Scope observer delegate createScopeForElementAndIdentifier(element: Element, identifier: string) { return new Scope(this.schema, element, identifier, this.logger) } scopeConnected(scope: Scope) { this.scopesByIdentifier.add(scope.identifier, scope) const module = this.modulesByIdentifier.get(scope.identifier) if (module) { module.connectContextForScope(scope) } } scopeDisconnected(scope: Scope) { this.scopesByIdentifier.delete(scope.identifier, scope) const module = this.modulesByIdentifier.get(scope.identifier) if (module) { module.disconnectContextForScope(scope) } } // Modules private connectModule(module: Module) { this.modulesByIdentifier.set(module.identifier, module) const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier) scopes.forEach((scope) => module.connectContextForScope(scope)) } private disconnectModule(module: Module) { this.modulesByIdentifier.delete(module.identifier) const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier) scopes.forEach((scope) => module.disconnectContextForScope(scope)) } } ================================================ FILE: src/core/schema.ts ================================================ export interface Schema { controllerAttribute: string actionAttribute: string targetAttribute: string targetAttributeForScope(identifier: string): string outletAttributeForScope(identifier: string, outlet: string): string keyMappings: { [key: string]: string } } export const defaultSchema: Schema = { controllerAttribute: "data-controller", actionAttribute: "data-action", targetAttribute: "data-target", targetAttributeForScope: (identifier) => `data-${identifier}-target`, outletAttributeForScope: (identifier, outlet) => `data-${identifier}-${outlet}-outlet`, keyMappings: { enter: "Enter", tab: "Tab", esc: "Escape", space: " ", up: "ArrowUp", down: "ArrowDown", left: "ArrowLeft", right: "ArrowRight", home: "Home", end: "End", page_up: "PageUp", page_down: "PageDown", // [a-z] ...objectFromEntries("abcdefghijklmnopqrstuvwxyz".split("").map((c) => [c, c])), // [0-9] ...objectFromEntries("0123456789".split("").map((n) => [n, n])), }, } function objectFromEntries(array: [string, any][]): object { // polyfill return array.reduce((memo, [k, v]) => ({ ...memo, [k]: v }), {}) } ================================================ FILE: src/core/scope.ts ================================================ import { ClassMap } from "./class_map" import { DataMap } from "./data_map" import { Guide } from "./guide" import { Logger } from "./logger" import { Schema } from "./schema" import { attributeValueContainsToken } from "./selectors" import { TargetSet } from "./target_set" import { OutletSet } from "./outlet_set" export class Scope { readonly schema: Schema readonly element: Element readonly identifier: string readonly guide: Guide readonly outlets: OutletSet readonly targets = new TargetSet(this) readonly classes = new ClassMap(this) readonly data = new DataMap(this) constructor(schema: Schema, element: Element, identifier: string, logger: Logger) { this.schema = schema this.element = element this.identifier = identifier this.guide = new Guide(logger) this.outlets = new OutletSet(this.documentScope, element) } findElement(selector: string): Element | undefined { return this.element.matches(selector) ? this.element : this.queryElements(selector).find(this.containsElement) } findAllElements(selector: string): Element[] { return [ ...(this.element.matches(selector) ? [this.element] : []), ...this.queryElements(selector).filter(this.containsElement), ] } containsElement = (element: Element): boolean => { return element.closest(this.controllerSelector) === this.element } queryElements(selector: string): Element[] { return Array.from(this.element.querySelectorAll(selector)) } private get controllerSelector(): string { return attributeValueContainsToken(this.schema.controllerAttribute, this.identifier) } private get isDocumentScope() { return this.element === document.documentElement } private get documentScope(): Scope { return this.isDocumentScope ? this : new Scope(this.schema, document.documentElement, this.identifier, this.guide.logger) } } ================================================ FILE: src/core/scope_observer.ts ================================================ import { ErrorHandler } from "./error_handler" import { Schema } from "./schema" import { Scope } from "./scope" import { Token, ValueListObserver, ValueListObserverDelegate } from "../mutation-observers" export interface ScopeObserverDelegate extends ErrorHandler { createScopeForElementAndIdentifier(element: Element, identifier: string): Scope scopeConnected(scope: Scope): void scopeDisconnected(scope: Scope): void } export class ScopeObserver implements ValueListObserverDelegate { readonly element: Element readonly schema: Schema private delegate: ScopeObserverDelegate private valueListObserver: ValueListObserver private scopesByIdentifierByElement: WeakMap> private scopeReferenceCounts: WeakMap constructor(element: Element, schema: Schema, delegate: ScopeObserverDelegate) { this.element = element this.schema = schema this.delegate = delegate this.valueListObserver = new ValueListObserver(this.element, this.controllerAttribute, this) this.scopesByIdentifierByElement = new WeakMap() this.scopeReferenceCounts = new WeakMap() } start() { this.valueListObserver.start() } stop() { this.valueListObserver.stop() } get controllerAttribute() { return this.schema.controllerAttribute } // Value observer delegate parseValueForToken(token: Token): Scope | undefined { const { element, content: identifier } = token return this.parseValueForElementAndIdentifier(element, identifier) } parseValueForElementAndIdentifier(element: Element, identifier: string): Scope | undefined { const scopesByIdentifier = this.fetchScopesByIdentifierForElement(element) let scope = scopesByIdentifier.get(identifier) if (!scope) { scope = this.delegate.createScopeForElementAndIdentifier(element, identifier) scopesByIdentifier.set(identifier, scope) } return scope } elementMatchedValue(element: Element, value: Scope) { const referenceCount = (this.scopeReferenceCounts.get(value) || 0) + 1 this.scopeReferenceCounts.set(value, referenceCount) if (referenceCount == 1) { this.delegate.scopeConnected(value) } } elementUnmatchedValue(element: Element, value: Scope) { const referenceCount = this.scopeReferenceCounts.get(value) if (referenceCount) { this.scopeReferenceCounts.set(value, referenceCount - 1) if (referenceCount == 1) { this.delegate.scopeDisconnected(value) } } } private fetchScopesByIdentifierForElement(element: Element) { let scopesByIdentifier = this.scopesByIdentifierByElement.get(element) if (!scopesByIdentifier) { scopesByIdentifier = new Map() this.scopesByIdentifierByElement.set(element, scopesByIdentifier) } return scopesByIdentifier } } ================================================ FILE: src/core/selectors.ts ================================================ export function attributeValueContainsToken(attributeName: string, token: string) { return `[${attributeName}~="${token}"]` } ================================================ FILE: src/core/string_helpers.ts ================================================ export function camelize(value: string) { return value.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase()) } export function namespaceCamelize(value: string) { return camelize(value.replace(/--/g, "-").replace(/__/g, "_")) } export function capitalize(value: string) { return value.charAt(0).toUpperCase() + value.slice(1) } export function dasherize(value: string) { return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`) } export function tokenize(value: string) { return value.match(/[^\s]+/g) || [] } ================================================ FILE: src/core/target_observer.ts ================================================ import { Multimap } from "../multimap" import { Token, TokenListObserver, TokenListObserverDelegate } from "../mutation-observers" import { Context } from "./context" export interface TargetObserverDelegate { targetConnected(element: Element, name: string): void targetDisconnected(element: Element, name: string): void } export class TargetObserver implements TokenListObserverDelegate { readonly context: Context readonly delegate: TargetObserverDelegate readonly targetsByName: Multimap private tokenListObserver?: TokenListObserver constructor(context: Context, delegate: TargetObserverDelegate) { this.context = context this.delegate = delegate this.targetsByName = new Multimap() } start() { if (!this.tokenListObserver) { this.tokenListObserver = new TokenListObserver(this.element, this.attributeName, this) this.tokenListObserver.start() } } stop() { if (this.tokenListObserver) { this.disconnectAllTargets() this.tokenListObserver.stop() delete this.tokenListObserver } } // Token list observer delegate tokenMatched({ element, content: name }: Token) { if (this.scope.containsElement(element)) { this.connectTarget(element, name) } } tokenUnmatched({ element, content: name }: Token) { this.disconnectTarget(element, name) } // Target management connectTarget(element: Element, name: string) { if (!this.targetsByName.has(name, element)) { this.targetsByName.add(name, element) this.tokenListObserver?.pause(() => this.delegate.targetConnected(element, name)) } } disconnectTarget(element: Element, name: string) { if (this.targetsByName.has(name, element)) { this.targetsByName.delete(name, element) this.tokenListObserver?.pause(() => this.delegate.targetDisconnected(element, name)) } } disconnectAllTargets() { for (const name of this.targetsByName.keys) { for (const element of this.targetsByName.getValuesForKey(name)) { this.disconnectTarget(element, name) } } } // Private private get attributeName() { return `data-${this.context.identifier}-target` } private get element() { return this.context.element } private get scope() { return this.context.scope } } ================================================ FILE: src/core/target_properties.ts ================================================ import { Constructor } from "./constructor" import { Controller } from "./controller" import { readInheritableStaticArrayValues } from "./inheritable_statics" import { capitalize } from "./string_helpers" export function TargetPropertiesBlessing(constructor: Constructor) { const targets = readInheritableStaticArrayValues(constructor, "targets") return targets.reduce((properties, targetDefinition) => { return Object.assign(properties, propertiesForTargetDefinition(targetDefinition)) }, {} as PropertyDescriptorMap) } function propertiesForTargetDefinition(name: string) { return { [`${name}Target`]: { get(this: Controller) { const target = this.targets.find(name) if (target) { return target } else { throw new Error(`Missing target element "${name}" for "${this.identifier}" controller`) } }, }, [`${name}Targets`]: { get(this: Controller) { return this.targets.findAll(name) }, }, [`has${capitalize(name)}Target`]: { get(this: Controller) { return this.targets.has(name) }, }, } } ================================================ FILE: src/core/target_set.ts ================================================ import { Scope } from "./scope" import { attributeValueContainsToken } from "./selectors" export class TargetSet { readonly scope: Scope constructor(scope: Scope) { this.scope = scope } get element() { return this.scope.element } get identifier() { return this.scope.identifier } get schema() { return this.scope.schema } has(targetName: string) { return this.find(targetName) != null } find(...targetNames: string[]) { return targetNames.reduce( (target, targetName) => target || this.findTarget(targetName) || this.findLegacyTarget(targetName), undefined as Element | undefined ) } findAll(...targetNames: string[]) { return targetNames.reduce( (targets, targetName) => [ ...targets, ...this.findAllTargets(targetName), ...this.findAllLegacyTargets(targetName), ], [] as Element[] ) } private findTarget(targetName: string) { const selector = this.getSelectorForTargetName(targetName) return this.scope.findElement(selector) } private findAllTargets(targetName: string) { const selector = this.getSelectorForTargetName(targetName) return this.scope.findAllElements(selector) } private getSelectorForTargetName(targetName: string) { const attributeName = this.schema.targetAttributeForScope(this.identifier) return attributeValueContainsToken(attributeName, targetName) } private findLegacyTarget(targetName: string) { const selector = this.getLegacySelectorForTargetName(targetName) return this.deprecate(this.scope.findElement(selector), targetName) } private findAllLegacyTargets(targetName: string) { const selector = this.getLegacySelectorForTargetName(targetName) return this.scope.findAllElements(selector).map((element) => this.deprecate(element, targetName)) } private getLegacySelectorForTargetName(targetName: string) { const targetDescriptor = `${this.identifier}.${targetName}` return attributeValueContainsToken(this.schema.targetAttribute, targetDescriptor) } private deprecate(element: T, targetName: string) { if (element) { const { identifier } = this const attributeName = this.schema.targetAttribute const revisedAttributeName = this.schema.targetAttributeForScope(identifier) this.guide.warn( element, `target:${targetName}`, `Please replace ${attributeName}="${identifier}.${targetName}" with ${revisedAttributeName}="${targetName}". ` + `The ${attributeName} attribute is deprecated and will be removed in a future version of Stimulus.` ) } return element } private get guide() { return this.scope.guide } } ================================================ FILE: src/core/utils.ts ================================================ export function isSomething(object: any): boolean { return object !== null && object !== undefined } export function hasProperty(object: any, property: string): boolean { return Object.prototype.hasOwnProperty.call(object, property) } ================================================ FILE: src/core/value_observer.ts ================================================ import { Context } from "./context" import { StringMapObserver, StringMapObserverDelegate } from "../mutation-observers" import { ValueDescriptor } from "./value_properties" import { capitalize } from "./string_helpers" export class ValueObserver implements StringMapObserverDelegate { readonly context: Context readonly receiver: any private stringMapObserver: StringMapObserver private valueDescriptorMap: { [attributeName: string]: ValueDescriptor } constructor(context: Context, receiver: any) { this.context = context this.receiver = receiver this.stringMapObserver = new StringMapObserver(this.element, this) this.valueDescriptorMap = (this.controller as any).valueDescriptorMap } start() { this.stringMapObserver.start() this.invokeChangedCallbacksForDefaultValues() } stop() { this.stringMapObserver.stop() } get element() { return this.context.element } get controller() { return this.context.controller } // String map observer delegate getStringMapKeyForAttribute(attributeName: string) { if (attributeName in this.valueDescriptorMap) { return this.valueDescriptorMap[attributeName].name } } stringMapKeyAdded(key: string, attributeName: string) { const descriptor = this.valueDescriptorMap[attributeName] if (!this.hasValue(key)) { this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), descriptor.writer(descriptor.defaultValue)) } } stringMapValueChanged(value: string, name: string, oldValue: string) { const descriptor = this.valueDescriptorNameMap[name] if (value === null) return if (oldValue === null) { oldValue = descriptor.writer(descriptor.defaultValue) } this.invokeChangedCallback(name, value, oldValue) } stringMapKeyRemoved(key: string, attributeName: string, oldValue: string) { const descriptor = this.valueDescriptorNameMap[key] if (this.hasValue(key)) { this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), oldValue) } else { this.invokeChangedCallback(key, descriptor.writer(descriptor.defaultValue), oldValue) } } private invokeChangedCallbacksForDefaultValues() { for (const { key, name, defaultValue, writer } of this.valueDescriptors) { if (defaultValue != undefined && !this.controller.data.has(key)) { this.invokeChangedCallback(name, writer(defaultValue), undefined) } } } private invokeChangedCallback(name: string, rawValue: string, rawOldValue: string | undefined) { const changedMethodName = `${name}Changed` const changedMethod = this.receiver[changedMethodName] if (typeof changedMethod == "function") { const descriptor = this.valueDescriptorNameMap[name] try { const value = descriptor.reader(rawValue) let oldValue = rawOldValue if (rawOldValue) { oldValue = descriptor.reader(rawOldValue) } changedMethod.call(this.receiver, value, oldValue) } catch (error) { if (error instanceof TypeError) { error.message = `Stimulus Value "${this.context.identifier}.${descriptor.name}" - ${error.message}` } throw error } } } private get valueDescriptors() { const { valueDescriptorMap } = this return Object.keys(valueDescriptorMap).map((key) => valueDescriptorMap[key]) } private get valueDescriptorNameMap() { const descriptors: { [type: string]: ValueDescriptor } = {} Object.keys(this.valueDescriptorMap).forEach((key) => { const descriptor = this.valueDescriptorMap[key] descriptors[descriptor.name] = descriptor }) return descriptors } private hasValue(attributeName: string) { const descriptor = this.valueDescriptorNameMap[attributeName] const hasMethodName = `has${capitalize(descriptor.name)}` return this.receiver[hasMethodName] } } ================================================ FILE: src/core/value_properties.ts ================================================ import { Constructor } from "./constructor" import { Controller } from "./controller" import { readInheritableStaticObjectPairs } from "./inheritable_statics" import { camelize, capitalize, dasherize } from "./string_helpers" import { isSomething, hasProperty } from "./utils" export function ValuePropertiesBlessing(constructor: Constructor) { const valueDefinitionPairs = readInheritableStaticObjectPairs(constructor, "values") const propertyDescriptorMap: PropertyDescriptorMap = { valueDescriptorMap: { get(this: Controller) { return valueDefinitionPairs.reduce((result, valueDefinitionPair) => { const valueDescriptor = parseValueDefinitionPair(valueDefinitionPair, this.identifier) const attributeName = this.data.getAttributeNameForKey(valueDescriptor.key) return Object.assign(result, { [attributeName]: valueDescriptor }) }, {} as ValueDescriptorMap) }, }, } return valueDefinitionPairs.reduce((properties, valueDefinitionPair) => { return Object.assign(properties, propertiesForValueDefinitionPair(valueDefinitionPair)) }, propertyDescriptorMap) } export function propertiesForValueDefinitionPair( valueDefinitionPair: ValueDefinitionPair, controller?: string ): PropertyDescriptorMap { const definition = parseValueDefinitionPair(valueDefinitionPair, controller) const { key, name, reader: read, writer: write } = definition return { [name]: { get(this: Controller) { const value = this.data.get(key) if (value !== null) { return read(value) } else { return definition.defaultValue } }, set(this: Controller, value: T | undefined) { if (value === undefined) { this.data.delete(key) } else { this.data.set(key, write(value)) } }, }, [`has${capitalize(name)}`]: { get(this: Controller): boolean { return this.data.has(key) || definition.hasCustomDefaultValue }, }, } } export type ValueDescriptor = { type: ValueType key: string name: string defaultValue: ValueTypeDefault hasCustomDefaultValue: boolean reader: Reader writer: Writer } export type ValueDescriptorMap = { [attributeName: string]: ValueDescriptor } export type ValueDefinitionMap = { [token: string]: ValueTypeDefinition } export type ValueDefinitionPair = [string, ValueTypeDefinition] export type ValueTypeConstant = typeof Array | typeof Boolean | typeof Number | typeof Object | typeof String export type ValueTypeDefault = Array | boolean | number | Object | string export type ValueTypeObject = Partial<{ type: ValueTypeConstant; default: ValueTypeDefault }> export type ValueTypeDefinition = ValueTypeConstant | ValueTypeDefault | ValueTypeObject export type ValueType = "array" | "boolean" | "number" | "object" | "string" function parseValueDefinitionPair([token, typeDefinition]: ValueDefinitionPair, controller?: string): ValueDescriptor { return valueDescriptorForTokenAndTypeDefinition({ controller, token, typeDefinition, }) } export function parseValueTypeConstant(constant?: ValueTypeConstant) { switch (constant) { case Array: return "array" case Boolean: return "boolean" case Number: return "number" case Object: return "object" case String: return "string" } } export function parseValueTypeDefault(defaultValue?: ValueTypeDefault) { switch (typeof defaultValue) { case "boolean": return "boolean" case "number": return "number" case "string": return "string" } if (Array.isArray(defaultValue)) return "array" if (Object.prototype.toString.call(defaultValue) === "[object Object]") return "object" } type ValueTypeObjectPayload = { controller?: string token: string typeObject: ValueTypeObject } export function parseValueTypeObject(payload: ValueTypeObjectPayload) { const { controller, token, typeObject } = payload const hasType = isSomething(typeObject.type) const hasDefault = isSomething(typeObject.default) const fullObject = hasType && hasDefault const onlyType = hasType && !hasDefault const onlyDefault = !hasType && hasDefault const typeFromObject = parseValueTypeConstant(typeObject.type) const typeFromDefaultValue = parseValueTypeDefault(payload.typeObject.default) if (onlyType) return typeFromObject if (onlyDefault) return typeFromDefaultValue if (typeFromObject !== typeFromDefaultValue) { const propertyPath = controller ? `${controller}.${token}` : token throw new Error( `The specified default value for the Stimulus Value "${propertyPath}" must match the defined type "${typeFromObject}". The provided default value of "${typeObject.default}" is of type "${typeFromDefaultValue}".` ) } if (fullObject) return typeFromObject } type ValueTypeDefinitionPayload = { controller?: string token: string typeDefinition: ValueTypeDefinition } export function parseValueTypeDefinition(payload: ValueTypeDefinitionPayload): ValueType { const { controller, token, typeDefinition } = payload const typeObject = { controller, token, typeObject: typeDefinition as ValueTypeObject } const typeFromObject = parseValueTypeObject(typeObject as ValueTypeObjectPayload) const typeFromDefaultValue = parseValueTypeDefault(typeDefinition as ValueTypeDefault) const typeFromConstant = parseValueTypeConstant(typeDefinition as ValueTypeConstant) const type = typeFromObject || typeFromDefaultValue || typeFromConstant if (type) return type const propertyPath = controller ? `${controller}.${typeDefinition}` : token throw new Error(`Unknown value type "${propertyPath}" for "${token}" value`) } export function defaultValueForDefinition(typeDefinition: ValueTypeDefinition): ValueTypeDefault { const constant = parseValueTypeConstant(typeDefinition as ValueTypeConstant) if (constant) return defaultValuesByType[constant] const hasDefault = hasProperty(typeDefinition, "default") const hasType = hasProperty(typeDefinition, "type") const typeObject = typeDefinition as ValueTypeObject if (hasDefault) return typeObject.default! if (hasType) { const { type } = typeObject const constantFromType = parseValueTypeConstant(type) if (constantFromType) return defaultValuesByType[constantFromType] } return typeDefinition } function valueDescriptorForTokenAndTypeDefinition(payload: ValueTypeDefinitionPayload) { const { token, typeDefinition } = payload const key = `${dasherize(token)}-value` const type = parseValueTypeDefinition(payload) return { type, key, name: camelize(key), get defaultValue() { return defaultValueForDefinition(typeDefinition) }, get hasCustomDefaultValue() { return parseValueTypeDefault(typeDefinition) !== undefined }, reader: readers[type], writer: writers[type] || writers.default, } } const defaultValuesByType = { get array() { return [] }, boolean: false, number: 0, get object() { return {} }, string: "", } type Reader = (value: string) => any const readers: { [type: string]: Reader } = { array(value: string): any[] { const array = JSON.parse(value) if (!Array.isArray(array)) { throw new TypeError( `expected value of type "array" but instead got value "${value}" of type "${parseValueTypeDefault(array)}"` ) } return array }, boolean(value: string): boolean { return !(value == "0" || String(value).toLowerCase() == "false") }, number(value: string): number { return Number(value.replace(/_/g, "")) }, object(value: string): object { const object = JSON.parse(value) if (object === null || typeof object != "object" || Array.isArray(object)) { throw new TypeError( `expected value of type "object" but instead got value "${value}" of type "${parseValueTypeDefault(object)}"` ) } return object }, string(value: string): string { return value }, } type Writer = (value: any) => string const writers: { [type: string]: Writer } = { default: writeString, array: writeJSON, object: writeJSON, } function writeJSON(value: any) { return JSON.stringify(value) } function writeString(value: any) { return `${value}` } ================================================ FILE: src/index.d.ts ================================================ export * from "./core" export as namespace Stimulus ================================================ FILE: src/index.js ================================================ export * from "./core" export * from "./multimap" export * from "./mutation-observers" ================================================ FILE: src/index.ts ================================================ export * from "./core" export * from "./multimap" export * from "./mutation-observers" ================================================ FILE: src/multimap/index.ts ================================================ export * from "./indexed_multimap" export * from "./multimap" export * from "./set_operations" ================================================ FILE: src/multimap/indexed_multimap.ts ================================================ import { Multimap } from "./multimap" import { add, del } from "./set_operations" export class IndexedMultimap extends Multimap { private keysByValue: Map> constructor() { super() this.keysByValue = new Map() } get values(): V[] { return Array.from(this.keysByValue.keys()) } add(key: K, value: V) { super.add(key, value) add(this.keysByValue, value, key) } delete(key: K, value: V) { super.delete(key, value) del(this.keysByValue, value, key) } hasValue(value: V): boolean { return this.keysByValue.has(value) } getKeysForValue(value: V): K[] { const set = this.keysByValue.get(value) return set ? Array.from(set) : [] } } ================================================ FILE: src/multimap/multimap.ts ================================================ import { add, del } from "./set_operations" export class Multimap { private valuesByKey: Map> constructor() { this.valuesByKey = new Map>() } get keys() { return Array.from(this.valuesByKey.keys()) } get values(): V[] { const sets = Array.from(this.valuesByKey.values()) return sets.reduce((values, set) => values.concat(Array.from(set)), []) } get size(): number { const sets = Array.from(this.valuesByKey.values()) return sets.reduce((size, set) => size + set.size, 0) } add(key: K, value: V) { add(this.valuesByKey, key, value) } delete(key: K, value: V) { del(this.valuesByKey, key, value) } has(key: K, value: V): boolean { const values = this.valuesByKey.get(key) return values != null && values.has(value) } hasKey(key: K): boolean { return this.valuesByKey.has(key) } hasValue(value: V): boolean { const sets = Array.from(this.valuesByKey.values()) return sets.some((set) => set.has(value)) } getValuesForKey(key: K): V[] { const values = this.valuesByKey.get(key) return values ? Array.from(values) : [] } getKeysForValue(value: V): K[] { return Array.from(this.valuesByKey) .filter(([_key, values]) => values.has(value)) .map(([key, _values]) => key) } } ================================================ FILE: src/multimap/set_operations.ts ================================================ export function add(map: Map>, key: K, value: V) { fetch(map, key).add(value) } export function del(map: Map>, key: K, value: V) { fetch(map, key).delete(value) prune(map, key) } export function fetch(map: Map>, key: K): Set { let values = map.get(key) if (!values) { values = new Set() map.set(key, values) } return values } export function prune(map: Map>, key: K) { const values = map.get(key) if (values != null && values.size == 0) { map.delete(key) } } ================================================ FILE: src/mutation-observers/attribute_observer.ts ================================================ import { ElementObserver, ElementObserverDelegate } from "./element_observer" export interface AttributeObserverDelegate { elementMatchedAttribute?(element: Element, attributeName: string): void elementAttributeValueChanged?(element: Element, attributeName: string): void elementUnmatchedAttribute?(element: Element, attributeName: string): void } export class AttributeObserver implements ElementObserverDelegate { attributeName: string private delegate: AttributeObserverDelegate private elementObserver: ElementObserver constructor(element: Element, attributeName: string, delegate: AttributeObserverDelegate) { this.attributeName = attributeName this.delegate = delegate this.elementObserver = new ElementObserver(element, this) } get element(): Element { return this.elementObserver.element } get selector(): string { return `[${this.attributeName}]` } start() { this.elementObserver.start() } pause(callback: () => void) { this.elementObserver.pause(callback) } stop() { this.elementObserver.stop() } refresh() { this.elementObserver.refresh() } get started(): boolean { return this.elementObserver.started } // Element observer delegate matchElement(element: Element): boolean { return element.hasAttribute(this.attributeName) } matchElementsInTree(tree: Element): Element[] { const match = this.matchElement(tree) ? [tree] : [] const matches = Array.from(tree.querySelectorAll(this.selector)) return match.concat(matches) } elementMatched(element: Element) { if (this.delegate.elementMatchedAttribute) { this.delegate.elementMatchedAttribute(element, this.attributeName) } } elementUnmatched(element: Element) { if (this.delegate.elementUnmatchedAttribute) { this.delegate.elementUnmatchedAttribute(element, this.attributeName) } } elementAttributeChanged(element: Element, attributeName: string) { if (this.delegate.elementAttributeValueChanged && this.attributeName == attributeName) { this.delegate.elementAttributeValueChanged(element, attributeName) } } } ================================================ FILE: src/mutation-observers/element_observer.ts ================================================ export interface ElementObserverDelegate { matchElement(element: Element): boolean matchElementsInTree(tree: Element): Element[] elementMatched?(element: Element): void elementUnmatched?(element: Element): void elementAttributeChanged?(element: Element, attributeName: string): void } export class ElementObserver { element: Element started: boolean private delegate: ElementObserverDelegate private elements: Set private mutationObserver: MutationObserver private mutationObserverInit: MutationObserverInit = { attributes: true, childList: true, subtree: true } constructor(element: Element, delegate: ElementObserverDelegate) { this.element = element this.started = false this.delegate = delegate this.elements = new Set() this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations)) } start() { if (!this.started) { this.started = true this.mutationObserver.observe(this.element, this.mutationObserverInit) this.refresh() } } pause(callback: () => void) { if (this.started) { this.mutationObserver.disconnect() this.started = false } callback() if (!this.started) { this.mutationObserver.observe(this.element, this.mutationObserverInit) this.started = true } } stop() { if (this.started) { this.mutationObserver.takeRecords() this.mutationObserver.disconnect() this.started = false } } refresh() { if (this.started) { const matches = new Set(this.matchElementsInTree()) for (const element of Array.from(this.elements)) { if (!matches.has(element)) { this.removeElement(element) } } for (const element of Array.from(matches)) { this.addElement(element) } } } // Mutation record processing private processMutations(mutations: MutationRecord[]) { if (this.started) { for (const mutation of mutations) { this.processMutation(mutation) } } } private processMutation(mutation: MutationRecord) { if (mutation.type == "attributes") { this.processAttributeChange(mutation.target as Element, mutation.attributeName!) } else if (mutation.type == "childList") { this.processRemovedNodes(mutation.removedNodes) this.processAddedNodes(mutation.addedNodes) } } private processAttributeChange(element: Element, attributeName: string) { if (this.elements.has(element)) { if (this.delegate.elementAttributeChanged && this.matchElement(element)) { this.delegate.elementAttributeChanged(element, attributeName) } else { this.removeElement(element) } } else if (this.matchElement(element)) { this.addElement(element) } } private processRemovedNodes(nodes: NodeList) { for (const node of Array.from(nodes)) { const element = this.elementFromNode(node) if (element) { this.processTree(element, this.removeElement) } } } private processAddedNodes(nodes: NodeList) { for (const node of Array.from(nodes)) { const element = this.elementFromNode(node) if (element && this.elementIsActive(element)) { this.processTree(element, this.addElement) } } } // Element matching private matchElement(element: Element): boolean { return this.delegate.matchElement(element) } private matchElementsInTree(tree: Element = this.element): Element[] { return this.delegate.matchElementsInTree(tree) } private processTree(tree: Element, processor: (element: Element) => void) { for (const element of this.matchElementsInTree(tree)) { processor.call(this, element) } } private elementFromNode(node: Node): Element | undefined { if (node.nodeType == Node.ELEMENT_NODE) { return node as Element } } private elementIsActive(element: Element): boolean { if (element.isConnected != this.element.isConnected) { return false } else { return this.element.contains(element) } } // Element tracking private addElement(element: Element) { if (!this.elements.has(element)) { if (this.elementIsActive(element)) { this.elements.add(element) if (this.delegate.elementMatched) { this.delegate.elementMatched(element) } } } } private removeElement(element: Element) { if (this.elements.has(element)) { this.elements.delete(element) if (this.delegate.elementUnmatched) { this.delegate.elementUnmatched(element) } } } } ================================================ FILE: src/mutation-observers/index.ts ================================================ export * from "./attribute_observer" export * from "./element_observer" export * from "./selector_observer" export * from "./string_map_observer" export * from "./token_list_observer" export * from "./value_list_observer" ================================================ FILE: src/mutation-observers/selector_observer.ts ================================================ import { ElementObserver, ElementObserverDelegate } from "./element_observer" import { Multimap } from "../multimap" export interface SelectorObserverDelegate { selectorMatched(element: Element, selector: string, details: object): void selectorUnmatched(element: Element, selector: string, details: object): void selectorMatchElement?(element: Element, details: object): boolean } export class SelectorObserver implements ElementObserverDelegate { private readonly elementObserver: ElementObserver private readonly delegate: SelectorObserverDelegate private readonly matchesByElement: Multimap private readonly details: object _selector: string | null constructor(element: Element, selector: string, delegate: SelectorObserverDelegate, details: object) { this._selector = selector this.details = details this.elementObserver = new ElementObserver(element, this) this.delegate = delegate this.matchesByElement = new Multimap() } get started(): boolean { return this.elementObserver.started } get selector() { return this._selector } set selector(selector: string | null) { this._selector = selector this.refresh() } start() { this.elementObserver.start() } pause(callback: () => void) { this.elementObserver.pause(callback) } stop() { this.elementObserver.stop() } refresh() { this.elementObserver.refresh() } get element(): Element { return this.elementObserver.element } // Element observer delegate matchElement(element: Element): boolean { const { selector } = this if (selector) { const matches = element.matches(selector) if (this.delegate.selectorMatchElement) { return matches && this.delegate.selectorMatchElement(element, this.details) } return matches } else { return false } } matchElementsInTree(tree: Element): Element[] { const { selector } = this if (selector) { const match = this.matchElement(tree) ? [tree] : [] const matches = Array.from(tree.querySelectorAll(selector)).filter((match) => this.matchElement(match)) return match.concat(matches) } else { return [] } } elementMatched(element: Element) { const { selector } = this if (selector) { this.selectorMatched(element, selector) } } elementUnmatched(element: Element) { const selectors = this.matchesByElement.getKeysForValue(element) for (const selector of selectors) { this.selectorUnmatched(element, selector) } } elementAttributeChanged(element: Element, _attributeName: string) { const { selector } = this if (selector) { const matches = this.matchElement(element) const matchedBefore = this.matchesByElement.has(selector, element) if (matches && !matchedBefore) { this.selectorMatched(element, selector) } else if (!matches && matchedBefore) { this.selectorUnmatched(element, selector) } } } // Selector management private selectorMatched(element: Element, selector: string) { this.delegate.selectorMatched(element, selector, this.details) this.matchesByElement.add(selector, element) } private selectorUnmatched(element: Element, selector: string) { this.delegate.selectorUnmatched(element, selector, this.details) this.matchesByElement.delete(selector, element) } } ================================================ FILE: src/mutation-observers/string_map_observer.ts ================================================ export interface StringMapObserverDelegate { getStringMapKeyForAttribute(attributeName: string): string | undefined stringMapKeyAdded?(key: string, attributeName: string): void stringMapValueChanged?(value: string | null, key: string, oldValue: string | null): void stringMapKeyRemoved?(key: string, attributeName: string, oldValue: string | null): void } export class StringMapObserver { readonly element: Element readonly delegate: StringMapObserverDelegate private started: boolean private stringMap: Map private mutationObserver: MutationObserver constructor(element: Element, delegate: StringMapObserverDelegate) { this.element = element this.delegate = delegate this.started = false this.stringMap = new Map() this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations)) } start() { if (!this.started) { this.started = true this.mutationObserver.observe(this.element, { attributes: true, attributeOldValue: true }) this.refresh() } } stop() { if (this.started) { this.mutationObserver.takeRecords() this.mutationObserver.disconnect() this.started = false } } refresh() { if (this.started) { for (const attributeName of this.knownAttributeNames) { this.refreshAttribute(attributeName, null) } } } // Mutation record processing private processMutations(mutations: MutationRecord[]) { if (this.started) { for (const mutation of mutations) { this.processMutation(mutation) } } } private processMutation(mutation: MutationRecord) { const attributeName = mutation.attributeName if (attributeName) { this.refreshAttribute(attributeName, mutation.oldValue) } } // State tracking private refreshAttribute(attributeName: string, oldValue: string | null) { const key = this.delegate.getStringMapKeyForAttribute(attributeName) if (key != null) { if (!this.stringMap.has(attributeName)) { this.stringMapKeyAdded(key, attributeName) } const value = this.element.getAttribute(attributeName) if (this.stringMap.get(attributeName) != value) { this.stringMapValueChanged(value, key, oldValue) } if (value == null) { const oldValue = this.stringMap.get(attributeName) this.stringMap.delete(attributeName) if (oldValue) this.stringMapKeyRemoved(key, attributeName, oldValue) } else { this.stringMap.set(attributeName, value) } } } private stringMapKeyAdded(key: string, attributeName: string) { if (this.delegate.stringMapKeyAdded) { this.delegate.stringMapKeyAdded(key, attributeName) } } private stringMapValueChanged(value: string | null, key: string, oldValue: string | null) { if (this.delegate.stringMapValueChanged) { this.delegate.stringMapValueChanged(value, key, oldValue) } } private stringMapKeyRemoved(key: string, attributeName: string, oldValue: string | null) { if (this.delegate.stringMapKeyRemoved) { this.delegate.stringMapKeyRemoved(key, attributeName, oldValue) } } private get knownAttributeNames() { return Array.from(new Set(this.currentAttributeNames.concat(this.recordedAttributeNames))) } private get currentAttributeNames() { return Array.from(this.element.attributes).map((attribute) => attribute.name) } private get recordedAttributeNames() { return Array.from(this.stringMap.keys()) } } ================================================ FILE: src/mutation-observers/token_list_observer.ts ================================================ import { AttributeObserver, AttributeObserverDelegate } from "./attribute_observer" import { Multimap } from "../multimap" export interface Token { element: Element attributeName: string index: number content: string } export interface TokenListObserverDelegate { tokenMatched(token: Token): void tokenUnmatched(token: Token): void } export class TokenListObserver implements AttributeObserverDelegate { private attributeObserver: AttributeObserver private delegate: TokenListObserverDelegate private tokensByElement: Multimap constructor(element: Element, attributeName: string, delegate: TokenListObserverDelegate) { this.attributeObserver = new AttributeObserver(element, attributeName, this) this.delegate = delegate this.tokensByElement = new Multimap() } get started(): boolean { return this.attributeObserver.started } start() { this.attributeObserver.start() } pause(callback: () => void) { this.attributeObserver.pause(callback) } stop() { this.attributeObserver.stop() } refresh() { this.attributeObserver.refresh() } get element(): Element { return this.attributeObserver.element } get attributeName(): string { return this.attributeObserver.attributeName } // Attribute observer delegate elementMatchedAttribute(element: Element) { this.tokensMatched(this.readTokensForElement(element)) } elementAttributeValueChanged(element: Element) { const [unmatchedTokens, matchedTokens] = this.refreshTokensForElement(element) this.tokensUnmatched(unmatchedTokens) this.tokensMatched(matchedTokens) } elementUnmatchedAttribute(element: Element) { this.tokensUnmatched(this.tokensByElement.getValuesForKey(element)) } private tokensMatched(tokens: Token[]) { tokens.forEach((token) => this.tokenMatched(token)) } private tokensUnmatched(tokens: Token[]) { tokens.forEach((token) => this.tokenUnmatched(token)) } private tokenMatched(token: Token) { this.delegate.tokenMatched(token) this.tokensByElement.add(token.element, token) } private tokenUnmatched(token: Token) { this.delegate.tokenUnmatched(token) this.tokensByElement.delete(token.element, token) } private refreshTokensForElement(element: Element): [Token[], Token[]] { const previousTokens = this.tokensByElement.getValuesForKey(element) const currentTokens = this.readTokensForElement(element) const firstDifferingIndex = zip(previousTokens, currentTokens).findIndex( ([previousToken, currentToken]) => !tokensAreEqual(previousToken, currentToken) ) if (firstDifferingIndex == -1) { return [[], []] } else { return [previousTokens.slice(firstDifferingIndex), currentTokens.slice(firstDifferingIndex)] } } private readTokensForElement(element: Element): Token[] { const attributeName = this.attributeName const tokenString = element.getAttribute(attributeName) || "" return parseTokenString(tokenString, element, attributeName) } } function parseTokenString(tokenString: string, element: Element, attributeName: string): Token[] { return tokenString .trim() .split(/\s+/) .filter((content) => content.length) .map((content, index) => ({ element, attributeName, content, index })) } function zip(left: L[], right: R[]): [L | undefined, R | undefined][] { const length = Math.max(left.length, right.length) return Array.from({ length }, (_, index) => [left[index], right[index]] as [L, R]) } function tokensAreEqual(left?: Token, right?: Token) { return left && right && left.index == right.index && left.content == right.content } ================================================ FILE: src/mutation-observers/value_list_observer.ts ================================================ import { Token, TokenListObserver, TokenListObserverDelegate } from "./token_list_observer" export interface ValueListObserverDelegate { parseValueForToken(token: Token): T | undefined elementMatchedValue(element: Element, value: T): void elementUnmatchedValue(element: Element, value: T): void } interface ParseResult { value?: T error?: Error } export class ValueListObserver implements TokenListObserverDelegate { private tokenListObserver: TokenListObserver private delegate: ValueListObserverDelegate private parseResultsByToken: WeakMap> private valuesByTokenByElement: WeakMap> constructor(element: Element, attributeName: string, delegate: ValueListObserverDelegate) { this.tokenListObserver = new TokenListObserver(element, attributeName, this) this.delegate = delegate this.parseResultsByToken = new WeakMap() this.valuesByTokenByElement = new WeakMap() } get started(): boolean { return this.tokenListObserver.started } start() { this.tokenListObserver.start() } stop() { this.tokenListObserver.stop() } refresh() { this.tokenListObserver.refresh() } get element(): Element { return this.tokenListObserver.element } get attributeName(): string { return this.tokenListObserver.attributeName } tokenMatched(token: Token) { const { element } = token const { value } = this.fetchParseResultForToken(token) if (value) { this.fetchValuesByTokenForElement(element).set(token, value) this.delegate.elementMatchedValue(element, value) } } tokenUnmatched(token: Token) { const { element } = token const { value } = this.fetchParseResultForToken(token) if (value) { this.fetchValuesByTokenForElement(element).delete(token) this.delegate.elementUnmatchedValue(element, value) } } private fetchParseResultForToken(token: Token) { let parseResult = this.parseResultsByToken.get(token) if (!parseResult) { parseResult = this.parseToken(token) this.parseResultsByToken.set(token, parseResult) } return parseResult } private fetchValuesByTokenForElement(element: Element) { let valuesByToken = this.valuesByTokenByElement.get(element) if (!valuesByToken) { valuesByToken = new Map() this.valuesByTokenByElement.set(element, valuesByToken) } return valuesByToken } private parseToken(token: Token): ParseResult { try { const value = this.delegate.parseValueForToken(token) return { value } } catch (error: any) { return { error } } } } ================================================ FILE: src/tests/cases/application_test_case.ts ================================================ import { Application } from "../../core/application" import { DOMTestCase } from "./dom_test_case" import { Schema, defaultSchema } from "../../core/schema" export class TestApplication extends Application { handleError(error: Error, _message: string, _detail: object) { throw error } } export class ApplicationTestCase extends DOMTestCase { schema: Schema = defaultSchema application!: Application async runTest(testName: string) { try { this.application = new TestApplication(this.fixtureElement, this.schema) this.setupApplication() this.application.start() await super.runTest(testName) } finally { this.application.stop() } } setupApplication() { // Override in subclasses to register controllers } } ================================================ FILE: src/tests/cases/controller_test_case.ts ================================================ import { ApplicationTestCase } from "./application_test_case" import { Constructor } from "../../core/constructor" import { Controller, ControllerConstructor } from "../../core/controller" export class ControllerTests extends ApplicationTestCase { identifier: string | string[] = "test" controllerConstructor!: ControllerConstructor fixtureHTML = `
    ` setupApplication() { this.identifiers.forEach((identifier) => { this.application.register(identifier, this.controllerConstructor) }) } get controller(): T { const controller = this.controllers[0] if (controller) { return controller } else { throw new Error("no controller connected") } } get identifiers(): string[] { if (typeof this.identifier == "string") { return [this.identifier] } else { return this.identifier } } get controllers(): T[] { return this.application.controllers as any as T[] } } export function ControllerTestCase(): Constructor> export function ControllerTestCase(constructor: Constructor): Constructor> export function ControllerTestCase( constructor?: Constructor ): Constructor> { return class extends ControllerTests { controllerConstructor = constructor || (Controller as any) } as any } ================================================ FILE: src/tests/cases/dom_test_case.ts ================================================ import { TestCase } from "./test_case" interface TriggerEventOptions { bubbles?: boolean setDefaultPrevented?: boolean } const defaultTriggerEventOptions: TriggerEventOptions = { bubbles: true, setDefaultPrevented: true, } export class DOMTestCase extends TestCase { fixtureSelector = "#qunit-fixture" fixtureHTML = "" async runTest(testName: string) { await this.renderFixture() await super.runTest(testName) } async renderFixture(fixtureHTML = this.fixtureHTML) { this.fixtureElement.innerHTML = fixtureHTML return this.nextFrame } get fixtureElement(): Element { const element = document.querySelector(this.fixtureSelector) if (element) { return element } else { throw new Error(`missing fixture element "${this.fixtureSelector}"`) } } async triggerEvent(selectorOrTarget: string | EventTarget, type: string, options: TriggerEventOptions = {}) { const { bubbles, setDefaultPrevented } = { ...defaultTriggerEventOptions, ...options } const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget const event = document.createEvent("Events") event.initEvent(type, bubbles, true) // IE <= 11 does not set `defaultPrevented` when `preventDefault()` is called on synthetic events if (setDefaultPrevented) { event.preventDefault = function () { Object.defineProperty(this, "defaultPrevented", { get: () => true, configurable: true }) } } eventTarget.dispatchEvent(event) await this.nextFrame return event } async triggerMouseEvent(selectorOrTarget: string | EventTarget, type: string, options: MouseEventInit = {}) { const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget const event = new MouseEvent(type, options) eventTarget.dispatchEvent(event) await this.nextFrame return event } async triggerKeyboardEvent(selectorOrTarget: string | EventTarget, type: string, options: KeyboardEventInit = {}) { const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget const event = new KeyboardEvent(type, options) eventTarget.dispatchEvent(event) await this.nextFrame return event } async setAttribute(selectorOrElement: string | Element, name: string, value: string) { const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement element.setAttribute(name, value) await this.nextFrame } async removeAttribute(selectorOrElement: string | Element, name: string) { const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement element.removeAttribute(name) await this.nextFrame } async appendChild(selectorOrElement: T | string, child: T) { const parent = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement parent.appendChild(child) await this.nextFrame } async remove(selectorOrElement: Element | string) { const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement element.remove() await this.nextFrame } findElement(selector: string) { const element = this.fixtureElement.querySelector(selector) if (element) { return element } else { throw new Error(`couldn't find element "${selector}"`) } } findElements(...selectors: string[]) { return selectors.map((selector) => this.findElement(selector)) } get nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(resolve)) } } ================================================ FILE: src/tests/cases/index.ts ================================================ export * from "./application_test_case" export * from "./controller_test_case" export * from "./dom_test_case" export * from "./log_controller_test_case" export * from "./observer_test_case" export * from "./test_case" ================================================ FILE: src/tests/cases/log_controller_test_case.ts ================================================ import { ControllerTestCase } from "./controller_test_case" import { LogController, ActionLogEntry } from "../controllers/log_controller" import { ControllerConstructor } from "../../core/controller" export class LogControllerTestCase extends ControllerTestCase(LogController) { controllerConstructor!: ControllerConstructor & { actionLog: ActionLogEntry[] } async setup() { this.controllerConstructor.actionLog = [] await super.setup() } assertActions(...actions: any[]) { this.assert.equal(this.actionLog.length, actions.length) actions.forEach((expected, index) => { const keys = Object.keys(expected) const actual = slice(this.actionLog[index] || {}, keys) const result = keys.every((key) => deepEqual(expected[key], actual[key])) this.assert.pushResult({ result, actual, expected, message: "" }) }) } assertNoActions() { this.assert.equal(this.actionLog.length, 0) } get actionLog(): ActionLogEntry[] { return this.controllerConstructor.actionLog } } function slice(object: any, keys: string[]): any { return keys.reduce((result: any, key: string) => ((result[key] = object[key]), result), {}) } function deepEqual(obj1: any, obj2: any): boolean { if (obj1 === obj2) { return true } else if (typeof obj1 === "object" && typeof obj2 === "object") { if (Object.keys(obj1).length !== Object.keys(obj2).length) { return false } for (const prop in obj1) { if (!deepEqual(obj1[prop], obj2[prop])) { return false } } return true } else { return false } } ================================================ FILE: src/tests/cases/observer_test_case.ts ================================================ import { DOMTestCase } from "./dom_test_case" export interface Observer { start(): void stop(): void } export class ObserverTestCase extends DOMTestCase { observer!: Observer calls: any[][] = [] private setupCallCount = 0 async setup() { this.observer.start() await this.nextFrame this.setupCallCount = this.calls.length } async teardown() { this.observer.stop() } get testCalls() { return this.calls.slice(this.setupCallCount) } recordCall(methodName: string, ...args: any[]) { this.calls.push([methodName, ...args]) } } ================================================ FILE: src/tests/cases/test_case.ts ================================================ export class TestCase { readonly assert: Assert static defineModule(moduleName: string = this.name, qUnit: QUnit = QUnit) { qUnit.module(moduleName, (_hooks) => { this.manifest.forEach(([type, name]) => { type = this.shouldSkipTest(name) ? "skip" : type const method = (qUnit as any)[type] as Function const test = this.getTest(name) method.call(qUnit, name, test) }) }) } static getTest(testName: string) { return async (assert: Assert) => this.runTest(testName, assert) } static runTest(testName: string, assert: Assert) { const testCase = new this(assert) return testCase.runTest(testName) } static shouldSkipTest(_testName: string): boolean { return false } static get manifest() { return this.testPropertyNames.map((name) => [name.slice(0, 4), name.slice(5)]) } static get testNames(): string[] { return this.manifest.map(([_type, name]) => name) } static get testPropertyNames(): string[] { return Object.keys(this.prototype).filter((name) => name.match(/^(skip|test|todo) /)) } constructor(assert: Assert) { this.assert = assert } async runTest(testName: string) { try { await this.setup() await this.runTestBody(testName) } finally { await this.teardown() } } async runTestBody(testName: string) { const testCase = (this as any)[`test ${testName}`] || (this as any)[`todo ${testName}`] if (typeof testCase == "function") { return testCase.call(this) } else { return Promise.reject(`test not found: "${testName}"`) } } async setup() { // Override this method in your subclass to prepare your test environment } async teardown() { // Override this method in your subclass to clean up your test environment } } ================================================ FILE: src/tests/controllers/class_controller.ts ================================================ import { Controller } from "../../core/controller" class BaseClassController extends Controller { static classes = ["active"] readonly activeClass!: string readonly activeClasses!: string[] readonly hasActiveClass!: boolean } export class ClassController extends BaseClassController { static classes = ["enabled", "loading", "success"] readonly hasEnabledClass!: boolean readonly enabledClass!: string readonly enabledClasses!: string[] readonly loadingClass!: string readonly successClass!: string readonly successClasses!: string[] } ================================================ FILE: src/tests/controllers/default_value_controller.ts ================================================ import { Controller } from "../../core/controller" import { ValueDefinitionMap, ValueDescriptorMap } from "../../core/value_properties" export class DefaultValueController extends Controller { static values: ValueDefinitionMap = { defaultBoolean: false, defaultBooleanTrue: { type: Boolean, default: true }, defaultBooleanFalse: { type: Boolean, default: false }, defaultBooleanOverride: true, defaultString: "", defaultStringHello: { type: String, default: "Hello" }, defaultStringOverride: "Override me", defaultNumber: 0, defaultNumberThousand: { type: Number, default: 1000 }, defaultNumberZero: { type: Number, default: 0 }, defaultNumberOverride: 9999, defaultArray: [], defaultArrayFilled: { type: Array, default: [1, 2, 3] }, defaultArrayOverride: [9, 9, 9], defaultObject: {}, defaultObjectPerson: { type: Object, default: { name: "David" } }, defaultObjectOverride: { override: "me" }, } valueDescriptorMap!: ValueDescriptorMap defaultBooleanValue!: boolean hasDefaultBooleanValue!: boolean defaultBooleanTrueValue!: boolean defaultBooleanFalseValue!: boolean hasDefaultBooleanTrueValue!: boolean hasDefaultBooleanFalseValue!: boolean defaultBooleanOverrideValue!: boolean hasDefaultBooleanOverrideValue!: boolean defaultStringValue!: string hasDefaultStringValue!: boolean defaultStringHelloValue!: string hasDefaultStringHelloValue!: boolean defaultStringOverrideValue!: string hasDefaultStringOverrideValue!: boolean defaultNumberValue!: number hasDefaultNumberValue!: boolean defaultNumberThousandValue!: number hasDefaultNumberThousandValue!: boolean defaultNumberZeroValue!: number hasDefaultNumberZeroValue!: boolean defaultNumberOverrideValue!: number hasDefaultNumberOverrideValue!: boolean defaultArrayValue!: any[] hasDefaultArrayValue!: boolean defaultArrayFilledValue!: { [key: string]: any } hasDefaultArrayFilledValue!: boolean defaultArrayOverrideValue!: { [key: string]: any } hasDefaultArrayOverrideValue!: boolean defaultObjectValue!: object hasDefaultObjectValue!: boolean defaultObjectPersonValue!: object hasDefaultObjectPersonValue!: boolean defaultObjectOverrideValue!: object hasDefaultObjectOverrideValue!: boolean lifecycleCallbacks: string[] = [] initialize() { this.lifecycleCallbacks.push("initialize") } connect() { this.lifecycleCallbacks.push("connect") } defaultBooleanValueChanged() { this.lifecycleCallbacks.push("defaultBooleanValueChanged") } } ================================================ FILE: src/tests/controllers/log_controller.ts ================================================ import { ActionEvent } from "../../core/action_event" import { Controller } from "../../core/controller" export type ActionLogEntry = { name: string controller: Controller identifier: string eventType: string currentTarget: EventTarget | null params: { [key: string]: any } defaultPrevented: boolean passive: boolean } export class LogController extends Controller { static actionLog: ActionLogEntry[] = [] initializeCount = 0 connectCount = 0 disconnectCount = 0 initialize() { this.initializeCount++ } connect() { this.connectCount++ } disconnect() { this.disconnectCount++ } log(event: ActionEvent) { this.recordAction("log", event) } log2(event: ActionEvent) { this.recordAction("log2", event) } log3(event: ActionEvent) { this.recordAction("log3", event) } logPassive(event: ActionEvent) { event.preventDefault() if (event.defaultPrevented) { this.recordAction("logPassive", event, false) } else { this.recordAction("logPassive", event, true) } } stop(event: ActionEvent) { this.recordAction("stop", event) event.stopImmediatePropagation() } get actionLog() { return (this.constructor as typeof LogController).actionLog } private recordAction(name: string, event: ActionEvent, passive?: boolean) { this.actionLog.push({ name, controller: this, identifier: this.identifier, eventType: event.type, currentTarget: event.currentTarget, params: event.params, defaultPrevented: event.defaultPrevented, passive: passive || false, }) } } ================================================ FILE: src/tests/controllers/outlet_controller.ts ================================================ import { Controller } from "../../core/controller" class BaseOutletController extends Controller { static outlets = ["alpha"] alphaOutlet!: Controller | null alphaOutlets!: Controller[] alphaOutletElement!: Element | null alphaOutletElements!: Element[] hasAlphaOutlet!: boolean } export class OutletController extends BaseOutletController { static classes = ["connected", "disconnected"] static outlets = ["beta", "gamma", "delta", "omega", "namespaced--epsilon"] static values = { alphaOutletConnectedCallCount: Number, alphaOutletDisconnectedCallCount: Number, betaOutletConnectedCallCount: Number, betaOutletDisconnectedCallCount: Number, betaOutletsInConnect: Number, gammaOutletConnectedCallCount: Number, gammaOutletDisconnectedCallCount: Number, namespacedEpsilonOutletConnectedCallCount: Number, namespacedEpsilonOutletDisconnectedCallCount: Number, } betaOutlet!: Controller | null betaOutlets!: Controller[] betaOutletElement!: Element | null betaOutletElements!: Element[] hasBetaOutlet!: boolean namespacedEpsilonOutlet!: Controller | null namespacedEpsilonOutlets!: Controller[] namespacedEpsilonOutletElement!: Element | null namespacedEpsilonOutletElements!: Element[] hasNamespacedEpsilonOutlet!: boolean hasConnectedClass!: boolean hasDisconnectedClass!: boolean connectedClass!: string disconnectedClass!: string alphaOutletConnectedCallCountValue = 0 alphaOutletDisconnectedCallCountValue = 0 betaOutletConnectedCallCountValue = 0 betaOutletDisconnectedCallCountValue = 0 betaOutletsInConnectValue = 0 gammaOutletConnectedCallCountValue = 0 gammaOutletDisconnectedCallCountValue = 0 namespacedEpsilonOutletConnectedCallCountValue = 0 namespacedEpsilonOutletDisconnectedCallCountValue = 0 connect() { this.betaOutletsInConnectValue = this.betaOutlets.length } alphaOutletConnected(_outlet: Controller, element: Element) { if (this.hasConnectedClass) element.classList.add(this.connectedClass) this.alphaOutletConnectedCallCountValue++ } alphaOutletDisconnected(_outlet: Controller, element: Element) { if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) this.alphaOutletDisconnectedCallCountValue++ } betaOutletConnected(_outlet: Controller, element: Element) { if (this.hasConnectedClass) element.classList.add(this.connectedClass) this.betaOutletConnectedCallCountValue++ } betaOutletDisconnected(_outlet: Controller, element: Element) { if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) this.betaOutletDisconnectedCallCountValue++ } gammaOutletConnected(_outlet: Controller, element: Element) { if (this.hasConnectedClass) element.classList.add(this.connectedClass) this.gammaOutletConnectedCallCountValue++ } namespacedEpsilonOutletConnected(_outlet: Controller, element: Element) { if (this.hasConnectedClass) element.classList.add(this.connectedClass) this.namespacedEpsilonOutletConnectedCallCountValue++ } namespacedEpsilonOutletDisconnected(_outlet: Controller, element: Element) { if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) this.namespacedEpsilonOutletDisconnectedCallCountValue++ } } ================================================ FILE: src/tests/controllers/target_controller.ts ================================================ import { Controller } from "../../core/controller" class BaseTargetController extends Controller { static targets = ["alpha"] alphaTarget!: Element | null alphaTargets!: Element[] hasAlphaTarget!: boolean } export class TargetController extends BaseTargetController { static classes = ["connected", "disconnected"] static targets = ["beta", "input", "recursive"] static values = { inputTargetConnectedCallCount: Number, inputTargetDisconnectedCallCount: Number, recursiveTargetConnectedCallCount: Number, recursiveTargetDisconnectedCallCount: Number, } betaTarget!: Element | null betaTargets!: Element[] hasBetaTarget!: boolean inputTarget!: Element | null inputTargets!: Element[] hasInputTarget!: boolean hasConnectedClass!: boolean hasDisconnectedClass!: boolean connectedClass!: string disconnectedClass!: string inputTargetConnectedCallCountValue = 0 inputTargetDisconnectedCallCountValue = 0 recursiveTargetConnectedCallCountValue = 0 recursiveTargetDisconnectedCallCountValue = 0 inputTargetConnected(element: Element) { if (this.hasConnectedClass) element.classList.add(this.connectedClass) this.inputTargetConnectedCallCountValue++ } inputTargetDisconnected(element: Element) { if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) this.inputTargetDisconnectedCallCountValue++ } recursiveTargetConnected(element: Element) { element.remove() this.recursiveTargetConnectedCallCountValue++ this.element.append(element) } recursiveTargetDisconnected(_element: Element) { this.recursiveTargetDisconnectedCallCountValue++ } } ================================================ FILE: src/tests/controllers/value_controller.ts ================================================ import { Controller } from "../../core/controller" import { ValueDefinitionMap, ValueDescriptorMap } from "../../core/value_properties" class BaseValueController extends Controller { static values: ValueDefinitionMap = { shadowedBoolean: String, string: String, numeric: Number, } valueDescriptorMap!: ValueDescriptorMap stringValue!: string numericValue!: number } export class ValueController extends BaseValueController { static values: ValueDefinitionMap = { shadowedBoolean: Boolean, missingString: String, ids: Array, options: Object, "time-24hr": Boolean, } shadowedBooleanValue!: boolean missingStringValue!: string idsValue!: any[] optionsValue!: { [key: string]: any } time24hrValue!: boolean loggedNumericValues: number[] = [] oldLoggedNumericValues: any[] = [] numericValueChanged(value: number, oldValue: any) { this.loggedNumericValues.push(value) this.oldLoggedNumericValues.push(oldValue) } loggedMissingStringValues: string[] = [] oldLoggedMissingStringValues: any[] = [] missingStringValueChanged(value: string, oldValue: any) { this.loggedMissingStringValues.push(value) this.oldLoggedMissingStringValues.push(oldValue) } optionsValues: Object[] = [] oldOptionsValues: any[] = [] optionsValueChanged(value: Object, oldValue: any) { this.optionsValues.push(value) this.oldOptionsValues.push(oldValue) } } ================================================ FILE: src/tests/fixtures/application_start/helpers.ts ================================================ import { Application, Controller } from "../../../core" export function startApplication() { const startState = document.readyState class PostMessageController extends Controller { itemTargets!: Element[] static targets = ["item"] connect() { const connectState = document.readyState const targetCount = this.itemTargets.length const message = JSON.stringify({ startState, connectState, targetCount }) parent.postMessage(message, location.origin) } } const application = Application.start() application.register("a", PostMessageController) } ================================================ FILE: src/tests/fixtures/application_start/index.html ================================================
    ================================================ FILE: src/tests/fixtures/application_start/index.ts ================================================ import { startApplication } from "./helpers" startApplication() addEventListener("DOMContentLoaded", startApplication) addEventListener("load", startApplication) ================================================ FILE: src/tests/index.ts ================================================ const context = require.context("./modules", true, /\.js$/) const modules = context.keys().map((key) => context(key).default) modules.forEach((constructor) => constructor.defineModule()) ================================================ FILE: src/tests/modules/core/action_click_filter_tests.ts ================================================ import { LogControllerTestCase } from "../../cases/log_controller_test_case" export default class ActionClickFilterTests extends LogControllerTestCase { identifier = ["a"] fixtureHTML = `
    ` async "test ignoring clicks with unmatched modifier"() { const button = this.findElement("#ctrl") await this.triggerMouseEvent(button, "click", { ctrlKey: true }) await this.nextFrame this.assertActions( { name: "log", identifier: "a", eventType: "click", currentTarget: button }, { name: "log2", identifier: "a", eventType: "click", currentTarget: button } ) } } ================================================ FILE: src/tests/modules/core/action_keyboard_filter_tests.ts ================================================ import { TestApplication } from "../../cases/application_test_case" import { LogControllerTestCase } from "../../cases/log_controller_test_case" import { Schema, defaultSchema } from "../../../core/schema" import { Application } from "../../../core/application" const customSchema = { ...defaultSchema, keyMappings: { ...defaultSchema.keyMappings, a: "a", b: "b" } } export default class ActionKeyboardFilterTests extends LogControllerTestCase { schema: Schema = customSchema application: Application = new TestApplication(this.fixtureElement, this.schema) identifier = ["a"] fixtureHTML = `
    ` async "test ignore event handlers associated with modifiers other than Enter"() { const button = this.findElement("#button1") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "Enter" }) this.assertActions( { name: "log", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than Space"() { const button = this.findElement("#button1") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: " " }) this.assertActions( { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than Tab"() { const button = this.findElement("#button2") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "Tab" }) this.assertActions( { name: "log", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than Escape"() { const button = this.findElement("#button2") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "Escape" }) this.assertActions( { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than ArrowUp"() { const button = this.findElement("#button3") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "ArrowUp" }) this.assertActions( { name: "log", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than ArrowDown"() { const button = this.findElement("#button3") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "ArrowDown" }) this.assertActions( { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than ArrowLeft"() { const button = this.findElement("#button4") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "ArrowLeft" }) this.assertActions( { name: "log", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than ArrowRight"() { const button = this.findElement("#button4") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "ArrowRight" }) this.assertActions( { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than Home"() { const button = this.findElement("#button5") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "Home" }) this.assertActions( { name: "log", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test ignore event handlers associated with modifiers other than End"() { const button = this.findElement("#button5") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "End" }) this.assertActions( { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button } ) } async "test keyup"() { const button = this.findElement("#button6") await this.nextFrame await this.triggerKeyboardEvent(button, "keyup", { key: "End" }) this.assertActions( { name: "log", identifier: "a", eventType: "keyup", currentTarget: button }, { name: "log3", identifier: "a", eventType: "keyup", currentTarget: button } ) } async "test global event"() { const button = this.findElement("#button7") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "Escape", bubbles: true }) this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: document }) } async "test custom keymapping: a"() { const button = this.findElement("#button8") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "a" }) this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: button }) } async "test custom keymapping: b"() { const button = this.findElement("#button8") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "b" }) this.assertActions({ name: "log2", identifier: "a", eventType: "keydown", currentTarget: button }) } async "test custom keymapping: unknown c"() { const button = this.findElement("#button8") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "c" }) this.assertActions() } async "test ignore event handlers associated with modifiers other than shift+a"() { const button = this.findElement("#button9") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "A", shiftKey: true }) this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: button }) } async "test ignore event handlers associated with modifiers other than a"() { const button = this.findElement("#button9") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "a" }) this.assertActions({ name: "log2", identifier: "a", eventType: "keydown", currentTarget: button }) } async "test ignore event handlers associated with modifiers other than ctrol+shift+a"() { const button = this.findElement("#button9") await this.nextFrame await this.triggerKeyboardEvent(button, "keydown", { key: "A", ctrlKey: true, shiftKey: true }) this.assertActions({ name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }) } async "test ignore filter syntax when not a keyboard event"() { const button = this.findElement("#button10") await this.nextFrame await this.triggerEvent(button, "jquery.custom.event") this.assertActions({ name: "log", identifier: "a", eventType: "jquery.custom.event", currentTarget: button }) } async "test ignore filter syntax when not a keyboard event (case2)"() { const button = this.findElement("#button10") await this.nextFrame await this.triggerEvent(button, "jquery.a") this.assertActions({ name: "log2", identifier: "a", eventType: "jquery.a", currentTarget: button }) } } ================================================ FILE: src/tests/modules/core/action_ordering_tests.ts ================================================ import { LogControllerTestCase } from "../../cases/log_controller_test_case" export default class ActionOrderingTests extends LogControllerTestCase { identifier = ["c", "d"] fixtureHTML = `
    ` async "test adding an action to the right"() { this.actionValue = "c#log d#log2 c#log3" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log", identifier: "c", eventType: "click", currentTarget: this.element } ) } async "test adding an action to the left"() { this.actionValue = "c#log3 c#log d#log2" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, { name: "log", identifier: "c", eventType: "click", currentTarget: this.element } ) } async "test removing an action from the right"() { this.actionValue = "c#log d#log2" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, { name: "log", identifier: "c", eventType: "click", currentTarget: this.element } ) } async "test removing an action from the left"() { this.actionValue = "d#log2 c#log3" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log", identifier: "c", eventType: "click", currentTarget: this.element } ) } async "test replacing an action on the left"() { this.actionValue = "d#log2 c#log3" await this.nextFrame this.actionValue = "c#log d#log2 c#log3" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log", identifier: "c", eventType: "click", currentTarget: this.element } ) } async "test stopping an action"() { this.actionValue = "c#log d#stop c#log3" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "stop", identifier: "d", eventType: "click", currentTarget: this.buttonElement } ) } async "test disconnecting a controller disconnects its actions"() { this.controllerValue = "c" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log", identifier: "c", eventType: "click", currentTarget: this.element } ) } set controllerValue(value: string) { this.element.setAttribute("data-controller", value) } set actionValue(value: string) { this.buttonElement.setAttribute("data-action", value) } get element() { return this.findElement("div") } get buttonElement() { return this.findElement("button") } } ================================================ FILE: src/tests/modules/core/action_params_case_insensitive_tests.ts ================================================ import ActionParamsTests from "./action_params_tests" export default class ActionParamsCaseInsensitiveTests extends ActionParamsTests { identifier = ["CamelCase", "AnotherOne"] fixtureHTML = `
    ` expectedParamsForCamelCase = { id: 123, multiWordExample: "/path", payload: { value: 1, }, active: true, empty: "", inactive: false, } async "test clicking on the element does return its params"() { this.actionValue = "click->CamelCase#log" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions({ identifier: "CamelCase", params: this.expectedParamsForCamelCase }) } async "test global event return element params where the action is defined"() { this.actionValue = "keydown@window->CamelCase#log" await this.nextFrame await this.triggerEvent("#outside", "keydown") this.assertActions({ identifier: "CamelCase", params: this.expectedParamsForCamelCase }) } async "test passing params to namespaced controller"() { this.actionValue = "click->CamelCase#log click->AnotherOne#log2" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { identifier: "CamelCase", params: this.expectedParamsForCamelCase }, { identifier: "AnotherOne", params: { id: 234 } } ) } } ================================================ FILE: src/tests/modules/core/action_params_tests.ts ================================================ import { LogControllerTestCase } from "../../cases/log_controller_test_case" export default class ActionParamsTests extends LogControllerTestCase { identifier = ["c", "d"] fixtureHTML = `
    ` expectedParamsForC = { id: 123, multiWordExample: "/path", payload: { value: 1, }, active: true, empty: "", inactive: false, } async "test clicking on the element does return its params"() { this.actionValue = "click->c#log" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions({ identifier: "c", params: this.expectedParamsForC }) } async "test global event return element params where the action is defined"() { this.actionValue = "keydown@window->c#log" await this.nextFrame await this.triggerEvent("#outside", "keydown") this.assertActions({ identifier: "c", params: this.expectedParamsForC }) } async "test passing params to namespaced controller"() { this.actionValue = "click->c#log click->d#log2" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions({ identifier: "c", params: this.expectedParamsForC }, { identifier: "d", params: { id: 234 } }) } async "test updating manually the params values"() { this.actionValue = "click->c#log" await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions({ identifier: "c", params: this.expectedParamsForC }) this.buttonElement.setAttribute("data-c-id-param", "234") this.buttonElement.setAttribute("data-c-new-param", "new") this.buttonElement.removeAttribute("data-c-payload-param") this.triggerEvent(this.buttonElement, "click") this.assertActions( { identifier: "c", params: this.expectedParamsForC }, { identifier: "c", params: { id: 234, new: "new", multiWordExample: "/path", active: true, empty: "", inactive: false, }, } ) } async "test clicking on a nested element does return the params of the actionable element"() { this.actionValue = "click->c#log" await this.nextFrame await this.triggerEvent(this.nestedElement, "click") this.assertActions({ identifier: "c", params: this.expectedParamsForC }) } set actionValue(value: string) { this.buttonElement.setAttribute("data-action", value) } get element() { return this.findElement("div") } get buttonElement() { return this.findElement("button") } get nestedElement() { return this.findElement("#nested") } } ================================================ FILE: src/tests/modules/core/action_tests.ts ================================================ import { LogControllerTestCase } from "../../cases/log_controller_test_case" export default class ActionTests extends LogControllerTestCase { identifier = "c" fixtureHTML = `
    ` async "test default event"() { await this.triggerEvent("button", "click") this.assertActions({ name: "log", eventType: "click" }) } async "test bubbling events"() { await this.triggerEvent("span", "click") this.assertActions({ eventType: "click", currentTarget: this.findElement("button") }) } async "test non-bubbling events"() { await this.triggerEvent("span", "click", { bubbles: false }) this.assertNoActions() await this.triggerEvent("button", "click", { bubbles: false }) this.assertActions({ eventType: "click" }) } async "test nested actions"() { const innerController = this.controllers[1] await this.triggerEvent("#inner", "click") this.assert.ok(true) this.assertActions({ controller: innerController, eventType: "click" }) } async "test global actions"() { await this.triggerEvent("#outside", "keydown") this.assertActions({ name: "log", eventType: "keydown" }) } async "test nested global actions"() { const innerController = this.controllers[1] await this.triggerEvent("#outside", "keyup") this.assertActions({ controller: innerController, eventType: "keyup" }) } async "test multiple actions"() { await this.triggerEvent("#multiple", "mousedown") await this.triggerEvent("#multiple", "click") this.assertActions( { name: "log", eventType: "mousedown" }, { name: "log", eventType: "click" }, { name: "log2", eventType: "click" } ) } async "test actions on svg elements"() { await this.triggerEvent("#svgRoot", "click") await this.triggerEvent("#svgChild", "mousedown") this.assertActions({ name: "log", eventType: "click" }, { name: "log", eventType: "mousedown" }) } } ================================================ FILE: src/tests/modules/core/action_timing_tests.ts ================================================ import { Controller } from "../../../core/controller" import { ControllerTestCase } from "../../cases/controller_test_case" class ActionTimingController extends Controller { static targets = ["button"] buttonTarget!: HTMLButtonElement event?: Event connect() { this.buttonTarget.click() } record(event: Event) { this.event = event } } export default class ActionTimingTests extends ControllerTestCase(ActionTimingController) { controllerConstructor = ActionTimingController identifier = "c" fixtureHTML = `
    ` async "test triggering an action on connect"() { const { event } = this.controller this.assert.ok(event) this.assert.equal(event && event.type, "click") } } ================================================ FILE: src/tests/modules/core/application_start_tests.ts ================================================ import { DOMTestCase } from "../../cases" export default class ApplicationStartTests extends DOMTestCase { iframe!: HTMLIFrameElement async setup() { this.iframe = document.createElement("iframe") this.iframe.src = "/base/src/tests/fixtures/application_start/index.html" this.fixtureElement.appendChild(this.iframe) } async "test starting an application when the document is loading"() { const message = await this.messageFromStartState("loading") this.assertIn(message.connectState, ["interactive", "complete"]) this.assert.equal(message.targetCount, 3) } async "test starting an application when the document is interactive"() { const message = await this.messageFromStartState("interactive") this.assertIn(message.connectState, ["interactive", "complete"]) this.assert.equal(message.targetCount, 3) } async "test starting an application when the document is complete"() { const message = await this.messageFromStartState("complete") this.assertIn(message.connectState, ["complete"]) this.assert.equal(message.targetCount, 3) } private messageFromStartState(startState: string): Promise { return new Promise((resolve) => { const receiveMessage = (event: MessageEvent) => { if (event.source == this.iframe.contentWindow) { const message = JSON.parse(event.data) if (message.startState == startState) { removeEventListener("message", receiveMessage) resolve(message) } } } addEventListener("message", receiveMessage) }) } private assertIn(actual: any, expected: any[]) { const state = expected.indexOf(actual) > -1 const message = `${JSON.stringify(actual)} is not in ${JSON.stringify(expected)}` this.assert.ok(state, message) } } ================================================ FILE: src/tests/modules/core/application_tests.ts ================================================ import { ApplicationTestCase } from "../../cases/application_test_case" import { LogController } from "../../controllers/log_controller" class AController extends LogController {} class BController extends LogController {} export default class ApplicationTests extends ApplicationTestCase { fixtureHTML = `
    ` private definitions = [ { controllerConstructor: AController, identifier: "a" }, { controllerConstructor: BController, identifier: "b" }, ] async "test Application#register"() { this.assert.equal(this.controllers.length, 0) this.application.register("log", LogController) await this.renderFixture(`
    `) this.assert.equal(this.controllers[0].initializeCount, 1) this.assert.equal(this.controllers[0].connectCount, 1) } "test Application#load"() { this.assert.equal(this.controllers.length, 0) this.application.load(this.definitions) this.assert.equal(this.controllers.length, 2) this.assert.ok(this.controllers[0] instanceof AController) this.assert.equal(this.controllers[0].initializeCount, 1) this.assert.equal(this.controllers[0].connectCount, 1) this.assert.ok(this.controllers[1] instanceof BController) this.assert.equal(this.controllers[1].initializeCount, 1) this.assert.equal(this.controllers[1].connectCount, 1) } "test Application#unload"() { this.application.load(this.definitions) const originalControllers = this.controllers this.application.unload("a") this.assert.equal(originalControllers[0].disconnectCount, 1) this.assert.equal(this.controllers.length, 1) this.assert.ok(this.controllers[0] instanceof BController) } get controllers() { return this.application.controllers as LogController[] } } ================================================ FILE: src/tests/modules/core/class_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" import { ClassController } from "../../controllers/class_controller" export default class ClassTests extends ControllerTestCase(ClassController) { fixtureHTML = `
    ` "test accessing a class property"() { this.assert.ok(this.controller.hasActiveClass) this.assert.equal(this.controller.activeClass, "test--active") this.assert.deepEqual(this.controller.activeClasses, ["test--active"]) } "test accessing a missing class property throws an error"() { this.assert.notOk(this.controller.hasEnabledClass) this.assert.raises(() => this.controller.enabledClass) this.assert.equal(this.controller.enabledClasses.length, 0) } "test classes must be scoped by identifier"() { this.assert.equal(this.controller.loadingClass, "busy") } "test multiple classes map to array"() { this.assert.deepEqual(this.controller.successClasses, ["bg-green-400", "border", "border-green-600"]) } "test accessing a class property returns first class if multiple classes are used"() { this.assert.equal(this.controller.successClass, "bg-green-400") } } ================================================ FILE: src/tests/modules/core/data_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" export default class DataTests extends ControllerTestCase() { fixtureHTML = `
    ` "test DataSet#get"() { this.assert.equal(this.controller.data.get("alpha"), "hello world") this.assert.equal(this.controller.data.get("betaGamma"), "123") this.assert.equal(this.controller.data.get("nonexistent"), null) } "test DataSet#set"() { this.assert.equal(this.controller.data.set("alpha", "ok"), "ok") this.assert.equal(this.controller.data.get("alpha"), "ok") this.assert.equal(this.findElement("div").getAttribute(`data-${this.identifier}-alpha`), "ok") } "test DataSet#has"() { this.assert.ok(this.controller.data.has("alpha")) this.assert.ok(this.controller.data.has("betaGamma")) this.assert.notOk(this.controller.data.has("nonexistent")) } "test DataSet#delete"() { this.controller.data.delete("alpha") this.assert.equal(this.controller.data.get("alpha"), null) this.assert.notOk(this.controller.data.has("alpha")) this.assert.notOk(this.findElement("div").hasAttribute(`data-${this.identifier}-alpha`)) } } ================================================ FILE: src/tests/modules/core/default_value_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" import { DefaultValueController } from "../../controllers/default_value_controller" export default class DefaultValueTests extends ControllerTestCase(DefaultValueController) { fixtureHTML = `
    ` // Booleans "test custom default boolean values"() { this.assert.deepEqual(this.controller.defaultBooleanValue, false) this.assert.ok(this.controller.hasDefaultBooleanValue) this.assert.deepEqual(this.get("default-boolean-value"), null) this.assert.deepEqual(this.controller.defaultBooleanTrueValue, true) this.assert.ok(this.controller.hasDefaultBooleanTrueValue) this.assert.deepEqual(this.get("default-boolean-true-value"), null) this.assert.deepEqual(this.controller.defaultBooleanFalseValue, false) this.assert.ok(this.controller.hasDefaultBooleanFalseValue) this.assert.deepEqual(this.get("default-boolean-false-value"), null) } "test should be able to set a new value for custom default boolean values"() { this.assert.deepEqual(this.get("default-boolean-true-value"), null) this.assert.deepEqual(this.controller.defaultBooleanTrueValue, true) this.assert.ok(this.controller.hasDefaultBooleanTrueValue) this.controller.defaultBooleanTrueValue = false this.assert.deepEqual(this.get("default-boolean-true-value"), "false") this.assert.deepEqual(this.controller.defaultBooleanTrueValue, false) this.assert.ok(this.controller.hasDefaultBooleanTrueValue) } "test should override custom default boolean value with given data-attribute"() { this.assert.deepEqual(this.get("default-boolean-override-value"), "false") this.assert.deepEqual(this.controller.defaultBooleanOverrideValue, false) this.assert.ok(this.controller.hasDefaultBooleanOverrideValue) } // Strings "test custom default string values"() { this.assert.deepEqual(this.controller.defaultStringValue, "") this.assert.ok(this.controller.hasDefaultStringValue) this.assert.deepEqual(this.get("default-string-value"), null) this.assert.deepEqual(this.controller.defaultStringHelloValue, "Hello") this.assert.ok(this.controller.hasDefaultStringHelloValue) this.assert.deepEqual(this.get("default-string-hello-value"), null) } "test should be able to set a new value for custom default string values"() { this.assert.deepEqual(this.get("default-string-value"), null) this.assert.deepEqual(this.controller.defaultStringValue, "") this.assert.ok(this.controller.hasDefaultStringValue) this.controller.defaultStringValue = "New Value" this.assert.deepEqual(this.get("default-string-value"), "New Value") this.assert.deepEqual(this.controller.defaultStringValue, "New Value") this.assert.ok(this.controller.hasDefaultStringValue) } "test should override custom default string value with given data-attribute"() { this.assert.deepEqual(this.get("default-string-override-value"), "I am the expected value") this.assert.deepEqual(this.controller.defaultStringOverrideValue, "I am the expected value") this.assert.ok(this.controller.hasDefaultStringOverrideValue) } // Numbers "test custom default number values"() { this.assert.deepEqual(this.controller.defaultNumberValue, 0) this.assert.ok(this.controller.hasDefaultNumberValue) this.assert.deepEqual(this.get("default-number-value"), null) this.assert.deepEqual(this.controller.defaultNumberThousandValue, 1000) this.assert.ok(this.controller.hasDefaultNumberThousandValue) this.assert.deepEqual(this.get("default-number-thousand-value"), null) this.assert.deepEqual(this.controller.defaultNumberZeroValue, 0) this.assert.ok(this.controller.hasDefaultNumberZeroValue) this.assert.deepEqual(this.get("default-number-zero-value"), null) } "test should be able to set a new value for custom default number values"() { this.assert.deepEqual(this.get("default-number-value"), null) this.assert.deepEqual(this.controller.defaultNumberValue, 0) this.assert.ok(this.controller.hasDefaultNumberValue) this.controller.defaultNumberValue = 123 this.assert.deepEqual(this.get("default-number-value"), "123") this.assert.deepEqual(this.controller.defaultNumberValue, 123) this.assert.ok(this.controller.hasDefaultNumberValue) } "test should override custom default number value with given data-attribute"() { this.assert.deepEqual(this.get("default-number-override-value"), "42") this.assert.deepEqual(this.controller.defaultNumberOverrideValue, 42) this.assert.ok(this.controller.hasDefaultNumberOverrideValue) } // Arrays "test custom default array values"() { this.assert.deepEqual(this.controller.defaultArrayValue, []) this.assert.ok(this.controller.hasDefaultArrayValue) this.assert.deepEqual(this.get("default-array-value"), null) this.assert.deepEqual(this.controller.defaultArrayFilledValue, [1, 2, 3]) this.assert.ok(this.controller.hasDefaultArrayFilledValue) this.assert.deepEqual(this.get("default-array-filled-value"), null) } "test should be able to set a new value for custom default array values"() { this.assert.deepEqual(this.get("default-array-value"), null) this.assert.deepEqual(this.controller.defaultArrayValue, []) this.assert.ok(this.controller.hasDefaultArrayValue) this.controller.defaultArrayValue = [1, 2] this.assert.deepEqual(this.get("default-array-value"), "[1,2]") this.assert.deepEqual(this.controller.defaultArrayValue, [1, 2]) this.assert.ok(this.controller.hasDefaultArrayValue) } "test should override custom default array value with given data-attribute"() { this.assert.deepEqual(this.get("default-array-override-value"), "[9,8,7]") this.assert.deepEqual(this.controller.defaultArrayOverrideValue, [9, 8, 7]) this.assert.ok(this.controller.hasDefaultArrayOverrideValue) } // Objects "test custom default object values"() { this.assert.deepEqual(this.controller.defaultObjectValue, {}) this.assert.ok(this.controller.hasDefaultObjectValue) this.assert.deepEqual(this.get("default-object-value"), null) this.assert.deepEqual(this.controller.defaultObjectPersonValue, { name: "David" }) this.assert.ok(this.controller.hasDefaultObjectPersonValue) this.assert.deepEqual(this.get("default-object-filled-value"), null) } "test should be able to set a new value for custom default object values"() { this.assert.deepEqual(this.get("default-object-value"), null) this.assert.deepEqual(this.controller.defaultObjectValue, {}) this.assert.ok(this.controller.hasDefaultObjectValue) this.controller.defaultObjectValue = { new: "value" } this.assert.deepEqual(this.get("default-object-value"), '{"new":"value"}') this.assert.deepEqual(this.controller.defaultObjectValue, { new: "value" }) this.assert.ok(this.controller.hasDefaultObjectValue) } "test should override custom default object value with given data-attribute"() { this.assert.deepEqual(this.get("default-object-override-value"), '{"expected":"value"}') this.assert.deepEqual(this.controller.defaultObjectOverrideValue, { expected: "value" }) this.assert.ok(this.controller.hasDefaultObjectOverrideValue) } "test [name]ValueChanged callbacks fire after initialize and before connect"() { this.assert.deepEqual(this.controller.lifecycleCallbacks, ["initialize", "defaultBooleanValueChanged", "connect"]) } has(name: string) { return this.element.hasAttribute(this.attr(name)) } get(name: string) { return this.element.getAttribute(this.attr(name)) } set(name: string, value: string) { return this.element.setAttribute(this.attr(name), value) } attr(name: string) { return `data-${this.identifier}-${name}` } get element() { return this.controller.element } } ================================================ FILE: src/tests/modules/core/error_handler_tests.ts ================================================ import { Controller } from "../../../core/controller" import { Application } from "../../../core/application" import { ControllerTestCase } from "../../cases/controller_test_case" class MockLogger { errors: any[] = [] logs: any[] = [] warns: any[] = [] log(event: any) { this.logs.push(event) } error(event: any) { this.errors.push(event) } warn(event: any) { this.warns.push(event) } groupCollapsed() {} groupEnd() {} } class ErrorWhileConnectingController extends Controller { connect() { throw new Error("bad!") } } class TestApplicationWithDefaultErrorBehavior extends Application {} export default class ErrorHandlerTests extends ControllerTestCase(ErrorWhileConnectingController) { controllerConstructor = ErrorWhileConnectingController async setupApplication() { const logger = new MockLogger() this.application = new TestApplicationWithDefaultErrorBehavior(this.fixtureElement, this.schema) this.application.logger = logger window.onerror = function (message, source, lineno, colno, _error) { logger.log( `error from window.onerror. message = ${message}, source = ${source}, lineno = ${lineno}, colno = ${colno}` ) } super.setupApplication() } async "test errors in connect are thrown and handled by built in logger"() { const mockLogger: any = this.application.logger // when `ErrorWhileConnectingController#connect` throws, the controller's application's logger's `error` function // is called; in this case that's `MockLogger#error`. this.assert.equal(1, mockLogger.errors.length) } async "test errors in connect are thrown and handled by window.onerror"() { const mockLogger: any = this.application.logger this.assert.equal(1, mockLogger.logs.length) this.assert.equal( "error from window.onerror. message = Error connecting controller, source = , lineno = 0, colno = 0", mockLogger.logs[0] ) } } ================================================ FILE: src/tests/modules/core/es6_tests.ts ================================================ import { LogController } from "../../controllers/log_controller" import { LogControllerTestCase } from "../../cases/log_controller_test_case" export default class ES6Tests extends LogControllerTestCase { static shouldSkipTest(_testName: string) { return !(supportsES6Classes() && supportsReflectConstruct()) } fixtureHTML = `
    ` fixtureScript = ` _stimulus.application.register("es6", class extends _stimulus.LogController {}) ` async renderFixture() { ;(window as any)["_stimulus"] = { LogController, application: this.application } await super.renderFixture() const scriptElement = document.createElement("script") scriptElement.textContent = this.fixtureScript this.fixtureElement.appendChild(scriptElement) await this.nextFrame } async teardown() { this.application.unload("test") delete (window as any)["_stimulus"] } async "test ES6 controller classes"() { await this.triggerEvent("button", "click") this.assertActions({ eventType: "click", currentTarget: this.findElement("button") }) } } function supportsES6Classes() { try { return eval("(class {}), true") } catch (error) { return false } } function supportsReflectConstruct() { return typeof Reflect == "object" && typeof Reflect.construct == "function" } ================================================ FILE: src/tests/modules/core/event_options_tests.ts ================================================ import type { Controller } from "src/core" import { LogControllerTestCase } from "../../cases/log_controller_test_case" export default class EventOptionsTests extends LogControllerTestCase { identifier = ["c", "d"] fixtureHTML = `
    ` async "test different syntaxes for once action"() { await this.setAction(this.buttonElement, "click->c#log:once d#log2:once c#log3:once") await this.triggerEvent(this.buttonElement, "click") await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement } ) } async "test mix once and standard actions"() { await this.setAction(this.buttonElement, "c#log:once d#log2 c#log3") await this.triggerEvent(this.buttonElement, "click") await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement } ) } async "test stop propagation with once"() { await this.setAction(this.buttonElement, "c#stop:once c#log") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "stop", identifier: "c", eventType: "click", currentTarget: this.buttonElement }) await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "stop", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement } ) } async "test global once actions"() { await this.setAction(this.buttonElement, "keydown@window->c#log:once") await this.triggerEvent("#outside", "keydown") await this.triggerEvent("#outside", "keydown") this.assertActions({ name: "log", eventType: "keydown" }) } async "test edge case when updating action list with setAttribute preserves once history"() { await this.setAction(this.buttonElement, "c#log:once") await this.triggerEvent(this.buttonElement, "click") await this.triggerEvent(this.buttonElement, "click") //modify with a setAttribute and c#log should not be called anyhow await this.setAction(this.buttonElement, "c#log2 c#log:once d#log") await this.triggerEvent(this.buttonElement, "click") this.assertActions( { name: "log", identifier: "c" }, { name: "log2", identifier: "c" }, { name: "log", identifier: "d" } ) } async "test default passive action"() { await this.setAction(this.buttonElement, "scroll->c#logPassive:passive") await this.triggerEvent(this.buttonElement, "scroll", { setDefaultPrevented: false }) this.assertActions({ name: "logPassive", eventType: "scroll", passive: true }) } async "test global passive actions"() { await this.setAction(this.buttonElement, "mouseup@window->c#logPassive:passive") await this.triggerEvent("#outside", "mouseup", { setDefaultPrevented: false }) this.assertActions({ name: "logPassive", eventType: "mouseup", passive: true }) } async "test passive false actions"() { // by default touchmove is true in chrome await this.setAction(this.buttonElement, "touchmove@window->c#logPassive:!passive") await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) this.assertActions({ name: "logPassive", eventType: "touchmove", passive: false }) } async "test multiple options"() { // by default touchmove is true in chrome await this.setAction(this.buttonElement, "touchmove@window->c#logPassive:once:!passive") await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) this.assertActions({ name: "logPassive", eventType: "touchmove", passive: false }) } async "test wrong options are silently ignored"() { await this.setAction(this.buttonElement, "c#log:wrong:verywrong") await this.triggerEvent(this.buttonElement, "click") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "log", identifier: "c" }, { name: "log", identifier: "c" }) } async "test stop option with implicit event"() { await this.setAction(this.element, "click->c#log") await this.setAction(this.buttonElement, "c#log2:stop") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "log2", eventType: "click" }) } async "test stop option with explicit event"() { await this.setAction(this.element, "keydown->c#log") await this.setAction(this.buttonElement, "keydown->c#log2:stop") await this.triggerEvent(this.buttonElement, "keydown") this.assertActions({ name: "log2", eventType: "keydown" }) } async "test event propagation without stop option"() { await this.setAction(this.element, "click->c#log") await this.setAction(this.buttonElement, "c#log2") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "log2", eventType: "click" }, { name: "log", eventType: "click" }) } async "test prevent option with implicit event"() { await this.setAction(this.buttonElement, "c#log:prevent") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "log", eventType: "click", defaultPrevented: true }) } async "test prevent option with explicit event"() { await this.setAction(this.buttonElement, "keyup->c#log:prevent") await this.triggerEvent(this.buttonElement, "keyup") this.assertActions({ name: "log", eventType: "keyup", defaultPrevented: true }) } async "test self option"() { await this.setAction(this.buttonElement, "click->c#log:self") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "log", eventType: "click" }) } async "test self option on parent"() { await this.setAction(this.element, "click->c#log:self") await this.triggerEvent(this.buttonElement, "click") this.assertNoActions() } async "test custom action option callback params contain the controller instance"() { let lastActionOptions: { controller?: Controller } = {} const mockCallback = (options: Object) => { lastActionOptions = options } this.application.registerActionOption("all", (options: Object) => { mockCallback(options) return true }) await this.setAction(this.buttonElement, "click->c#log:all") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }) this.assert.deepEqual(["name", "value", "event", "element", "controller"], Object.keys(lastActionOptions)) this.assert.equal( lastActionOptions.controller, this.application.getControllerForElementAndIdentifier(this.element, "c") ) this.controllerConstructor.actionLog = [] // clear actions await this.setAction(this.buttonElement, "click->d#log:all") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "log", identifier: "d", eventType: "click", currentTarget: this.buttonElement }) this.assert.deepEqual(["name", "value", "event", "element", "controller"], Object.keys(lastActionOptions)) this.assert.equal( lastActionOptions.controller, this.application.getControllerForElementAndIdentifier(this.element, "d") ) } async "test custom option"() { this.application.registerActionOption("open", ({ value, event: { type, target } }) => { switch (type) { case "toggle": return target instanceof HTMLDetailsElement && target.open == value default: return true } }) await this.setAction(this.detailsElement, "toggle->c#log:open") await this.toggleElement(this.detailsElement) await this.toggleElement(this.detailsElement) await this.toggleElement(this.detailsElement) this.assertActions({ name: "log", eventType: "toggle" }, { name: "log", eventType: "toggle" }) } async "test inverted custom option"() { this.application.registerActionOption("open", ({ value, event: { type, target } }) => { switch (type) { case "toggle": return target instanceof HTMLDetailsElement && target.open == value default: return true } }) await this.setAction(this.detailsElement, "toggle->c#log:!open") await this.toggleElement(this.detailsElement) await this.toggleElement(this.detailsElement) await this.toggleElement(this.detailsElement) this.assertActions({ name: "log", eventType: "toggle" }) } async "test custom action option callback event contains params"() { let lastActionEventParams: Object = {} // clone the params to ensure we check the value as the callback receives it // not the event after all actions have resolved const mockCallback = ({ event: { params = {} } = {} }) => { lastActionEventParams = { ...params } } this.application.registerActionOption("all", (options: Object) => { mockCallback(options) return true }) this.buttonElement.setAttribute("data-c-custom-number-param", "41") this.buttonElement.setAttribute("data-c-custom-string-param", "validation") this.buttonElement.setAttribute("data-c-custom-boolean-param", "true") this.buttonElement.setAttribute("data-d-should-ignore-param", "_IGNORED_") await this.setAction(this.buttonElement, "click->c#log:all") await this.triggerEvent(this.buttonElement, "click") this.assertActions({ name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }) const expectedEventParams = { customBoolean: true, customNumber: 41, customString: "validation", } this.assert.deepEqual(this.controllerConstructor.actionLog[0].params, expectedEventParams) this.assert.deepEqual(lastActionEventParams, expectedEventParams) } setAction(element: Element, value: string) { element.setAttribute("data-action", value) return this.nextFrame } toggleElement(details: Element) { details.toggleAttribute("open") return this.nextFrame } get element() { return this.findElement("div") } get buttonElement() { return this.findElement("button") } get detailsElement() { return this.findElement("details") } } ================================================ FILE: src/tests/modules/core/extending_application_tests.ts ================================================ import { Application } from "../../../core/application" import { DOMTestCase } from "../../cases/dom_test_case" import { ActionDescriptorFilter } from "src/core/action_descriptor" const mockCallback: { (label: string): void; lastCall: string | null } = (label: string) => { mockCallback.lastCall = label } mockCallback.lastCall = null class TestApplicationWithCustomBehavior extends Application { registerActionOption(name: string, filter: ActionDescriptorFilter): void { mockCallback(`registerActionOption:${name}`) super.registerActionOption(name, filter) } } export default class ExtendingApplicationTests extends DOMTestCase { application!: Application async runTest(testName: string) { try { // use the documented way to start & reference only the returned application instance this.application = TestApplicationWithCustomBehavior.start(this.fixtureElement) await super.runTest(testName) } finally { this.application.stop() } } async setup() { mockCallback.lastCall = null } async teardown() { mockCallback.lastCall = null } async "test extended class method is supported when using MyApplication.start()"() { this.assert.equal(mockCallback.lastCall, null) const mockTrue = () => true this.application.registerActionOption("kbd", mockTrue) this.assert.equal(this.application.actionDescriptorFilters["kbd"], mockTrue) this.assert.equal(mockCallback.lastCall, "registerActionOption:kbd") const mockFalse = () => false this.application.registerActionOption("xyz", mockFalse) this.assert.equal(this.application.actionDescriptorFilters["xyz"], mockFalse) this.assert.equal(mockCallback.lastCall, "registerActionOption:xyz") } } ================================================ FILE: src/tests/modules/core/legacy_target_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" import { TargetController } from "../../controllers/target_controller" export default class LegacyTargetTests extends ControllerTestCase(TargetController) { fixtureHTML = `
    ` warningCount = 0 async setupApplication() { super.setupApplication() this.application.logger = Object.create(console, { warn: { value: () => this.warningCount++, }, }) } "test TargetSet#find"() { this.assert.equal(this.controller.targets.find("alpha"), this.findElement("#alpha1")) this.assert.equal(this.warningCount, 1) } "test TargetSet#find prefers scoped target attributes"() { this.assert.equal(this.controller.targets.find("gamma"), this.findElement("#beta1")) this.assert.equal(this.warningCount, 0) } "test TargetSet#findAll"() { this.assert.deepEqual(this.controller.targets.findAll("alpha"), this.findElements("#alpha1", "#alpha2")) this.assert.equal(this.warningCount, 2) } "test TargetSet#findAll prioritizes scoped target attributes"() { this.assert.deepEqual(this.controller.targets.findAll("gamma"), this.findElements("#beta1", "#gamma1")) this.assert.equal(this.warningCount, 1) } "test TargetSet#findAll with multiple arguments"() { this.assert.deepEqual( this.controller.targets.findAll("alpha", "beta"), this.findElements("#alpha1", "#alpha2", "#beta1") ) this.assert.equal(this.warningCount, 3) } "test TargetSet#has"() { this.assert.equal(this.controller.targets.has("gamma"), true) this.assert.equal(this.controller.targets.has("delta"), false) this.assert.equal(this.warningCount, 0) } "test TargetSet#find ignores child controller targets"() { this.assert.equal(this.controller.targets.find("delta"), null) this.findElement("#child").removeAttribute("data-controller") this.assert.equal(this.controller.targets.find("delta"), this.findElement("#delta1")) this.assert.equal(this.warningCount, 1) } "test linked target properties"() { this.assert.equal(this.controller.betaTarget, this.findElement("#beta1")) this.assert.deepEqual(this.controller.betaTargets, this.findElements("#beta1")) this.assert.equal(this.controller.hasBetaTarget, true) this.assert.equal(this.warningCount, 1) } "test inherited linked target properties"() { this.assert.equal(this.controller.alphaTarget, this.findElement("#alpha1")) this.assert.deepEqual(this.controller.alphaTargets, this.findElements("#alpha1", "#alpha2")) this.assert.equal(this.warningCount, 2) } "test singular linked target property throws an error when no target is found"() { this.findElement("#beta1").removeAttribute("data-target") this.assert.equal(this.controller.hasBetaTarget, false) this.assert.equal(this.controller.betaTargets.length, 0) this.assert.throws(() => this.controller.betaTarget) } } ================================================ FILE: src/tests/modules/core/lifecycle_tests.ts ================================================ import { LogControllerTestCase } from "../../cases/log_controller_test_case" export default class LifecycleTests extends LogControllerTestCase { controllerElement!: Element async setup() { this.controllerElement = this.controller.element } async "test Controller#initialize"() { const controller = this.controller this.assert.equal(controller.initializeCount, 1) await this.reconnectControllerElement() this.assert.equal(this.controller, controller) this.assert.equal(controller.initializeCount, 1) } async "test Controller#connect"() { this.assert.equal(this.controller.connectCount, 1) await this.reconnectControllerElement() this.assert.equal(this.controller.connectCount, 2) } async "test Controller#disconnect"() { const controller = this.controller this.assert.equal(controller.disconnectCount, 0) await this.disconnectControllerElement() this.assert.equal(controller.disconnectCount, 1) } async reconnectControllerElement() { await this.disconnectControllerElement() await this.connectControllerElement() } async connectControllerElement() { this.fixtureElement.appendChild(this.controllerElement) await this.nextFrame } async disconnectControllerElement() { this.fixtureElement.removeChild(this.controllerElement) await this.nextFrame } } ================================================ FILE: src/tests/modules/core/loading_tests.ts ================================================ import { ApplicationTestCase } from "../../cases/application_test_case" import { LogController } from "../../controllers/log_controller" class UnloadableController extends LogController { static get shouldLoad() { return false } } class LoadableController extends LogController { static get shouldLoad() { return true } } class AfterLoadController extends LogController { static values = { example: { default: "demo", type: String }, } static afterLoad(identifier: string, application: any) { const newElement = document.createElement("div") newElement.classList.add("after-load-test") newElement.setAttribute(application.schema.controllerAttribute, identifier) application.element.append(newElement) document.dispatchEvent( new CustomEvent("test", { detail: { identifier, application, exampleDefault: this.values.example.default, controller: this }, }) ) } } export default class ApplicationTests extends ApplicationTestCase { fixtureHTML = `
    ` "test module with false shouldLoad should not load when registering"() { this.application.register("unloadable", UnloadableController) this.assert.equal(this.controllers.length, 0) } "test module with true shouldLoad should load when registering"() { this.application.register("loadable", LoadableController) this.assert.equal(this.controllers.length, 1) } "test module with afterLoad method should be triggered when registered"() { // set up an event listener to track the params passed into the AfterLoadController let data: { application?: any; identifier?: string; exampleDefault?: string; controller?: any } = {} document.addEventListener("test", (({ detail }: CustomEvent) => { data = detail }) as EventListener) this.assert.equal(data.application, undefined) this.assert.equal(data.controller, undefined) this.assert.equal(data.exampleDefault, undefined) this.assert.equal(data.identifier, undefined) this.application.register("after-load", AfterLoadController) // check the DOM element has been added based on params provided this.assert.equal(this.findElements('[data-controller="after-load"]').length, 1) // check that static method was correctly called with the params this.assert.equal(data.application, this.application) this.assert.equal(data.controller, AfterLoadController) this.assert.equal(data.exampleDefault, "demo") this.assert.equal(data.identifier, "after-load") } get controllers() { return this.application.controllers as LogController[] } } ================================================ FILE: src/tests/modules/core/memory_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" export default class MemoryTests extends ControllerTestCase() { controllerElement!: Element async setup() { this.controllerElement = this.controller.element } fixtureHTML = `
    ` async "test removing a controller clears dangling eventListeners"() { this.assert.equal(this.application.dispatcher.eventListeners.length, 2) await this.fixtureElement.removeChild(this.controllerElement) this.assert.equal(this.application.dispatcher.eventListeners.length, 0) } } ================================================ FILE: src/tests/modules/core/outlet_order_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" import { OutletController } from "../../controllers/outlet_controller" const connectOrder: string[] = [] class OutletOrderController extends OutletController { connect() { connectOrder.push(`${this.identifier}-${this.element.id}-start`) super.connect() connectOrder.push(`${this.identifier}-${this.element.id}-end`) } } export default class OutletOrderTests extends ControllerTestCase(OutletOrderController) { fixtureHTML = `
    Search
    Beta
    Beta
    Beta
    ` get identifiers() { return ["alpha", "beta"] } async "test can access outlets in connect() even if they are referenced before they are connected"() { this.assert.equal(this.controller.betaOutletsInConnectValue, 3) this.controller.betaOutlets.forEach((outlet) => { this.assert.equal(outlet.identifier, "beta") this.assert.equal(Array.from(outlet.element.classList.values()), "beta") }) this.assert.deepEqual(connectOrder, [ "alpha-alpha1-start", "beta-beta-1-start", "beta-beta-1-end", "beta-beta-2-start", "beta-beta-2-end", "beta-beta-3-start", "beta-beta-3-end", "alpha-alpha1-end", ]) } } ================================================ FILE: src/tests/modules/core/outlet_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" import { OutletController } from "../../controllers/outlet_controller" export default class OutletTests extends ControllerTestCase(OutletController) { fixtureHTML = `
    ` get identifiers() { return ["test", "alpha", "beta", "gamma", "delta", "omega", "namespaced--epsilon"] } "test OutletSet#find"() { this.assert.equal(this.controller.outlets.find("alpha"), this.findElement("#alpha1")) this.assert.equal(this.controller.outlets.find("beta"), this.findElement("#beta1")) this.assert.equal(this.controller.outlets.find("delta"), this.findElement("#delta1")) this.assert.equal(this.controller.outlets.find("namespaced--epsilon"), this.findElement("#epsilon1")) } "test OutletSet#findAll"() { this.assert.deepEqual(this.controller.outlets.findAll("alpha"), this.findElements("#alpha1", "#alpha2")) this.assert.deepEqual(this.controller.outlets.findAll("beta"), this.findElements("#beta1", "#beta2")) this.assert.deepEqual( this.controller.outlets.findAll("namespaced--epsilon"), this.findElements("#epsilon1", "#epsilon2") ) } "test OutletSet#findAll with multiple arguments"() { this.assert.deepEqual( this.controller.outlets.findAll("alpha", "beta", "namespaced--epsilon"), this.findElements("#alpha1", "#alpha2", "#beta1", "#beta2", "#epsilon1", "#epsilon2") ) } "test OutletSet#has"() { this.assert.equal(this.controller.outlets.has("alpha"), true) this.assert.equal(this.controller.outlets.has("beta"), true) this.assert.equal(this.controller.outlets.has("gamma"), false) this.assert.equal(this.controller.outlets.has("delta"), true) this.assert.equal(this.controller.outlets.has("omega"), false) this.assert.equal(this.controller.outlets.has("namespaced--epsilon"), true) } "test OutletSet#has when attribute gets added later"() { this.assert.equal(this.controller.outlets.has("gamma"), false) this.controller.element.setAttribute(`data-${this.identifier}-gamma-outlet`, ".gamma") this.assert.equal(this.controller.outlets.has("gamma"), true) } "test OutletSet#has when no element with selector exists"() { this.controller.element.setAttribute(`data-${this.identifier}-gamma-outlet`, "#doesntexist") this.assert.equal(this.controller.outlets.has("gamma"), false) } "test OutletSet#has when selector matches but element doesn't have the right controller"() { this.controller.element.setAttribute(`data-${this.identifier}-gamma-outlet`, ".alpha") this.assert.equal(this.controller.outlets.has("gamma"), false) } "test linked outlet properties"() { const element = this.findElement("#beta1") const betaOutlet = this.controller.application.getControllerForElementAndIdentifier(element, "beta") this.assert.equal(this.controller.betaOutlet, betaOutlet) this.assert.equal(this.controller.betaOutletElement, element) const elements = this.findElements("#beta1", "#beta2") const betaOutlets = elements.map((element) => this.controller.application.getControllerForElementAndIdentifier(element, "beta") ) this.assert.deepEqual(this.controller.betaOutlets, betaOutlets) this.assert.deepEqual(this.controller.betaOutletElements, elements) this.assert.equal(this.controller.hasBetaOutlet, true) } "test inherited linked outlet properties"() { const element = this.findElement("#alpha1") const alphaOutlet = this.controller.application.getControllerForElementAndIdentifier(element, "alpha") this.assert.equal(this.controller.alphaOutlet, alphaOutlet) this.assert.equal(this.controller.alphaOutletElement, element) const elements = this.findElements("#alpha1", "#alpha2") const alphaOutlets = elements.map((element) => this.controller.application.getControllerForElementAndIdentifier(element, "alpha") ) this.assert.deepEqual(this.controller.alphaOutlets, alphaOutlets) this.assert.deepEqual(this.controller.alphaOutletElements, elements) } "test singular linked outlet property throws an error when no outlet is found"() { this.findElements("#alpha1", "#alpha2").forEach((e) => { e.removeAttribute("id") e.removeAttribute("class") e.removeAttribute("data-controller") }) this.assert.equal(this.controller.hasAlphaOutlet, false) this.assert.equal(this.controller.alphaOutlets.length, 0) this.assert.equal(this.controller.alphaOutletElements.length, 0) this.assert.throws(() => this.controller.alphaOutlet) this.assert.throws(() => this.controller.alphaOutletElement) } async "test outlet connected callback fires"() { const alphaOutlets = this.controller.alphaOutletElements.filter((outlet) => outlet.classList.contains("connected")) this.assert.equal(alphaOutlets.length, 2) this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2) } "test outlet connected callback fires for namespaced outlets"() { const epsilonOutlets = this.controller.namespacedEpsilonOutletElements.filter((outlet) => outlet.classList.contains("connected") ) this.assert.equal(epsilonOutlets.length, 2) this.assert.equal(this.controller.namespacedEpsilonOutletConnectedCallCountValue, 2) } async "test outlet connected callback when element is inserted"() { const betaOutletElement = document.createElement("div") await this.setAttribute(betaOutletElement, "class", "beta") await this.setAttribute(betaOutletElement, "data-controller", "beta") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) await this.appendChild(this.controller.element, betaOutletElement) this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) this.assert.ok( betaOutletElement.classList.contains("connected"), `expected "${betaOutletElement.className}" to contain "connected"` ) this.assert.ok(betaOutletElement.isConnected, "element is present in document") await this.appendChild("#container", betaOutletElement.cloneNode(true)) this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 4) } async "test outlet connected callback when present element adds matching outlet selector attribute"() { const element = this.findElement("#beta3") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) await this.setAttribute(element, "data-controller", "beta") await this.setAttribute(element, "class", "beta") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) this.assert.ok(element.isConnected, "element is still present in document") } async "test outlet connected callback when present element already has connected controller and adds matching outlet selector attribute"() { const element = this.findElement("#beta4") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) await this.setAttribute(element, "class", "beta") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) this.assert.ok(element.isConnected, "element is still present in document") } async "test outlet connect callback when an outlet present in the document adds a matching data-controller attribute"() { const element = this.findElement("#beta5") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) await this.setAttribute(element, "data-controller", "beta") this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) this.assert.ok(element.isConnected, "element is still present in document") } async "test outlet disconnected callback fires when calling disconnect() on the controller"() { this.assert.equal( this.controller.alphaOutletElements.filter((outlet) => outlet.classList.contains("disconnected")).length, 0 ) this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) this.controller.context.disconnect() await this.nextFrame this.assert.equal( this.controller.alphaOutletElements.filter((outlet) => outlet.classList.contains("disconnected")).length, 2 ) this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 2) } async "test outlet disconnected callback when element is removed"() { const disconnectedAlpha = this.findElement("#alpha1") this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) this.assert.notOk( disconnectedAlpha.classList.contains("disconnected"), `expected "${disconnectedAlpha.className}" not to contain "disconnected"` ) await this.remove(disconnectedAlpha) this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) this.assert.ok( disconnectedAlpha.classList.contains("disconnected"), `expected "${disconnectedAlpha.className}" to contain "disconnected"` ) this.assert.notOk(disconnectedAlpha.isConnected, "element is not present in document") } async "test outlet disconnected callback when element is removed with namespaced outlet"() { const disconnectedEpsilon = this.findElement("#epsilon1") this.assert.equal(this.controller.namespacedEpsilonOutletDisconnectedCallCountValue, 0) this.assert.notOk( disconnectedEpsilon.classList.contains("disconnected"), `expected "${disconnectedEpsilon.className}" not to contain "disconnected"` ) await this.remove(disconnectedEpsilon) this.assert.equal(this.controller.namespacedEpsilonOutletDisconnectedCallCountValue, 1) this.assert.ok( disconnectedEpsilon.classList.contains("disconnected"), `expected "${disconnectedEpsilon.className}" to contain "disconnected"` ) this.assert.notOk(disconnectedEpsilon.isConnected, "element is not present in document") } async "test outlet disconnected callback when an outlet present in the document removes the selector attribute"() { const element = this.findElement("#alpha1") this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) this.assert.notOk( element.classList.contains("disconnected"), `expected "${element.className}" not to contain "disconnected"` ) await this.removeAttribute(element, "id") this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) this.assert.ok( element.classList.contains("disconnected"), `expected "${element.className}" to contain "disconnected"` ) this.assert.ok(element.isConnected, "element is still present in document") } async "test outlet disconnected callback when an outlet present in the document removes the data-controller attribute"() { const element = this.findElement("#alpha1") this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) this.assert.notOk( element.classList.contains("disconnected"), `expected "${element.className}" not to contain "disconnected"` ) await this.removeAttribute(element, "data-controller") this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) this.assert.ok( element.classList.contains("disconnected"), `expected "${element.className}" to contain "disconnected"` ) this.assert.ok(element.isConnected, "element is still present in document") } async "test outlet connect callback when the controlled element's outlet attribute is added"() { const gamma2 = this.findElement("#gamma2") await this.setAttribute(this.controller.element, `data-${this.identifier}-gamma-outlet`, "#gamma2") this.assert.equal(this.controller.gammaOutletConnectedCallCountValue, 1) this.assert.ok(gamma2.isConnected, "#gamma2 is still present in document") this.assert.ok(gamma2.classList.contains("connected"), `expected "${gamma2.className}" to contain "connected"`) } async "test outlet connect callback doesn't get trigged when any attribute gets added to the controller element"() { this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2) this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) this.assert.equal(this.controller.gammaOutletConnectedCallCountValue, 0) this.assert.equal(this.controller.namespacedEpsilonOutletConnectedCallCountValue, 2) await this.setAttribute(this.controller.element, "data-some-random-attribute", "#alpha1") this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2) this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) this.assert.equal(this.controller.gammaOutletConnectedCallCountValue, 0) this.assert.equal(this.controller.namespacedEpsilonOutletConnectedCallCountValue, 2) this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) this.assert.equal(this.controller.betaOutletDisconnectedCallCountValue, 0) this.assert.equal(this.controller.gammaOutletDisconnectedCallCountValue, 0) this.assert.equal(this.controller.namespacedEpsilonOutletDisconnectedCallCountValue, 0) } async "test outlet connect callback when the controlled element's outlet attribute is changed"() { const alpha1 = this.findElement("#alpha1") const alpha2 = this.findElement("#alpha2") await this.setAttribute(this.controller.element, `data-${this.identifier}-alpha-outlet`, "#alpha1") this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2) this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) this.assert.ok(alpha1.isConnected, "alpha1 is still present in document") this.assert.ok(alpha2.isConnected, "alpha2 is still present in document") this.assert.ok(alpha1.classList.contains("connected"), `expected "${alpha1.className}" to contain "connected"`) this.assert.notOk( alpha1.classList.contains("disconnected"), `expected "${alpha1.className}" to contain "disconnected"` ) this.assert.ok( alpha2.classList.contains("disconnected"), `expected "${alpha2.className}" to contain "disconnected"` ) } async "test outlet disconnected callback when the controlled element's outlet attribute is removed"() { const alpha1 = this.findElement("#alpha1") const alpha2 = this.findElement("#alpha2") await this.removeAttribute(this.controller.element, `data-${this.identifier}-alpha-outlet`) this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 2) this.assert.ok(alpha1.isConnected, "#alpha1 is still present in document") this.assert.ok(alpha2.isConnected, "#alpha2 is still present in document") this.assert.ok( alpha1.classList.contains("disconnected"), `expected "${alpha1.className}" to contain "disconnected"` ) this.assert.ok( alpha2.classList.contains("disconnected"), `expected "${alpha2.className}" to contain "disconnected"` ) } } ================================================ FILE: src/tests/modules/core/string_helpers_tests.ts ================================================ import { TestCase } from "../../cases/test_case" import * as helpers from "../../../core/string_helpers" export default class StringHelpersTests extends TestCase { "test should camelize strings"() { this.assert.equal(helpers.camelize("underscore_value"), "underscoreValue") this.assert.equal(helpers.camelize("Underscore_value"), "UnderscoreValue") this.assert.equal(helpers.camelize("underscore_Value"), "underscore_Value") this.assert.equal(helpers.camelize("Underscore_Value"), "Underscore_Value") this.assert.equal(helpers.camelize("multi_underscore_value"), "multiUnderscoreValue") this.assert.equal(helpers.camelize("dash-value"), "dashValue") this.assert.equal(helpers.camelize("Dash-value"), "DashValue") this.assert.equal(helpers.camelize("dash-Value"), "dash-Value") this.assert.equal(helpers.camelize("Dash-Value"), "Dash-Value") this.assert.equal(helpers.camelize("multi-dash-value"), "multiDashValue") } "test should namespace camelize strings"() { this.assert.equal(helpers.namespaceCamelize("underscore__value"), "underscoreValue") this.assert.equal(helpers.namespaceCamelize("Underscore__value"), "UnderscoreValue") this.assert.equal(helpers.namespaceCamelize("underscore__Value"), "underscore_Value") this.assert.equal(helpers.namespaceCamelize("Underscore__Value"), "Underscore_Value") this.assert.equal(helpers.namespaceCamelize("multi__underscore__value"), "multiUnderscoreValue") this.assert.equal(helpers.namespaceCamelize("dash--value"), "dashValue") this.assert.equal(helpers.namespaceCamelize("Dash--value"), "DashValue") this.assert.equal(helpers.namespaceCamelize("dash--Value"), "dash-Value") this.assert.equal(helpers.namespaceCamelize("Dash--Value"), "Dash-Value") this.assert.equal(helpers.namespaceCamelize("multi--dash--value"), "multiDashValue") } "test should dasherize strings"() { this.assert.equal(helpers.dasherize("camelizedValue"), "camelized-value") this.assert.equal(helpers.dasherize("longCamelizedValue"), "long-camelized-value") } "test should capitalize strings"() { this.assert.equal(helpers.capitalize("lowercase"), "Lowercase") this.assert.equal(helpers.capitalize("Uppercase"), "Uppercase") } "test should tokenize strings"() { this.assert.deepEqual(helpers.tokenize(""), []) this.assert.deepEqual(helpers.tokenize("one"), ["one"]) this.assert.deepEqual(helpers.tokenize("two words"), ["two", "words"]) this.assert.deepEqual(helpers.tokenize("a_lot of-words with special--chars mixed__in"), [ "a_lot", "of-words", "with", "special--chars", "mixed__in", ]) } } ================================================ FILE: src/tests/modules/core/target_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" import { TargetController } from "../../controllers/target_controller" export default class TargetTests extends ControllerTestCase(TargetController) { fixtureHTML = `
    ` "test TargetSet#find"() { this.assert.equal(this.controller.targets.find("alpha"), this.findElement("#alpha1")) } "test TargetSet#findAll"() { this.assert.deepEqual(this.controller.targets.findAll("alpha"), this.findElements("#alpha1", "#alpha2")) } "test TargetSet#findAll with multiple arguments"() { this.assert.deepEqual( this.controller.targets.findAll("alpha", "beta"), this.findElements("#alpha1", "#alpha2", "#beta1") ) } "test TargetSet#has"() { this.assert.equal(this.controller.targets.has("gamma"), true) this.assert.equal(this.controller.targets.has("delta"), false) } "test TargetSet#find ignores child controller targets"() { this.assert.equal(this.controller.targets.find("delta"), null) this.findElement("#child").removeAttribute("data-controller") this.assert.equal(this.controller.targets.find("delta"), this.findElement("#delta1")) } "test linked target properties"() { this.assert.equal(this.controller.betaTarget, this.findElement("#beta1")) this.assert.deepEqual(this.controller.betaTargets, this.findElements("#beta1")) this.assert.equal(this.controller.hasBetaTarget, true) } "test inherited linked target properties"() { this.assert.equal(this.controller.alphaTarget, this.findElement("#alpha1")) this.assert.deepEqual(this.controller.alphaTargets, this.findElements("#alpha1", "#alpha2")) } "test singular linked target property throws an error when no target is found"() { this.findElement("#beta1").removeAttribute(`data-${this.identifier}-target`) this.assert.equal(this.controller.hasBetaTarget, false) this.assert.equal(this.controller.betaTargets.length, 0) this.assert.throws(() => this.controller.betaTarget) } "test target connected callback fires after initialize() and when calling connect()"() { const connectedInputs = this.controller.inputTargets.filter((target) => target.classList.contains("connected")) this.assert.equal(connectedInputs.length, 1) this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) } async "test target connected callback when element is inserted"() { const connectedInput = document.createElement("input") connectedInput.setAttribute(`data-${this.controller.identifier}-target`, "input") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) this.controller.element.appendChild(connectedInput) await this.nextFrame this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 2) this.assert.ok( connectedInput.classList.contains("connected"), `expected "${connectedInput.className}" to contain "connected"` ) this.assert.ok(connectedInput.isConnected, "element is present in document") } async "test target connected callback when present element adds the target attribute"() { const element = this.findElement("#alpha1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) element.setAttribute(`data-${this.controller.identifier}-target`, "input") await this.nextFrame this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 2) this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) this.assert.ok(element.isConnected, "element is still present in document") } async "test target connected callback when element adds a token to an existing target attribute"() { const element = this.findElement("#alpha1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) element.setAttribute(`data-${this.controller.identifier}-target`, "alpha input") await this.nextFrame this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 2) this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) this.assert.ok(element.isConnected, "element is still present in document") } async "test target disconnected callback fires when calling disconnect() on the controller"() { this.assert.equal( this.controller.inputTargets.filter((target) => target.classList.contains("disconnected")).length, 0 ) this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) this.controller.context.disconnect() await this.nextFrame this.assert.equal( this.controller.inputTargets.filter((target) => target.classList.contains("disconnected")).length, 1 ) this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 1) } async "test target disconnected callback when element is removed"() { const disconnectedInput = this.findElement("#input1") this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) this.assert.notOk( disconnectedInput.classList.contains("disconnected"), `expected "${disconnectedInput.className}" not to contain "disconnected"` ) disconnectedInput.parentElement?.removeChild(disconnectedInput) await this.nextFrame this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 1) this.assert.ok( disconnectedInput.classList.contains("disconnected"), `expected "${disconnectedInput.className}" to contain "disconnected"` ) this.assert.notOk(disconnectedInput.isConnected, "element is not present in document") } async "test target disconnected callback when an element present in the document removes the target attribute"() { const element = this.findElement("#input1") this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) this.assert.notOk( element.classList.contains("disconnected"), `expected "${element.className}" not to contain "disconnected"` ) element.removeAttribute(`data-${this.controller.identifier}-target`) await this.nextFrame this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 1) this.assert.ok( element.classList.contains("disconnected"), `expected "${element.className}" to contain "disconnected"` ) this.assert.ok(element.isConnected, "element is still present in document") } async "test target disconnected(), then connected() callback fired when the target name is present after the attribute change"() { const element = this.findElement("#input1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) this.assert.notOk( element.classList.contains("disconnected"), `expected "${element.className}" not to contain "disconnected"` ) element.setAttribute(`data-${this.controller.identifier}-target`, "input") await this.nextFrame this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 2) this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 1) this.assert.ok( element.classList.contains("disconnected"), `expected "${element.className}" to contain "disconnected"` ) this.assert.ok(element.isConnected, "element is still present in document") } async "test [target]Connected() and [target]Disconnected() do not loop infinitely"() { this.controller.element.insertAdjacentHTML( "beforeend", `
    ` ) await this.nextFrame this.assert.ok(!!this.fixtureElement.querySelector("#recursive2")) this.assert.equal(this.controller.recursiveTargetConnectedCallCountValue, 1) this.assert.equal(this.controller.recursiveTargetDisconnectedCallCountValue, 0) } } ================================================ FILE: src/tests/modules/core/value_properties_tests.ts ================================================ import { ValueController } from "../../controllers/value_controller" import { ControllerTestCase } from "../../cases/controller_test_case" import { parseValueTypeDefault, parseValueTypeConstant, parseValueTypeObject, parseValueTypeDefinition, defaultValueForDefinition, } from "../../../core/value_properties" export default class ValuePropertiesTests extends ControllerTestCase(ValueController) { "test parseValueTypeConstant"() { this.assert.equal(parseValueTypeConstant(String), "string") this.assert.equal(parseValueTypeConstant(Boolean), "boolean") this.assert.equal(parseValueTypeConstant(Array), "array") this.assert.equal(parseValueTypeConstant(Object), "object") this.assert.equal(parseValueTypeConstant(Number), "number") this.assert.equal(parseValueTypeConstant("" as any), undefined) this.assert.equal(parseValueTypeConstant({} as any), undefined) this.assert.equal(parseValueTypeConstant([] as any), undefined) this.assert.equal(parseValueTypeConstant(true as any), undefined) this.assert.equal(parseValueTypeConstant(false as any), undefined) this.assert.equal(parseValueTypeConstant(0 as any), undefined) this.assert.equal(parseValueTypeConstant(1 as any), undefined) this.assert.equal(parseValueTypeConstant(null!), undefined) this.assert.equal(parseValueTypeConstant(undefined), undefined) } "test parseValueTypeDefault"() { this.assert.equal(parseValueTypeDefault(""), "string") this.assert.equal(parseValueTypeDefault("Some string"), "string") this.assert.equal(parseValueTypeDefault(true), "boolean") this.assert.equal(parseValueTypeDefault(false), "boolean") this.assert.equal(parseValueTypeDefault([]), "array") this.assert.equal(parseValueTypeDefault([1, 2, 3]), "array") this.assert.equal(parseValueTypeDefault([true, false, true]), "array") this.assert.equal(parseValueTypeDefault([{}, {}, {}]), "array") this.assert.equal(parseValueTypeDefault({}), "object") this.assert.equal(parseValueTypeDefault({ one: "key" }), "object") this.assert.equal(parseValueTypeDefault(-1), "number") this.assert.equal(parseValueTypeDefault(0), "number") this.assert.equal(parseValueTypeDefault(1), "number") this.assert.equal(parseValueTypeDefault(-0.1), "number") this.assert.equal(parseValueTypeDefault(0.0), "number") this.assert.equal(parseValueTypeDefault(0.1), "number") this.assert.equal(parseValueTypeDefault(null!), undefined) this.assert.equal(parseValueTypeDefault(undefined!), undefined) } "test parseValueTypeObject"() { const typeObject = (object: any) => { return parseValueTypeObject({ controller: this.controller.identifier, token: "url", typeObject: object, }) } this.assert.equal(typeObject({ type: String, default: "" }), "string") this.assert.equal(typeObject({ type: String, default: "123" }), "string") this.assert.equal(typeObject({ type: String }), "string") this.assert.equal(typeObject({ default: "" }), "string") this.assert.equal(typeObject({ default: "123" }), "string") this.assert.equal(typeObject({ type: Number, default: 0 }), "number") this.assert.equal(typeObject({ type: Number, default: 1 }), "number") this.assert.equal(typeObject({ type: Number, default: -1 }), "number") this.assert.equal(typeObject({ type: Number }), "number") this.assert.equal(typeObject({ default: 0 }), "number") this.assert.equal(typeObject({ default: 1 }), "number") this.assert.equal(typeObject({ default: -1 }), "number") this.assert.equal(typeObject({ type: Array, default: [] }), "array") this.assert.equal(typeObject({ type: Array, default: [1] }), "array") this.assert.equal(typeObject({ type: Array }), "array") this.assert.equal(typeObject({ default: [] }), "array") this.assert.equal(typeObject({ default: [1] }), "array") this.assert.equal(typeObject({ type: Object, default: {} }), "object") this.assert.equal(typeObject({ type: Object, default: { some: "key" } }), "object") this.assert.equal(typeObject({ type: Object }), "object") this.assert.equal(typeObject({ default: {} }), "object") this.assert.equal(typeObject({ default: { some: "key" } }), "object") this.assert.equal(typeObject({ type: Boolean, default: true }), "boolean") this.assert.equal(typeObject({ type: Boolean, default: false }), "boolean") this.assert.equal(typeObject({ type: Boolean }), "boolean") this.assert.equal(typeObject({ default: false }), "boolean") this.assert.throws(() => typeObject({ type: Boolean, default: "something else" }), { name: "Error", message: `The specified default value for the Stimulus Value "test.url" must match the defined type "boolean". The provided default value of "something else" is of type "string".`, }) this.assert.throws(() => typeObject({ type: Boolean, default: "true" }), { name: "Error", message: `The specified default value for the Stimulus Value "test.url" must match the defined type "boolean". The provided default value of "true" is of type "string".`, }) } "test parseValueTypeDefinition booleans"() { const typeDefinition = (definition: any) => { return parseValueTypeDefinition({ controller: this.controller.identifier, token: "url", typeDefinition: definition, }) } this.assert.equal(typeDefinition(Boolean), "boolean") this.assert.equal(typeDefinition(true), "boolean") this.assert.equal(typeDefinition(false), "boolean") this.assert.equal(typeDefinition({ type: Boolean, default: false }), "boolean") this.assert.equal(typeDefinition({ type: Boolean }), "boolean") this.assert.equal(typeDefinition({ default: true }), "boolean") // since the provided value is actually an object, it's going to be of type "object" this.assert.equal(typeDefinition({ default: null }), "object") this.assert.equal(typeDefinition({ default: undefined }), "object") this.assert.equal(typeDefinition({}), "object") this.assert.equal(typeDefinition(""), "string") this.assert.equal(typeDefinition([]), "array") this.assert.throws(() => typeDefinition(null)) this.assert.throws(() => typeDefinition(undefined)) } "test defaultValueForDefinition"() { this.assert.deepEqual(defaultValueForDefinition(String), "") this.assert.deepEqual(defaultValueForDefinition(Boolean), false) this.assert.deepEqual(defaultValueForDefinition(Object), {}) this.assert.deepEqual(defaultValueForDefinition(Array), []) this.assert.deepEqual(defaultValueForDefinition(Number), 0) this.assert.deepEqual(defaultValueForDefinition({ type: String }), "") this.assert.deepEqual(defaultValueForDefinition({ type: Boolean }), false) this.assert.deepEqual(defaultValueForDefinition({ type: Object }), {}) this.assert.deepEqual(defaultValueForDefinition({ type: Array }), []) this.assert.deepEqual(defaultValueForDefinition({ type: Number }), 0) this.assert.deepEqual(defaultValueForDefinition({ type: String, default: null }), null) this.assert.deepEqual(defaultValueForDefinition({ type: Boolean, default: null }), null) this.assert.deepEqual(defaultValueForDefinition({ type: Object, default: null }), null) this.assert.deepEqual(defaultValueForDefinition({ type: Array, default: null }), null) this.assert.deepEqual(defaultValueForDefinition({ type: Number, default: null }), null) this.assert.deepEqual(defaultValueForDefinition({ type: String, default: "some string" }), "some string") this.assert.deepEqual(defaultValueForDefinition({ type: Boolean, default: true }), true) this.assert.deepEqual(defaultValueForDefinition({ type: Object, default: { some: "key" } }), { some: "key" }) this.assert.deepEqual(defaultValueForDefinition({ type: Array, default: [1, 2, 3] }), [1, 2, 3]) this.assert.deepEqual(defaultValueForDefinition({ type: Number, default: 99 }), 99) this.assert.deepEqual(defaultValueForDefinition("some string"), "some string") this.assert.deepEqual(defaultValueForDefinition(true), true) this.assert.deepEqual(defaultValueForDefinition({ some: "key" }), { some: "key" }) this.assert.deepEqual(defaultValueForDefinition([1, 2, 3]), [1, 2, 3]) this.assert.deepEqual(defaultValueForDefinition(99), 99) this.assert.deepEqual(defaultValueForDefinition({ default: "some string" }), "some string") this.assert.deepEqual(defaultValueForDefinition({ default: true }), true) this.assert.deepEqual(defaultValueForDefinition({ default: { some: "key" } }), { some: "key" }) this.assert.deepEqual(defaultValueForDefinition({ default: [1, 2, 3] }), [1, 2, 3]) this.assert.deepEqual(defaultValueForDefinition({ default: 99 }), 99) this.assert.deepEqual(defaultValueForDefinition({ default: null }), null) this.assert.deepEqual(defaultValueForDefinition({ default: undefined }), undefined) } } ================================================ FILE: src/tests/modules/core/value_tests.ts ================================================ import { ControllerTestCase } from "../../cases/controller_test_case" import { ValueController } from "../../controllers/value_controller" export default class ValueTests extends ControllerTestCase(ValueController) { fixtureHTML = `
    ` "test string values"() { this.assert.deepEqual(this.controller.stringValue, "ok") this.controller.stringValue = "cool" this.assert.deepEqual(this.controller.stringValue, "cool") this.assert.deepEqual(this.get("string-value"), "cool") } "test numeric values"() { this.assert.deepEqual(this.controller.numericValue, 123) this.controller.numericValue = 456 this.assert.deepEqual(this.controller.numericValue, 456) this.assert.deepEqual(this.get("numeric-value"), "456") this.controller.numericValue = "789" as any this.assert.deepEqual(this.controller.numericValue, 789) this.controller.numericValue = 1.23 this.assert.deepEqual(this.controller.numericValue, 1.23) this.assert.deepEqual(this.get("numeric-value"), "1.23") this.controller.numericValue = Infinity this.assert.deepEqual(this.controller.numericValue, Infinity) this.assert.deepEqual(this.get("numeric-value"), "Infinity") this.controller.numericValue = "garbage" as any this.assert.ok(isNaN(this.controller.numericValue)) this.assert.equal(this.get("numeric-value"), "garbage") this.controller.numericValue = "" as any this.assert.equal(this.controller.numericValue, 0) this.assert.equal(this.get("numeric-value"), "") // Number values should support Numeric separators this.set("numeric-value", "7_150") this.assert.equal(this.controller.numericValue, 7150) // Number values should be written simply, without Numeric separators this.controller.numericValue = 10500 this.assert.deepEqual(this.get("numeric-value"), "10500") } "test boolean values"() { this.assert.deepEqual(this.controller.shadowedBooleanValue, true) this.controller.shadowedBooleanValue = false this.assert.deepEqual(this.controller.shadowedBooleanValue, false) this.assert.deepEqual(this.get("shadowed-boolean-value"), "false") this.controller.shadowedBooleanValue = "" as any this.assert.deepEqual(this.controller.shadowedBooleanValue, true) this.assert.deepEqual(this.get("shadowed-boolean-value"), "") this.controller.shadowedBooleanValue = 0 as any this.assert.deepEqual(this.controller.shadowedBooleanValue, false) this.assert.deepEqual(this.get("shadowed-boolean-value"), "0") this.controller.shadowedBooleanValue = 1 as any this.assert.deepEqual(this.controller.shadowedBooleanValue, true) this.assert.deepEqual(this.get("shadowed-boolean-value"), "1") this.controller.shadowedBooleanValue = "False" as any this.assert.deepEqual(this.controller.shadowedBooleanValue, false) this.assert.deepEqual(this.get("shadowed-boolean-value"), "False") } "test array values"() { this.assert.deepEqual(this.controller.idsValue, [1, 2, 3]) this.controller.idsValue.push(4) this.assert.deepEqual(this.controller.idsValue, [1, 2, 3]) this.controller.idsValue = [] this.assert.deepEqual(this.controller.idsValue, []) this.assert.deepEqual(this.get("ids-value"), "[]") this.controller.idsValue = null as any this.assert.throws(() => this.controller.idsValue) this.controller.idsValue = {} as any this.assert.throws(() => this.controller.idsValue) } "test object values"() { this.assert.deepEqual(this.controller.optionsValue, { one: [2, 3] }) this.controller.optionsValue["one"] = 0 this.assert.deepEqual(this.controller.optionsValue, { one: [2, 3] }) this.controller.optionsValue = {} this.assert.deepEqual(this.controller.optionsValue, {}) this.assert.deepEqual(this.get("options-value"), "{}") this.controller.optionsValue = null as any this.assert.throws(() => this.controller.optionsValue) this.controller.optionsValue = [] as any this.assert.throws(() => this.controller.optionsValue) } "test accessing a string value returns the empty string when the attribute is missing"() { this.controller.stringValue = undefined as any this.assert.notOk(this.has("string-value")) this.assert.deepEqual(this.controller.stringValue, "") } "test accessing a numeric value returns zero when the attribute is missing"() { this.controller.numericValue = undefined as any this.assert.notOk(this.has("numeric-value")) this.assert.deepEqual(this.controller.numericValue, 0) } "test accessing a boolean value returns false when the attribute is missing"() { this.controller.shadowedBooleanValue = undefined as any this.assert.notOk(this.has("shadowed-boolean-value")) this.assert.deepEqual(this.controller.shadowedBooleanValue, false) } "test accessing an array value returns an empty array when the attribute is missing"() { this.controller.idsValue = undefined as any this.assert.notOk(this.has("ids-value")) this.assert.deepEqual(this.controller.idsValue, []) this.controller.idsValue.push(1) this.assert.deepEqual(this.controller.idsValue, []) } "test accessing an object value returns an empty object when the attribute is missing"() { this.controller.optionsValue = undefined as any this.assert.notOk(this.has("options-value")) this.assert.deepEqual(this.controller.optionsValue, {}) this.controller.optionsValue.hello = true this.assert.deepEqual(this.controller.optionsValue, {}) } async "test changed callbacks"() { this.assert.deepEqual(this.controller.loggedNumericValues, [123]) this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0]) this.controller.numericValue = 0 await this.nextFrame this.assert.deepEqual(this.controller.loggedNumericValues, [123, 0]) this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0, 123]) this.set("numeric-value", "1") await this.nextFrame this.assert.deepEqual(this.controller.loggedNumericValues, [123, 0, 1]) this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0, 123, 0]) } async "test changed callbacks for object"() { this.assert.deepEqual(this.controller.optionsValues, [{ one: [2, 3] }]) this.assert.deepEqual(this.controller.oldOptionsValues, [{}]) this.controller.optionsValue = { person: { name: "John", age: 42, active: true } } await this.nextFrame this.assert.deepEqual(this.controller.optionsValues, [ { one: [2, 3] }, { person: { name: "John", age: 42, active: true } }, ]) this.assert.deepEqual(this.controller.oldOptionsValues, [{}, { one: [2, 3] }]) this.set("options-value", "{}") await this.nextFrame this.assert.deepEqual(this.controller.optionsValues, [ { one: [2, 3] }, { person: { name: "John", age: 42, active: true } }, {}, ]) this.assert.deepEqual(this.controller.oldOptionsValues, [ {}, { one: [2, 3] }, { person: { name: "John", age: 42, active: true } }, ]) } async "test default values trigger changed callbacks"() { this.assert.deepEqual(this.controller.loggedMissingStringValues, [""]) this.assert.deepEqual(this.controller.oldLoggedMissingStringValues, [undefined]) this.controller.missingStringValue = "hello" await this.nextFrame this.assert.deepEqual(this.controller.loggedMissingStringValues, ["", "hello"]) this.assert.deepEqual(this.controller.oldLoggedMissingStringValues, [undefined, ""]) this.controller.missingStringValue = undefined as any await this.nextFrame this.assert.deepEqual(this.controller.loggedMissingStringValues, ["", "hello", ""]) this.assert.deepEqual(this.controller.oldLoggedMissingStringValues, [undefined, "", "hello"]) } "test keys may be specified in kebab-case"() { this.assert.equal(this.controller.time24hrValue, true) } has(name: string) { return this.element.hasAttribute(this.attr(name)) } get(name: string) { return this.element.getAttribute(this.attr(name)) } set(name: string, value: string) { return this.element.setAttribute(this.attr(name), value) } attr(name: string) { return `data-${this.identifier}-${name}` } get element() { return this.controller.element } } ================================================ FILE: src/tests/modules/mutation-observers/attribute_observer_tests.ts ================================================ import { AttributeObserver, AttributeObserverDelegate } from "../../../mutation-observers/attribute_observer" import { ObserverTestCase } from "../../cases/observer_test_case" export default class AttributeObserverTests extends ObserverTestCase implements AttributeObserverDelegate { attributeName = "data-test" fixtureHTML = `
    ` observer = new AttributeObserver(this.fixtureElement, this.attributeName, this) async "test elementMatchedAttribute"() { this.assert.deepEqual(this.calls, [["elementMatchedAttribute", this.outerElement, this.attributeName]]) } async "test elementAttributeValueChanged"() { this.outerElement.setAttribute(this.attributeName, "hello") await this.nextFrame this.assert.deepEqual(this.calls, [ ["elementMatchedAttribute", this.outerElement, this.attributeName], ["elementAttributeValueChanged", this.outerElement, this.attributeName], ]) } async "test elementUnmatchedAttribute"() { this.outerElement.removeAttribute(this.attributeName) await this.nextFrame this.assert.deepEqual(this.calls, [ ["elementMatchedAttribute", this.outerElement, this.attributeName], ["elementUnmatchedAttribute", this.outerElement, this.attributeName], ]) } async "test observes attribute changes to child elements"() { this.innerElement.setAttribute(this.attributeName, "hello") await this.nextFrame this.assert.deepEqual(this.calls, [ ["elementMatchedAttribute", this.outerElement, this.attributeName], ["elementMatchedAttribute", this.innerElement, this.attributeName], ]) } async "test ignores other attributes"() { this.outerElement.setAttribute(this.attributeName + "-x", "hello") await this.nextFrame this.assert.deepEqual(this.calls, [["elementMatchedAttribute", this.outerElement, this.attributeName]]) } async "test observes removal of nested matched element HTML"() { const { innerElement, outerElement } = this innerElement.setAttribute(this.attributeName, "") await this.nextFrame this.fixtureElement.innerHTML = "" await this.nextFrame this.assert.deepEqual(this.calls, [ ["elementMatchedAttribute", outerElement, this.attributeName], ["elementMatchedAttribute", innerElement, this.attributeName], ["elementUnmatchedAttribute", outerElement, this.attributeName], ["elementUnmatchedAttribute", innerElement, this.attributeName], ]) } async "test ignores synchronously disconnected elements"() { const { innerElement, outerElement } = this outerElement.removeChild(innerElement) innerElement.setAttribute(this.attributeName, "") await this.nextFrame this.assert.deepEqual(this.calls, [["elementMatchedAttribute", outerElement, this.attributeName]]) } async "test ignores synchronously moved elements"() { const { innerElement, outerElement } = this document.body.appendChild(innerElement) innerElement.setAttribute(this.attributeName, "") await this.nextFrame this.assert.deepEqual(this.calls, [["elementMatchedAttribute", outerElement, this.attributeName]]) document.body.removeChild(innerElement) } get outerElement() { return this.findElement("#outer") } get innerElement() { return this.findElement("#inner") } // Attribute observer delegate elementMatchedAttribute(element: Element, attributeName: string) { this.recordCall("elementMatchedAttribute", element, attributeName) } elementAttributeValueChanged(element: Element, attributeName: string) { this.recordCall("elementAttributeValueChanged", element, attributeName) } elementUnmatchedAttribute(element: Element, attributeName: string) { this.recordCall("elementUnmatchedAttribute", element, attributeName) } } ================================================ FILE: src/tests/modules/mutation-observers/selector_observer_tests.ts ================================================ import { SelectorObserver, SelectorObserverDelegate } from "../../../mutation-observers/selector_observer" import { ObserverTestCase } from "../../cases/observer_test_case" export default class SelectorObserverTests extends ObserverTestCase implements SelectorObserverDelegate { attributeName = "data-test" selector = "div[data-test~=two]" details = { some: "details" } fixtureHTML = `
    ` observer = new SelectorObserver(this.fixtureElement, this.selector, this, this.details) async "test should match when observer starts"() { this.assert.deepEqual(this.calls, [ ["selectorMatched", this.element, this.selector, this.details], ["selectorMatched", this.div2, this.selector, this.details], ]) } async "test should match when element gets appended"() { const element1 = document.createElement("div") const element2 = document.createElement("div") element1.dataset.test = "one two" element2.dataset.test = "three four" this.element.appendChild(element1) this.element.appendChild(element2) await this.nextFrame this.assert.deepEqual(this.calls, [ ["selectorMatched", this.element, this.selector, this.details], ["selectorMatched", this.div2, this.selector, this.details], ["selectorMatched", element1, this.selector, this.details], ]) } async "test should not match/unmatch when the attribute gets updated and matching selector persists"() { this.element.setAttribute(this.attributeName, "two three") await this.nextFrame this.assert.deepEqual(this.testCalls, []) } async "test should match when attribute gets updated and start to matche selector"() { this.div1.setAttribute(this.attributeName, "updated two") await this.nextFrame this.assert.deepEqual(this.testCalls, [["selectorMatched", this.div1, this.selector, this.details]]) } async "test should unmatch when attribute gets updated but matching attribute value gets removed"() { this.div2.setAttribute(this.attributeName, "updated") await this.nextFrame this.assert.deepEqual(this.testCalls, [["selectorUnmatched", this.div2, this.selector, this.details]]) } async "test should unmatch when attribute gets removed"() { this.element.removeAttribute(this.attributeName) this.div2.removeAttribute(this.attributeName) await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["selectorUnmatched", this.element, this.selector, this.details], ["selectorUnmatched", this.div2, this.selector, this.details], ]) } async "test should unmatch when element gets removed"() { const element = this.element const div1 = this.div1 const div2 = this.div2 element.remove() div1.remove() div2.remove() await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["selectorUnmatched", element, this.selector, this.details], ["selectorUnmatched", div2, this.selector, this.details], ]) } async "test should not match/unmatch when observer is paused"() { this.observer.pause(() => { this.div2.remove() const element = document.createElement("div") element.dataset.test = "one two" this.element.appendChild(element) }) await this.nextFrame this.assert.deepEqual(this.testCalls, []) } get element(): Element { return this.findElement("#container") } get div1(): Element { return this.findElement("#div1") } get div2(): Element { return this.findElement("#div2") } // Selector observer delegate selectorMatched(element: Element, selector: string, details: object) { this.recordCall("selectorMatched", element, selector, details) } selectorUnmatched(element: Element, selector: string, details: object) { this.recordCall("selectorUnmatched", element, selector, details) } } ================================================ FILE: src/tests/modules/mutation-observers/token_list_observer_tests.ts ================================================ import { Token, TokenListObserver, TokenListObserverDelegate } from "../../../mutation-observers/token_list_observer" import { ObserverTestCase } from "../../cases/observer_test_case" export default class TokenListObserverTests extends ObserverTestCase implements TokenListObserverDelegate { attributeName = "data-test" fixtureHTML = `
    ` observer = new TokenListObserver(this.fixtureElement, this.attributeName, this) async "test tokenMatched"() { this.assert.deepEqual(this.calls, [ ["tokenMatched", this.element, this.attributeName, "one", 0], ["tokenMatched", this.element, this.attributeName, "two", 1], ]) } async "test adding a token to the right"() { this.tokenString = "one two three" await this.nextFrame this.assert.deepEqual(this.testCalls, [["tokenMatched", this.element, this.attributeName, "three", 2]]) } async "test inserting a token in the middle"() { this.tokenString = "one three two" await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["tokenUnmatched", this.element, this.attributeName, "two", 1], ["tokenMatched", this.element, this.attributeName, "three", 1], ["tokenMatched", this.element, this.attributeName, "two", 2], ]) } async "test removing the leftmost token"() { this.tokenString = "two" await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["tokenUnmatched", this.element, this.attributeName, "one", 0], ["tokenUnmatched", this.element, this.attributeName, "two", 1], ["tokenMatched", this.element, this.attributeName, "two", 0], ]) } async "test removing the rightmost token"() { this.tokenString = "one" await this.nextFrame this.assert.deepEqual(this.testCalls, [["tokenUnmatched", this.element, this.attributeName, "two", 1]]) } async "test removing the only token"() { this.tokenString = "one" await this.nextFrame this.tokenString = "" await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["tokenUnmatched", this.element, this.attributeName, "two", 1], ["tokenUnmatched", this.element, this.attributeName, "one", 0], ]) } get element(): Element { return this.findElement("div") } set tokenString(value: string) { this.element.setAttribute(this.attributeName, value) } // Token observer delegate tokenMatched(token: Token) { this.recordCall("tokenMatched", token.element, token.attributeName, token.content, token.index) } tokenUnmatched(token: Token) { this.recordCall("tokenUnmatched", token.element, token.attributeName, token.content, token.index) } } ================================================ FILE: src/tests/modules/mutation-observers/value_list_observer_tests.ts ================================================ import { Token, ValueListObserver, ValueListObserverDelegate } from "../../../mutation-observers" import { ObserverTestCase } from "../../cases/observer_test_case" export interface Value { id: number token: Token } export default class ValueListObserverTests extends ObserverTestCase implements ValueListObserverDelegate { attributeName = "data-test" fixtureHTML = `
    ` observer = new ValueListObserver(this.fixtureElement, this.attributeName, this) lastValueId = 0 async "test elementMatchedValue"() { this.assert.deepEqual(this.calls, [["elementMatchedValue", this.element, 1, "one"]]) } async "test adding a token to the right"() { this.valueString = "one two" await this.nextFrame this.assert.deepEqual(this.testCalls, [["elementMatchedValue", this.element, 2, "two"]]) } async "test adding a token to the left"() { this.valueString = "two one" await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["elementUnmatchedValue", this.element, 1, "one"], ["elementMatchedValue", this.element, 2, "two"], ["elementMatchedValue", this.element, 3, "one"], ]) } async "test removing a token from the right"() { this.valueString = "one two" await this.nextFrame this.valueString = "one" await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["elementMatchedValue", this.element, 2, "two"], ["elementUnmatchedValue", this.element, 2, "two"], ]) } async "test removing a token from the left"() { this.valueString = "one two" await this.nextFrame this.valueString = "two" await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["elementMatchedValue", this.element, 2, "two"], ["elementUnmatchedValue", this.element, 1, "one"], ["elementUnmatchedValue", this.element, 2, "two"], ["elementMatchedValue", this.element, 3, "two"], ]) } async "test removing the only token"() { this.valueString = "" await this.nextFrame this.assert.deepEqual(this.testCalls, [["elementUnmatchedValue", this.element, 1, "one"]]) } async "test removing and re-adding a token produces a new value"() { this.valueString = "" await this.nextFrame this.valueString = "one" await this.nextFrame this.assert.deepEqual(this.testCalls, [ ["elementUnmatchedValue", this.element, 1, "one"], ["elementMatchedValue", this.element, 2, "one"], ]) } get element() { return this.findElement("div") } set valueString(value: string) { this.element.setAttribute(this.attributeName, value) } // Value observer delegate parseValueForToken(token: Token) { return { id: ++this.lastValueId, token } } elementMatchedValue(element: Element, value: Value) { this.recordCall("elementMatchedValue", element, value.id, value.token.content) } elementUnmatchedValue(element: Element, value: Value) { this.recordCall("elementUnmatchedValue", element, value.id, value.token.content) } } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "lib": [ "dom", "dom.iterable", "es2015", "scripthost" ], "module": "es2015", "moduleResolution": "node", "noUnusedLocals": true, "rootDir": "src", "strict": true, "target": "es2017", "removeComments": true, "outDir": "dist", "baseUrl": ".", "noEmit": false, "declaration": false }, "exclude": [ "dist" ], "include": [ "src/**/*" ] } ================================================ FILE: tsconfig.test.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "target": "es5" } }