Skip to content

Commit 1460534

Browse files
committed
Add support for @outside global event filter
- 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
1 parent 8cbca6d commit 1460534

File tree

9 files changed

+127
-14
lines changed

9 files changed

+127
-14
lines changed

docs/reference/actions.md

+14
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,20 @@ You can append `@window` or `@document` to the event name (along with any filter
141141
</div>
142142
```
143143

144+
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.
145+
146+
```html
147+
<main>
148+
<button type="button">Other</button>
149+
<div class="popover" data-controller="popover" data-action="click@outside->popover#close">
150+
<button data-action="click->popover#close" type="button">Close</button>
151+
<p>Popover content... <a href="#">a link</a></p>
152+
</div>
153+
</main>
154+
```
155+
156+
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.
157+
144158
### Options
145159

146160
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).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
close() {
5+
this.element.removeAttribute("open")
6+
}
7+
}

examples/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ application.register("clipboard", ClipboardController)
99
import ContentLoaderController from "./controllers/content_loader_controller"
1010
application.register("content-loader", ContentLoaderController)
1111

12+
import DetailsController from "./controllers/details_controller"
13+
application.register("details", DetailsController)
14+
1215
import HelloController from "./controllers/hello_controller"
1316
application.register("hello", HelloController)
1417

examples/server.js

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const pages = [
2222
{ path: "/clipboard", title: "Clipboard" },
2323
{ path: "/slideshow", title: "Slideshow" },
2424
{ path: "/content-loader", title: "Content Loader" },
25+
{ path: "/details", title: "Details" },
2526
{ path: "/tabs", title: "Tabs" },
2627
]
2728

examples/views/details.ejs

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<%- include("layout/head") %>
2+
3+
<strong>Opening outside a details item will close them, clicking inside details will not close that one.</strong>
4+
5+
<details data-controller="details" data-action="click@outside->details#close">
6+
<summary>Item 1</summary>
7+
<p>These are the details for item 1 with a <button type="button">button</button> and some additional content.</p>
8+
</details>
9+
10+
<details data-controller="details" data-action="click@outside->details#close">
11+
<summary>Item 2</summary>
12+
<p>These are the details for item 2 with a <button type="button">button</button> and some additional content.</p>
13+
</details>
14+
15+
<details data-controller="details" data-action="click@outside->details#close">
16+
<summary>Item 3</summary>
17+
<p>These are the details for item 3 with a <button type="button">button</button> and some additional content.</p>
18+
</details>
19+
20+
<details data-controller="details" data-action="click@outside->details#close">
21+
<summary>Item 4</summary>
22+
<p>These are the details for item 4 with a <button type="button">button</button> and some additional content.</p>
23+
</details>
24+
25+
<%- include("layout/tail") %>

src/core/action.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class Action {
1414
readonly eventOptions: AddEventListenerOptions
1515
readonly identifier: string
1616
readonly methodName: string
17+
readonly globalFilter: string
1718
readonly keyFilter: string
1819
readonly schema: Schema
1920

@@ -29,6 +30,7 @@ export class Action {
2930
this.eventOptions = descriptor.eventOptions || {}
3031
this.identifier = descriptor.identifier || error("missing identifier")
3132
this.methodName = descriptor.methodName || error("missing method name")
33+
this.globalFilter = descriptor.globalFilter || ""
3234
this.keyFilter = descriptor.keyFilter || ""
3335
this.schema = schema
3436
}
@@ -39,6 +41,13 @@ export class Action {
3941
return `${this.eventName}${eventFilter}${eventTarget}->${this.identifier}#${this.methodName}`
4042
}
4143

44+
shouldIgnoreGlobalEvent(event: Event, element: Element): boolean {
45+
if (!this.globalFilter) return false
46+
const eventTarget = event.target
47+
if (!(eventTarget instanceof Element)) return false
48+
return element.contains(eventTarget) // assume that one globalFilter exists ('outside')
49+
}
50+
4251
shouldIgnoreKeyboardEvent(event: KeyboardEvent): boolean {
4352
if (!this.keyFilter) {
4453
return false
@@ -90,7 +99,7 @@ export class Action {
9099
}
91100

92101
private get eventTargetName() {
93-
return stringifyEventTarget(this.eventTarget)
102+
return stringifyEventTarget(this.eventTarget, this.globalFilter)
94103
}
95104

96105
private get keyMappings() {

src/core/action_descriptor.ts

+23-9
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ type ActionDescriptorFilterOptions = {
1010
controller: Controller<Element>
1111
}
1212

13+
enum GlobalTargets {
14+
window = "window",
15+
document = "document",
16+
outside = "outside",
17+
}
18+
19+
type GlobalTargetValues = null | keyof typeof GlobalTargets
20+
1321
export const defaultActionDescriptorFilters: ActionDescriptorFilters = {
1422
stop({ event, value }) {
1523
if (value) event.stopPropagation()
@@ -38,15 +46,20 @@ export interface ActionDescriptor {
3846
eventName: string
3947
identifier: string
4048
methodName: string
49+
globalFilter: string
4150
keyFilter: string
4251
}
4352

44-
// capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6 7 7
45-
const descriptorPattern = /^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/
53+
// See capture number groups in the comment below.
54+
const descriptorPattern =
55+
// 1 1 2 2 3 3 4 4 5 5 6 6 7 7
56+
/^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document|outside))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/
4657

4758
export function parseActionDescriptorString(descriptorString: string): Partial<ActionDescriptor> {
4859
const source = descriptorString.trim()
4960
const matches = source.match(descriptorPattern) || []
61+
const globalTargetName = (matches[4] || null) as GlobalTargetValues
62+
5063
let eventName = matches[2]
5164
let keyFilter = matches[3]
5265

@@ -56,19 +69,20 @@ export function parseActionDescriptorString(descriptorString: string): Partial<A
5669
}
5770

5871
return {
59-
eventTarget: parseEventTarget(matches[4]),
72+
eventTarget: parseEventTarget(globalTargetName),
6073
eventName,
6174
eventOptions: matches[7] ? parseEventOptions(matches[7]) : {},
6275
identifier: matches[5],
6376
methodName: matches[6],
77+
globalFilter: globalTargetName === GlobalTargets.outside ? GlobalTargets.outside : "",
6478
keyFilter: matches[1] || keyFilter,
6579
}
6680
}
6781

68-
function parseEventTarget(eventTargetName: string): EventTarget | undefined {
69-
if (eventTargetName == "window") {
82+
function parseEventTarget(globalTargetName?: GlobalTargetValues): EventTarget | undefined {
83+
if (globalTargetName == GlobalTargets.window) {
7084
return window
71-
} else if (eventTargetName == "document") {
85+
} else if (globalTargetName == GlobalTargets.document || globalTargetName === GlobalTargets.outside) {
7286
return document
7387
}
7488
}
@@ -79,10 +93,10 @@ function parseEventOptions(eventOptions: string): AddEventListenerOptions {
7993
.reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {})
8094
}
8195

82-
export function stringifyEventTarget(eventTarget: EventTarget) {
96+
export function stringifyEventTarget(eventTarget: EventTarget, globalFilter: string): string | undefined {
8397
if (eventTarget == window) {
84-
return "window"
98+
return GlobalTargets.window
8599
} else if (eventTarget == document) {
86-
return "document"
100+
return globalFilter === GlobalTargets.outside ? GlobalTargets.outside : GlobalTargets.document
87101
}
88102
}

src/core/binding.ts

+4
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export class Binding {
8686
private willBeInvokedByEvent(event: Event): boolean {
8787
const eventTarget = event.target
8888

89+
if (this.action.shouldIgnoreGlobalEvent(event, this.action.element)) {
90+
return false
91+
}
92+
8993
if (event instanceof KeyboardEvent && this.action.shouldIgnoreKeyboardEvent(event)) {
9094
return false
9195
}

src/tests/modules/core/action_tests.ts

+40-4
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import { Action } from "../../../core/action"
12
import { LogControllerTestCase } from "../../cases/log_controller_test_case"
23

34
export default class ActionTests extends LogControllerTestCase {
45
identifier = "c"
56
fixtureHTML = `
6-
<div data-controller="c" data-action="keydown@window->c#log">
7-
<button data-action="c#log"><span>Log</span></button>
8-
<div id="outer" data-action="click->c#log">
7+
<div data-controller="c" data-action="keydown@window->c#log focus@outside->c#log">
8+
<button id="outer-sibling-button" data-action="c#log"><span>Log</span></button>
9+
<div id="outer" data-action="hover@outside->c#log click->c#log">
910
<div id="inner" data-controller="c" data-action="click->c#log keyup@window->c#log"></div>
1011
</div>
1112
<div id="multiple" data-action="click->c#log click->c#log2 mousedown->c#log"></div>
1213
</div>
13-
<div id="outside"></div>
14+
<div id="outside">
15+
<button type="button" id="outside-inner-button">Outside inner button</button>
16+
</div>
1417
<svg id="svgRoot" data-controller="c" data-action="click->c#log">
1518
<circle id="svgChild" data-action="mousedown->c#log" cx="5" cy="5" r="5">
1619
</svg>
@@ -66,4 +69,37 @@ export default class ActionTests extends LogControllerTestCase {
6669
await this.triggerEvent("#svgChild", "mousedown")
6770
this.assertActions({ name: "log", eventType: "click" }, { name: "log", eventType: "mousedown" })
6871
}
72+
73+
async "test global 'outside' action that excludes outside elements"() {
74+
await this.triggerEvent("#outer-sibling-button", "focus")
75+
76+
this.assertNoActions()
77+
78+
await this.triggerEvent("#outside-inner-button", "focus")
79+
await this.triggerEvent("#svgRoot", "focus")
80+
81+
this.assertActions({ name: "log", eventType: "focus" }, { name: "log", eventType: "focus" })
82+
83+
// validate that the action token string correctly resolves to the original action
84+
const attributeName = "data-action"
85+
const element = document.getElementById("outer") as Element
86+
const [content] = (element.getAttribute("data-action") || "").split(" ")
87+
const action = Action.forToken({ content, element, index: 0, attributeName }, this.application.schema)
88+
89+
this.assert.equal("hover@outside->c#log", `${action}`)
90+
}
91+
92+
async "test global 'outside' action that excludes the element with attached action"() {
93+
// an event from inside the controlled element but outside the element with the action
94+
await this.triggerEvent("#inner", "hover")
95+
96+
// an event on the element with the action
97+
await this.triggerEvent("#outer", "hover")
98+
99+
this.assertNoActions()
100+
101+
// an event inside the controlled element but outside the element with the action
102+
await this.triggerEvent("#outer-sibling-button", "hover")
103+
this.assertActions({ name: "log", eventType: "hover" })
104+
}
69105
}

0 commit comments

Comments
 (0)