coral-spectrum/coral-component-colorinput/src/scripts/ColorInput.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 {BaseFormField} from '../../../coral-base-formfield';
import Color from './Color';
import ColorInputItem from './ColorInputItem';
import {SelectableCollection} from '../../../coral-collection';
import {Icon} from '../../../coral-component-icon';
import '../../../coral-component-textfield';
import '../../../coral-component-button';
import '../../../coral-component-popover';
import './ColorInputColorProperties';
import './ColorInputSwatches';
import base from '../templates/base';
import {validate, transform, commons, i18n} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-ColorInput';
/**
Enumeration for {@link ColorInput} variants.
@typedef {Object} ColorInputVariantEnum
@property {String} DEFAULT
Use ColorInput as a formfield (default).
@property {String} SWATCH
Use a simple swatch as ColorInput.
*/
const variant = {
DEFAULT: 'default',
SWATCH: 'swatch'
};
/**
Enumeration for {@link ColorInput} auto generated colors options.
@typedef {Object} ColorInputAutoGenerateColorsEnum
@property {String} OFF
Disable auto generation.
@property {String} SHADES
Automatically generate shades (darker colors) of all colors.
@property {String} TINTS
Automatically generate tints (lighter colors) of all colors.
*/
const autoGenerateColors = {
OFF: 'off',
SHADES: 'shades',
TINTS: 'tints'
};
/**
Enumeration for {@link ColorInput} swatches display options.
@typedef {Object} ColorInputShowSwatchesEnum
@property {String} ON
Display swatches view (default).
@property {String} OFF
Hide swatches view.
*/
const showSwatches = {
ON: 'on',
OFF: 'off'
};
/**
Enumeration for {@link ColorInput} color properties display options.
@typedef {Object} ColorInputShowPropertiesEnum
@property {String} ON
Display color properties view (default).
@property {String} OFF
Hide color properties view.
*/
const showProperties = {
ON: 'on',
OFF: 'off'
};
/**
Enumeration for {@link ColorInput} default colors display options.
@typedef {Object} ColorInputShowDefaultColorsEnum
@property {String} ON
Display default colors (default).
@property {String} OFF
Hide default colors.
*/
const showDefaultColors = {
ON: 'on',
OFF: 'off'
};
/**
@class Coral.ColorInput
@classdesc A ColorInput component than can be used as a form field to select from a list of color options.
@htmltag coral-colorinput
@extends {HTMLElement}
@extends {BaseComponent}
@extends {BaseFormField}
*/
const ColorInput = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
/** @ignore */
constructor() {
super();
// Prepare templates
this._elements = {};
base.call(this._elements, {commons, i18n});
const overlay = this._elements.overlay;
const overlayId = overlay.id;
// Add a reference to this
overlay._colorinput = this;
// Extend form field events
const events = commons.extend(this._events, {
'key:down ._coral-ColorInput-input:not([readonly])': '_onKeyDown',
'key:down [handle="colorPreview"]': '_onKeyDown',
'click [handle="colorPreview"]': '_onColorPreviewClick',
'key:esc input': '_onKeyEsc',
'key:enter input': '_onKeyEsc',
// private
'coral-colorinput-item:_selectedchanged': '_onItemSelectedChanged'
});
// Overlay
events[`global:capture:coral-overlay:beforeopen #${overlayId}`] = '_beforeOverlayOpen';
events[`global:capture:coral-overlay:close #${overlayId}`] = '_onOverlayClose';
events[`global:key:esc #${overlayId}`] = '_onKeyEsc';
// Events
this._delegateEvents(events);
// Pre-define labellable element
this._labellableElement = this._elements.input;
// Used for eventing
this._oldSelection = null;
// Init the collection mutation observer
this.items._startHandlingItems(true);
}
/**
Returns the inner overlay to allow customization.
@type {Popover}
@readonly
*/
get overlay() {
return this._elements.overlay;
}
/**
The Collection Interface that allows interacting with the items that the component contains.
@type {SelectableCollection}
@readonly
*/
get items() {
// just init on demand
if (!this._items) {
this._items = new SelectableCollection({
host: this,
itemTagName: 'coral-colorinput-item',
onItemAdded: this._onItemAdded,
onItemRemoved: this._onItemRemoved
});
}
return this._items;
}
/**
The selected item in the ColorInput.
@type {HTMLElement}
@readonly
*/
get selectedItem() {
return this.items._getLastSelected();
}
/**
The ColorInput variant. See {@link ColorInputVariantEnum}.
@default ColorInputVariantEnum.DEFAULT
@type {String}
@htmlattribute variant
@htmlattributereflected
*/
get variant() {
return this._variant || variant.DEFAULT;
}
set variant(value) {
value = transform.string(value).toLowerCase();
this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
this._reflectAttribute('variant', this._variant);
if (this._variant === variant.SWATCH) {
this.classList.add('_coral-ColorInput--swatch');
this._elements.input.setAttribute('tabindex', -1);
this._elements.colorPreview.removeAttribute('tabindex');
} else {
this.classList.remove('_coral-ColorInput--swatch');
this._elements.input.removeAttribute('tabindex');
}
this._syncColorPreviewIcon();
}
/**
Convenient property to get/set the the current color. If the value is no valid color it will return
<code>null</code> (The getter will return a copy of the current selected color).
@type {Color}
*/
get valueAsColor() {
if (!this._color) {
this._color = new Color();
}
// sync this._color with the hidden field if necessary
const newColor = new Color();
newColor.value = this.value;
if (!this._color.isSimilarTo(newColor, true)) {
this._color.value = newColor.value;
this._color.alpha = newColor.alpha;
}
if (this._color.rgb === null) {
return null;
}
return this._color.clone();
}
set valueAsColor(value) {
if (!this._color) {
this._color = new Color();
}
if (!value) {
// clear color values
this._color.value = '';
this.value = '';
} else {
// set color values
this._color = value;
if (value.alpha < 1) {
// if an alpha value is used store rgba in the hidden field (it is the only format that can store alpha)
this.value = value.rgbaValue;
} else {
this.value = value.value;
}
}
}
/**
Should shades (darker colors) or tints (lighter colors) automatically be generated.
See {@link ColorInputAutoGenerateColorsEnum}.
@default Coral.ColorInput.autoGenerateColors.OFF
@type {String}
@htmlattribute autogeneratecolors
*/
get autoGenerateColors() {
return this._autoGenerateColors || autoGenerateColors.OFF;
}
set autoGenerateColors(value) {
value = transform.string(value).toLowerCase();
this._autoGenerateColors = validate.enumeration(autoGenerateColors)(value) && value || autoGenerateColors.OFF;
this._recalculateGeneratedColors();
}
/**
Whether swatches view should be displayed. See {@link ColorInputSwatches}.
@default ColorInputShowSwatchesEnum.ON
@type {ColorInputSwatches}
@htmlattribute showswatches
*/
get showSwatches() {
return this._showSwatches || showSwatches.ON;
}
set showSwatches(value) {
value = transform.string(value).toLowerCase();
this._showSwatches = validate.enumeration(showSwatches)(value) && value || showSwatches.ON;
this._showOrHideView(this._elements.swatchesView, this._showSwatches === showSwatches.OFF);
}
/**
Whether properties view should be displayed. See {@link ColorInputColorProperties}.
@default ColorInputShowPropertiesEnum.ON
@type {String}
@htmlattribute showproperties
*/
get showProperties() {
return this._showProperties || showProperties.ON;
}
set showProperties(value) {
value = transform.string(value).toLowerCase();
this._showProperties = validate.enumeration(showProperties)(value) && value || showProperties.ON;
this._showOrHideView(this._elements.propertiesView, this._showProperties === showProperties.OFF);
}
/**
Whether default colors should be displayed. Link {@link ColorInputShowDefaultColorsEnum}.
@default ColorInputShowDefaultColorsEnum.ON
@type {String}
@htmlattribute showdefaultcolors
*/
get showDefaultColors() {
return this._showDefaultColors || showDefaultColors.ON;
}
set showDefaultColors(value) {
value = transform.string(value).toLowerCase();
this._showDefaultColors = validate.enumeration(showDefaultColors)(value) && value || showDefaultColors.ON;
const defaultPalette = this._elements.defaultPalette;
if (this._showDefaultColors === showDefaultColors.ON) {
if (!defaultPalette.parentNode) {
this.insertBefore(defaultPalette, this.firstChild || null);
}
} else if (defaultPalette.parentNode) {
defaultPalette.parentNode.removeChild(defaultPalette);
}
}
/**
Short hint that describes the expected value of the ColorInput. It is displayed when the ColorInput is empty
and the variant is {@link Coral.ColorInput.variant.DEFAULT}
@type {String}
@default ""
@htmlattribute placeholder
@htmlattributereflected
*/
get placeholder() {
return this._elements.input.placeholder;
}
set placeholder(value) {
this._reflectAttribute('placeholder', value);
this._elements.input.placeholder = value;
}
/**
The value of the color. This value can be set in 5 different formats (HEX, RGB, RGBA, HSB and CMYK). Corrects a
hex value, if it is represented by 3 or 6 characters with or without '#'
e.g:
HEX: #FFFFFF
RGB: rgb(16,16,16)
RGBA: rgba(215,40,40,0.9)
RGBA: hsb(360,100,100)
CMYK: cmyk(0,100,50,0)
@type {String}
@default ""
@htmlattribute value
*/
get value() {
return this._value || '';
}
set value(value) {
const oldColor = new Color();
oldColor.value = this.value;
const newColor = new Color();
newColor.value = value;
if (!newColor.isSimilarTo(oldColor, false)) {
this._value = value;
// make sure right ColorInput.Item is selected even if input field was set by hand
this._selectColorInputColor(newColor);
// trigger a change event
this.trigger('coral-colorinput:_valuechange');
}
// always set the input to the current value
this._elements.input.value = this.value;
this._updateColorPreview();
}
/**
Name used to submit the data in a form.
@type {String}
@default ""
@htmlattribute name
@htmlattributereflected
*/
get name() {
return this._elements.input.name;
}
set name(value) {
this._reflectAttribute('name', value);
this._elements.input.name = value;
}
/**
Whether this field is disabled or not.
@type {Boolean}
@default false
@htmlattribute disabled
@htmlattributereflected
*/
get disabled() {
return this._disabled || false;
}
set disabled(value) {
this._disabled = transform.booleanAttr(value);
this._reflectAttribute('disabled', this._disabled);
this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
this.classList.toggle('is-disabled', this._disabled);
this._elements.input.disabled = this.disabled;
this._elements.colorPreview.disabled = this._disabled || this.readOnly;
this._syncColorPreviewIcon();
}
/**
Inherited from {@link BaseFormField#invalid}.
*/
get invalid() {
return super.invalid;
}
set invalid(value) {
super.invalid = value;
this._elements.input.invalid = this.invalid;
}
/**
Whether this field is required or not.
@type {Boolean}
@default false
@htmlattribute required
@htmlattributereflected
*/
get required() {
return this._required || false;
}
set required(value) {
this._required = transform.booleanAttr(value);
this._reflectAttribute('required', this._required);
this._elements.input.required = this._required;
}
/**
Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
@type {Boolean}
@default false
@htmlattribute readonly
@htmlattributereflected
*/
get readOnly() {
return this._readOnly || false;
}
set readOnly(value) {
this._readOnly = transform.booleanAttr(value);
this._reflectAttribute('readonly', this._readOnly);
this._elements.input.readOnly = this._readOnly;
this._elements.colorPreview.disabled = this.disabled || this._readOnly;
}
/**
Inherited from {@link BaseFormField#labelledBy}.
*/
get labelledBy() {
return super.labelledBy;
}
set labelledBy(value) {
super.labelledBy = value;
// Sync input aria-labelledby
this._elements.input[value ? 'setAttribute' : 'removeAttribute']('aria-labelledby', value);
// in case the user focuses the buttons, he will still get a notion of the usage of the component
if (this.labelledBy) {
this.setAttribute('aria-labelledby', this.labelledBy);
this._elements.colorPreview.setAttribute('aria-labelledby',
[this.labelledBy,
this._elements.colorPreview.label.id].join(' '));
} else {
this.removeAttribute('aria-labelledby');
this._elements.colorPreview.removeAttribute('aria-labelledby');
}
}
/** @private */
_onItemSelectedChanged(event) {
event.stopImmediatePropagation();
this._validateSelection(event.target);
}
/** @private */
_onItemAdded(item) {
this._validateSelection(item);
if (this._elements.overlay.open) {
// simply close the overlay whenever a color is added
this._elements.overlay.open = false;
}
}
/** @private */
_onItemRemoved() {
if (this._elements.overlay.open) {
// simply close the overlay whenever a color is removed
this._elements.overlay.open = false;
}
}
/** @private */
_validateSelection(item) {
const selectedItems = this.items._getAllSelected();
// Last selected item wins
item = item || selectedItems[selectedItems.length - 1];
if (item && item.hasAttribute('selected') && selectedItems.length > 1) {
selectedItems.forEach((selectedItem) => {
if (selectedItem !== item) {
// Don't trigger change events
this._preventTriggeringEvents = true;
selectedItem.removeAttribute('selected');
}
});
// We can trigger change events again
this._preventTriggeringEvents = false;
}
this._triggerChangeEvent();
}
/** @private */
_triggerChangeEvent() {
const selectedItem = this.selectedItem;
const oldSelection = this._oldSelection;
if (!this._preventTriggeringEvents && selectedItem !== oldSelection) {
// update hidden fields
if (selectedItem) {
this.value = selectedItem.getAttribute('value');
}
this.trigger('coral-colorinput:change', {
oldSelection: oldSelection,
selection: selectedItem
});
this._oldSelection = selectedItem;
}
}
/** @ignore */
_onColorPreviewClick(event) {
// restore focus to appropriate element when overlay closes
this._elements.overlay.returnFocusTo(this.variant === variant.SWATCH ? event.matchedTarget : this._elements.input);
}
_onInputChange(event) {
if (event.target === this._elements.input) {
// only handle changes to the hidden input field ...
// stops the current event
event.stopPropagation();
const color = new Color();
color.value = event.target[this._eventTargetProperty];
this._setActiveColor(color);
}
}
/** @ignore */
_onKeyDown(event) {
event.stopPropagation();
// restore focus to appropriate element when overlay closes
this._elements.overlay.returnFocusTo(this.variant === variant.SWATCH ? event.matchedTarget : this._elements.input);
this._elements.overlay.open = true;
}
/** @ignore */
_onKeyEsc(event) {
if (!this._elements.overlay.open) {
return;
}
event.stopPropagation();
this._elements.overlay.open = false;
}
/** @ignore */
_beforeOverlayOpen() {
// Make sure appropriate tabbable descendant will receive focus
if (this.showProperties === showProperties.ON) {
this._elements.overlay.focusOnShow = this._elements.propertiesView._elements.colorPreview2;
} else if (this.showSwatches === showSwatches.ON) {
this._elements.overlay.focusOnShow =
this._elements.overlay.querySelector('coral-colorinput-swatch[selected] > button') ||
'coral-colorinput-swatch > button';
}
// set aria-expanded state
this._elements.input.setAttribute('aria-expanded', true);
this._elements.colorPreview.setAttribute('aria-expanded', true);
}
/** @ignore */
_onOverlayClose() {
// set aria-expanded state
this._elements.input.setAttribute('aria-expanded', true);
this._elements.colorPreview.setAttribute('aria-expanded', false);
}
/**
Checks if the current input is valid or not. This check will only be performed on user interaction.
@ignore
*/
_validateInputValue() {
this.invalid = this.value !== '' && this.valueAsColor === null;
}
/** @ignore */
_showOrHideView(view, hide) {
view.hidden = hide;
// Remove both classes and add only the required one
this._elements.overlay.classList.remove('_coral-ColorInput-onlySwatchesView', '_coral-ColorInput-onlyPropertiesView');
if (!this._elements.propertiesView.hidden && this._elements.swatchesView.hidden) {
this._elements.overlay.classList.add('_coral-ColorInput-onlyPropertiesView');
} else if (this._elements.propertiesView.hidden && !this._elements.swatchesView.hidden) {
this._elements.overlay.classList.add('_coral-ColorInput-onlySwatchesView');
}
// Update accessibility label for colorPreview button when only swatches are shown
if (this.showProperties === showProperties.OFF &&
this.showSwatches === showSwatches.ON) {
this._elements.colorPreview.label.textContent = i18n.get('Swatches');
this._elements.overlay.setAttribute('aria-label', i18n.get('Swatches'));
} else {
this._elements.colorPreview.label.textContent = i18n.get('Color Picker');
this._elements.overlay.setAttribute('aria-label', i18n.get('Color Picker'));
}
}
/** @ignore */
_recalculateGeneratedColors() {
// remove old generated tint colors
const childrenList = this.querySelectorAll('coral-colorinput-item[coral-colorinput-generatedcolor]');
const childrenListLength = childrenList.length;
for (let i = 0 ; i < childrenListLength ; i++) {
childrenList[i].remove();
}
if (this.autoGenerateColors !== autoGenerateColors.OFF) {
const colorElements = this.items.getAll();
let colorEl = null;
let color = null;
let colorIndex = 0;
let generatedIndex = 0;
let generatedColorEl = null;
let generatedColors = [];
for (colorIndex = 0 ; colorIndex < colorElements.length ; colorIndex++) {
colorEl = colorElements[colorIndex];
color = new Color();
color.value = colorEl.value;
generatedColors = this.autoGenerateColors === autoGenerateColors.TINTS ? color.calculateTintColors(5) : color.calculateShadeColors(5);
for (generatedIndex = generatedColors.length - 1 ; generatedIndex >= 0 ; generatedIndex--) {
generatedColorEl = new ColorInputItem();
// be sure to add alpha
generatedColorEl.value = generatedColors[generatedIndex].rgbaValue;
generatedColorEl.setAttribute('coral-colorinput-generatedcolor', '');
colorEl.parentNode.insertBefore(generatedColorEl, colorEl.nextSibling);
}
}
}
}
/** @ignore */
_syncColorPreviewIcon() {
const colorPreview = this._elements.colorPreview;
colorPreview.icon = this.disabled && this.variant === variant.SWATCH ? 'lockClosed' : '';
colorPreview.iconSize = Icon.size.SMALL;
}
/** @ignore */
_setActiveColor(color) {
// method used by subviews to set a color and trigger a change event if needed
const oldColor = this.valueAsColor ? this.valueAsColor : new Color();
this.valueAsColor = color;
if (!oldColor.isSimilarTo(this.valueAsColor, false)) {
// test if current color is invalid
this._validateInputValue();
// trigger a change event (change events should only be triggered when an user interaction happened)
this.trigger('change');
}
}
/** @ignore */
_selectColorInputColor(newColor) {
let selectColorInItems = true;
if (this.selectedItem) {
const selectedColor = new Color();
selectedColor.value = this.selectedItem.value;
// only select color if it is not already selected
selectColorInItems = !selectedColor.isSimilarTo(newColor, true);
}
if (selectColorInItems) {
// select right color in this.items (if necessary and possible)
const selectedItem = this.selectedItem;
if (selectedItem) {
selectedItem.removeAttribute('selected');
}
const colorElements = this.items.getAll();
const colorElementsCount = colorElements.length;
let color = null;
for (let i = 0 ; i < colorElementsCount ; i++) {
color = new Color();
color.value = colorElements[i].getAttribute('value');
if (color.isSimilarTo(newColor, true)) {
colorElements[i].setAttribute('selected', '');
break;
}
}
}
}
/** @private */
_setDefaultSelectedItem() {
const selectedItem = this.selectedItem;
const value = this.value;
// Sync selectedItem if value is set
if (value && !selectedItem) {
const color = new Color();
color.value = value;
this._selectColorInputColor(color);
}
// Also sync color preview
this._updateColorPreview();
}
_updateColorPreview() {
const isValueEmpty = this.value === '';
// update color preview
const currentColor = this.valueAsColor;
this._elements.colorPreview.style.backgroundColor = currentColor ? currentColor.rgbaValue : '';
this.classList.toggle('_coral-ColorInput--novalue', isValueEmpty);
// Update preview in overlay
const preview = this._elements.overlay.querySelector('._coral-ColorInput-preview');
if (preview) {
preview.classList.toggle('_coral-ColorInput-preview--novalue', isValueEmpty);
}
this._elements.input.setAttribute("aria-label", this._items._container._color._value);
}
/**
Returns {@link ColorInput} variants.
@return {ColorInputVariantEnum}
*/
static get variant() {
return variant;
}
/**
Returns {@link ColorInput} auto generated colors options.
@return {ColorInputAutoGenerateColorsEnum}
*/
static get autoGenerateColors() {
return autoGenerateColors;
}
/**
Returns {@link ColorInput} swatches display options.
@return {ColorInputShowSwatchesEnum}
*/
static get showSwatches() {
return showSwatches;
}
/**
Returns {@link ColorInput} color properties display options.
@return {ColorInputShowDefaultColorsEnum}
*/
static get showDefaultColors() {
return showDefaultColors;
}
/**
Returns {@link ColorInput} default colors display options.
@return {ColorInputShowPropertiesEnum}
*/
static get showProperties() {
return showProperties;
}
static get _attributePropertyMap() {
return commons.extend(super._attributePropertyMap, {
autogeneratecolors: 'autoGenerateColors',
showswatches: 'showSwatches',
showproperties: 'showProperties',
showdefaultcolors: 'showDefaultColors'
});
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'variant',
'autogeneratecolors',
'showswatches',
'showproperties',
'showdefaultcolors',
'placeholder'
]);
}
/** @ignore */
connectedCallback() {
super.connectedCallback();
const overlay = this._elements.overlay;
// Cannot be open by default when rendered
overlay.removeAttribute('open');
// Restore in DOM
if (overlay._parent) {
overlay._parent.appendChild(overlay);
}
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
this.setAttribute('role', 'group');
const frag = document.createDocumentFragment();
// Render template
frag.appendChild(this._elements.defaultPalette);
frag.appendChild(this._elements.input);
frag.appendChild(this._elements.buttonWrapper);
frag.appendChild(this._elements.overlay);
// Support cloneNode
while (this.firstChild) {
const child = this.firstChild;
if (child.nodeType === Node.ELEMENT_NODE && child.hasAttribute('handle')) {
this.removeChild(child);
} else {
frag.appendChild(child);
}
}
// we use 'this' so properly aligns to the input
this._elements.overlay.target = this._elements.colorPreview;
this.appendChild(frag);
// Make sure colors are generated
this.autoGenerateColors = this.autoGenerateColors;
this.showDefaultColors = this.showDefaultColors;
// Don't trigger events once connected
this._preventTriggeringEvents = true;
// Make sure we don't have multiple items selected
this._validateSelection();
// If value is set to default palette item value, we have to make sure it's selected
this._setDefaultSelectedItem();
// We can trigger events gain
this._preventTriggeringEvents = false;
this._oldSelection = this.selectedItem;
}
/** @ignore */
disconnectedCallback() {
super.disconnectedCallback();
const overlay = this._elements.overlay;
// In case it was moved out don't forget to remove it
if (!this.contains(overlay)) {
overlay._parent = overlay._repositioned ? document.body : this;
overlay.remove();
}
}
});
export default ColorInput;