From cded4b8165e6f1c140e54c2c8239d41b37483c6b Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Thu, 11 Jun 2026 14:54:46 -0400 Subject: [PATCH] Bug: Fix subtemplate events in each blocks --- .../examples/subtemplate-events-each.mdx | 10 +++ .../subtemplate-events-each/component.css | 24 ++++++ .../subtemplate-events-each/component.html | 5 ++ .../subtemplate-events-each/component.js | 30 +++++++ .../subtemplate-events-each/counter.html | 4 + .../test/browser/subtemplate-events.test.js | 85 +++++++++++++++++++ packages/templating/src/template.js | 6 +- 7 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/examples/subtemplate-events-each.mdx create mode 100644 docs/src/examples/framework/events/subtemplate-events-each/component.css create mode 100644 docs/src/examples/framework/events/subtemplate-events-each/component.html create mode 100644 docs/src/examples/framework/events/subtemplate-events-each/component.js create mode 100644 docs/src/examples/framework/events/subtemplate-events-each/counter.html create mode 100644 packages/renderer/test/browser/subtemplate-events.test.js diff --git a/docs/src/content/examples/subtemplate-events-each.mdx b/docs/src/content/examples/subtemplate-events-each.mdx new file mode 100644 index 000000000..462ddb22d --- /dev/null +++ b/docs/src/content/examples/subtemplate-events-each.mdx @@ -0,0 +1,10 @@ +--- +title: 'Subtemplate Events' +id: 'subtemplate-events-each' +exampleType: 'folder' +category: 'Framework' +subcategory: 'Events' +tags: ['component', 'events', 'subtemplate', 'each'] +tip: 'Each `{>}` invocation clones the subtemplate — state and events are per instance, with no keys or ids threaded through the parent' +description: Subtemplates handle their own state and events for each item +--- diff --git a/docs/src/examples/framework/events/subtemplate-events-each/component.css b/docs/src/examples/framework/events/subtemplate-events-each/component.css new file mode 100644 index 000000000..a294faabc --- /dev/null +++ b/docs/src/examples/framework/events/subtemplate-events-each/component.css @@ -0,0 +1,24 @@ +:host { + display: block; + font-family: var(--page-font); +} + +.counters { + margin: 0; + padding: 0; + list-style: none; + + .counter { + display: flex; + align-items: center; + gap: var(--gap-xs); + padding: var(--padding-3xs) var(--padding-xs); + border: var(--subtle-border); + border-radius: var(--border-radius); + margin-bottom: var(--gap-3xs); + + .label { + flex: 1; + } + } +} diff --git a/docs/src/examples/framework/events/subtemplate-events-each/component.html b/docs/src/examples/framework/events/subtemplate-events-each/component.html new file mode 100644 index 000000000..f3f4dd295 --- /dev/null +++ b/docs/src/examples/framework/events/subtemplate-events-each/component.html @@ -0,0 +1,5 @@ + diff --git a/docs/src/examples/framework/events/subtemplate-events-each/component.js b/docs/src/examples/framework/events/subtemplate-events-each/component.js new file mode 100644 index 000000000..8bc1dee7a --- /dev/null +++ b/docs/src/examples/framework/events/subtemplate-events-each/component.js @@ -0,0 +1,30 @@ +import { defineComponent, getText } from '@semantic-ui/component'; + +const css = await getText('./component.css'); +const template = await getText('./component.html'); + +const counter = defineComponent({ + template: await getText('./counter.html'), + defaultState: { + clicks: 0, + }, + events: { + 'click ui-button'({ state }) { + state.clicks.increment(); + }, + }, +}); + +defineComponent({ + tagName: 'counter-list', + template, + css, + subTemplates: { counter }, + defaultState: { + items: [ + { id: 1, label: 'one' }, + { id: 2, label: 'two' }, + { id: 3, label: 'three' }, + ], + }, +}); diff --git a/docs/src/examples/framework/events/subtemplate-events-each/counter.html b/docs/src/examples/framework/events/subtemplate-events-each/counter.html new file mode 100644 index 000000000..a538694b2 --- /dev/null +++ b/docs/src/examples/framework/events/subtemplate-events-each/counter.html @@ -0,0 +1,4 @@ +
  • + {label} + Clicked {clicks} +
  • diff --git a/packages/renderer/test/browser/subtemplate-events.test.js b/packages/renderer/test/browser/subtemplate-events.test.js new file mode 100644 index 000000000..091d09735 --- /dev/null +++ b/packages/renderer/test/browser/subtemplate-events.test.js @@ -0,0 +1,85 @@ +// Subtemplate-own events for {>name} invocations inside an each block. +// Two shapes per engine: the invocation wrapped in an element, and the +// invocation as the top level of the each item content. The DOM and the +// rendered output are identical — only the bind-time parent of the {>} +// comment differs. + +import { defineComponent } from '@semantic-ui/component'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { RENDERING_ENGINES } from './test-utils.js'; + +RENDERING_ENGINES.forEach((engine) => { + describe(engine, () => { + let tagCounter = 0; + function uniqueTag() { + return `test-subtpl-events-${engine}-${++tagCounter}`; + } + + function clickOn(element) { + element.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }), + ); + } + + async function mount(tag) { + const el = document.createElement(tag); + document.body.appendChild(el); + await el.rendered; + return el; + } + + beforeEach(() => { + document.body.innerHTML = ''; + }); + + function defineRow() { + let fired = 0; + const row = defineComponent({ + renderingEngine: engine, + template: '
  • {name}
  • ', + events: { + 'click .lbl'() { + fired++; + }, + }, + }); + return { row, count: () => fired }; + } + + describe('subtemplate events inside each', () => { + it('fires when the invocation is wrapped in an element', async () => { + const tag = uniqueTag(); + const { row, count } = defineRow(); + defineComponent({ + tagName: tag, + renderingEngine: engine, + subTemplates: { row }, + template: '', + defaultState: { + items: [{ id: 1, name: 'one' }], + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelector('.lbl')); + expect(count()).toBe(1); + }); + + it('fires when the invocation is the top level of the item content', async () => { + const tag = uniqueTag(); + const { row, count } = defineRow(); + defineComponent({ + tagName: tag, + renderingEngine: engine, + subTemplates: { row }, + template: '', + defaultState: { + items: [{ id: 1, name: 'one' }], + }, + }); + const el = await mount(tag); + clickOn(el.shadowRoot.querySelector('.lbl')); + expect(count()).toBe(1); + }); + }); + }); +}); diff --git a/packages/templating/src/template.js b/packages/templating/src/template.js index 665cc8e07..a4192b415 100644 --- a/packages/templating/src/template.js +++ b/packages/templating/src/template.js @@ -751,8 +751,12 @@ export const Template = class Template { // Find the direct child of the renderRoot that is an ancestor of the event.target // then confirm position isNodeInTemplate(node) { + // subtemplates attach while their content is still in the build + // fragment, so stored parentNode can be a dead fragment — the start + // anchor's live parent is the real boundary at event time + const parentNode = this.startNode?.parentNode || this.parentNode; const getRootChild = (node) => { - while (node && node.parentNode !== this.parentNode) { + while (node && node.parentNode !== parentNode) { if (node.parentNode === null && node.host) { node = node.host; }