<button class="bux-popover__trigger bux-context-menu__trigger bux-context-menu__trigger--icon-button">
<span class="visually-hidden">additional breadcrumbs</span>
<span aria-hidden="true" class="icon icon-dots-h"></span>
</button>
<div class="bux-popover__container bux-context-menu__container container--position-below-trigger container--justify-caret-left">
<div class="bux-popover__caret caret--justify-trigger-center">
<svg viewBox="0 0 10 10">
<path class="caret--chevron"></path>
</svg>
</div>
<div class="bux-context-menu__active-item-indicator" aria-hidden="true"></div>
<ul class="bux-popover__body">
<li class="bux-context-menu__item">
<a class="item__link" href="">
Level 1
</a>
</li>
<li class="bux-context-menu__item">
<a class="item__link" href="">
Level 2
</a>
</li>
</ul>
</div>
{% if container_position == 'left' or container_position == 'right' %}
{% set container_position_class = ' container--position-' ~ container_position ~ '-of-trigger' %}
{% elseif container_position == 'above' or container_position == 'below' %}
{% set container_position_class = ' container--position-' ~ container_position ~ '-trigger' %}
{% else %}
{% set container_position_class = '' %}
{% endif %}
{% if container_align_to_caret %}
{% set container_caret_class = ' container--align-caret-' ~ container_align_to_caret %}
{% elseif container_justify_to_caret %}
{% set container_caret_class = ' container--justify-caret-' ~ container_justify_to_caret %}
{% else %}
{% set container_caret_class = '' %}
{% endif %}
{% if caret_justify_to_trigger %}
{% set caret_class = ' caret--justify-trigger-' ~ caret_justify_to_trigger %}
{% elseif caret_align_to_trigger %}
{% set caret_class = ' caret--align-trigger-' ~ caret_align_to_trigger %}
{% else %}
{% set caret_class = '' %}
{% endif %}
<button class="bux-popover__trigger bux-context-menu__trigger bux-context-menu__trigger--icon-button"{% if stay_open == "true" %} data-stay-open="true"{% endif %}>
<span class="visually-hidden">{{ label }}</span>
<span aria-hidden="true" class="icon icon-{{ trigger_icon }}"></span>
</button>
<div class="bux-popover__container bux-context-menu__container{{ container_position_class }}{{ container_caret_class }}">
<div class="bux-popover__caret {{ caret_class }}">
<svg viewBox="0 0 10 10">
<path class="caret--chevron"></path>
</svg>
</div>
<div class="bux-context-menu__active-item-indicator" aria-hidden="true"></div>
<ul class="bux-popover__body">
{% for item in items %}
<li class="bux-context-menu__item">
<a class="item__link" href="{{ item.href }}">
{{ item.text }}
</a>
</li>
{% endfor %}
</ul>
</div>
site_name_prefix: Office of
site_name: Learning Relations Excellence
site_slogan: Additional text or site slogan
address_1: 100 Building Name
address_2: 1 Oval Mall
city: Columbus
state: OH
zip: '43210'
contact_email: email@osu.edu
contact_phone: 614-292-OHIO
contact_tty: 614-688-8605
trigger_icon: dots-h
container_position: below
caret_justify_to_trigger: center
container_justify_to_caret: left
label: additional breadcrumbs
items:
- text: Level 1
href: null
- text: Level 2
href: null
.bux-context-menu__trigger--icon-button {
display: inline-block;
margin-right: 4px;
cursor: pointer;
transition-timing-function: cubic-bezier(.4, 0, .2, 1);
transition-duration: .3s;
transition-property: background-color, border-color, color, fill, stroke;
vertical-align: middle;
color: $scarlet;
border: none;
background-color: $clear;
place-items: center;
&:hover {
background-color: $gray-light-80;
}
&:focus-visible {
outline: 2px solid $focus;
outline-offset: 2px;
}
.icon:before {
position: relative;
top: 3px;
}
}
.bux-context-menu__container .bux-popover__caret {
fill: $white;
& svg path {
stroke-width: 2;
stroke: $gray-light-60;
fill: $white;
}
}
.bux-context-menu__container .bux-popover__body {
border-color: $gray-light-60;
}
.bux-context-menu__active-item-indicator {
width: 4px;
height: var(--item-height);
background-color: $gray-dark-60;
position: fixed;
top: 0;
left: 0;
}
.bux-context-menu__item {
display: block;
width: 100%;
background-color: $white;
.item__link {
font-size: $ts-sm;
display: block;
padding: 12px 16px;
text-decoration: none;
color: $gray-dark-80;
background-color: $white;
&:hover {
background-color: $gray-light-80;
}
&:focus-visible {
outline: 2px solid $focus;
outline-offset: -1px;
}
}
}
const $contextMenuConfig = {
queries: {
/* How the init function finds the context menu trigger */
trigger: ".bux-context-menu__trigger", /* How the init function finds the context menu body container */
container: ".bux-context-menu__container", /* What the BuxContextMenuController uses to find its menu items */
items: "a, button",
},
/** Keyboard keys that we may need to handle keypresses from */
navKeys: [" ", "Tab", "ArrowDown", "ArrowUp", "Home", "End", "Escape"],
clipPaths: {
fromCollapsed: "polygon(0 0, 100% 0, 100% 0, 0 0)",
toCollapsed: "polygon(0 100%, 100% 100%, 100% 100%, 0 100%)",
expanded: "polygon(0 0, 100% 0, 100% 100%, 0 100%)"
}
}
/**
* Takes a BuxContextMenuController or BuxContextMenuItem and returns:
* - a `focus` object with the focus callables for the:
* - next item
* - previous item
* - first item
* - last item
*
* (from the context of whichever component type was passed in)
*
* and the `contextMenu` to use to call its `collapse()` or `expand()` method.
*/
const getKeydownHandlers = (component) => {
const contextMenu = component instanceof BuxContextMenuController
? component
: component.parentController;
const nextItem = component instanceof BuxContextMenuController
? component.firstItem
: component.nextItem;
const previousItem = component instanceof BuxContextMenuController
? component.lastItem
: component.previousItem;
const firstItem = contextMenu.firstItem;
const lastItem = contextMenu.lastItem;
return {
nextItem,
previousItem,
firstItem,
lastItem,
contextMenu,
};
};
/**
* navByKb gets called from event listeners, so it gets bound with a `this` that
* is either a BuxContextMenuController or BuxContextMenuItem.
*/
const navByKb = function (event) {
const {
key,
shiftKey
} = event;
/* If the nav is being called by the controller while it's collapsed, we only
need to handle space bar keypresses -- so, we can potentially bail early */
if (this instanceof
BuxContextMenuController &&
!this.isExpanded &&
key !==
" ") {
return;
}
/* Bail early if it's not a key we care about */
if (!($contextMenuConfig.navKeys.includes(key))) return;
/* Now we know we're going to handle it, so we can prevent default */
event.preventDefault();
/* Since this function gets called from either the item or the container, we
ask getKeydownHandlers to get the relevant items and the context menu
instance for us */
const {
nextItem,
previousItem,
firstItem,
lastItem,
contextMenu
} = getKeydownHandlers(this);
if ((key === "Tab" && !shiftKey) || key === "ArrowDown") {
nextItem.element.focus();
} else if ((key === "Tab" && shiftKey) || key === "ArrowUp") {
previousItem.element.focus();
} else if (key === "Home") {
firstItem.element.focus();
} else if (key === "End") {
lastItem.element.focus();
} else if (key ===
"Escape" ||
(key ===
" " &&
this instanceof
BuxContextMenuController &&
this.isExpanded)) {
contextMenu.collapse();
} else if (key === " ") {
contextMenu.expand();
}
};
/* ==============
Main classes
============== */
/**
* Items contained within a `BuxContextMenuController` that handle their keydown
* behavior and expose their `nextItem` and `previousItem` siblings.
*/
class BuxContextMenuItem {
constructor(element, contextMenuIndex, parentController) {
this.element = element;
this.contextMenuIndex = contextMenuIndex;
this.parentController = parentController;
this.element.addEventListener("keydown",
(event) => navByKb.bind(this)(event));
}
get nextItem() {
if (this === this.parentController.lastItem) {
return this.parentController.trigger;
} else {
return this.parentController.items[this.contextMenuIndex + 1];
}
}
get previousItem() {
if (this === this.parentController.firstItem) {
return this.parentController.trigger;
} else {
return this.parentController.items[this.contextMenuIndex - 1];
}
}
}
class BuxContextMenuIndicator {
/**
* @param indicatorElement {HTMLElement}
* @param parentController {BuxContextMenuController}
*/
constructor(indicatorElement, parentController) {
this.element = indicatorElement;
this.parentController = parentController;
this.collapse();
}
get currentTransform() {
const style = window.getComputedStyle(this.element);
const transform = style.getPropertyValue('transform')
const matches = transform?.match(
/matrix\(\d*, \d*, \d*, \d*, (-?\d*\.?\d*), (-?\d*\.?\d*)\)/)
return {
x: parseInt(matches?.[1] ?? '0'),
y: parseInt(matches?.[2] ?? '0'),
}
}
set height(height) {
this.element.style.setProperty("--item-height", `${ height }px`);
}
collapse() {
this.element.animate([{clipPath: $contextMenuConfig.clipPaths.expanded}, { clipPath: $contextMenuConfig.clipPaths.toCollapsed }], {
easing: 'cubic-bezier(.4, 0, .2, 1)',
fill: 'both',
duration: 150
});
}
expand() {
this.element.animate([{clipPath: $contextMenuConfig.clipPaths.fromCollapsed}, { clipPath: $contextMenuConfig.clipPaths.expanded }], {
easing: 'cubic-bezier(.4, 0, .2, 1)',
fill: 'both',
duration: 150
});
}
getTransformForItem(newItem) {
const newItemRect = newItem.element.getBoundingClientRect();
this.height = newItemRect.height;
const currentIndicatorRect = this.element.getBoundingClientRect();
const currentTransform = this.currentTransform;
return {
x: newItemRect.x - currentIndicatorRect.x + currentTransform.x,
y: newItemRect.y - currentIndicatorRect.y + currentTransform.y
}
}
/**
* @param newItem {BuxContextMenuItem}
*/
teleportTo(newItem) {
const transform = this.getTransformForItem(newItem);
this.element.animate([{
transform: `translateX(${ transform.x }px) translateY(${ transform.y }px)`
}], {
easing: 'cubic-bezier(.4, 0, .2, 1)',
fill: 'both',
duration: 0
})
}
/**
* @param newItem {BuxContextMenuItem}
*/
animateTo(newItem) {
const toTransform = this.getTransformForItem(newItem);
this.element.animate([{
transform: `translateX(${ toTransform.x }px) translateY(${ toTransform.y }px)`
}], {
easing: 'cubic-bezier(.4, 0, .2, 1)',
fill: 'both',
duration: 150
})
}
}
/**
* Handles a context menu that handles expand/collapse functionality and instantiates the items it contains with
* `BuxContextMenuItem`s.
*/
class BuxContextMenuController extends BuxPopoverController {
constructor(triggerElement, containerElement) {
/* BuxPopoverController takes care of all toggling and positioning, and adds
* the trigger, caret, and container elements */
super(triggerElement, containerElement);
this.trigger.element.addEventListener("keydown",
(event) => navByKb.bind(this)(event));
const indicatorElement = this.container.element.querySelector(
'.bux-context-menu__active-item-indicator');
this.indicator = new BuxContextMenuIndicator(indicatorElement, this);
/* Init items */
this.items =
Array.from(this.container.element.querySelectorAll($contextMenuConfig.queries.items))
.map((itemElement, index) => new BuxContextMenuItem(itemElement,
index,
this));
this.items.forEach(this.addIndicatorListeners.bind(this));
}
get firstItem() {
return this.items[0];
}
get lastItem() {
return this.items[this.items.length - 1];
}
get itemElements() {
return this.items.map(i => i.element);
}
expand() {
super.expand();
this.indicator.teleportTo(this.firstItem);
requestAnimationFrame(() => {
this.firstItem.element.focus();
})
}
/**
* @param item {BuxContextMenuItem}
*/
addIndicatorListeners(item) {
item.element.addEventListener("mouseenter", (event) => {
if (this.itemElements.includes(event.relatedTarget)) {
this.indicator.animateTo(item);
} else {
this.indicator.teleportTo(item);
this.indicator.expand();
}
});
item.element.addEventListener("mouseleave", (event) => {
if (this.itemElements.includes(event.relatedTarget)) {
return;
}
this.indicator.collapse();
})
}
}
/* ===============
Instantiation
=============== */
/**
* Finds the first element that comes after the given trigger element in the DOM and has the popover container classname
*/
const getContainerFromTrigger = (trigger) => Array.from(document.querySelectorAll(
$contextMenuConfig.queries.container))
.find(container => trigger.compareDocumentPosition(container) === 4);
/**
* Finds triggers, matches them with containers, and initializes controllers
*/
const initBuxContextMenus = () => {
const triggers = Array.from(document.querySelectorAll($contextMenuConfig.queries.trigger));
triggers.forEach(trigger => {
const container = getContainerFromTrigger(trigger);
if (!container) {
console.warn(
"BUX Context Menu found a trigger element, but couldn't find a container element that came after it in the DOM. This is probably an issue with the page's markup. No BUX Popover Context Menu can be created.");
return;
}
return new BuxContextMenuController(trigger, container);
});
};
initBuxContextMenus();
No notes defined.