A scrollable set now starts 52dp in, so it reads as scrollable; a primary indicator is inset 2dp at each side while a secondary one still spans the tab; a link marked aria-current takes the active label colour the indicator already followed; and the focus ring is 2px outside the tab, as everywhere else in the package, paid for with 5px of room around the bar that the state layer and the indicator reach back into. Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-10, N-18, N-20, N-21). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
149 lines
7.3 KiB
PHP
149 lines
7.3 KiB
PHP
<?php
|
|
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
it('renders the tablist on the server with the first enabled tab chosen', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-tabs id="share" :tabs="[['name' => 'files', 'label' => 'Files', 'disabled' => true], ['name' => 'people', 'label' => 'People', 'icon' => 'group', 'badge' => 3]]">
|
|
<x-tab name="people">Anna</x-tab>
|
|
</x-tabs>
|
|
BLADE);
|
|
|
|
expect($html)
|
|
->toContain('role="tablist"')
|
|
->toContain('data-variant="primary"')
|
|
->toContain('id="share-people"')
|
|
->toContain('aria-controls="share-people-panel"')
|
|
->toMatch('/data-tab="people"\s+aria-controls="share-people-panel"\s+aria-selected="true"\s+tabindex="0"/')
|
|
->toMatch('/data-tab="files"\s+aria-controls="share-files-panel"\s+aria-selected="false"\s+tabindex="-1"/')
|
|
->toContain('disabled')
|
|
->toContain('data-tab-indicator')
|
|
->toContain('--tabs-indicator: share-indicator')
|
|
->toContain('x-modelable="selected"')
|
|
// The panel's id is the server's, so aria-controls never dangles (N-05).
|
|
->toMatch('/role="tabpanel"\s+id="share-people-panel"\s+aria-labelledby="share-people"/')
|
|
->toContain('>3</span>');
|
|
});
|
|
|
|
it('renders every panel but the chosen one hidden, before Alpine boots', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-tabs :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People']]" selected="people">
|
|
<x-tab name="files">Three files.</x-tab>
|
|
<x-tab name="people">Anna and Ben.</x-tab>
|
|
</x-tabs>
|
|
BLADE);
|
|
|
|
expect($html)
|
|
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
|
|
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/')
|
|
// The tab button and its panel agree on the id the slot worked out for itself.
|
|
->toMatch('/aria-controls="(tabs-[0-9a-f]{8})-people-panel"/')
|
|
->toMatch('/id="(tabs-[0-9a-f]{8})-people-panel"/');
|
|
});
|
|
|
|
it('takes a variant, stacks icons and scrolls on request', function () {
|
|
expect((string) $this->blade('<x-tabs variant="secondary" stacked scrollable :tabs="[[\'name\' => \'a\', \'label\' => \'A\']]" />'))
|
|
->toContain('data-variant="secondary"')
|
|
->not->toContain('data-stacked')
|
|
->toContain('data-scrollable')
|
|
->and((string) $this->blade('<x-tabs stacked selected="b" :tabs="[[\'name\' => \'a\', \'label\' => \'A\'], [\'name\' => \'b\', \'label\' => \'B\']]" />'))
|
|
->toContain('data-stacked')
|
|
->toMatch('/data-tab="b"\s+aria-controls="[^"]+"\s+aria-selected="true"/');
|
|
});
|
|
|
|
it('entangles the chosen tab with Livewire and renders the one the property names', function () {
|
|
Livewire::component('tabs-probe', new class extends Component
|
|
{
|
|
public string $tab = 'people';
|
|
|
|
public function render(): string
|
|
{
|
|
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]"><x-tab name="files">Three files.</x-tab><x-tab name="people">Anna and Ben.</x-tab></x-tabs></div>';
|
|
}
|
|
});
|
|
|
|
expect(Livewire::test('tabs-probe')->html())
|
|
->toContain(".entangle('tab').live")
|
|
->not->toContain('x-modelable')
|
|
->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
|
|
// The panel the property names is the one drawn, before Alpine entangles anything (N-05).
|
|
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
|
|
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/');
|
|
});
|
|
|
|
it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', function () {
|
|
$css = file_get_contents(__DIR__.'/../../../resources/css/components/tabs.css');
|
|
|
|
expect($css)
|
|
// 52dp before the first of a scrollable set (N-10).
|
|
->toContain("[data-tabs-bar][data-scrollable] {\n padding-inline-start: 3.25rem;")
|
|
// A primary indicator is inset 2dp each side; a secondary one spans the tab (N-18).
|
|
->toMatch('/\[data-tab-indicator\] \{\s+position: absolute;\s+inset-inline: 2px;/')
|
|
->toMatch('/\[data-variant="secondary"\] \[data-tab-indicator\] \{\s+inset-inline: 0;/')
|
|
// The ring is 2px outside, as everywhere else in the package (N-20).
|
|
->toContain('outline-offset: 2px;')
|
|
->not->toContain('outline-offset: -3px;')
|
|
// A link marked as the page is the chosen tab too (N-21).
|
|
->toContain('[data-tab]:is([aria-selected="true"], [aria-current="page"]) {');
|
|
});
|
|
|
|
it('draws section navigation as secondary tabs and a picker', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-section-nav label="Settings" :items="[
|
|
['title' => 'Profile', 'icon' => 'person', 'url' => '/settings/profile'],
|
|
['title' => 'Security', 'icon' => 'lock', 'url' => '/settings/security', 'active' => true, 'badge' => 1],
|
|
]" />
|
|
BLADE);
|
|
|
|
expect($html)
|
|
->toContain('aria-label="Settings"')
|
|
->toContain('data-section-picker')
|
|
->toContain('data-variant="secondary"')
|
|
->toContain('medium:flex')
|
|
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
|
|
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
|
|
// The picker is a menu of places: the current one is the page, not a checked choice,
|
|
// and a section's badge shows there as well as on its tab.
|
|
->not->toContain('role="menuitemcheckbox"')
|
|
->toMatch('/role="menuitem"[^>]*aria-current="page"[^>]*href="\/settings\/security"/')
|
|
->toMatch('/data-section-picker.*Security.*>\s*1\s*<.*<nav/s');
|
|
});
|
|
|
|
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
|
|
$this->get('/');
|
|
|
|
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
|
|
|
|
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
|
|
->toContain('medium:grid medium:grid-cols-4 large:flex')
|
|
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
|
|
->not->toContain('wire:navigate');
|
|
});
|
|
|
|
it('keeps the page\'s section current while a Livewire component on it updates', function () {
|
|
Livewire::component('section-nav-probe', new class extends Component
|
|
{
|
|
public string $page = '';
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->page = url()->current();
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return '<div><x-section-nav :items="[[\'title\' => \'Here\', \'url\' => $page], [\'title\' => \'Elsewhere\', \'url\' => \'/elsewhere\']]" no-wire-navigate /></div>';
|
|
}
|
|
});
|
|
|
|
$current = '/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"\s+data-tab\s+aria-current="page"/';
|
|
|
|
$probe = Livewire::test('section-nav-probe');
|
|
|
|
expect($probe->html())->toMatch($current)
|
|
->and($probe->call('$refresh')->html())->toMatch($current)
|
|
// Once as the tab, once as the picker's item: one section, drawn for both widths.
|
|
->and(substr_count($probe->html(), 'aria-current="page"'))->toBe(2);
|
|
});
|