├── .github └── workflows │ └── main.yml ├── LICENSE.md ├── UPGRADING.md ├── composer.json ├── composer.lock ├── helpers.php └── src ├── Facades └── Nav.php ├── Item.php ├── Nav.php └── NavigatorServiceProvider.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | os: [ ubuntu-latest ] 16 | php: [ 8.2, 8.3, 8.4 ] 17 | laravel: [ 11.*, 12.* ] 18 | stability: [ prefer-stable ] 19 | include: 20 | - laravel: 11.* 21 | testbench: ^9.0 22 | - laravel: 12.* 23 | testbench: ^10.0 24 | 25 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v2 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: ${{ matrix.php }} 35 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 36 | coverage: none 37 | 38 | - name: Install dependencies 39 | run: | 40 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 41 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 42 | 43 | - name: Check Code Style 44 | run: vendor/bin/pint --test 45 | 46 | - name: Check Types 47 | run: vendor/bin/phpstan analyse 48 | 49 | - name: Execute tests 50 | run: vendor/bin/pest 51 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sam Rowden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Unreleased 4 | 5 | ### `when` and `unless` methods 6 | The `when` and `unless` methods have been renamed to `includeWhen` and `includeUnless` respectively. 7 | 8 | Update your application accordingly: 9 | ```diff 10 | Nav::item('Home') 11 | - ->when($someCondition) 12 | + ->includeWhen($someCondition) 13 | ... 14 | Nav::item('Dashboard') 15 | - ->unless($someCondition) 16 | + ->includeUnless($someCondition) 17 | ``` 18 | 19 | ## 1.0.0 20 | Navigator no longer supports the following: 21 | - Laravel 10 and below 22 | - PHP 8.1 and below 23 | 24 | Ensure to upgrade your application to these technologies before upgrading to this version. 25 | 26 | ## 0.3.0 27 | - Navigator no longer uses `LazyCollections`, instead it uses the core `Collection` class. As such, ensure all references are updated as applicable. As such, PHP generator support has been removed. 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nedwors/navigator", 3 | "description": "A Laravel package to ease defining navigation menus", 4 | "keywords": [ 5 | "nedwors", 6 | "laravel", 7 | "menu", 8 | "navigation" 9 | ], 10 | "homepage": "https://github.com/nedwors/navigator", 11 | "license": "MIT", 12 | "type": "library", 13 | "authors": [ 14 | { 15 | "name": "Sam Rowden", 16 | "email": "nedwors@gmail.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.2", 22 | "illuminate/support": "^11.0|^12.0" 23 | }, 24 | "require-dev": { 25 | "laravel/pint": "^1.21", 26 | "nunomaduro/larastan": "^3.0", 27 | "orchestra/testbench": "^9.0|^10.0", 28 | "pestphp/pest": "^3.0", 29 | "pestphp/pest-plugin-laravel": "^3.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Nedwors\\Navigator\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Nedwors\\Navigator\\Tests\\": "tests" 39 | } 40 | }, 41 | "scripts": { 42 | "lint": "./vendor/bin/pint", 43 | "test:lint": "./vendor/bin/pint --test", 44 | "test:types": "./vendor/bin/phpstan analyse", 45 | "test:unit": "vendor/bin/pest", 46 | "test": [ 47 | "@lint", 48 | "\n\n", 49 | "@test:lint", 50 | "\n\n", 51 | "@test:types", 52 | "\n\n", 53 | "@test:unit" 54 | ] 55 | }, 56 | "config": { 57 | "sort-packages": true, 58 | "allow-plugins": { 59 | "pestphp/pest-plugin": true 60 | } 61 | }, 62 | "extra": { 63 | "laravel": { 64 | "providers": [ 65 | "Nedwors\\Navigator\\NavigatorServiceProvider" 66 | ], 67 | "aliases": { 68 | "Menu": "Nedwors\\Navigator\\Facades\\Menu" 69 | } 70 | } 71 | }, 72 | "minimum-stability": "stable", 73 | "prefer-stable": true 74 | } 75 | -------------------------------------------------------------------------------- /helpers.php: -------------------------------------------------------------------------------- 1 | */ 10 | function navitems(string $menu = Nav::DEFAULT): Collection 11 | { 12 | return Facades\Nav::items($menu); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Facades/Nav.php: -------------------------------------------------------------------------------- 1 | items(?string $menu = null) Retrieve items in the given menu 15 | * @method static string toJson(?string $menu = null, mixed $options = 0) Retrieve items in the given menu as json, using Laravel Collection's toJson method 16 | * @method static self filter(Closure $filter, ?string $menu = null) Define how the items should be filtered upon retrieval 17 | * @method static self activeWhen(Closure $activeCheck, ?string $menu = null) Define what qualifies an item as active 18 | */ 19 | class Nav extends Facade 20 | { 21 | protected static function getFacadeAccessor(): string 22 | { 23 | return Navigator\Nav::class; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Item.php: -------------------------------------------------------------------------------- 1 | $subItems Retrieve the item's sub menu items 20 | * @property-read bool $hasActiveDescendants Determine if any of the item's descendants are active 21 | * 22 | * @extends Fluent 23 | */ 24 | class Item extends Fluent 25 | { 26 | public ?string $url = null; 27 | 28 | /** @var Closure(): iterable|iterable */ 29 | protected Closure|iterable $descendants = []; 30 | 31 | /** @var array */ 32 | protected array $conditions = []; 33 | 34 | /** @var Closure(self): bool */ 35 | protected ?Closure $activeCheck = null; 36 | 37 | /** @var Closure(self): bool */ 38 | protected ?Closure $filter = null; 39 | 40 | public function called(string $name): self 41 | { 42 | $translated = __($name); 43 | 44 | $this->name = is_string($translated) ? $translated : $name; 45 | 46 | return $this; 47 | } 48 | 49 | public function for(string $route, mixed $parameters = [], bool $absolute = true): self 50 | { 51 | $this->url = Route::has($route) ? route($route, $parameters, $absolute) : $route; 52 | 53 | return $this; 54 | } 55 | 56 | public function icon(string $icon): self 57 | { 58 | $this->icon = $icon; 59 | 60 | return $this; 61 | } 62 | 63 | public function heroicon(string $heroicon): self 64 | { 65 | $this->heroicon = $heroicon; 66 | 67 | return $this; 68 | } 69 | 70 | /** @param Closure(): iterable|iterable $items */ 71 | public function subItems(Closure|iterable $items): self 72 | { 73 | $this->descendants = $items; 74 | 75 | return $this; 76 | } 77 | 78 | public function includeWhen(?bool $condition): self 79 | { 80 | $this->conditions[] = (bool) $condition; 81 | 82 | return $this; 83 | } 84 | 85 | public function includeUnless(?bool $condition): self 86 | { 87 | return $this->includeWhen(! (bool) $condition); 88 | } 89 | 90 | /** @param Closure(self): bool $activeCheck */ 91 | public function activeWhen(Closure $activeCheck): self 92 | { 93 | $this->activeCheck = $activeCheck; 94 | 95 | return $this; 96 | } 97 | 98 | /** @param Closure(self): bool $filter */ 99 | public function filterSubItemsUsing(Closure $filter): self 100 | { 101 | $this->filter = $filter; 102 | 103 | return $this; 104 | } 105 | 106 | /** @return array */ 107 | public function jsonSerialize(): array 108 | { 109 | return $this->toArray(); 110 | } 111 | 112 | /** @return array */ 113 | public function toArray(): array 114 | { 115 | $coreAttributes = [ 116 | 'name' => $this->name, 117 | 'url' => $this->url, 118 | 'icon' => $this->icon, 119 | 'heroicon' => $this->heroicon, 120 | 'subItems' => $this->subItems, 121 | 'active' => $this->active, 122 | 'hasActiveDescendants' => $this->hasActiveDescendants, 123 | ]; 124 | 125 | $additionalAttributes = [ 126 | 'attributes' => Arr::except($this->attributes, array_keys($coreAttributes)), 127 | ]; 128 | 129 | return array_merge($coreAttributes, $additionalAttributes); 130 | } 131 | 132 | public function __get($name): mixed 133 | { 134 | return match ($name) { 135 | 'active' => $this->active(), 136 | 'available' => $this->available(), 137 | 'subItems' => $this->getSubItems(), 138 | 'hasActiveDescendants' => $this->hasActiveDescendants($this->subItems), 139 | default => parent::__get($name), 140 | }; 141 | } 142 | 143 | protected function active(): bool 144 | { 145 | return is_null($this->activeCheck) ? URL::current() == URL::to($this->url ?? '') : (bool) value($this->activeCheck, $this); 146 | } 147 | 148 | protected function available(): bool 149 | { 150 | return collect($this->conditions)->every(fn (bool $condition) => $condition); 151 | } 152 | 153 | /** @return Collection */ 154 | protected function getSubItems(): Collection 155 | { 156 | return Collection::make(value($this->descendants)) 157 | /** @phpstan-ignore-next-line */ 158 | ->unless(is_null($this->filter), fn (Collection $items) => $items->filter($this->filter)->each->filterSubItemsUsing($this->filter)) 159 | /** @phpstan-ignore-next-line */ 160 | ->unless(is_null($this->activeCheck), fn (Collection $items) => $items->each->activeWhen($this->activeCheck)); 161 | } 162 | 163 | /** @param Collection $items */ 164 | protected function hasActiveDescendants(Collection $items): bool 165 | { 166 | return $items->reduce(fn (bool $active, self $item) => $item->subItems->isEmpty() ? $active : $this->hasActiveDescendants($item->subItems), 167 | $items->contains->active 168 | ); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Nav.php: -------------------------------------------------------------------------------- 1 | > */ 18 | protected array $itemsArray = []; 19 | 20 | /** @var array */ 21 | protected array $activeChecks = []; 22 | 23 | /** @var array */ 24 | protected array $filters = []; 25 | 26 | public function item(string $name): Item 27 | { 28 | return resolve(Item::class)->called($name); 29 | } 30 | 31 | /** @param Closure(\Illuminate\Contracts\Auth\Authenticatable|null $user): iterable $items */ 32 | public function define(Closure $items, string $menu = self::DEFAULT): self 33 | { 34 | $this->itemsArray[$menu] = $items; 35 | 36 | return $this; 37 | } 38 | 39 | public function toJson(string $menu = self::DEFAULT, int $options = 0): string 40 | { 41 | return $this->items($menu)->toJson($options); 42 | } 43 | 44 | /** @return Collection */ 45 | public function items(string $menu = self::DEFAULT): Collection 46 | { 47 | /** @phpstan-ignore-next-line */ 48 | return Collection::make(value($this->itemsArray[$menu] ?? [], auth()->user())) 49 | ->pipe($this->injectActiveCheck($menu)) 50 | ->pipe($this->applyFilter($menu)); 51 | } 52 | 53 | /** @param Closure(Item): bool $filter */ 54 | public function filter(Closure $filter, string $menu = self::DEFAULT): self 55 | { 56 | $this->filters[$menu] = $filter; 57 | 58 | return $this; 59 | } 60 | 61 | /** @param Closure(Item): bool $activeCheck */ 62 | public function activeWhen(Closure $activeCheck, string $menu = self::DEFAULT): self 63 | { 64 | $this->activeChecks[$menu] = $activeCheck; 65 | 66 | return $this; 67 | } 68 | 69 | protected function injectActiveCheck(string $menu): Closure 70 | { 71 | return match (true) { 72 | isset($this->activeChecks[$menu]) => fn (Collection $items) => $items->each->activeWhen($this->activeChecks[$menu]), 73 | isset($this->activeChecks[self::DEFAULT]) => fn (Collection $items) => $items->each->activeWhen($this->activeChecks[self::DEFAULT]), 74 | default => fn (Collection $items) => $items, 75 | }; 76 | } 77 | 78 | protected function applyFilter(string $menu): Closure 79 | { 80 | $filter = match (true) { 81 | isset($this->filters[$menu]) => $this->filters[$menu], 82 | isset($this->filters[self::DEFAULT]) => $this->filters[self::DEFAULT], 83 | default => fn (Item $item) => $item->available, 84 | }; 85 | 86 | /** @phpstan-ignore-next-line */ 87 | return fn (Collection $items) => $items->filter($filter)->each->filterSubItemsUsing($filter); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/NavigatorServiceProvider.php: -------------------------------------------------------------------------------- 1 |