Skip to content

Commit db64cb5

Browse files
author
LB Johnston
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 b73bef7 commit db64cb5

File tree

9 files changed

+129
-16
lines changed

9 files changed

+129
-16
lines changed

docs/reference/actions.md

Lines changed: 14 additions & 0 deletions
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).
Lines changed: 7 additions & 0 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ app.use(express.static(publicPath))
1818
app.use(webpackMiddleware(webpack(webpackConfig)))
1919

2020
const pages = [
21-
{ path: "/hello", title: "Hello" },
2221
{ path: "/clipboard", title: "Clipboard" },
23-
{ path: "/slideshow", title: "Slideshow" },
2422
{ path: "/content-loader", title: "Content Loader" },
23+
{ path: "/details", title: "Details" },
24+
{ path: "/hello", title: "Hello" },
25+
{ path: "/slideshow", title: "Slideshow" },
2526
{ path: "/tabs", title: "Tabs" },
2627
]
2728

examples/views/details.ejs

Lines changed: 25 additions & 0 deletions
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

Lines changed: 10 additions & 1 deletion
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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ type ActionDescriptorFilterOptions = {
77
element: Element
88
}
99

10+
enum GlobalTargets {
11+
window = "window",
12+
document = "document",
13+
outside = "outside",
14+
}
15+
16+
type GlobalTargetValues = null | keyof typeof GlobalTargets
17+
1018
export const defaultActionDescriptorFilters: ActionDescriptorFilters = {
1119
stop({ event, value }) {
1220
if (value) event.stopPropagation()
@@ -35,15 +43,20 @@ export interface ActionDescriptor {
3543
eventName: string
3644
identifier: string
3745
methodName: string
46+
globalFilter: string
3847
keyFilter: string
3948
}
4049

41-
// capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6 7 7
42-
const descriptorPattern = /^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/
50+
// See capture number groups in the comment below.
51+
const descriptorPattern =
52+
// 1 1 2 2 3 3 4 4 5 5 6 6 7 7
53+
/^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document|outside))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/
4354

4455
export function parseActionDescriptorString(descriptorString: string): Partial<ActionDescriptor> {
4556
const source = descriptorString.trim()
4657
const matches = source.match(descriptorPattern) || []
58+
const globalTargetName = (matches[4] || null) as GlobalTargetValues
59+
4760
let eventName = matches[2]
4861
let keyFilter = matches[3]
4962

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

5568
return {
56-
eventTarget: parseEventTarget(matches[4]),
69+
eventTarget: parseEventTarget(globalTargetName),
5770
eventName,
5871
eventOptions: matches[7] ? parseEventOptions(matches[7]) : {},
5972
identifier: matches[5],
6073
methodName: matches[6],
74+
globalFilter: globalTargetName === GlobalTargets.outside ? GlobalTargets.outside : "",
6175
keyFilter: matches[1] || keyFilter,
6276
}
6377
}
6478

65-
function parseEventTarget(eventTargetName: string): EventTarget | undefined {
66-
if (eventTargetName == "window") {
79+
function parseEventTarget(globalTargetName?: GlobalTargetValues): EventTarget | undefined {
80+
if (globalTargetName == GlobalTargets.window) {
6781
return window
68-
} else if (eventTargetName == "document") {
82+
} else if (globalTargetName == GlobalTargets.document || globalTargetName === GlobalTargets.outside) {
6983
return document
7084
}
7185
}
@@ -76,10 +90,10 @@ function parseEventOptions(eventOptions: string): AddEventListenerOptions {
7690
.reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {})
7791
}
7892

79-
export function stringifyEventTarget(eventTarget: EventTarget) {
93+
export function stringifyEventTarget(eventTarget: EventTarget, globalFilter: string): string | undefined {
8094
if (eventTarget == window) {
81-
return "window"
95+
return GlobalTargets.window
8296
} else if (eventTarget == document) {
83-
return "document"
97+
return globalFilter === GlobalTargets.outside ? GlobalTargets.outside : GlobalTargets.document
8498
}
8599
}

src/core/binding.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export class Binding {
8282
private willBeInvokedByEvent(event: Event): boolean {
8383
const eventTarget = event.target
8484

85+
if (this.action.shouldIgnoreGlobalEvent(event, this.action.element)) {
86+
return false
87+
}
88+
8589
if (event instanceof KeyboardEvent && this.action.shouldIgnoreKeyboardEvent(event)) {
8690
return false
8791
}

src/tests/modules/core/action_tests.ts

Lines changed: 40 additions & 4 deletions
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)