ExamplesPlaygroundReference Source

coral-spectrum/coral-component-icon/src/scripts/Icon.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 {transform, validate, commons, i18n} from '../../../coral-utils';
  15. import ICON_MAP from '../../../coral-compat/data/iconMap';
  16. import SPECTRUM_ICONS_PATH from '../resources/spectrum-icons.svg';
  17. import SPECTRUM_ICONS_COLOR_PATH from '../resources/spectrum-icons-color.svg';
  18. import SPECTRUM_CSS_ICONS_PATH from '../resources/spectrum-css-icons.svg';
  19. import loadIcons from './loadIcons';
  20. import {Decorator} from '../../../coral-decorator';
  21. import {SPECTRUM_ICONS, SPECTRUM_ICONS_COLOR, SPECTRUM_CSS_ICONS} from './iconCollection';
  22.  
  23. const SPECTRUM_ICONS_IDENTIFIER = 'spectrum-';
  24. const SPECTRUM_COLORED_ICONS_IDENTIFIER = [
  25. 'ColorLight',
  26. 'Color_Light',
  27. 'ColorDark',
  28. 'Color_Dark',
  29. 'ColorActive',
  30. 'Color_Active',
  31. // Unique colored icons
  32. 'AdobeExperienceCloudColor',
  33. 'AdobeExperiencePlatformColor',
  34. ];
  35.  
  36. let resourcesPath = (commons.options.icons || '').trim();
  37. if (resourcesPath.length && resourcesPath[resourcesPath.length - 1] !== '/') {
  38. resourcesPath += '/';
  39. }
  40.  
  41. // @IE11
  42. const IS_IE11 = !window.ActiveXObject && 'ActiveXObject' in window;
  43. let iconsExternal = commons.options.iconsExternal || 'on';
  44. if (IS_IE11) {
  45. iconsExternal = 'off';
  46. }
  47.  
  48. const resolveIconsPath = (iconsPath) => {
  49. const path = commons._script.src;
  50. return `${path.split('/').slice(0, -iconsPath.split('/').length).join('/')}/${iconsPath}`;
  51. };
  52.  
  53. /**
  54. Regex used to match URLs. Assume it's a URL if it has a slash, colon, or dot.
  55.  
  56. @ignore
  57. */
  58. const URL_REGEX = /\/|:|\./g;
  59.  
  60. /**
  61. Regex used to match unresolved templates e.g. for data-binding
  62.  
  63. @ignore
  64. */
  65. const TEMPLATE_REGEX = /.*\{\{.+\}\}.*/g;
  66.  
  67. /**
  68. Regex used to split camel case icon names into more screen-reader friendly alt text.
  69.  
  70. @ignore
  71. */
  72. const SPLIT_CAMELCASE_REGEX = /([a-z])([A-Z0-9])/g;
  73.  
  74. /**
  75. Regex used to match the sized spectrum icon prefix
  76.  
  77. @ignore
  78. */
  79. const SPECTRUM_ICONS_IDENTIFIER_REGEX = /^spectrum(?:-css)?-icon(?:-\d{1,3})?-/gi;
  80.  
  81. /**
  82. Regex used match the variant postfix for an icon
  83.  
  84. @ignore
  85. */
  86. const ICONS_VARIANT_POSTFIX_REGEX = /(Outline)?(Filled)?(Small|Medium|Large)?(Color)?_?(Active|Dark|Light)?$/;
  87.  
  88. /**
  89. Translation hint used for localizing default alt text for an icon
  90.  
  91. @ignore
  92. */
  93. const ICON_ALT_TRANSLATION_HINT = 'default icon alt text';
  94.  
  95. /**
  96. Returns capitalized string. This is used to map the icons with their SVG counterpart.
  97.  
  98. @ignore
  99. @param {String} s
  100. @return {String}
  101. */
  102. const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
  103.  
  104. /**
  105. Enumeration for {@link Icon} sizes.
  106.  
  107. @typedef {Object} IconSizeEnum
  108.  
  109. @property {String} EXTRA_EXTRA_SMALL
  110. Extra extra small size icon, typically 9px size.
  111. @property {String} EXTRA_SMALL
  112. Extra small size icon, typically 12px size.
  113. @property {String} SMALL
  114. Small size icon, typically 18px size. This is the default size.
  115. @property {String} MEDIUM
  116. Medium size icon, typically 24px size.
  117. @property {String} LARGE
  118. Large icon, typically 36px size.
  119. @property {String} EXTRA_LARGE
  120. Extra large icon, typically 48px size.
  121. @property {String} EXTRA_EXTRA_LARGE
  122. Extra extra large icon, typically 72px size.
  123. */
  124. const size = {
  125. EXTRA_EXTRA_SMALL: 'XXS',
  126. EXTRA_SMALL: 'XS',
  127. SMALL: 'S',
  128. MEDIUM: 'M',
  129. LARGE: 'L',
  130. EXTRA_LARGE: 'XL',
  131. EXTRA_EXTRA_LARGE: 'XXL'
  132. };
  133.  
  134.  
  135. /**
  136. Enumeration for {@link Icon} autoAriaLabel value.
  137.  
  138. @typedef {Object} IconAutoAriaLabelEnum
  139.  
  140. @property {String} ON
  141. The aria-label attribute is automatically set based on the icon name.
  142. @property {String} OFF
  143. The aria-label attribute is not set and has to be provided explicitly.
  144. */
  145. const autoAriaLabel = {
  146. ON: 'on',
  147. OFF: 'off'
  148. };
  149.  
  150. // icon's base classname
  151. const CLASSNAME = '_coral-Icon';
  152.  
  153. // builds an array containing all possible size classnames. this will be used to remove classnames when the size
  154. // changes
  155. const ALL_SIZE_CLASSES = [];
  156. for (const sizeValue in size) {
  157. ALL_SIZE_CLASSES.push(`${CLASSNAME}--size${size[sizeValue]}`);
  158. }
  159.  
  160. // Based on https://github.com/adobe/spectrum-css/tree/master/icons
  161. const sizeMap = {
  162. XXS: 18,
  163. XS: 24,
  164. S: 18,
  165. M: 24,
  166. L: 18,
  167. XL: 24,
  168. XXL: 24
  169. };
  170.  
  171. /**
  172. @class Coral.Icon
  173. @classdesc An Icon component. Icon ships with a set of SVG icons.
  174. @htmltag coral-icon
  175. @extends {HTMLElement}
  176. @extends {BaseComponent}
  177. */
  178. const Icon = Decorator(class extends BaseComponent(HTMLElement) {
  179. /** @ignore */
  180. constructor() {
  181. super();
  182.  
  183. this._elements = {};
  184. }
  185.  
  186. /**
  187. Whether aria-label is set automatically. See {@link IconAutoAriaLabelEnum}.
  188.  
  189. @type {String}
  190. @default IconAutoAriaLabelEnum.OFF
  191. */
  192. get autoAriaLabel() {
  193. return this._autoAriaLabel || autoAriaLabel.OFF;
  194. }
  195.  
  196. set autoAriaLabel(value) {
  197. value = transform.string(value).toLowerCase();
  198. value = validate.enumeration(autoAriaLabel)(value) && value || autoAriaLabel.OFF;
  199. if(validate.valueMustChange(this._autoAriaLabel, value)) {
  200. this._autoAriaLabel = value;
  201. this._updateAltText();
  202. }
  203. }
  204.  
  205. /**
  206. Icon name.
  207.  
  208. @type {String}
  209. @default ""
  210. @htmlattribute icon
  211. @htmlattributereflected
  212. */
  213. get icon() {
  214. return this._icon || '';
  215. }
  216.  
  217. set icon(value) {
  218. const icon = transform.string(value).trim();
  219.  
  220. // Avoid rendering the same icon
  221. if (icon !== this._icon || this.hasAttribute('_context')) {
  222. this._icon = icon;
  223. this._reflectAttribute('icon', this._icon);
  224.  
  225. // Ignore unresolved templates
  226. if (this._icon.match(TEMPLATE_REGEX)) {
  227. return;
  228. }
  229.  
  230. // Use the existing img
  231. if (this._hasRawImage) {
  232. this._elements.image.classList.add(CLASSNAME, `${CLASSNAME}--image`);
  233. this._updateAltText();
  234. return;
  235. }
  236.  
  237. // Remove image and SVG elements
  238. ['image', 'svg'].forEach((type) => {
  239. const el = this._elements[type] || this.querySelector(`.${CLASSNAME}--${type}`);
  240. if (el) {
  241. el.remove();
  242. }
  243. });
  244.  
  245. // Sets the desired icon
  246. if (this._icon) {
  247. // Detect if it's a URL
  248. if (this._icon.match(URL_REGEX)) {
  249. // Create an image and add it to the icon
  250. this._elements.image = this._elements.image || document.createElement('img');
  251. this._elements.image.className = `${CLASSNAME} ${CLASSNAME}--image`;
  252. this._elements.image.src = this.icon;
  253. this.appendChild(this._elements.image);
  254. } else {
  255. this._updateIcon();
  256. }
  257. }
  258.  
  259. this._updateAltText();
  260. }
  261. }
  262.  
  263. /**
  264. Size of the icon. It accepts both lower and upper case sizes. See {@link IconSizeEnum}.
  265.  
  266. @type {String}
  267. @default IconSizeEnum.SMALL
  268. @htmlattribute size
  269. @htmlattributereflected
  270. */
  271. get size() {
  272. return this._size || size.SMALL;
  273. }
  274.  
  275. set size(value) {
  276. const oldSize = this._size;
  277.  
  278. value = transform.string(value).toUpperCase();
  279. value = validate.enumeration(size)(value) && value || size.SMALL;
  280. this._reflectAttribute('size', value);
  281. if(validate.valueMustChange(this._size, value)) {
  282. this._size = value;
  283.  
  284. // removes all the existing sizes
  285. this.classList.remove(...ALL_SIZE_CLASSES);
  286. // adds the new size
  287. this.classList.add(`${CLASSNAME}--size${value}`);
  288. // We need to update the icon if the size changed
  289. if (oldSize && oldSize !== value && this.contains(this._elements.svg)) {
  290. this._elements.svg.remove();
  291. this._updateIcon();
  292. }
  293. this._updateAltText();
  294. }
  295. }
  296.  
  297. /** @private */
  298. get title() {
  299. return this.getAttribute('title');
  300. }
  301.  
  302. set title(value) {
  303. this.setAttribute('title', value);
  304. }
  305.  
  306. /** @private */
  307. get alt() {
  308. return this.getAttribute('alt');
  309. }
  310.  
  311. set alt(value) {
  312. this.setAttribute('alt', value);
  313. }
  314.  
  315. _updateIcon() {
  316. let iconId = this.icon;
  317.  
  318. // If icon name is passed, we have to build the icon Id based on the icon name
  319. if (iconId.indexOf(SPECTRUM_ICONS_IDENTIFIER) !== 0) {
  320. const iconMapped = ICON_MAP[iconId];
  321. let iconName;
  322.  
  323. // Restore default state
  324. this.removeAttribute('_context');
  325.  
  326. if (iconMapped) {
  327. if (iconMapped.spectrumIcon) {
  328. // Use the default mapped icon
  329. iconName = iconMapped.spectrumIcon;
  330. } else {
  331. // Verify if icon should be light or dark by looking up parents theme
  332. const closest = this.closest('.coral--light, .coral--dark, .coral--lightest, .coral--darkest');
  333.  
  334. if (closest) {
  335. if (closest.classList.contains('coral--light') || closest.classList.contains('coral--lightest')) {
  336. // Use light icon
  337. iconName = iconMapped.spectrumIconLight;
  338. } else {
  339. // Use dark icon
  340. iconName = iconMapped.spectrumIconDark;
  341. }
  342. }
  343. // Use light by default
  344. else {
  345. iconName = iconMapped.spectrumIconLight;
  346. }
  347.  
  348. // Mark icon as contextual icon because the icon name is defined based on the theme
  349. this.setAttribute('_context', '');
  350. }
  351.  
  352. // Inform user about icon name changes
  353. if (iconName) {
  354. commons._log('warn', `Coral.Icon: the icon ${iconId} has been deprecated. Please use ${iconName} instead.`);
  355. } else {
  356. commons._log('warn', `Coral.Icon: the icon ${iconId} has been removed. Please contact Icons@Adobe.`);
  357. }
  358. }
  359. // In most cases, using the capitalized icon name maps to the spectrum icon name
  360. else {
  361. iconName = capitalize(iconId);
  362. }
  363.  
  364. // Verify if icon name is a colored icon
  365. if (SPECTRUM_COLORED_ICONS_IDENTIFIER.some(identifier => iconName.indexOf(identifier) !== -1)) {
  366. // Colored icons are 24 by default
  367. iconId = `spectrum-icon-24-${iconName}`;
  368. } else {
  369. const sizeAttribute = this.getAttribute('size');
  370. const iconSize = sizeMap[sizeAttribute && sizeAttribute.toUpperCase() || size.SMALL];
  371. iconId = `spectrum-icon-${iconSize}-${iconName}`;
  372. }
  373. }
  374.  
  375. // Insert SVG Icon using HTML because DOMly doesn't support document.createElementNS for <use> element
  376. this.insertAdjacentHTML('beforeend', this.constructor._renderSVG(iconId));
  377.  
  378. this._elements.svg = this.lastElementChild;
  379. }
  380.  
  381. /**
  382. Updates the aria-label or img alt attribute depending on value of alt, title, icon and autoAriaLabel.
  383.  
  384. In cases where the alt attribute has been removed or set to an empty string,
  385. for example, when the alt property is undefined and we add the attribute alt=''
  386. to explicitly override the default behavior, or when we remove an alt attribute
  387. thus restoring the default behavior, we make sure to update the alt text.
  388. @private
  389. */
  390. _updateAltText(value) {
  391. const hasAutoAriaLabel = this.autoAriaLabel === autoAriaLabel.ON;
  392. const img = this._elements.image;
  393. const isImage = this.contains(img);
  394.  
  395. // alt should be prioritized over title
  396. let altText = typeof this.alt === 'string' ? this.alt : this.title;
  397.  
  398. if (typeof value === 'string') {
  399. altText = this.alt || value;
  400. } else if (isImage) {
  401. altText = altText || img.getAttribute('alt') || img.getAttribute('title') || '';
  402. } else if (hasAutoAriaLabel) {
  403. let iconName = this.icon.replace(SPECTRUM_ICONS_IDENTIFIER_REGEX, '');
  404. iconName = iconName.replace(ICONS_VARIANT_POSTFIX_REGEX, '');
  405. altText = i18n.get(iconName.replace(SPLIT_CAMELCASE_REGEX, '$1 $2').toLowerCase(), ICON_ALT_TRANSLATION_HINT);
  406. }
  407.  
  408. // If no other role has been set, provide the appropriate
  409. // role depending on whether or not the icon is an arbitrary image URL.
  410. const role = this.getAttribute('role');
  411. const roleOverride = role && (role !== 'presentation' && role !== 'img');
  412. if (!roleOverride) {
  413. this.setAttribute('role', isImage ? 'presentation' : 'img');
  414. }
  415.  
  416. // Set accessibility attributes accordingly
  417. if (isImage) {
  418. hasAutoAriaLabel && this.removeAttribute('aria-label');
  419. img.setAttribute('alt', altText);
  420. } else if (altText === '') {
  421. this.removeAttribute('aria-label');
  422. if (!roleOverride) {
  423. this.removeAttribute('role');
  424. }
  425. } else if (altText) {
  426. this.setAttribute('aria-label', altText);
  427. }
  428. }
  429.  
  430. /**
  431. Whether SVG icons are referenced as external resource (on/off)
  432.  
  433. @return {String}
  434. */
  435. static _iconsExternal() {
  436. return iconsExternal;
  437. }
  438.  
  439. /**
  440. Returns the SVG markup.
  441.  
  442. @param {String} iconId
  443. @param {Array.<String>} additionalClasses
  444. @return {String}
  445. */
  446. static _renderSVG(iconId, additionalClasses = []) {
  447. additionalClasses.unshift(CLASSNAME);
  448. additionalClasses.unshift(`${CLASSNAME}--svg`);
  449.  
  450. let iconPath = `#${iconId}`;
  451.  
  452. // If not colored icons
  453. if (this._iconsExternal() === 'on' && !SPECTRUM_COLORED_ICONS_IDENTIFIER.some(identifier => iconId.indexOf(identifier) !== -1)) {
  454. // Generate spectrum-css-icons path
  455. if (iconId.indexOf('spectrum-css') === 0) {
  456. iconPath = resourcesPath ? `${resourcesPath}${SPECTRUM_CSS_ICONS}.svg#${iconId}` : `${resolveIconsPath(SPECTRUM_CSS_ICONS_PATH)}#${iconId}`;
  457. }
  458. // Generate spectrum-icons path
  459. else {
  460. iconPath = resourcesPath ? `${resourcesPath}${SPECTRUM_ICONS}.svg#${iconId}` : `${resolveIconsPath(SPECTRUM_ICONS_PATH)}#${iconId}`;
  461. }
  462. }
  463.  
  464. return `
  465. <svg focusable="false" aria-hidden="true" class="${additionalClasses.join(' ')}">
  466. <use xlink:href="${iconPath}"></use>
  467. </svg>
  468. `;
  469. }
  470.  
  471. /**
  472. Returns {@link Icon} sizes.
  473.  
  474. @return {IconSizeEnum}
  475. */
  476. static get size() {
  477. return size;
  478. }
  479.  
  480. /**
  481. Returns {@link Icon} autoAriaLabel options.
  482.  
  483. @return {IconAutoAriaLabelEnum}
  484. */
  485. static get autoAriaLabel() {
  486. return autoAriaLabel;
  487. }
  488.  
  489. /**
  490. Loads the SVG icons. It's requesting the icons based on the JS file path by default.
  491.  
  492. @param {String} [url] SVG icons url.
  493. */
  494. static load(url) {
  495. const resolveIconsPath = (iconsPath) => {
  496. const path = commons._script.src;
  497. if (iconsExternal === 'js') {
  498. iconsPath = iconsPath.replace('.svg', '.js');
  499. }
  500.  
  501. return `${path.split('/').slice(0, -iconsPath.split('/').length).join('/')}/${iconsPath}`;
  502. };
  503.  
  504. if (url === SPECTRUM_ICONS) {
  505. url = resolveIconsPath(SPECTRUM_ICONS_PATH);
  506. } else if (url === SPECTRUM_ICONS_COLOR) {
  507. url = resolveIconsPath(SPECTRUM_ICONS_COLOR_PATH);
  508. } else if (url === SPECTRUM_CSS_ICONS) {
  509. url = resolveIconsPath(SPECTRUM_CSS_ICONS_PATH);
  510. }
  511.  
  512. loadIcons(url);
  513. }
  514.  
  515. static get _attributePropertyMap() {
  516. return commons.extend(super._attributePropertyMap, {
  517. autoarialabel: 'autoAriaLabel'
  518. });
  519. }
  520.  
  521. /** @ignore */
  522. static get observedAttributes() {
  523. return super.observedAttributes.concat(['autoarialabel', 'icon', 'size', 'alt', 'title']);
  524. }
  525.  
  526. /** @ignore */
  527. attributeChangedCallback(name, oldValue, value) {
  528. if (name === 'alt' || name === 'title') {
  529. this._updateAltText(value);
  530. } else {
  531. super.attributeChangedCallback(name, oldValue, value);
  532. }
  533. }
  534.  
  535. /** @ignore */
  536. connectedCallback() {
  537. super.connectedCallback();
  538.  
  539. // Contextual icons need to be checked again
  540. if (this.hasAttribute('_context')) {
  541. this.icon = this.icon;
  542. }
  543. }
  544.  
  545. /** @ignore */
  546. render() {
  547. super.render();
  548.  
  549. this.classList.add(CLASSNAME);
  550.  
  551. // Set default size
  552. if (!this._size) {
  553. this.size = size.SMALL;
  554. }
  555.  
  556. const img = this.querySelector(`img:not(.${CLASSNAME}--image)`);
  557. if (img) {
  558. this._elements.image = img;
  559. this._hasRawImage = true;
  560. this.icon = img.getAttribute('src');
  561. this._hasRawImage = false;
  562. }
  563. }
  564. });
  565.  
  566. // Load icon collections by default
  567. const iconCollections = [SPECTRUM_ICONS_COLOR];
  568. let extension = '.svg';
  569. if (Icon._iconsExternal() === 'off' || Icon._iconsExternal() === 'js') {
  570. iconCollections.push(SPECTRUM_CSS_ICONS);
  571. iconCollections.push(SPECTRUM_ICONS);
  572.  
  573. if (Icon._iconsExternal() === 'js') {
  574. extension = '.js';
  575. }
  576. }
  577. iconCollections.forEach(iconSet => Icon.load(resourcesPath ? `${resourcesPath}${iconSet}${extension}` : iconSet));
  578.  
  579. export default Icon;