<nav id="example-nav--main--dark" class="cwf-nav cwf-nav--main cwf-nav--dark" aria-label="Main">
<button id="example-nav--main--dark__hamburger" aria-label="Open the main menu" class="cwf-nav__hamburger">
Menu
</button>
<div class="cwf-nav__container">
<div class="cwf-nav__controller">
<ul class="cwf-nav__list cwf-nav__list--level-1 ">
<li class="cwf-nav__item cwf-nav__item--has-dropdown ">
<a href="#" class="cwf-nav__link">
Parent
</a>
<button class="cwf-nav__toggle" aria-label="Parent dropdown" aria-expanded="false">
<i class="cwf-nav__icon" aria-hidden="true"></i>
</button>
<ul class="cwf-nav__list cwf-nav__list--level-2 ">
<li class="cwf-nav__item ">
<a href="#" class="cwf-nav__link">
Child 1
</a>
</li>
<li class="cwf-nav__item ">
<a href="#" class="cwf-nav__link">
Child 2
</a>
</li>
<li class="cwf-nav__item cwf-nav__item--has-dropdown ">
<a href="#" class="cwf-nav__link">
Child 3
</a>
<button class="cwf-nav__toggle" aria-label="Child 3 dropdown" aria-expanded="false">
<i class="cwf-nav__icon" aria-hidden="true"></i>
</button>
<ul class="cwf-nav__list cwf-nav__list--level-3 ">
<li class="cwf-nav__item ">
<a href="#" class="cwf-nav__link">
Grandchild 1
</a>
</li>
<li class="cwf-nav__item cwf-nav__item--is-current">
<a href="#" class="cwf-nav__link" aria-current="page">
Grandchild 2
</a>
</li>
<li class="cwf-nav__item ">
<a href="#" class="cwf-nav__link">
Grandchild 3
</a>
</li>
</ul>
</li>
</ul>
</li>
<li class="cwf-nav__item ">
<a href="#" class="cwf-nav__link">
Example
</a>
</li>
<li class="cwf-nav__item ">
<a href="#" class="cwf-nav__link">
Test
</a>
</li>
</ul>
</div>
</div>
</nav>
{% macro generate(title, routes, level, limit, class) %}
{% if routes|length %}
{% set listClasses = [
'cwf-nav__list',
'cwf-nav__list--level-' ~ level,
class
] %}
<ul class="{{ listClasses|join(' ') }}">
{% for route in routes %}
{% set hasDropdown = route.children
? 'cwf-nav__item--has-dropdown'
: ''
%}
{% set isCurrent = route.name == title
? 'cwf-nav__item--is-current'
: ''
%}
{% set classes = ['cwf-nav__item', hasDropdown, isCurrent] %}
{% set name = route.name.short ?? route.name.full ?? route.name
%}
<li class="{{ classes|join(' ') }}">
{% set href = route.href %}
<a href="{{ href }}"
class="cwf-nav__link"
{{ isCurrent ? ' aria-current="page"' }}>
{{ name }}
</a>
{% if
route.children is defined
and (route.children|length)
and level <= limit %}
<button class="cwf-nav__toggle"
aria-label="{{ name }} dropdown"
aria-expanded="false">
<i class="cwf-nav__icon" aria-hidden="true"></i>
</button>
{{
_self.generate(
title,
route.children,
level + 1,
limit
)
}}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endmacro %}
{% macro main(title, routes) %}
{% set filteredRoutes = [] %}
{% for route in routes %}
{% if route.level == 1 %}
{% set filteredRoutes = filteredRoutes|merge([route]) %}
{% endif %}
{% endfor %}
{{- _self.generate(title, filteredRoutes, 1, 2) -}}
{% endmacro %}
{% macro sub(title, routes, siblings) %}
{% set filteredRoutes = [] %}
{% set parentLink = '' %}
{% set break = false %}
{% for route in routes if not break %}
{% if route.name.full == title or route.name == title %}
{% set break = true %}
{% set parent = false %}
{% set hasParent = route.parents is defined
and (route.parents|length)
%}
{% if hasParent %}
{% set parent = route.parents|last %}
{% endif %}
{% set hasChildren = route.children is defined
and (route.children|length)
%}
{% set hasSiblings = parent
and parent.children is defined
and (parent.children|length)
and (parent.children|length) > 1
%}
{% if hasChildren %}
{% set filteredRoutes = route.children %}
{% elseif hasSiblings and siblings %}
{% set filteredRoutes = parent.children %}
{% endif %}
{% endif %}
{% endfor %}
{{- _self.generate(title, filteredRoutes, 2, 2, 'cwf-nav__list--scroll') -}}
{% endmacro %}
{% import _self as nav %}
{% set list %}
{% if type == 'main' %}
{{ nav.main(title, routes) }}
{% endif %} {% if type == 'sub' %}
{{ nav.sub(title, routes, siblings) }}
{% endif %}
{% endset %}
{% if list|trim|length %}
<nav id="{{ id ?? 'cwf-nav--' ~ type }}"
class="cwf-nav cwf-nav--{{ type }} cwf-nav--{{ theme }}"
aria-label="{{ type|capitalize }}">
{% if type == 'main' %}
<button id="{{
id is defined
? id ~ '__hamburger'
: 'cwf-nav__hamburger'
}}"
aria-label="Open the main menu"
class="cwf-nav__hamburger">
Menu
</button>
{% endif %}
<div class="cwf-nav__container">
<div class="cwf-nav__controller">
{{ list|trim }}
</div>
</div>
</nav>
{% endif %}
{
"id": "example-nav--main--dark",
"type": "main",
"theme": "dark",
"title": "Grandchild 2",
"routes": [
{
"level": 1,
"name": "Parent",
"href": "#",
"children": [
{
"name": "Child 1",
"href": "#"
},
{
"name": "Child 2",
"href": "#"
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"level": 2,
"name": "Child 1",
"href": "#",
"parents": [
{
"name": "Parent",
"href": "#",
"children": [
{
"name": "Child 1",
"href": "#"
},
{
"name": "Child 2",
"href": "#"
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
}
]
},
{
"level": 2,
"name": "Child 2",
"href": "#",
"parents": [
{
"name": "Parent",
"href": "#",
"children": [
{
"name": "Child 1",
"href": "#"
},
{
"name": "Child 2",
"href": "#"
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
}
]
},
{
"level": 2,
"name": "Child 3",
"href": "#",
"parents": [
{
"name": "Parent",
"href": "#",
"children": [
{
"name": "Child 1",
"href": "#"
},
{
"name": "Child 2",
"href": "#"
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
}
],
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
},
{
"level": 3,
"name": "Grandchild 1",
"href": "#",
"parents": [
{
"name": "Parent",
"href": "#",
"children": [
{
"name": "Child 1",
"href": "#"
},
{
"name": "Child 2",
"href": "#"
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"level": 3,
"name": "Grandchild 2",
"href": "#",
"parents": [
{
"name": "Parent",
"href": "#",
"children": [
{
"name": "Child 1",
"href": "#"
},
{
"name": "Child 2",
"href": "#"
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"level": 3,
"name": "Grandchild 3",
"href": "#",
"parents": [
{
"name": "Parent",
"href": "#",
"children": [
{
"name": "Child 1",
"href": "#"
},
{
"name": "Child 2",
"href": "#"
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"name": "Child 3",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"level": 1,
"name": "Example",
"href": "#"
},
{
"level": 1,
"name": "Test",
"href": "#"
}
]
}
// Navigation component styles
@use "../../shared/animation";
@use "../../shared/media";
@use "../../shared/style";
@use "../../shared/theme";
@use "sass:map";
// Selector prefix
$prefix: "cwf" !default;
.#{$prefix}-nav {
line-height: 1.2;
font-family: theme.font--sans-serif();
@include media.breakpoint {
background-color: var(--cwf-nav--background-color);
}
}
// Nav dark colors
$hamburger__color--dark: style.color("white") !default;
$background-color--dark: style.darken("gray-dark", 33%) !default; // #222
$list--level-2__background-color--dark: style.color("gray-dark") !default;
$list--level-3__background-color--dark: style.color("gray") !default;
$color--dark: style.color("white") !default;
$color--active--dark: style.color("black") !default;
$border-color--dark: style.color("gray-light") !default;
$color--accent--dark: style.color("gold") !default;
$controller__background-color--dark: style.color("black") !default;
// Nav light colors
$hamburger__color--light: style.color("black") !default;
$background-color--light: style.darken(
"white-dark",
3.125%
) !default; // #f0f0f0
$list--level-2__background-color--light: style.color("white-dark") !default;
$list--level-3__background-color--light: style.color("white") !default;
$color--light: style.darken("gray-lightest", 6.5%) !default; // #6d6d6d
$color--active--light: theme.accent--foreground() !default;
$border-color--light: style.lighten("white-darkest", 33%) !default; // #ddd
$color--accent--light: theme.accent--background() !default;
$controller__background-color--light: style.color("white") !default;
// Nav themes
$themes: (
"dark": (
"hamburger-mobile-foreground-color": $hamburger__color--dark,
"background-color": $background-color--dark,
"background-color-level-2": $list--level-2__background-color--dark,
"background-color-level-3": $list--level-3__background-color--dark,
"foreground-color": $color--dark,
"active-foreground-color": $color--active--dark,
"border-color": $border-color--dark,
"accent-color": $color--accent--dark,
"controller-background-color": $controller__background-color--dark
),
"light": (
"hamburger-mobile-foreground-color": $hamburger__color--light,
"background-color": $background-color--light,
"background-color-level-2": $list--level-2__background-color--light,
"background-color-level-3": $list--level-3__background-color--light,
"foreground-color": $color--light,
"active-foreground-color": $color--active--light,
"border-color": $border-color--light,
"accent-color": $color--accent--light,
"controller-background-color": $controller__background-color--light
)
);
$theme--keys: "hamburger-mobile-foreground-color", "background-color",
"background-color-level-2", "background-color-level-3", "foreground-color",
"active-foreground-color", "border-color", "accent-color",
"controller-background-color", "theme";
@function theme--validate($instructions) {
@return map.keys($instructions) == $theme--keys;
}
@mixin theme($instructions) {
@if theme--validate($instructions) {
$theme: map.get($instructions, "theme");
$hamburger-foreground-color: map.get(
$instructions,
"hamburger-mobile-foreground-color"
);
$background-color: map.get($instructions, "background-color");
$background-color-level-2: map.get(
$instructions,
"background-color-level-2"
);
$background-color-level-3: map.get(
$instructions,
"background-color-level-3"
);
$foreground-color: map.get($instructions, "foreground-color");
$active-foreground-color: map.get(
$instructions,
"active-foreground-color"
);
$border-color: map.get($instructions, "border-color");
$accent-color: map.get($instructions, "accent-color");
$controller-background-color: map.get(
$instructions,
"controller-background-color"
);
.#{$prefix}-nav--#{$theme} {
--cwf-nav__hamburger--mobile-foreground-color: #{$hamburger-foreground-color};
--cwf-nav--background-color: #{$background-color};
--cwf-nav--background-color--level-2: #{$background-color-level-2};
--cwf-nav--background-color--level-3: #{$background-color-level-3};
--cwf-nav--foreground-color: #{$foreground-color};
--cwf-nav--active--foreground-color: #{$active-foreground-color};
--cwf-nav--border-color: #{$border-color};
--cwf-nav--accent-color: #{$accent-color};
--cwf-nav__controller--background-color: #{$controller-background-color};
}
} @else {
@warn "Invalid navigation themes provided!";
}
}
@mixin theme--official($theme) {
$colors: map.get($themes, $theme);
$instructions: map.merge(
$colors,
(
"theme": $theme
)
);
@include theme($instructions);
}
@each $theme, $colors in $themes {
@include theme--official($theme);
}
$container__background-color: style.opacity("black", 68%) !default;
$container__background-color--reduced-transparency: style.color(
"black"
) !default;
.#{$prefix}-nav__container {
display: none;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: var(--cwf-nav__container--background-color);
@include style.z-index("modal");
--cwf-nav__container--background-color: #{$container__background-color};
@include media.reduced(transparency) {
--cwf-nav__container--background-color: #{$container__background-color--reduced-transparency};
}
@include media.reduced(transparency, no-preference) {
--cwf-nav__container--background-color: #{$container__background-color};
}
@include media.breakpoint {
display: block;
position: static;
background-color: unset;
@include theme.contain;
}
}
.#{$prefix}-nav__controller {
height: 100%;
background-color: var(--cwf-nav__controller--background-color);
}
.#{$prefix}-nav--main .#{$prefix}-nav__controller {
width: 70%;
@include media.breakpoint {
width: 100%;
}
}
.#{$prefix}-nav--modal:is(.#{$prefix}-nav--main) .#{$prefix}-nav__controller {
padding-top: 64px;
}
.#{$prefix}-nav--modal:is(.#{$prefix}-nav--main) .#{$prefix}-nav__controller {
@include media.breakpoint {
padding-top: 0;
}
}
.#{$prefix}-nav__item {
display: flex;
flex-wrap: wrap;
position: relative;
}
.#{$prefix}-nav__link {
flex: 1;
display: flex;
align-items: center;
font-weight: 500;
overflow: hidden;
padding: 1rem 1.75rem;
position: relative;
background-color: transparent;
text-align: left;
text-decoration: none;
color: var(--cwf-nav--foreground-color);
&:hover,
&:focus {
background-color: var(--cwf-nav--accent-color);
color: var(--cwf-nav--active--foreground-color);
.#{$prefix}-nav--main & {
@include media.breakpoint {
& + .#{$prefix}-nav__toggle {
color: var(--cwf-nav--active--foreground-color);
}
}
}
}
}
.#{$prefix}-nav__list {
display: none;
list-style-type: none;
width: 100%;
margin: 0;
padding: 0;
border: 1px solid var(--cwf-nav--border-color);
& .#{$prefix}-nav__list {
border: none;
border-top: 1px solid var(--cwf-nav--border-color);
}
}
$list--scroll__background--fallback: transparent !default;
$list--scroll__background: style.opacity("black", 25%) !default;
@mixin nav__list--scroll-shadow($side) {
background: var(--cwf-nav__list--scroll--background-fallback);
background: radial-gradient(
ellipse farthest-corner at $side center,
var(--cwf-nav__list--scroll--background) 0%,
transparent 75%
);
@include media.reduced(transparency) {
background: var(--cwf-nav__list--scroll--background-fallback);
}
@include media.reduced(transparency, no-preference) {
background: radial-gradient(
ellipse farthest-corner at $side center,
var(--cwf-nav__list--scroll--background) 0%,
transparent 75%
);
}
}
.#{$prefix}-nav__list--scroll {
position: relative;
max-height: 551px;
overflow-y: auto;
--cwf-nav__list--scroll--background-fallback: #{$list--scroll__background--fallback};
--cwf-nav__list--scroll--background: #{$list--scroll__background};
--cwf-nav__list--scroll-top: 0;
--cwf-nav__list--scroll-bottom: 0;
&:before {
top: 0;
margin-bottom: -0.5rem;
@include nav__list--scroll-shadow(top);
opacity: var(--cwf-nav__list--scroll-top);
}
&:before,
&:after {
content: "";
position: sticky;
display: block;
width: 100%;
height: 0.5rem;
pointer-events: none;
@include style.z-index("content", "middle");
}
&:after {
bottom: 0;
margin-top: -0.5rem;
@include nav__list--scroll-shadow(bottom);
opacity: var(--cwf-nav__list--scroll-bottom);
}
}
.#{$prefix}-nav--main .#{$prefix}-nav__list {
@include style.z-index("menu", "middle");
border: none;
}
.#{$prefix}-nav__item--is-expanded > .#{$prefix}-nav__list,
.#{$prefix}-nav__controller > .#{$prefix}-nav__list {
display: block;
}
.#{$prefix}-nav__list--level-1 {
background-color: var(--cwf-nav--background-color);
margin: 0;
width: 100%;
@include style.z-index("page");
}
.#{$prefix}-nav--modal:is(.#{$prefix}-nav--main)
.#{$prefix}-nav__list--level-1 {
height: 100%;
overflow-y: auto;
}
$list--level-1__link__border-color--main: transparent !default;
$list--level-1__link__border-color--active--main: style.opacity(
"black",
15%
) !default;
.#{$prefix}-nav--main .#{$prefix}-nav__list--level-1 {
@include media.breakpoint {
display: flex;
flex-direction: row;
& > .#{$prefix}-nav__item > .#{$prefix}-nav__link {
padding-top: calc(1rem - 1px);
border-top: 1px solid
var(--cwf-nav--main__list--level-1__link--border-color);
--cwf-nav--main__list--level-1__link--border-color: #{$list--level-1__link__border-color--main};
--cwf-nav--main__list--level-1__link--active--border-color: #{$list--level-1__link__border-color--active--main};
&:hover,
&:focus {
border-color: var(
--cwf-nav--main__list--level-1__link--active--border-color
);
}
}
}
}
.#{$prefix}-nav--sub .#{$prefix}-nav__list--level-2 {
@include style.z-index("content");
}
// make room for the dropdown arrows on level 1
.#{$prefix}-nav__list--level-1
> .#{$prefix}-nav__item--has-dropdown
> .#{$prefix}-nav__link,
.#{$prefix}-nav__list--level-2
> .#{$prefix}-nav__item--has-dropdown
> .#{$prefix}-nav__link {
padding-right: 3rem;
}
.#{$prefix}-nav--sub
.#{$prefix}-nav__list--level-1
> .#{$prefix}-nav__item
> .#{$prefix}-nav__link,
.#{$prefix}-nav--sub
.#{$prefix}-nav__list--level-1
> .#{$prefix}-nav__item
> .#{$prefix}-nav__toggle {
display: none;
}
.#{$prefix}-nav__item {
border-bottom: 1px solid var(--cwf-nav--border-color);
&:last-child {
border-bottom: none;
}
}
.#{$prefix}-nav--main .#{$prefix}-nav__list--level-1 > .#{$prefix}-nav__item {
@include media.breakpoint {
border-bottom: none;
border-right: 1px solid var(--cwf-nav--border-color);
}
}
.#{$prefix}-nav--sub .#{$prefix}-nav__list--level-1 > .#{$prefix}-nav__item {
@include media.breakpoint {
border-right: none;
}
}
.#{$prefix}-nav--sub
.#{$prefix}-nav__list--level-1
> .#{$prefix}-nav__item:first-of-type {
@include media.breakpoint {
border-top: none;
}
}
.#{$prefix}-nav--main
.#{$prefix}-nav__list--level-1
> .#{$prefix}-nav__item:last-of-type {
@include media.breakpoint {
border-right: none;
}
}
.#{$prefix}-nav__list--level-3 > .#{$prefix}-nav__item > .#{$prefix}-nav__link {
padding-left: 2.5rem;
}
.#{$prefix}-nav--main
.#{$prefix}-nav__list--level-3
> .#{$prefix}-nav__item
> .#{$prefix}-nav__link {
@include media.breakpoint {
padding-left: 1.75rem;
}
}
.#{$prefix}-nav__list--level-2 {
padding: 0;
background-color: var(--cwf-nav--background-color--level-2);
}
.#{$prefix}-nav--main .#{$prefix}-nav__list--level-2 {
@include media.breakpoint {
top: 100%;
}
}
.#{$prefix}-nav--main .#{$prefix}-nav__list--level-2,
.#{$prefix}-nav--main .#{$prefix}-nav__list--level-3 {
@include media.breakpoint {
position: absolute;
min-width: 14rem;
}
}
.#{$prefix}-nav__list--level-3 {
padding: 0;
background-color: var(--cwf-nav--background-color--level-3);
}
.#{$prefix}-nav--main .#{$prefix}-nav__list--level-3 {
@include media.breakpoint {
left: 100%;
top: 0;
}
}
$toggle__background-color: transparent !default;
.#{$prefix}-nav__toggle {
width: 3rem;
min-height: 3rem;
padding: 0;
border: none;
background-color: var(--cwf-nav__toggle--background-color);
font-size: 1rem;
color: var(--cwf-nav--foreground-color);
--cwf-nav__toggle--background-color: #{$toggle__background-color};
@include style.cursor;
&:hover,
&:focus {
background-color: var(--cwf-nav--accent-color);
}
@include media.touch {
position: relative;
&:before {
content: "";
position: absolute;
left: 0;
top: 10%;
width: 1px;
height: 80%;
background-color: var(--cwf-nav--border-color);
}
&:focus:before {
display: none;
}
}
}
.#{$prefix}-nav--dark .#{$prefix}-nav__toggle {
&:hover,
&:focus {
color: var(--cwf-nav--background-color);
}
}
.#{$prefix}-nav--light .#{$prefix}-nav__toggle {
&:hover,
&:focus {
color: var(--cwf-nav--background-color--level-3);
}
}
.#{$prefix}-nav--main .#{$prefix}-nav__toggle {
@include media.breakpoint {
position: absolute;
height: 100%;
top: 0;
right: 0;
padding: 0;
pointer-events: none;
@include media.touch {
&:before {
display: none;
}
}
}
}
$icon: "\f078" !default; // Chevron down
.#{$prefix}-nav__icon {
display: inline-block;
@include style.icon;
@include animation.transition(transform);
&:before {
content: $icon;
}
.#{$prefix}-nav__toggle[aria-expanded="true"] & {
@include animation.flip;
}
}
$list--level-2__toggle__icon--main: "\f054" !default; // Chevron right
.#{$prefix}-nav--main
.#{$prefix}-nav__list--level-2
.#{$prefix}-nav__toggle
.#{$prefix}-nav__icon:before {
@include media.breakpoint {
content: $list--level-2__toggle__icon--main;
}
}
$hamburger__background-color: transparent !default;
$hamburger__color: style.darken("gray-dark", 0.75%) !default; // #313131
$hamburger__background-color--active: style.opacity("black", 5%) !default;
$hamburger__color--active: style.color("black") !default;
.#{$prefix}-nav__hamburger {
align-items: center;
background-color: var(--cwf-nav__hamburger--background-color);
border: none;
color: var(--cwf-nav__hamburger--foreground-color);
display: flex;
flex-direction: column;
font-size: 0.65rem;
font-weight: 700;
height: 64px;
justify-content: space-evenly;
left: 0;
min-width: 48px;
padding: 0.5rem 0;
position: absolute;
top: 0;
@include style.z-index("modal", "middle");
--cwf-nav__hamburger--background-color: #{$hamburger__background-color};
--cwf-nav__hamburger--foreground-color: #{$hamburger__color};
--cwf-nav__hamburger--active--background-color: #{$hamburger__background-color--active};
--cwf-nav__hamburger--active--foreground-color: #{$hamburger__color--active};
&:hover,
&:focus {
background-color: var(--cwf-nav__hamburger--active--background-color);
color: var(--cwf-nav__hamburger--active--foreground-color);
}
.#{$prefix}-nav--main & {
@include media.breakpoint {
display: none;
}
}
.#{$prefix}-nav--modal & {
left: 70%;
right: 0;
flex-direction: row-reverse;
width: 0;
min-width: 128px;
padding: 0;
font-size: 1rem;
color: var(--cwf-nav__hamburger--mobile-foreground-color);
transform: translateX(-100%);
}
}
$hamburger__icon--expand: "\f0c9" !default; // Bars
$hamburger__icon--collapse: "\f00d" !default; // Times
.#{$prefix}-nav__hamburger:before {
@include style.icon($hamburger__icon--expand);
font-size: 1.25rem;
.#{$prefix}-nav--modal & {
content: $hamburger__icon--collapse;
font-size: 1.5rem;
}
}
// Navigation utilities
.#{$prefix}-nav--modal:is(.#{$prefix}-nav--main) .#{$prefix}-nav__container {
display: block;
}
.#{$prefix}-nav__item > .#{$prefix}-nav__link:before {
content: "";
display: none;
position: absolute;
left: 0;
top: 0;
width: 0.5rem;
height: 100%;
background-color: var(--cwf-nav--accent-color);
}
.#{$prefix}-nav__item--is-current > .#{$prefix}-nav__link:before {
display: block;
}
.#{$prefix}-nav--main
.#{$prefix}-nav__list--level-1
> .#{$prefix}-nav__item.#{$prefix}-nav__item--is-current
> .#{$prefix}-nav__link:before {
@include media.breakpoint {
top: unset;
bottom: 0;
width: 100%;
height: 0.25rem;
}
}
.#{$prefix}-nav--main
.#{$prefix}-nav__list--level-3
> .#{$prefix}-nav__item.#{$prefix}-nav__item--is-current
> .#{$prefix}-nav__link:before {
@include media.breakpoint {
left: unset;
right: 0;
}
}
.#{$prefix}-nav__item.#{$prefix}-nav__item--is-parent-of-current
> .#{$prefix}-nav__link:before {
opacity: 0.32;
}
.#{$prefix}-nav--main
.#{$prefix}-nav__list--level-1
.#{$prefix}-nav__item.#{$prefix}-nav__item--is-parent-of-current
> .#{$prefix}-nav__link:before {
@include media.breakpoint {
opacity: 0.64;
}
}
// The default component class
import { Component } from '../../shared/component.js';
// Lock/unlock document scrolling
import { toggleScrollLock } from '../../shared/event.js';
// Provide functionality to all navs
export class Nav extends Component {
constructor({
// Desktop breakpoint
desktopBreakpoint = 1024,
// Prefix
prefix = 'cwf',
// Selectors
container = 'nav__container',
controller = 'nav__controller',
hamburger = 'nav__hamburger',
iconRotate = 'nav__icon--rotate',
item = 'nav__item',
itemHasDropdown = 'nav__item--has-dropdown',
itemIsCurrent = 'nav__item--is-current',
itemIsExpanded = 'nav__item--is-expanded',
itemIsParentOfCurrent = 'nav__item--is-parent-of-current',
link = 'nav__link',
list = 'nav__list',
listLevel = 'nav__list--level-',
nav = 'nav',
scrollable = 'nav__list--scroll',
modal = 'nav--modal',
subNav = 'nav--sub',
toggle = 'nav__toggle',
// Properties
listScrollTop = '--cwf-nav__list--scroll-top',
listScrollBottom = '--cwf-nav__list--scroll-bottom'
} = {}) {
super({
prefix,
classes: {
container,
controller,
hamburger,
iconRotate,
item,
itemHasDropdown,
itemIsCurrent,
itemIsExpanded,
itemIsParentOfCurrent,
link,
list,
listLevel,
nav,
scrollable,
modal,
subNav,
toggle
}
});
// Set the desktop breakpoint,...
this.desktopBreakpoint = desktopBreakpoint;
// ... list level regular expression,...
this.classes.listLevel = new RegExp(this.classes.listLevel + '?(\\d)');
// ... and properties
this.properties = {
listScrollTop,
listScrollBottom
};
// Bind this to all event methods that need the class context
this.normalizeReferences = this.normalizeReferences.bind(this);
this.keyboardNavigation = this.keyboardNavigation.bind(this);
this.toggleDropdownMenu = this.toggleDropdownMenu.bind(this);
this.toggleMainMenu = this.toggleMainMenu.bind(this);
// Register the toggle scroll lock function to this component
this.toggleScrollLock = toggleScrollLock.bind(this);
}
// Normalize a nav reference
normalizeReferences(nav) {
// Initialize a reference object...
let reference = {};
// ... and store the nav as the element
reference.element = nav;
// Store whether this nav is a sub nav,...
reference.isSubNav = nav.classList.contains(this.classes.subNav);
// ... its lists,...
reference.lists = Array.from(nav.querySelectorAll(this.selectors.list));
// ... items,...
reference.items = Array.from(nav.querySelectorAll(this.selectors.item));
// ... toggles,...
reference.toggles = Array.from(
nav.querySelectorAll(this.selectors.toggle)
);
// ... and links
reference.links = Array.from(nav.querySelectorAll(this.selectors.link));
// If the nav is a sub nav, return the reference object as is
if (reference.isSubNav) return reference;
// Otherwise, also store its hamburger button,...
reference.hamburger = nav.querySelector(this.selectors.hamburger);
// ... container,...
reference.container = nav.querySelector(this.selectors.container);
// ... and controller
reference.controller = nav.querySelector(this.selectors.controller);
// Finally, return the reference object
return reference;
}
// Get all references to the navs and their lists/items
getReferences() {
// Attempt to grab all navs from the page...
const navs = Array.from(document.querySelectorAll(this.selectors.nav));
// ... and if none exist, do nothing else
if (!navs) return;
// Otherwise, normalize/store the references to these navs
this.references = navs.map(this.normalizeReferences);
}
// Get an item's toggle
getToggle(item) {
return Array.from(item.children).find((child) =>
child.matches(this.selectors.toggle)
);
}
// Add the `--has-dropdown` item class to any drop-down items without it
indicateHasDropdown(item) {
// If the item already has the `--has-dropdown` item class, return the item
if (item.classList.contains(this.classes.itemHasDropdown)) return item;
// Attempt to grab the item's toggle...
const toggle = this.getToggle(item);
// ... and if none exist, return the item
if (!toggle) return item;
// Othwerwise, add the `--has-dropdown` item class to the item...
item.classList.add(this.classes.itemHasDropdown);
// ... and return it
return item;
}
// Attempt to find/indicate the current item of a nav
findCurrentItem(items) {
// Grab the full/relative path of the current page...
const { href, pathname } = window.location,
// ... and try to find an item with a link with an HREF that matches
currentItem = items.find((item) => {
// Attempt to grab the item's link...
const link = item.querySelector(this.selectors.link);
// ... and if it doesn't exist...
if (!link) return false;
// ... or its href doesn't match the full/relative path, return false
if (link.href !== href && link.href !== pathname) return false;
// Otherwise, return true
return true;
});
// If no item was found, return undefined
if (!currentItem) return undefined;
// Grab the current item's link
const currentLink = currentItem.querySelector(this.selectors.link);
// Otherwise, add the `--is-current` class to it,...
currentItem.classList.add(this.classes.itemIsCurrent);
// ... indicate via ARIA that this its link goes to the current page,...
currentLink.setAttribute('aria-current', 'page');
// ... and return it
return currentItem;
}
// Find the parent items of the current item
findParentsOfCurrent(item) {
// Get the parent item of the current item...
let parent = item.parentNode.parentNode;
// ... and initialize a parents array
let parents = [];
// While there's a parent item that's an <li> and not a nav controller,...
while (
parent &&
parent.tagName === 'LI' &&
!parent.classList.contains(this.classes.controller)
) {
// ... push it to the parents array...
parents.push(parent);
// ... and recurse up to the next parent item
parent = parent.parentNode.parentNode;
}
// Finally, return all parents
return parents;
}
// Indicate the current item tree
indicateCurrentItemTree(currentItem, isSubNav) {
// Find all parent items of the current item...
const parents = this.findParentsOfCurrent(currentItem);
// ... and for each,...
parents.forEach((parent) => {
// ... add the current and parent-of-current classes to it
parent.classList.add(
this.classes.itemIsCurrent,
this.classes.itemIsParentOfCurrent
);
});
// If the current navigation is a main-nav and the viewport is not at desktop size, or it's a sub nav, do nothing else
if (!isSubNav && !(window.innerWidth < this.desktopBreakpoint)) return;
// Create an array of current items from the current item and its parents...
const currentItems = [currentItem, ...parents];
// ... and for each,...
currentItems.forEach((item) => {
// ... expand it
item.classList.add(this.classes.itemIsExpanded);
// Next, attempt to find its toggle...
const toggle = this.getToggle(item);
// ... and if it doesn't exist...
if (!toggle) return;
// ... or it's not visible, do nothing else
if (window.getComputedStyle(toggle).display !== 'block') return;
// Otherwise, rotate its icon
this.toggleExpanded(toggle, true);
});
}
// Indicate the current items
indicateCurrentItems({ items, isSubNav }) {
// Either store or find the current item...
const currentItem =
items.find((item) => {
return item.classList.contains(this.classes.itemIsCurrent);
}) || this.findCurrentItem(items);
// ... and if one was stored, indicate its current tree
if (currentItem) this.indicateCurrentItemTree(currentItem, isSubNav);
}
// Set the toggle's "aria-expanded" attribute
toggleExpanded(toggle, value) {
// If there's no toggle, do nothing else...
if (!toggle) return;
// ... otherwise, set its "aria-expanded" attribute to the provided value
toggle.setAttribute('aria-expanded', value);
}
// Collapse/expand dropdown menus
toggleDropdownMenu({ type, currentTarget }, expand) {
// Check whether the current target is a toggle...
const isItem = currentTarget.classList.contains(this.classes.item);
// ... and store the item...
const item = isItem ? currentTarget : currentTarget.parentElement;
// ... and the toggle acocordingly
const toggle = this.getToggle(item);
// Grab the desktop breakpoint
const { desktopBreakpoint } = this;
// ... and if the window isn't at desktop size when triggered by hovering, do nothing else
if (
['mouseenter', 'mouseleave'].includes(type) &&
!(window.innerWidth > desktopBreakpoint)
)
return;
// Initialize a state and focus variable...
let state, focus;
switch (type) {
case 'mouseenter':
// ... and for 'mouseeneter' events, set state to true and focus to false,...
state = true;
focus = false;
break;
case 'mouseleave':
// ... for 'mouseleave' events, set both to false,...
state = false;
focus = false;
break;
default:
// ... otherwise, set both to the optional expand variable
state = expand;
focus = expand;
}
// Check whether the item is expanded or not...
const expanded = item.classList.contains(this.classes.itemIsExpanded);
// ... and whether to force it open/close or toggle it
const value = typeof state === 'boolean' ? state : !expanded;
// Toggle the item's expand class...
item.classList.toggle(this.classes.itemIsExpanded, value);
// ... and the toggle's "aria-expanded" attribute
this.toggleExpanded(toggle, value);
// If nothing is to be focused, do nothing else
if (!focus) return;
// Grab the list and its first link from the current target...
const list = item.querySelector(this.selectors.list);
const firstLink = list.querySelector(this.selectors.link);
// ... and focus it
firstLink.focus();
}
// Focus the previous/next link (with options to collapse the to-be-focused link's parent item)
focusLink(direction, links, from, collapse = false) {
// Store the first/last links,...
const firstLink = links[0],
lastLink = links[links.length - 1];
// If the direction is "first", focus the first link...
if (direction === 'first') return firstLink.focus();
// ... and if the direction is "last", focus the last link
if (direction === 'last') return lastLink.focus();
// Check whether the from element is an item...
const isItem = from.classList.contains(this.classes.item),
// ... and get the current link accordingly,...
current = isItem ? from.querySelector(this.selectors.link) : from,
// ... and get the index of the current link
index = links.indexOf(current);
// Initialize storage for a link to compare the current link to,...
let compare,
// ... a link to wrap to,,,
wrap,
// ... and the index of the sibling link to move to
sibling;
// Dependending on the direction provided...
switch (direction) {
case 'previous':
// Previous = if the current link is the first link, wrap to the last link, otherwise move to the previous sibling link
compare = firstLink;
wrap = lastLink;
sibling = index - 1;
break;
case 'next':
// Next = if the current link is the last link, wrap to the first link, otherwise move to the next sibling link
compare = lastLink;
wrap = firstLink;
sibling = index + 1;
}
// ... change what link should be focused
const focusable = current === compare ? wrap : links[sibling];
// If collapse has been enabled,...
if (collapse) {
// ... get the parent item of the link to be focused...
const item = focusable.parentElement;
// ... and collapse it if open
this.toggleDropdownMenu({ currentTarget: item }, false);
}
// Finally, focus the appropriate link
return focusable.focus();
}
// Focus an item's element
focusItemElement(element, item) {
// Grab the item element...
const target = item.querySelector(this.selectors[element]);
// ... and focus it
target.focus();
}
// Provide advanced keyboard navigation
keyboardNavigation(event) {
// Pull the type and current target from the event
const { type, currentTarget } = event;
// Either add/remove a keydown event listener when the current target is focused/blurred respectively...
switch (type) {
case 'focus':
currentTarget.addEventListener(
'keydown',
this.keyboardNavigation
);
break;
case 'blur':
currentTarget.removeEventListener(
'keydown',
this.keyboardNavigation
);
}
// ... and if the event is not a keydown, do nothing else
if (type !== 'keydown') return;
// Pull the key from the event...
const { key } = event;
// ... and if it's tab...
if (key === 'Tab') return;
// ... or it's not an arrow key/home/end/escape,...
if (
![
'ArrowLeft',
'ArrowUp',
'ArrowRight',
'ArrowDown',
'Home',
'End',
'Escape'
].includes(key)
)
return;
// ... or if there's an outstanding active composition, do nothing else
if (event.isComposing) return;
// Prevent the default behavior
event.preventDefault();
// Store whether the current target is a toggle or not
const isToggle = currentTarget.classList.contains(this.classes.toggle);
// Get the links and sub nav check of the nav containing the current target
const { links, isSubNav } = this.references.find((nav) => {
// If the current target is not a toggle, find a nav with links containing it...
if (!isToggle) return nav.links.includes(currentTarget);
// ... otherwise, find a nav with toggles containing it
return nav.toggles.includes(currentTarget);
});
// Store all visible, focusable links,...
const visibleLinks = links.filter((link) => link.offsetParent !== null),
// ... the current target's parent (item),...
item = currentTarget.parentElement,
// ... if the item has a dropdown,...
hasDropdown = item.classList.contains(this.classes.itemHasDropdown),
// ... if the item is expanded,...
isExpanded = item.classList.contains(this.classes.itemIsExpanded),
// ... the item's parent (list),...
list = item.parentElement,
// ... the list's level,..
levelMatch = list.className.match(this.classes.listLevel),
level = levelMatch && levelMatch.length ? Number(levelMatch[1]) : 0,
// ... if the list is not the first level within the nav,...
isNotTopLevel = isSubNav ? level !== 2 : level !== 1,
// ... and store focusable links within the same list
sameListLinks = links.filter(
(link) => link.parentElement.parentElement === list
);
// Main navigation on desktop
if (!isSubNav && !(window.innerWidth < this.desktopBreakpoint)) {
// Level 1
if (level === 1) {
// Toggles
if (isToggle) {
switch (key) {
case 'ArrowLeft':
// Level 1, arrow left = focus the current item's link
return this.focusItemElement('link', item);
case 'ArrowUp':
// Level 1, arrow up = collapse the dropdown
return this.toggleDropdownMenu(
{ currentTarget },
false
);
case 'ArrowRight':
// Level 1, arrow right = focus the next same list link from the current item's link
return this.focusLink(
'next',
sameListLinks,
item,
true
);
}
}
switch (key) {
case 'ArrowLeft':
// Level 1, arrow left = focus previous link within the same list
return this.focusLink(
'previous',
sameListLinks,
currentTarget
);
case 'ArrowUp':
// Level 1, arrow up = nothing
return;
case 'ArrowRight':
// Level 1, arrow right = focus next link within the same list
return this.focusLink(
'next',
sameListLinks,
currentTarget
);
}
// Level 1, arrow down
if (key === 'ArrowDown') {
// Level 1, arrow down, has dropdown = expand dropdown and focus first link
if (hasDropdown)
return this.toggleDropdownMenu({ currentTarget }, true);
// Level 1, arrow down = nothing
return;
}
}
// Level 2
if (level === 2) {
// Toggles
if (isToggle) {
switch (key) {
case 'ArrowLeft':
// Level 2, arrow left = focus the current item's link
return this.focusItemElement('link', item);
case 'ArrowUp':
// Level 2, arrow up = focus previous link within the same list from the current item's link
return this.focusLink(
'previous',
sameListLinks,
item
);
case 'ArrowRight':
// Level 2, arrow right = open the dropdown
return this.toggleDropdownMenu(
{ currentTarget },
true
);
case 'ArrowDown':
// Level 2, arrow down = focus next link within the same list from current item's link
return this.focusLink('next', sameListLinks, item);
}
}
// Level 2, arrow right, has dropdown = expand dropdown and focus first link
if (key === 'ArrowRight' && hasDropdown)
return this.toggleDropdownMenu({ currentTarget }, true);
// Level 2, arrow up, is first link of same list = focus previous visible link and collapse dropdown
if (key === 'ArrowUp' && currentTarget === sameListLinks[0])
return this.focusLink(
'previous',
visibleLinks,
currentTarget,
true
);
}
// Level 3, arrow left = focus previous visible link from first link of the same list and collapse dropdown
if (level === 3 && key === 'ArrowLeft')
return this.focusLink(
'previous',
visibleLinks,
sameListLinks[0],
true
);
// Defaults
switch (key) {
case 'ArrowUp':
// Arrow up = focus previous link within the same list
return this.focusLink(
'previous',
sameListLinks,
currentTarget
);
case 'ArrowDown':
// Arrow down = focus next link within the same list
return this.focusLink('next', sameListLinks, currentTarget);
case 'Home':
// Home = focus first link within the same list
return this.focusLink('first', sameListLinks);
case 'End':
// End = focus last link within the same list
return this.focusLink('last', sameListLinks);
}
}
// All toggles
if (isToggle) {
// Arrow up
if (key === 'ArrowUp') {
// Arrow up, item is expanded = collapse the dropdown
if (isExpanded)
return this.toggleDropdownMenu({ currentTarget }, false);
// Arrow up = focus the previous visible link from the current item's link
return this.focusLink('previous', visibleLinks, item, true);
}
// Defaults
switch (key) {
case 'ArrowLeft':
// Arrow left = focus the current item's link
return this.focusItemElement('link', item);
case 'ArrowDown':
// Arrow down = expand the dropdown
return this.toggleDropdownMenu({ currentTarget }, true);
}
}
// All navigation
switch (key) {
case 'ArrowUp':
// Arrow up = focus previous visible link
return this.focusLink('previous', visibleLinks, currentTarget);
case 'ArrowDown':
// Arrow down = focus next visible link
return this.focusLink('next', visibleLinks, currentTarget);
case 'Home':
// Home = focus first visible link
return this.focusLink('first', visibleLinks);
case 'End':
// End = focus last visible link
return this.focusLink('last', visibleLinks);
}
// Not top level list, escape = focus previous visible link from first link of same list and collapse
if (isNotTopLevel && key === 'Escape')
return this.focusLink(
'previous',
visibleLinks,
sameListLinks[0],
true
);
// Arrow right, has dropdown = focus the item's toggle
if (key === 'ArrowRight' && hasDropdown)
return this.focusItemElement('toggle', item);
}
// Bind to every nav's toggle's click event
togglesOnClickFocusBlur({ toggles }) {
// If it has none, do nothing else
if (!toggles.length) return;
// Otherwiser, for each toggle,...
toggles.forEach((toggle) => {
// ... toggle its sub menu when clicked, and provide advanced keyboard navigation when focused/blurred
toggle[this.listener]('click', this.toggleDropdownMenu);
toggle[this.listener]('focus', this.keyboardNavigation);
toggle[this.listener]('blur', this.keyboardNavigation);
});
}
// Bind to every link's focus/blur events
linksOnFocusBlur({ links }) {
// If no links exist, do nothing else
if (!links.length) return;
// Otherwise, for each link,...
links.forEach((link) => {
// ... provide advanced keyboard nevigation when focused (and remove it when blurred)
link[this.listener]('focus', this.keyboardNavigation);
link[this.listener]('blur', this.keyboardNavigation);
});
}
// Bind to every main nav's item's mouse enter/leave event if they have dropdowns
itemsWithDropdownsOnHover({ items }) {
// If no items exist, do nothing else
if (!items.length) return;
// Otherwise, filter the items that have dropdowns...
items
.filter((item) =>
item.classList.contains(this.classes.itemHasDropdown)
)
.forEach((item) => {
// ... and toggle its dropdown menu on mouse enter/leave
item[this.listener]('mouseenter', this.toggleDropdownMenu);
item[this.listener]('mouseleave', this.toggleDropdownMenu);
});
}
// Start or stop core functionality
allFunctionality() {
// For each nav,...
this.references.forEach((nav) => {
// ... indicate the current items and their trees...
this.indicateCurrentItems(nav);
// ... bind to every nav's toggle's click event,...
this.togglesOnClickFocusBlur(nav);
// ... and bind to every link's focus/blur events
this.linksOnFocusBlur(nav);
});
}
// Collapse/expand the main menu
toggleMainMenu({ currentTarget }) {
// Change the default behavior whether the current target is a link or not
const isLink = currentTarget.classList.contains(this.classes.link),
listener = super.stateListener(!isLink);
// Get the nav of the current target...
const nav = this.references.find((nav) => {
// If the current target is not a link, find a nav with a hamburger button or container that matches the current target...
if (!isLink)
return (
nav.hamburger === currentTarget ||
nav.container === currentTarget
);
// ... otherwise, find a nav with links that include the current target
return nav.links.includes(currentTarget);
});
const hamburgerAriaLabel = nav.hamburger.getAttribute('aria-label');
const hamburgerAriaLabelText = hamburgerAriaLabel.includes('Open')
? hamburgerAriaLabel.replace('Open', 'Close')
: hamburgerAriaLabel.replace('Close', 'Open');
const hamburgerText = nav.hamburger.textContent.includes('Menu')
? 'Close'
: 'Menu';
// Toggle the modal class on the nav element...
nav.element.classList.toggle(this.classes.modal);
// ... and toggle scrolling functionality
this.toggleScrollLock();
nav.hamburger.setAttribute('aria-label', hamburgerAriaLabelText);
// ... toggle the nav's hamburger button's text between "Menu" and "Close",...
nav.hamburger.textContent = hamburgerText;
// ... and toggle the main menu when a link is clicked
nav.links.forEach((link) => {
link[listener]('click', this.toggleMainMenu);
});
}
// Stop event propagation
stopPropagation(event) {
event.stopPropagation();
}
// Bind to a main nav's hamburger button click event
hamburgerOnClick({ hamburger }) {
// If no hamburger button exists, do nothing else
if (!hamburger) return;
// Otherwise, toggle the main menu when its clicked
hamburger[this.listener]('click', this.toggleMainMenu);
}
// Bind to the nav container's click event
containerOnClick({ container, controller }) {
// If no container or controller exist, do nothing else
if (!container || !controller) return;
// Otherwise, toggle the main menu when the overflow container is clicked...
container[this.listener]('click', this.toggleMainMenu);
// ... and stop event propagation if the nav controller is clicked
controller[this.listener]('click', this.stopPropagation);
}
// Start or stop the main navigation functionality
mainFunctionality() {
// For each main nav...
this.references
.filter(({ isSubNav }) => !isSubNav)
.forEach((nav) => {
// ... bind to its item's mouse enter/leave event if they have dropdowns,...
this.itemsWithDropdownsOnHover(nav);
// ... hamburger's click event,...
this.hamburgerOnClick(nav);
// ... and nav container's click event
this.containerOnClick(nav);
});
}
// Adjusts the opacity of the visual scroll indicators of a nav list based on the scroll position
indicateScrollDirection({ target }) {
// Grab the relavent scroll information from the target,...
const { scrollTop, scrollHeight, clientHeight } = target,
// ... how far it's scrolled from the top,
top = Math.round(scrollTop);
// ... and the maximum distance of scrolling
let max = scrollHeight - clientHeight;
// Normalize the maximum distance to zero (if lower)
if (max < 0) max = 0;
// Figure out the percent scrolled from the top/bottom
const percentFromTop = (top / max || 0).toFixed(2),
percentFromBottom = ((max - top) / max || 0).toFixed(2);
// Set the scroll top...
target.style.setProperty(this.properties.listScrollTop, percentFromTop);
// ... and scroll bottom CSS custom property of the list to correspond with opacity
target.style.setProperty(
this.properties.listScrollBottom,
percentFromBottom
);
}
scrollToCurrentItem(list) {
const current = list.querySelector(this.selectors.itemIsCurrent);
if (!current) return;
const { offsetTop } = current;
list.scrollTop = offsetTop;
}
// Adds visual indicators that a nav list is scrollable
indicateScrollable({ lists }) {
// If no lists exists, do nothing else
if (!lists.length) return;
// For each list,...
lists.forEach((list) => {
// If the list isn't scrollable, return it as is
if (!list.classList.contains(this.classes.scrollable)) return list;
// Otherwise, scroll to the current item...
this.scrollToCurrentItem(list);
// ... and indicate the scroll direction up front...
this.indicateScrollDirection({ target: list });
// ... and whenever the list is scrolled thereafter
list[this.listener](
'scroll',
this.indicateScrollDirection.bind(this)
);
});
}
// Start or stop the sub navigation functionality
subFunctionality() {
// For each sub nav...
this.references
.filter(({ isSubNav }) => isSubNav)
.forEach(this.indicateScrollable.bind(this));
}
// Mount/unmount the navigation functionality
mount(run) {
super.mount(run);
// If unmounting and no container exists,...
if (!run && !this.references.length) {
// ... simply delete the settings/references...
delete this.settings;
delete this.references;
// ... and do nothing else
return;
}
// If mounting, get all references
if (run) this.getReferences();
// Handle all global/sepecific nav functionalities
this.allFunctionality();
this.mainFunctionality();
this.subFunctionality();
// If unmounting,...
if (!run) {
// ... delete the settings...
delete this.settings;
// ... and references
delete this.references;
}
}
}
The navigation component, commonly loacated at the top and left of each page, provides multiple links to allow users to easily navigate the site. This allows for the creation of a main (site-wide) navigation and sub (page) navigation.
All navigations consist of a wrapping <nav>
element containing an unordered list (<ul>
) of links. A nested unordered list of links creates a dropdown menu. 2 levels of dropdown menus are supported. If a parent link has a dropdown menu, a toggle dropdown button is included as its sibling, and uses a down-facing chevron icon. When the dropdown menu is toggled, the toggle button’s icon rotates 180 degrees.
Main navigations also include a hamburger button, which is only visible on small/mobile viewports. When clicked, the main navigation is reveled on the left side of the viewport, and behaves more similarly to sub navigations.
All navigations support dark and light themes. A main navigation commonly uses the dark theme, and sub navigations commonly use the light theme.
The dark theme uses a near-black background color with increasingly brighter background colors for dropdown menus. The accent/highlight color is VCU gold.
The light theme uses a near-white background color with increasingly brighter background colors for dropdown menus. The accent/highlight color is the globally configured accent background color (default blue).
All navigations are hidden on small/mobile viewports, however the main navigation can still be toggled open via its hamburger button.
Sub navigations will restrict its vertical space by allowing its container to scroll vertically.
All navigations support advanced keyboard navigation, including the use of arrow, home, and end keys. Which arrow keys are used depends on whether its a main or sub navigation, and intuitively corresponds with the direction of the link you’re moving to. In addition, the indication of the current page’s navigation link (and its parents) can be set even if its not explicitly indicated in the markup.
Main navigations allow for dropdown menus to be closed by pressing Escape or using arrow keys to move away from dropdown menus. Arrow navigation in dropdowns will loop through the dropdown menu links until closed.
Sub navigations that can be vertically scrolled will have scroll indicators (subtle shadows) that indicate which direction the nav can be scrolled in.
The navigation is implemented in T4 as the “Navigation” Compass content type, meaning its classes retain the .cwf-
prefix.
This content type should only be used within the global “Site-Header” (main navigation) or “Site-Nav” (sub navigation) sections to have it displayed globally within the header or left sidebar respectively.
In the “Injectors” field of the navigation content type, the following injectors can be used:
id:{custom_id}
- Overrides the default, T4 ID of the navigation with a custom ID.class:{custom_classes}
- Adds custom classes to the navigation.style:{custom_styles}
- Adds custom styles to a style
attribute of the navigation.before:{custom_html}
- Adds custom HTML before the navigation.after:{custom_html}
- Adds custom HTML after the navigation.