Skip to content

Commit c221aa5

Browse files
committed
Improved mobile menus + accessibility + animations + state management
1 parent ae91940 commit c221aa5

File tree

7 files changed

+342
-28
lines changed

7 files changed

+342
-28
lines changed

resources/lang/en/filament-flexible-content-block-pages.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@
8888
'dropdown' => 'Dropdown Menu',
8989
],
9090
'no_items' => 'No menu items available',
91+
'mobile_toggle_label' => 'Open main menu',
92+
'toggle_submenu' => 'Toggle :label submenu',
9193
],
9294
'menu_items' => [
9395
'lbl' => 'menu item',

resources/lang/nl/filament-flexible-content-block-pages.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@
8888
'dropdown' => 'Dropdown Menu',
8989
],
9090
'no_items' => 'Geen menu-items beschikbaar',
91+
'mobile_toggle_label' => 'Hoofdmenu openen',
92+
'toggle_submenu' => ':label submenu in-/uitklappen',
9193
],
9294
'menu_items' => [
9395
'lbl' => 'menu-item',
Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,133 @@
1-
{{-- Dropdown menu template --}}
1+
{{-- Dropdown menu template with Alpine.js --}}
22
@if($items && $menu)
3-
<div class="menu-navigation menu-dropdown relative inline-block text-left">
3+
<div class="menu-navigation menu-dropdown relative inline-block text-left"
4+
x-data="dropdownMenu()"
5+
x-init="init()"
6+
@click.away="close()"
7+
@keydown.escape="close()">
48
<div>
5-
<button type="button" class="inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" id="menu-button" aria-expanded="true" aria-haspopup="true">
9+
<button type="button"
10+
class="inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
11+
:id="buttonId"
12+
:aria-expanded="isOpen"
13+
:aria-controls="menuId"
14+
aria-haspopup="true"
15+
@click="toggle()"
16+
@keydown.enter="toggle()"
17+
@keydown.space.prevent="toggle()"
18+
@keydown.arrow-down.prevent="openAndFocusFirst()"
19+
@keydown.arrow-up.prevent="openAndFocusLast()">
620
{{ $menu->name }}
7-
<svg class="-mr-1 h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
21+
<svg class="-mr-1 h-5 w-5 text-gray-400 transition-transform"
22+
:class="{ 'rotate-180': isOpen }"
23+
viewBox="0 0 20 20"
24+
fill="currentColor"
25+
aria-hidden="true">
826
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
927
</svg>
1028
</button>
1129
</div>
1230

13-
<div class="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
31+
<div x-show="isOpen"
32+
x-transition:enter="transition ease-out duration-100"
33+
x-transition:enter-start="transform opacity-0 scale-95"
34+
x-transition:enter-end="transform opacity-100 scale-100"
35+
x-transition:leave="transition ease-in duration-75"
36+
x-transition:leave-start="transform opacity-100 scale-100"
37+
x-transition:leave-end="transform opacity-0 scale-95"
38+
class="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
39+
role="menu"
40+
aria-orientation="vertical"
41+
:aria-labelledby="buttonId"
42+
:id="menuId"
43+
@keydown.arrow-down.prevent="focusNext()"
44+
@keydown.arrow-up.prevent="focusPrevious()"
45+
@keydown.escape="closeAndFocusButton()"
46+
@keydown.tab="close()">
1447
<div class="py-1" role="none">
1548
@foreach($items as $item)
1649
<x-flexible-pages-menu-item :item="$item" :style="$style" />
1750
@endforeach
1851
</div>
1952
</div>
2053
</div>
54+
55+
<script>
56+
document.addEventListener('alpine:init', () => {
57+
Alpine.data('dropdownMenu', () => ({
58+
isOpen: false,
59+
buttonId: 'menu-button-' + Math.random().toString(36).substr(2, 9),
60+
menuId: 'menu-' + Math.random().toString(36).substr(2, 9),
61+
62+
init() {
63+
// Initialize any needed setup
64+
},
65+
66+
toggle() {
67+
this.isOpen ? this.close() : this.open();
68+
},
69+
70+
open() {
71+
this.isOpen = true;
72+
},
73+
74+
close() {
75+
this.isOpen = false;
76+
},
77+
78+
openAndFocusFirst() {
79+
this.open();
80+
this.$nextTick(() => {
81+
this.focusFirstItem();
82+
});
83+
},
84+
85+
openAndFocusLast() {
86+
this.open();
87+
this.$nextTick(() => {
88+
this.focusLastItem();
89+
});
90+
},
91+
92+
closeAndFocusButton() {
93+
this.close();
94+
this.$nextTick(() => {
95+
this.$refs.button?.focus();
96+
});
97+
},
98+
99+
focusFirstItem() {
100+
const items = this.getMenuItems();
101+
if (items.length > 0) {
102+
items[0].focus();
103+
}
104+
},
105+
106+
focusLastItem() {
107+
const items = this.getMenuItems();
108+
if (items.length > 0) {
109+
items[items.length - 1].focus();
110+
}
111+
},
112+
113+
focusNext() {
114+
const items = this.getMenuItems();
115+
const currentIndex = items.indexOf(document.activeElement);
116+
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
117+
items[nextIndex].focus();
118+
},
119+
120+
focusPrevious() {
121+
const items = this.getMenuItems();
122+
const currentIndex = items.indexOf(document.activeElement);
123+
const previousIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
124+
items[previousIndex].focus();
125+
},
126+
127+
getMenuItems() {
128+
return Array.from(this.$el.querySelectorAll('[role="menuitem"]'));
129+
}
130+
}));
131+
});
132+
</script>
21133
@endif
Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,86 @@
1-
{{-- Horizontal menu template --}}
1+
{{-- Horizontal menu template with mobile toggle --}}
22
@if($items && $menu)
3-
<nav class="menu-navigation menu-horizontal" role="navigation" aria-label="{{ $menu->name }}">
4-
<ul class="flex space-x-6">
3+
<nav class="menu-navigation menu-horizontal"
4+
role="navigation"
5+
aria-label="{{ $menu->name }}"
6+
x-data="horizontalMenu()"
7+
x-init="init()">
8+
9+
{{-- Mobile menu button --}}
10+
<div class="md:hidden">
11+
<button type="button"
12+
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
13+
:aria-expanded="mobileOpen"
14+
:aria-controls="mobileMenuId"
15+
@click="toggleMobile()"
16+
@keydown.enter="toggleMobile()"
17+
@keydown.space.prevent="toggleMobile()">
18+
<span class="sr-only">{{ flexiblePagesTrans('menu.mobile_toggle_label') }}</span>
19+
{{-- Hamburger icon --}}
20+
<svg x-show="!mobileOpen" class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
21+
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
22+
</svg>
23+
{{-- Close icon --}}
24+
<svg x-show="mobileOpen" class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
25+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
26+
</svg>
27+
</button>
28+
</div>
29+
30+
{{-- Desktop menu --}}
31+
<ul class="hidden md:flex md:space-x-6" role="menubar">
532
@foreach($items as $item)
6-
<x-flexible-pages-menu-item :item="$item" :style="$style" />
33+
<li role="none">
34+
<x-flexible-pages-menu-item :item="$item" :style="$style" />
35+
</li>
736
@endforeach
837
</ul>
38+
39+
{{-- Mobile menu --}}
40+
<div x-show="mobileOpen"
41+
x-transition:enter="transition ease-out duration-100"
42+
x-transition:enter-start="transform opacity-0 scale-95"
43+
x-transition:enter-end="transform opacity-100 scale-100"
44+
x-transition:leave="transition ease-in duration-75"
45+
x-transition:leave-start="transform opacity-100 scale-100"
46+
x-transition:leave-end="transform opacity-0 scale-95"
47+
class="md:hidden absolute top-full left-0 right-0 z-50 bg-white shadow-lg border-t"
48+
:id="mobileMenuId"
49+
@click.away="closeMobile()"
50+
@keydown.escape="closeMobile()">
51+
<ul class="px-2 pt-2 pb-3 space-y-1" role="menu">
52+
@foreach($items as $item)
53+
<li role="none">
54+
<x-flexible-pages-menu-item :item="$item" :style="'mobile'" />
55+
</li>
56+
@endforeach
57+
</ul>
58+
</div>
959
</nav>
60+
61+
<script>
62+
document.addEventListener('alpine:init', () => {
63+
Alpine.data('horizontalMenu', () => ({
64+
mobileOpen: false,
65+
mobileMenuId: 'mobile-menu-' + Math.random().toString(36).substr(2, 9),
66+
67+
init() {
68+
// Close mobile menu on window resize to desktop
69+
window.addEventListener('resize', () => {
70+
if (window.innerWidth >= 768) { // md breakpoint
71+
this.mobileOpen = false;
72+
}
73+
});
74+
},
75+
76+
toggleMobile() {
77+
this.mobileOpen = !this.mobileOpen;
78+
},
79+
80+
closeMobile() {
81+
this.mobileOpen = false;
82+
}
83+
}));
84+
});
85+
</script>
1086
@endif
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{{-- Mobile menu item template --}}
2+
@php
3+
$linkClasses = collect(['block', 'px-3', 'py-2', 'rounded-md', 'text-base', 'font-medium'])
4+
->when($item['is_current'], fn($collection) => $collection->push('text-indigo-700', 'bg-indigo-50'))
5+
->when(!$item['is_current'], fn($collection) => $collection->push('text-gray-700', 'hover:text-gray-900', 'hover:bg-gray-50'))
6+
->filter()
7+
->implode(' ');
8+
@endphp
9+
10+
<a href="{{ $item['url'] }}"
11+
class="{{ $linkClasses }}"
12+
role="menuitem"
13+
@if($item['target'] !== '_self') target="{{ $item['target'] }}" @endif
14+
@if($item['is_current']) aria-current="page" @endif
15+
{!! $getDataAttributes() !!}>
16+
{{ $item['label'] }}
17+
@if($item['has_children'])
18+
<span class="ml-2 text-gray-400">▼</span>
19+
@endif
20+
</a>
21+
22+
@if($item['has_children'])
23+
<div class="pl-4">
24+
@foreach($item['children'] as $child)
25+
<a href="{{ $child['url'] }}"
26+
class="block px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50"
27+
role="menuitem"
28+
@if($child['target'] !== '_self') target="{{ $child['target'] }}" @endif
29+
@if($child['is_current']) aria-current="page" @endif>
30+
{{ $child['label'] }}
31+
</a>
32+
@endforeach
33+
</div>
34+
@endif
Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{{-- Vertical menu item template --}}
1+
{{-- Vertical menu item template with collapsible submenus --}}
22
@php
33
$classes = collect(['menu-item'])
44
->when($item['has_children'], fn($collection) => $collection->push('has-children'))
@@ -13,33 +13,73 @@
1313
->when(!$item['is_current'], fn($collection) => $collection->push('text-gray-700', 'hover:bg-gray-100'))
1414
->filter()
1515
->implode(' ');
16+
17+
$itemId = 'menu-item-' . $item['id'];
1618
@endphp
1719

18-
<li class="{{ $classes }}" {!! $getDataAttributes() !!}>
19-
<a href="{{ $item['url'] }}"
20-
class="{{ $linkClasses }}"
21-
@if($item['target'] !== '_self') target="{{ $item['target'] }}" @endif
22-
@if($item['is_current']) aria-current="page" @endif>
23-
<span class="flex-1">{{ $item['label'] }}</span>
24-
@if($item['has_children'])
25-
<svg class="w-5 h-5 ml-2" fill="currentColor" viewBox="0 0 20 20">
26-
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
27-
</svg>
28-
@endif
29-
</a>
20+
<li class="{{ $classes }}"
21+
{!! $getDataAttributes() !!}
22+
@if($item['has_children'])
23+
data-has-children="true"
24+
data-item-id="{{ $itemId }}"
25+
@endif>
3026

3127
@if($item['has_children'])
32-
<ul class="ml-6 mt-2 space-y-1">
28+
{{-- Parent item with submenu toggle --}}
29+
<div class="flex items-center">
30+
<a href="{{ $item['url'] }}"
31+
class="{{ $linkClasses }} flex-1"
32+
role="menuitem"
33+
@if($item['target'] !== '_self') target="{{ $item['target'] }}" @endif
34+
@if($item['is_current']) aria-current="page" @endif>
35+
<span class="flex-1">{{ $item['label'] }}</span>
36+
</a>
37+
<button type="button"
38+
class="p-1 ml-2 rounded hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
39+
@click="$parent.toggleSubmenu('{{ $itemId }}')"
40+
:aria-expanded="$parent.isExpanded('{{ $itemId }}')"
41+
:aria-controls="'submenu-{{ $itemId }}'"
42+
aria-label="{{ flexiblePagesTrans('menu.toggle_submenu', ['label' => $item['label']]) }}">
43+
<svg class="w-4 h-4 transition-transform"
44+
:class="{ 'rotate-90': $parent.isExpanded('{{ $itemId }}') }"
45+
fill="currentColor"
46+
viewBox="0 0 20 20">
47+
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
48+
</svg>
49+
</button>
50+
</div>
51+
52+
{{-- Collapsible submenu --}}
53+
<ul x-show="$parent.isExpanded('{{ $itemId }}')"
54+
x-transition:enter="transition ease-out duration-100"
55+
x-transition:enter-start="opacity-0 scale-95"
56+
x-transition:enter-end="opacity-100 scale-100"
57+
x-transition:leave="transition ease-in duration-75"
58+
x-transition:leave-start="opacity-100 scale-100"
59+
x-transition:leave-end="opacity-0 scale-95"
60+
class="ml-6 mt-2 space-y-1"
61+
id="submenu-{{ $itemId }}"
62+
role="menu">
3363
@foreach($item['children'] as $child)
34-
<li>
64+
<li role="none">
3565
<a href="{{ $child['url'] }}"
3666
class="flex items-center px-3 py-2 text-sm rounded-lg {{ $child['is_current'] ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50' }}"
67+
role="menuitem"
3768
@if($child['target'] !== '_self') target="{{ $child['target'] }}" @endif
3869
@if($child['is_current']) aria-current="page" @endif>
3970
{{ $child['label'] }}
4071
</a>
4172
</li>
4273
@endforeach
4374
</ul>
75+
@else
76+
{{-- Regular menu item without children --}}
77+
<a href="{{ $item['url'] }}"
78+
class="{{ $linkClasses }}"
79+
role="menuitem"
80+
@if($item['target'] !== '_self') target="{{ $item['target'] }}" @endif
81+
@if($item['is_current']) aria-current="page" @endif>
82+
<span class="flex-1">{{ $item['label'] }}</span>
83+
</a>
4484
@endif
4585
</li>

0 commit comments

Comments
 (0)