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