Skip to content

Commit 1886f8d

Browse files
Merge pull request #21230 from NullVoxPopuli-ai-agent/implement-element-helper
Implement `(element)` helper for dynamic tag names (RFC #389)
2 parents b6282e9 + ec7f86a commit 1886f8d

File tree

5 files changed

+387
-1
lines changed

5 files changed

+387
-1
lines changed

packages/@ember/-internals/glimmer/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,4 +497,5 @@ export {
497497
setComponentManager,
498498
} from './lib/utils/managers';
499499
export { isSerializationFirstNode } from './lib/utils/serialization-first-node-helpers';
500+
export { default as element } from './lib/helpers/element';
500501
export { uniqueId } from './lib/helpers/unique-id';
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
@module @ember/helper
3+
*/
4+
5+
import type { CapturedArguments, InternalComponentManager } from '@glimmer/interfaces';
6+
import { createComputeRef, valueForRef, NULL_REFERENCE } from '@glimmer/reference';
7+
import { setInternalComponentManager } from '@glimmer/manager';
8+
import { assert } from '@ember/debug';
9+
import { DEBUG } from '@glimmer/env';
10+
import { internalHelper } from './internal-helper';
11+
12+
// ============ Element Component (for string tag names) ============
13+
// Renders content wrapped in the specified HTML element, or just renders
14+
// content without a wrapper for empty string.
15+
16+
const ELEMENT_CAPABILITIES = {
17+
createInstance: true,
18+
wrapped: true,
19+
};
20+
21+
class ElementComponentManager {
22+
getCapabilities() {
23+
return ELEMENT_CAPABILITIES;
24+
}
25+
26+
getDebugName(state: ElementComponentDefinition) {
27+
return `(element "${state.tagName}")`;
28+
}
29+
30+
getSelf() {
31+
return NULL_REFERENCE;
32+
}
33+
34+
getDestroyable() {
35+
return null;
36+
}
37+
38+
didCreateElement() {}
39+
40+
create(_owner: object, state: ElementComponentDefinition) {
41+
// For empty string, return null so getTagName returns null (no wrapper element)
42+
return state.tagName || null;
43+
}
44+
45+
getTagName(state: string | null) {
46+
return state;
47+
}
48+
49+
didRenderLayout() {}
50+
didUpdateLayout() {}
51+
didCreate() {}
52+
didUpdate() {}
53+
}
54+
55+
const ELEMENT_COMPONENT_MANAGER = new ElementComponentManager();
56+
57+
class ElementComponentDefinition {
58+
constructor(public tagName: string) {}
59+
60+
toString(): string {
61+
return `(element "${this.tagName}")`;
62+
}
63+
}
64+
65+
setInternalComponentManager(
66+
ELEMENT_COMPONENT_MANAGER as unknown as InternalComponentManager,
67+
ElementComponentDefinition.prototype
68+
);
69+
70+
// Cache component definitions per tag name to avoid creating duplicate definitions
71+
const ELEMENT_DEFINITIONS = new Map<string, ElementComponentDefinition>();
72+
73+
function getElementDefinition(tagName: string): ElementComponentDefinition {
74+
let definition = ELEMENT_DEFINITIONS.get(tagName);
75+
if (definition === undefined) {
76+
definition = new ElementComponentDefinition(tagName);
77+
ELEMENT_DEFINITIONS.set(tagName, definition);
78+
}
79+
return definition;
80+
}
81+
82+
// ============ Element Helper ============
83+
84+
/**
85+
The `element` helper lets you dynamically set the tag name of an element.
86+
87+
```handlebars
88+
{{#let (element @tagName) as |Tag|}}
89+
<Tag class="my-element">Hello</Tag>
90+
{{/let}}
91+
```
92+
93+
When `@tagName` is `"h1"`, this renders `<h1 class="my-element">Hello</h1>`.
94+
95+
When `@tagName` is an empty string `""`, the block content is rendered without
96+
a wrapping element.
97+
98+
Passing `null`, `undefined`, or non-string values will throw an assertion error.
99+
100+
Changing the tag name will tear down and recreate the element and its contents.
101+
102+
@method element
103+
@for Ember.Templates.helpers
104+
@public
105+
*/
106+
export default internalHelper(({ positional, named }: CapturedArguments) => {
107+
return createComputeRef(
108+
() => {
109+
if (DEBUG) {
110+
assert('The `element` helper takes a single positional argument', positional.length === 1);
111+
assert(
112+
'The `element` helper does not take any named arguments',
113+
Object.keys(named).length === 0
114+
);
115+
}
116+
117+
let tagName = valueForRef(positional[0]!);
118+
119+
if (DEBUG) {
120+
assert(
121+
`The argument passed to the \`element\` helper must be a string${
122+
tagName === null || tagName === undefined || typeof tagName === 'object'
123+
? ''
124+
: ` (you passed \`${tagName}\`)`
125+
}`,
126+
typeof tagName === 'string'
127+
);
128+
}
129+
130+
return getElementDefinition(tagName as string);
131+
},
132+
null,
133+
'element'
134+
);
135+
});
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { DEBUG } from '@glimmer/env';
2+
import { tracked } from '@glimmer/tracking';
3+
import { RenderingTestCase, moduleFor, runTask } from 'internal-test-helpers';
4+
import { element as elementHelper, hash } from '@ember/helper';
5+
import { on } from '@ember/modifier';
6+
import { template } from '@ember/template-compiler/runtime';
7+
8+
moduleFor(
9+
'Helpers test: {{element}}',
10+
class extends RenderingTestCase {
11+
'@test it renders a tag with the given tag name'() {
12+
let AComponent = template(
13+
`{{#let (element "h1") as |Tag|}}<Tag id="content">hello world!</Tag>{{/let}}`,
14+
{ scope: () => ({ element: elementHelper }) }
15+
);
16+
this.renderComponent(AComponent, { expect: '<h1 id="content">hello world!</h1>' });
17+
}
18+
19+
'@test it does not render any tags when passed an empty string'() {
20+
let AComponent = template(
21+
`{{#let (element "") as |Tag|}}<Tag id="content">hello world!</Tag>{{/let}}`,
22+
{ scope: () => ({ element: elementHelper }) }
23+
);
24+
this.renderComponent(AComponent, { expect: 'hello world!' });
25+
}
26+
27+
['@test it throws when passed null']() {
28+
if (!DEBUG) {
29+
this.assert.expect(0);
30+
return;
31+
}
32+
33+
let nil = null;
34+
this.assert.throws(() => {
35+
let AComponent = template(`{{#let (element nil) as |Tag|}}<Tag>hello</Tag>{{/let}}`, {
36+
scope: () => ({ element: elementHelper, nil }),
37+
});
38+
this.renderComponent(AComponent, { expect: '' });
39+
}, /The argument passed to the `element` helper must be a string/);
40+
}
41+
42+
['@test it throws when passed undefined']() {
43+
if (!DEBUG) {
44+
this.assert.expect(0);
45+
return;
46+
}
47+
48+
let undef = undefined;
49+
this.assert.throws(() => {
50+
let AComponent = template(`{{#let (element undef) as |Tag|}}<Tag>hello</Tag>{{/let}}`, {
51+
scope: () => ({ element: elementHelper, undef }),
52+
});
53+
this.renderComponent(AComponent, { expect: '' });
54+
}, /The argument passed to the `element` helper must be a string/);
55+
}
56+
57+
'@test it works with element modifiers'() {
58+
let didClick = () => {};
59+
let AComponent = template(
60+
`{{#let (element "button") as |Tag|}}<Tag type="button" id="action" {{on "click" didClick}}>hello world!</Tag>{{/let}}`,
61+
{ scope: () => ({ element: elementHelper, on, didClick }) }
62+
);
63+
this.renderComponent(AComponent, {
64+
expect: '<button type="button" id="action">hello world!</button>',
65+
});
66+
}
67+
68+
'@test it can be rendered multiple times'() {
69+
let AComponent = template(
70+
`{{#let (element "h1") as |Tag|}}<Tag id="content-1">hello</Tag><Tag id="content-2">world</Tag><Tag id="content-3">!!!!!</Tag>{{/let}}`,
71+
{ scope: () => ({ element: elementHelper }) }
72+
);
73+
this.renderComponent(AComponent, {
74+
expect:
75+
'<h1 id="content-1">hello</h1><h1 id="content-2">world</h1><h1 id="content-3">!!!!!</h1>',
76+
});
77+
}
78+
79+
'@test it renders when the tag name changes'() {
80+
class State {
81+
@tracked htmlTag = 'h1';
82+
}
83+
84+
let state = new State();
85+
86+
let AComponent = template(
87+
`{{#let (element state.htmlTag) as |Tag|}}<Tag id="content">hello</Tag>{{/let}}`,
88+
{ scope: () => ({ element: elementHelper, state }) }
89+
);
90+
this.renderComponent(AComponent, {
91+
expect: '<h1 id="content">hello</h1>',
92+
});
93+
94+
runTask(() => (state.htmlTag = 'h2'));
95+
this.assertHTML('<h2 id="content">hello</h2>');
96+
97+
runTask(() => (state.htmlTag = 'h3'));
98+
this.assertHTML('<h3 id="content">hello</h3>');
99+
100+
runTask(() => (state.htmlTag = ''));
101+
this.assertText('hello');
102+
103+
runTask(() => (state.htmlTag = 'h1'));
104+
this.assertHTML('<h1 id="content">hello</h1>');
105+
}
106+
107+
'@test it can be passed as argument and works with ...attributes'() {
108+
let Inner = template(
109+
`{{#let @tag as |Tag|}}<Tag id="content" ...attributes>{{yield}}</Tag>{{/let}}`,
110+
{ scope: () => ({ element: elementHelper }) }
111+
);
112+
113+
let Outer = template(`<Inner @tag={{element "p"}} class="extra">Test</Inner>`, {
114+
scope: () => ({ Inner, element: elementHelper }),
115+
});
116+
117+
this.renderComponent(Outer, { expect: '<p id="content" class="extra">Test</p>' });
118+
}
119+
120+
['@test it requires at least one argument']() {
121+
if (!DEBUG) {
122+
this.assert.expect(0);
123+
return;
124+
}
125+
126+
this.assert.throws(() => {
127+
let AComponent = template(`{{#let (element) as |Tag|}}<Tag>hello</Tag>{{/let}}`, {
128+
scope: () => ({ element: elementHelper }),
129+
});
130+
this.renderComponent(AComponent, { expect: '' });
131+
}, /The `element` helper takes a single positional argument/);
132+
}
133+
134+
['@test it requires no more than one argument']() {
135+
if (!DEBUG) {
136+
this.assert.expect(0);
137+
return;
138+
}
139+
140+
this.assert.throws(() => {
141+
let AComponent = template(`{{#let (element "h1" "h2") as |Tag|}}<Tag>hello</Tag>{{/let}}`, {
142+
scope: () => ({ element: elementHelper }),
143+
});
144+
this.renderComponent(AComponent, { expect: '' });
145+
}, /The `element` helper takes a single positional argument/);
146+
}
147+
148+
['@test it does not take any named arguments']() {
149+
if (!DEBUG) {
150+
this.assert.expect(0);
151+
return;
152+
}
153+
154+
this.assert.throws(() => {
155+
let AComponent = template(
156+
`{{#let (element "h1" id="content") as |Tag|}}<Tag>hello</Tag>{{/let}}`,
157+
{ scope: () => ({ element: elementHelper }) }
158+
);
159+
this.renderComponent(AComponent, { expect: '' });
160+
}, /The `element` helper does not take any named arguments/);
161+
}
162+
163+
['@test it throws when passed a number']() {
164+
if (!DEBUG) {
165+
this.assert.expect(0);
166+
return;
167+
}
168+
169+
let num = 123;
170+
this.assert.throws(() => {
171+
let AComponent = template(`{{#let (element num) as |Tag|}}<Tag>hello</Tag>{{/let}}`, {
172+
scope: () => ({ element: elementHelper, num }),
173+
});
174+
this.renderComponent(AComponent, { expect: '' });
175+
}, /The argument passed to the `element` helper must be a string \(you passed `123`\)/);
176+
}
177+
178+
['@test it throws when passed a boolean']() {
179+
if (!DEBUG) {
180+
this.assert.expect(0);
181+
return;
182+
}
183+
184+
let bool = false;
185+
this.assert.throws(() => {
186+
let AComponent = template(`{{#let (element bool) as |Tag|}}<Tag>hello</Tag>{{/let}}`, {
187+
scope: () => ({ element: elementHelper, bool }),
188+
});
189+
this.renderComponent(AComponent, { expect: '' });
190+
}, /The argument passed to the `element` helper must be a string \(you passed `false`\)/);
191+
}
192+
193+
['@test it throws when passed an object']() {
194+
if (!DEBUG) {
195+
this.assert.expect(0);
196+
return;
197+
}
198+
199+
this.assert.throws(() => {
200+
let AComponent = template(`{{#let (element (hash)) as |Tag|}}<Tag>hello</Tag>{{/let}}`, {
201+
scope: () => ({ element: elementHelper, hash }),
202+
});
203+
this.renderComponent(AComponent, { expect: '' });
204+
}, /The argument passed to the `element` helper must be a string/);
205+
}
206+
}
207+
);

packages/@ember/helper/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
get as glimmerGet,
1212
fn as glimmerFn,
1313
} from '@glimmer/runtime';
14-
import { uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
14+
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
1515
import { type Opaque } from '@ember/-internals/utility-types';
1616

1717
/**
@@ -470,6 +470,26 @@ export interface GetHelper extends Opaque<'helper:get'> {}
470470
export const fn = glimmerFn as FnHelper;
471471
export interface FnHelper extends Opaque<'helper:fn'> {}
472472

473+
/**
474+
* The `element` helper lets you dynamically set the tag name of an element.
475+
*
476+
* ```js
477+
* import { element } from '@ember/helper';
478+
*
479+
* <template>
480+
* {{#let (element @tagName) as |Tag|}}
481+
* <Tag class="my-element">Hello</Tag>
482+
* {{/let}}
483+
* </template>
484+
* ```
485+
*
486+
* When `@tagName` is `"h1"`, this renders `<h1 class="my-element">Hello</h1>`.
487+
* When `@tagName` is an empty string, the block content is rendered without a
488+
* wrapping element. When `@tagName` is `null` or `undefined`, nothing is rendered.
489+
*/
490+
export const element = glimmerElement as ElementHelper;
491+
export interface ElementHelper extends Opaque<'helper:element'> {}
492+
473493
/**
474494
* Use the {{uniqueId}} helper to generate a unique ID string suitable for use as
475495
* an ID attribute in the DOM.

0 commit comments

Comments
 (0)