<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
  • Content:
    .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;
        }
      }
    }
    
  • URL: /components/raw/context-menu/_context-menu.scss
  • Filesystem Path: src/components/context-menu/_context-menu.scss
  • Size: 1.4 KB
  • Content:
    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();
    
  • URL: /components/raw/context-menu/context-menu.js
  • Filesystem Path: src/components/context-menu/context-menu.js
  • Size: 9.5 KB

No notes defined.