diff --git a/README.md b/README.md index 53db24f..1fdc47e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ active state detection, and pre-compiled icons — perfect for Inertia.js, React - **Multiple Navigations** — Define unlimited nav structures (main, footer, sidebar, user menu) - **Fluent Builder API** — IDE-friendly builder with full autocomplete support +- **Sections & Groups** — Organize items with top-level sections and collapsible groups - **Route-Based** — Use Laravel route names with full IDE autocomplete - **Breadcrumb Generation** — Auto-generate breadcrumbs from your navigation config - **Active State Detection** — Smart detection of active items and their parents @@ -92,7 +93,8 @@ return [ Available helpers: - `nav_item($label, $route?, $icon?)` — Standard navigation item -- `nav_group($label, $children?, $icon?)` — Collapsible group/section +- `nav_group($label, $children?, $icon?)` — Collapsible group +- `nav_section($label, $children?, $icon?)` — Top-level section containing items and groups - `nav_separator()` — Visual separator - `nav_divider($spacing?)` — Divider with optional spacing - `nav_external($label, $url, $icon?)` — External link @@ -153,6 +155,12 @@ Navigation::register('sidebar') ->child('All Users', 'users.index') ->child('Create User', 'users.create') ->separator() + ->section('Admin', [ + Item::make('Roles')->route('admin.roles'), + Item::group('Settings', [ + Item::make('General')->route('settings.general'), + ]), + ], 'shield') ->item('Settings', 'settings', 'settings') ->done(); @@ -218,6 +226,17 @@ Item::separator() // Divider with spacing Item::divider('large') +// Group (collapsible sub-menu) +Item::group('Settings', [ + Item::make('Profile')->route('settings.profile'), +]) + +// Section (top-level container for items and groups) +Item::section('Workspace', [ + Item::make('Dashboard')->route('dashboard'), + Item::group('Settings', [...]), +]) + // With children Item::make('Settings') ->route('settings') @@ -265,6 +284,10 @@ Item::make('Dashboard') ## Groups & Sections +Use **groups** for collapsible sub-menus and **sections** for top-level structural containers that can hold both items and groups. + +### Groups + Organize navigation items into collapsible groups with headers: ```php @@ -364,6 +387,115 @@ Groups output with these additional fields: ] ``` +### Sections + +Sections are top-level structural containers that can hold both items and groups. They support all the same fluent options as groups but default to **non-collapsible** so they read as structural dividers (e.g., `WORKSPACE`, `ADMIN`). + +```php +use OffloadProject\Navigation\Item; + +return [ + 'navigations' => [ + 'sidebar' => [ + Item::section('Workspace', [ + Item::make('Dashboard')->route('dashboard')->icon('home'), + Item::group('Settings', [ + Item::make('Profile')->route('settings.profile'), + Item::make('Security')->route('settings.security'), + ])->icon('cog'), + ])->icon('layers'), + + Item::section('Admin', [ + Item::make('Users')->route('admin.users'), + Item::make('Roles')->route('admin.roles'), + ])->can('access-admin'), + ], + ], +]; +``` + +Or with helper functions: + +```php +return [ + 'navigations' => [ + 'sidebar' => [ + nav_section('Workspace', [ + nav_item('Dashboard', 'dashboard', 'home'), + nav_group('Settings', [ + nav_item('Profile', 'settings.profile'), + nav_item('Security', 'settings.security'), + ], 'cog'), + ], 'layers'), + ], + ], +]; +``` + +Or via the runtime builder: + +```php +Navigation::register('sidebar') + ->section('Workspace', [ + Item::make('Dashboard')->route('dashboard'), + Item::group('Settings', [ + Item::make('Profile')->route('settings.profile'), + ]), + ]) + ->done(); +``` + +#### Section Options + +Sections support every option groups support — icons, gates, badges, custom meta, visibility, and (opt-in) collapsibility: + +```php +// Default: non-collapsible structural divider +Item::section('Workspace', [...]) + +// Make it collapsible (and optionally start collapsed) +Item::section('Advanced', [...])->collapsible()->collapsed() + +// With icon and authorization +Item::section('Admin', [...])->icon('shield')->can('access-admin') + +// With a badge +Item::section('Notifications', [...])->badge(3) +``` + +#### Nesting Rules + +Sections are top-level only. Nesting a section inside another section or inside a group throws `InvalidNavigationItemException`: + +```php +// Not allowed — sections cannot be nested +Item::section('Outer', [ + Item::section('Inner', [...]), // throws +]) + +// Not allowed — groups cannot contain sections +Item::group('Settings', [ + Item::section('Inner', [...]), // throws +]) +``` + +#### Section Output + +Sections output with these additional fields: + +```php +[ + 'id' => 'nav-sidebar-0', + 'label' => 'Workspace', + 'isActive' => true, // Active if any child is active + 'icon' => '...', + 'section' => true, // Identifies this as a section + 'collapsible' => false, // Defaults to false (opt in via ->collapsible()) + 'collapsed' => false, + 'children' => [...], // Items and/or groups +] +``` + ## Route Parameters Pass parameters for routes that require them: diff --git a/src/Data/NavigationItem.php b/src/Data/NavigationItem.php index 2febeb5..593595a 100644 --- a/src/Data/NavigationItem.php +++ b/src/Data/NavigationItem.php @@ -190,6 +190,22 @@ private static function validate(array $data): void if (isset($data['params']) && ! isset($data['route'])) { throw InvalidNavigationItemException::paramsWithoutRoute($data); } + + // Sections cannot be nested inside sections or groups + $isSection = ! empty($data['section']); + $isGroup = ! empty($data['group']); + + if (($isSection || $isGroup) && isset($data['children']) && is_array($data['children'])) { + foreach ($data['children'] as $child) { + if ($child instanceof ItemBuilder) { + $child = $child->toArray(); + } + + if (is_array($child) && ! empty($child['section'])) { + throw InvalidNavigationItemException::nestedSection($data, $isSection ? 'section' : 'group'); + } + } + } } /** diff --git a/src/Exceptions/InvalidNavigationItemException.php b/src/Exceptions/InvalidNavigationItemException.php index 6e1466c..e2d491e 100644 --- a/src/Exceptions/InvalidNavigationItemException.php +++ b/src/Exceptions/InvalidNavigationItemException.php @@ -100,6 +100,21 @@ public static function paramsWithoutRoute(array $item): self ); } + /** + * Thrown when a section is nested inside another section or a group. + * + * @param array $item + */ + public static function nestedSection(array $item, string $parentKind): self + { + return new self( + message: sprintf('Navigation sections cannot be nested inside a %s.', $parentKind), + item: $item, + suggestion: 'Sections are top-level structural containers. Place sections at the root of a navigation, and put items or groups inside them — not other sections.', + docsSection: '#sections' + ); + } + /** * Thrown when an invalid HTTP method is specified. * diff --git a/src/ItemBuilder.php b/src/ItemBuilder.php index eed4757..66c3173 100644 --- a/src/ItemBuilder.php +++ b/src/ItemBuilder.php @@ -106,7 +106,6 @@ public static function divider(string $spacing = 'default'): static * Item::make('Profile')->route('settings.profile'), * Item::make('Security')->route('settings.security'), * ]) - * * @example Item::group('Admin') * ->icon('shield') * ->collapsible() @@ -124,6 +123,39 @@ public static function group(string $label, array $children = []): static return $item; } + /** + * Create a navigation section that groups items and groups under a header. + * + * Sections are top-level structural containers that can hold items and groups. + * They don't have routes — they're purely for organization. Unlike groups, + * sections default to non-collapsible (treated as a structural divider) but + * support all the same fluent options. + * + * @param string $label Section header label + * @param array|ItemBuilder> $children Items and groups in this section + * + * @example Item::section('Workspace', [ + * Item::make('Dashboard')->route('dashboard'), + * Item::group('Settings', [ + * Item::make('Profile')->route('settings.profile'), + * ]), + * ]) + * @example Item::section('Admin') + * ->icon('shield') + * ->collapsible() + * ->children([...]) + */ + public static function section(string $label, array $children = []): static + { + $item = new static($label); + $item->meta['section'] = true; + $item->meta['collapsible'] = false; + $item->meta['collapsed'] = false; + $item->children = $children; + + return $item; + } + /** * Set the display label. * diff --git a/src/NavigationBuilder.php b/src/NavigationBuilder.php index 3b488f0..f31b01f 100644 --- a/src/NavigationBuilder.php +++ b/src/NavigationBuilder.php @@ -145,6 +145,24 @@ public function child(string|Closure $label, ?string $route = null, ?string $ico return $this; } + /** + * Add a section containing items and groups. + * + * @param string $label Section header label + * @param array|ItemBuilder> $children Items and groups in this section + * @param string|null $icon Icon name + */ + public function section(string $label, array $children = [], ?string $icon = null): self + { + $section = Item::section($label, $children); + + if ($icon !== null) { + $section->icon($icon); + } + + return $this->add($section); + } + /** * Add a separator. */ diff --git a/src/helpers.php b/src/helpers.php index 21b66ff..d3fc91e 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -94,7 +94,7 @@ function nav_action(string $label, string $route, string $method = 'post', ?stri if (! function_exists('nav_group')) { /** - * Create a navigation group/section with a header. + * Create a navigation group with a collapsible header. * * Groups organize navigation items under a collapsible header. * @@ -119,3 +119,33 @@ function nav_group(string $label, array $children = [], ?string $icon = null): I return $group; } } + +if (! function_exists('nav_section')) { + /** + * Create a navigation section that holds items and groups under a header. + * + * Sections are top-level structural containers. They support the same + * fluent options as groups but default to non-collapsible. + * + * @param string $label Section header label + * @param array|ItemBuilder> $children Items and groups in this section + * @param string|null $icon Icon name + * + * @example nav_section('Workspace', [ + * nav_item('Dashboard', 'dashboard'), + * nav_group('Settings', [ + * nav_item('Profile', 'settings.profile'), + * ]), + * ]) + */ + function nav_section(string $label, array $children = [], ?string $icon = null): ItemBuilder + { + $section = Item::section($label, $children); + + if ($icon !== null) { + $section->icon($icon); + } + + return $section; + } +} diff --git a/tests/Unit/SectionsTest.php b/tests/Unit/SectionsTest.php new file mode 100644 index 0000000..599226d --- /dev/null +++ b/tests/Unit/SectionsTest.php @@ -0,0 +1,252 @@ + 'dashboard')->name('dashboard'); + Route::get('/settings/profile', fn () => 'profile')->name('settings.profile'); + Route::get('/settings/security', fn () => 'security')->name('settings.security'); + Route::get('/admin/users', fn () => 'users')->name('admin.users'); + Route::get('/admin/roles', fn () => 'roles')->name('admin.roles'); + + Navigation::clearAll(); + }); + + it('creates a basic section with children', function (): void { + $section = Item::section('Workspace', [ + Item::make('Dashboard')->route('dashboard'), + Item::make('Profile')->route('settings.profile'), + ])->toArray(); + + expect($section) + ->toHaveKey('label', 'Workspace') + ->toHaveKey('section', true) + ->toHaveKey('collapsible', false) + ->toHaveKey('collapsed', false) + ->toHaveKey('children'); + + expect($section['children'])->toHaveCount(2); + }); + + it('creates a section without initial children', function (): void { + $section = Item::section('Workspace') + ->children([ + Item::make('Dashboard')->route('dashboard'), + ]) + ->toArray(); + + expect($section) + ->toHaveKey('label', 'Workspace') + ->toHaveKey('section', true); + + expect($section['children'])->toHaveCount(1); + }); + + it('section can hold both items and groups', function (): void { + $section = Item::section('Workspace', [ + Item::make('Dashboard')->route('dashboard'), + Item::group('Settings', [ + Item::make('Profile')->route('settings.profile'), + Item::make('Security')->route('settings.security'), + ]), + ])->toArray(); + + expect($section['children'])->toHaveCount(2); + expect($section['children'][0])->toHaveKey('label', 'Dashboard'); + expect($section['children'][1])->toHaveKey('group', true); + expect($section['children'][1]['children'])->toHaveCount(2); + }); + + it('section can be made collapsible', function (): void { + $section = Item::section('Advanced') + ->collapsible() + ->collapsed() + ->toArray(); + + expect($section) + ->toHaveKey('collapsible', true) + ->toHaveKey('collapsed', true); + }); + + it('section can have an icon', function (): void { + $section = Item::section('Workspace') + ->icon('layers') + ->toArray(); + + expect($section)->toHaveKey('icon', 'layers'); + }); + + it('section can have a gate check', function (): void { + $section = Item::section('Admin') + ->can('access-admin') + ->toArray(); + + expect($section)->toHaveKey('can', 'access-admin'); + }); + + it('section can have badges and custom meta', function (): void { + $section = Item::section('Notifications') + ->badge(3) + ->meta('beta', true) + ->toArray(); + + expect($section) + ->toHaveKey('badge', 3) + ->toHaveKey('badgeColor', 'default') + ->toHaveKey('beta', true); + }); + + it('sections render in navigation output', function (): void { + Navigation::addNavigation('sidebar', [ + Item::section('Workspace', [ + Item::make('Dashboard')->route('dashboard'), + Item::group('Settings', [ + Item::make('Profile')->route('settings.profile'), + Item::make('Security')->route('settings.security'), + ]), + ])->icon('layers'), + ]); + + $items = Navigation::get('sidebar')->items(); + + expect($items)->toHaveCount(1); + expect($items[0]) + ->toHaveKey('label', 'Workspace') + ->toHaveKey('section', true) + ->toHaveKey('collapsible', false); + + expect($items[0]['children'])->toHaveCount(2); + expect($items[0]['children'][0]['label'])->toBe('Dashboard'); + expect($items[0]['children'][1]['label'])->toBe('Settings'); + expect($items[0]['children'][1]['group'])->toBeTrue(); + }); + + it('sections without routes do not have url in output', function (): void { + Navigation::addNavigation('main', [ + Item::section('Workspace', [ + Item::make('Dashboard')->route('dashboard'), + ]), + ]); + + $items = Navigation::get('main')->items(); + + expect($items[0])->not->toHaveKey('url'); + }); + + it('section active state bubbles up from children', function (): void { + Navigation::addNavigation('main', [ + Item::section('Workspace', [ + Item::make('Profile')->route('settings.profile'), + Item::group('Admin', [ + Item::make('Users')->route('admin.users'), + ]), + ]), + ]); + + $this->get('/settings/profile'); + + $items = Navigation::get('main')->items(); + + expect($items[0]['isActive'])->toBeTrue(); + expect($items[0]['children'][0]['isActive'])->toBeTrue(); + expect($items[0]['children'][1]['isActive'])->toBeFalse(); + }); + + it('nav_section helper creates a section', function (): void { + $section = nav_section('Workspace', [ + nav_item('Dashboard', 'dashboard'), + ])->toArray(); + + expect($section) + ->toHaveKey('label', 'Workspace') + ->toHaveKey('section', true) + ->toHaveKey('children'); + }); + + it('nav_section helper accepts an icon', function (): void { + $section = nav_section('Workspace', [], 'layers')->toArray(); + + expect($section) + ->toHaveKey('icon', 'layers') + ->toHaveKey('section', true); + }); + + it('nav_section helper supports chaining', function (): void { + $section = nav_section('Admin') + ->icon('shield') + ->can('admin') + ->collapsible() + ->children([ + nav_item('Users', 'admin.users'), + nav_group('Roles', [ + nav_item('All Roles', 'admin.roles'), + ]), + ]) + ->toArray(); + + expect($section) + ->toHaveKey('label', 'Admin') + ->toHaveKey('icon', 'shield') + ->toHaveKey('can', 'admin') + ->toHaveKey('collapsible', true) + ->toHaveKey('children'); + + expect($section['children'])->toHaveCount(2); + }); + + it('NavigationBuilder::section adds a section', function (): void { + Navigation::register('sidebar') + ->section('Workspace', [ + Item::make('Dashboard')->route('dashboard'), + Item::group('Settings', [ + Item::make('Profile')->route('settings.profile'), + ]), + ]) + ->done(); + + $items = Navigation::get('sidebar')->items(); + + expect($items)->toHaveCount(1); + expect($items[0]) + ->toHaveKey('label', 'Workspace') + ->toHaveKey('section', true); + expect($items[0]['children'])->toHaveCount(2); + expect($items[0]['children'][1]['group'])->toBeTrue(); + }); + + it('rejects sections nested inside other sections', function (): void { + Navigation::addNavigation('main', [ + Item::section('Outer', [ + Item::section('Inner', [ + Item::make('Profile')->route('settings.profile'), + ]), + ]), + ]); + + expect(fn () => Navigation::get('main'))->toThrow( + InvalidNavigationItemException::class, + 'Navigation sections cannot be nested inside a section.' + ); + }); + + it('rejects sections nested inside groups', function (): void { + Navigation::addNavigation('main', [ + Item::group('Settings', [ + Item::section('Inner', [ + Item::make('Profile')->route('settings.profile'), + ]), + ]), + ]); + + expect(fn () => Navigation::get('main'))->toThrow( + InvalidNavigationItemException::class, + 'Navigation sections cannot be nested inside a group.' + ); + }); +});