1<?php 2/** 3 * Zend Framework (http://framework.zend.com/) 4 * 5 * @link http://github.com/zendframework/zf2 for the canonical source repository 6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com) 7 * @license http://framework.zend.com/license/new-bsd New BSD License 8 */ 9 10namespace Zend\View\Helper\Navigation; 11 12use RecursiveIteratorIterator; 13use Zend\Navigation\AbstractContainer; 14use Zend\Navigation\Page\AbstractPage; 15use Zend\View\Exception; 16 17/** 18 * Helper for rendering menus from navigation containers 19 */ 20class Menu extends AbstractHelper 21{ 22 /** 23 * Whether page class should be applied to <li> element 24 * 25 * @var bool 26 */ 27 protected $addClassToListItem = false; 28 29 /** 30 * Whether labels should be escaped 31 * 32 * @var bool 33 */ 34 protected $escapeLabels = true; 35 36 /** 37 * Whether only active branch should be rendered 38 * 39 * @var bool 40 */ 41 protected $onlyActiveBranch = false; 42 43 /** 44 * Partial view script to use for rendering menu 45 * 46 * @var string|array 47 */ 48 protected $partial = null; 49 50 /** 51 * Whether parents should be rendered when only rendering active branch 52 * 53 * @var bool 54 */ 55 protected $renderParents = true; 56 57 /** 58 * CSS class to use for the ul element 59 * 60 * @var string 61 */ 62 protected $ulClass = 'navigation'; 63 64 /** 65 * CSS class to use for the active li element 66 * 67 * @var string 68 */ 69 protected $liActiveClass = 'active'; 70 71 /** 72 * View helper entry point: 73 * Retrieves helper and optionally sets container to operate on 74 * 75 * @param AbstractContainer $container [optional] container to operate on 76 * @return self 77 */ 78 public function __invoke($container = null) 79 { 80 if (null !== $container) { 81 $this->setContainer($container); 82 } 83 84 return $this; 85 } 86 87 /** 88 * Renders menu 89 * 90 * Implements {@link HelperInterface::render()}. 91 * 92 * If a partial view is registered in the helper, the menu will be rendered 93 * using the given partial script. If no partial is registered, the menu 94 * will be rendered as an 'ul' element by the helper's internal method. 95 * 96 * @see renderPartial() 97 * @see renderMenu() 98 * 99 * @param AbstractContainer $container [optional] container to render. Default is 100 * to render the container registered in the helper. 101 * @return string 102 */ 103 public function render($container = null) 104 { 105 $partial = $this->getPartial(); 106 if ($partial) { 107 return $this->renderPartial($container, $partial); 108 } 109 110 return $this->renderMenu($container); 111 } 112 113 /** 114 * Renders the deepest active menu within [$minDepth, $maxDepth], (called 115 * from {@link renderMenu()}) 116 * 117 * @param AbstractContainer $container container to render 118 * @param string $ulClass CSS class for first UL 119 * @param string $indent initial indentation 120 * @param int|null $minDepth minimum depth 121 * @param int|null $maxDepth maximum depth 122 * @param bool $escapeLabels Whether or not to escape the labels 123 * @param bool $addClassToListItem Whether or not page class applied to <li> element 124 * @param string $liActiveClass CSS class for active LI 125 * @return string 126 */ 127 protected function renderDeepestMenu( 128 AbstractContainer $container, 129 $ulClass, 130 $indent, 131 $minDepth, 132 $maxDepth, 133 $escapeLabels, 134 $addClassToListItem, 135 $liActiveClass 136 ) { 137 if (!$active = $this->findActive($container, $minDepth - 1, $maxDepth)) { 138 return ''; 139 } 140 141 // special case if active page is one below minDepth 142 if ($active['depth'] < $minDepth) { 143 if (!$active['page']->hasPages(!$this->renderInvisible)) { 144 return ''; 145 } 146 } elseif (!$active['page']->hasPages(!$this->renderInvisible)) { 147 // found pages has no children; render siblings 148 $active['page'] = $active['page']->getParent(); 149 } elseif (is_int($maxDepth) && $active['depth'] +1 > $maxDepth) { 150 // children are below max depth; render siblings 151 $active['page'] = $active['page']->getParent(); 152 } 153 154 /* @var $escaper \Zend\View\Helper\EscapeHtmlAttr */ 155 $escaper = $this->view->plugin('escapeHtmlAttr'); 156 $ulClass = $ulClass ? ' class="' . $escaper($ulClass) . '"' : ''; 157 $html = $indent . '<ul' . $ulClass . '>' . PHP_EOL; 158 159 foreach ($active['page'] as $subPage) { 160 if (!$this->accept($subPage)) { 161 continue; 162 } 163 164 // render li tag and page 165 $liClasses = array(); 166 // Is page active? 167 if ($subPage->isActive(true)) { 168 $liClasses[] = $liActiveClass; 169 } 170 // Add CSS class from page to <li> 171 if ($addClassToListItem && $subPage->getClass()) { 172 $liClasses[] = $subPage->getClass(); 173 } 174 $liClass = empty($liClasses) ? '' : ' class="' . $escaper(implode(' ', $liClasses)) . '"'; 175 176 $html .= $indent . ' <li' . $liClass . '>' . PHP_EOL; 177 $html .= $indent . ' ' . $this->htmlify($subPage, $escapeLabels, $addClassToListItem) . PHP_EOL; 178 $html .= $indent . ' </li>' . PHP_EOL; 179 } 180 181 $html .= $indent . '</ul>'; 182 183 return $html; 184 } 185 186 /** 187 * Renders helper 188 * 189 * Renders a HTML 'ul' for the given $container. If $container is not given, 190 * the container registered in the helper will be used. 191 * 192 * Available $options: 193 * 194 * 195 * @param AbstractContainer $container [optional] container to create menu from. 196 * Default is to use the container retrieved 197 * from {@link getContainer()}. 198 * @param array $options [optional] options for controlling rendering 199 * @return string 200 */ 201 public function renderMenu($container = null, array $options = array()) 202 { 203 $this->parseContainer($container); 204 if (null === $container) { 205 $container = $this->getContainer(); 206 } 207 208 209 $options = $this->normalizeOptions($options); 210 211 if ($options['onlyActiveBranch'] && !$options['renderParents']) { 212 $html = $this->renderDeepestMenu( 213 $container, 214 $options['ulClass'], 215 $options['indent'], 216 $options['minDepth'], 217 $options['maxDepth'], 218 $options['escapeLabels'], 219 $options['addClassToListItem'], 220 $options['liActiveClass'] 221 ); 222 } else { 223 $html = $this->renderNormalMenu( 224 $container, 225 $options['ulClass'], 226 $options['indent'], 227 $options['minDepth'], 228 $options['maxDepth'], 229 $options['onlyActiveBranch'], 230 $options['escapeLabels'], 231 $options['addClassToListItem'], 232 $options['liActiveClass'] 233 ); 234 } 235 236 return $html; 237 } 238 239 /** 240 * Renders a normal menu (called from {@link renderMenu()}) 241 * 242 * @param AbstractContainer $container container to render 243 * @param string $ulClass CSS class for first UL 244 * @param string $indent initial indentation 245 * @param int|null $minDepth minimum depth 246 * @param int|null $maxDepth maximum depth 247 * @param bool $onlyActive render only active branch? 248 * @param bool $escapeLabels Whether or not to escape the labels 249 * @param bool $addClassToListItem Whether or not page class applied to <li> element 250 * @param string $liActiveClass CSS class for active LI 251 * @return string 252 */ 253 protected function renderNormalMenu( 254 AbstractContainer $container, 255 $ulClass, 256 $indent, 257 $minDepth, 258 $maxDepth, 259 $onlyActive, 260 $escapeLabels, 261 $addClassToListItem, 262 $liActiveClass 263 ) { 264 $html = ''; 265 266 // find deepest active 267 $found = $this->findActive($container, $minDepth, $maxDepth); 268 /* @var $escaper \Zend\View\Helper\EscapeHtmlAttr */ 269 $escaper = $this->view->plugin('escapeHtmlAttr'); 270 271 if ($found) { 272 $foundPage = $found['page']; 273 $foundDepth = $found['depth']; 274 } else { 275 $foundPage = null; 276 } 277 278 // create iterator 279 $iterator = new RecursiveIteratorIterator( 280 $container, 281 RecursiveIteratorIterator::SELF_FIRST 282 ); 283 if (is_int($maxDepth)) { 284 $iterator->setMaxDepth($maxDepth); 285 } 286 287 // iterate container 288 $prevDepth = -1; 289 foreach ($iterator as $page) { 290 $depth = $iterator->getDepth(); 291 $isActive = $page->isActive(true); 292 if ($depth < $minDepth || !$this->accept($page)) { 293 // page is below minDepth or not accepted by acl/visibility 294 continue; 295 } elseif ($onlyActive && !$isActive) { 296 // page is not active itself, but might be in the active branch 297 $accept = false; 298 if ($foundPage) { 299 if ($foundPage->hasPage($page)) { 300 // accept if page is a direct child of the active page 301 $accept = true; 302 } elseif ($foundPage->getParent()->hasPage($page)) { 303 // page is a sibling of the active page... 304 if (!$foundPage->hasPages(!$this->renderInvisible) || 305 is_int($maxDepth) && $foundDepth + 1 > $maxDepth) { 306 // accept if active page has no children, or the 307 // children are too deep to be rendered 308 $accept = true; 309 } 310 } 311 } 312 313 if (!$accept) { 314 continue; 315 } 316 } 317 318 // make sure indentation is correct 319 $depth -= $minDepth; 320 $myIndent = $indent . str_repeat(' ', $depth); 321 322 if ($depth > $prevDepth) { 323 // start new ul tag 324 if ($ulClass && $depth == 0) { 325 $ulClass = ' class="' . $escaper($ulClass) . '"'; 326 } else { 327 $ulClass = ''; 328 } 329 $html .= $myIndent . '<ul' . $ulClass . '>' . PHP_EOL; 330 } elseif ($prevDepth > $depth) { 331 // close li/ul tags until we're at current depth 332 for ($i = $prevDepth; $i > $depth; $i--) { 333 $ind = $indent . str_repeat(' ', $i); 334 $html .= $ind . ' </li>' . PHP_EOL; 335 $html .= $ind . '</ul>' . PHP_EOL; 336 } 337 // close previous li tag 338 $html .= $myIndent . ' </li>' . PHP_EOL; 339 } else { 340 // close previous li tag 341 $html .= $myIndent . ' </li>' . PHP_EOL; 342 } 343 344 // render li tag and page 345 $liClasses = array(); 346 // Is page active? 347 if ($isActive) { 348 $liClasses[] = $liActiveClass; 349 } 350 // Add CSS class from page to <li> 351 if ($addClassToListItem && $page->getClass()) { 352 $liClasses[] = $page->getClass(); 353 } 354 $liClass = empty($liClasses) ? '' : ' class="' . $escaper(implode(' ', $liClasses)) . '"'; 355 356 $html .= $myIndent . ' <li' . $liClass . '>' . PHP_EOL 357 . $myIndent . ' ' . $this->htmlify($page, $escapeLabels, $addClassToListItem) . PHP_EOL; 358 359 // store as previous depth for next iteration 360 $prevDepth = $depth; 361 } 362 363 if ($html) { 364 // done iterating container; close open ul/li tags 365 for ($i = $prevDepth+1; $i > 0; $i--) { 366 $myIndent = $indent . str_repeat(' ', $i-1); 367 $html .= $myIndent . ' </li>' . PHP_EOL 368 . $myIndent . '</ul>' . PHP_EOL; 369 } 370 $html = rtrim($html, PHP_EOL); 371 } 372 373 return $html; 374 } 375 376 /** 377 * Renders the given $container by invoking the partial view helper 378 * 379 * The container will simply be passed on as a model to the view script 380 * as-is, and will be available in the partial script as 'container', e.g. 381 * <code>echo 'Number of pages: ', count($this->container);</code>. 382 * 383 * @param AbstractContainer $container [optional] container to pass to view 384 * script. Default is to use the container 385 * registered in the helper. 386 * @param string|array $partial [optional] partial view script to use. 387 * Default is to use the partial 388 * registered in the helper. If an array 389 * is given, it is expected to contain two 390 * values; the partial view script to use, 391 * and the module where the script can be 392 * found. 393 * @return string 394 * @throws Exception\RuntimeException if no partial provided 395 * @throws Exception\InvalidArgumentException if partial is invalid array 396 */ 397 public function renderPartial($container = null, $partial = null) 398 { 399 $this->parseContainer($container); 400 if (null === $container) { 401 $container = $this->getContainer(); 402 } 403 404 if (null === $partial) { 405 $partial = $this->getPartial(); 406 } 407 408 if (empty($partial)) { 409 throw new Exception\RuntimeException( 410 'Unable to render menu: No partial view script provided' 411 ); 412 } 413 414 $model = array( 415 'container' => $container 416 ); 417 418 /** @var \Zend\View\Helper\Partial $partialHelper */ 419 $partialHelper = $this->view->plugin('partial'); 420 421 if (is_array($partial)) { 422 if (count($partial) != 2) { 423 throw new Exception\InvalidArgumentException( 424 'Unable to render menu: A view partial supplied as ' 425 . 'an array must contain two values: partial view ' 426 . 'script and module where script can be found' 427 ); 428 } 429 430 return $partialHelper($partial[0], $model); 431 } 432 433 return $partialHelper($partial, $model); 434 } 435 436 /** 437 * Renders the inner-most sub menu for the active page in the $container 438 * 439 * This is a convenience method which is equivalent to the following call: 440 * <code> 441 * renderMenu($container, array( 442 * 'indent' => $indent, 443 * 'ulClass' => $ulClass, 444 * 'minDepth' => null, 445 * 'maxDepth' => null, 446 * 'onlyActiveBranch' => true, 447 * 'renderParents' => false, 448 * 'liActiveClass' => $liActiveClass 449 * )); 450 * </code> 451 * 452 * @param AbstractContainer $container [optional] container to 453 * render. Default is to render 454 * the container registered in 455 * the helper. 456 * @param string $ulClass [optional] CSS class to 457 * use for UL element. Default 458 * is to use the value from 459 * {@link getUlClass()}. 460 * @param string|int $indent [optional] indentation as 461 * a string or number of 462 * spaces. Default is to use 463 * the value retrieved from 464 * {@link getIndent()}. 465 * @param string $liActiveClass [optional] CSS class to 466 * use for UL element. Default 467 * is to use the value from 468 * {@link getUlClass()}. 469 * @return string 470 */ 471 public function renderSubMenu( 472 AbstractContainer $container = null, 473 $ulClass = null, 474 $indent = null, 475 $liActiveClass = null 476 ) { 477 return $this->renderMenu($container, array( 478 'indent' => $indent, 479 'ulClass' => $ulClass, 480 'minDepth' => null, 481 'maxDepth' => null, 482 'onlyActiveBranch' => true, 483 'renderParents' => false, 484 'escapeLabels' => true, 485 'addClassToListItem' => false, 486 'liActiveClass' => $liActiveClass 487 )); 488 } 489 490 /** 491 * Returns an HTML string containing an 'a' element for the given page if 492 * the page's href is not empty, and a 'span' element if it is empty 493 * 494 * Overrides {@link AbstractHelper::htmlify()}. 495 * 496 * @param AbstractPage $page page to generate HTML for 497 * @param bool $escapeLabel Whether or not to escape the label 498 * @param bool $addClassToListItem Whether or not to add the page class to the list item 499 * @return string 500 */ 501 public function htmlify(AbstractPage $page, $escapeLabel = true, $addClassToListItem = false) 502 { 503 // get attribs for element 504 $attribs = array( 505 'id' => $page->getId(), 506 'title' => $this->translate($page->getTitle(), $page->getTextDomain()), 507 ); 508 509 if ($addClassToListItem === false) { 510 $attribs['class'] = $page->getClass(); 511 } 512 513 // does page have a href? 514 $href = $page->getHref(); 515 if ($href) { 516 $element = 'a'; 517 $attribs['href'] = $href; 518 $attribs['target'] = $page->getTarget(); 519 } else { 520 $element = 'span'; 521 } 522 523 $html = '<' . $element . $this->htmlAttribs($attribs) . '>'; 524 $label = $this->translate($page->getLabel(), $page->getTextDomain()); 525 if ($escapeLabel === true) { 526 /** @var \Zend\View\Helper\EscapeHtml $escaper */ 527 $escaper = $this->view->plugin('escapeHtml'); 528 $html .= $escaper($label); 529 } else { 530 $html .= $label; 531 } 532 $html .= '</' . $element . '>'; 533 534 return $html; 535 } 536 537 /** 538 * Normalizes given render options 539 * 540 * @param array $options [optional] options to normalize 541 * @return array 542 */ 543 protected function normalizeOptions(array $options = array()) 544 { 545 if (isset($options['indent'])) { 546 $options['indent'] = $this->getWhitespace($options['indent']); 547 } else { 548 $options['indent'] = $this->getIndent(); 549 } 550 551 if (isset($options['ulClass']) && $options['ulClass'] !== null) { 552 $options['ulClass'] = (string) $options['ulClass']; 553 } else { 554 $options['ulClass'] = $this->getUlClass(); 555 } 556 557 if (array_key_exists('minDepth', $options)) { 558 if (null !== $options['minDepth']) { 559 $options['minDepth'] = (int) $options['minDepth']; 560 } 561 } else { 562 $options['minDepth'] = $this->getMinDepth(); 563 } 564 565 if ($options['minDepth'] < 0 || $options['minDepth'] === null) { 566 $options['minDepth'] = 0; 567 } 568 569 if (array_key_exists('maxDepth', $options)) { 570 if (null !== $options['maxDepth']) { 571 $options['maxDepth'] = (int) $options['maxDepth']; 572 } 573 } else { 574 $options['maxDepth'] = $this->getMaxDepth(); 575 } 576 577 if (!isset($options['onlyActiveBranch'])) { 578 $options['onlyActiveBranch'] = $this->getOnlyActiveBranch(); 579 } 580 581 if (!isset($options['escapeLabels'])) { 582 $options['escapeLabels'] = $this->escapeLabels; 583 } 584 585 if (!isset($options['renderParents'])) { 586 $options['renderParents'] = $this->getRenderParents(); 587 } 588 589 if (!isset($options['addClassToListItem'])) { 590 $options['addClassToListItem'] = $this->getAddClassToListItem(); 591 } 592 593 if (isset($options['liActiveClass']) && $options['liActiveClass'] !== null) { 594 $options['liActiveClass'] = (string) $options['liActiveClass']; 595 } else { 596 $options['liActiveClass'] = $this->getLiActiveClass(); 597 } 598 599 return $options; 600 } 601 602 /** 603 * Sets a flag indicating whether labels should be escaped 604 * 605 * @param bool $flag [optional] escape labels 606 * @return self 607 */ 608 public function escapeLabels($flag = true) 609 { 610 $this->escapeLabels = (bool) $flag; 611 return $this; 612 } 613 614 /** 615 * Enables/disables page class applied to <li> element 616 * 617 * @param bool $flag [optional] page class applied to <li> element 618 * Default is true. 619 * @return self fluent interface, returns self 620 */ 621 public function setAddClassToListItem($flag = true) 622 { 623 $this->addClassToListItem = (bool) $flag; 624 return $this; 625 } 626 627 /** 628 * Returns flag indicating whether page class should be applied to <li> element 629 * 630 * By default, this value is false. 631 * 632 * @return bool whether parents should be rendered 633 */ 634 public function getAddClassToListItem() 635 { 636 return $this->addClassToListItem; 637 } 638 639 /** 640 * Sets a flag indicating whether only active branch should be rendered 641 * 642 * @param bool $flag [optional] render only active branch. 643 * @return self 644 */ 645 public function setOnlyActiveBranch($flag = true) 646 { 647 $this->onlyActiveBranch = (bool) $flag; 648 return $this; 649 } 650 651 /** 652 * Returns a flag indicating whether only active branch should be rendered 653 * 654 * By default, this value is false, meaning the entire menu will be 655 * be rendered. 656 * 657 * @return bool 658 */ 659 public function getOnlyActiveBranch() 660 { 661 return $this->onlyActiveBranch; 662 } 663 664 /** 665 * Sets which partial view script to use for rendering menu 666 * 667 * @param string|array $partial partial view script or null. If an array is 668 * given, it is expected to contain two 669 * values; the partial view script to use, 670 * and the module where the script can be 671 * found. 672 * @return self 673 */ 674 public function setPartial($partial) 675 { 676 if (null === $partial || is_string($partial) || is_array($partial)) { 677 $this->partial = $partial; 678 } 679 680 return $this; 681 } 682 683 /** 684 * Returns partial view script to use for rendering menu 685 * 686 * @return string|array|null 687 */ 688 public function getPartial() 689 { 690 return $this->partial; 691 } 692 693 /** 694 * Enables/disables rendering of parents when only rendering active branch 695 * 696 * See {@link setOnlyActiveBranch()} for more information. 697 * 698 * @param bool $flag [optional] render parents when rendering active branch. 699 * @return self 700 */ 701 public function setRenderParents($flag = true) 702 { 703 $this->renderParents = (bool) $flag; 704 return $this; 705 } 706 707 /** 708 * Returns flag indicating whether parents should be rendered when rendering 709 * only the active branch 710 * 711 * By default, this value is true. 712 * 713 * @return bool 714 */ 715 public function getRenderParents() 716 { 717 return $this->renderParents; 718 } 719 720 /** 721 * Sets CSS class to use for the first 'ul' element when rendering 722 * 723 * @param string $ulClass CSS class to set 724 * @return self 725 */ 726 public function setUlClass($ulClass) 727 { 728 if (is_string($ulClass)) { 729 $this->ulClass = $ulClass; 730 } 731 732 return $this; 733 } 734 735 /** 736 * Returns CSS class to use for the first 'ul' element when rendering 737 * 738 * @return string 739 */ 740 public function getUlClass() 741 { 742 return $this->ulClass; 743 } 744 745 /** 746 * Sets CSS class to use for the active 'li' element when rendering 747 * 748 * @param string $liActiveClass CSS class to set 749 * @return self 750 */ 751 public function setLiActiveClass($liActiveClass) 752 { 753 if (is_string($liActiveClass)) { 754 $this->liActiveClass = $liActiveClass; 755 } 756 757 return $this; 758 } 759 760 /** 761 * Returns CSS class to use for the active 'li' element when rendering 762 * 763 * @return string 764 */ 765 public function getLiActiveClass() 766 { 767 return $this->liActiveClass; 768 } 769} 770