Skip to main content

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