coral-spectrum/coral-component-colorpicker/src/scripts/ColorPicker.js
/**
* Copyright 2021 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 ColorFormats from './ColorFormats';
import '../../../coral-component-textfield';
import '../../../coral-component-button';
import '../../../coral-component-popover';
import base from '../templates/base';
import {validate, transform, commons, i18n} from '../../../coral-utils';
import { TinyColor } from '@ctrl/tinycolor';
import colorUtil from "./ColorUtil";
const CLASSNAME = '_coral-ColorPicker';
/**
@class Coral.ColorPicker
@classdesc A ColorPicker component than can be used as a form field to select from a list of color options.
@htmltag coral-colorpicker
@extends {HTMLElement}
@extends {BaseComponent}
@extends {BaseFormField}
*/
class ColorPicker 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;
// Extend form field events
const events = commons.extend(this._events, {
'key:down ._coral-ColorPicker-input:not([readonly])': '_onKeyDown',
'key:down [handle="colorPreview"]': '_onKeyDown',
'click [handle="colorPreview"]': '_onColorPreviewClick',
'key:esc input': '_onKeyEsc',
'key:enter input': '_onKeyEsc',
'capture:change [handle="input"]': '_onColorInputChange',
'change [handle="propertiesView"]': '_onPropertyChange'
});
// 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);
this.value = "";
this._format = ColorFormats.HSL;
this._sendChaneEvent = this._chaneEventDebounced();
}
/** @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 */
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();
}
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
this.setAttribute('role', 'group');
const frag = document.createDocumentFragment();
// Render template
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);
}
}
this.appendChild(frag);
// These should be used to set a property since property handler aren't called until elements are attached to dom.
// Attribute values are delivered to change-listeners even if element isn't attached to dom yet, so attributes
// can be set to e.g. this._elements.colorPreview.
this._input = this.querySelector("[handle='input']");
this._preview = this.querySelector("[handle='colorPreview']");
this._overlay = this.querySelector("[handle='overlay']");
this._overlay.setAttribute('aria-live', 'off');
this._properties = this._overlay.querySelector("[handle='propertiesView']");
this._update(this._value);
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'value',
'formats',
'disabled',
'label',
'labelledby',
'readonly'
]);
}
/** @ignore */
static get _attributePropertyMap() {
return commons.extend(super._attributePropertyMap, {
labelledby: 'labelledBy',
readonly: 'readOnly'
});
}
/**
The ColorPicker label.
@default 'Select Color'
@type {String}
@htmlattribute label
@htmlattributereflected
*/
get label() {
return this._label || i18n.get('Color Picker');
}
set label(value) {
this._label = value;
this._reflectAttribute('label', this.label);
this._elements.input.setAttribute('aria-label', this.label);
}
/**
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');
}
}
/**
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[this._disabled ? 'setAttribute' : 'removeAttribute']('disabled', this._disabled);
this._elements.colorPreview[this._disabled ? 'setAttribute' : 'removeAttribute']('disabled', this._disabled);
}
/**
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[this._readOnly ? 'setAttribute' : 'removeAttribute']('readonly', this._readOnly);
this._elements.colorPreview[(this.disabled || this._readOnly) ? 'setAttribute' : 'removeAttribute']('disabled', this.disabled || this._readOnly);
}
/**
The ColorPicker value. value should be a valid color in supported format.
@default Empty
@type {String}
@htmlattribute label
@htmlattributereflected
*/
get value() {
return this._value;
}
set value(value) {
this._update(value);
}
/**
The ColorPicker formats. comma separated formats should be in supported formats.
Any invalid/unsupported format is ignored.
Values selected in any other format will be converted to this format.
@default ColorFormats.HSL
@type {String}
@htmlattribute formats
@htmlattributereflected
*/
get formats() {
return this._formats || "";
}
set formats(value) {
let formats = value.split(',');
formats = colorUtil.getValidFormats(formats);
if(formats.length > 0) {
this._formats = formats;
this._format = formats[0];
this._reflectAttribute('formats', this._formats);
this._elements.propertiesView.setAttribute('formats', value);
// refresh color in this new format
this._update(colorUtil.formatColorString(this.value, this._format));
}
}
/** @private */
_update(value) {
if(this.value === value) {
return;
}
// sync UI for empty value
this.classList[ (value == "") ? 'add' : 'remove']('_coral-ColorPicker--novalue');
this._elements.colorPreview.classList[ (value == "") ? 'add' : 'remove']('_coral-ColorPicker-preview--novalue');
// Empty value isn't invalid.
let color = new TinyColor(value);
let isInvalid = (value !== "" && !color.isValid);
this[isInvalid ? 'setAttribute' : 'removeAttribute']('invalid', "true");
this._elements.input[isInvalid ? 'setAttribute' : 'removeAttribute']('invalid', "true");
if(color.isValid && (!this._formats || this._formats.indexOf(color.format) !== -1)) {
this._format = color.format;
}
this._value = (value == "" || !color.isValid) ? value : colorUtil.formatColorString(value, this._format);
this._elements.input.value = this._value;
this._elements.propertiesView.setAttribute('color', this._value);
this._elements.colorPreview.style["background-color"] = new TinyColor(this._value).toHslString();
}
_debounce(func, timeout = 1000) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => { func.apply(this, args); }, timeout);
};
}
_chaneEventDebounced() {
var self = this;
return this._debounce(function() {
self.trigger('change');
}, 1000);
}
/** @private */
_change(color) {
this._update(color);
var self = this;
this._sendChaneEvent();
}
/***************** Interaction handlers***********/
/** @private */
_onKeyDown(event) {
event.stopPropagation();
// restore focus to appropriate element when overlay closes
this._elements.overlay.returnFocusTo(event.matchedTarget);
this._elements.overlay.open = true;
}
/** @private */
_onKeyEsc(event) {
if (!this._elements.overlay.open) {
return;
}
event.stopPropagation();
this._elements.overlay.open = false;
}
/** @private */
_onColorPreviewClick(event) {
// restore focus to appropriate element when overlay closes
this._elements.overlay.returnFocusTo(event.matchedTarget);
}
/** @private */
_beforeOverlayOpen() {
// set aria-expanded state
this._elements.input.setAttribute('aria-expanded', true);
this._elements.colorPreview.setAttribute('aria-expanded', true);
}
_onOverlayClose() {
// set aria-expanded state
this._elements.input.setAttribute('aria-expanded', true);
this._elements.colorPreview.setAttribute('aria-expanded', false);
}
/** @private */
_onColorInputChange(event) {
this._update(this._input.value);
}
/** @private */
_onPropertyChange(event) {
event.stopImmediatePropagation();
this._change(event.detail ? event.detail : this._properties.color);
}
}
export default ColorPicker;