<?php
|
namespace JVBase\ui;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Menu/Navigation UI component with fluent interface
|
*
|
* Usage:
|
* $menu = new Menu('primary-nav');
|
* $menu->addItem()->text('Home')->url('/')->icon('house');
|
*
|
* $menu->addItem()->text('About')
|
* ->url('/about/')
|
* ->submenu(function($submenu) {
|
* $submenu->addItem()->text('Team')->url('/about/team/');
|
* $submenu->addItem()->text('History')->url('/about/history/');
|
* });
|
*
|
* echo $menu->render();
|
*/
|
class Navigation {
|
private string $id;
|
public array $items = [];
|
private array $classes = [];
|
protected array $defaultMenuClasses = [];
|
private bool $isNav = true;
|
private bool $hasToggle = false;
|
protected array $defaultItemClasses = [];
|
private int $counter = 0;
|
private bool $isDrawer = false;
|
private bool $drawerCollapsed = true;
|
|
public function __construct(string $id = '') {
|
$this->id = $id ?: 'menu-' . uniqid();
|
}
|
|
public function getID():string
|
{
|
return $this->id;
|
}
|
|
|
/**
|
* Add a menu item
|
*
|
* @return MenuItem
|
*/
|
public function addItem(?string $text = null, ?string $icon = null): MenuItem {
|
$item = new MenuItem(++$this->counter);
|
$this->items[] = $item;
|
if ($text) {
|
$item->text($text);
|
}
|
if ($icon) {
|
$item->icon($icon);
|
}
|
if (!empty($this->defaultItemClasses)) {
|
foreach ($this->defaultItemClasses as $class) {
|
$item->addClass($class);
|
}
|
}
|
return $item;
|
}
|
|
/**
|
* Add CSS class to the nav element
|
*
|
* @param string $class
|
* @return self
|
*/
|
public function addClass(string $class): self {
|
$this->classes[] = $class;
|
return $this;
|
}
|
public function addMenuClass(string $class):self {
|
$this->menuClasses[] = $class;
|
return $this;
|
}
|
|
public function defaultMenuClasses(array $classes):self {
|
$classes = array_filter($classes, fn ($class) => is_string($class));
|
$this->defaultMenuClasses = $classes;
|
return $this;
|
}
|
|
public function defaultItemClasses(array $classes): self {
|
$classes = array_filter($classes, fn ($class) => is_string($class));
|
$this->defaultItemClasses = $classes;
|
return $this;
|
}
|
|
/**
|
* Set whether this nav has a toggle button
|
*
|
* @param bool $hasToggle
|
* @return self
|
*/
|
public function hasToggle(bool $hasToggle = true): self {
|
$this->hasToggle = $hasToggle;
|
return $this;
|
}
|
|
|
public function isNav(bool $isNav = true):self {
|
$this->isNav = $isNav;
|
return $this;
|
}
|
|
/**
|
* Render the menu HTML
|
*
|
* @param bool $return Whether to return or echo
|
* @return string
|
*/
|
public function render(bool $return = true): string {
|
if (empty($this->items)) {
|
return '';
|
}
|
|
$classStr = !empty($this->classes) ? ' class="' . esc_attr(implode(' ', array_merge([$this->id],$this->classes))) . '"' : '';
|
|
$html = '';
|
if ($this->isNav) {
|
$html = '<nav id="' . esc_attr($this->id).'"' . $classStr.'>';
|
|
// Drawer toggle or regular toggle
|
if ($this->isDrawer) {
|
$html .= '<button class="toggle main" type="button"
|
aria-expanded="' . ($this->drawerCollapsed ? 'false' : 'true') . '"
|
aria-controls="' . esc_attr($this->id) . '-list">
|
' . jvbIcon('caret-left') . '
|
<span class="screen-reader-text">Toggle Menu</span>
|
</button>';
|
} elseif ($this->hasToggle) {
|
$html .= '<button class="toggle main" type="button" aria-expanded="false" aria-controls="' . esc_attr($this->id) . '">
|
' . jvbIcon('list') . '
|
<span class="screen-reader-text">Toggle Menu</span>
|
</button>';
|
}
|
}
|
if (!$this->isNav) {
|
$classStr = (empty($this->defaultMenuClasses)) ? '' : ' class="'.implode(' ', $this->defaultMenuClasses).'"';
|
}
|
|
$html .= '<ul id="' . esc_attr($this->id) . '-list" '.$classStr.'>';
|
|
foreach ($this->items as $item) {
|
$html .= $item->render();
|
}
|
|
$html .= '</ul>';
|
if ($this->isNav) {
|
$html .= '</nav>';
|
}
|
|
|
if ($return) {
|
return $html;
|
}
|
|
echo $html;
|
return $html;
|
}
|
|
/**
|
* Configure as a drawer-style menu
|
*
|
* @param bool $collapsed Initial state
|
* @return self
|
*/
|
public function asDrawer(bool $collapsed = true): self {
|
$this->isDrawer = true;
|
$this->drawerCollapsed = $collapsed;
|
$this->addClass('drawer');
|
if (!$collapsed) {
|
$this->addClass('open');
|
}
|
return $this;
|
}
|
|
public function isDrawer(): bool {
|
return $this->isDrawer;
|
}
|
/**
|
* Add a section header
|
*
|
* @param string $title
|
* @return MenuSection
|
*/
|
public function addSection(string $title): MenuSection {
|
$section = new MenuSection($title, ++$this->counter);
|
$this->items[] = $section;
|
|
if (!empty($this->defaultItemClasses)) {
|
foreach ($this->defaultItemClasses as $class) {
|
$section->addItemClass($class);
|
}
|
}
|
|
return $section;
|
}
|
|
/**
|
* Populate menu from array structure
|
*
|
* @param array $items Array of menu items
|
* @return self
|
*/
|
public function populateFromArray(array $items): self {
|
foreach ($items as $item) {
|
// Handle sections
|
if (!empty($item['section'])) {
|
$section = $this->addSection($item['section']);
|
if (!empty($item['items'])) {
|
$this->populateSection($section, $item['items']);
|
}
|
continue;
|
}
|
|
// Handle regular items
|
$menuItem = $this->addItem($item['text'] ?? '', $item['icon'] ?? '');
|
|
if (!empty($item['class'])) {
|
$menuItem->addClass($item['class']);
|
}
|
if (!empty($item['url'])) {
|
$menuItem->url($item['url']);
|
}
|
|
|
if (!empty($item['submenu'])) {
|
$submenu = $menuItem->submenu();
|
$submenu->populateFromArray($item['submenu']);
|
}
|
}
|
|
return $this;
|
}
|
|
private function populateSection(MenuSection $section, array $items): void {
|
foreach ($items as $item) {
|
$menuItem = $section->addItem($item['text'] ?? '', $item['icon'] ?? null);
|
|
if (!empty($item['url'])) {
|
$menuItem->url($item['url']);
|
}
|
|
if (!empty($item['submenu'])) {
|
$submenu = $menuItem->submenu();
|
$submenu->populateFromArray($item['submenu']);
|
}
|
}
|
}
|
}
|
|
/**
|
* Individual menu item with support for submenus
|
*/
|
class MenuItem {
|
private int $id;
|
private string $text = '';
|
private ?string $url = null;
|
private ?string $icon = null;
|
private ?Navigation $submenu = null;
|
private array $classes = [];
|
private array $menuClasses = [];
|
private array $attributes = [];
|
private bool $current = false;
|
|
public function __construct(int $id) {
|
$this->id = $id;
|
}
|
|
/**
|
* Set the menu item text
|
*
|
* @param string $text
|
* @return self
|
*/
|
public function text(string $text): self {
|
$this->text = $text;
|
return $this;
|
}
|
|
/**
|
* Set the menu item URL
|
*
|
* @param string $url
|
* @return self
|
*/
|
public function url(string $url): self {
|
$this->url = $url;
|
return $this;
|
}
|
|
/**
|
* Set the menu item icon
|
*
|
* @param string $icon
|
* @return self
|
*/
|
public function icon(string $icon): self {
|
$this->icon = (str_starts_with($icon, '<i')) ? $icon : jvbIcon($icon);
|
return $this;
|
}
|
|
protected function renderClasses(array $classes):string {
|
return empty($classes) ? '' : ' class="'.implode(' ', array_filter($classes, fn($class) => is_string($class))).'"';
|
}
|
|
/**
|
* Add a submenu
|
*
|
* @param ?string $id
|
* @return Navigation
|
*/
|
public function submenu(?string $id = null): Navigation {
|
if (!$id) {
|
$id = 'submenu-' . uniqid();
|
}
|
$submenu = new Navigation($id);
|
$submenu->isNav(false);
|
|
if (!empty($this->defaultMenuClasses)) {
|
foreach ($this->defaultMenuClasses as $class) {
|
$submenu->addClass($class);
|
}
|
}
|
$this->submenu = $submenu;
|
return $submenu;
|
}
|
|
/**
|
* Add CSS class to the list item
|
*
|
* @param string $class
|
* @return self
|
*/
|
public function addClass(string $class): self {
|
$this->classes[] = $class;
|
return $this;
|
}
|
|
/**
|
* Mark this item as current/active
|
*
|
* @param bool $current
|
* @return self
|
*/
|
public function current(bool $current = true): self {
|
$this->current = $current;
|
if ($current) {
|
$this->addClass('current');
|
}
|
return $this;
|
}
|
|
/**
|
* Add custom attribute to the link element
|
*
|
* @param string $key
|
* @param string $value
|
* @return self
|
*/
|
public function attribute(string $key, string $value): self {
|
$this->attributes[$key] = $value;
|
return $this;
|
}
|
|
/**
|
* Render the menu item HTML
|
*
|
* @return string
|
*/
|
public function render(): string {
|
if (!$this->url && (!$this->submenu || empty($this->submenu->items))) {
|
return '';
|
}
|
$classes = $this->classes;
|
if ($this->submenu) {
|
$classes[] = 'has-submenu';
|
}
|
|
$classStr = $this->renderClasses($classes);
|
|
$html = '<li' . $classStr . '>';
|
$html .= '<div class="row nowrap">';
|
// Render link or button
|
if ($this->url) {
|
$attrs = '';
|
foreach ($this->attributes as $key => $value) {
|
$attrs .= ' ' . esc_attr($key) . '="' . esc_attr($value) . '"';
|
}
|
|
$html .= '<a href="' . esc_url($this->url) . '"' . $attrs . '>';
|
} else {
|
$html .= '<span class="a">';
|
}
|
|
if ($this->icon) {
|
$html .= $this->icon;
|
}
|
$html .= '<span class="title">'.esc_html($this->text) . '</span>';
|
|
|
$html .= ($this->url) ? '</a>' : '</span>';
|
|
// Render submenu if exists
|
if ($this->submenu) {
|
$html .= '<button class="toggle"
|
data-action="toggle-submenu"
|
title="Toggle Submenu"
|
aria-label="Open '.$this->submenu->getID().' Submenu" aria-expanded="false" aria-controls="'.$this->submenu->getID().'">'.
|
jvbIcon('caret-down', ['title'=>'Toggle Submenu']).
|
'</button>';
|
$html .= '</div>';
|
$html .= $this->submenu->render();
|
}else {
|
$html .= '</div>';
|
}
|
|
$html .= '</li>';
|
|
return $html;
|
}
|
}
|
|
/**
|
* Menu section with header and items
|
*/
|
class MenuSection {
|
private int $id;
|
private string $title;
|
private array $items = [];
|
private array $classes = [];
|
private array $defaultItemClasses = [];
|
private int $counter = 0;
|
|
public function __construct(string $title, int $id) {
|
$this->title = $title;
|
$this->id = $id;
|
}
|
|
public function addItem(?string $text = null, ?string $icon = null): MenuItem {
|
$item = new MenuItem(++$this->counter);
|
$this->items[] = $item;
|
|
if ($text) $item->text($text);
|
if ($icon) $item->icon($icon);
|
|
if (!empty($this->defaultItemClasses)) {
|
foreach ($this->defaultItemClasses as $class) {
|
$item->addClass($class);
|
}
|
}
|
|
return $item;
|
}
|
|
public function addClass(string $class): self {
|
$this->classes[] = $class;
|
return $this;
|
}
|
|
public function addItemClass(string $class): self {
|
$this->defaultItemClasses[] = $class;
|
return $this;
|
}
|
|
public function render(): string {
|
if (empty($this->items)) {
|
return '';
|
}
|
|
$classStr = !empty($this->classes) ? ' class="menu-section ' . esc_attr(implode(' ', $this->classes)) . '"' : ' class="menu-section"';
|
|
$html = '<li' . $classStr . '>';
|
$html .= '<span class="section-title">' . esc_html($this->title) . '</span>';
|
$html .= '<ul class="section-items">';
|
|
foreach ($this->items as $item) {
|
$html .= $item->render();
|
}
|
|
$html .= '</ul></li>';
|
|
return $html;
|
}
|
}
|