Navigation
Tabs code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/tabs.blade.php
@props([
'tabs' => [],
'default' => null,
'variant' => 'underline',
'vertical' => false,
'icons' => [],
'compact' => false,
'syncHash' => false,
])
@php
/*
| Tabbed interface driven by Alpine. Pass `tabs` as [key => label] and provide
| matching panels via named slots (slot name === key).
|
| variant: underline, pills
| vertical: stack the tab list beside the panels instead of above them
| icons: optional [key => icon name] map
| compact: use a tighter horizontal tab treatment
|
| Usage:
| <x-ui.tabs :tabs="['overview' => 'Overview', 'activity' => 'Activity']">
| <x-slot:overview>...</x-slot:overview>
| <x-slot:activity>...</x-slot:activity>
| </x-ui.tabs>
| <x-ui.tabs variant="pills" vertical :tabs="[...]">...</x-ui.tabs>
*/
$first = $default ?? array_key_first($tabs);
$tabKeys = array_map(fn ($key) => (string) $key, array_keys($tabs));
$defaultIndex = array_search((string) $first, $tabKeys, true);
$defaultIndex = $defaultIndex === false ? 0 : $defaultIndex;
$instanceId = substr(md5(json_encode($tabKeys).$variant.($vertical ? 'v' : '')), 0, 10);
$isCompact = $compact && ! $vertical;
$pillsListClasses = $vertical
? 'flex w-48 shrink-0 flex-col items-stretch gap-1 self-start rounded-xl bg-gray-100 p-1 dark:bg-gray-800'
: ($isCompact
? 'inline-flex items-center gap-0.5 rounded-lg bg-gray-100 p-0.5 dark:bg-gray-800'
: 'inline-flex items-center gap-1 rounded-xl bg-gray-100 p-1 dark:bg-gray-800');
$underlineListClasses = $vertical
? 'flex w-48 shrink-0 flex-col items-stretch gap-1 self-start border-e border-gray-200 pe-4 dark:border-gray-800'
: 'flex items-center '.($isCompact ? 'gap-4' : 'gap-6').' border-b border-gray-200 dark:border-gray-800';
@endphp
<div
x-data="{
active: @js((string) $first),
keys: @js($tabKeys),
focusIndex: @js($defaultIndex),
activate(key) {
const index = this.keys.indexOf(key);
if (index === -1) {
return;
}
this.active = key;
this.focusIndex = index;
},
focusKey(key) {
this.$nextTick(() => this.$refs['tab-' + key]?.focus());
},
move(direction) {
if (this.keys.length === 0) {
return;
}
const next = (this.focusIndex + direction + this.keys.length) % this.keys.length;
const key = this.keys[next];
this.activate(key);
this.focusKey(key);
},
moveTo(index) {
if (this.keys.length === 0) {
return;
}
const next = index < 0 ? this.keys.length - 1 : (index >= this.keys.length ? 0 : index);
const key = this.keys[next];
this.activate(key);
this.focusKey(key);
},
}"
@if ($syncHash)
x-init="
const selectFromHash = () => {
const key = window.location.hash.slice(1);
if (keys.includes(key)) {
activate(key);
}
};
selectFromHash();
window.addEventListener('hashchange', selectFromHash);
"
@endif
{{ $attributes->merge(['class' => 'w-full'.($vertical ? ' flex gap-6' : '')]) }}
>
@if ($variant === 'pills')
<div class="{{ $pillsListClasses }}" role="tablist" @if ($vertical) aria-orientation="vertical" @endif>
@foreach ($tabs as $key => $label)
@php
$tabIcon = $icons[$key] ?? null;
$tabSlug = Str::slug((string) $key) ?: 'tab';
$tabId = "tabs-{$instanceId}-tab-{$tabSlug}";
$panelId = "tabs-{$instanceId}-panel-{$tabSlug}";
@endphp
<button
id="{{ $tabId }}"
x-ref="tab-{{ $key }}"
type="button"
role="tab"
aria-controls="{{ $panelId }}"
:aria-selected="(active === @js((string) $key)).toString()"
:tabindex="active === @js((string) $key) ? '0' : '-1'"
@click="activate(@js((string) $key)); @if ($syncHash) window.history.replaceState(null, '', '#{{ $key }}'); @endif"
@keydown.arrow-right.prevent="move(1)"
@keydown.arrow-down.prevent="move(1)"
@keydown.arrow-left.prevent="move(-1)"
@keydown.arrow-up.prevent="move(-1)"
@keydown.home.prevent="moveTo(0)"
@keydown.end.prevent="moveTo(keys.length - 1)"
:class="active === '{{ $key }}' ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'"
class="inline-flex items-center gap-1.5 font-medium transition cursor-pointer {{ $isCompact ? 'rounded-md px-2.5 py-1.5 text-xs' : 'rounded-lg px-3.5 py-1.5 text-sm' }} {{ $vertical ? 'text-start' : '' }}"
>
@if ($tabIcon)
<x-ui.icon :name="$tabIcon" size="sm" />
@endif
<span>{{ $label }}</span>
</button>
@endforeach
</div>
@else
<div class="{{ $underlineListClasses }}" role="tablist" @if ($vertical) aria-orientation="vertical" @endif>
@foreach ($tabs as $key => $label)
@php
$tabIcon = $icons[$key] ?? null;
$tabSlug = Str::slug((string) $key) ?: 'tab';
$tabId = "tabs-{$instanceId}-tab-{$tabSlug}";
$panelId = "tabs-{$instanceId}-panel-{$tabSlug}";
@endphp
<button
id="{{ $tabId }}"
x-ref="tab-{{ $key }}"
type="button"
role="tab"
aria-controls="{{ $panelId }}"
:aria-selected="(active === @js((string) $key)).toString()"
:tabindex="active === @js((string) $key) ? '0' : '-1'"
@click="activate(@js((string) $key)); @if ($syncHash) window.history.replaceState(null, '', '#{{ $key }}'); @endif"
@keydown.arrow-right.prevent="move(1)"
@keydown.arrow-down.prevent="move(1)"
@keydown.arrow-left.prevent="move(-1)"
@keydown.arrow-up.prevent="move(-1)"
@keydown.home.prevent="moveTo(0)"
@keydown.end.prevent="moveTo(keys.length - 1)"
:class="active === '{{ $key }}' ? 'border-brand-500 text-gray-900 dark:text-white' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-700'"
@if ($vertical)
class="-me-px inline-flex items-center gap-1.5 border-e-2 px-3 py-2 text-start text-sm font-medium transition cursor-pointer"
@else
class="-mb-px inline-flex items-center gap-1.5 border-b-2 px-1 {{ $isCompact ? 'py-2 text-xs' : 'py-3 text-sm' }} font-medium transition cursor-pointer"
@endif
>
@if ($tabIcon)
<x-ui.icon :name="$tabIcon" size="sm" />
@endif
<span>{{ $label }}</span>
</button>
@endforeach
</div>
@endif
<div class="{{ $vertical ? 'min-w-0 flex-1' : 'mt-5' }}">
@foreach ($tabs as $key => $label)
@php
$tabSlug = Str::slug((string) $key) ?: 'tab';
$tabId = "tabs-{$instanceId}-tab-{$tabSlug}";
$panelId = "tabs-{$instanceId}-panel-{$tabSlug}";
@endphp
<div
id="{{ $panelId }}"
x-show="active === '{{ $key }}'"
x-cloak
role="tabpanel"
aria-labelledby="{{ $tabId }}"
tabindex="0"
>
{{ ${$key} ?? '' }}
</div>
@endforeach
</div>
</div>