coral-spectrum/coral-component-shell/src/scripts/ShellMenuBarItem.js
/**
* Copyright 2019 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {BaseComponent} from '../../../coral-base-component';
import {commons, transform, validate} from '../../../coral-utils';
import '../../../coral-component-icon';
import '../../../coral-component-button';
import '../../../coral-component-anchorbutton';
import menuBarItem from '../templates/menuBarItem';
/**
Enumeration for {@link ShellMenuBarItem} icon variants.
@typedef {Object} ShellMenuBarItemIconVariantEnum
@property {String} DEFAULT
A default menubar item.
@property {String} CIRCLE
A round image as menubar item.
*/
const iconVariant = {
DEFAULT: 'default',
CIRCLE: 'circle'
};
/**
Enumeration for valid aria-haspopup values.
@typedef {Object} ShellMenuBarItemHasPopupRoleEnum
@property {String} MENU
ShellMenuBarItem opens a menu.
@property {String} LISTBOX
ShellMenuBarItem opens a list box.
@property {String} TREE
ShellMenuBarItem opens a tree.
@property {String} GRID
ShellMenuBarItem opens a grid.
@property {String} DIALOG
ShellMenuBarItem opens a dialog.
@property {Null} DEFAULT
Defaults to null.
*/
const hasPopupRole = {
MENU: 'menu',
LISTBOX: 'listbox',
TREE: 'tree',
GRID: 'grid',
DIALOG: 'dialog',
DEFAULT: null
};
// the Menubar Item's base classname
const CLASSNAME = '_coral-Shell-menubar-item';
// Builds a string containing all possible iconVariant classnames. This will be used to remove classnames when the variant
// changes
const ALL_ICON_VARIANT_CLASSES = [];
for (const variantValue in iconVariant) {
ALL_ICON_VARIANT_CLASSES.push(`${CLASSNAME}--${iconVariant[variantValue]}`);
}
/**
@class Coral.Shell.MenuBar.Item
@classdesc A Shell MenuBar Item component
@htmltag coral-shell-menubar-item
@extends {HTMLElement}
@extends {BaseComponent}
*/
class ShellMenuBarItem extends BaseComponent(HTMLElement) {
/** @ignore */
constructor() {
super();
// Templates
this._elements = {};
menuBarItem.call(this._elements);
// Events
this._delegateEvents({
'click [handle="shellMenuButton"]': '_handleButtonClick',
// it has to be global because the menus are not direct children
'global:coral-overlay:close': '_handleOverlayEvent',
'global:coral-overlay:beforeclose': '_handleOverlayBeforeEvent',
'global:coral-overlay:open': '_handleOverlayEvent',
'global:coral-overlay:beforeopen': '_handleOverlayBeforeEvent'
});
}
/**
Specifies the icon name used inside the menu item.
See {@link Icon} for valid icon names.
@type {String}
@default ""
@htmlattribute icon
*/
get icon() {
return this._elements.shellMenuButton.icon;
}
set icon(value) {
this._elements.shellMenuButton.icon = value;
}
/**
Size of the icon. It accepts both lower and upper case sizes. See {@link ButtonIconSizeEnum}.
@type {String}
@default ButtonIconSizeEnum.SMALL
@htmlattribute iconsize
@htmlattributereflected
*/
get iconSize() {
return this._elements.shellMenuButton.iconSize;
}
set iconSize(value) {
this._elements.shellMenuButton.iconSize = value;
// Required for styling
this._reflectAttribute('iconsize', this.iconSize);
}
/**
The menubar item's iconVariant. See {@link ShellMenuBarItemIconVariantEnum}.
@type {String}
@default ShellMenuBarItemIconVariantEnum.DEFAULT
@htmlattribute iconvariant
*/
get iconVariant() {
return this._iconVariant || iconVariant.DEFAULT;
}
set iconVariant(value) {
value = transform.string(value).toLowerCase();
this._iconVariant = validate.enumeration(iconVariant)(value) && value || iconVariant.DEFAULT;
// removes all the existing variants
this.classList.remove(...ALL_ICON_VARIANT_CLASSES);
// adds the new variant
if (this.variant !== iconVariant.DEFAULT) {
this.classList.add(`${CLASSNAME}--${this._iconVariant}`);
}
}
/**
The notification badge content.
@type {String}
@default ""
@htmlattribute badge
*/
get badge() {
return this._elements.shellMenuButton.getAttribute('badge') || '';
}
set badge(value) {
// Non-truthy values shouldn't show
// null, empty string, 0, etc
this._elements.shellMenuButton[!value || value === '0' ? 'removeAttribute' : 'setAttribute']('badge', value);
}
/**
Whether the menu is open or not.
@type {Boolean}
@default false
@htmlattribute open
@htmlattributereflected
@emits {coral-shell-menubar-item:open}
@emits {coral-shell-menubar-item:close}
*/
get open() {
return this._open || false;
}
set open(value) {
const menu = this._getMenu();
// if we want to open the dialog we need to make sure there is a valid menu or hasPopup
if (menu === null && this.hasPopup === hasPopupRole.DEFAULT) {
return;
}
this._open = transform.booleanAttr(value);
this._reflectAttribute('open', this._open);
// if the menu is valid, toggle the menu and trigger the appropriate event
if (menu !== null) {
// Toggle the target menu
if (menu.open !== this._open) {
menu.open = this._open;
}
this.trigger(`coral-shell-menubar-item:${this._open ? 'open' : 'close'}`);
}
this._elements.shellMenuButton.setAttribute('aria-expanded', this._open);
}
/**
The menubar item's label content zone.
@type {ButtonLabel}
@contentzone
*/
get label() {
return this._getContentZone(this._elements.shellMenuButtonLabel);
}
set label(value) {
this._setContentZone('label', value, {
handle: 'shellMenuButtonLabel',
tagName: 'coral-button-label',
insert: function (label) {
this._elements.shellMenuButton.label = label;
}
});
}
/**
The menu that this menu item should show. If a CSS selector is provided, the first matching element will be
used.
@type {?HTMLElement|String}
@default null
@htmlattribute menu
*/
get menu() {
return this._menu || null;
}
set menu(value) {
let menu;
if (value instanceof HTMLElement) {
this._menu = value;
menu = this._menu;
} else {
this._menu = String(value);
menu = document.querySelector(this._menu);
}
// Link menu with item
if (menu !== null) {
this.id = this.id || commons.getUID();
menu.setAttribute('target', `#${this.id}`);
if (this.hasPopup === hasPopupRole.DEFAULT) {
this.hasPopup = menu.getAttribute('role') || hasPopupRole.DIALOG;
}
} else if (this._menu && this.hasPopup !== hasPopupRole.DEFAULT) {
this.hasPopup = hasPopupRole.DEFAULT;
}
}
/**
Whether the item opens a popup dialog or menu. Accepts either "menu", "listbox", "tree", "grid", or "dialog".
@type {?String}
@default ShellMenuBarItemHasPopupRoleEnum.DEFAULT
@htmlattribute haspopup
*/
get hasPopup() {
return this._hasPopup || null;
}
set hasPopup(value) {
value = transform.string(value).toLowerCase();
this._hasPopup = validate.enumeration(hasPopupRole)(value) && value || hasPopupRole.DEFAULT;
const shellMenuButton = this._elements.shellMenuButton;
let ariaHaspopup = this._hasPopup;
if (ariaHaspopup) {
shellMenuButton.setAttribute('aria-haspopup', ariaHaspopup);
shellMenuButton.setAttribute('aria-expanded', this.open);
} else {
shellMenuButton.removeAttribute('aria-haspopup');
shellMenuButton.removeAttribute('aria-expanded');
}
}
_handleOverlayBeforeEvent(event) {
const target = event.target;
if (target === this._getMenu()) {
// Mark button as selected
this._elements.shellMenuButton.classList.toggle('is-selected', !target.open);
}
}
/** @private */
_handleOverlayEvent(event) {
const target = event.target;
// matches the open state of the target in case it was open separately
if (target === this._getMenu()) {
const shellMenuButton = this._elements.shellMenuButton;
if (this.open !== target.open) {
this.open = target.open;
} else if (shellMenuButton.getAttribute('aria-expanded') !== target.open) {
shellMenuButton.setAttribute('aria-expanded', target.open);
}
}
}
/** @ignore */
_handleButtonClick() {
this.open = !this.open;
}
/** @ignore */
_getMenu(targetValue) {
// Use passed target
targetValue = targetValue || this.menu;
if (targetValue instanceof Node) {
// Just return the provided Node
return targetValue;
}
// Dynamically get the target node based on target
let newTarget = null;
if (typeof targetValue === 'string') {
newTarget = document.querySelector(targetValue);
}
return newTarget;
}
get _contentZones() {
return {'coral-button-label': 'label'};
}
/** @ignore */
focus() {
this._elements.shellMenuButton.focus();
}
/**
Returns {@link ShellMenuBarItem} icon variants.
@return {ShellMenuBarItemIconVariantEnum}
*/
static get iconVariant() {
return iconVariant;
}
static get _attributePropertyMap() {
return commons.extend(super._attributePropertyMap, {
haspopup: 'hasPopup',
iconsize: 'iconSize',
iconvariant: 'iconVariant'
});
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'haspopup',
'icon',
'iconsize',
'iconvariant',
'badge',
'open',
'menu',
'aria-label'
]);
}
/** @ignore */
attributeChangedCallback(name, oldValue, value) {
// a11y When user doesn't supply a button label (for an icon-only button),
// providing aria-label will correctly pass it on to the shell menu button child element.
if (name === 'aria-label') {
if (value && this._elements.shellMenuButton.textContent.trim() === '') {
this._elements.shellMenuButton.setAttribute('aria-label', value);
}
} else {
super.attributeChangedCallback(name, oldValue, value);
}
}
/** @ignore */
render() {
super.render();
this.setAttribute('role', 'listitem');
this.classList.add(CLASSNAME);
const button = this.querySelector('._coral-Shell-menu-button');
if (button) {
this._elements.shellMenuButton = button;
this._elements.shellMenuButtonLabel = this.querySelector('coral-button-label');
} else {
while (this.firstChild) {
this._elements.shellMenuButtonLabel.appendChild(this.firstChild);
}
this.appendChild(this._elements.shellMenuButton);
}
this.label = this._elements.shellMenuButtonLabel;
// Sync menu
if (this.menu !== null) {
this.menu = this.menu;
}
}
/**
Triggered after the {@link ShellMenuBarItem} is opened with <code>show()</code> or <code>instance.open = true</code>
@typedef {CustomEvent} coral-shell-menubar-item:open
*/
/**
Triggered after the {@link ShellMenuBarItem} is closed with <code>hide()</code> or <code>instance.open = false</code>
@typedef {CustomEvent} coral-shell-menubar-item:close
*/
}
export default ShellMenuBarItem;