Tabs
Tabbed navigation — a compound panel-switching primitive, or a standalone bar whose active marker slides between tabs.
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
{{-- 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>
Installation
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:
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.
-
resources/views/components/ui/tab-strip.blade.php -
resources/js/ui/tab-strip.js
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.
# 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
{{-- Link mode: tabs with href render a nav of links; the current page carries
aria-current="page". Close still works (button or Delete). --}}
<div class="w-full max-w-2xl">
<x-ui.tab-strip
:label="__('Recent pages')"
value="invoices"
:addable="false"
:tabs="[
['id' => 'overview', 'label' => __('Overview'), 'href' => '#overview', 'closable' => false],
['id' => 'invoices', 'label' => __('Invoices'), 'href' => '#invoices', 'count' => 12],
['id' => 'customers', 'label' => __('Customers'), 'href' => '#customers'],
]"
/>
</div>
Long Content
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
@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 }}>
/**
* 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