Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 133 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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' => '<svg>...</svg>',
'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:
Expand Down
16 changes: 16 additions & 0 deletions src/Data/NavigationItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment on lines +194 to +205
}
}
}
}

/**
Expand Down
15 changes: 15 additions & 0 deletions src/Exceptions/InvalidNavigationItemException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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.
*
Expand Down
34 changes: 33 additions & 1 deletion src/ItemBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<int, array<string, mixed>|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;
Comment on lines +148 to +156
}

/**
* Set the display label.
*
Expand Down
18 changes: 18 additions & 0 deletions src/NavigationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, array<string, mixed>|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.
*/
Expand Down
32 changes: 31 additions & 1 deletion src/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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<int, array<string, mixed>|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;
}
}
Loading