blade(<<<'BLADE' Anna BLADE); expect($html) ->toContain('role="tablist"') ->toContain('data-md-variant="primary"') ->toContain('id="share-people"') ->toContain('aria-controls="share-people-panel"') ->toMatch('/data-md-tab="people"\s+aria-controls="share-people-panel"\s+aria-selected="true"\s+tabindex="0"/') ->toMatch('/data-md-tab="files"\s+aria-controls="share-files-panel"\s+aria-selected="false"\s+tabindex="-1"/') ->toContain('disabled') ->toContain('data-md-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"/') ->toContain('data-md-tab-panel'); }); it('takes a variant, stacks icons and scrolls on request', function () { expect((string) $this->blade('')) ->toContain('data-md-variant="secondary"') ->not->toContain('data-md-stacked') ->toContain('data-md-scrollable') ->and((string) $this->blade('')) ->toContain('data-md-stacked') ->toMatch('/data-md-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-md-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('tells the server at once only when the caller asked with wire:model.live', function () { Livewire::component('tabs-deferred-probe', new class extends Component { public string $tab = 'files'; public function render(): string { return '
'; } }); Livewire::component('tabs-live-probe', new class extends Component { public string $tab = 'files'; public function render(): string { return '
'; } }); // @entangle appends `.live` itself for `wire:model.live`; the view adds nothing of its own, so a // deferred `wire:model` stays deferred. $live = Livewire::test('tabs-live-probe')->html(); expect(Livewire::test('tabs-deferred-probe')->html())->toContain(".entangle('tab')")->not->toContain(".entangle('tab').live") ->and(substr_count($live, ".entangle('tab').live"))->toBe(1) ->and($live)->not->toContain('.live.live'); }); it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', function () { $css = ComponentStylesheet::read('tabs'); expect($css->declarations('[data-md-tabs-bar][data-md-scrollable]')) // 52dp before the first of a scrollable set (N-10). ->toHaveKey('padding-inline-start', '52px') // A primary indicator is inset 2dp each side; a secondary one spans the tab (N-18). ->and($css->declarations('[data-md-tab-indicator]')) ->toHaveKey('inset-inline', '2px') ->and($css->declarations('[data-md-tabs-bar][data-md-variant=\'secondary\'] [data-md-tab-indicator]')) ->toHaveKey('inset-inline', '0') // The ring is 2px outside, as everywhere else in the package (N-20). ->and($css->declarations('[data-md-tab]:focus-visible')) ->toBe(['color' => 'var(--md-sys-color-on-surface)', 'outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px']) // A link marked as the page is the chosen tab too (N-21). ->and($css->declarations('[data-md-tab]:is([aria-selected=\'true\'], [aria-current=\'page\'])')) ->toBe(['color' => 'var(--md-sys-color-primary)']); }); /** * Plan step 46, M3 ยง Tabs: "Labels: single row by default; may wrap to a max second line if needed * with truncation, or use scrollable tabs to give longer titles more room." Both views render the * text of a label in its own `data-md-tab-text`, so the one rule in tabs.css clamps it. */ it('renders a label\'s text in its own hook, in tabs and section tabs alike', function () { expect((string) $this->blade('')) ->toMatch('/\s*People with access<\/span>\s*]*data-md-badge/') ->and((string) $this->blade('')) ->toMatch('/\s*Two-factor authentication<\/span>\s*]*data-md-badge/'); }); it('lets a fixed tab\'s label wrap to a second line and truncate there, and a scrollable one keep its row', function () { $css = ComponentStylesheet::read('tabs'); expect($css->declarations('[data-md-tab]')) // M3's 48px is the tab's minimum: it grows to hold a second line, and the bar stretches the rest. ->toHaveKey('min-height', '48px') ->not->toHaveKey('height') ->toHaveKey('min-width', '90px') // The single row a scrollable tab's label and every badge keep. ->toHaveKey('white-space', 'nowrap') ->and($css->declarations('[data-md-tabs-bar][data-md-stacked] [data-md-tab]')) ->toBe(['min-height' => '64px']) // The content fills a grown tab, so a primary indicator at its foot stays on the divider. ->and($css->declarations('[data-md-tab-content]')) ->toHaveKey('align-self', 'stretch') ->not->toHaveKey('height') ->and($css->declarations('[data-md-tab-label]')) ->toHaveKey('min-width', '0') ->and($css->declarations('[data-md-tabs-bar]:not([data-md-scrollable]) [data-md-tab-text]')) ->toBe([ 'display' => '-webkit-box', 'min-width' => '0', 'overflow' => 'hidden', '-webkit-box-orient' => 'vertical', '-webkit-line-clamp' => '2', 'text-align' => 'center', 'text-overflow' => 'ellipsis', 'white-space' => 'normal', ]) // The ring's room and the state layer still reach past a grown tab to the divider. ->and($css->declarations('[data-md-tab]::before')) ->toHaveKey('inset-block', 'calc(-1 * var(--tabs-ring))') ->and($css->declarations('[data-md-tab-indicator]')) ->toHaveKey('bottom', 'calc(-1 * var(--tabs-ring))') ->and(collect($css->rules())->filter(fn (array $rule): bool => str_contains($rule['selector'], 'data-md-tab-text'))->pluck('selector')->all()) ->toBe(['[data-md-tabs-bar]:not([data-md-scrollable]) [data-md-tab-text]']); }); it('hides every panel until Alpine takes over, and stands the panel\'s content clear of the bar', function () { $css = ComponentStylesheet::read('tabs'); expect($css->declarations('[data-md-tab-panel]')) ->toBe(['padding-top' => 'var(--md-sys-measurement-space300)', 'outline' => 'none']); }); 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-md-section-nav-picker') ->toContain('data-md-variant="secondary"') ->toMatch('/href="\/settings\/security"\s+data-md-tab\s+aria-current="page"\s+wire:navigate/') ->not->toMatch('/href="\/settings\/profile"\s+data-md-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-md-section-nav-picker.*Security.*>\s*1\s*<.*