<nav id="example-breadcrumb" aria-label="Breadcrumb" class="cwf-breadcrumb">
<ol class="cwf-breadcrumb__list">
<li class="cwf-breadcrumb__item cwf-breadcrumb__item--has-dropdown">
<button class="cwf-breadcrumb__toggle">
<i class="fas fa-ellipsis-h cwf-breadcrumb__icon">
</i>
<span class="cwf-breadcrumb__text">More</span>
</button>
<ol class="cwf-breadcrumb__list cwf-breadcrumb__list--dropdown">
<li class="cwf-breadcrumb__item">
<a href="/" class="cwf-breadcrumb__link">
<i class="fas fa-home cwf-breadcrumb__icon"></i>
<span class="cwf-breadcrumb__text">Home</span>
</a>
</li>
<li class="cwf-breadcrumb__item">
<a href="#" class="cwf-breadcrumb__link">
Parent
</a>
</li>
</ol>
</li>
<li class="cwf-breadcrumb__item">
<a href="#" class="cwf-breadcrumb__link">
Child 1
</a>
</li>
<li class="cwf-breadcrumb__item">
Grandchild 2
</li>
</ol>
</nav>
{% set break = false %}
{% set homeItem %}
<li class="cwf-breadcrumb__item">
<a href="/" class="cwf-breadcrumb__link">
<i class="fas fa-home cwf-breadcrumb__icon"></i>
<span class="cwf-breadcrumb__text">Home</span>
</a>
</li>
{% endset %}
<nav id="{{ id ?? 'cwf-breadcrumb' }}"
aria-label="Breadcrumb"
class="cwf-breadcrumb">
<ol class="cwf-breadcrumb__list">
{% for route in routes if not break %}
{% if route.name.full == title or route.name == title %}
{% if route.parents is defined %}
{% set parents = route.parents %}
{% if (parents|length) > 1 %}
<li class="cwf-breadcrumb__item cwf-breadcrumb__item--has-dropdown">
<button class="cwf-breadcrumb__toggle">
<i class="fas fa-ellipsis-h cwf-breadcrumb__icon">
</i>
<span class="cwf-breadcrumb__text">More</span>
</button>
<ol class="cwf-breadcrumb__list cwf-breadcrumb__list--dropdown">
{{ homeItem }}
{% for parent in parents %}
{% if parent != (parents|last) %}
<li class="cwf-breadcrumb__item">
<a href="{{ parent.href }}"
class="cwf-breadcrumb__link">
{{ parent.name.short
?? parent.name.full
?? parent.name }}
</a>
</li>
{% endif %}
{% endfor %}
</ol>
</li>
{% else %}
{{ homeItem }}
{% endif %}
{% set parent = parents|last %}
<li class="cwf-breadcrumb__item">
<a href="{{ parent.href }}"
class="cwf-breadcrumb__link">
{{ parent.name.full ?? parent.name }}
</a>
</li>
{% else %}
{{ homeItem }}
{% endif %}
<li class="cwf-breadcrumb__item">
{{ route.name.full ?? route.name }}
</li>
{% set break = true %}
{% endif %}
{% endfor %}
</ol>
</nav>
{# CMS implementation note: "The link to the current page has aria-current set to page. If the element representing the current page is not a link, aria-current is optional." Since we can't set the aria-current automatically in T4 we can just not make the last item a link. #}
{
"id": "example-breadcrumb",
"routes": [
{
"level": 1,
"name": "Parent",
"href": "#",
"children": [
{
"name": "Child 1",
"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": "#",
"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": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"name": "Child 1",
"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": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"name": "Child 1",
"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": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
},
{
"name": "Child 1",
"href": "#",
"children": [
{
"name": "Grandchild 1",
"href": "#"
},
{
"name": "Grandchild 2",
"href": "#"
},
{
"name": "Grandchild 3",
"href": "#"
}
]
}
]
}
],
"title": "Grandchild 2"
}
// Breadcrumb component styles
@use "../../shared/animation";
@use "../../shared/media";
@use "../../shared/style";
@use "../../shared/theme";
@use "../../utilities/screen-reader/shared" as screen-reader;
// Selector prefix
$prefix: "cwf" !default;
// Breadcrumb item separator colors
$item--separator__border-color: style.color("gray-lightest") !default;
// Breadcrumb link colors
$link__color--mobile: style.color("gray-light") !default;
$link__color--desktop: style.color("blue", "accent") !default;
// Breadcrumb dropdown list colors
$list--dropdown__border-color: style.lighten("white-darkest", 33%) !default;
$list--dropdown__background-color: style.color("white") !default;
.#{$prefix}-breadcrumb {
@include style.spacing;
width: 100%;
font-family: theme.font--sans-serif();
--cwf-breadcrumb--separator-color: #{$item--separator__border-color};
--cwf-breadcrumb--mobile-link-color: #{$link__color--mobile};
--cwf-breadcrumb--desktop-link-color: #{$link__color--desktop};
--cwf-breadcrumb__list--dropdown--border-color: #{$list--dropdown__border-color};
--cwf-breadcrumb__list--dropdown--background-color: #{$list--dropdown__background-color};
}
.#{$prefix}-breadcrumb__list {
display: flex;
justify-content: space-between;
width: 100%;
margin: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
@include media.breakpoint {
justify-content: flex-start;
}
}
.#{$prefix}-breadcrumb__item {
display: none;
align-items: center;
list-style-type: none;
padding: 0.5rem 0;
&:first-child {
display: inline-flex;
order: 1;
@include media.breakpoint {
order: 0;
}
}
&:nth-last-child(2) {
display: inline-flex;
& .#{$prefix}-breadcrumb__link {
display: inline-flex;
align-items: baseline;
}
& .#{$prefix}-breadcrumb__link:before {
display: inline-flex;
width: 1rem;
height: 1rem;
-webkit-font-smoothing: antialiased;
@include style.icon("\f053"); // Left chevron
@include media.breakpoint {
display: none;
}
}
& .#{$prefix}-breadcrumb__icon {
display: none;
}
& .#{$prefix}-breadcrumb__text {
@include screen-reader.visible;
}
}
@include media.breakpoint {
display: inline-flex;
&:after {
content: "";
display: inline-block;
height: 0.8rem;
margin-left: 0.5rem;
margin-right: 0.5rem;
border-right: 0.1em solid var(--cwf-breadcrumb--separator-color);
transform: rotate(20deg);
}
&:last-child:after {
display: none;
}
}
}
.#{$prefix}-breadcrumb__link {
color: var(--cwf-breadcrumb--mobile-link-color);
text-decoration: none;
@include media.breakpoint {
color: var(--cwf-breadcrumb--desktop-link-color);
text-decoration: underline;
}
}
.#{$prefix}-breadcrumb__text {
@include screen-reader.only;
@include media.breakpoint {
@include screen-reader.visible;
}
}
.#{$prefix}-breadcrumb__icon {
display: inline-block;
@include media.breakpoint {
display: none;
}
}
.#{$prefix}-breadcrumb__item--has-dropdown {
position: relative;
.#{$prefix}-breadcrumb__list--dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
width: auto;
flex-direction: column;
border: 1px solid var(--cwf-breadcrumb__list--dropdown--border-color);
background-color: var(
--cwf-breadcrumb__list--dropdown--background-color
);
@include media.breakpoint {
right: initial;
left: 0;
}
}
.#{$prefix}-breadcrumb__item {
display: block;
padding: 0;
border-bottom: 1px solid
var(--cwf-breadcrumb__list--dropdown--border-color);
&:first-child {
order: 0;
}
&:first-child,
&:nth-last-child(2) {
.#{$prefix}-breadcrumb__icon {
display: none;
}
.#{$prefix}-breadcrumb__text {
display: static;
}
.#{$prefix}-breadcrumb__link:before {
display: none;
}
}
&:last-child {
border-bottom: none;
}
&:after {
display: none;
}
}
.#{$prefix}-breadcrumb__link {
display: flex !important;
padding: 0.5rem 1rem;
text-decoration: none;
@include animation.transition(background-color, color);
&:hover,
&:focus {
background-color: var(--cwf-breadcrumb--desktop-link-color);
color: var(--cwf-breadcrumb__list--dropdown--background-color);
}
.#{$prefix}-breadcrumb__text {
@include screen-reader.visible;
}
}
}
.#{$prefix}-breadcrumb__toggle {
padding: 0;
border: none;
background-color: transparent;
font-size: 1rem;
font-weight: bold;
color: var(--cwf-breadcrumb--mobile-link-color);
@include media.breakpoint {
color: var(--cwf-breadcrumb--desktop-link-color);
text-decoration: underline;
}
}
.#{$prefix}-breadcrumb__item--expand-dropdown {
.#{$prefix}-breadcrumb__list--dropdown {
display: flex;
@include style.z-index("menu");
}
}
// The default component class
import { Component } from '../../shared/component.js';
// Provide functionality to the breadcrumb
export class Breadcrumb extends Component {
constructor({
prefix = 'cwf',
breadcrumb = 'breadcrumb',
list = 'breadcrumb__list',
listIsDropdown = 'breadcrumb__list--dropdown',
item = 'breadcrumb__item',
itemHasDropdown = 'breadcrumb__item--has-dropdown',
itemExpandDropdown = 'breadcrumb__item--expand-dropdown',
link = 'breadcrumb__link',
toggle = 'breadcrumb__toggle'
} = {}) {
super({
prefix,
classes: {
breadcrumb,
list,
listIsDropdown,
item,
itemHasDropdown,
itemExpandDropdown,
link,
toggle
}
});
// Bind "this" to the appropriate methods
this.breadcrumbKeyboardNavigation =
this.breadcrumbKeyboardNavigation.bind(this);
this.closeDropdownGlobally = this.closeDropdownGlobally.bind(this);
this.handleCloseDropdownGlobally =
this.handleCloseDropdownGlobally.bind(this);
this.dropdownKeyboardNavigation =
this.dropdownKeyboardNavigation.bind(this);
this.handleToggleDropdown = this.handleToggleDropdown.bind(this);
}
// Store breadcrumb references
store() {
// Attempt to grab all breadcrumbs...
const elements = this.references.length
? this.references
: Array.from(document.querySelectorAll(this.selectors.breadcrumb));
// ... and if none exist, do nothing else
if (!elements.length) return;
// Finally, store the references
this.references = elements.map((element) => {
// If the reference is already an object, return the reference as is
if (element.element) return element;
// Otherwise, grab the breadcrumb's direct links...
const links = Array.from(
element.querySelectorAll(
[
':scope',
this.selectors.list,
this.selectors.item,
this.selectors.link
].join('>')
)
);
// ... and create a reference object from the element and its links
let reference = {
element,
links
};
// Next, check to see if a dropdown parent item exists...
const parent = element.querySelector(
this.selectors.itemHasDropdown
);
// ... and if doesn't, return the reference object as-is
if (!parent) return reference;
// Otherwise, set the reference's expanded flag to false...
reference.expanded = false;
// ... and add a dropdown key object consisting of...
reference.dropdown = {
// ... the dropdown parent item,...
parent,
// ... the toggle,...
toggle: parent.querySelector(this.selectors.toggle),
// ... the dropdown list,...
list: parent.querySelector(this.selectors.listIsDropdown),
// ... and the dropdown links
links: Array.from(parent.querySelectorAll(this.selectors.link))
};
// ... and return the reference
return reference;
});
}
// Returns a reference containing the given element
getReferenceOf(element, asArray = false) {
// If the element is a boolean, return all references...
if (typeof element === 'boolean') return this.references;
// ... otherwise, find the reference containing the given element
const match = this.references.find((reference) => {
return (
reference.links.includes(element) ||
(reference.dropdown
? reference.dropdown.toggle === element ||
reference.dropdown.links.includes(element)
: false)
);
});
// Finally, if no match was found, return an appropriate nullish value...
if (!match) return asArray ? [] : match;
// ... or return the match in an array or by itself
return asArray ? [match] : match;
}
// Breadcrumb keyboard navigation
breadcrumbKeyboardNavigation(event) {
// Grab the type and current target from the event
const { type, currentTarget } = event;
// Handle whether to add/remove a keydown event listener based on the event type...
switch (type) {
case 'focus': // Focus = add keydown event listener
currentTarget.addEventListener(
'keydown',
this.breadcrumbKeyboardNavigation
);
break;
case 'blur': // Blur = remove keydown event listener
currentTarget.removeEventListener(
'keydown',
this.breadcrumbKeyboardNavigation
);
}
// ... and if this isn't a keydown event, do nothing else
if (type !== 'keydown') return;
// Grab the key from the event...
const { key } = event;
// ... and if the key isn't home, left/right arrows, or end, do nothing else
if (!['Home', 'ArrowLeft', 'ArrowRight', 'End'].includes(key)) return;
// Prevent the default action
event.preventDefault();
// Attempt to grab the reference containing the current target,...
const reference = this.getReferenceOf(currentTarget);
// ... and if doesn't exist, do nothing else
if (!reference) return;
// Otherwise, create a list of focusable elements,...
const focusable = [
reference.dropdown ? reference.dropdown.toggle : null,
...reference.links
].filter(Boolean);
// ... and figure out the first/last focusable element as well as the currently focused element's index
const firstIndex = 0,
firstFocusable = focusable[firstIndex],
index = focusable.indexOf(currentTarget),
lastIndex = focusable.length - 1,
lastFocusable = focusable[lastIndex],
isOnFirst = index === firstIndex,
isOnLast = index === lastIndex;
// Handle what to do based on what key was pressed
switch (key) {
case 'Home': // Home = Focus the first element
return firstFocusable.focus();
case 'ArrowLeft': // Left arrow = Focus the previous element, looping around to the last element
return isOnFirst
? lastFocusable.focus()
: focusable[index - 1].focus();
case 'ArrowRight': // Right arrow = Focus the next element, looping around the first element
return isOnLast
? firstFocusable.focus()
: focusable[index + 1].focus();
case 'End': // End = Focus the last element
return lastFocusable.focus();
}
}
// Handles generic focus/blur events
handleFocusBlurEvents(element, listener) {
element[this.listener]('focus', listener, false);
element[this.listener]('blur', listener, false);
}
// Handle breadcrumb keyboard navigation
handleBreadcrumbKeyboardNavigation(element) {
this.handleFocusBlurEvents(element, this.breadcrumbKeyboardNavigation);
}
// Close the dropdown globally
closeDropdownGlobally({ type, target }, { dropdown }) {
// If the reference doesn't have a dropdown, do nothing else
if (!dropdown) return;
// Grab all dropdown references....
const { parent, toggle, list, links } = dropdown;
// ... and create a list of valid targets
let targets = [
parent,
toggle,
...Array.from(toggle.children),
list,
...links
];
// Add all link children to the list of valid targets
links.forEach(({ children }) => {
targets.push(...children);
});
// If this is a click event and one of the valid targets were clicked, do nothing else
if (type === 'click' && targets.includes(target)) return;
// Otherwise, close the dropdown
return this.handleToggleDropdown(false);
}
handleCloseDropdownGlobally(event) {
// Grab the type and key from the event...
const { type, key } = event;
// ... and if the type is keydown but the key is not "Escape", do nothing else
if (type === 'keydown' && key !== 'Escape') return;
// Otherwise, close the dropdown for each current reference
this.current.forEach(this.closeDropdownGlobally.bind(this, event));
}
// Toggle a dropdown open/close
toggleDropdown(event, reference) {
// If the reference doesn't have a dropdown, do nothing else
if (!reference.dropdown) return;
// Set the reference's expanded flag
reference.expanded =
typeof event === 'boolean' ? event : !reference.expanded;
// Get the dropdown parent item
const { parent } = reference.dropdown;
// Add/remove the expanded class to the dropdown parent based on the expanded state
parent.classList.toggle(
this.classes.itemExpandDropdown,
reference.expanded
);
// When an animation frame is available...
window.requestAnimationFrame(() => {
// If the event was a click, add a global keydown event to close the dropdown
if (event.detail)
document[super.stateListener(reference.expanded)](
'keydown',
this.handleCloseDropdownGlobally
);
// Add a global click event to close the dropdown
document[super.stateListener(reference.expanded)](
'click',
this.handleCloseDropdownGlobally
);
});
// If the event was a click...
if (event.detail) return;
// ... or the dropdown has been collapsed, do nothing else
if (!reference.expanded) return;
// Grab all the dropdown links...
const { links } = reference.dropdown;
// ... and focus the first one
links[0].focus();
}
// Handle dropdown toggling
handleToggleDropdown(event) {
// Store whether the event is forced,...
const forced = typeof event === 'boolean';
// ... get the reference,...
const references = this.getReferenceOf(
forced || event.currentTarget,
true
);
// ... and if the reference doesn't exist, do nothing else
if (!references || !references.length) return;
// Globally store the current references...
this.current = references;
// ... and toggle the dropdown for each reference
references.forEach(this.toggleDropdown.bind(this, event));
}
// Dropdown keyboard navigation
dropdownKeyboardNavigation(event) {
// Grab the type and current target from the event
const { type, currentTarget } = event;
// Handle whether to add/remove a keydown event listener based on the event type...
switch (type) {
case 'focus': // Focus = add keydown event listener
currentTarget.addEventListener(
'keydown',
this.dropdownKeyboardNavigation
);
break;
case 'blur': // Blur = remove keydown event listener
currentTarget.removeEventListener(
'keydown',
this.dropdownKeyboardNavigation
);
}
// ... and if this isn't a keydown event, do nothing else
if (type !== 'keydown') return;
// Attempt to grab the reference of the current target...
const reference = this.getReferenceOf(currentTarget);
// ... and if none were found, do nothing else
if (!reference) return;
// Grab the key from the event...
const { key } = event;
// ... and if it's escape,...
if (key === 'Escape') {
// ... close the dropdown,...
this.handleToggleDropdown(false);
// ... and focus the dropdown toggle
return reference.dropdown.toggle.focus();
}
// If the key isn't home, up/down arrows, or end, do nothing else
if (!['Home', 'ArrowUp', 'ArrowDown', 'End'].includes(key)) return;
// Prevent the default action
event.preventDefault();
// Grab the dropdown links...
const { links } = reference.dropdown;
// ... and figure out...
const firstIndex = 0;
// ... the first...
const firstLink = links[firstIndex];
const index = links.indexOf(currentTarget);
const lastIndex = links.length - 1;
// ... and last links...
const lastLink = links[lastIndex];
// ... as well as the currently focused link's index
const isOnFirst = index === firstIndex;
const isOnLast = index === lastIndex;
// Handle what to do based on what key was pressed
switch (key) {
case 'Home': // Home = Focus the first link
return firstLink.focus();
case 'ArrowUp': // Up arrow = Focus the previous link, looping around to the last link
return isOnFirst ? lastLink.focus() : links[index - 1].focus();
case 'ArrowDown': // Down arrow = Focus the previous, looping around the first link
return isOnLast ? firstLink.focus() : links[index + 1].focus();
case 'End': // End = Focus the last link
return lastLink.focus();
}
}
// Handle dropdown keyboard navigation
handleDropdownKeyboardNavigation(element) {
this.handleFocusBlurEvents(element, this.dropdownKeyboardNavigation);
}
// Handle breadcrumb functionality
functionality() {
// For each reference,...
this.references.forEach(({ links, dropdown }) => {
// ... handle breadcrumb keyboard navigation on the direct links...
links.forEach(this.handleBreadcrumbKeyboardNavigation.bind(this));
// ... and if it doesn't have a dropdown, do nothing else
if (!dropdown) return;
const { toggle, links: dropdownLinks } = dropdown;
// Otherwise, handle dropdown toggling when the toggle is clicked,...
toggle[this.listener]('click', this.handleToggleDropdown, false);
// ... handle breadcrumb keyboard navigation on the dropdown toggle,...
this.handleBreadcrumbKeyboardNavigation(toggle);
// ... and handle dropdown keyboard navigation on the dropdown links
dropdownLinks.forEach(
this.handleDropdownKeyboardNavigation.bind(this)
);
});
}
// Mount/unmount the breadcrumb functionality
mount(run) {
super.mount(run);
// If mounting, store breadcrumb references...
if (run) this.store();
// ... and handle their functionality
this.functionality();
// If unmounting, delete the breadcrumb references
if (!run) this.references = [];
}
}
The breadcrumb component is a tertiary navigation that helps users understand where in the page structure they are currently at. Inspiration taken from the WAI Aria Practices 1.1 breadcrumb example.
The breadcrumb navigation is automatically included in the page layout, and is placed within the <main>
column above all page content.
The breadcrumb component can be rendered differently depending on how deep within the page structure it’s being used. The styles will also change on mobile viewports to more cleanly display the information.
By default, the breadcrumb will have links to the parent page or the grandparent and parent pages, and the name of the current page, horizontally and separated by forward slashes. On smaller viewports, the parent page will be displayed as back button to the left, and if a grandparent page is included, it will displayed to the right (the homepage will be styled as a home icon to preserve space).
When the current page is 4 or more levels deep, all pages higher than the parent page will be truncated within a “More” menu. Clicking on the “More” button will open up the menu showing all pages higher than the parent page. On smaller viewports, this button/menu will be displayed to the right as a 3 vertical dots icon.
All breadcrumb navigations support custom keyboard navigation:
If the breadcrumb is truncated, and focus is with the “More” menu, the following custom keyboard navigation is available:
The breadcrumb component is not tied to a specific content type. Its styles are included in the Compass T4 page layout CSS, and is used by the page layout at the top of the main column on any page except the homepage. The breadcrumb will automatically populate and update based on your site’s section structure.