Skip to content

Commit 5e26593

Browse files
authored
feat(view): add x-slot define attribute to declare slot ownership in nested components (#2104)
1 parent 14b7284 commit 5e26593

3 files changed

Lines changed: 199 additions & 0 deletions

File tree

docs/1-essentials/02-views.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,64 @@ For instance, the snippet below implements a tab component that accepts any numb
577577
</x-tabs>
578578
```
579579

580+
### Define slot ownership in nested view components
581+
582+
You can use `<x-slot name="mySlot" />` interchangeably to both *define* a slot with optional default content, or to provide content to *populate* the slot. Tempest will consider the hierarchy of the components from the AST to automatically detect your intent, however in more complex, especially nested, view components this can result in unexpected behaviour.
583+
584+
To override this behaviour and manually control in which view component your slots are considered to be *defined*, you can use `<x-slot define="mySlot" />` syntax instead. This causes the slot to be registered against the view component in which the keyword 'define' is used, instead of where the `slot` itself appears in the AST.
585+
586+
#### Extendable view component example using `define`
587+
588+
Let us assume you have an `x-container` view component, which is a `<div>` with formatting to act as a flex container for responsive sizing. You use this component repeatedly across your project, and it's effectively a macro to open and close the `<div>`; it doesn't have any slots or do anything special itself otherwise, with only a default `<x-slot/>` to render whatever it is given.
589+
```html x-container.view.php
590+
<div class="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><x-slot/></div>
591+
```
592+
Now, assume we have an `x-header` in which we wish to use the `x-container`. Our `x-header` wishes to place slots `left` and `right` inside it; `x-header` owns these slots and wishes to expose these slots at the callsite in case they need custom content. Using the `define` keyword tells Tempest to treat these slots as *defined* by `x-header` instead of as a *slot to fill* inside `x-container`. Using `name` here instead of `define` would mean that Tempest falls back to the AST, and treats them as if they are slots of `<x-container>`.
593+
```html x-header.view.php
594+
<header>
595+
<x-slot name="top" />
596+
<x-container>
597+
<x-slot define="left"> <!-- define this slot as a slot of x-header, compiled and passed into x-container's default slot -->
598+
<x-header-left />
599+
</x-slot>
600+
<div>
601+
I am in the center
602+
</div>
603+
<x-slot define="right"> <!-- define this slot as a slot of x-header, compiled and passed into x-container's default slot -->
604+
<x-header-right />
605+
</x-slot>
606+
</x-container>
607+
<x-slot name="bottom" />
608+
</header>
609+
```
610+
At the callsite, you still use the `name` attribute to define which slot you're placing content into:
611+
```html callsite.view.php
612+
<x-header>
613+
<x-slot name="left">Some content I want to insert</x-slot>
614+
</x-header>
615+
```
616+
This example would replace the default content `<x-header-left />` instead with the literal string `Some content I want to insert` - or whatever you provide.
617+
618+
#### Populating a child's name slot using `define`
619+
620+
You can also push content into a child's named slot, not just the default slot, by creating a `define`d slot as follows:
621+
```html x-outer.view.php
622+
<div class="outer">
623+
<x-inner>
624+
<x-slot name="left">
625+
<x-slot define="left">default-left-content</x-slot>
626+
</x-slot>
627+
</x-inner>
628+
</div>
629+
```
630+
Again, the `define` keyword registers a `left` slot against the view component `<x-outer>` irrespective of it's position in the AST, and means that at the callsite:
631+
```html outercallsite.view.php
632+
<x-outer>
633+
<x-slot name="left">My override</x-slot>
634+
</x-outer>
635+
```
636+
And so, this places the literal string `My override` into `<x-innner>`'s `left` slot.
637+
580638
### Dynamic view components
581639

582640
On some occasions, you might want to dynamically render view components, for example, render a view component whose name is determined at runtime. You can use the `{html}<x-component :is="">` element to do so:

packages/view/src/Elements/ViewComponentElement.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,12 @@ private function compileSlotToken(Token $slotToken): string
352352

353353
private function resolveSlotName(Token $slotToken): string
354354
{
355+
$define = $slotToken->getAttribute('define');
356+
357+
if ($define !== null && $define !== '') {
358+
return $define;
359+
}
360+
355361
$name = $slotToken->getAttribute('name');
356362

357363
if ($name !== null && $name !== '') {

tests/Integration/View/ViewComponentTest.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,4 +1299,139 @@ public function test_nested_slot_rendering(): void
12991299

13001300
$this->assertSnippetsMatch($expected, $html);
13011301
}
1302+
1303+
public function test_define_slot_renders_default_content_into_child_component(): void
1304+
{
1305+
// <x-slot define="left"> in x-header's template is OWNED by x-header.
1306+
// x-container has no "left" slot — it only has a default slot.
1307+
// When the caller provides nothing for "left", the default content inside
1308+
// the define tag is compiled and flows into x-container's default slot.
1309+
$this->view->registerViewComponent('x-container', '<div class="container"><x-slot /></div>');
1310+
$this->view->registerViewComponent('x-header-left', '<span>default-left</span>');
1311+
$this->view->registerViewComponent('x-header', <<<'HTML'
1312+
<header>
1313+
<x-container>
1314+
<x-slot define="left">
1315+
<x-header-left />
1316+
</x-slot>
1317+
<div>center</div>
1318+
</x-container>
1319+
</header>
1320+
HTML);
1321+
1322+
$html = $this->view->render('<x-header></x-header>');
1323+
1324+
$this->assertSnippetsMatch(
1325+
'<header><div class="container"><span>default-left</span><div>center</div></div></header>',
1326+
$html,
1327+
);
1328+
}
1329+
1330+
public function test_define_slot_is_overridden_by_caller_using_name(): void
1331+
{
1332+
// When the caller fills the "left" slot using <x-slot name="left">, the compiled
1333+
// caller content replaces the default and flows into x-container's default slot.
1334+
// x-container remains unaware that "left" ever existed.
1335+
$this->view->registerViewComponent('x-container', '<div class="container"><x-slot /></div>');
1336+
$this->view->registerViewComponent('x-header-left', '<span>default-left</span>');
1337+
$this->view->registerViewComponent('x-header', <<<'HTML'
1338+
<header>
1339+
<x-container>
1340+
<x-slot define="left">
1341+
<x-header-left />
1342+
</x-slot>
1343+
<div>center</div>
1344+
</x-container>
1345+
</header>
1346+
HTML);
1347+
1348+
$html = $this->view->render(<<<'HTML'
1349+
<x-header>
1350+
<x-slot name="left"><span>custom-left</span></x-slot>
1351+
</x-header>
1352+
HTML);
1353+
1354+
$this->assertSnippetsMatch(
1355+
'<header><div class="container"><span>custom-left</span><div>center</div></div></header>',
1356+
$html,
1357+
);
1358+
}
1359+
1360+
public function test_multiple_define_slots_alongside_static_content_in_child_component(): void
1361+
{
1362+
// The full x-header scenario: two define slots flanking static content,
1363+
// all flowing as one contiguous default slot into x-container.
1364+
$this->view->registerViewComponent('x-container', '<div class="container"><x-slot /></div>');
1365+
$this->view->registerViewComponent('x-header-left', '<span>default-left</span>');
1366+
$this->view->registerViewComponent('x-header-right', '<span>default-right</span>');
1367+
$this->view->registerViewComponent('x-header', <<<'HTML'
1368+
<header>
1369+
<x-container>
1370+
<x-slot define="left"><x-header-left /></x-slot>
1371+
<div>center</div>
1372+
<x-slot define="right"><x-header-right /></x-slot>
1373+
</x-container>
1374+
</header>
1375+
HTML);
1376+
1377+
// No caller overrides — all three pieces use defaults.
1378+
$html = $this->view->render('<x-header></x-header>');
1379+
1380+
$this->assertSnippetsMatch(
1381+
'<header><div class="container"><span>default-left</span><div>center</div><span>default-right</span></div></header>',
1382+
$html,
1383+
);
1384+
1385+
// Caller overrides only "right"; "left" stays default.
1386+
$html = $this->view->render(<<<'HTML'
1387+
<x-header>
1388+
<x-slot name="right"><span>custom-right</span></x-slot>
1389+
</x-header>
1390+
HTML);
1391+
1392+
$this->assertSnippetsMatch(
1393+
'<header><div class="container"><span>default-left</span><div>center</div><span>custom-right</span></div></header>',
1394+
$html,
1395+
);
1396+
}
1397+
1398+
public function test_define_slot_inside_child_named_slot_filler(): void
1399+
{
1400+
// <x-slot define="left"> nested inside <x-slot name="left"> (a named slot filler
1401+
// for x-inner) evaluates x-outer's own "left" slot and places the result into
1402+
// x-inner's "left" slot. The parent of the define token is the x-slot filler tag,
1403+
// which is not a view component, so $parentIsComponent is false and compileSlotToken()
1404+
// is called correctly.
1405+
$this->view->registerViewComponent('x-inner', '<div class="inner"><x-slot name="left" /></div>');
1406+
$this->view->registerViewComponent('x-header-left', '<span>default-left</span>');
1407+
$this->view->registerViewComponent('x-outer', <<<'HTML'
1408+
<div class="outer">
1409+
<x-inner>
1410+
<x-slot name="left">
1411+
<x-slot define="left"><x-header-left /></x-slot>
1412+
</x-slot>
1413+
</x-inner>
1414+
</div>
1415+
HTML);
1416+
1417+
// No caller override — default content flows through into x-inner's named slot.
1418+
$html = $this->view->render('<x-outer></x-outer>');
1419+
1420+
$this->assertSnippetsMatch(
1421+
'<div class="outer"><div class="inner"><span>default-left</span></div></div>',
1422+
$html,
1423+
);
1424+
1425+
// Caller overrides "left" — custom content flows through into x-inner's named slot.
1426+
$html = $this->view->render(<<<'HTML'
1427+
<x-outer>
1428+
<x-slot name="left"><span>custom-left</span></x-slot>
1429+
</x-outer>
1430+
HTML);
1431+
1432+
$this->assertSnippetsMatch(
1433+
'<div class="outer"><div class="inner"><span>custom-left</span></div></div>',
1434+
$html,
1435+
);
1436+
}
13021437
}

0 commit comments

Comments
 (0)