ExamplesPlaygroundReference Source

coral-spectrum/coral-component-shell/src/scripts/ShellMenuBarItem.js

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import {BaseComponent} from '../../../coral-base-component';
  14. import {commons, transform, validate} from '../../../coral-utils';
  15. import '../../../coral-component-icon';
  16. import '../../../coral-component-button';
  17. import '../../../coral-component-anchorbutton';
  18. import menuBarItem from '../templates/menuBarItem';
  19.  
  20. /**
  21. Enumeration for {@link ShellMenuBarItem} icon variants.
  22.  
  23. @typedef {Object} ShellMenuBarItemIconVariantEnum
  24.  
  25. @property {String} DEFAULT
  26. A default menubar item.
  27. @property {String} CIRCLE
  28. A round image as menubar item.
  29. */
  30. const iconVariant = {
  31. DEFAULT: 'default',
  32. CIRCLE: 'circle'
  33. };
  34.  
  35. /**
  36. Enumeration for valid aria-haspopup values.
  37.  
  38. @typedef {Object} ShellMenuBarItemHasPopupRoleEnum
  39. @property {String} MENU
  40. ShellMenuBarItem opens a menu.
  41. @property {String} LISTBOX
  42. ShellMenuBarItem opens a list box.
  43. @property {String} TREE
  44. ShellMenuBarItem opens a tree.
  45. @property {String} GRID
  46. ShellMenuBarItem opens a grid.
  47. @property {String} DIALOG
  48. ShellMenuBarItem opens a dialog.
  49. @property {Null} DEFAULT
  50. Defaults to null.
  51.  
  52. */
  53. const hasPopupRole = {
  54. MENU: 'menu',
  55. LISTBOX: 'listbox',
  56. TREE: 'tree',
  57. GRID: 'grid',
  58. DIALOG: 'dialog',
  59. DEFAULT: null
  60. };
  61.  
  62. // the Menubar Item's base classname
  63. const CLASSNAME = '_coral-Shell-menubar-item';
  64.  
  65. // Builds a string containing all possible iconVariant classnames. This will be used to remove classnames when the variant
  66. // changes
  67. const ALL_ICON_VARIANT_CLASSES = [];
  68. for (const variantValue in iconVariant) {
  69. ALL_ICON_VARIANT_CLASSES.push(`${CLASSNAME}--${iconVariant[variantValue]}`);
  70. }
  71.  
  72. /**
  73. @class Coral.Shell.MenuBar.Item
  74. @classdesc A Shell MenuBar Item component
  75. @htmltag coral-shell-menubar-item
  76. @extends {HTMLElement}
  77. @extends {BaseComponent}
  78. */
  79. class ShellMenuBarItem extends BaseComponent(HTMLElement) {
  80. /** @ignore */
  81. constructor() {
  82. super();
  83.  
  84. // Templates
  85. this._elements = {};
  86. menuBarItem.call(this._elements);
  87.  
  88. // Events
  89. this._delegateEvents({
  90. 'click [handle="shellMenuButton"]': '_handleButtonClick',
  91.  
  92. // it has to be global because the menus are not direct children
  93. 'global:coral-overlay:close': '_handleOverlayEvent',
  94. 'global:coral-overlay:beforeclose': '_handleOverlayBeforeEvent',
  95. 'global:coral-overlay:open': '_handleOverlayEvent',
  96. 'global:coral-overlay:beforeopen': '_handleOverlayBeforeEvent'
  97. });
  98. }
  99.  
  100. /**
  101. Specifies the icon name used inside the menu item.
  102. See {@link Icon} for valid icon names.
  103.  
  104. @type {String}
  105. @default ""
  106. @htmlattribute icon
  107. */
  108. get icon() {
  109. return this._elements.shellMenuButton.icon;
  110. }
  111.  
  112. set icon(value) {
  113. this._elements.shellMenuButton.icon = value;
  114. }
  115.  
  116. /**
  117. Size of the icon. It accepts both lower and upper case sizes. See {@link ButtonIconSizeEnum}.
  118.  
  119. @type {String}
  120. @default ButtonIconSizeEnum.SMALL
  121. @htmlattribute iconsize
  122. @htmlattributereflected
  123. */
  124. get iconSize() {
  125. return this._elements.shellMenuButton.iconSize;
  126. }
  127.  
  128. set iconSize(value) {
  129. this._elements.shellMenuButton.iconSize = value;
  130. // Required for styling
  131. this._reflectAttribute('iconsize', this.iconSize);
  132. }
  133.  
  134. /**
  135. The menubar item's iconVariant. See {@link ShellMenuBarItemIconVariantEnum}.
  136.  
  137. @type {String}
  138. @default ShellMenuBarItemIconVariantEnum.DEFAULT
  139. @htmlattribute iconvariant
  140. */
  141. get iconVariant() {
  142. return this._iconVariant || iconVariant.DEFAULT;
  143. }
  144.  
  145. set iconVariant(value) {
  146. value = transform.string(value).toLowerCase();
  147. this._iconVariant = validate.enumeration(iconVariant)(value) && value || iconVariant.DEFAULT;
  148.  
  149. // removes all the existing variants
  150. this.classList.remove(...ALL_ICON_VARIANT_CLASSES);
  151. // adds the new variant
  152. if (this.variant !== iconVariant.DEFAULT) {
  153. this.classList.add(`${CLASSNAME}--${this._iconVariant}`);
  154. }
  155. }
  156.  
  157. /**
  158. The notification badge content.
  159.  
  160. @type {String}
  161. @default ""
  162. @htmlattribute badge
  163. */
  164. get badge() {
  165. return this._elements.shellMenuButton.getAttribute('badge') || '';
  166. }
  167.  
  168. set badge(value) {
  169. // Non-truthy values shouldn't show
  170. // null, empty string, 0, etc
  171. this._elements.shellMenuButton[!value || value === '0' ? 'removeAttribute' : 'setAttribute']('badge', value);
  172. }
  173.  
  174. /**
  175. Whether the menu is open or not.
  176.  
  177. @type {Boolean}
  178. @default false
  179. @htmlattribute open
  180. @htmlattributereflected
  181.  
  182. @emits {coral-shell-menubar-item:open}
  183. @emits {coral-shell-menubar-item:close}
  184. */
  185. get open() {
  186. return this._open || false;
  187. }
  188.  
  189. set open(value) {
  190. const menu = this._getMenu();
  191.  
  192. // if we want to open the dialog we need to make sure there is a valid menu or hasPopup
  193. if (menu === null && this.hasPopup === hasPopupRole.DEFAULT) {
  194. return;
  195. }
  196.  
  197. this._open = transform.booleanAttr(value);
  198. this._reflectAttribute('open', this._open);
  199.  
  200. // if the menu is valid, toggle the menu and trigger the appropriate event
  201. if (menu !== null) {
  202. // Toggle the target menu
  203. if (menu.open !== this._open) {
  204. menu.open = this._open;
  205. }
  206.  
  207. this.trigger(`coral-shell-menubar-item:${this._open ? 'open' : 'close'}`);
  208. }
  209.  
  210. this._elements.shellMenuButton.setAttribute('aria-expanded', this._open);
  211. }
  212.  
  213. /**
  214. The menubar item's label content zone.
  215.  
  216. @type {ButtonLabel}
  217. @contentzone
  218. */
  219. get label() {
  220. return this._getContentZone(this._elements.shellMenuButtonLabel);
  221. }
  222.  
  223. set label(value) {
  224. this._setContentZone('label', value, {
  225. handle: 'shellMenuButtonLabel',
  226. tagName: 'coral-button-label',
  227. insert: function (label) {
  228. this._elements.shellMenuButton.label = label;
  229. }
  230. });
  231. }
  232.  
  233. /**
  234. The menu that this menu item should show. If a CSS selector is provided, the first matching element will be
  235. used.
  236.  
  237. @type {?HTMLElement|String}
  238. @default null
  239. @htmlattribute menu
  240. */
  241. get menu() {
  242. return this._menu || null;
  243. }
  244.  
  245. set menu(value) {
  246. let menu;
  247. if (value instanceof HTMLElement) {
  248. this._menu = value;
  249. menu = this._menu;
  250. } else {
  251. this._menu = String(value);
  252. menu = document.querySelector(this._menu);
  253. }
  254.  
  255. // Link menu with item
  256. if (menu !== null) {
  257. this.id = this.id || commons.getUID();
  258. menu.setAttribute('target', `#${this.id}`);
  259. if (this.hasPopup === hasPopupRole.DEFAULT) {
  260. this.hasPopup = menu.getAttribute('role') || hasPopupRole.DIALOG;
  261. }
  262. } else if (this._menu && this.hasPopup !== hasPopupRole.DEFAULT) {
  263. this.hasPopup = hasPopupRole.DEFAULT;
  264. }
  265. }
  266.  
  267. /**
  268. Whether the item opens a popup dialog or menu. Accepts either "menu", "listbox", "tree", "grid", or "dialog".
  269. @type {?String}
  270. @default ShellMenuBarItemHasPopupRoleEnum.DEFAULT
  271. @htmlattribute haspopup
  272. */
  273. get hasPopup() {
  274. return this._hasPopup || null;
  275. }
  276.  
  277. set hasPopup(value) {
  278. value = transform.string(value).toLowerCase();
  279. this._hasPopup = validate.enumeration(hasPopupRole)(value) && value || hasPopupRole.DEFAULT;
  280.  
  281. const shellMenuButton = this._elements.shellMenuButton;
  282. let ariaHaspopup = this._hasPopup;
  283.  
  284. if (ariaHaspopup) {
  285. shellMenuButton.setAttribute('aria-haspopup', ariaHaspopup);
  286. shellMenuButton.setAttribute('aria-expanded', this.open);
  287. } else {
  288. shellMenuButton.removeAttribute('aria-haspopup');
  289. shellMenuButton.removeAttribute('aria-expanded');
  290. }
  291. }
  292.  
  293. _handleOverlayBeforeEvent(event) {
  294. const target = event.target;
  295.  
  296. if (target === this._getMenu()) {
  297. // Mark button as selected
  298. this._elements.shellMenuButton.classList.toggle('is-selected', !target.open);
  299. }
  300. }
  301.  
  302. /** @private */
  303. _handleOverlayEvent(event) {
  304. const target = event.target;
  305.  
  306. // matches the open state of the target in case it was open separately
  307. if (target === this._getMenu()) {
  308. const shellMenuButton = this._elements.shellMenuButton;
  309. if (this.open !== target.open) {
  310. this.open = target.open;
  311. } else if (shellMenuButton.getAttribute('aria-expanded') !== target.open) {
  312. shellMenuButton.setAttribute('aria-expanded', target.open);
  313. }
  314. }
  315. }
  316.  
  317. /** @ignore */
  318. _handleButtonClick() {
  319. this.open = !this.open;
  320. }
  321.  
  322. /** @ignore */
  323. _getMenu(targetValue) {
  324. // Use passed target
  325. targetValue = targetValue || this.menu;
  326.  
  327. if (targetValue instanceof Node) {
  328. // Just return the provided Node
  329. return targetValue;
  330. }
  331.  
  332. // Dynamically get the target node based on target
  333. let newTarget = null;
  334. if (typeof targetValue === 'string') {
  335. newTarget = document.querySelector(targetValue);
  336. }
  337.  
  338. return newTarget;
  339. }
  340.  
  341. get _contentZones() {
  342. return {'coral-button-label': 'label'};
  343. }
  344.  
  345. /** @ignore */
  346. focus() {
  347. this._elements.shellMenuButton.focus();
  348. }
  349.  
  350. /**
  351. Returns {@link ShellMenuBarItem} icon variants.
  352.  
  353. @return {ShellMenuBarItemIconVariantEnum}
  354. */
  355. static get iconVariant() {
  356. return iconVariant;
  357. }
  358.  
  359. static get _attributePropertyMap() {
  360. return commons.extend(super._attributePropertyMap, {
  361. haspopup: 'hasPopup',
  362. iconsize: 'iconSize',
  363. iconvariant: 'iconVariant'
  364. });
  365. }
  366.  
  367. /** @ignore */
  368. static get observedAttributes() {
  369. return super.observedAttributes.concat([
  370. 'haspopup',
  371. 'icon',
  372. 'iconsize',
  373. 'iconvariant',
  374. 'badge',
  375. 'open',
  376. 'menu',
  377. 'aria-label'
  378. ]);
  379. }
  380.  
  381. /** @ignore */
  382. attributeChangedCallback(name, oldValue, value) {
  383. // a11y When user doesn't supply a button label (for an icon-only button),
  384. // providing aria-label will correctly pass it on to the shell menu button child element.
  385. if (name === 'aria-label') {
  386. if (value && this._elements.shellMenuButton.textContent.trim() === '') {
  387. this._elements.shellMenuButton.setAttribute('aria-label', value);
  388. }
  389. } else {
  390. super.attributeChangedCallback(name, oldValue, value);
  391. }
  392. }
  393.  
  394. /** @ignore */
  395. render() {
  396. super.render();
  397.  
  398. this.setAttribute('role', 'listitem');
  399.  
  400. this.classList.add(CLASSNAME);
  401.  
  402. const button = this.querySelector('._coral-Shell-menu-button');
  403.  
  404. if (button) {
  405. this._elements.shellMenuButton = button;
  406. this._elements.shellMenuButtonLabel = this.querySelector('coral-button-label');
  407. } else {
  408. while (this.firstChild) {
  409. this._elements.shellMenuButtonLabel.appendChild(this.firstChild);
  410. }
  411.  
  412. this.appendChild(this._elements.shellMenuButton);
  413. }
  414.  
  415. this.label = this._elements.shellMenuButtonLabel;
  416.  
  417. // Sync menu
  418. if (this.menu !== null) {
  419. this.menu = this.menu;
  420. }
  421. }
  422.  
  423. /**
  424. Triggered after the {@link ShellMenuBarItem} is opened with <code>show()</code> or <code>instance.open = true</code>
  425.  
  426. @typedef {CustomEvent} coral-shell-menubar-item:open
  427. */
  428.  
  429. /**
  430. Triggered after the {@link ShellMenuBarItem} is closed with <code>hide()</code> or <code>instance.open = false</code>
  431.  
  432. @typedef {CustomEvent} coral-shell-menubar-item:close
  433. */
  434. }
  435.  
  436. export default ShellMenuBarItem;