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.'
+ );
+ });
+});