diff --git a/src/blockdom/block_compiler.ts b/src/blockdom/block_compiler.ts index 0fd57806..dda42947 100644 --- a/src/blockdom/block_compiler.ts +++ b/src/blockdom/block_compiler.ts @@ -396,12 +396,12 @@ function updateCtx(ctx: BlockCtx, tree: IntermediateTree) { }); break; case "handler": { - const setupHandler = createEventHandler(info.event!); + const { setup, update } = createEventHandler(info.event!); ctx.locations.push({ idx: info.idx, refIdx: info.refIdx!, - setData: setupHandler, - updateData: setupHandler, + setData: setup, + updateData: update, }); break; } diff --git a/src/blockdom/config.ts b/src/blockdom/config.ts index 8d5a38cb..fc18e286 100644 --- a/src/blockdom/config.ts +++ b/src/blockdom/config.ts @@ -1,3 +1,13 @@ +export function filterOutModifiersFromData(dataList: any[]): { modifiers: string[], data: any[]} { + dataList = dataList.slice(); + const modifiers = []; + let elm; + while ((elm = dataList[0]) && typeof elm === "string") { + modifiers.push(dataList.shift()); + } + return { modifiers, data: dataList }; +} + export const config = { // whether or not blockdom should normalize DOM whenever a block is created. // Normalizing dom mean removing empty text nodes (or containing only spaces) @@ -10,6 +20,7 @@ export const config = { if (typeof data === "function") { data(ev); } else if (Array.isArray(data)) { + data = filterOutModifiersFromData(data).data; data[0](data[1], ev); } }, diff --git a/src/blockdom/events.ts b/src/blockdom/events.ts index 577d20e9..95b4dd0b 100644 --- a/src/blockdom/events.ts +++ b/src/blockdom/events.ts @@ -1,21 +1,61 @@ import { config } from "./config"; -export function createEventHandler(event: string) { - setupSyntheticEvent(event); - const key = `__event__${event}`; - return function setupHandler(this: HTMLElement, data: any) { - (this as any)[key] = data; - }; +type EventHandlerSetter = (this: HTMLElement, data: any) => void; + +interface EventHandlerCreator { + setup: EventHandlerSetter; + update: EventHandlerSetter; } -function nativeToSyntheticEvent(event: Event, name: string) { - const eventKey = `__event__${name}`; +export function createEventHandler(rawEvent: string): EventHandlerCreator { + const eventName = rawEvent.split(".")[0]; + if (rawEvent.includes(".synthetic")) { + return createSyntheticHandler(eventName); + } else { + return createElementHandler(eventName); + } +} + +// Native listener +function createElementHandler(evName: string): EventHandlerCreator { + let eventKey = `__event__${evName}`; + function listener(ev: Event) { + const currentTarget = ev.currentTarget; + if (!currentTarget || !document.contains(currentTarget as HTMLElement)) return; + const data = (currentTarget as any)[eventKey]; + if (!data) return; + config.mainEventHandler(data, ev); + } + + function setup(this: HTMLElement, data: any) { + (this as any)[eventKey] = data; + this.addEventListener(evName, listener); + } + + function update(this: HTMLElement, data: any) { + (this as any)[eventKey] = data; + } + + return { setup, update }; +} + +// Synthetic handler: a form of event delegation that allows placing only one +// listener per event type. +function createSyntheticHandler(evName: string): EventHandlerCreator { + let eventKey = `__event__synthetic_${evName}`; + setupSyntheticEvent(evName, eventKey); + function setup(this: HTMLElement, data: any) { + (this as any)[eventKey] = data; + } + return { setup, update: setup }; +} + +function nativeToSyntheticEvent(eventKey: string, event: Event) { let dom = event.target; while (dom !== null) { const data = (dom as any)[eventKey]; if (data) { config.mainEventHandler(data, event); - return; } dom = (dom as any).parentNode; } @@ -23,10 +63,10 @@ function nativeToSyntheticEvent(event: Event, name: string) { const CONFIGURED_SYNTHETIC_EVENTS: { [event: string]: boolean } = {}; -function setupSyntheticEvent(name: string) { - if (CONFIGURED_SYNTHETIC_EVENTS[name]) { +function setupSyntheticEvent(evName: string, eventKey: string) { + if (CONFIGURED_SYNTHETIC_EVENTS[eventKey]) { return; } - document.addEventListener(name, (event) => nativeToSyntheticEvent(event, name)); - CONFIGURED_SYNTHETIC_EVENTS[name] = true; + document.addEventListener(evName, (event) => nativeToSyntheticEvent(eventKey, event)); + CONFIGURED_SYNTHETIC_EVENTS[eventKey] = true; } diff --git a/src/component/handler.ts b/src/component/handler.ts index c8336d15..2fb8b5d1 100644 --- a/src/component/handler.ts +++ b/src/component/handler.ts @@ -1,7 +1,10 @@ +import { filterOutModifiersFromData } from "../blockdom/config"; + export function mainEventHandler(data: any, ev: Event) { if (typeof data === "function") { data(ev); } else { + data = filterOutModifiersFromData(data).data; const ctx = data[0]; const method = data[1]; const args = data[2] || []; diff --git a/tests/blockdom/block_event_handling.test.ts b/tests/blockdom/block_event_handling.test.ts index 47ac97ad..14827fc1 100644 --- a/tests/blockdom/block_event_handling.test.ts +++ b/tests/blockdom/block_event_handling.test.ts @@ -106,3 +106,92 @@ test("two same block nodes with different handlers", async () => { (fixture.firstChild!.nextSibling as HTMLDivElement).click(); expect(steps).toEqual(["1", "2"]); }); + +test("two same block nodes with different handlers (synthetic)", async () => { + const block = createBlock('
'); + let steps: string[] = []; + let handler1 = () => steps.push("1"); + let handler2 = () => steps.push("2"); + const tree = multi([block([handler1]), block([handler2])]); + + mount(tree, fixture); + expect(fixture.innerHTML).toBe("
"); + + (fixture.firstChild as HTMLDivElement).click(); + (fixture.firstChild!.nextSibling as HTMLDivElement).click(); + expect(steps).toEqual(["1", "2"]); +}); + +test("synthetic and native handlers can cohabitate", async () => { + const block = createBlock('
'); + let steps: string[] = []; + let handler1 = () => steps.push("1");; + let handler2 = () => steps.push("2"); + const tree = block([handler1, handler2]); + + mount(tree, fixture); + expect(fixture.innerHTML).toBe("
"); + + (fixture.firstChild!.firstChild as HTMLDivElement).click(); + expect(steps).toEqual(["2", "1"]); + (fixture.firstChild as HTMLDivElement).click(); + expect(steps).toEqual(["2", "1", "1"]); +}); + +test("synthetic and native handlers can cohabitate (2)", async () => { + const block = createBlock('
'); + let steps: string[] = []; + let handler1 = () => steps.push("1");; + let handler2 = () => steps.push("2"); + const tree = block([handler1, handler2]); + + mount(tree, fixture); + expect(fixture.innerHTML).toBe("
"); + + (fixture.firstChild!.firstChild as HTMLDivElement).click(); + expect(steps).toEqual(["1", "2"]); + (fixture.firstChild as HTMLDivElement).click(); + expect(steps).toEqual(["1", "2", "1"]); +}); + +test("synthetic and native handlers can cohabitate (3)", async () => { + const parent = createBlock(`
`) + const block = createBlock('
'); + const blockSynth = createBlock('
'); + let steps: string[] = []; + const handler0 = () => steps.push("0");; + let handler1 = () => steps.push("1");; + let handler2 = () => steps.push("2"); + const tree = parent([handler0], [block([handler1]), blockSynth([handler2])]); + + mount(tree, fixture); + expect(fixture.innerHTML).toBe("
"); + + const children = fixture.children[0].children; + + (children[0] as HTMLElement).click(); + expect(steps).toEqual(["1", "0"]); + (children[1] as HTMLElement).click(); + expect(steps).toEqual(["1", "0", "0", "2"]); +}); + +test("synthetic and native handlers can cohabitate (5)", async () => { + const parent = createBlock(`
`) + const block = createBlock('
'); + const blockSynth = createBlock('
'); + let steps: string[] = []; + const handler0 = (ev: Event) => { steps.push("0"); ev.stopPropagation();}; + let handler1 = () => steps.push("1");; + let handler2 = () => steps.push("2"); + const tree = parent([handler0], [block([handler1]), blockSynth([handler2])]); + + mount(tree, fixture); + expect(fixture.innerHTML).toBe("
"); + + const children = fixture.children[0].children; + + (children[0] as HTMLElement).click(); + expect(steps).toEqual(["1", "0"]); + (children[1] as HTMLElement).click(); + expect(steps).toEqual(["1", "0", "0"]); +}); diff --git a/tests/components/event_handling.test.ts b/tests/components/event_handling.test.ts index f1747d23..995f19da 100644 --- a/tests/components/event_handling.test.ts +++ b/tests/components/event_handling.test.ts @@ -33,7 +33,7 @@ describe("event handling", () => { expect(fixture.innerHTML).toBe("
simple vnode
2
"); }); - test.skip("support for callable expression in event handler", async () => { + test("support for callable expression in event handler", async () => { class Counter extends Component { static template = xml`
`; diff --git a/tests/misc/portal.test.ts b/tests/misc/portal.test.ts index 52f43f5e..2ee53eba 100644 --- a/tests/misc/portal.test.ts +++ b/tests/misc/portal.test.ts @@ -432,7 +432,7 @@ describe("Portal", () => { expect(parent.env).toStrictEqual({}); }); - test.skip("Portal composed with t-slot", async () => { + test("Portal composed with t-slot", async () => { const steps: Array = []; let childInst: Component | null = null; class Child2 extends Component { diff --git a/tests/qweb/__snapshots__/event_handling.test.ts.snap b/tests/qweb/__snapshots__/event_handling.test.ts.snap index e5e5307f..bf6a05a9 100644 --- a/tests/qweb/__snapshots__/event_handling.test.ts.snap +++ b/tests/qweb/__snapshots__/event_handling.test.ts.snap @@ -240,6 +240,38 @@ exports[`t-on receive event in first argument 1`] = ` }" `; +exports[`t-on t-on modifiers (native listener) basic support for native listener 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = [ctx, 'divClicked']; + let d2 = [ctx, 'btnClicked']; + return block1([d1, d2]); + } +}" +`; + +exports[`t-on t-on modifiers (synthetic listener) basic support for synthetic 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = [ctx, 'divClicked']; + let d2 = [ctx, 'btnClicked']; + return block1([d1, d2]); + } +}" +`; + exports[`t-on t-on with inline statement (function call) 1`] = ` "function anonymous(bdom, helpers ) { diff --git a/tests/qweb/event_handling.test.ts b/tests/qweb/event_handling.test.ts index cac23173..72f839a4 100644 --- a/tests/qweb/event_handling.test.ts +++ b/tests/qweb/event_handling.test.ts @@ -301,4 +301,55 @@ describe("t-on", () => { "Missing event name with t-on directive" ); }); + + describe("t-on modifiers (native listener)", () => { + test("basic support for native listener", () => { + const template = `
+ +
`; + + const steps: string[] = []; + + const owner = { + divClicked(ev: Event) { + expect(ev.currentTarget).toBe(div); + steps.push("divClicked"); + }, + btnClicked(ev: Event) { + expect(ev.currentTarget).toBe(button); + steps.push("btnClicked"); + }, + }; + const node = mountToFixture(template, owner); + const div = node.querySelector(".myClass"); + const button = (node).getElementsByTagName("button")[0]; + button.click(); + expect(steps).toEqual(["btnClicked", "divClicked"]); + }); + }); + + describe("t-on modifiers (synthetic listener)", () => { + test("basic support for synthetic", () => { + const template = `
+ +
`; + + const steps: string[] = []; + + const owner = { + divClicked(ev: Event) { + expect(ev.currentTarget).toBe(document); + steps.push("divClicked"); + }, + btnClicked(ev: Event) { + expect(ev.currentTarget).toBe(document); + steps.push("btnClicked"); + }, + }; + const node = mountToFixture(template, owner); + const button = (node).getElementsByTagName("button")[0]; + button.click(); + expect(steps).toEqual(["btnClicked", "divClicked"]); + }); + }); });