Skip to content

Commit

Permalink
Implement InnerTemplatePart class
Browse files Browse the repository at this point in the history
The [3.3. Conditionals and Loops using Nested Templates][] section of
the specification mentions special treatment of `<template>` elements
with `[directive]` and `[expression]` attributes within `<template>`
elements. They're to be treated as parts of their own, represented by an
`InnerTemplatePart` interface:

```ts
 InnerTemplatePart : NodeTemplatePart {
    HTMLTemplateElement template;
    attribute DOMString directive;
}
```

This commit introduces that class, along with special treatment whenever
collecting parts from an `HTMLTemplateElement` that also has a
`[directive]` attribute.

To demonstrate their utility, this commit includes a test case that
exercises a naive implementation of an `if` conditional. As a caveat,
it's worth mentioning that the specification proposal explicitly
mentions the nuance surrounding looping and conditional rendering:

> this approach involves the template process callback cloning template
> parts along with other nodes, or let author scripts manually specify to
> which element each template part belongs. This quickly becomes an
> entangled mess because now we could have multiple template parts that
> refer to a single DOM location or an attribute, and we have to start
> dealing with multiple template parts trying to override one another even
> though there is no good use case for such a behavior.
>
> We like the idea of supporting very basic control flow such as `if` and
> `foreach` in the default template process callback but we don't think it's
> a show stopper if the default template process callback didn't support
> them in the initial cut.

This commit does not aim to introduce a canonical implementation for
conditionals or looping, but it should enable a change like that in the
future.

[3.3. Conditionals and Loops using Nested Templates]: https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Template-Instantiation.md#33-conditionals-and-loops-using-nested-templates
  • Loading branch information
seanpdoyle committed Dec 13, 2024
1 parent 743b970 commit 8aba104
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 9 deletions.
11 changes: 11 additions & 0 deletions src/inner-template-part.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {NodeTemplatePart} from './node-template-part.js'

export class InnerTemplatePart extends NodeTemplatePart {
constructor(public template: HTMLTemplateElement) {
super(template, template.getAttribute('expression') ?? '')
}

get directive(): string {
return this.template.getAttribute('directive') ?? ''
}
}
12 changes: 6 additions & 6 deletions src/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import type {TemplatePart, TemplateTypeInit} from './types.js'
import type {TemplateInstance} from './template-instance.js'
import {AttributeTemplatePart} from './attribute-template-part.js'

type PartProcessor = (part: TemplatePart, value: unknown) => void
type PartProcessor = (part: TemplatePart, value: unknown, state: unknown) => void

export function createProcessor(processPart: PartProcessor): TemplateTypeInit {
return {
processCallback(_: TemplateInstance, parts: Iterable<TemplatePart>, params: unknown): void {
if (typeof params !== 'object' || !params) return
processCallback(_: TemplateInstance, parts: Iterable<TemplatePart>, state: unknown): void {
if (typeof state !== 'object' || !state) return
for (const part of parts) {
if (part.expression in params) {
const value = (params as Record<string, unknown>)[part.expression] ?? ''
processPart(part, value)
if (part.expression in state) {
const value = (state as Record<string, unknown>)[part.expression] ?? ''
processPart(part, value, state)
}
}
},
Expand Down
9 changes: 7 additions & 2 deletions src/template-instance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {parse} from './template-string-parser.js'
import {AttributeValueSetter, AttributeTemplatePart} from './attribute-template-part.js'
import {InnerTemplatePart} from './inner-template-part.js'
import {NodeTemplatePart} from './node-template-part.js'
import {propertyIdentity} from './processors.js'
import {TemplatePart, TemplateTypeInit} from './types.js'
Expand All @@ -9,8 +10,12 @@ function* collectParts(el: DocumentFragment): Generator<TemplatePart> {
let node
while ((node = walker.nextNode())) {
if (node instanceof HTMLTemplateElement) {
for (const part of collectParts(node.content)) {
yield part
if (node.hasAttribute('directive')) {
yield new InnerTemplatePart(node)
} else {
for (const part of collectParts(node.content)) {
yield part
}
}
} else if (node instanceof Element && node.hasAttributes()) {
for (let i = 0; i < node.attributes.length; i += 1) {
Expand Down
20 changes: 20 additions & 0 deletions test/processors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {expect} from '@open-wc/testing'
import {TemplateInstance} from '../src/template-instance'
import {InnerTemplatePart} from '../src/inner-template-part'
import type {TemplateTypeInit} from '../src/types'
import {createProcessor} from '../src/processors'
describe('createProcessor', () => {
Expand Down Expand Up @@ -29,4 +30,23 @@ describe('createProcessor', () => {
instance.update({y: 'world'})
expect(calls).to.eql(0)
})

describe('handling InnerTemplatePart', () => {
beforeEach(() => {
processor = createProcessor(part => {
if (part instanceof InnerTemplatePart) calls += 1
})
})

it('detects InnerTemplatePart instances with <template> element', () => {
template.innerHTML = '<template directive="if" expression="x">{{x}}</template>'
new TemplateInstance(template, {x: true}, processor)
expect(calls).to.eql(1)
})

it('does not detect InnerTemplatePart instances without <template> element', () => {
new TemplateInstance(template, {x: true}, processor)
expect(calls).to.eql(0)
})
})
})
29 changes: 28 additions & 1 deletion test/template-instance.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {expect} from '@open-wc/testing'
import {TemplateInstance} from '../src/template-instance'
import {NodeTemplatePart} from '../src/node-template-part'
import {propertyIdentityOrBooleanAttribute, createProcessor} from '../src/processors'
import {InnerTemplatePart} from '../src/inner-template-part'
import {processPropertyIdentity, propertyIdentityOrBooleanAttribute, createProcessor} from '../src/processors'

describe('template-instance', () => {
it('applies data to templated text nodes', () => {
Expand Down Expand Up @@ -354,5 +355,31 @@ describe('template-instance', () => {
expect(processCallCount).to.equal(2)
})
})

describe('handling InnerTemplatePart', () => {
it('makes outer state available to inner parts', () => {
const processor = createProcessor((part, value, state) => {
if (part instanceof InnerTemplatePart && part.directive === 'if') {
if (typeof state === 'object' && (state as Record<string, unknown>)[part.expression]) {
part.replace(new TemplateInstance(part.template, state, processor))
} else {
part.replace()
}
} else {
processPropertyIdentity(part, value)
}
})
const template = Object.assign(document.createElement('template'), {
innerHTML: '{{x}}<template directive="if" expression="y">{{y}}</template>',
})

const root = document.createElement('div')
root.appendChild(new TemplateInstance(template, {x: 'x', y: 'y'}, processor))
expect(root.innerHTML).to.equal('xy')

root.replaceChildren(new TemplateInstance(template, {x: 'x', y: false}, processor))
expect(root.innerHTML).to.equal('x')
})
})
})
})

0 comments on commit 8aba104

Please sign in to comment.