Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for @outside global event filter #695

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -10,6 +10,14 @@ type ActionDescriptorFilterOptions = {
controller: Controller<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 @@ -38,15 +46,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 @@ -56,19 +69,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 @@ -79,10 +93,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 @@ -86,6 +86,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" })
}
}