coral-spectrum/coral-component-steplist/src/scripts/StepList.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 {SelectableCollection} from '../../../coral-collection';
import {transform, validate, commons, i18n} from '../../../coral-utils';
import getTarget from './getTarget';
import {Decorator} from '../../../coral-decorator';
/**
Enumeration for {@link StepList} interaction options.
@todo support "click only past steps" mode
@typedef {Object} StepListInteractionEnum
@property {String} ON
Steps can be clicked to visit them.
@property {String} OFF
Steps cannot be clicked.
*/
const interaction = {
ON: 'on',
OFF: 'off'
};
/**
Enumeration for {@link StepList} sizes.
@typedef {Object} StepListSizeEnum
@property {String} SMALL
A small-sized StepList.
@property {String} LARGE
A large-sized StepList.
*/
const size = {
SMALL: 'S',
LARGE: 'L'
};
// the StepList's base classname
const CLASSNAME = '_coral-Steplist';
/**
@class Coral.StepList
@classdesc A StepList component that holds a collection of steps.
@htmltag coral-steplist
@extends {HTMLElement}
@extends {BaseComponent}
*/
const StepList = Decorator(class extends BaseComponent(HTMLElement) {
/** @ignore */
constructor() {
super();
this._delegateEvents({
'click > coral-step > [handle="link"]': '_onStepClick',
'capture:focus > coral-step': '_onStepMouseEnter',
'capture:mouseenter > coral-step > [handle="link"]': '_onStepMouseEnter',
'capture:blur > coral-step': '_onStepMouseLeave',
'capture:mouseleave > coral-step > [handle="link"]': '_onStepMouseLeave',
'key:enter > coral-step > [handle="link"]': '_onStepKeyboardSelect',
'key:space > coral-step > [handle="link"]': '_onStepKeyboardSelect',
'key:home > coral-step > [handle="link"]': '_onHomeKey',
'key:end > coral-step > [handle="link"]': '_onEndKey',
'key:pagedown > coral-step > [handle="link"]': '_selectNextItem',
'key:right > coral-step > [handle="link"]': '_selectNextItem',
'key:down > coral-step > [handle="link"]': '_selectNextItem',
'key:pageup > coral-step > [handle="link"]': '_selectPreviousItem',
'key:left > coral-step > [handle="link"]': '_selectPreviousItem',
'key:up > coral-step > [handle="link"]': '_selectPreviousItem',
// private
'coral-step:_selectedchanged': '_onItemSelectedChanged'
});
// Used for eventing
this._oldSelection = null;
// Init the collection mutation observer
this.items._startHandlingItems(true);
}
/**
The Collection Interface that allows interacting with the items that the component contains.
@type {SelectableCollection}
@readonly
*/
get items() {
// we do lazy initialization of the collection
if (!this._items) {
this._items = new SelectableCollection({
host: this,
itemTagName: 'coral-step',
onItemAdded: this._validateSelection,
onItemRemoved: this._validateSelection
});
}
return this._items;
}
/**
Returns the selected step.
@type {HTMLElement}
@readonly
*/
get selectedItem() {
return this.items._getLastSelected();
}
/**
The target component that will be linked to the StepList. It accepts either a CSS selector or a DOM element. If
a CSS Selector is provided, the first matching element will be used. Items will be selected based on the index.
If both target and {@link Coral.Step#target} are set, the second will have higher priority.
@type {?HTMLElement|String}
@default null
@htmlattribute target
*/
get target() {
return this._target || null;
}
set target(value) {
if (value === null || typeof value === 'string' || value instanceof Node) {
this._target = value;
// we do this in the sync in case the target was not yet in the DOM
window.requestAnimationFrame(() => {
const realTarget = getTarget(this._target);
// we add proper accessibility if available
if (realTarget) {
const stepItems = this.items.getAll();
const panelItems = realTarget.items ? realTarget.items.getAll() : realTarget.children;
// we need to add a11y to all components, regardless of whether they can be perfectly paired
const maxItems = Math.max(stepItems.length, panelItems.length);
let step;
let panel;
for (let i = 0 ; i < maxItems ; i++) {
step = stepItems[i];
panel = panelItems[i];
// if the step has its own target, we assume the target component will handle its own accessibility.
// if the target is an empty string we simply ignore it
if (step && step.target && step.target.trim() !== '') {
continue;
}
if (panel) {
panel.setAttribute('role', 'region');
}
if (step && panel) {
// sets the required ids
step.id = step.id || commons.getUID();
panel.id = panel.id || commons.getUID();
// creates a 2 way binding for accessibility
step.setAttribute('aria-controls', panel.id);
panel.setAttribute('aria-labelledby', step.id);
} else if (step) {
// cleans the aria since there is no matching panel
step.removeAttribute('aria-controls');
} else {
// cleans the aria since there is no matching Step
panel.removeAttribute('aria-labelledby');
}
}
}
});
}
}
/**
The size of the StepList. It accepts both lower and upper case sizes. Currently only "S" and "L" (the default)
are available.
See {@link StepListSizeEnum}.
@type {String}
@default StepListSizeEnum.LARGE
@htmlattribute size
@htmlattributereflected
*/
get size() {
return this._size || size.LARGE;
}
set size(value) {
value = transform.string(value).toUpperCase();
this._size = validate.enumeration(size)(value) && value || size.LARGE;
this._reflectAttribute('size', this._size);
this.classList.toggle(`${CLASSNAME}--small`, this._size === size.SMALL);
if (!this.items.length) {
return;
}
// update aria-label for all children
const _syncItemLabelled = () => {
const isSmall = this.size === size.SMALL;
const steps = this.items.getAll();
const stepsCount = steps.length;
for (let i = 0 ; i < stepsCount ; i++) {
const step = steps[i];
const label = step._elements.label;
if (!step.labelled && label.textContent.length) {
label.classList.toggle('u-coral-screenReaderOnly', isSmall);
label.style.display = isSmall ? 'block' : '';
}
}
};
const lastItem = this.items.last();
if (typeof lastItem._syncTabIndex === 'function') {
_syncItemLabelled();
} else {
commons.ready(lastItem, _syncItemLabelled);
}
}
/**
Whether Steps should be interactive or not. When interactive, a Step can be clicked to jump to it.
See {@link StepListInteractionEnum}.
@type {String}
@default StepListInteractionEnum.OFF
@htmlattribute interaction
@htmlattributereflected
*/
get interaction() {
return this._interaction || interaction.OFF;
}
set interaction(value) {
value = transform.string(value).toLowerCase();
this._interaction = validate.enumeration(interaction)(value) && value || interaction.OFF;
this._reflectAttribute('interaction', this._interaction);
const isInteractive = this._interaction === interaction.ON;
this.classList.toggle(`${CLASSNAME}--interactive`, isInteractive);
if (!this.items.length) {
return;
}
// update tab index for all children
const _syncItemProps = () => {
const steps = this.items.getAll();
const stepsCount = steps.length;
for (let i = 0 ; i < stepsCount ; i++) {
// update tab index for all children
steps[i]._syncTabIndex(isInteractive);
//update posin set and total size for all steps
steps[i]._syncSizeAndCurrentIndex(i + 1, stepsCount);
}
};
const lastItem = this.items.last();
if (typeof lastItem._syncTabIndex === 'function') {
_syncItemProps();
} else {
commons.ready(lastItem, _syncItemProps);
}
}
/** @private */
_syncItemTabIndex(item) {
item._syncTabIndex(this.interaction === interaction.ON);
}
/** @private */
_onItemSelectedChanged(event) {
event.stopImmediatePropagation();
const item = event.target;
this._syncItemTabIndex(item);
this._validateSelection(item);
}
/** @private */
_validateSelection(item) {
// gets the current selection
const selection = this.items._getAllSelected();
const selectionCount = selection.length;
// if no item is currently selected, we need to find a candidate
if (selectionCount === 0) {
// gets the first candidate for selection
const selectable = this.items._getFirstSelectable();
if (selectable) {
selectable.setAttribute('selected', '');
}
}
// more items are selected, so we find a single item and deselect everything else
else if (selectionCount > 1) {
// By default, the last one stays selected
item = item || selection[selection.length - 1];
for (let i = 0 ; i < selectionCount ; i++) {
if (selection[i] !== item) {
// Don't trigger change events
this._preventTriggeringEvents = true;
selection[i].removeAttribute('selected');
}
}
// We can trigger change events again
this._preventTriggeringEvents = false;
}
// sets the state-related classes every time the selection changes
this._setStateClasses();
this._triggerChangeEvent();
}
/** @private */
_triggerChangeEvent() {
const selectedItem = this.selectedItem;
const oldSelection = this._oldSelection;
if (!this._preventTriggeringEvents && selectedItem !== oldSelection) {
this.trigger('coral-steplist:change', {
oldSelection: oldSelection,
selection: selectedItem
});
this._oldSelection = selectedItem;
}
}
/** @private */
_setStateClasses() {
let selectedItemIndex = Infinity;
this.items.getAll().forEach((item, index) => {
// Use attribute instead of property as items might not be initialized
if (item.hasAttribute('selected')) {
// Mark which one is selected
selectedItemIndex = index;
}
// Add/remove classes based on index
item.classList.toggle('is-complete', index < selectedItemIndex);
if (!item._elements) {
return;
}
// Set accessibilityState text label
let accessibilityLabel = i18n.get('not completed: ');
if (index < selectedItemIndex) {
accessibilityLabel = i18n.get('completed: ');
} else if (index === selectedItemIndex) {
accessibilityLabel = i18n.get('current: ');
}
item._elements.accessibilityLabel.innerHTML = accessibilityLabel;
});
}
/** @private */
_onStepKeyboardSelect(event) {
if (this.interaction === interaction.ON) {
event.preventDefault();
event.stopPropagation();
const item = event.matchedTarget.closest('coral-step');
this._selectAndFocusItem(item);
this._trackEvent('click', 'coral-steplist-item', event, item);
}
}
/** @private */
_onStepClick(event) {
if (this.interaction === interaction.ON) {
event.preventDefault();
event.stopPropagation();
const item = event.matchedTarget.closest('coral-step');
// Disabled item should not get selected
if (item.disabled) {
return;
}
this._selectAndFocusItem(item);
this._trackEvent('click', 'coral-steplist-item', event, item);
}
}
/** @private */
_onStepMouseEnter() {
if (this.size === size.SMALL) {
const step = event.target.closest('coral-step');
// we only show the tooltip if we have a label to show
if (step._elements.label.innerHTML.trim() !== '') {
step._elements.overlay.content.innerHTML = step._elements.label.innerHTML;
step._elements.overlay.open = true;
}
}
}
/** @private */
_onStepMouseLeave(event) {
if (this.size === size.SMALL) {
const step = event.target.closest('coral-step');
step._elements.overlay.open = false;
}
}
/** @private */
_onHomeKey(event) {
if (this.interaction === interaction.ON) {
event.preventDefault();
const item = this.items._getFirstSelectable();
this._selectAndFocusItem(item);
}
}
/** @private */
_onEndKey(event) {
if (this.interaction === interaction.ON) {
event.preventDefault();
const item = this.items._getLastSelectable();
this._selectAndFocusItem(item);
}
}
/** @private */
_selectNextItem(event) {
if (this.interaction === interaction.ON) {
event.preventDefault();
this.next();
}
}
/** @private */
_selectPreviousItem(event) {
if (this.interaction === interaction.ON) {
event.preventDefault();
this.previous();
}
}
/** @private */
_selectAndFocusItem(item) {
if (item) {
item.setAttribute('selected', '');
item.focus();
}
}
/**
Show the next Step.
@emits {coral-steplist:change}
*/
next() {
let item = this.selectedItem;
if (item) {
item = this.items._getNextSelectable(item);
this._selectAndFocusItem(item);
}
}
/**
Show the previous Step.
@emits {coral-steplist:change}
*/
previous() {
let item = this.selectedItem;
if (item) {
item = this.items._getPreviousSelectable(item);
this._selectAndFocusItem(item);
}
}
/**
Returns {@link StepList} sizes.
@return {StepListSizeEnum}
*/
static get size() {
return size;
}
/**
Returns {@link StepList} interaction options.
@return {StepListInteractionEnum}
*/
static get interaction() {
return interaction;
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat(['target', 'size', 'interaction']);
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
// Default reflected attributes
if (!this._interaction) {
this.interaction = interaction.OFF;
}
if (!this._size) {
this.size = size.LARGE;
}
// A11y
this.setAttribute('role', 'list');
// provide accessibility label for the list
if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
this.setAttribute('aria-label', i18n.get('Step List'));
}
// the screen reader should not navigate to hidden element
// the element is hidden if has only one child
if (this.items.length === 1) {
this.setAttribute('aria-hidden', 'true');
this.interaction = StepList.interaction.OFF;
}
// Don't trigger events once connected
this._preventTriggeringEvents = true;
this._validateSelection();
this._preventTriggeringEvents = false;
this._oldSelection = this.selectedItem;
}
/**
Triggered when the {@link StepList} selected {@link Step} has changed.
@typedef {CustomEvent} coral-steplist:change
@property {Step} detail.selection
The newly selected Step.
@property {Step} detail.oldSelection
The previously selected Step.
*/
});
export default StepList;