Add support for named slots (#661)

* feat: support named slots, slot fallback content

* docs: document slots

* chore: add changeset

* fix: build errors

* chore: prefer `patch` version
This commit is contained in:
Nate Moore 2021-07-12 13:07:39 -05:00 committed by GitHub
parent f62973b5ca
commit 8f4562afbe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 346 additions and 27 deletions

View file

@ -0,0 +1,7 @@
---
'astro': patch
---
Improve slot support, adding support for named slots and fallback content within `slot` elements.
See the new [Slots documentation](https://github.com/snowpackjs/astro/blob/main/docs/core-concepts/astro-components.md#slots) for more information.

View file

@ -120,6 +120,62 @@ const { greeting = 'Hello', name } = Astro.props;
</main> </main>
``` ```
### Slots
`.astro` files use the [`<slot>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot) element to enable component composition. Coming from React, this is the same concept as `children`. You can think of the `<slot>` element as a placeholder for markup which will be passed from outside of the component.
```astro
<!-- MyComponent.astro -->
<div id="my-component">
<slot /> <!-- children will go here -->
</div>
<!-- Usage -->
<MyComponent>
<h1>Hello world!</h1>
</MyComponent>
```
Slots are especially powerful when using **named slots**. Rather than a single `<slot>` element which renders _all_ children, named slots allow you to specify where certain children should be placed.
> **Note** The `slot` attribute is not restricted to plain HTML, components can use `slot` as well!
```astro
<!-- MyComponent.astro -->
<div id="my-component">
<header>
<slot name="header" /> <!-- children with the `slot="header"` attribute will go here -->
</header>
<main>
<!-- children without a `slot` (or with the `slot="default"`) attribute will go here -->
<slot />
</main>
<footer>
<slot name="footer"> <!-- children with the `slot="footer"` attribute will go here -->
</footer>
</div>
<!-- Usage -->
<MyComponent>
<h1 slot="header">Hello world!</h1>
<p>Lorem ipsum ...</p>
<FooterComponent slot="footer" />
</MyComponent>
```
Slots also have the ability to render **fallback content**. When there are no matching children passed to a `<slot>`, a `<slot>` element will be replaced with its own children.
```astro
<!-- MyComponent.astro -->
<div id="my-component">
<slot>
<h1>I will render when this slot does not have any children!</h1>
</slot>
</div>
```
### Fragments ### Fragments
At the top-level of an `.astro` file, you may render any number of elements. At the top-level of an `.astro` file, you may render any number of elements.
@ -154,7 +210,10 @@ Inside of an expression, you must wrap multiple elements in a Fragment. Fragment
| File extension | `.astro` | `.jsx` or `.tsx` | | File extension | `.astro` | `.jsx` or `.tsx` |
| User-Defined Components | `<Capitalized>` | `<Capitalized>` | | User-Defined Components | `<Capitalized>` | `<Capitalized>` |
| Expression Syntax | `{}` | `{}` | | Expression Syntax | `{}` | `{}` |
| Spread Attributes | `{...props}` | `{...props}` | | Spread Attributes | `{...props}` | `{...props}`
|
| Children | `<slot>` (with named slot support) | `children`
|
| Boolean Attributes | `autocomplete` === `autocomplete={true}` | `autocomplete` === `autocomplete={true}` | | Boolean Attributes | `autocomplete` === `autocomplete={true}` | `autocomplete` === `autocomplete={true}` |
| Inline Functions | `{items.map(item => <li>{item}</li>)}` | `{items.map(item => <li>{item}</li>)}` | | Inline Functions | `{items.map(item => <li>{item}</li>)}` | `{items.map(item => <li>{item}</li>)}` |
| IDE Support | WIP - [VS Code][code-ext] | Phenomenal | | IDE Support | WIP - [VS Code][code-ext] | Phenomenal |
@ -199,6 +258,4 @@ import thumbnailSrc from './thumbnail.png';
If youd prefer to organize assets alongside Astro components, you may import the file in JavaScript inside the component script. This works as intended but this makes `thumbnail.png` harder to reference in other parts of your app, as its final URL isnt easily-predictable (unlike assets in `public/*`, where the final URL is guaranteed to never change). If youd prefer to organize assets alongside Astro components, you may import the file in JavaScript inside the component script. This works as intended but this makes `thumbnail.png` harder to reference in other parts of your app, as its final URL isnt easily-predictable (unlike assets in `public/*`, where the final URL is guaranteed to never change).
### TODO: Composition (Slots)
[code-ext]: https://marketplace.visualstudio.com/items?itemName=astro-build.astro-vscode [code-ext]: https://marketplace.visualstudio.com/items?itemName=astro-build.astro-vscode

View file

@ -679,7 +679,8 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
buffers.out += buffers.out === '' ? '' : ','; buffers.out += buffers.out === '' ? '' : ',';
if (node.type === 'Slot') { if (node.type === 'Slot') {
buffers[curr] += `(children`; state.importStatements.add(`import { __astro_slot } from 'astro/dist/internal/__astro_slot.js';`);
buffers[curr] += `h(__astro_slot, ${attributes ? generateAttributes(attributes) : 'null'}, children`;
paren++; paren++;
return; return;
} }
@ -687,6 +688,11 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
if (curr === 'markdown') { if (curr === 'markdown') {
await pushMarkdownToBuffer(); await pushMarkdownToBuffer();
} }
if (attributes.slot) {
state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
paren++;
}
buffers[curr] += `h("${name}", ${attributes ? generateAttributes(attributes) : 'null'}`; buffers[curr] += `h("${name}", ${attributes ? generateAttributes(attributes) : 'null'}`;
paren++; paren++;
return; return;
@ -712,8 +718,13 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
await pushMarkdownToBuffer(); await pushMarkdownToBuffer();
} }
paren++; if (attributes.slot) {
state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
paren++;
}
buffers[curr] += `h(${componentName}, ${attributes ? generateAttributes(attributes) : 'null'}`; buffers[curr] += `h(${componentName}, ${attributes ? generateAttributes(attributes) : 'null'}`;
paren++;
return; return;
} else if (!state.declarations.has(componentName) && !componentInfo && !isCustomElementTag(componentName)) { } else if (!state.declarations.has(componentName) && !componentInfo && !isCustomElementTag(componentName)) {
throw new Error(`Unable to render "${componentName}" because it is undefined\n ${state.filename}`); throw new Error(`Unable to render "${componentName}" because it is undefined\n ${state.filename}`);
@ -741,6 +752,11 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
await pushMarkdownToBuffer(); await pushMarkdownToBuffer();
} }
if (attributes.slot) {
state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
paren++;
}
paren++; paren++;
buffers[curr] += `h(${wrapper}, ${attributes ? generateAttributes(attributes) : 'null'}`; buffers[curr] += `h(${wrapper}, ${attributes ? generateAttributes(attributes) : 'null'}`;
} catch (err) { } catch (err) {
@ -817,6 +833,10 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
if (curr === 'markdown') { if (curr === 'markdown') {
await pushMarkdownToBuffer(); await pushMarkdownToBuffer();
} }
if (node.attributes.find((attr: any) => attr.name === 'slot')) {
buffers.out += ')';
paren--;
}
if (paren !== -1) { if (paren !== -1) {
buffers.out += ')'; buffers.out += ')';
paren--; paren--;
@ -840,6 +860,10 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
return; return;
} }
} }
if (node.attributes.find((attr: any) => attr.name === 'slot')) {
buffers.out += ')';
paren--;
}
if (paren !== -1) { if (paren !== -1) {
buffers.out += ')'; buffers.out += ')';
paren--; paren--;

View file

@ -2,7 +2,6 @@ import type { Renderer, AstroComponentMetadata } from '../@types/astro';
import hash from 'shorthash'; import hash from 'shorthash';
import { valueToEstree, Value } from 'estree-util-value-to-estree'; import { valueToEstree, Value } from 'estree-util-value-to-estree';
import { generate } from 'astring'; import { generate } from 'astring';
import * as astro from './renderer-astro';
import * as astroHtml from './renderer-html'; import * as astroHtml from './renderer-html';
// A more robust version alternative to `JSON.stringify` that can handle most values // A more robust version alternative to `JSON.stringify` that can handle most values
@ -16,13 +15,6 @@ export interface RendererInstance {
hydrationPolyfills: string[]; hydrationPolyfills: string[];
} }
const astroRendererInstance: RendererInstance = {
source: '',
renderer: astro as Renderer,
polyfills: [],
hydrationPolyfills: [],
};
const astroHtmlRendererInstance: RendererInstance = { const astroHtmlRendererInstance: RendererInstance = {
source: '', source: '',
renderer: astroHtml as Renderer, renderer: astroHtml as Renderer,
@ -33,7 +25,7 @@ const astroHtmlRendererInstance: RendererInstance = {
let rendererInstances: RendererInstance[] = []; let rendererInstances: RendererInstance[] = [];
export function setRenderers(_rendererInstances: RendererInstance[]) { export function setRenderers(_rendererInstances: RendererInstance[]) {
rendererInstances = [astroRendererInstance].concat(_rendererInstances); rendererInstances = ([] as RendererInstance[]).concat(_rendererInstances);
} }
function isCustomElementTag(name: string | Function) { function isCustomElementTag(name: string | Function) {
@ -121,15 +113,48 @@ const getComponentName = (Component: any, componentProps: any) => {
} }
}; };
export const __astro_component = (Component: any, metadata: AstroComponentMetadata = {} as any) => { const prepareSlottedChildren = (children: string|Record<any, any>[]) => {
const $slots: Record<string, string> = {
default: ''
};
for (const child of children) {
if (typeof child === 'string') {
$slots.default += child;
} else if (typeof child === 'object' && child['$slot']) {
if (!$slots[child['$slot']]) $slots[child['$slot']] = '';
$slots[child['$slot']] += child.children.join('').replace(new RegExp(`slot="${child['$slot']}"\s*`, ''));
}
}
return { $slots };
}
const removeSlottedChildren = (_children: string|Record<any, any>[]) => {
let children = '';
for (const child of _children) {
if (typeof child === 'string') {
children += child;
} else if (typeof child === 'object' && child['$slot']) {
children += child.children.join('');
}
}
return children;
}
/** The main wrapper for any components in Astro files */
export function __astro_component(Component: any, metadata: AstroComponentMetadata = {} as any) {
if (Component == null) { if (Component == null) {
throw new Error(`Unable to render ${metadata.displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`); throw new Error(`Unable to render ${metadata.displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`);
} else if (typeof Component === 'string' && !isCustomElementTag(Component)) { } else if (typeof Component === 'string' && !isCustomElementTag(Component)) {
throw new Error(`Astro is unable to render ${metadata.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`); throw new Error(`Astro is unable to render ${metadata.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`);
} }
return async (props: any, ..._children: string[]) => { return async function __astro_component_internal(props: any, ..._children: any[]) {
const children = _children.join('\n'); if (Component.isAstroComponent) {
return Component.__render(props, prepareSlottedChildren(_children));
}
const children = removeSlottedChildren(_children);
let instance = await resolveRenderer(Component, props, children); let instance = await resolveRenderer(Component, props, children);
if (!instance) { if (!instance) {

View file

@ -0,0 +1,15 @@
/** */
export function __astro_slot_content({ name }: { name: string}, ...children: any[]) {
return { '$slot': name, children };
}
export const __astro_slot = ({ name = 'default' }: { name: string}, _children: any, ...fallback: string[]) => {
if (name === 'default' && typeof _children === 'string') {
return _children ? _children : fallback;
}
if (!_children.$slots) {
throw new Error(`__astro_slot encountered an unexpected child:\n${JSON.stringify(_children)}`);
}
const children = _children.$slots[name];
return children ? children : fallback;
};

View file

@ -1,8 +0,0 @@
export function check(Component: any) {
return Component.isAstroComponent;
}
export async function renderToStaticMarkup(Component: any, props: any, children: string) {
const html = await Component.__render(props, children);
return { html };
}

View file

@ -0,0 +1,79 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { doc } from './test-utils.js';
import { setup, setupBuild } from './helpers.js';
const Slots = suite('Slot test');
setup(Slots, './fixtures/astro-slots', {
runtimeOptions: {
mode: 'development',
},
});
setupBuild(Slots, './fixtures/astro-slots');
Slots('Basic named slots work', async ({ runtime }) => {
const result = await runtime.load('/');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('#a').text(), 'A');
assert.equal($('#b').text(), 'B');
assert.equal($('#c').text(), 'C');
assert.equal($('#default').text(), 'Default');
});
Slots('Dynamic named slots work', async ({ runtime }) => {
const result = await runtime.load('/dynamic');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('#a').text(), 'A');
assert.equal($('#b').text(), 'B');
assert.equal($('#c').text(), 'C');
assert.equal($('#default').text(), 'Default');
});
Slots('Slots render fallback content by default', async ({ runtime }) => {
const result = await runtime.load('/fallback');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('#default').length, 1);
});
Slots('Slots override fallback content', async ({ runtime }) => {
const result = await runtime.load('/fallback-override');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('#override').length, 1);
});
Slots('Slots work with multiple elements', async ({ runtime }) => {
const result = await runtime.load('/multiple');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('#a').text(), 'ABC');
});
Slots('Slots work on Components', async ({ runtime }) => {
const result = await runtime.load('/component');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('#a').length, 1);
assert.equal($('#a').children('astro-component').length, 1, 'Slotted component into #a');
assert.equal($('#default').children('astro-component').length, 1, 'Slotted component into default slot');
});
Slots.run();

View file

@ -0,0 +1,3 @@
{
"workspaceRoot": "../../../../../"
}

View file

@ -0,0 +1,3 @@
<slot>
<div id="default"></div>
</slot>

View file

@ -0,0 +1,15 @@
<div id="a">
<slot name="a" />
</div>
<div id="b">
<slot name="b" />
</div>
<div id="c">
<slot name="c" />
</div>
<div id="default">
<slot />
</div>

View file

@ -0,0 +1,17 @@
---
import Slotted from '../components/Slotted.astro';
const Component = 'astro-component';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<Slotted>
<Component slot="a">A</Component>
<Component>Default</Component>
</Slotted>
</body>
</html>

View file

@ -0,0 +1,19 @@
---
import Slotted from '../components/Slotted.astro';
const slots = ['a', 'b', 'c']
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<Slotted>
<span slot={slots[0]}>A</span>
<span slot={slots[1]}>B</span>
<span slot={slots[2]}>C</span>
<span>Default</span>
</Slotted>
</body>
</html>

View file

@ -0,0 +1,16 @@
---
import Fallback from '../components/Fallback.astro';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<div id="fallback">
<Fallback>
<div id="override" />
</Fallback>
</div>
</body>
</html>

View file

@ -0,0 +1,14 @@
---
import Fallback from '../components/Fallback.astro';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<div id="fallback">
<Fallback />
</div>
</body>
</html>

View file

@ -0,0 +1,17 @@
---
import Slotted from '../components/Slotted.astro';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<Slotted>
<span slot="a">A</span>
<span slot="b">B</span>
<span slot="c">C</span>
<span>Default</span>
</Slotted>
</body>
</html>

View file

@ -0,0 +1,16 @@
---
import Slotted from '../components/Slotted.astro';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<Slotted>
<span slot="a">A</span>
<span slot="a">B</span>
<span slot="a">C</span>
</Slotted>
</body>
</html>

View file

@ -16,7 +16,7 @@ export default function createCollectHeaders() {
let text = ''; let text = '';
visit(node, 'text', (child) => { visit(node, 'text', (child) => {
text += child.value; text += (child as any).value;
}); });
let slug = node.properties.id || slugger.slug(text); let slug = node.properties.id || slugger.slug(text);

View file

@ -4,7 +4,7 @@ export default function rehypeExpressions(): any {
return function (node: any): any { return function (node: any): any {
return map(node, (child) => { return map(node, (child) => {
if (child.type === 'mdxTextExpression') { if (child.type === 'mdxTextExpression') {
return { type: 'text', value: `{${child.value}}` }; return { type: 'text', value: `{${(child as any).value}}` };
} }
return child; return child;
}); });