diff --git a/docs/reference/actions.md b/docs/reference/actions.md index fad03108..5df13778 100644 --- a/docs/reference/actions.md +++ b/docs/reference/actions.md @@ -141,6 +141,20 @@ You can append `@window` or `@document` to the event name (along with any filter ``` +Alternatively, you can append `@outside` to the event name which will act similar to `@document` but only trigger if the event's target is outside the element with the action. + +```html +
+ +
+ +

Popover content... a link

+
+
+``` + +In the example above, the user can close the popover explicitly via the close button or by clicking anywhere outside the `div.popover`, but clicking on the link inside the popover will not trigger the close action. + ### 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). diff --git a/examples/controllers/details_controller.js b/examples/controllers/details_controller.js new file mode 100644 index 00000000..925a616b --- /dev/null +++ b/examples/controllers/details_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + close() { + this.element.removeAttribute("open") + } +} diff --git a/examples/index.js b/examples/index.js index b44c9c76..a89e5f19 100644 --- a/examples/index.js +++ b/examples/index.js @@ -9,6 +9,9 @@ application.register("clipboard", ClipboardController) import ContentLoaderController from "./controllers/content_loader_controller" application.register("content-loader", ContentLoaderController) +import DetailsController from "./controllers/details_controller" +application.register("details", DetailsController) + import HelloController from "./controllers/hello_controller" application.register("hello", HelloController) diff --git a/examples/server.js b/examples/server.js index a5573f33..35ad1abb 100644 --- a/examples/server.js +++ b/examples/server.js @@ -22,6 +22,7 @@ const pages = [ { path: "/clipboard", title: "Clipboard" }, { path: "/slideshow", title: "Slideshow" }, { path: "/content-loader", title: "Content Loader" }, + { path: "/details", title: "Details" }, { path: "/tabs", title: "Tabs" }, ] diff --git a/examples/views/details.ejs b/examples/views/details.ejs new file mode 100644 index 00000000..5eb6ca6e --- /dev/null +++ b/examples/views/details.ejs @@ -0,0 +1,25 @@ +<%- include("layout/head") %> + +Opening outside a details item will close them, clicking inside details will not close that one. + +
+ Item 1 +

These are the details for item 1 with a and some additional content.

+
+ +
+ Item 2 +

These are the details for item 2 with a and some additional content.

+
+ +
+ Item 3 +

These are the details for item 3 with a and some additional content.

+
+ +
+ Item 4 +

These are the details for item 4 with a and some additional content.

+
+ +<%- include("layout/tail") %> diff --git a/src/core/action.ts b/src/core/action.ts index e009399b..898d95bb 100644 --- a/src/core/action.ts +++ b/src/core/action.ts @@ -14,6 +14,7 @@ export class Action { readonly eventOptions: AddEventListenerOptions readonly identifier: string readonly methodName: string + readonly globalFilter: string readonly keyFilter: string readonly schema: Schema @@ -29,6 +30,7 @@ export class Action { this.eventOptions = descriptor.eventOptions || {} this.identifier = descriptor.identifier || error("missing identifier") this.methodName = descriptor.methodName || error("missing method name") + this.globalFilter = descriptor.globalFilter || "" this.keyFilter = descriptor.keyFilter || "" this.schema = schema } @@ -39,6 +41,13 @@ export class Action { return `${this.eventName}${eventFilter}${eventTarget}->${this.identifier}#${this.methodName}` } + shouldIgnoreGlobalEvent(event: Event, element: Element): boolean { + if (!this.globalFilter) return false + const eventTarget = event.target + if (!(eventTarget instanceof Element)) return false + return element.contains(eventTarget) // assume that one globalFilter exists ('outside') + } + shouldIgnoreKeyboardEvent(event: KeyboardEvent): boolean { if (!this.keyFilter) { return false @@ -90,7 +99,7 @@ export class Action { } private get eventTargetName() { - return stringifyEventTarget(this.eventTarget) + return stringifyEventTarget(this.eventTarget, this.globalFilter) } private get keyMappings() { diff --git a/src/core/action_descriptor.ts b/src/core/action_descriptor.ts index 917bf240..088fd915 100644 --- a/src/core/action_descriptor.ts +++ b/src/core/action_descriptor.ts @@ -7,6 +7,14 @@ type ActionDescriptorFilterOptions = { element: Element } +enum GlobalTargets { + window = "window", + document = "document", + outside = "outside", +} + +type GlobalTargetValues = null | keyof typeof GlobalTargets + export const defaultActionDescriptorFilters: ActionDescriptorFilters = { stop({ event, value }) { if (value) event.stopPropagation() @@ -35,15 +43,20 @@ export interface ActionDescriptor { eventName: string identifier: string methodName: string + globalFilter: string keyFilter: string } -// capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6 7 7 -const descriptorPattern = /^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/ +// See capture number groups in the comment below. +const descriptorPattern = + // 1 1 2 2 3 3 4 4 5 5 6 6 7 7 + /^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document|outside))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/ export function parseActionDescriptorString(descriptorString: string): Partial { const source = descriptorString.trim() const matches = source.match(descriptorPattern) || [] + const globalTargetName = (matches[4] || null) as GlobalTargetValues + let eventName = matches[2] let keyFilter = matches[3] @@ -53,19 +66,20 @@ export function parseActionDescriptorString(descriptorString: string): Partial Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {}) } -export function stringifyEventTarget(eventTarget: EventTarget) { +export function stringifyEventTarget(eventTarget: EventTarget, globalFilter: string): string | undefined { if (eventTarget == window) { - return "window" + return GlobalTargets.window } else if (eventTarget == document) { - return "document" + return globalFilter === GlobalTargets.outside ? GlobalTargets.outside : GlobalTargets.document } } diff --git a/src/core/binding.ts b/src/core/binding.ts index bbaea3db..e4e00f2c 100644 --- a/src/core/binding.ts +++ b/src/core/binding.ts @@ -82,6 +82,10 @@ export class Binding { private willBeInvokedByEvent(event: Event): boolean { const eventTarget = event.target + if (this.action.shouldIgnoreGlobalEvent(event, this.action.element)) { + return false + } + if (event instanceof KeyboardEvent && this.action.shouldIgnoreKeyboardEvent(event)) { return false } diff --git a/src/tests/modules/core/action_tests.ts b/src/tests/modules/core/action_tests.ts index 4e3c7718..2c764f25 100644 --- a/src/tests/modules/core/action_tests.ts +++ b/src/tests/modules/core/action_tests.ts @@ -1,16 +1,19 @@ +import { Action } from "../../../core/action" import { LogControllerTestCase } from "../../cases/log_controller_test_case" export default class ActionTests extends LogControllerTestCase { identifier = "c" fixtureHTML = ` -
- -
+
+ +
-
+
+ +
@@ -66,4 +69,37 @@ export default class ActionTests extends LogControllerTestCase { await this.triggerEvent("#svgChild", "mousedown") this.assertActions({ name: "log", eventType: "click" }, { name: "log", eventType: "mousedown" }) } + + async "test global 'outside' action that excludes outside elements"() { + await this.triggerEvent("#outer-sibling-button", "focus") + + this.assertNoActions() + + await this.triggerEvent("#outside-inner-button", "focus") + await this.triggerEvent("#svgRoot", "focus") + + this.assertActions({ name: "log", eventType: "focus" }, { name: "log", eventType: "focus" }) + + // validate that the action token string correctly resolves to the original action + const attributeName = "data-action" + const element = document.getElementById("outer") as Element + const [content] = (element.getAttribute("data-action") || "").split(" ") + const action = Action.forToken({ content, element, index: 0, attributeName }, this.application.schema) + + this.assert.equal("hover@outside->c#log", `${action}`) + } + + async "test global 'outside' action that excludes the element with attached action"() { + // an event from inside the controlled element but outside the element with the action + await this.triggerEvent("#inner", "hover") + + // an event on the element with the action + await this.triggerEvent("#outer", "hover") + + this.assertNoActions() + + // an event inside the controlled element but outside the element with the action + await this.triggerEvent("#outer-sibling-button", "hover") + this.assertActions({ name: "log", eventType: "hover" }) + } }