ExamplesPlaygroundReference Source

coral-spectrum/coral-base-button/src/scripts/BaseButton.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 {BaseLabellable} from '../../../coral-base-labellable';
  14. import {Icon} from '../../../coral-component-icon';
  15. import {transform, validate, commons} from '../../../coral-utils';
  16.  
  17. /**
  18. Enumeration for {@link Button}, {@link AnchorButton} icon sizes.
  19.  
  20. @typedef {Object} ButtonIconSizeEnum
  21.  
  22. @property {String} EXTRA_EXTRA_SMALL
  23. Extra extra small size icon, typically 9px size.
  24. @property {String} EXTRA_SMALL
  25. Extra small size icon, typically 12px size.
  26. @property {String} SMALL
  27. Small size icon, typically 18px size. This is the default size.
  28. @property {String} MEDIUM
  29. Medium size icon, typically 24px size.
  30. */
  31. const iconSize = {};
  32. const excludedIconSizes = [Icon.size.LARGE, Icon.size.EXTRA_LARGE, Icon.size.EXTRA_EXTRA_LARGE];
  33. for (const key in Icon.size) {
  34. // Populate button icon sizes by excluding the largest icon sizes
  35. if (excludedIconSizes.indexOf(Icon.size[key]) === -1) {
  36. iconSize[key] = Icon.size[key];
  37. }
  38. }
  39.  
  40. /**
  41. Enumeration for {@link Button}, {@link AnchorButton} variants.
  42.  
  43. @typedef {Object} ButtonVariantEnum
  44.  
  45. @property {String} CTA
  46. A button that is meant to grab the user's attention.
  47. @property {String} PRIMARY
  48. A button that is meant to grab the user's attention.
  49. @property {String} QUIET
  50. A quiet button that indicates that the button's action is the primary action.
  51. @property {String} SECONDARY
  52. A button that indicates that the button's action is the secondary action.
  53. @property {String} QUIET_SECONDARY
  54. A quiet secondary button.
  55. @property {String} ACTION
  56. An action button.
  57. @property {String} QUIET_ACTION
  58. A quiet action button.
  59. @property {String} MINIMAL
  60. A quiet minimalistic button.
  61. @property {String} WARNING
  62. A button that indicates that the button's action is dangerous.
  63. @property {String} QUIET_WARNING
  64. A quiet warning button,
  65. @property {String} OVER_BACKGROUND
  66. A button to be placed on top of colored background.
  67. @property {String} DEFAULT
  68. The default button look and feel.
  69. */
  70. const variant = {
  71. CTA: 'cta',
  72. PRIMARY: 'primary',
  73. SECONDARY: 'secondary',
  74. QUIET: 'quiet',
  75. MINIMAL: 'minimal',
  76. WARNING: 'warning',
  77. ACTION: 'action',
  78. QUIET_ACTION: 'quietaction',
  79. QUIET_SECONDARY: 'quietsecondary',
  80. QUIET_WARNING: 'quietwarning',
  81. OVER_BACKGROUND: 'overbackground',
  82. DEFAULT: 'default',
  83. // Private to be used for custom Button classes like field buttons
  84. _CUSTOM: '_custom'
  85. };
  86.  
  87. // the button's base classname
  88. const CLASSNAME = '_coral-Button';
  89. const ACTION_CLASSNAME = '_coral-ActionButton';
  90.  
  91. const ALL_VARIANT_CLASSES = [
  92. `${CLASSNAME}--cta`,
  93. `${CLASSNAME}--primary`,
  94. `${CLASSNAME}--secondary`,
  95. `${CLASSNAME}--warning`,
  96. `${CLASSNAME}--quiet`,
  97. `${ACTION_CLASSNAME}--quiet`,
  98. `${CLASSNAME}--overBackground`,
  99. ];
  100.  
  101. const VARIANT_MAP = {
  102. cta: [CLASSNAME, ALL_VARIANT_CLASSES[0]],
  103. primary: [CLASSNAME, ALL_VARIANT_CLASSES[0]],
  104. secondary: [CLASSNAME, ALL_VARIANT_CLASSES[2]],
  105. warning: [CLASSNAME, ALL_VARIANT_CLASSES[3]],
  106. quiet: [CLASSNAME, ALL_VARIANT_CLASSES[1], ALL_VARIANT_CLASSES[4]],
  107. minimal: [CLASSNAME, ALL_VARIANT_CLASSES[2], ALL_VARIANT_CLASSES[4]],
  108. default: [CLASSNAME, ALL_VARIANT_CLASSES[1]],
  109. action: [ACTION_CLASSNAME],
  110. quietaction: [ACTION_CLASSNAME, ALL_VARIANT_CLASSES[5]],
  111. quietsecondary: [CLASSNAME, ALL_VARIANT_CLASSES[2], ALL_VARIANT_CLASSES[4]],
  112. quietwarning: [CLASSNAME, ALL_VARIANT_CLASSES[3], ALL_VARIANT_CLASSES[4]],
  113. overbackground: [CLASSNAME, ALL_VARIANT_CLASSES[6]]
  114. };
  115.  
  116. /**
  117. Enumeration for {@link BaseButton} sizes.
  118.  
  119. @typedef {Object} ButtonSizeEnum
  120.  
  121. @property {String} MEDIUM
  122. A medium button is the default, normal sized button.
  123. @property {String} LARGE
  124. Not supported. Falls back to MEDIUM.
  125. */
  126. const size = {
  127. MEDIUM: 'M',
  128. LARGE: 'L'
  129. };
  130.  
  131. /**
  132. Enumeration for {@link BaseButton} icon position options.
  133.  
  134. @typedef {Object} ButtonIconPositionEnum
  135.  
  136. @property {String} RIGHT
  137. Position should be right of the button label.
  138. @property {String} LEFT
  139. Position should be left of the button label.
  140. */
  141. const iconPosition = {
  142. RIGHT: 'right',
  143. LEFT: 'left'
  144. };
  145.  
  146. /**
  147. @base BaseButton
  148. @classdesc The base element for Button components
  149. */
  150. const BaseButton = (superClass) => class extends BaseLabellable(superClass) {
  151. /** @ignore */
  152. constructor() {
  153. super();
  154.  
  155. // Templates
  156. this._elements = {
  157. // Create or fetch the label element
  158. label: this.querySelector(this._contentZoneTagName) || document.createElement(this._contentZoneTagName),
  159. icon: this.querySelector('coral-icon')
  160. };
  161.  
  162. // Events
  163. this._events = {
  164. mousedown: '_onMouseDown',
  165. click: '_onClick'
  166. };
  167.  
  168. super._observeLabel();
  169. }
  170.  
  171. /**
  172. The label of the button.
  173. @type {HTMLElement}
  174. @contentzone
  175. */
  176. get label() {
  177. return this._getContentZone(this._elements.label);
  178. }
  179.  
  180. set label(value) {
  181. this._setContentZone('label', value, {
  182. handle: 'label',
  183. tagName: this._contentZoneTagName,
  184. insert: function (label) {
  185. // Update label styles
  186. this._updateLabel(label);
  187.  
  188. // Ensure there's no extra space left for icon only buttons
  189. if (label.innerHTML.trim() === '') {
  190. label.textContent = '';
  191. }
  192.  
  193. if (this.iconPosition === iconPosition.LEFT) {
  194. this.appendChild(label);
  195. } else {
  196. this.insertBefore(label, this.firstChild);
  197. }
  198. }
  199. });
  200. }
  201.  
  202. /**
  203. Position of the icon relative to the label. If no <code>iconPosition</code> is provided, it will be set on the
  204. left side by default.
  205. See {@link ButtonIconPositionEnum}.
  206.  
  207. @type {String}
  208. @default ButtonIconPositionEnum.LEFT
  209. @htmlattribute iconposition
  210. @htmlattributereflected
  211. */
  212. get iconPosition() {
  213. return this._iconPosition || iconPosition.LEFT;
  214. }
  215.  
  216. set iconPosition(value) {
  217. value = transform.string(value).toLowerCase();
  218. value = validate.enumeration(iconPosition)(value) && value || iconPosition.LEFT;
  219. this._reflectAttribute('iconposition', value);
  220.  
  221. if(validate.valueMustChange(this._iconPosition, value)) {
  222. this._iconPosition = value;
  223. this._updateIcon(this.icon);
  224. }
  225. }
  226.  
  227. /**
  228. Specifies the icon name used inside the button. See {@link Icon} for valid icon names.
  229.  
  230. @type {String}
  231. @default ""
  232. @htmlattribute icon
  233. */
  234. get icon() {
  235. if (this._elements.icon) {
  236. return this._elements.icon.getAttribute('icon') || '';
  237. }
  238.  
  239. return this._icon || '';
  240. }
  241.  
  242. set icon(value) {
  243. value = transform.string(value);
  244. if(validate.valueMustChange(this._icon, value)) {
  245. this._icon = value;
  246. this._updateIcon(value);
  247. }
  248. }
  249.  
  250. /**
  251. Size of the icon. It accepts both lower and upper case sizes. See {@link ButtonIconSizeEnum}.
  252.  
  253. @type {String}
  254. @default ButtonIconSizeEnum.SMALL
  255. @htmlattribute iconsize
  256. */
  257. get iconSize() {
  258. if (this._elements.icon) {
  259. return this._elements.icon.getAttribute('size') || Icon.size.SMALL;
  260. }
  261.  
  262. return this._iconSize || Icon.size.SMALL;
  263. }
  264.  
  265. set iconSize(value) {
  266. value = transform.string(value).toUpperCase();
  267. value = validate.enumeration(Icon.size)(value) && value || Icon.size.SMALL;
  268.  
  269. if(validate.valueMustChange(this._iconSize, value)) {
  270. this._iconSize = value;
  271. this._updatedIcon && this._getIconElement().setAttribute('size', value);
  272. }
  273. }
  274.  
  275. /**
  276. Whether aria-label is set automatically. See {@link IconAutoAriaLabelEnum}.
  277.  
  278. @type {String}
  279. @default IconAutoAriaLabelEnum.OFF
  280. @htmlattribute autoarialabel
  281. */
  282. get iconAutoAriaLabel() {
  283. if (this._elements.icon) {
  284. return this._elements.icon.getAttribute('autoarialabel') || Icon.autoAriaLabel.OFF;
  285. }
  286.  
  287. return this._iconAutoAriaLabel || Icon.autoAriaLabel.OFF;
  288. }
  289.  
  290. set iconAutoAriaLabel(value) {
  291. value = transform.string(value).toLowerCase();
  292. value = validate.enumeration(Icon.autoAriaLabel)(value) && value || Icon.autoAriaLabel.OFF;
  293.  
  294. if(validate.valueMustChange(this._iconAutoAriaLabel, value)) {
  295. this._iconAutoAriaLabel = value;
  296. this._updatedIcon && this._getIconElement().setAttribute('autoarialabel', value);
  297. }
  298. }
  299.  
  300. /**
  301. The size of the button. It accepts both lower and upper case sizes. See {@link ButtonSizeEnum}.
  302. Currently only "MEDIUM" is supported.
  303.  
  304. @type {String}
  305. @default ButtonSizeEnum.MEDIUM
  306. @htmlattribute size
  307. @htmlattributereflected
  308. */
  309. get size() {
  310. return this._size || size.MEDIUM;
  311. }
  312.  
  313. set size(value) {
  314. value = transform.string(value).toUpperCase();
  315. this._size = validate.enumeration(size)(value) && value || size.MEDIUM;
  316. this._reflectAttribute('size', this._size);
  317. }
  318.  
  319. /**
  320. Whether the button is selected.
  321.  
  322. @type {Boolean}
  323. @default false
  324. @htmlattribute selected
  325. @htmlattributereflected
  326. */
  327. get selected() {
  328. return this._selected || false;
  329. }
  330.  
  331. set selected(value) {
  332. value = transform.booleanAttr(value);
  333. this._reflectAttribute('selected', value);
  334.  
  335. if(validate.valueMustChange(this._selected, value)) {
  336. this._selected = value;
  337.  
  338. this.classList.toggle('is-selected', value);
  339. this.trigger('coral-button:_selectedchanged');
  340. }
  341. }
  342.  
  343. // We just reflect it but we also trigger an event to be used by button group
  344. /** @ignore */
  345. get value() {
  346. return this.getAttribute('value');
  347. }
  348.  
  349. set value(value) {
  350. this._reflectAttribute('value', value);
  351.  
  352. this.trigger('coral-button:_valuechanged');
  353. }
  354.  
  355. /**
  356. Expands the button to the full width of the parent.
  357.  
  358. @type {Boolean}
  359. @default false
  360. @htmlattribute block
  361. @htmlattributereflected
  362. */
  363. get block() {
  364. return this._block || false;
  365. }
  366.  
  367. set block(value) {
  368. value = transform.booleanAttr(value);
  369. this._reflectAttribute('block', value);
  370.  
  371. if(validate.valueMustChange(this._block, value)) {
  372. this._block = value;
  373.  
  374. this.classList.toggle(`${CLASSNAME}--block`, value);
  375. }
  376. }
  377.  
  378. /**
  379. The button's variant. See {@link ButtonVariantEnum}.
  380.  
  381. @type {String}
  382. @default ButtonVariantEnum.DEFAULT
  383. @htmlattribute variant
  384. @htmlattributereflected
  385. */
  386. get variant() {
  387. return this._variant || variant.DEFAULT;
  388. }
  389.  
  390. set variant(value) {
  391. value = transform.string(value).toLowerCase();
  392. value = validate.enumeration(variant)(value) && value || variant.DEFAULT;
  393.  
  394. this._reflectAttribute('variant', value);
  395.  
  396. if(validate.valueMustChange(this._variant , value)) {
  397. this._variant = value;
  398.  
  399. // removes every existing variant
  400. this.classList.remove(CLASSNAME, ACTION_CLASSNAME);
  401. this.classList.remove(...ALL_VARIANT_CLASSES);
  402. if (value === variant._CUSTOM) {
  403. this.classList.remove(CLASSNAME);
  404. } else {
  405. this.classList.add(...VARIANT_MAP[value]);
  406. if (value === variant.ACTION || value === variant.QUIET_ACTION) {
  407. this.classList.remove(CLASSNAME);
  408. }
  409. }
  410. // Update label styles
  411. this._updateLabel();
  412. }
  413. }
  414.  
  415. /**
  416. Inherited from {@link BaseComponent#trackingElement}.
  417. */
  418. get trackingElement() {
  419. return typeof this._trackingElement === 'undefined' ?
  420. // keep spaces to only 1 max and trim. this mimics native html behaviors
  421. (this.label || this).textContent.replace(/\s{2,}/g, ' ').trim() || this.icon :
  422. this._trackingElement;
  423. }
  424.  
  425. set trackingElement(value) {
  426. super.trackingElement = value;
  427. }
  428.  
  429. _onClick(event) {
  430. if (!this.disabled) {
  431. this._trackEvent('click', this.getAttribute('is'), event);
  432. }
  433. }
  434.  
  435. /** @ignore */
  436. _updateIcon(value) {
  437. if (!this._updatedIcon && this._elements.icon) {
  438. return;
  439. }
  440.  
  441. this._updatedIcon = true;
  442.  
  443. const iconSizeValue = this.iconSize;
  444. const iconAutoAriaLabelValue = this.iconAutoAriaLabel;
  445. const iconElement = this._getIconElement();
  446. iconElement.icon = value;
  447. // Update size as well
  448. iconElement.size = iconSizeValue;
  449. // Update autoAriaLabel as well
  450. iconElement.autoAriaLabel = iconAutoAriaLabelValue;
  451.  
  452. // removes the icon element from the DOM.
  453. if (this.icon === '') {
  454. iconElement.remove();
  455. }
  456. // add or adjust the icon. Add it back since it was blown away by textContent
  457. else if (!iconElement.parentNode || this._iconPosition) {
  458. if (this.contains(this.label)) {
  459. // insertBefore with <code>null</code> appends
  460. this.insertBefore(iconElement, this.iconPosition === iconPosition.LEFT ? this.label : this.label.nextElementSibling);
  461. }
  462. }
  463.  
  464. super._toggleIconAriaHidden();
  465. }
  466.  
  467. /** @ignore */
  468. _getIconElement() {
  469. if (!this._elements.icon) {
  470. this._elements.icon = new Icon();
  471. this._elements.icon.size = this.iconSize;
  472. }
  473. return this._elements.icon;
  474. }
  475.  
  476. /**
  477. Forces button to receive focus on mousedown
  478. @param {MouseEvent} event mousedown event
  479. @ignore
  480. */
  481. _onMouseDown(event) {
  482. const target = event.matchedTarget;
  483.  
  484. // Wait a frame or button won't receive focus in Safari.
  485. window.requestAnimationFrame(() => {
  486. if (target !== document.activeElement) {
  487. target.focus();
  488. }
  489. });
  490. }
  491.  
  492. _updateLabel(label) {
  493. label = label || this._elements.label;
  494.  
  495. label.classList.remove(`${CLASSNAME}-label`, `${ACTION_CLASSNAME}-label`);
  496.  
  497. if (this._variant !== variant._CUSTOM) {
  498. if (this._variant === variant.ACTION || this._variant === variant.QUIET_ACTION) {
  499. label.classList.add(`${ACTION_CLASSNAME}-label`);
  500. } else {
  501. label.classList.add(`${CLASSNAME}-label`);
  502. }
  503. }
  504. }
  505.  
  506. /** @private */
  507. get _contentZoneTagName() {
  508. return Object.keys(this._contentZones)[0];
  509. }
  510.  
  511. get _contentZones() {
  512. return {'coral-button-label': 'label'};
  513. }
  514.  
  515. /**
  516. Returns {@link BaseButton} sizes.
  517.  
  518. @return {ButtonSizeEnum}
  519. */
  520. static get size() {
  521. return size;
  522. }
  523.  
  524. /**
  525. Returns {@link BaseButton} variants.
  526.  
  527. @return {ButtonVariantEnum}
  528. */
  529. static get variant() {
  530. return variant;
  531. }
  532.  
  533. /**
  534. Returns {@link BaseButton} icon positions.
  535.  
  536. @return {ButtonIconPositionEnum}
  537. */
  538. static get iconPosition() {
  539. return iconPosition;
  540. }
  541.  
  542. /**
  543. Returns {@link BaseButton} icon sizes.
  544.  
  545. @return {ButtonIconSizeEnum}
  546. */
  547. static get iconSize() {
  548. return iconSize;
  549. }
  550.  
  551. static get _attributePropertyMap() {
  552. return commons.extend(super._attributePropertyMap, {
  553. iconposition: 'iconPosition',
  554. iconsize: 'iconSize',
  555. iconautoarialabel: 'iconAutoAriaLabel'
  556. });
  557. }
  558.  
  559. /** @ignore */
  560. static get observedAttributes() {
  561. return super.observedAttributes.concat([
  562. 'iconposition',
  563. 'iconsize',
  564. 'icon',
  565. 'iconautoarialabel',
  566. 'size',
  567. 'selected',
  568. 'block',
  569. 'variant',
  570. 'value'
  571. ]);
  572. }
  573.  
  574. /** @ignore */
  575. render() {
  576. super.render();
  577.  
  578. // Default reflected attributes
  579. if (!this._variant) {
  580. this.variant = variant.DEFAULT;
  581. }
  582. if (!this._size) {
  583. this.size = size.MEDIUM;
  584. }
  585.  
  586. // Create a fragment
  587. const fragment = document.createDocumentFragment();
  588.  
  589. const label = this._elements.label;
  590.  
  591. const contentZoneProvided = label.parentNode;
  592.  
  593. // Remove it so we can process children
  594. if (contentZoneProvided) {
  595. this.removeChild(label);
  596. }
  597.  
  598. let iconAdded = false;
  599. // Process remaining elements as necessary
  600. while (this.firstChild) {
  601. const child = this.firstChild;
  602.  
  603. if (child.nodeName === 'CORAL-ICON') {
  604. // Don't add duplicated icons
  605. if (iconAdded) {
  606. this.removeChild(child);
  607. } else {
  608. // Conserve existing icon element to content
  609. this._elements.icon = child;
  610. fragment.appendChild(child);
  611. iconAdded = true;
  612. }
  613. }
  614. // Avoid content zone to be voracious
  615. else if (contentZoneProvided) {
  616. fragment.appendChild(child);
  617. } else {
  618. // Move anything else into the label
  619. label.appendChild(child);
  620. }
  621. }
  622.  
  623. // Add the frag to the component
  624. this.appendChild(fragment);
  625.  
  626. // Assign the content zones, moving them into place in the process
  627. this.label = label;
  628.  
  629. // Make sure the icon is well positioned
  630. this._updatedIcon = true;
  631. this._updateIcon(this.icon);
  632. }
  633.  
  634. /**
  635. Triggered when {@link BaseButton#selected} changed.
  636.  
  637. @typedef {CustomEvent} coral-button:_selectedchanged
  638.  
  639. @private
  640. */
  641.  
  642. /**
  643. Triggered when {@link BaseButton#value} changed.
  644.  
  645. @typedef {CustomEvent} coral-button:_valuechanged
  646.  
  647. @private
  648. */
  649. };
  650.  
  651. export default BaseButton;