K

Panels

Navigace

Jak se resource dostane do menu — položka, kterou deklaruje, skupina, ve které sedí, a workspace, který obojí uspořádá.

Na této stránce

Registr odpovídá na otázku co existuje. Menu je otázka jiná — co se zobrazí, pod jakým nadpisem, v jakém pořadí a kam která položka odkazuje. Na tu odpovídají tři malé objekty: položka, kterou deklaruje resource, skupina, kterou deklaruje aplikace, a workspace, který je uspořádá.

Deklarace položky

Resource, který se má objevit v menu, implementuje ProvidesNavigation:

use NyonCode\WireCore\Core\Resources\Contracts\ProvidesNavigation;
use NyonCode\WireCore\Core\Resources\Navigation\NavigationItem;
 
public static function navigation(): NavigationItem
{
return NavigationItem::make('Objednávky')
->icon('outline:shopping-cart')
->group('sales')
->sort(10)
->badge(fn () => Order::whereNull('shipped_at')->count(), 'danger');
}

Statické, jako identita, a ze stejného důvodu: menu se staví ze všech registrovaných resources naráz a instancovat každý jen kvůli tomu, jak se jmenuje, by znamenalo složit tabulku a formulář na každou položku. Resource, který tohle neimplementuje, je pořád registrovaný a routovatelný — jen se neobjeví, což je přesně to, co chce interní nebo vnořený resource.

NavigationItem stojí na kanonických concernech HasLabel / HasIcon / HasVisibility, ne na vlastních properties, takže mluví stejným slovníkem jako každá jiná komponenta. Přidává jen to, co potřebuje menu: group(), sort() a badge(). Closure v badge se vyhodnocuje při každém čtení, nikdy se necachuje — počet neodeslaných objednávek je špatně v okamžiku, kdy se uloží.

Položka, která si sama nepojmenuje popisek, se jmenuje po svém resource: NavigationItem::make() vedle ->icon() a ->group() je běžný tvar a menu ukáže pluralLabel() — „Objednávky“. Resource, který chce v menu jiný název než svůj plurál, ho předá a ten vyhraje.

Položky pod položkou

Položka může nést vlastní položky, což je druhý nadpis, který menu má — a není to skupina, protože je zároveň cílem i rodičem:

use NyonCode\WireCore\Core\Resources\Navigation\NavigationItem;
 
NavigationItem::make('Katalog')
->icon('outline:squares-2x2')
->children([
NavigationItem::make('Produkty')->url(route('products.index')),
NavigationItem::make('Kategorie')->url(route('categories.index'))->sort(10),
NavigationItem::make('Archiv')
->url(route('products.archive'))
->visible(fn (): bool => auth()->user()?->can('viewArchive') ?? false),
]);

Jedna úroveň, a to schválně. Děti dítěte nečte nic, co kreslí menu: sidebar zanořený třikrát je sidebar, do kterého se nedá trefit myší, a třetí úroveň patří na stránku, jako záložky nebo sekundární navigace. Hlubší zanoření se neodmítá, jen se nevykreslí — kdo to udělá, uvidí to okamžitě.

Filtrování a řazení dělá getChildren(), ne view: skryté děti vypadnou a zbytek se vrátí v pořadí sort(), takže každý povrch, který kreslí submenu, se shodne na tom, co v něm je. Je to týž důvod, proč položky filtruje Workspace a ne sidebar. Closure se vyhodnocuje při každém čtení ze stejného důvodu jako badge — děti, které závisejí na tom, co smí vidět přihlášený uživatel, se nesmějí rozhodnout jednou, při registraci, a zapamatovat pro všechny.

hasChildren() odpoví, jestli se pod položkou vůbec něco vykreslí, a to rozhoduje, jestli je řádek odkaz, nebo rozbalovátko. Ptejte se jím místo počítání, protože děti můžou být v tu chvíli ještě closure. Co s tím rozdílem udělá zúžená lišta — tooltip pro list, popover pro rodiče — je věc shellu, ne téhle vrstvy.

Skupiny

group() bere klíč, ne nadpis. Skupinu nevlastní žádný resource — sdílí ji jich několik — takže co nadpis říká, jakou nese ikonu, kde sedí mezi ostatními skupinami a jestli je vůbec vidět, patří NavigationGroup, deklarované tam, kde si aplikace tuhle svou část skládá:

use NyonCode\WireCore\Core\Resources\Navigation\NavigationGroup;
use NyonCode\WireCore\Core\Resources\Navigation\NavigationGroups;
 
public function boot(): void
{
$this->app->make(NavigationGroups::class)->registerMany([
NavigationGroup::make('sales')
->label(__('nav.sales'))
->icon('outline:banknotes')
->sort(10),
NavigationGroup::make('admin')
->sort(90)
->collapsed()
->visible(fn (): bool => auth()->user()?->isAdmin() ?? false),
]);
}

Skupina, kterou nikdo nedeklaruje, funguje dál. Workspace si z klíče udělá implicitní, takže ->group('sales') žádnou registraci nepotřebuje a nadpis spadne na Str::headline() klíče. Registrace říká pět věcí, které holý klíč neumí — nadpis oddělený od klíče, ikonu, pořadí mezi ostatními skupinami, jednu podmínku viditelnosti pro všechno uvnitř a jestli se skupina skládá. Typovaný seznam je API NavigationGroup níž.

Nadpis a klíč jsou oddělené schválně. ->group(__('nav.billing')) udělal z překladu klíč pole, takže totéž menu bylo v každém jazyce klíčované jinak; klíč je teď slug a text vlastní HasLabel. hiddenLabel() skupinu zachová, ale nadpis nevykreslí — to chce menu, které odděluje linkou místo slovem.

Skládání přichází z CanBeCollapsed->collapsible() dá nadpisu rozbalovátko, ->collapsed() ho nechá začít zavřený — týž concern, který používá Section a Repeater, takže ta dvojice znamená napříč frameworkem jednu věc, a ne tři. Složení skrývá, neodebírá: složená skupina pořád ukazuje své položky v zúžené liště, kde se nadpis, který by ji rozbalil, nekreslí vůbec.

Provider není jediné místo. NavigationGroups je singleton v kontejneru, takže cokoli, co drží kontejner, do něj smí zaregistrovat dřív, než se menu postaví — boot() provideru je jen ta obvyklá chvíle. Modul se ho nedotkne vůbec: svou skupinu vrátí z Module::navigation() a wire-core ji zaregistruje při bootu modulů, takže modul veze svůj nadpis, ikonu i pořadí vedle resourců, které seskupuje, místo aby je nechal na tom, kdo ho nainstaloval. Moduly se bootují v pořadí registrace, takže modul uvedený za tím, na kterém závisí, vidí jeho skupiny už deklarované.

Registrace téhož klíče podruhé přepíše, což je způsob, jak aplikace upraví skupinu dodanou balíčkem, aniž by ten balíček editovala. NavigationGroups je singleton v kontejneru a jinak obyčejný registr — jeho API má pět metod.

Workspace

Workspace výsledek uspořádá:

use NyonCode\WireCore\Core\Resources\Workspace;
 
$nav = app(Workspace::class)->navigation();
// ['sales' => NavigationGroup, '' => NavigationGroup] bez skupiny je klíč ''

Skupiny se vrací v pořadí sort() a shodné drží pořadí, v jakém se registrovala jejich první položka; uvnitř skupiny platí totéž pro položky. Skryté položky vypadnou a skrytá skupina si své položky vezme s sebou.

Položky zůstávají klíčované registrovaným klíčem, přes seskupení i přes řazení, a každá nese URL stránky svého klíče. Nic ji nedeklaruje: registr URL pořád nedrží — ten, který by ji držel, by byl panel — ale menu se zeptá, kam je klíč routovaný, a odpověď doplní; null pro resource, který nedeklaruje stránky, i pro aplikaci, která neroutuje nic.

Položka může svůj cíl pojmenovat sama přes ->url('https://status.example.com') a to, co pojmenuje, vždycky vyhraje — externí odkaz nebo aplikace, jejíž shell má vlastní URL schéma.

Workspace::items() odpovídá na tutéž otázku bez nadpisů: každá viditelná položka, plochý seznam v pořadí sort(), klíčovaný registrovaným klíčem — to, co ukáže menu, které skupiny nekreslí. Položky ze skryté skupiny v něm taky nejsou.

Obojí bere zónu, a linkedOnly: true, když má menu obsahovat jen to, na co ta zóna dosáhne:

app(Workspace::class)->navigation(); // všechny položky, bez zóny
app(Workspace::class)->navigation(zone: 'business'); // odkazující do business
app(Workspace::class)->navigation(zone: 'business', linkedOnly: true);

Workspace neví, co je resource, a to je záměr. Jeho položky přicházejí z Catalogu, který čte libovolný počet zdrojů RegistrySource — jedním je ResourceRegistry, druhým Widgets\DashboardRegistry — takže menu míchá resources, dashboardy a cokoli, co aplikace zaregistruje později, aniž by se o nich Workspace dozvěděl. Router i paleta globálního hledání čtou tentýž katalog, takže jedna registrace obslouží všechny tři. Dva zdroje hlásící se k jednomu klíči jsou odmítnuty, ne smířeny: jedna položka by jinak zabrala místo druhé a menu, kterému tiše zmizel řádek, se pozná až v den, kdy ten řádek byl potřeba.

Fallback popisku výše je resourcový, protože pluralLabel() je slovo resource. Cokoli jiného v menu si položku pojmenuje samo.

Stejně jako registr nevlastní Workspace routing ani layout — menu vykresluje aplikace. Ptá se, kam je klíč routovaný; nerozhoduje o tom.

Vykreslení menu

@foreach($nav as $group)
@if($group->hasVisibleLabel())
<p>{!! icon($group->getIcon()) !!} {{ $group->getLabel() }}</p>
@endif
 
@foreach($group->getItems() as $key => $item)
{{-- Registrovaná položka bez vlastní stránky do menu pořád patří;
jen to není odkaz. --}}
<a @if($item->getUrl()) href="{{ $item->getUrl() }}" wire:navigate @endif>
{!! icon($item->getIcon()) !!}
{{ $item->getLabel() }}
<x-wire::badge :color="$item->getBadgeColor() ?? 'gray'">{{ $item->getBadge() }}</x-wire::badge>
</a>
 
@if($item->hasChildren())
<ul>
@foreach($item->getChildren() as $child)
<li><a href="{{ $child->getUrl() }}" wire:navigate>{{ $child->getLabel() }}</a></li>
@endforeach
</ul>
@endif
@endforeach
@endforeach

wire-admin tohle menu dodává napsané — viz sidebar, který kreslí tytéž tři objekty, aktivní položku pozná z právě vykreslované routy a žádný vlastní stav nedrží. To výše je pro aplikaci, která si kreslí vlastní rám.

Která položka je aktivní

Jedno čtení požadavku, postavené jednou při renderu stránky a předané každému řádku: ActiveNavigation. Rozhodují tři pravidla, vyhrává první odpověď.

  1. Co položka deklarovala. activeWhen() níž. Nic dalšího se neptá.
  2. Její registrovaný klíč, proti klíči vykreslované routy. Tohle drží řádek Objednávky rozsvícený i na wire.orders.edit: menu zná resource, ne to, na které z jeho stránek stojíte.
  3. Její URL, na přesnou shodu — lomítko na konci a relativní URL se normalizují pryč, query string ne. Dvě položky nad jedním seznamem, Vše a Archiv, jsou dvě položky.

aria-current čte totéž a dělí to na dvě: page pro položku, jejíž URL je vykreslovaná URL, a true pro větev, uvnitř které stojíte. To není puntičkářství — řádek resource zůstává aktivní i na jeho editační stránce a záložky záznamu nad formulářem už říkají „tohle je stránka, na které jste“.

Když je konvence vedle

Pravidlo 3 není shoda na prefix, a to vědomě. Prefixové pravidlo by rozsvítilo /settings na /settings/general/edit — což by chtěl každý — a stejně tak by rozsvítilo položku Domů, mířící na vlastní cestu shellu, na každé stránce pod ní, napořád, protože odsud nejde poznat sekci od kořene. Řádek, který svítí vždycky, je hlasitější vada než řádek, který nesvítí, i když by mohl.

Řekne to tedy položka, která to ví, a dostane přesně to, co myslela:

NavigationItem::make('Settings')
->url(route('settings.general'))
->activeWhen('settings/*'); // a každá stránka pod ní

Vzor se porovnává s aktuální cestou i s aktuálním jménem routy, takže fungují obě čtení téhož záměru:

->activeWhen('admin.settings.*'); // vzor na jméno routy
->activeWhen(['orders/*', 'invoices/*']); // několik
->activeWhen(fn (ActiveNavigation $active): bool => $active->page === 'edit');

Deklarace konvenci nahrazuje, nepřidává se k ní — což je to jediné, díky čemu jde větu „tady nikdy aktivní“ vůbec napsat.

Změna menu, které jste neregistrovali

Instalace modulu dá jeho položky do menu a aplikace je upraví přes hook navigation.building, místo aby ten modul neinstalovala:

$manager->hook(Hook::NavigationBuilding, function (NavigationBuildingPayload $payload) {
unset($payload->items['media']);
$payload->items['docs'] = NavigationItem::make('Docs')->url('/docs')->sort(90);
 
return $payload;
}, for: 'admin');

Běží nad plochým klíčovaným seznamem, před seskupením a seřazením, takže krmí navigation() i items() — hook jen v jednom z nich by nechal sidebar a command paletu neshodnout se na tom, co v menu je. Klíče zachovejte: právě z nich dělá konzument odkaz. Řadit tady je zbytečná práce, menu stejně seřadí sort() položky.

for: pojmenuje zónu — jedinou identitu, kterou menu má: nepatří k žádné komponentě a neukazuje jednu registrovanou třídu. Menu stavěné bez zóny žádné zúžení nenese, takže zúžený callback vynechá.

Metoda Vrací Účel
NavigationItem::make(string|Closure|null $label = null) self Nová položka. Bez popisku ji pojmenuje pluralLabel() resource
label(string|Closure|null $label) self Vlastní text položky, který ten fallback přebije
hiddenLabel(bool $condition = true) self Položku zachová, text nevykreslí — řádek jen s ikonou
icon(string|Icon|Closure|null $icon, string|IconPosition|null $position = null) self Ikona vedle popisku
group(string|Closure|null $group) self Klíč skupiny, pod kterou položka patří; null je nejvyšší úroveň
sort(int $sort) self Pořadí uvnitř skupiny; shody drží pořadí prvního výskytu
badge(mixed $badge, string|Closure|null $color = null) self Počet nebo krátký text vedle popisku, volitelně s barvou
url(string|Closure|null $url) self Explicitní cíl, který vždycky vyhraje nad routovaným
children(array|Closure $children) self Položky pod touhle — jedna úroveň, filtrované a seřazené při čtení
activeWhen(Closure|array|string|null $activeWhen) self Kdy položka platí za stránku, na které stojíte, když je konvence vedle: vzory na cestu nebo jméno routy, nebo Closure s ActiveNavigation. Konvenci nahrazuje
visible(bool|Closure $condition = true) / hidden(bool|Closure $condition = true) self Jestli je položka v menu vůbec
getLabel(): ?string string|null Vyhodnocený text, nebo null, když ji nic nepojmenovalo
hasVisibleLabel(): bool / isLabelHidden(): bool bool Jestli text vykreslit
getIcon(): ?string string|null Vyhodnocené jméno ikony
getGroup(): ?string string|null Vyhodnocený klíč skupiny
getSort(): int int Váha řazení
getBadge(): ?string string|null Badge, vyhodnocený při každém čtení, nikdy necachovaný
getBadgeColor(): ?string string|null Jeho barva, nebo null pro výchozí konzumenta
getUrl(): ?string string|null Explicitní URL, jinak routované, jinak null
getChildren(): array array<int, NavigationItem> Viditelné děti v pořadí sort()
hasChildren(): bool bool Jestli je řádek rozbalovátko místo obyčejného odkazu
isActiveWhen(ActiveNavigation $active): ?bool bool|null Vlastní odpověď položky, nebo null, když žádnou nedeklarovala — trojhodnotové, aby deklarované „ne“ nepropadlo do konvencí
isVisible(): bool / isHidden(): bool bool Vyhodnocená viditelnost

ActiveNavigation API

Metoda Vrací K čemu
ActiveNavigation::current() self Čtení vykreslovaného požadavku — klíč, druh stránky, URL, cesta a jméno routy. Jen při renderu celé stránky: uvnitř Livewire updatu je jméno routy livewire.update
withKey(?string $key) self Totéž čtení s klíčem, který si hostitel vyřešil sám — to předává <x-wire-admin::sidebar :active-key="…">
isActive(NavigationItem $item, ?string $key = null): bool bool Jestli je položka tam, kde stojíte, nebo tím, uvnitř čeho stojíte
isExactly(NavigationItem $item): bool bool Jestli položka je vykreslovaná stránka
hasActiveChild(NavigationItem $item): bool bool Jestli je jí něco pod ní
ariaCurrent(NavigationItem $item, ?string $key = null): ?string string|null 'page', 'true', nebo null
matchesPatterns(array $patterns): bool bool Jestli požadavek odpovídá některému vzoru, cestou nebo jménem routy
public ?string $key / $page / $url / $path / $routeName string|null Samotné čtení
Metoda Vrací Účel
NavigationGroup::make(string $key) self Klíč, na který položky míří přes group()
label(string|Closure|null $label) self Nadpis. Oddělený od klíče schválně: přeložený nadpis se nesmí stát klíčem pole
hiddenLabel(bool $condition = true) self Položky seskupí, nadpis nevykreslí
icon(string|Icon|Closure|null $icon, string|IconPosition|null $position = null) self Ikona vedle nadpisu
sort(int $sort) self Pořadí mezi skupinami; shody drží pořadí prvního výskytu
visible(bool|Closure $condition = true) / hidden(bool|Closure $condition = true) self Zobrazí nebo skryje celou skupinu — jedna podmínka místo téže podmínky na každém resource v ní
collapsible(bool|Closure $condition = true) self Nadpis dostane rozbalovátko
collapsed(bool|Closure $condition = true) self Začne složená. Složení skrývá; z zúžené lišty položky nikdy neodebere
withItems(array $items): self self Kopie nesoucí položky, které menu ukáže pod skupinou — volá ji Workspace; deklarovaná skupina je singleton, takže naplnit ji na místě by znamenalo, že druhé volání navigation() odpoví jinak než první
getKey(): string string Slug, který je zároveň getName()
getLabel(): ?string string|null Nadpis, s fallbackem na Str::headline() klíče
hasVisibleLabel(): bool bool Jestli nadpis vykreslit
getItems(): array array<string, NavigationItem> Položky pod ní, klíčované registrovaným klíčem
hasItems(): bool bool Jestli by skupina vůbec něco vykreslila
isCollapsible(): bool / isCollapsed(): bool bool Vyhodnocený stav složení

Singleton v kontejneru, ve kterém deklarované skupiny žijí. Workspace z něj čte; zapisuje do něj aplikace i balíček.

Metoda Vrací Účel
register(NavigationGroup $group): void void Deklaruje jednu skupinu; týž klíč podruhé přepíše, což je způsob, jak aplikace upraví skupinu dodanou balíčkem
registerMany(iterable $groups): void void Totéž pro víc skupin naráz
find(string $key): ?NavigationGroup NavigationGroup|null Deklarovaná skupina s tímhle klíčem, nebo null, když ji jmenují jen položky
has(string $key): bool bool Jestli byl klíč deklarovaný
all(): array array<string, NavigationGroup> Každá deklarovaná skupina, klíčovaná klíčem, v pořadí registrace

Workspace API

Metoda Vrací Účel
navigation(?string $zone = null, bool $linkedOnly = false) array<string, NavigationGroup> Menu: skupiny v pořadí, každá se svými položkami
items(?string $zone = null, bool $linkedOnly = false) array<string, NavigationItem> Totéž menu ploše, bez nadpisů
registered() array<string, class-string> Každá třída za menu, ať má položku nebo ne

Catalog API

Všechno, co aplikace zaregistrovala, ať je to cokoli — jeden seznam, ze kterého čte menu, router i vyhledávací paleta.

Metoda Vrací Účel
all(): array array<string, class-string> Každá registrovaná třída, klíčovaná, v pořadí registrace; dva zdroje hlásící se k jednomu klíči odmítne
implementing(string $capability): array array<string, class-string> Jen ty, které implementují daný kontrakt — ProvidesNavigation, ProvidesPages, GloballySearchable
find(string $key): ?string class-string|null Třída s tímto klíčem
has(string $key): bool bool Jestli je klíč registrovaný

Registr se stane jedním z jeho zdrojů tím, že implementuje RegistrySource (registeredClasses(): array) — tak se dashboard registr dostane ke všem třem povrchům, aniž by ho kterýkoli z nich importoval. Cokoli, co smí adresovat router, implementuje navíc HasRegistryKey (static key(): string) — ProvidesPages ho rozšiřuje, protože stránce, kterou nelze adresovat, nejde dát URL.

Související

  • Resources — vlastník, kterého položka jmenuje
  • Routování — odkud se bere URL položky a co na tom mění zóna
  • Moduly — položky celé oblasti deklarované v jednom manifestu
  • Sidebar — menu, které z tohohle všeho kreslí wire-admin
  • Globální vyhledávání — palette čtoucí stejný katalog
  • Widgety — dashboardy, druhý druh věcí, které menu drží