coral-spectrum/coral-component-columnview/src/scripts/ColumnViewItem.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 accessibilityState from '../templates/accessibilityState';
import {BaseComponent} from '../../../coral-base-component';
import {BaseLabellable} from '../../../coral-base-labellable';
import {Icon} from '../../../coral-component-icon';
import {Checkbox} from '../../../coral-component-checkbox';
import {commons, i18n, transform, validate} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-AssetList-item';
/**
Enumeration for {@link ColumnViewItem} variants.
@typedef {Object} ColumnViewItemVariantEnum
@property {String} DEFAULT
Default item variant. Contains no special decorations.
@property {String} DRILLDOWN
An item with a right arrow indicating that the navigation will go one level down.
*/
const variant = {
DEFAULT: 'default',
DRILLDOWN: 'drilldown'
};
/**
Utility that identifies Chrome on macOS, which announces drilldown items as "row 1 expanded" or "row 1 collapsed" when navigating between items.
*/
const isChromeMacOS = !!window && !!window.chrome && /Mac/i.test(window.navigator.platform);
/**
@class Coral.ColumnView.Item
@classdesc A ColumnView Item component
@htmltag coral-columnview-item
@extends {HTMLElement}
@extends {BaseComponent}
*/
const ColumnViewItem = Decorator(class extends BaseLabellable(BaseComponent(HTMLElement)) {
/** @ignore */
constructor() {
super();
// Content zone
this._elements = {
content: this.querySelector('coral-columnview-item-content') || document.createElement('coral-columnview-item-content'),
thumbnail: this.querySelector('coral-columnview-item-thumbnail') || document.createElement('coral-columnview-item-thumbnail'),
accessibilityState: this.querySelector('span[handle="accessibilityState"]')
};
if (!this._elements.accessibilityState) {
// Templates
accessibilityState.call(this._elements, {commons});
}
}
/**
The content of the item.
@type {ColumnViewItemContent}
@contentzone
*/
get content() {
return this._getContentZone(this._elements.content);
}
set content(value) {
this._setContentZone('content', value, {
handle: 'content',
tagName: 'coral-columnview-item-content',
insert: function (content) {
content.classList.add(`${CLASSNAME}Label`);
// Insert before chevron
this.insertBefore(content, this.querySelector('._coral-AssetList-itemChildIndicator'));
}
});
}
/**
The thumbnail of the item. It is used to hold an icon or an image.
@type {ColumnViewItemThumbnail}
@contentzone
*/
get thumbnail() {
return this._getContentZone(this._elements.thumbnail);
}
set thumbnail(value) {
this._setContentZone('thumbnail', value, {
handle: 'thumbnail',
tagName: 'coral-columnview-item-thumbnail',
insert: function (thumbnail) {
thumbnail.classList.add(`${CLASSNAME}Thumbnail`);
// Insert before content
this.insertBefore(thumbnail, this.content || null);
}
});
}
/**
The item's variant. See {@link ColumnViewItemVariantEnum}.
@type {String}
@default ColumnViewItemVariantEnum.DEFAULT
@htmlattribute variant
@htmlattributereflected
*/
get variant() {
return this._variant || variant.DEFAULT;
}
set variant(value) {
value = transform.string(value).toLowerCase();
value = validate.enumeration(variant)(value) && value || variant.DEFAULT;
this._reflectAttribute('variant', value);
if(validate.valueMustChange(this._variant, value)) {
this._variant = value;
if (value === variant.DRILLDOWN) {
// Render chevron on demand
const childIndicator = this.querySelector('._coral-AssetList-itemChildIndicator');
if (!childIndicator) {
this.insertAdjacentHTML('beforeend', Icon._renderSVG('spectrum-css-icon-ChevronRightMedium', ['_coral-AssetList-itemChildIndicator', '_coral-UIIcon-ChevronRightMedium']));
}
this.classList.add('is-branch');
// @a11y Update aria-expanded. Active drilldowns should be expanded.
// Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
// to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
// navigating between items.
if (this.selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
this.removeAttribute('aria-expanded');
} else {
this.setAttribute('aria-expanded', this.active);
}
} else {
this.classList.remove('is-branch');
this.removeAttribute('aria-expanded');
}
}
}
/**
Specifies the icon that will be placed inside the thumbnail. The size of the icon is always controlled by the
component.
@type {String}
@default ""
@htmlattribute icon
@htmlattributereflected
*/
get icon() {
return this._icon || '';
}
set icon(value) {
value = transform.string(value);
this._reflectAttribute('icon', value);
if(validate.valueMustChange(this._icon, value)) {
this._icon = value;
// ignored if it is an empty string
if (value) {
// creates a new icon element
if (!this._elements.icon) {
this._elements.icon = new Icon();
// register observer only if there present an icon field.
super._observeLabel();
}
this._elements.icon.icon = this.icon;
this._elements.icon.size = Icon.size.SMALL;
// removes all the items, since the icon attribute has precedence
this._elements.thumbnail.innerHTML = '';
// adds the newly created icon
this._elements.thumbnail.appendChild(this._elements.icon);
}
super._toggleIconAriaHidden();
}
}
/**
Whether the item is selected.
@type {Boolean}
@default false
@htmlattribute selected
@htmlattributereflected
*/
get selected() {
return this._selected || false;
}
set selected(value) {
value = transform.booleanAttr(value);
this._reflectAttribute('selected', value);
if(validate.valueMustChange(this._selected, value)) {
this._selected = value;
this.trigger('coral-columnview-item:_selectedchanged');
// wait a frame before updating attributes
commons.nextFrame(() => {
this.classList.toggle('is-selected', value);
this.setAttribute('aria-selected', value);
// @a11y Update aria-expanded. Active drilldowns should be expanded.
// Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
// to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
// navigating between items.
if (value === variant.DRILLDOWN) {
if (this._selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
this.removeAttribute('aria-expanded');
} else {
this.setAttribute('aria-expanded', this.active);
}
}
let accessibilityState = this._elements.accessibilityState;
if (value) {
// @a11y Panels to right of selected item are removed, so remove aria-owns and aria-describedby attributes.
this.removeAttribute('aria-owns');
this.removeAttribute('aria-describedby');
// @a11y Update content to ensure that checked state is announced by assistive technology when the item receives focus
accessibilityState.innerHTML = i18n.get(', checked');
// @a11y append live region content element
if (!this.contains(accessibilityState)) {
this.appendChild(accessibilityState);
}
}
// @a11y If deselecting from checked state,
else {
// @a11y remove, but retain reference to accessibilityState state
if (accessibilityState.parentNode) {
this._elements.accessibilityState = accessibilityState.parentNode.removeChild(accessibilityState);
}
// @a11y Update content to remove checked state
this._elements.accessibilityState.innerHTML = '';
}
// @a11y Item should be labelled by thumbnail, content, and if appropriate accessibility state.
let ariaLabelledby = this._elements.thumbnail.id + ' ' + this._elements.content.id;
this.setAttribute('aria-labelledby', this.selected ? `${ariaLabelledby} ${accessibilityState.id}` : ariaLabelledby);
// Sync checkbox item selector
const itemSelector = this.querySelector('coral-checkbox[coral-columnview-itemselect]');
if (itemSelector) {
itemSelector[value ? 'setAttribute' : 'removeAttribute']('checked', '');
}
});
}
}
/**
Whether the item is active.
@type {Boolean}
@default false
@htmlattribut active
@htmlattributereflected
*/
get active() {
return this._active || false;
}
set active(value) {
value = transform.booleanAttr(value);
this._reflectAttribute('active', value);
if(validate.valueMustChange(this._active, value)) {
this._active = value;
this.classList.toggle('is-navigated', value);
this.setAttribute('aria-selected', this.hasAttribute('_selectable') ? this.selected : value);
// @a11y Update aria-expanded. Active drilldowns should be expanded.
// Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
// to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
// navigating between items.
if (this.variant === variant.DRILLDOWN) {
if (this.selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
this.removeAttribute('aria-expanded');
} else {
this.setAttribute('aria-expanded', this.active);
}
}
if (!value) {
// @a11y Inactive items are not expanded, so remove aria-owns and aria-describedby attributes.
this.removeAttribute('aria-owns');
this.removeAttribute('aria-describedby');
}
this.trigger('coral-columnview-item:_activechanged');
}
}
get _contentZones() {
return {
'coral-columnview-item-content': 'content',
'coral-columnview-item-thumbnail': 'thumbnail'
};
}
/** @ignore */
attributeChangedCallback(name, oldValue, value) {
if (name === '_selectable') {
// Disable selection
if (value === null) {
this.classList.remove('is-selectable');
}
// Enable selection
else {
this.classList.add('is-selectable');
let itemSelector = this.querySelector('[coral-columnview-itemselect]');
// Render checkbox on demand
if (!itemSelector) {
itemSelector = new Checkbox();
itemSelector.setAttribute('coral-columnview-itemselect', '');
if (this.classList.contains('is-selected')) {
itemSelector.setAttribute('checked', '');
}
itemSelector._elements.input.tabIndex = -1;
itemSelector.setAttribute('labelledby', this._elements.content.id);
// Add the item selector as first child
this.insertBefore(itemSelector, this.firstChild);
}
}
} else {
super.attributeChangedCallback(name, oldValue, value);
}
}
/**
Returns {@link ColumnViewItem} variants.
@return {ColumnViewItemVariantEnum}
*/
static get variant() {
return variant;
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'variant',
'icon',
'selected',
'active',
'_selectable'
]);
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
// @a11y
this.setAttribute('role', 'treeitem');
this.id = this.id || commons.getUID();
// only set tabIndex if it is not already set
if (!this.hasAttribute('tabindex')) {
this.tabIndex = this.active || this.selected ? 0 : -1;
}
// Default reflected attributes
if (!this._variant) {
this.variant = variant.DEFAULT;
}
const thumbnail = this._elements.thumbnail;
const content = this._elements.content;
const contentZoneProvided = content.parentNode || thumbnail.parentNode;
if (!contentZoneProvided) {
// move the contents of the item into the content zone
while (this.firstChild) {
content.appendChild(this.firstChild);
}
}
// Assign content zones
this.content = content;
this.thumbnail = thumbnail;
// @a11y thumbnail img element should have alt attribute
const thumbnailImg = thumbnail.querySelector('img:not([alt])');
if (thumbnailImg) {
thumbnailImg.setAttribute('alt', '');
}
// @ally add aria-labelledby so that JAWS/IE announces item correctly
thumbnail.id = thumbnail.id || commons.getUID();
content.id = content.id || commons.getUID();
// @a11y Add live region element to ensure announcement of selected state
const accessibilityState = this._elements.accessibilityState;
// @a11y accessibility state string should announce in document lang, rather than item lang.
accessibilityState.setAttribute('lang', i18n.locale);
// @a11y Item should be labelled by thumbnail, content, and accessibility state.
this.setAttribute('aria-labelledby', thumbnail.id + ' ' + content.id);
//adding html title, on hovering over textcontent title will be visible
this.setAttribute('title', this.content.textContent.trim());
}
});
export default ColumnViewItem;