<nav id="example-nav--sub--light" class="cwf-nav cwf-nav--sub cwf-nav--light" aria-label="Sub">
    <div class="cwf-nav__container">
        <div class="cwf-nav__controller">
            <ul class="cwf-nav__list cwf-nav__list--level-2 cwf-nav__list--scroll">
                <li class="cwf-nav__item  cwf-nav__item--is-current">
                    <a href="#" class="cwf-nav__link" aria-current="page">
                        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  ">
                            <a href="#" class="cwf-nav__link">
                                Grandchild 2
                            </a>
                        </li>
                        <li class="cwf-nav__item  ">
                            <a href="#" class="cwf-nav__link">
                                Grandchild 3
                            </a>
                        </li>
                    </ul>

                </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--sub--light",
  "type": "sub",
  "theme": "light",
  "siblings": true,
  "title": "Child 1",
  "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": "#"
    }
  ]
}
  • Content:
    // 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;
        }
    }
    
  • URL: /components/raw/nav/_index.scss
  • Filesystem Path: components/nav/_index.scss
  • Size: 18.7 KB
  • Content:
    // 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;
            }
        }
    }
    
  • URL: /components/raw/nav/index.js
  • Filesystem Path: components/nav/index.js
  • Size: 33.9 KB

Navigation

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.

Structural

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.

Styles

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.

Functionality

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.

T4 implementation

The navigation is implemented in T4 as the “Navigation” Compass content type, meaning its classes retain the .cwf- prefix.

Areas

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.

Injectors

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.