*/
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' => '',
'book' => '',
'headphones' => '',
];
if ( ! isset( $icons[ $name ] ) ) {
return '';
}
return sprintf(
'',
$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(
'',
'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 . '
%3$s
',
'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> Settings of the open ancestors, by depth. */
private $stack = [];
/** @var array 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 $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
\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%s\n",
$indent,
esc_url( (string) $this->current->url ),
esc_html( trim( wp_strip_all_tags( $this->current->title ) ) )
);
}
$output .= sprintf( "%s\t
\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
\n%s
\n", $indent, $indent )
: sprintf( "%s\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 . '
'
. $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 .= "
\n";
}
/**
* A top-level item: the thing you see in the bar.
*
* @param WP_Post $item Menu item.
* @param array $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( '
', esc_attr( implode( ' ', $classes ) ) );
$out .= $this->link( $item, $nav, 'site-nav__top' );
if ( $panel ) {
$out .= sprintf(
'',
$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 $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( '
', $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( '
%s
', 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 $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( '%s', 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( '%s', 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(
'%s',
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 $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 $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 '
' . get_search_form( [ 'echo' => false ] ) . '
';
}
return '
' . wp_kses_post( do_shortcode( $html ) ) . '
';
}
/**
* 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 $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 = '