529 lines
17 KiB
PHP
529 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* The navigation: one tree, rendered from the menu itself.
|
|
*
|
|
* What this replaces is worth stating, because it is the whole argument.
|
|
*
|
|
* The site it comes from runs UberMenu, whose per-item settings blob carries
|
|
* 149 keys. Across all 15 desktop menus — 748 items — exactly eight of them are
|
|
* ever set to something that matters:
|
|
*
|
|
* span 167 the column's width, stored as a fraction ("1-4", "2-3")
|
|
* layout 165 image_left | image_below
|
|
* image 159 a thumbnail beside the label
|
|
* panel 96 this top-level item opens a mega panel
|
|
* heading 94 the item is a heading, not a link
|
|
* block 56 raw HTML dropped into the panel
|
|
* icon 52 one of exactly three icons, site-wide
|
|
* feed 14 list child pages of a given parent
|
|
*
|
|
* Everything else in the blob is the plugin's own defaults written back
|
|
* verbatim — 667 items carry `dt_orderby: name` and `tab_layout: left` without
|
|
* a tab or a taxonomy anywhere in the menu.
|
|
*
|
|
* So the eight live in one `_tlig_nav` array, and the structure comes from the
|
|
* tree with no configuration at all: depth 0 is the bar, depth 1 a column,
|
|
* depth 2 a link. No per-item heights, no per-item colours, no background image
|
|
* per panel — the panel's ground is the parchment border-image, which is one
|
|
* file at any height.
|
|
*
|
|
* @package TLIG
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Read an item's TLIG settings.
|
|
*
|
|
* @param int $item_id Menu item post ID.
|
|
* @return array<string,mixed>
|
|
*/
|
|
function tlig_nav_meta( int $item_id ): array {
|
|
$meta = get_post_meta( $item_id, '_tlig_nav', true );
|
|
|
|
return is_array( $meta ) ? $meta : [];
|
|
}
|
|
|
|
/**
|
|
* The three icons the entire site uses, inline.
|
|
*
|
|
* A whole icon font was loaded for these. Counted across all 15 menus: 24
|
|
* `fa-search`, 14 `fa-book-open`, 14 `fa-headphones`, and nothing else. Inline
|
|
* SVG costs no request and inherits `currentColor`, so an icon on the dark
|
|
* ground needs no second rule.
|
|
*
|
|
* @param string $name Icon key.
|
|
* @return string SVG markup, or '' for an unknown name.
|
|
*/
|
|
function tlig_nav_icon( string $name ): string {
|
|
static $icons = [
|
|
'search' => '<path d="M11 4a7 7 0 1 0 4.19 12.6l4.1 4.1 1.41-1.42-4.1-4.1A7 7 0 0 0 11 4Zm0 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z"/>',
|
|
'book' => '<path d="M3 5c2.5-1 5-1 8 .5v14c-3-1.5-5.5-1.5-8-.5V5Zm18 0c-2.5-1-5-1-8 .5v14c3-1.5 5.5-1.5 8-.5V5Z"/>',
|
|
'headphones' => '<path d="M12 3a9 9 0 0 0-9 9v5a3 3 0 0 0 3 3h2v-8H5v0a7 7 0 0 1 14 0v0h-3v8h2a3 3 0 0 0 3-3v-5a9 9 0 0 0-9-9Z"/>',
|
|
];
|
|
|
|
if ( ! isset( $icons[ $name ] ) ) {
|
|
return '';
|
|
}
|
|
|
|
return sprintf(
|
|
'<svg class="site-nav__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">%s</svg>',
|
|
$icons[ $name ]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the primary menu.
|
|
*
|
|
* @param string $location Registered menu location.
|
|
*/
|
|
function tlig_nav( string $location = 'primary' ): void {
|
|
if ( ! has_nav_menu( $location ) ) {
|
|
return;
|
|
}
|
|
|
|
// The toggle is part of the menu's own markup rather than the header's, so
|
|
// `aria-controls` can name the list it opens without the template having to
|
|
// know the id. Below the tablet breakpoint it is the entire mobile menu.
|
|
$toggle = sprintf(
|
|
'<button type="button" class="site-nav__toggle" aria-expanded="false" aria-controls="%s">%s</button>',
|
|
'tlig-menu',
|
|
esc_html__( 'Menu', 'tlig' )
|
|
);
|
|
|
|
wp_nav_menu( [
|
|
'theme_location' => $location,
|
|
'container' => 'nav',
|
|
'container_class' => 'site-nav',
|
|
'container_aria_label' => __( 'Primary navigation', 'tlig' ),
|
|
'menu_id' => 'tlig-menu',
|
|
'menu_class' => 'site-nav__bar',
|
|
'items_wrap' => $toggle . '<ul id="%1$s" class="%2$s">%3$s</ul>',
|
|
'depth' => 3,
|
|
'walker' => new TLIG_Nav_Walker(),
|
|
'fallback_cb' => false,
|
|
] );
|
|
}
|
|
|
|
/**
|
|
* Turns a three-level menu into a bar of mega panels.
|
|
*
|
|
* `Walker_Nav_Menu` hands `start_lvl()` a depth but not the item that owns the
|
|
* level, so the current item is stashed in `start_el()` — the panel wrapper has
|
|
* to know whose panel it is in order to give it an id for `aria-controls`.
|
|
*/
|
|
class TLIG_Nav_Walker extends Walker_Nav_Menu {
|
|
|
|
/** @var WP_Post|null The item most recently opened, for start_lvl(). */
|
|
private $current;
|
|
|
|
/** @var array<int,array<string,mixed>> Settings of the open ancestors, by depth. */
|
|
private $stack = [];
|
|
|
|
/** @var array<int,int> How many children each item has, by item ID. */
|
|
private $siblings = [];
|
|
|
|
/**
|
|
* Counts each item's children before walking.
|
|
*
|
|
* A column with no width of its own should share the row with its
|
|
* neighbours, which means knowing how many there are. UberMenu stored that
|
|
* as `columns: auto` and worked it out at render time; the walker is handed
|
|
* the whole tree here, so it can do the same.
|
|
*
|
|
* @param array<int,WP_Post> $elements All menu items.
|
|
* @param int $max_depth Depth limit.
|
|
* @param mixed ...$args Passed through.
|
|
* @return string
|
|
*/
|
|
public function walk( $elements, $max_depth, ...$args ) {
|
|
$this->siblings = [];
|
|
|
|
foreach ( (array) $elements as $element ) {
|
|
$parent = (int) ( $element->menu_item_parent ?? 0 );
|
|
$this->siblings[ $parent ] = ( $this->siblings[ $parent ] ?? 0 ) + 1;
|
|
}
|
|
|
|
return parent::walk( $elements, $max_depth, ...$args );
|
|
}
|
|
|
|
/**
|
|
* Opens a submenu level.
|
|
*
|
|
* Depth 0 → the panel and its grid. Depth 1 → the list of links in a column.
|
|
*
|
|
* @param string $output Accumulated markup, by reference.
|
|
* @param int $depth Depth of the *parent* item.
|
|
* @param stdClass $args wp_nav_menu arguments.
|
|
*/
|
|
public function start_lvl( &$output, $depth = 0, $args = null ) {
|
|
$indent = str_repeat( "\t", $depth );
|
|
|
|
if ( 0 === $depth ) {
|
|
$id = $this->current ? 'tlig-panel-' . (int) $this->current->ID : '';
|
|
|
|
$output .= sprintf(
|
|
"\n%s<div class=\"site-nav__panel\" id=\"%s\">\n",
|
|
$indent,
|
|
esc_attr( $id )
|
|
);
|
|
|
|
// The panel repeats its own item as a link, and the stylesheet shows
|
|
// it only in the stacked layout. There, a tap on the bar item opens
|
|
// the panel instead of following the link — which is what a phone
|
|
// user expects, and what the old site got wrong in the opposite
|
|
// direction: with hover the only way in, its top-level pages were
|
|
// unreachable on a phone. This is the way back to that page.
|
|
if ( $this->current && '' !== trim( (string) $this->current->url ) && '#' !== $this->current->url ) {
|
|
$output .= sprintf(
|
|
"%s\t<a class=\"site-nav__self\" href=\"%s\">%s</a>\n",
|
|
$indent,
|
|
esc_url( (string) $this->current->url ),
|
|
esc_html( trim( wp_strip_all_tags( $this->current->title ) ) )
|
|
);
|
|
}
|
|
|
|
$output .= sprintf( "%s\t<ul class=\"site-nav__grid\">\n", $indent );
|
|
|
|
return;
|
|
}
|
|
|
|
$output .= sprintf( "\n%s<ul class=\"site-nav__links\">\n", $indent );
|
|
}
|
|
|
|
/**
|
|
* Closes a submenu level.
|
|
*
|
|
* @param string $output Accumulated markup, by reference.
|
|
* @param int $depth Depth of the parent item.
|
|
* @param stdClass $args wp_nav_menu arguments.
|
|
*/
|
|
public function end_lvl( &$output, $depth = 0, $args = null ) {
|
|
$indent = str_repeat( "\t", $depth );
|
|
|
|
$output .= 0 === $depth
|
|
? sprintf( "%s\t</ul>\n%s</div>\n", $indent, $indent )
|
|
: sprintf( "%s</ul>\n", $indent );
|
|
}
|
|
|
|
/**
|
|
* Opens an item.
|
|
*
|
|
* @param string $output Accumulated markup, by reference.
|
|
* @param WP_Post $item Menu item.
|
|
* @param int $depth Depth of the item.
|
|
* @param stdClass $args wp_nav_menu arguments.
|
|
* @param int $current_object_id Unused.
|
|
*/
|
|
public function start_el( &$output, $item, $depth = 0, $args = null, $current_object_id = 0 ) {
|
|
$this->current = $item;
|
|
|
|
$nav = tlig_nav_meta( (int) $item->ID );
|
|
$this->stack[ $depth ] = $nav;
|
|
|
|
$has_children = in_array( 'menu-item-has-children', (array) $item->classes, true );
|
|
$indent = str_repeat( "\t", $depth + 1 );
|
|
|
|
switch ( $depth ) {
|
|
case 0:
|
|
$output .= $indent . $this->bar_item( $item, $nav, $has_children );
|
|
break;
|
|
|
|
case 1:
|
|
$output .= $indent . $this->column( $item, $nav, $has_children );
|
|
break;
|
|
|
|
default:
|
|
// A leaf can carry a block or a feed too — the English
|
|
// TESTIMONIES panel is two of them, and dropping them here is
|
|
// how they went missing the first time.
|
|
$output .= $indent . '<li class="site-nav__leaf">'
|
|
. $this->link( $item, $nav, 'site-nav__link' )
|
|
. $this->block( $nav )
|
|
. $this->feed( $nav );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Closes an item.
|
|
*
|
|
* @param string $output Accumulated markup, by reference.
|
|
* @param WP_Post $item Menu item.
|
|
* @param int $depth Depth of the item.
|
|
* @param stdClass $args wp_nav_menu arguments.
|
|
*/
|
|
public function end_el( &$output, $item, $depth = 0, $args = null ) {
|
|
$output .= "</li>\n";
|
|
}
|
|
|
|
/**
|
|
* A top-level item: the thing you see in the bar.
|
|
*
|
|
* @param WP_Post $item Menu item.
|
|
* @param array<string,mixed> $nav Its TLIG settings.
|
|
* @param bool $has_children Whether it opens a panel.
|
|
* @return string
|
|
*/
|
|
private function bar_item( WP_Post $item, array $nav, bool $has_children ): string {
|
|
$panel = $has_children && ! empty( $nav['panel'] );
|
|
|
|
$classes = [ 'site-nav__item' ];
|
|
if ( $panel ) {
|
|
$classes[] = 'site-nav__item--panel';
|
|
}
|
|
if ( in_array( 'current-menu-item', (array) $item->classes, true ) ) {
|
|
$classes[] = 'is-current';
|
|
}
|
|
|
|
$attrs = '';
|
|
if ( $panel ) {
|
|
// The link still goes where it always went; the button beside it is
|
|
// what opens the panel. A single element cannot both navigate and
|
|
// disclose, and hover alone leaves touch and keyboard out.
|
|
$attrs = sprintf(
|
|
' aria-expanded="false" aria-controls="%s"',
|
|
esc_attr( 'tlig-panel-' . (int) $item->ID )
|
|
);
|
|
}
|
|
|
|
$out = sprintf( '<li class="%s">', esc_attr( implode( ' ', $classes ) ) );
|
|
$out .= $this->link( $item, $nav, 'site-nav__top' );
|
|
|
|
if ( $panel ) {
|
|
$out .= sprintf(
|
|
'<button type="button" class="site-nav__disclose"%s><span class="visually-hidden">%s</span></button>',
|
|
$attrs,
|
|
/* translators: %s: menu item label. */
|
|
esc_html( sprintf( __( 'Open the %s menu', 'tlig' ), wp_strip_all_tags( $item->title ) ) )
|
|
);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* A column inside a panel.
|
|
*
|
|
* The width is a grid span, not a pixel height: `--tlig-span` feeds
|
|
* `grid-column: span var(--tlig-span)`. UberMenu stored it as a fraction of
|
|
* the row, which is the same thing said less directly.
|
|
*
|
|
* @param WP_Post $item Menu item.
|
|
* @param array<string,mixed> $nav Its TLIG settings.
|
|
* @param bool $has_children Whether it holds links.
|
|
* @return string
|
|
*/
|
|
private function column( WP_Post $item, array $nav, bool $has_children ): string {
|
|
$span = isset( $nav['span'] ) ? max( 1, min( 12, (int) $nav['span'] ) ) : 0;
|
|
|
|
// No width of its own: divide the row by however many columns this
|
|
// panel has. Without this every unsized column fell back to a quarter,
|
|
// which is right for a panel of four and wrong for the 28 panels —
|
|
// UNITY in DIVERSITY, MISSION, SEARCH, across the languages — that
|
|
// hold a single column. Those were rendering a 1200px banner into a
|
|
// quarter of the width.
|
|
if ( ! $span ) {
|
|
$count = max( 1, (int) ( $this->siblings[ (int) $item->menu_item_parent ] ?? 1 ) );
|
|
$span = max( 1, min( 12, (int) round( 12 / $count ) ) );
|
|
}
|
|
|
|
$style = sprintf( ' style="--tlig-span:%d"', $span );
|
|
|
|
$out = sprintf( '<li class="site-nav__col"%s>', $style );
|
|
|
|
// A heading is an item with its link switched off. It was a link with
|
|
// `disable_link: on` before; here it is simply not an anchor, which is
|
|
// also what a screen reader needs to hear.
|
|
$title = trim( wp_strip_all_tags( $item->title ) );
|
|
|
|
$linked = false;
|
|
|
|
if ( ! empty( $nav['heading'] ) && '' !== $title ) {
|
|
$out .= sprintf( '<h3 class="site-nav__heading">%s</h3>', esc_html( $title ) );
|
|
} elseif ( ! $has_children && '' !== $title ) {
|
|
$out .= $this->link( $item, $nav, 'site-nav__link' );
|
|
$linked = true;
|
|
}
|
|
|
|
// `link()` already emits the thumbnail, so only a column that did not
|
|
// render one needs it here — otherwise a childless column with an image
|
|
// shows it twice.
|
|
if ( ! $linked ) {
|
|
$out .= $this->figure( $nav );
|
|
}
|
|
$out .= $this->block( $nav );
|
|
$out .= $this->feed( $nav );
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* An anchor, with its optional thumbnail and icon.
|
|
*
|
|
* @param WP_Post $item Menu item.
|
|
* @param array<string,mixed> $nav Its TLIG settings.
|
|
* @param string $class Class for the anchor.
|
|
* @return string
|
|
*/
|
|
private function link( WP_Post $item, array $nav, string $class ): string {
|
|
$title = trim( wp_strip_all_tags( $item->title ) );
|
|
$inner = $this->figure( $nav );
|
|
|
|
if ( ! empty( $nav['icon'] ) ) {
|
|
$inner .= tlig_nav_icon( (string) $nav['icon'] );
|
|
}
|
|
|
|
if ( '' !== $title ) {
|
|
$inner .= sprintf( '<span class="site-nav__label">%s</span>', esc_html( $title ) );
|
|
}
|
|
|
|
if ( '' === $inner ) {
|
|
return '';
|
|
}
|
|
|
|
// An item with its link switched off still has something to show — the
|
|
// English menu's fourth column is a single picture with `disable_link`
|
|
// set, and wrapping it in an anchor to nowhere would be a lie to anyone
|
|
// tabbing through. It renders as the content without the anchor.
|
|
// No destination either: an item can lose its link because the editor
|
|
// switched it off, or because it never had one — 137 of the 748 items
|
|
// point at an `#ubermenu-column` placeholder. Both are content without
|
|
// a link, and neither should be an anchor to nowhere.
|
|
if ( ! empty( $nav['heading'] ) || '' === trim( (string) $item->url ) || '#' === $item->url ) {
|
|
$static = $class . ' is-static';
|
|
|
|
if ( '' === $title && ! empty( $nav['image'] ) ) {
|
|
$static .= ' ' . $class . '--figure';
|
|
}
|
|
|
|
return sprintf( '<span class="%s">%s</span>', esc_attr( $static ), $inner );
|
|
}
|
|
|
|
$classes = [ $class ];
|
|
if ( ! empty( $nav['layout'] ) ) {
|
|
$classes[] = $class . '--' . str_replace( '_', '-', (string) $nav['layout'] );
|
|
}
|
|
|
|
// A picture with nothing to label is the column's content, not a
|
|
// thumbnail beside a word, and the stylesheet sizes the two differently.
|
|
if ( '' === $title && ! empty( $nav['image'] ) ) {
|
|
$classes[] = $class . '--figure';
|
|
}
|
|
|
|
return sprintf(
|
|
'<a class="%s" href="%s"%s>%s</a>',
|
|
esc_attr( implode( ' ', $classes ) ),
|
|
esc_url( (string) $item->url ),
|
|
$item->target ? sprintf( ' target="%s" rel="noopener"', esc_attr( $item->target ) ) : '',
|
|
$inner
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The item's thumbnail, if it has one.
|
|
*
|
|
* @param array<string,mixed> $nav Item settings.
|
|
* @return string
|
|
*/
|
|
private function figure( array $nav ): string {
|
|
if ( empty( $nav['image'] ) ) {
|
|
return '';
|
|
}
|
|
|
|
$id = (int) $nav['image'];
|
|
if ( ! $id ) {
|
|
return '';
|
|
}
|
|
|
|
// `large`, not `medium`. Four of these are a column's whole content, and
|
|
// two of them are 1200 and 1380 pixels wide in the library — asking for
|
|
// `medium` handed the browser a 300px copy to stretch across a 730px
|
|
// column. `large` carries a srcset, so a 56px thumbnail still costs a
|
|
// small file.
|
|
return wp_get_attachment_image(
|
|
$id,
|
|
'large',
|
|
false,
|
|
[
|
|
'class' => 'site-nav__thumb',
|
|
'loading' => 'lazy',
|
|
'alt' => '',
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Raw HTML attached to the item.
|
|
*
|
|
* 56 items carry one. They are not arbitrary — across every language only
|
|
* five tags appear in them, and 14 of the 56 are the search form. They are
|
|
* kept as an escape hatch, not as a design tool, and the content is filtered
|
|
* so a menu cannot inject a stylesheet the way the Danish one does today.
|
|
*
|
|
* @param array<string,mixed> $nav Item settings.
|
|
* @return string
|
|
*/
|
|
private function block( array $nav ): string {
|
|
if ( empty( $nav['block'] ) ) {
|
|
return '';
|
|
}
|
|
|
|
$html = (string) $nav['block'];
|
|
|
|
if ( false !== strpos( $html, '[search-form]' ) ) {
|
|
return '<div class="site-nav__block">' . get_search_form( [ 'echo' => false ] ) . '</div>';
|
|
}
|
|
|
|
return '<div class="site-nav__block">' . wp_kses_post( do_shortcode( $html ) ) . '</div>';
|
|
}
|
|
|
|
/**
|
|
* A list of child pages, generated rather than hand-maintained.
|
|
*
|
|
* This is the one setting that makes the menu content-driven in the literal
|
|
* sense: publish a page under the named parent and it appears, in every
|
|
* language, without anyone editing a menu.
|
|
*
|
|
* @param array<string,mixed> $nav Item settings.
|
|
* @return string
|
|
*/
|
|
private function feed( array $nav ): string {
|
|
if ( empty( $nav['feed'] ) || ! is_array( $nav['feed'] ) ) {
|
|
return '';
|
|
}
|
|
|
|
$feed = $nav['feed'];
|
|
$parent = isset( $feed['parent'] ) ? (int) $feed['parent'] : 0;
|
|
|
|
if ( ! $parent ) {
|
|
return '';
|
|
}
|
|
|
|
$children = get_posts( [
|
|
'post_type' => 'page',
|
|
'post_parent' => $parent,
|
|
'posts_per_page' => isset( $feed['count'] ) ? (int) $feed['count'] : -1,
|
|
'orderby' => isset( $feed['orderby'] ) ? (string) $feed['orderby'] : 'title',
|
|
'order' => 'ASC',
|
|
'suppress_filters' => false,
|
|
] );
|
|
|
|
if ( ! $children ) {
|
|
return '';
|
|
}
|
|
|
|
$out = '<ul class="site-nav__links site-nav__links--feed">';
|
|
foreach ( $children as $child ) {
|
|
$out .= sprintf(
|
|
'<li class="site-nav__leaf"><a class="site-nav__link" href="%s"><span class="site-nav__label">%s</span></a></li>',
|
|
esc_url( (string) get_permalink( $child ) ),
|
|
esc_html( get_the_title( $child ) )
|
|
);
|
|
}
|
|
|
|
return $out . '</ul>';
|
|
}
|
|
}
|