src/View/Helper/Navigation/AbstractMenu.php
<?php /** * This file is part of the mimmi20/mezzio-navigation-laminasviewrenderer package. * * Copyright (c) 2020-2025, Thomas Mueller <mimmi20@live.de> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ declare(strict_types = 1); namespace Mimmi20\Mezzio\Navigation\LaminasView\View\Helper\Navigation; use Laminas\I18n\Exception\RuntimeException;use Laminas\Stdlib\Exception\InvalidArgumentException;use Laminas\View\Exception;use Laminas\View\Helper\EscapeHtmlAttr;use Laminas\View\Model\ModelInterface;use Mimmi20\LaminasView\Helper\PartialRenderer\Helper\PartialRendererInterface;use Mimmi20\Mezzio\Navigation\ContainerInterface;use Mimmi20\Mezzio\Navigation\Page\PageInterface;use Mimmi20\NavigationHelper\ContainerParser\ContainerParserInterface;use Mimmi20\NavigationHelper\Htmlify\HtmlifyInterface;use Override;use RecursiveIteratorIterator; use function array_key_exists;use function array_merge;use function assert;use function count;use function get_debug_type;use function implode;use function is_array;use function is_bool;use function is_int;use function is_string;use function rtrim;use function sprintf;use function str_repeat; use const PHP_EOL; /** * Helper for rendering menus from navigation containers. * * phpcs:disable SlevomatCodingStandard.Classes.TraitUseDeclaration.MultipleTraitsPerDeclaration */abstract class AbstractMenu extends AbstractHelper implements MenuInterface{ /** * Whether page class should be applied to <li> element. */ protected bool $addClassToListItem = false; /** * Whether labels should be escaped. */ protected bool $escapeLabels = true; /** * Whether only active branch should be rendered. */ protected bool $onlyActiveBranch = false; /** * Partial view script to use for rendering menu. * * @var array<int, string>|ModelInterface|string|null */ protected array | ModelInterface | string | null $partial = null; /** * Whether parents should be rendered when only rendering active branch. */ protected bool $renderParents = true; /** * CSS class to use for the ul element. */ protected string $ulClass = 'navigation'; /** * CSS class to use for the li elements. */ protected string $liClass = ''; /** * CSS class to use for the active li element. */ protected string $liActiveClass = 'active'; /** @throws void */ public function __construct( HtmlifyInterface $htmlify, ContainerParserInterface $containerParser, protected EscapeHtmlAttr $escaper, private readonly PartialRendererInterface $renderer, ) { parent::__construct($htmlify, $containerParser); } /** * Renders menu. * * Implements {@link ViewHelperInterface::render()}. * * If a partial view is registered in the helper, the menu will be rendered * using the given partial script. If no partial is registered, the menu * will be rendered as an 'ul' element by the helper's internal method. * * @see renderPartial() * @see renderMenu() * * @param ContainerInterface<PageInterface>|string|null $container [optional] container to render. Default is null, which indicates that the helper should render the container returned by {@link getContainer()}. * * @throws Exception\InvalidArgumentException * @throws Exception\RuntimeException */ #[Override] public function render(ContainerInterface | string | null $container = null): string { $partial = $this->getPartial(); if ($partial) { return $this->renderPartial($container, $partial); } return $this->renderMenu($container); } /** * Renders the given $container by invoking the partial view helper. * * The container will simply be passed on as a model to the view script * as-is, and will be available in the partial script as 'container', e.g. * <code>echo 'Number of pages: ', count($this->container);</code>. * * @param ContainerInterface<PageInterface>|string|null $container [optional] container to pass to view script. Default is to use the container registered in the helper. * @param array<int, string>|ModelInterface|string|null $partial [optional] partial view script to use. Default is to use the partial registered in the helper. If an array is given, the first value is used for the partial view script. * * @throws Exception\RuntimeException if no partial provided * @throws Exception\InvalidArgumentException if partial is invalid array */ #[Override] public function renderPartial( ContainerInterface | string | null $container = null, array | ModelInterface | string | null $partial = null, ): string { return $this->renderPartialModel([], $container, $partial); } /** * Renders the given $container by invoking the partial view helper with the given parameters as the model. * * The container will simply be passed on as a model to the view script * as-is, and will be available in the partial script as 'container', e.g. * <code>echo 'Number of pages: ', count($this->container);</code>. * * Any parameters provided will be passed to the partial via the view model. * * @param array<int|string, mixed> $params * @param ContainerInterface<PageInterface>|string|null $container [optional] container to pass to view script. Default is to use the container registered in the helper. * @param array<int, string>|string|null $partial [optional] partial view script to use. Default is to use the partial registered in the helper. If an array is given, the first value is used for the partial view script. * * @throws Exception\RuntimeException if no partial provided * @throws Exception\InvalidArgumentException if partial is invalid array */ #[Override] public function renderPartialWithParams( array $params = [], ContainerInterface | string | null $container = null, array | string | null $partial = null, ): string { return $this->renderPartialModel($params, $container, $partial); } /** * Returns an HTML string containing an 'a' element for the given page if * the page's href is not empty, and a 'span' element if it is empty. * * Overrides {@link AbstractHelper::htmlify()}. * * @param PageInterface $page page to generate HTML for * @param bool $escapeLabel Whether to escape the label * @param bool $addClassToListItem Whether to add the page class to the list item * * @throws Exception\RuntimeException * @throws Exception\InvalidArgumentException */ #[Override] public function htmlify(PageInterface $page, bool $escapeLabel = true, bool $addClassToListItem = false): string { try { return $this->htmlify->toHtml(static::class, $page, $escapeLabel, $addClassToListItem); } catch (RuntimeException $e) { throw new Exception\RuntimeException($e->getMessage(), $e->getCode(), $e); } } /** * Sets a flag indicating whether labels should be escaped. * * @param bool $flag [optional] escape labels * * @throws void */ #[Override] public function escapeLabels(bool $flag = true): static { $this->escapeLabels = $flag; return $this; } /** @throws void */ public function getEscapeLabels(): bool { return $this->escapeLabels; } /** * Enables/disables page class applied to <li> element. * * @param bool $flag [optional] page class applied to <li> element Default * is true * * @return static fluent interface * * @throws void */ #[Override] public function setAddClassToListItem(bool $flag = true): static { $this->addClassToListItem = $flag; return $this; } /** * Returns flag indicating whether page class should be applied to <li> element. * * By default, this value is false. * * @return bool whether parents should be rendered * * @throws void */ #[Override] public function getAddClassToListItem(): bool { return $this->addClassToListItem; } /** * Sets a flag indicating whether only active branch should be rendered. * * @param bool $flag [optional] render only active branch * * @throws void */ #[Override] public function setOnlyActiveBranch(bool $flag = true): static { $this->onlyActiveBranch = $flag; return $this; } /** * Returns a flag indicating whether only active branch should be rendered. * * By default, this value is false, meaning the entire menu will be rendered. * * @throws void */ #[Override] public function getOnlyActiveBranch(): bool { return $this->onlyActiveBranch; } /** * Sets which partial view script to use for rendering menu. * * @param array<int, string>|ModelInterface|string|null $partial partial view script or null. If an array is given, the first value is used for the partial view script. * * @throws void * * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ #[Override] public function setPartial($partial): static { if ( $partial === null || is_string($partial) || is_array($partial) || $partial instanceof ModelInterface ) { $this->partial = $partial; } return $this; } /** * Returns partial view script to use for rendering menu. * * @return array<int, string>|ModelInterface|string|null * * @throws void */ #[Override] public function getPartial(): array | ModelInterface | string | null { return $this->partial; } /** * Enables/disables rendering of parents when only rendering active branch. * * See {@link setOnlyActiveBranch()} for more information. * * @param bool $flag [optional] render parents when rendering active branch * * @throws void */ #[Override] public function setRenderParents(bool $flag = true): static { $this->renderParents = $flag; return $this; } /** * Returns flag indicating whether parents should be rendered when rendering only the active branch. * * By default, this value is true. * * @throws void */ #[Override] public function getRenderParents(): bool { return $this->renderParents; } /** * Sets CSS class to use for the first 'ul' element when rendering. * * @param string $ulClass CSS class to set * * @throws void */ #[Override] public function setUlClass(string $ulClass): static { $this->ulClass = $ulClass; return $this; } /** * Returns CSS class to use for the first 'ul' element when rendering. * * @throws void */ #[Override] public function getUlClass(): string { return $this->ulClass; } /** * Sets CSS class to use for the 'li' elements when rendering. * * @param string $liClass CSS class to set * * @throws void */ #[Override] public function setLiClass(string $liClass): static { $this->liClass = $liClass; return $this; } /** * Returns CSS class to use for the 'li' elements when rendering. * * @throws void */ #[Override] public function getLiClass(): string { return $this->liClass; } /** * Sets CSS class to use for the active 'li' element when rendering. * * @param string $liActiveClass CSS class to set * * @throws void */ #[Override] public function setLiActiveClass(string $liActiveClass): static { $this->liActiveClass = $liActiveClass; return $this; } /** * Returns CSS class to use for the active 'li' element when rendering. * * @throws void */ #[Override] public function getLiActiveClass(): string { return $this->liActiveClass; } /** * Renders helper. * * Renders a HTML 'ul' for the given $container. If $container is not given, * the container registered in the helper will be used. * * Available $options: * * @param ContainerInterface<PageInterface>|string|null $container [optional] container to create menu from. Default is to use the container retrieved from {@link getContainer()}. * @param array<string, bool|int|string|null> $options [optional] options for controlling rendering * @phpstan-param array{indent?: int|string|null, ulClass?: string|null, liClass?: string|null, minDepth?: int|null, maxDepth?: int|null, onlyActiveBranch?: bool, renderParents?: bool, escapeLabels?: bool, addClassToListItem?: bool, liActiveClass?: string|null} $options * * @throws Exception\RuntimeException * @throws Exception\InvalidArgumentException */ #[Override] public function renderMenu(ContainerInterface | string | null $container = null, array $options = []): string { try { $container = $this->containerParser->parseContainer($container); } catch (InvalidArgumentException $e) { throw new Exception\InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } if ($container === null) { $container = $this->getContainer(); } $options = $this->normalizeOptions($options); assert(is_string($options['ulClass'])); assert(is_string($options['liClass'])); assert(is_string($options['indent'])); assert(is_int($options['minDepth'])); assert(is_bool($options['onlyActiveBranch'])); assert(is_bool($options['renderParents'])); assert(is_bool($options['escapeLabels'])); assert(is_bool($options['addClassToListItem'])); assert(is_string($options['liActiveClass'])); assert($container instanceof ContainerInterface); if ($options['onlyActiveBranch'] && !$options['renderParents']) { return $this->renderDeepestMenu( container: $container, ulClass: $options['ulClass'], liCssClass: $options['liClass'], indent: $options['indent'], minDepth: $options['minDepth'], maxDepth: $options['maxDepth'], escapeLabels: $options['escapeLabels'], addClassToListItem: $options['addClassToListItem'], liActiveClass: $options['liActiveClass'], ); } return $this->renderNormalMenu( container: $container, ulClass: $options['ulClass'], liCssClass: $options['liClass'], indent: $options['indent'], minDepth: $options['minDepth'], maxDepth: $options['maxDepth'], onlyActive: $options['onlyActiveBranch'], escapeLabels: $options['escapeLabels'], addClassToListItem: $options['addClassToListItem'], liActiveClass: $options['liActiveClass'], ); } /** * Renders the inner-most sub menu for the active page in the $container. * * This is a convenience method which is equivalent to the following call: * <code> * renderMenu($container, array( * 'indent' => $indent, * 'ulClass' => $ulClass, * 'liClass' => $liClass, * 'minDepth' => null, * 'maxDepth' => null, * 'onlyActiveBranch' => true, * 'renderParents' => false, * 'liActiveClass' => $liActiveClass * )); * </code> * * @param ContainerInterface<PageInterface>|null $container [optional] container to render. Default is to render the container registered in the helper. * @param string|null $ulClass [optional] CSS class to use for UL element. Default is to use the value from {@link getUlClass()}. * @param string|null $liClass [optional] CSS class to use for LI elements. Default is to use the value from {@link getLiClass()}. * @param int|string|null $indent [optional] indentation as a string or number of spaces. Default is to use the value retrieved from {@link getIndent()}. * @param string|null $liActiveClass [optional] CSS class to use for UL * * @throws Exception\RuntimeException * @throws Exception\InvalidArgumentException */ #[Override] public function renderSubMenu( ContainerInterface | null $container = null, string | null $ulClass = null, string | null $liClass = null, int | string | null $indent = null, string | null $liActiveClass = null, ): string { $this->setMaxDepth(null); $this->setMinDepth(null); $this->setRenderParents(false); $this->setAddClassToListItem(false); return $this->renderMenu( $container, [ 'indent' => $indent, 'ulClass' => $ulClass, 'liClass' => $liClass, 'onlyActiveBranch' => true, 'escapeLabels' => true, 'liActiveClass' => $liActiveClass, ], ); } /** * Normalizes given render options. * * @param array<string, bool|int|string|null> $options [optional] options for controlling rendering * @phpstan-param array{indent?: int|string|null, ulClass?: string|null, liClass?: string|null, minDepth?: int|null, maxDepth?: int|null, onlyActiveBranch?: bool, renderParents?: bool, escapeLabels?: bool, addClassToListItem?: bool, liActiveClass?: string|null} $options * * @return array<string, bool|int|string|null> * @phpstan-return array{indent: string, ulClass: string, liClass: string, minDepth: int|null, maxDepth: int|null, onlyActiveBranch: bool, renderParents: bool, escapeLabels: bool, addClassToListItem: bool, liActiveClass: string} * * @throws void */ protected function normalizeOptions(array $options = []): array { if (isset($options['indent'])) { assert(is_int($options['indent']) || is_string($options['indent'])); $options['indent'] = $this->getWhitespace($options['indent']); } else { $options['indent'] = $this->getIndent(); } if (!array_key_exists('ulClass', $options) || $options['ulClass'] === null) { $options['ulClass'] = $this->getUlClass(); } if (!array_key_exists('liClass', $options) || $options['liClass'] === null) { $options['liClass'] = $this->getLiClass(); } if (!array_key_exists('minDepth', $options) || $options['minDepth'] === null) { $options['minDepth'] = $this->getMinDepth(); } if ($options['minDepth'] < 0 || $options['minDepth'] === null) { $options['minDepth'] = 0; } if (!array_key_exists('maxDepth', $options) || $options['maxDepth'] === null) { $options['maxDepth'] = $this->getMaxDepth(); } if (!array_key_exists('onlyActiveBranch', $options)) { $options['onlyActiveBranch'] = $this->getOnlyActiveBranch(); } if (!array_key_exists('escapeLabels', $options)) { $options['escapeLabels'] = $this->getEscapeLabels(); } if (!array_key_exists('renderParents', $options)) { $options['renderParents'] = $this->getRenderParents(); } if (!array_key_exists('addClassToListItem', $options)) { $options['addClassToListItem'] = $this->getAddClassToListItem(); } if (!array_key_exists('liActiveClass', $options) || $options['liActiveClass'] === null) { $options['liActiveClass'] = $this->getLiActiveClass(); } return $options; } /** * @param array<string, int|PageInterface|null> $found * @phpstan-param array{page?: PageInterface|null, depth?: int|null} $found * * @throws void */ protected function isActiveBranch(array $found, PageInterface $page, int | null $maxDepth): bool { if (!array_key_exists('page', $found) || !($found['page'] instanceof PageInterface)) { return false; } $foundPage = $found['page']; $foundDepth = $found['depth'] ?? 0; if ($foundPage->hasPage($page)) { // accept if page is a direct child of the active page return true; } if ( $foundPage->getParent() instanceof ContainerInterface && $foundPage->getParent()->hasPage($page) ) { // page is a sibling of the active page... if ( !$foundPage->hasPages(!$this->renderInvisible) || is_int($maxDepth) && $foundDepth + 1 > $maxDepth ) { // accept if active page has no children, or the // children are too deep to be rendered return true; } } return false; } /** * Render a partial with the given "model". * * @param array<int|string, mixed> $params * @param ContainerInterface<PageInterface>|string|null $container * @param array<int, string>|ModelInterface|string|null $partial * * @throws Exception\RuntimeException if no partial provided * @throws Exception\InvalidArgumentException if partial is invalid array */ private function renderPartialModel( array $params, ContainerInterface | string | null $container, array | ModelInterface | string | null $partial, ): string { if ($partial === null) { $partial = $this->getPartial(); } if ($partial === null || $partial === '' || $partial === []) { throw new Exception\RuntimeException( 'Unable to render menu: No partial view script provided', ); } if (is_array($partial)) { if (count($partial) !== 2) { throw new Exception\InvalidArgumentException( 'Unable to render menu: A view partial supplied as ' . 'an array must contain one value: the partial view script', ); } $partial = $partial[0]; } try { $container = $this->containerParser->parseContainer($container); } catch (InvalidArgumentException $e) { throw new Exception\InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } if ($container === null) { $container = $this->getContainer(); } return $this->renderer->render( $partial, array_merge($params, ['container' => $container]), ); } /** * Renders the deepest active menu within [$minDepth, $maxDepth], (called from {@link renderMenu()}). * * @param ContainerInterface<PageInterface> $container container to render * @param string $ulClass CSS class for first UL * @param string $liCssClass CSS class for all LI * @param string $indent initial indentation * @param int $minDepth minimum depth * @param int|null $maxDepth maximum depth * @param bool $escapeLabels Whether or not to escape the labels * @param bool $addClassToListItem Whether or not page class applied to <li> element * @param string $liActiveClass CSS class for active LI * * @throws Exception\RuntimeException * @throws Exception\InvalidArgumentException */ private function renderDeepestMenu( ContainerInterface $container, string $ulClass, string $liCssClass, string $indent, int $minDepth, int | null $maxDepth, bool $escapeLabels, bool $addClassToListItem, string $liActiveClass, ): string { $active = $this->findActive($container, $minDepth - 1, $maxDepth); if (!array_key_exists('page', $active) || !($active['page'] instanceof PageInterface)) { return ''; } $activePage = $active['page']; // special case if active page is one below minDepth if (!array_key_exists('depth', $active) || $active['depth'] < $minDepth) { if (!$activePage->hasPages(!$this->renderInvisible)) { return ''; } } elseif (!$active['page']->hasPages(!$this->renderInvisible)) { // found pages has no children; render siblings $activePage = $active['page']->getParent(); } elseif (is_int($maxDepth) && $active['depth'] + 1 > $maxDepth) { // children are below max depth; render siblings $activePage = $active['page']->getParent(); } if ($ulClass !== '') { $ulClass = ($this->escaper)($ulClass); assert(is_string($ulClass)); $ulClass = ' class="' . $ulClass . '"'; } $html = $indent . '<ul' . $ulClass . '>' . PHP_EOL; assert( $activePage instanceof ContainerInterface, sprintf( '$activePage should be an Instance of %s, but was %s', ContainerInterface::class, get_debug_type($activePage), ), ); foreach ($activePage as $subPage) { if (!$this->accept($subPage)) { continue; } // render li tag and page $liClasses = []; // Is page active? if ($subPage->isActive(true)) { $liClasses[] = $liActiveClass; } if ($liCssClass) { $liClasses[] = $liCssClass; } if ($subPage->getLiClass()) { $liClasses[] = $subPage->getLiClass(); } // Add CSS class from page to <li> if ($addClassToListItem && $subPage->getClass()) { $liClasses[] = $subPage->getClass(); } $liClass = ''; if ($liClasses !== []) { $liClass = ($this->escaper)(implode(' ', $liClasses)); assert(is_string($liClass)); $liClass = ' class="' . $liClass . '"'; } $html .= $indent . ' <li' . $liClass . '>' . PHP_EOL; try { $subPageHtml = $this->htmlify->toHtml( static::class, $subPage, $escapeLabels, $addClassToListItem, ); } catch (RuntimeException $e) { throw new Exception\RuntimeException($e->getMessage(), $e->getCode(), $e); } $html .= $indent . ' ' . $subPageHtml . PHP_EOL; $html .= $indent . ' </li>' . PHP_EOL; } return $html . $indent . '</ul>'; } /** * Renders a normal menu (called from {@link renderMenu()}). * * @param ContainerInterface<PageInterface> $container container to render * @param string $ulClass CSS class for first UL * @param string $liCssClass CSS class for all LI * @param string $indent initial indentation * @param int|null $minDepth minimum depth * @param int|null $maxDepth maximum depth * @param bool $onlyActive render only active branch? * @param bool $escapeLabels Whether or not to escape the labels * @param bool $addClassToListItem Whether or not page class applied to <li> element * @param string $liActiveClass CSS class for active LI * * @throws Exception\RuntimeException * @throws Exception\InvalidArgumentException */ private function renderNormalMenu( ContainerInterface $container, string $ulClass, string $liCssClass, string $indent, int | null $minDepth, int | null $maxDepth, bool $onlyActive, bool $escapeLabels, bool $addClassToListItem, string $liActiveClass, ): string { $html = ''; // find deepest active $found = $this->findActive($container, $minDepth, $maxDepth); // create iterator $iterator = new RecursiveIteratorIterator($container, RecursiveIteratorIterator::SELF_FIRST); if (is_int($maxDepth)) { $iterator->setMaxDepth($maxDepth); } // iterate container $prevDepth = -1; foreach ($iterator as $page) { assert( $page instanceof PageInterface, sprintf( '$page should be an Instance of %s, but was %s', PageInterface::class, get_debug_type($page), ), ); $depth = $iterator->getDepth(); if ($depth < $minDepth || !$this->accept($page)) { // page is below minDepth or not accepted by Authorization/Visibility continue; } $isActive = $page->isActive(true); if ($onlyActive && !$isActive) { // page is not active itself, but might be in the active branch $accept = $this->isActiveBranch($found, $page, $maxDepth); if (!$accept) { continue; } } // make sure indentation is correct $depth -= $minDepth; $myIndent = $indent . str_repeat(' ', $depth); if ($depth > $prevDepth) { // start new ul tag if ($ulClass && $depth === 0) { $ulClass = ($this->escaper)($ulClass); assert(is_string($ulClass)); $ulClass = ' class="' . $ulClass . '"'; } else { $ulClass = ''; } $html .= $myIndent . '<ul' . $ulClass . '>' . PHP_EOL; } elseif ($prevDepth > $depth) { // close li/ul tags until we're at current depth for ($i = $prevDepth; $i > $depth; --$i) { $ind = $indent . str_repeat(' ', $i); $html .= $ind . ' </li>' . PHP_EOL; $html .= $ind . '</ul>' . PHP_EOL; } // close previous li tag $html .= $myIndent . ' </li>' . PHP_EOL; } else { // close previous li tag $html .= $myIndent . ' </li>' . PHP_EOL; } // render li tag and page $liClasses = []; // Is page active? if ($isActive) { $liClasses[] = $liActiveClass; } if ($liCssClass) { $liClasses[] = $liCssClass; } if ($page->getLiClass()) { $liClasses[] = $page->getLiClass(); } // Add CSS class from page to <li> if ($addClassToListItem && $page->getClass()) { $liClasses[] = $page->getClass(); } $liClass = ''; if ($liClasses !== []) { $liClass = ($this->escaper)(implode(' ', $liClasses)); assert(is_string($liClass)); $liClass = ' class="' . $liClass . '"'; } try { $pageHtml = $this->htmlify->toHtml( static::class, $page, $escapeLabels, $addClassToListItem, ); } catch (RuntimeException $e) { throw new Exception\RuntimeException($e->getMessage(), $e->getCode(), $e); } $html .= $myIndent . ' <li' . $liClass . '>' . PHP_EOL . $myIndent . ' ' . $pageHtml . PHP_EOL; // store as previous depth for next iteration $prevDepth = $depth; } if ($html) { // done iterating container; close open ul/li tags for ($i = $prevDepth + 1; 0 < $i; --$i) { $myIndent = $indent . str_repeat(' ', $i - 1); $html .= $myIndent . ' </li>' . PHP_EOL . $myIndent . '</ul>' . PHP_EOL; } $html = rtrim($html, PHP_EOL); } return $html; }}