Skip to content
Brok UI

Loading…

No results

Tabs

Open source Core

Tabbed navigation — a compound panel-switching primitive, or a standalone bar whose active marker slides between tabs.

Version
v1.0.1
Stability
stable
License
MIT
Related
Tabs
Segmented Nav

Tab Strip — Editor/workspace document tabs: quiet tabs with an optional icon and count, a raised active tab, per-tab close (button or Delete), an add button and horizontal overflow scrolling, with the active id bound through x-modelable and a link mode that renders a nav with aria-current.

Preview

Press Delete to close the tab.
Size
previews.components.tab-strip.default.blade.php Blade
{{-- Workspace tabs: an overview tab with a count that cannot close, the active
     document with a document icon, a plain tab, and the add button. The add
     listener creates an "Untitled" tab client-side. --}}
@php
    // Trusted, developer-written SVG inner markup (24×24 stroke paths).
    $gridIcon = '<rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" />';
    $docIcon = '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M16 13H8" /><path d="M16 17H8" />';
@endphp
<div class="w-full max-w-2xl">
    <x-ui.tab-strip
        :label="__('Open documents')"
        value="design-team"
        :tabs="[
            [
                'id' => 'artifacts',
                'label' => __('All artifacts'),
                'count' => 2,
                'closable' => false,
                'icon' => $gridIcon,
            ],
            [
                'id' => 'design-team',
                'label' => __('Design team project'),
                'icon' => $docIcon,
            ],
            [
                'id' => 'roadmap',
                'label' => __('Q3 roadmap'),
            ],
        ]"
        x-on:tab-strip:add="$event.detail.add()"
    />
</div>
Sm Current
Md Current

Installation

terminal
php artisan ui:add tab-strip

Note

This component ships an Alpine behavior module at resources/js/ui/tab-strip.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './tab-strip.js';

Registry contract

php artisan ui:add tab-strip writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/tab-strip.blade.php
  • js resources/js/ui/tab-strip.js
Registry dependencies
icon
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

tab-strip.md
# Brok UI: Tab Strip (`tab-strip`)

Editor/workspace document tabs: quiet tabs with an optional icon and count, a raised active tab, per-tab close (button or Delete), an add button and horizontal overflow scrolling, with the active id bound through x-modelable and a link mode that renders a nav with aria-current.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add tab-strip
```

## Usage

```blade
{{-- Workspace tabs: an overview tab with a count that cannot close, the active
     document with a document icon, a plain tab, and the add button. The add
     listener creates an "Untitled" tab client-side. --}}
@php
    // Trusted, developer-written SVG inner markup (24×24 stroke paths).
    $gridIcon = '<rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" />';
    $docIcon = '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M16 13H8" /><path d="M16 17H8" />';
@endphp
<div class="w-full max-w-2xl">
    <x-ui.tab-strip
        :label="__('Open documents')"
        value="design-team"
        :tabs="[
            [
                'id' => 'artifacts',
                'label' => __('All artifacts'),
                'count' => 2,
                'closable' => false,
                'icon' => $gridIcon,
            ],
            [
                'id' => 'design-team',
                'label' => __('Design team project'),
                'icon' => $docIcon,
            ],
            [
                'id' => 'roadmap',
                'label' => __('Q3 roadmap'),
            ],
        ]"
        x-on:tab-strip:add="$event.detail.add()"
    />
</div>
```

## Props

- `tabs` (array, default `[]`) — Tabs as arrays of id, label, icon (trusted SVG inner markup), count, closable (default true) and href.
- `value` (mixed|null, default `null`) — Id of the active tab. Binds with x-model or wire:model through x-modelable; defaults to the first tab.
- `label` (string|null, default `null`) — Accessible name of the tablist (or of the nav in link mode); falls back to "Open tabs".
- `name` (string|null, default `null`) — Alias of label for the accessible name.
- `size` (sm|md, default `sm`) — Control tier of the tabs and the add button: sm (32px) or md (40px).
- `addable` (bool, default `true`) — Shows the trailing New tab button, which dispatches tab-strip:add.
- `addLabel` (string|null, default `null`) — Accessible name of the add button; falls back to "New tab".
- `newTabLabel` (string|null, default `null`) — Label for a tab created with add() when the listener gives none; falls back to "Untitled".

## Use when

- Use to orient users and help them move across pages, sections, or commands.
- Showing the open documents, files or views of a workspace, where users switch between them, close them and open new ones.
- Needing the active tab bound with wire:model or x-model while the page renders the matching content itself.

## Avoid when

- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- The tabs are a fixed set of panels in one page section that never close; use tabs instead.
- Switching between a few top-level destinations of a product shell; use segmented-nav.

## Anti-patterns

- Hiding primary wayfinding in novelty interactions

## Rules

- Use the `<brok:tab-strip>` tag (or `<x-ui.tab-strip>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/tab-strip
- Registry JSON (files, props, contract): https://brokui.dev/r/open/tab-strip.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

long-content.blade.php Blade
{{-- Overflow: more tabs than fit scroll sideways (never wrap), long labels
     truncate, the active tab starts scrolled into view and the edge with more
     tabs fades. --}}
<div class="w-full max-w-sm">
    <x-ui.tab-strip
        :label="__('Open files')"
        value="settings"
        :tabs="[
            ['id' => 'readme', 'label' => 'README.md'],
            ['id' => 'routes', 'label' => 'routes/web.php'],
            ['id' => 'controller', 'label' => 'app/Http/Controllers/InvoiceExportController.php'],
            ['id' => 'migration', 'label' => 'database/migrations/create_invoices_table.php', 'count' => 3],
            ['id' => 'settings', 'label' => 'config/settings.php'],
            ['id' => 'tests', 'label' => 'tests/Feature/InvoiceExportTest.php'],
        ]"
        x-on:tab-strip:add="$event.detail.add()"
    />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
tabs array [] Tabs as arrays of id, label, icon (trusted SVG inner markup), count, closable (default true) and href.
value mixed | null null Id of the active tab. Binds with x-model or wire:model through x-modelable; defaults to the first tab.
label string | null null Accessible name of the tablist (or of the nav in link mode); falls back to "Open tabs".
name string | null null Alias of label for the accessible name.
size sm | md sm Control tier of the tabs and the add button: sm (32px) or md (40px).
addable bool true Shows the trailing New tab button, which dispatches tab-strip:add.
addLabel string | null null Accessible name of the add button; falls back to "New tab".
newTabLabel string | null null Label for a tab created with add() when the listener gives none; falls back to "Untitled".

Slots

Default Blade slot only.

Data slots

Stable hooks for CSS overrides and browser tests.

tab-strip tab-strip-add tab-strip-close tab-strip-count tab-strip-item tab-strip-label tab-strip-list tab-strip-tab

Behavior

  • Tabs mode follows the tabs pattern: roving tabindex with automatic activation, ArrowLeft and ArrowRight mirrored under RTL, Home and End.
  • Delete or Backspace on a closable tab, or its close button, dispatches a cancelable tab-strip:close event; unless a listener calls preventDefault() the tab is removed and selection and focus move to the neighbour tab.
  • The add button dispatches tab-strip:add; a listener creates a tab with event.detail.add({ label }), which selects and focuses it. Selection changes dispatch tab-strip:select. All events bubble with { id } in detail.
  • The row scrolls horizontally instead of wrapping, keeps the active tab in view and masks the edge that has more tabs.
  • When tabs have href, the strip renders a nav of links with aria-current="page" instead of a tablist.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Navigation and orientation

Orient users and move between destinations.

Use when

  • Use to orient users and help them move across pages, sections, or commands.
  • Showing the open documents, files or views of a workspace, where users switch between them, close them and open new ones.
  • Needing the active tab bound with wire:model or x-model while the page renders the matching content itself.

Avoid when

  • Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
  • The tabs are a fixed set of panels in one page section that never close; use tabs instead.
  • Switching between a few top-level destinations of a product shell; use segmented-nav.

Use instead

  • Visible links and local navigation

Anti-patterns

  • Hiding primary wayfinding in novelty interactions
Anatomy
tab-strip tab-strip-list tab-strip-item tab-strip-tab tab-strip-label tab-strip-count tab-strip-close tab-strip-add
Theming hooks
tab-strip

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Home End
Focus
managed
  • The close buttons are pointer affordances hidden from assistive tech inside the tablist; closable tabs announce the Delete shortcut through aria-keyshortcuts and a described-by hint.
  • Focus never falls to the page body after a close: it moves to the tab that takes the selection, or to the add button when no tab is left.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="tab-strip-{{ $record->id }}">
    {{-- Workspace tabs: an overview tab with a count that cannot close, the active
         document with a document icon, a plain tab, and the add button. The add
         listener creates an "Untitled" tab client-side. --}}
    @php
        // Trusted, developer-written SVG inner markup (24×24 stroke paths).
        $gridIcon = '<rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" />';
        $docIcon = '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M16 13H8" /><path d="M16 17H8" />';
    @endphp
    <div class="w-full max-w-2xl">
        <x-ui.tab-strip
            :label="__('Open documents')"
            value="design-team"
            :tabs="[
                [
                    'id' => 'artifacts',
                    'label' => __('All artifacts'),
                    'count' => 2,
                    'closable' => false,
                    'icon' => $gridIcon,
                ],
                [
                    'id' => 'design-team',
                    'label' => __('Design team project'),
                    'icon' => $docIcon,
                ],
                [
                    'id' => 'roadmap',
                    'label' => __('Q3 roadmap'),
                ],
            ]"
            x-on:tab-strip:add="$event.detail.add()"
        />
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/tab-strip.blade.php Blade
@props([
    // Each tab: ['id' => …, 'label' => …, 'icon' => '<path …/>'?, 'count' => 2?,
    // 'closable' => true?, 'href' => '/…'?]. `icon` is trusted SVG inner markup
    // (24×24 stroke paths) written by the developer — never user input.
    'tabs' => [],
    // The active tab id. Binds with x-model / wire:model through x-modelable.
    'value' => null,
    // Accessible name for the tablist (or the <nav> in link mode). `name` is an alias.
    'label' => null,
    'name' => null,
    // Control tier: sm (32px) or md (40px).
    'size' => 'sm',
    // Renders the trailing "New tab" button, which dispatches tab-strip:add.
    'addable' => true,
    'addLabel' => null,
    // Label a tab gets when a tab-strip:add listener calls add() without one.
    'newTabLabel' => null,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $recipe = $styles['tab-strip'];
    $size = array_key_exists((string) $size, $recipe['sizes']) ? (string) $size : 'sm';

    $rows = [];
    foreach (array_values(is_iterable($tabs) ? collect($tabs)->all() : []) as $index => $tab) {
        $tab = (array) $tab;
        $tabLabel = (string) ($tab['label'] ?? '');
        $rows[] = [
            'id' => (string) ($tab['id'] ?? ($tabLabel !== '' ? $tabLabel : 'tab-'.($index + 1))),
            'label' => $tabLabel,
            'icon' => filled($tab['icon'] ?? null) ? (string) $tab['icon'] : null,
            'count' => isset($tab['count']) && $tab['count'] !== '' ? (string) $tab['count'] : null,
            'closable' => (bool) ($tab['closable'] ?? true),
            'href' => filled($tab['href'] ?? null) ? (string) $tab['href'] : null,
            'active' => (bool) ($tab['active'] ?? false),
        ];
    }

    // Link mode: tabs that navigate render as a <nav> of anchors with
    // aria-current, never as a tablist (a tab must not change the page).
    $linkMode = collect($rows)->contains(fn (array $row): bool => $row['href'] !== null);

    $ids = array_column($rows, 'id');
    $active = $value !== null && in_array((string) $value, $ids, true) ? (string) $value : null;
    $active ??= collect($rows)->firstWhere('active', true)['id'] ?? null;
    if (! $linkMode) {
        $active ??= $ids[0] ?? null;
    }

    $accessibleName = $label ?? $name ?? __('Open tabs');
    $addText = $addLabel ?? __('New tab');
    $closeText = __('Close :label');

    // The last row is the <template> the JS clones for tabs added at runtime.
    $template = ['id' => '', 'label' => '', 'icon' => null, 'count' => null, 'closable' => true, 'href' => $linkMode ? '#' : null, 'active' => false, 'template' => true];
    $itemClass = $recipe['item'].' '.$recipe['sizes'][$size];
    $addClass = $recipe['add'].' '.$recipe['addSizes'][$size];
    $rootTag = $linkMode ? 'nav' : 'div';
@endphp

{{-- Tab strip: editor/workspace document tabs with per-tab close, counts and an
     add button. Behaviour (roving focus, Delete to close, events) lives in
     tab-strip.js; without JS it renders the same tabs, statically. --}}
<{{ $rootTag }}
    x-data="uiTabStrip(@js(['value' => $active, 'mode' => $linkMode ? 'links' : 'tabs', 'closeLabel' => $closeText, 'newTabLabel' => $newTabLabel ?? __('Untitled')]))"
    x-modelable="active"
    x-id="['tab-strip-hint']"
    data-slot="tab-strip"
    data-mode="{{ $linkMode ? 'links' : 'tabs' }}"
    data-size="{{ $size }}"
    @if ($linkMode) aria-label="{{ $accessibleName }}" @endif
    {{ $attributes->merge(['class' => $recipe['root']]) }}
>
    <div
        x-ref="viewport"
        data-slot="tab-strip-list"
        data-fade="none"
        @if (! $linkMode) role="tablist" aria-label="{{ $accessibleName }}" aria-orientation="horizontal" @endif
        x-on:keydown="onKey($event)"
        class="{{ $recipe['viewport'] }}"
    >
        @foreach ([...$rows, $template] as $row)
            @php
                $isTemplate = $row['template'] ?? false;
                $isActive = ! $isTemplate && $active !== null && $row['id'] === $active;
                $tag = $linkMode ? 'a' : 'button';
            @endphp
            @if ($isTemplate)
                <template x-ref="template">
            @endif
            <div
                data-slot="tab-strip-item"
                data-id="{{ $row['id'] }}"
                data-closable="{{ $row['closable'] ? 'true' : 'false' }}"
                data-state="{{ $isActive ? 'active' : 'inactive' }}"
                @if (! $linkMode)
                    role="presentation"
                    x-bind:data-state="isActive($el.dataset.id) ? 'active' : 'inactive'"
                @endif
                class="{{ $itemClass }}"
            >
                <{{ $tag }}
                    data-slot="tab-strip-tab"
                    data-id="{{ $row['id'] }}"
                    @if ($linkMode)
                        href="{{ $row['href'] ?? '#' }}"
                        @if ($isActive) aria-current="page" @endif
                    @else
                        type="button"
                        role="tab"
                        aria-selected="{{ $isActive ? 'true' : 'false' }}"
                        tabindex="{{ $isActive ? '0' : '-1' }}"
                        x-bind:aria-selected="isActive($el.dataset.id) ? 'true' : 'false'"
                        x-bind:tabindex="isActive($el.dataset.id) ? 0 : -1"
                        x-on:click="select($el.dataset.id)"
                    @endif
                    @if ($row['closable'])
                        aria-keyshortcuts="Delete"
                        x-bind:aria-describedby="$id('tab-strip-hint')"
                    @endif
                    class="{{ $recipe['tab'] }}"
                >
                    @if ($row['icon'])
                        <svg class="{{ $recipe['icon'] }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{!! $row['icon'] !!}</svg>
                    @endif
                    <span data-slot="tab-strip-label" class="{{ $recipe['label'] }}">{{ $row['label'] }}</span>
                    @if ($row['count'] !== null || $isTemplate)
                        <span data-slot="tab-strip-count" class="{{ $recipe['count'] }}" @if ($isTemplate) hidden @endif>{{ $row['count'] }}</span>
                    @endif
                </{{ $tag }}>
                @if ($row['closable'])
                    <button
                        type="button"
                        data-slot="tab-strip-close"
                        @if ($linkMode)
                            aria-label="{{ str_replace(':label', $row['label'], $closeText) }}"
                        @else
                            {{-- Inside a tablist only tabs may be exposed; keyboard users close with Delete. --}}
                            tabindex="-1"
                            aria-hidden="true"
                        @endif
                        x-on:click="close($el.closest('[data-slot=tab-strip-item]').dataset.id)"
                        class="{{ $recipe['close'] }}"
                    >
                        <x-ui.icon name="close" size="xs" />
                    </button>
                @endif
            </div>
            @if ($isTemplate)
                </template>
            @endif
        @endforeach
    </div>

    @if ($addable)
        <button
            type="button"
            data-slot="tab-strip-add"
            aria-label="{{ $addText }}"
            x-ref="add"
            x-on:click="add()"
            class="{{ $addClass }}"
        >
            <x-ui.icon name="plus" size="sm" />
        </button>
    @endif

    <span x-bind:id="$id('tab-strip-hint')" class="sr-only">{{ __('Press Delete to close the tab.') }}</span>
</{{ $rootTag }}>
resources/js/ui/tab-strip.js JS
/**
 * Tab strip behaviour — editor/workspace document tabs.
 *
 * Tabs mode (role="tablist"): roving tabindex with automatic activation, as in
 * `tabs` — ArrowLeft/Right (mirrored under RTL), Home and End move focus AND
 * select. Enter/Space select through the native button click.
 *
 * Close: each closable tab has a pointer-only close button (tabindex -1,
 * aria-hidden inside the tablist) and Delete/Backspace on the focused tab.
 * A cancelable, bubbling `tab-strip:close` ({ id }) fires first; unless a
 * listener calls preventDefault() the tab is removed client-side. When the
 * active tab closes, selection moves to the next tab (or the previous one at
 * the end); focus follows when it was inside the closed tab.
 *
 * Events (all bubble from the root): `tab-strip:select` ({ id }) after the
 * active tab changes, `tab-strip:close` ({ id }, cancelable) and
 * `tab-strip:add` ({ id: null, add }) — the add button only announces; a
 * listener creates the tab by calling `event.detail.add({ id?, label?, count?,
 * closable?, href? })`, which clones the server-rendered template, selects the
 * new tab and returns its id. Server-driven apps (Livewire) re-render instead.
 *
 * Link mode (<nav> of anchors): no roving; Delete still closes a tab.
 *
 * Overflow: the list scrolls horizontally. The active tab is kept in view by
 * setting scrollLeft directly (no smooth scroll, so reduced motion is safe),
 * and `data-fade` on the list names the PHYSICAL sides that have more content
 * so a CSS mask can fade them. The active tab is revealed again on resize and
 * when a `dir` attribute changes after load. Scroll listener and observers are
 * removed in destroy().
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiTabStrip', (config = {}) => ({
        active: config.value === undefined || config.value === null ? null : String(config.value),
        mode: config.mode === 'links' ? 'links' : 'tabs',
        _counter: 0,
        _onScroll: null,
        _resizeObserver: null,
        _dirObserver: null,

        init() {
            const viewport = this.$refs.viewport;

            if (this.mode === 'tabs') {
                this.$nextTick(() => this._ensureActive());
                this.$watch('active', () => this.$nextTick(() => this.reveal()));
            }

            if (viewport) {
                this._onScroll = () => this.updateFade();
                viewport.addEventListener('scroll', this._onScroll, { passive: true });
                if (window.ResizeObserver) {
                    this._resizeObserver = new ResizeObserver(() => this.reveal());
                    this._resizeObserver.observe(viewport);
                }
            }

            // A direction switch after load (a locale toggle setting dir on
            // <html> or any ancestor) flips the scroll origin: bring the active
            // tab back into view and recompute which edges fade.
            if (window.MutationObserver) {
                this._dirObserver = new MutationObserver(() => this.$nextTick(() => this.reveal()));
                this._dirObserver.observe(document.documentElement, {
                    attributes: true,
                    attributeFilter: ['dir'],
                    subtree: true,
                });
            }

            this.$nextTick(() => {
                this.reveal();
                this.updateFade();
            });
        },

        destroy() {
            const viewport = this.$refs.viewport;
            if (viewport && this._onScroll) {
                viewport.removeEventListener('scroll', this._onScroll);
            }
            this._onScroll = null;
            if (this._resizeObserver) {
                this._resizeObserver.disconnect();
                this._resizeObserver = null;
            }
            if (this._dirObserver) {
                this._dirObserver.disconnect();
                this._dirObserver = null;
            }
        },

        // --- Lookup ------------------------------------------------------------

        items() {
            const viewport = this.$refs.viewport;
            if (!viewport) return [];
            return Array.from(viewport.children).filter(
                (child) => child.getAttribute('data-slot') === 'tab-strip-item'
            );
        },

        itemFor(id) {
            if (id === null || id === undefined) return null;
            return this.items().find((item) => item.dataset.id === String(id)) ?? null;
        },

        tabIn(item) {
            return item ? item.querySelector('[data-slot="tab-strip-tab"]') : null;
        },

        isActive(id) {
            return this.active !== null && this.active !== undefined && String(this.active) === String(id);
        },

        // --- Selection ---------------------------------------------------------

        select(id) {
            if (this.mode !== 'tabs' || id === null || id === undefined) return;
            const next = String(id);
            if (this.isActive(next)) return;
            this.active = next;
            this._emit('tab-strip:select', { id: next });
        },

        // A bound model may name a tab that does not exist (or nothing): the
        // first tab then owns the selection so one tab is always a tab stop.
        _ensureActive() {
            if (this.itemFor(this.active)) return;
            const first = this.items()[0];
            this.active = first ? first.dataset.id : null;
        },

        // --- Keyboard ----------------------------------------------------------

        onKey(event) {
            const tab = event.target.closest ? event.target.closest('[data-slot="tab-strip-tab"]') : null;
            if (!tab || !this.$refs.viewport.contains(tab)) return;
            const item = tab.closest('[data-slot="tab-strip-item"]');

            if (event.key === 'Delete' || event.key === 'Backspace') {
                if (item && item.dataset.closable === 'true') {
                    event.preventDefault();
                    this.close(item.dataset.id);
                }
                return;
            }

            if (this.mode !== 'tabs') return;

            const tabs = this.items().map((entry) => this.tabIn(entry)).filter(Boolean);
            const current = tabs.indexOf(tab);
            if (current === -1) return;

            const rtl = getComputedStyle(this.$el).direction === 'rtl';
            const nextKey = rtl ? 'ArrowLeft' : 'ArrowRight';
            const prevKey = rtl ? 'ArrowRight' : 'ArrowLeft';

            let next;
            if (event.key === nextKey) {
                next = (current + 1) % tabs.length;
            } else if (event.key === prevKey) {
                next = (current - 1 + tabs.length) % tabs.length;
            } else if (event.key === 'Home') {
                next = 0;
            } else if (event.key === 'End') {
                next = tabs.length - 1;
            } else {
                return;
            }

            event.preventDefault();
            const target = tabs[next];
            target.focus();
            this.select(target.dataset.id);
        },

        // --- Close / add -------------------------------------------------------

        close(id) {
            const item = this.itemFor(id);
            if (!item || item.dataset.closable !== 'true') return false;

            const closedId = item.dataset.id;
            const event = this._emit('tab-strip:close', { id: closedId }, true);
            if (event.defaultPrevented) return false;

            const items = this.items();
            const index = items.indexOf(item);
            const neighbour = items[index + 1] ?? items[index - 1] ?? null;
            const hadFocus = item.contains(document.activeElement);
            const wasActive = this.mode === 'tabs' && this.isActive(closedId);

            item.remove();

            if (wasActive) {
                if (neighbour) {
                    this.select(neighbour.dataset.id);
                } else {
                    this.active = null;
                }
            }

            if (hadFocus) {
                const target =
                    this.mode === 'tabs'
                        ? this.tabIn(this.itemFor(this.active)) ?? this.tabIn(neighbour)
                        : this.tabIn(neighbour);
                (target ?? this.$refs.add ?? null)?.focus();
            }

            this.$nextTick(() => this.updateFade());
            return true;
        },

        add() {
            this._emit('tab-strip:add', { id: null, add: (tab) => this.addTab(tab) });
        },

        addTab(tab = {}) {
            const template = this.$refs.template;
            const viewport = this.$refs.viewport;
            if (!template || !viewport) return null;

            const id = String(tab.id ?? `tab-${Date.now().toString(36)}-${++this._counter}`);
            if (this.itemFor(id)) {
                this._focusAndSelect(id);
                return id;
            }

            const label = String(tab.label ?? config.newTabLabel ?? '');
            const item = template.content.firstElementChild.cloneNode(true);
            const button = this.tabIn(item);
            item.dataset.id = id;
            button.dataset.id = id;
            item.querySelector('[data-slot="tab-strip-label"]').textContent = label;

            const count = item.querySelector('[data-slot="tab-strip-count"]');
            if (count) {
                if (tab.count === undefined || tab.count === null || tab.count === '') {
                    count.remove();
                } else {
                    count.textContent = String(tab.count);
                    count.hidden = false;
                }
            }

            const close = item.querySelector('[data-slot="tab-strip-close"]');
            if (tab.closable === false) {
                item.dataset.closable = 'false';
                close?.remove();
                button.removeAttribute('aria-keyshortcuts');
                button.removeAttribute('x-bind:aria-describedby');
            } else if (close && close.hasAttribute('aria-label')) {
                close.setAttribute('aria-label', String(config.closeLabel ?? 'Close :label').replace(':label', label));
            }

            if (this.mode === 'links') {
                const href = typeof tab.href === 'string' && !/^\s*javascript:/i.test(tab.href) ? tab.href : '#';
                button.setAttribute('href', href);
            }

            viewport.insertBefore(item, template);
            this.$nextTick(() => {
                this._focusAndSelect(id);
                this.updateFade();
            });

            return id;
        },

        _focusAndSelect(id) {
            this.select(id);
            this.tabIn(this.itemFor(id))?.focus();
            this.reveal(id);
        },

        // --- Overflow ----------------------------------------------------------

        // Scroll the active tab (or the given / current-page one) into the
        // viewport, clear of the edge fades. Direct scrollLeft, never smooth.
        reveal(id = null) {
            const viewport = this.$refs.viewport;
            if (!viewport) return;
            let item = id !== null ? this.itemFor(id) : null;
            if (!item) {
                item =
                    this.mode === 'tabs'
                        ? this.itemFor(this.active)
                        : viewport.querySelector('[aria-current="page"]')?.closest('[data-slot="tab-strip-item"]');
            }
            if (!item) {
                this.updateFade();
                return;
            }

            const inset = 32;
            const box = viewport.getBoundingClientRect();
            const rect = item.getBoundingClientRect();
            if (rect.left < box.left + inset) {
                viewport.scrollLeft -= box.left + inset - rect.left;
            } else if (rect.right > box.right - inset) {
                viewport.scrollLeft += rect.right - (box.right - inset);
            }
            this.updateFade();
        },

        updateFade() {
            const viewport = this.$refs.viewport;
            if (!viewport) return;
            const max = viewport.scrollWidth - viewport.clientWidth;
            if (max <= 1) {
                viewport.dataset.fade = 'none';
                return;
            }
            // Distance scrolled from the physical left edge. RTL scrollLeft runs
            // from 0 (start, at the right) down to -max.
            const rtl = getComputedStyle(viewport).direction === 'rtl';
            const fromLeft = rtl ? max + viewport.scrollLeft : viewport.scrollLeft;
            const left = fromLeft > 1;
            const right = fromLeft < max - 1;
            viewport.dataset.fade = left && right ? 'both' : left ? 'left' : right ? 'right' : 'none';
        },

        _emit(name, detail, cancelable = false) {
            const event = new CustomEvent(name, { detail, bubbles: true, cancelable });
            this.$el.dispatchEvent(event);
            return event;
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0