blade(<<<'BLADE'
Anna
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');
});
it('renders every panel but the chosen one hidden, before Alpine boots', function () {
$html = (string) $this->blade(<<<'BLADE'
Three files.Anna and Ben.
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(''))
->toContain('data-variant="secondary"')
->not->toContain('data-stacked')
->toContain('data-scrollable')
->and((string) $this->blade(''))
->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 '
Three files.Anna and Ben.
';
}
});
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'
BLADE);
expect($html)
->toContain('aria-label="Settings"')
->toContain('data-section-picker')
->toContain('data-variant="secondary"')
->toContain('max-medium:hidden')
->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*<.*