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 @@
+
+ {#each item in items}
+ {>counter label=item.label}
+ {/each}
+
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: '{#each item in items}{>row name=item.name}
{/each}
',
+ 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: '{#each item in items}{>row name=item.name}{/each}
',
+ 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;
}