Skip to content

Commit

Permalink
Add support for @outside global event filter
Browse files Browse the repository at this point in the history
- When using `@outside`, it will behave the same as @document but only trigger the action if the event was triggered from outside the element with the attached action
- Closes #656
  • Loading branch information
lb- committed Jun 26, 2023
1 parent 7bf453c commit 67b7df1
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 14 deletions.
14 changes: 14 additions & 0 deletions docs/reference/actions.md
Expand Up @@ -141,6 +141,20 @@ You can append `@window` or `@document` to the event name (along with any filter
</div>
```

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
<main>
<button type="button">Other</button>
<div class="popover" data-controller="popover" data-action="click@outside->popover#close">
<button data-action="click->popover#close" type="button">Close</button>
<p>Popover content... <a href="#">a link</a></p>
</div>
</main>
```

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).
Expand Down
7 changes: 7 additions & 0 deletions examples/controllers/details_controller.js
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
close() {
this.element.removeAttribute("open")
}
}
3 changes: 3 additions & 0 deletions examples/index.js
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions examples/server.js
Expand Up @@ -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" },
]

Expand Down
25 changes: 25 additions & 0 deletions examples/views/details.ejs
@@ -0,0 +1,25 @@
<%- include("layout/head") %>

<strong>Opening outside a details item will close them, clicking inside details will not close that one.</strong>

<details data-controller="details" data-action="click@outside->details#close">
<summary>Item 1</summary>
<p>These are the details for item 1 with a <button type="button">button</button> and some additional content.</p>
</details>

<details data-controller="details" data-action="click@outside->details#close">
<summary>Item 2</summary>
<p>These are the details for item 2 with a <button type="button">button</button> and some additional content.</p>
</details>

<details data-controller="details" data-action="click@outside->details#close">
<summary>Item 3</summary>
<p>These are the details for item 3 with a <button type="button">button</button> and some additional content.</p>
</details>

<details data-controller="details" data-action="click@outside->details#close">
<summary>Item 4</summary>
<p>These are the details for item 4 with a <button type="button">button</button> and some additional content.</p>
</details>

<%- include("layout/tail") %>
11 changes: 10 additions & 1 deletion src/core/action.ts
Expand Up @@ -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

Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -90,7 +99,7 @@ export class Action {
}

private get eventTargetName() {
return stringifyEventTarget(this.eventTarget)
return stringifyEventTarget(this.eventTarget, this.globalFilter)
}

private get keyMappings() {
Expand Down
32 changes: 23 additions & 9 deletions src/core/action_descriptor.ts
Expand Up @@ -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()
Expand Down Expand Up @@ -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<ActionDescriptor> {
const source = descriptorString.trim()
const matches = source.match(descriptorPattern) || []
const globalTargetName = (matches[4] || null) as GlobalTargetValues

let eventName = matches[2]
let keyFilter = matches[3]

Expand All @@ -53,19 +66,20 @@ export function parseActionDescriptorString(descriptorString: string): Partial<A
}

return {
eventTarget: parseEventTarget(matches[4]),
eventTarget: parseEventTarget(globalTargetName),
eventName,
eventOptions: matches[7] ? parseEventOptions(matches[7]) : {},
identifier: matches[5],
methodName: matches[6],
globalFilter: globalTargetName === GlobalTargets.outside ? GlobalTargets.outside : "",
keyFilter: matches[1] || keyFilter,
}
}

function parseEventTarget(eventTargetName: string): EventTarget | undefined {
if (eventTargetName == "window") {
function parseEventTarget(globalTargetName?: GlobalTargetValues): EventTarget | undefined {
if (globalTargetName == GlobalTargets.window) {
return window
} else if (eventTargetName == "document") {
} else if (globalTargetName == GlobalTargets.document || globalTargetName === GlobalTargets.outside) {
return document
}
}
Expand All @@ -76,10 +90,10 @@ function parseEventOptions(eventOptions: string): AddEventListenerOptions {
.reduce((options, token) => 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
}
}
4 changes: 4 additions & 0 deletions src/core/binding.ts
Expand Up @@ -85,6 +85,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
}
Expand Down
44 changes: 40 additions & 4 deletions 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 = `
<div data-controller="c" data-action="keydown@window->c#log">
<button data-action="c#log"><span>Log</span></button>
<div id="outer" data-action="click->c#log">
<div data-controller="c" data-action="keydown@window->c#log focus@outside->c#log">
<button id="outer-sibling-button" data-action="c#log"><span>Log</span></button>
<div id="outer" data-action="hover@outside->c#log click->c#log">
<div id="inner" data-controller="c" data-action="click->c#log keyup@window->c#log"></div>
</div>
<div id="multiple" data-action="click->c#log click->c#log2 mousedown->c#log"></div>
</div>
<div id="outside"></div>
<div id="outside">
<button type="button" id="outside-inner-button">Outside inner button</button>
</div>
<svg id="svgRoot" data-controller="c" data-action="click->c#log">
<circle id="svgChild" data-action="mousedown->c#log" cx="5" cy="5" r="5">
</svg>
Expand Down Expand Up @@ -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" })
}
}

0 comments on commit 67b7df1

Please sign in to comment.