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>
```
### 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
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` |
| User-Defined Components | `<Capitalized>` | `<Capitalized>` |
| 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}` |
| Inline Functions | `{items.map(item => <li>{item}</li>)}` | `{items.map(item => <li>{item}</li>)}` |
| 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).
### TODO: Composition (Slots)
[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 === '' ? '' : ',';
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++;
return;
}
@ -687,6 +688,11 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
if (curr === 'markdown') {
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'}`;
paren++;
return;
@ -712,8 +718,13 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
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'}`;
paren++;
return;
} else if (!state.declarations.has(componentName) && !componentInfo && !isCustomElementTag(componentName)) {
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();
}
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++;
buffers[curr] += `h(${wrapper}, ${attributes ? generateAttributes(attributes) : 'null'}`;
} catch (err) {
@ -817,6 +833,10 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
if (node.attributes.find((attr: any) => attr.name === 'slot')) {
buffers.out += ')';
paren--;
}
if (paren !== -1) {
buffers.out += ')';
paren--;
@ -840,6 +860,10 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
return;
}
}
if (node.attributes.find((attr: any) => attr.name === 'slot')) {
buffers.out += ')';
paren--;
}
if (paren !== -1) {
buffers.out += ')';
paren--;

View file

@ -2,7 +2,6 @@ import type { Renderer, AstroComponentMetadata } from '../@types/astro';
import hash from 'shorthash';
import { valueToEstree, Value } from 'estree-util-value-to-estree';
import { generate } from 'astring';
import * as astro from './renderer-astro';
import * as astroHtml from './renderer-html';
// A more robust version alternative to `JSON.stringify` that can handle most values
@ -16,13 +15,6 @@ export interface RendererInstance {
hydrationPolyfills: string[];
}
const astroRendererInstance: RendererInstance = {
source: '',
renderer: astro as Renderer,
polyfills: [],
hydrationPolyfills: [],
};
const astroHtmlRendererInstance: RendererInstance = {
source: '',
renderer: astroHtml as Renderer,
@ -33,7 +25,7 @@ const astroHtmlRendererInstance: RendererInstance = {
let rendererInstances: RendererInstance[] = [];
export function setRenderers(_rendererInstances: RendererInstance[]) {
rendererInstances = [astroRendererInstance].concat(_rendererInstances);
rendererInstances = ([] as RendererInstance[]).concat(_rendererInstances);
}
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) {
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)) {
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[]) => {
const children = _children.join('\n');
return async function __astro_component_internal(props: any, ..._children: any[]) {
if (Component.isAstroComponent) {
return Component.__render(props, prepareSlottedChildren(_children));
}
const children = removeSlottedChildren(_children);
let instance = await resolveRenderer(Component, props, children);
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 = '';
visit(node, 'text', (child) => {
text += child.value;
text += (child as any).value;
});
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 map(node, (child) => {
if (child.type === 'mdxTextExpression') {
return { type: 'text', value: `{${child.value}}` };
return { type: 'text', value: `{${(child as any).value}}` };
}
return child;
});