Navigation
Mobile Bottom Nav code
Copy the implementation or inspect every file that belongs to this component unit.
app/Support/MobileBottomNav.php
<?php
namespace App\Support;
final class MobileBottomNav
{
/**
* @param array<int|string, mixed> $items
* @return list<array{
* label: string,
* href: ?string,
* icon: ?string,
* active: bool,
* badge: mixed,
* external: bool,
* panelSlot: ?string,
* children: list<array{
* label: string,
* href: ?string,
* active: bool,
* external: bool,
* children: list<array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}>
* }>
* }>
*/
public static function normalizeItems(array $items): array
{
return collect($items)
->map(function (mixed $item, string|int $key): array {
if (is_string($item)) {
return self::linkItem((string) $key, $item);
}
if (! is_array($item)) {
return self::linkItem(is_string($key) ? $key : 'Link', '#');
}
if (self::isChildMap($item)) {
return self::menuItem(
is_string($key) ? $key : 'Menu',
self::normalizeChildren($item),
);
}
return [
'label' => $item['label'] ?? (is_string($key) ? $key : 'Link'),
'href' => $item['href'] ?? null,
'icon' => $item['icon'] ?? null,
'active' => (bool) ($item['active'] ?? false),
'badge' => $item['badge'] ?? null,
'external' => (bool) ($item['external'] ?? false),
'panelSlot' => is_string($item['panelSlot'] ?? null) ? $item['panelSlot'] : null,
'children' => self::normalizeChildren($item['children'] ?? []),
];
})
->take(4)
->values()
->all();
}
/**
* @param array{
* label: string,
* href: ?string,
* icon: ?string,
* active: bool,
* badge: mixed,
* external: bool,
* children: list<array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}>
* } $item
*/
public static function isItemActive(array $item): bool
{
if ($item['active']) {
return true;
}
foreach ($item['children'] as $child) {
if (self::isChildActive($child)) {
return true;
}
}
return self::isLinkActive($item['href'], false);
}
/**
* @param array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>} $child
*/
public static function isChildActive(array $child): bool
{
if ($child['active']) {
return true;
}
foreach ($child['children'] ?? [] as $nestedChild) {
if (is_array($nestedChild) && self::isChildActive($nestedChild)) {
return true;
}
}
return self::isLinkActive($child['href'] ?? null, false);
}
/**
* @param array<string, mixed> $children
* @return list<array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}>
*/
private static function normalizeChildren(array $children): array
{
return collect($children)
->map(function (mixed $child, string|int $key): array {
if (is_string($child)) {
return self::childLink((string) $key, $child);
}
if (! is_array($child)) {
return self::childLink(is_string($key) ? $key : 'Link', '#');
}
if (self::isChildMap($child)) {
return self::childMenu(
is_string($key) ? $key : 'Menu',
self::normalizeChildren($child),
);
}
if (! empty($child['children']) && is_array($child['children'])) {
return self::childMenu(
$child['label'] ?? (is_string($key) ? $key : 'Menu'),
self::normalizeChildren($child['children']),
$child,
);
}
return self::childLink(
$child['label'] ?? (is_string($key) ? $key : 'Link'),
$child['href'] ?? '#',
$child,
);
})
->values()
->all();
}
/**
* @param array<string, mixed> $item
*/
private static function isChildMap(array $item): bool
{
if (
array_key_exists('href', $item)
|| array_key_exists('label', $item)
|| array_key_exists('children', $item)
) {
return false;
}
if ($item === []) {
return false;
}
return collect($item)->every(
fn (mixed $value): bool => is_string($value) || (is_array($value) && self::isChildMap($value)),
);
}
/**
* @param list<array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}> $children
* @return array{
* label: string,
* href: ?string,
* icon: ?string,
* active: bool,
* badge: mixed,
* external: bool,
* panelSlot: ?string,
* children: list<array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}>
* }
*/
private static function menuItem(string $label, array $children, ?string $panelSlot = null): array
{
return [
'label' => $label,
'href' => null,
'icon' => null,
'active' => false,
'badge' => null,
'external' => false,
'panelSlot' => $panelSlot,
'children' => $children,
];
}
/**
* @return array{
* label: string,
* href: ?string,
* icon: ?string,
* active: bool,
* badge: mixed,
* external: bool,
* panelSlot: ?string,
* children: list<array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}>
* }
*/
private static function linkItem(string $label, string $href = '#'): array
{
return [
'label' => $label,
'href' => $href,
'icon' => null,
'active' => false,
'badge' => null,
'external' => false,
'panelSlot' => null,
'children' => [],
];
}
/**
* @param array<string, mixed> $overrides
* @return array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}
*/
private static function childLink(string $label, string $href = '#', array $overrides = []): array
{
return [
'label' => $label,
'href' => $href,
'active' => (bool) ($overrides['active'] ?? false),
'external' => (bool) ($overrides['external'] ?? false),
'children' => [],
];
}
/**
* @param list<array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}> $children
* @param array<string, mixed> $overrides
* @return array{label: string, href: ?string, active: bool, external: bool, children: list<mixed>}
*/
private static function childMenu(string $label, array $children, array $overrides = []): array
{
return [
'label' => $label,
'href' => null,
'active' => (bool) ($overrides['active'] ?? false),
'external' => false,
'children' => $children,
];
}
private static function isLinkActive(?string $href, bool $active): bool
{
if ($active) {
return true;
}
$href = (string) ($href ?? '');
if ($href === '' || $href === '#' || str_starts_with($href, '#')) {
return false;
}
$current = rtrim(url()->current(), '/');
$target = rtrim(url($href), '/');
if ($current === $target) {
return true;
}
$targetPath = parse_url($target, PHP_URL_PATH) ?: '/';
if ($targetPath === '/') {
return false;
}
return str_starts_with($current.'/', $target.'/');
}
}
resources/views/components/ui/mobile-bottom-nav.blade.php
@props([
'items' => [],
'label' => 'Mobile navigation',
'fixed' => true,
'hideOnDesktop' => true,
'maxWidth' => 'md',
])
@php
/*
| Mobile viewport bottom navigation. Pass up to four top-level items as
| ['Label' => '/href'], ['Label' => ['Child' => '/href', ...]] for expandable
| menus (including nested submenus), or richer arrays with label, href, icon,
| active, badge, external, and children keys.
*/
$normalizedItems = \App\Support\MobileBottomNav::normalizeItems($items);
$maxWidthClass = [
'sm' => 'max-w-sm',
'md' => 'max-w-md',
'lg' => 'max-w-lg',
'none' => '',
][$maxWidth] ?? 'max-w-md';
$positionClass = $fixed ? 'fixed' : 'absolute';
$visibilityClass = $hideOnDesktop ? 'md:hidden' : '';
@endphp
@if ($normalizedItems !== [])
<nav
x-data="{ openIndex: null }"
x-on:keydown.escape.window="openIndex = null"
aria-label="{{ $label }}"
{{ $attributes->merge(['class' => "{$positionClass} inset-x-0 bottom-0 z-50 px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] {$visibilityClass}"]) }}
>
<div
x-show="openIndex !== null"
x-transition.opacity
x-cloak
class="fixed inset-0 z-40 bg-gray-950/30 backdrop-blur-[1px]"
x-on:click="openIndex = null"
aria-hidden="true"
></div>
<div class="relative z-50 mx-auto {{ $maxWidthClass }}">
@foreach ($normalizedItems as $index => $item)
@if ($item['children'] !== [])
<div
x-show="openIndex === {{ $index }}"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-y-2 opacity-0"
x-transition:enter-end="translate-y-0 opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-y-0 opacity-100"
x-transition:leave-end="translate-y-2 opacity-0"
x-cloak
class="absolute inset-x-0 bottom-full mb-2"
role="menu"
aria-label="{{ $item['label'] }}"
>
<div class="overflow-hidden rounded-2xl border border-gray-200/80 bg-white/95 shadow-xl shadow-gray-900/10 backdrop-blur-lg dark:border-gray-800/80 dark:bg-gray-950/95 dark:shadow-black/30">
<div class="border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ $item['label'] }}</p>
</div>
<div class="max-h-64 space-y-1 overflow-y-auto p-2">
<x-ui.mobile-bottom-nav.menu-items :items="$item['children']" />
@php
$panelFooter = null;
if (! empty($item['panelSlot'])) {
$panelVariable = 'panel'.str((string) $item['panelSlot'])->studly();
if (isset($$panelVariable) && ! $$panelVariable->isEmpty()) {
$panelFooter = $$panelVariable;
}
}
@endphp
@if ($panelFooter)
<div class="mt-2 space-y-2 border-t border-gray-200 pt-2 dark:border-gray-800" x-on:click.stop>
{{ $panelFooter }}
</div>
@endif
</div>
</div>
</div>
@endif
@endforeach
<div class="rounded-2xl border border-gray-200/80 bg-white/90 p-2 shadow-lg shadow-gray-900/10 backdrop-blur-lg dark:border-gray-800/80 dark:bg-gray-950/90 dark:shadow-black/30">
<div class="grid grid-flow-col auto-cols-fr gap-1">
@foreach ($normalizedItems as $index => $item)
@php
$active = \App\Support\MobileBottomNav::isItemActive($item);
// Blade does not compile @js() inside a component tag's
// attribute value, so the boolean is prepared here and
// interpolated with {{ }}, which does compile there.
$activeJs = $active ? 'true' : 'false';
$hasChildren = $item['children'] !== [];
$itemClasses = $active
? 'bg-brand-50 text-brand-700 dark:bg-brand-950/50 dark:text-brand-300'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/80 dark:hover:text-white';
@endphp
@if ($hasChildren)
<button
type="button"
x-on:click="openIndex = openIndex === {{ $index }} ? null : {{ $index }}"
x-bind:aria-expanded="(openIndex === {{ $index }}).toString()"
aria-haspopup="menu"
class="group relative flex min-h-14 min-w-0 flex-col items-center justify-center gap-1 rounded-lg px-2 py-2 text-center text-xs font-semibold transition focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-950"
x-bind:class="{
'bg-brand-50 text-brand-700 dark:bg-brand-950/50 dark:text-brand-300': @js($active),
'bg-gray-100 text-gray-900 ring-1 ring-inset ring-gray-300 dark:bg-gray-800 dark:text-white dark:ring-gray-600': openIndex === {{ $index }} && ! @js($active),
'text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/80 dark:hover:text-white': openIndex !== {{ $index }} && ! @js($active),
}"
>
@if ($item['icon'])
<span class="relative inline-flex size-6 items-center justify-center">
<x-ui.icon
:name="$item['icon']"
:weight="$active ? 'fill' : 'regular'"
size="xl"
class="transition"
x-bind:class="{
'text-brand-600 dark:text-brand-400': {{ $activeJs }},
'text-gray-800 dark:text-gray-200': openIndex === {{ $index }} && ! {{ $activeJs }},
'text-gray-400 group-hover:text-gray-600 dark:text-gray-500 dark:group-hover:text-gray-300': openIndex !== {{ $index }} && ! {{ $activeJs }},
}"
/>
@if ($item['badge'] !== null)
<span class="absolute -right-2 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-brand-600 px-1 text-[0.625rem] font-bold leading-4 text-white ring-2 ring-white dark:bg-brand-400 dark:text-gray-950 dark:ring-gray-950">
{{ $item['badge'] }}
</span>
@endif
</span>
@elseif ($item['badge'] !== null)
<span class="inline-flex min-w-4 items-center justify-center rounded-full bg-brand-600 px-1 text-[0.625rem] font-bold leading-4 text-white dark:bg-brand-400 dark:text-gray-950">
{{ $item['badge'] }}
</span>
@endif
<span class="max-w-full truncate">{{ $item['label'] }}</span>
</button>
@else
<a
href="{{ $item['href'] }}"
@if ($active) aria-current="page" @endif
@if ($item['external']) target="_blank" rel="noreferrer noopener" @endif
class="group relative flex min-h-14 min-w-0 flex-col items-center justify-center gap-1 rounded-lg px-2 py-2 text-center text-xs font-semibold transition focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-950 {{ $itemClasses }}"
>
@if ($item['icon'])
<span class="relative inline-flex size-6 items-center justify-center">
<x-ui.icon
:name="$item['icon']"
:weight="$active ? 'fill' : 'regular'"
size="xl"
class="{{ $active ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400 group-hover:text-gray-600 dark:text-gray-500 dark:group-hover:text-gray-300' }}"
/>
@if ($item['badge'] !== null)
<span class="absolute -right-2 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-brand-600 px-1 text-[0.625rem] font-bold leading-4 text-white ring-2 ring-white dark:bg-brand-400 dark:text-gray-950 dark:ring-gray-950">
{{ $item['badge'] }}
</span>
@endif
</span>
@elseif ($item['badge'] !== null)
<span class="inline-flex min-w-4 items-center justify-center rounded-full bg-brand-600 px-1 text-[0.625rem] font-bold leading-4 text-white dark:bg-brand-400 dark:text-gray-950">
{{ $item['badge'] }}
</span>
@endif
<span class="max-w-full truncate">{{ $item['label'] }}</span>
</a>
@endif
@endforeach
</div>
</div>
</div>
</nav>
@endif
resources/views/components/ui/mobile-bottom-nav/menu-items.blade.php
@props([
'items' => [],
'depth' => 0,
])
@foreach ($items as $child)
@php
$childActive = \App\Support\MobileBottomNav::isChildActive($child);
$hasNestedChildren = ($child['children'] ?? []) !== [];
$indentClass = $depth > 0 ? 'ps-5' : '';
@endphp
@if ($hasNestedChildren)
<div
x-data="{ expanded: @js($childActive) }"
@class(['space-y-0.5', $indentClass => $depth > 0])
>
<button
type="button"
role="menuitem"
x-on:click="expanded = ! expanded"
x-bind:aria-expanded="expanded.toString()"
@class([
'flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-sm transition',
'bg-brand-50 font-semibold text-brand-700 dark:bg-brand-950/50 dark:text-brand-300' => $childActive,
'font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white' => ! $childActive,
])
>
<span class="flex-1 text-left">{{ $child['label'] }}</span>
<x-ui.icon
name="caret-down"
weight="bold"
size="sm"
class="shrink-0 text-gray-400 transition-transform duration-200 dark:text-gray-500"
x-bind:class="expanded && 'rotate-180'"
/>
</button>
<div x-show="expanded" x-collapse x-cloak class="space-y-0.5">
<x-ui.mobile-bottom-nav.menu-items :items="$child['children']" :depth="$depth + 1" />
</div>
</div>
@else
<a
href="{{ $child['href'] }}"
role="menuitem"
@if ($childActive) aria-current="page" @endif
@if ($child['external']) target="_blank" rel="noreferrer noopener" @endif
x-on:click="openIndex = null"
@class([
'flex items-center rounded-lg px-3 py-2.5 text-sm transition',
$indentClass => $depth > 0,
'bg-brand-50 font-semibold text-brand-700 dark:bg-brand-950/50 dark:text-brand-300' => $childActive,
'font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white' => ! $childActive,
])
>{{ $child['label'] }}</a>
@endif
@endforeach