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:
parent
f62973b5ca
commit
8f4562afbe
18 changed files with 346 additions and 27 deletions
7
.changeset/fair-flowers-sleep.md
Normal file
7
.changeset/fair-flowers-sleep.md
Normal 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.
|
|
@ -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 you’d 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 isn’t 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
|
||||
|
|
|
@ -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--;
|
||||
|
|
|
@ -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) {
|
||||
|
|
15
packages/astro/src/internal/__astro_slot.ts
Normal file
15
packages/astro/src/internal/__astro_slot.ts
Normal 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;
|
||||
};
|
|
@ -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 };
|
||||
}
|
79
packages/astro/test/astro-slots.test.js
Normal file
79
packages/astro/test/astro-slots.test.js
Normal 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();
|
3
packages/astro/test/fixtures/astro-slots/snowpack.config.json
vendored
Normal file
3
packages/astro/test/fixtures/astro-slots/snowpack.config.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"workspaceRoot": "../../../../../"
|
||||
}
|
3
packages/astro/test/fixtures/astro-slots/src/components/Fallback.astro
vendored
Normal file
3
packages/astro/test/fixtures/astro-slots/src/components/Fallback.astro
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
<slot>
|
||||
<div id="default"></div>
|
||||
</slot>
|
15
packages/astro/test/fixtures/astro-slots/src/components/Slotted.astro
vendored
Normal file
15
packages/astro/test/fixtures/astro-slots/src/components/Slotted.astro
vendored
Normal 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>
|
17
packages/astro/test/fixtures/astro-slots/src/pages/component.astro
vendored
Normal file
17
packages/astro/test/fixtures/astro-slots/src/pages/component.astro
vendored
Normal 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>
|
19
packages/astro/test/fixtures/astro-slots/src/pages/dynamic.astro
vendored
Normal file
19
packages/astro/test/fixtures/astro-slots/src/pages/dynamic.astro
vendored
Normal 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>
|
16
packages/astro/test/fixtures/astro-slots/src/pages/fallback-override.astro
vendored
Normal file
16
packages/astro/test/fixtures/astro-slots/src/pages/fallback-override.astro
vendored
Normal 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>
|
14
packages/astro/test/fixtures/astro-slots/src/pages/fallback.astro
vendored
Normal file
14
packages/astro/test/fixtures/astro-slots/src/pages/fallback.astro
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
import Fallback from '../components/Fallback.astro';
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<!-- Head Stuff -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="fallback">
|
||||
<Fallback />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
17
packages/astro/test/fixtures/astro-slots/src/pages/index.astro
vendored
Normal file
17
packages/astro/test/fixtures/astro-slots/src/pages/index.astro
vendored
Normal 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>
|
16
packages/astro/test/fixtures/astro-slots/src/pages/multiple.astro
vendored
Normal file
16
packages/astro/test/fixtures/astro-slots/src/pages/multiple.astro
vendored
Normal 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>
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue