import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core'; import color from 'color'; import { clamp } from '../../utilities/math'; /** * @since 2.0 * @status stable * * @part base - The component's base wrapper. * @part trigger - The color picker's dropdown trigger. * @part copy-button - The copy button. * @part swatches - The container that holds swatches. * @part swatch - Each individual swatch. * @part grid - The color grid. * @part grid-handle - The color grid's handle. * @part hue-slider - The hue slider. * @part opacity-slider - The opacity slider. * @part slider - Hue and opacity sliders. * @part slider-handle - Hue and opacity slider handles. * @part preview - The preview color. * @part input - The text input. * @part copy-button - The copy button. */ @Component({ tag: 'sl-color-picker', styleUrl: 'color-picker.scss', shadow: true }) export class ColorPicker { bypassValueParse = false; copyButton: HTMLSlButtonElement; dropdown: HTMLSlDropdownElement; lastValueEmitted: string; menu: HTMLElement; input: HTMLSlInputElement; trigger: HTMLButtonElement; @Element() host: HTMLSlColorPickerElement; @State() inputValue = ''; @State() hue = 0; @State() saturation = 100; @State() lightness = 100; @State() alpha = 100; @State() showCopyCheckmark = false; /** The current color. */ @Prop({ mutable: true, reflect: true }) value = '#ffffff'; /** * The format to use for the display value. If opacity is enabled, these will translate to HEXA, RGBA, and HSLA * respectively. The color picker will always accept user input in any format (including CSS color names) and convert * it to the desired format. */ @Prop() format: 'hex' | 'rgb' | 'hsl' = 'hex'; /** Set to true to render the color picker inline rather than inside a dropdown. */ @Prop() inline = false; /** Determines the size of the color picker's trigger. This has no effect on inline color pickers. */ @Prop() size: 'small' | 'medium' | 'large' = 'medium'; /** The input's name attribute. */ @Prop({ reflect: true }) name = ''; /** Set to true to disable the color picker. */ @Prop() disabled = false; /** * This will be true when the control is in an invalid state. Validity is determined by the `setCustomValidity()` * method using the browser's constraint validation API. */ @Prop({ mutable: true, reflect: true }) invalid = false; /** * Enable this option to prevent the panel from being clipped when the component is placed inside a container with * `overflow: auto|scroll`. */ @Prop() hoist = false; /** Whether to show the opacity slider. */ @Prop() opacity = false; /** By default, the value will be set in lowercase. Set this to true to set it in uppercase instead. */ @Prop() uppercase = false; /** * An array of predefined color swatches to display. Can include any format the color picker can parse, including * HEX(A), RGB(A), HSL(A), and CSS color names. */ @Prop() swatches = [ '#d0021b', '#f5a623', '#f8e71c', '#8b572a', '#7ed321', '#417505', '#bd10e0', '#9013fe', '#4a90e2', '#50e3c2', '#b8e986', '#000', '#444', '#888', '#ccc', '#fff' ]; /** Emitted when the color picker's value changes. */ @Event({ eventName: 'sl-change' }) slChange: EventEmitter; /** Emitted when the color picker opens. Calling `event.preventDefault()` will prevent it from being opened. */ @Event({ eventName: 'sl-show' }) slShow: EventEmitter; /** Emitted after the color picker opens and all transitions are complete. */ @Event({ eventName: 'sl-after-show' }) slAfterShow: EventEmitter; /** Emitted when the color picker closes. Calling `event.preventDefault()` will prevent it from being closed. */ @Event({ eventName: 'sl-hide' }) slHide: EventEmitter; /** Emitted after the color picker closes and all transitions are complete. */ @Event({ eventName: 'sl-after-hide' }) slAfterHide: EventEmitter; @Watch('value') handleValueChange(newValue: string, oldValue: string) { if (!this.bypassValueParse) { const newColor = this.parseColor(newValue); if (newColor) { this.inputValue = this.value; this.hue = newColor.hsla.h; this.saturation = newColor.hsla.s; this.lightness = newColor.hsla.l; this.alpha = newColor.hsla.a * 100; } else { this.inputValue = oldValue; } } if (this.value !== this.lastValueEmitted) { this.slChange.emit(); this.lastValueEmitted = this.value; } } connectedCallback() { this.handleAlphaDrag = this.handleAlphaDrag.bind(this); this.handleAlphaInput = this.handleAlphaInput.bind(this); this.handleAlphaKeyDown = this.handleAlphaKeyDown.bind(this); this.handleCopy = this.handleCopy.bind(this); this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); this.handleDrag = this.handleDrag.bind(this); this.handleDropdownAfterHide = this.handleDropdownAfterHide.bind(this); this.handleDropdownAfterShow = this.handleDropdownAfterShow.bind(this); this.handleDropdownHide = this.handleDropdownHide.bind(this); this.handleDropdownShow = this.handleDropdownShow.bind(this); this.handleGridDrag = this.handleGridDrag.bind(this); this.handleGridKeyDown = this.handleGridKeyDown.bind(this); this.handleHueDrag = this.handleHueDrag.bind(this); this.handleHueInput = this.handleHueInput.bind(this); this.handleHueKeyDown = this.handleHueKeyDown.bind(this); this.handleLightnessInput = this.handleLightnessInput.bind(this); this.handleSaturationInput = this.handleSaturationInput.bind(this); this.handleInputChange = this.handleInputChange.bind(this); this.handleInputKeyDown = this.handleInputKeyDown.bind(this); } componentWillLoad() { if (!this.setColor(this.value)) { this.setColor(`#ffff`); } this.inputValue = this.value; this.lastValueEmitted = this.value; this.syncValues(); } /** Checks for validity and shows the browser's validation message if the control is invalid. */ @Method() async reportValidity() { // If the input is invalid, show the dropdown so the browser can focus on it if (!this.inline && this.input.invalid) { // this.dropdown.show(); // setTimeout(() => { // this.input.reportValidity(); // }, 300); return new Promise(resolve => { this.dropdown.addEventListener( 'sl-after-show', () => { this.input.reportValidity(); resolve(); }, { once: true } ); this.dropdown.show(); }); } else { return this.input.reportValidity(); } } /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ @Method() async setCustomValidity(message: string) { await this.input.setCustomValidity(message); this.invalid = this.input.invalid; } handleCopy() { this.input.select().then(() => { document.execCommand('copy'); this.copyButton.setFocus(); this.showCopyCheckmark = true; setTimeout(() => (this.showCopyCheckmark = false), 1000); }); } handleHueInput(event: Event) { const target = event.target as HTMLInputElement; this.hue = clamp(Number(target.value), 0, 360); } handleSaturationInput(event: Event) { const target = event.target as HTMLInputElement; this.saturation = clamp(Number(target.value), 0, 100); } handleLightnessInput(event: Event) { const target = event.target as HTMLInputElement; this.lightness = clamp(Number(target.value), 0, 100); } handleAlphaInput(event: Event) { const target = event.target as HTMLInputElement; this.alpha = clamp(Number(target.value), 0, 100); } handleAlphaDrag(event: any) { const container = this.host.shadowRoot.querySelector('.color-picker__slider.color-picker__alpha') as HTMLElement; const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement; const { width } = container.getBoundingClientRect(); handle.focus(); event.preventDefault(); this.handleDrag(event, container, x => { this.alpha = clamp((x / width) * 100, 0, 100); this.syncValues(); }); } handleHueDrag(event: any) { const container = this.host.shadowRoot.querySelector('.color-picker__slider.color-picker__hue') as HTMLElement; const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement; const { width } = container.getBoundingClientRect(); handle.focus(); event.preventDefault(); this.handleDrag(event, container, x => { this.hue = clamp((x / width) * 360, 0, 360); this.syncValues(); }); } handleGridDrag(event: any) { const grid = this.host.shadowRoot.querySelector('.color-picker__grid') as HTMLElement; const handle = grid.querySelector('.color-picker__grid-handle') as HTMLElement; const { width, height } = grid.getBoundingClientRect(); handle.focus(); event.preventDefault(); this.handleDrag(event, grid, (x, y) => { this.saturation = clamp((x / width) * 100, 0, 100); this.lightness = clamp(100 - (y / height) * 100, 0, 100); this.syncValues(); }); } handleDrag(event: any, container: HTMLElement, onMove: (x: number, y: number) => void) { if (this.disabled) { return false; } const move = (event: any) => { const dims = container.getBoundingClientRect(); const offsetX = dims.left + container.ownerDocument.defaultView.pageXOffset; const offsetY = dims.top + container.ownerDocument.defaultView.pageYOffset; const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX; const y = (event.changedTouches ? event.changedTouches[0].pageY : event.pageY) - offsetY; onMove(x, y); }; // Move on init move(event); const stop = () => { document.removeEventListener('mousemove', move); document.removeEventListener('touchmove', move); document.removeEventListener('mouseup', stop); document.removeEventListener('touchend', stop); }; document.addEventListener('mousemove', move); document.addEventListener('touchmove', move); document.addEventListener('mouseup', stop); document.addEventListener('touchend', stop); } handleAlphaKeyDown(event: KeyboardEvent) { const increment = event.shiftKey ? 10 : 1; if (event.key === 'ArrowLeft') { event.preventDefault(); this.alpha = clamp(this.alpha - increment, 0, 100); this.syncValues(); } if (event.key === 'ArrowRight') { event.preventDefault(); this.alpha = clamp(this.alpha + increment, 0, 100); this.syncValues(); } if (event.key === 'Home') { event.preventDefault(); this.alpha = 0; this.syncValues(); } if (event.key === 'End') { event.preventDefault(); this.alpha = 100; this.syncValues(); } } handleHueKeyDown(event: KeyboardEvent) { const increment = event.shiftKey ? 10 : 1; if (event.key === 'ArrowLeft') { event.preventDefault(); this.hue = clamp(this.hue - increment, 0, 360); this.syncValues(); } if (event.key === 'ArrowRight') { event.preventDefault(); this.hue = clamp(this.hue + increment, 0, 360); this.syncValues(); } if (event.key === 'Home') { event.preventDefault(); this.hue = 0; this.syncValues(); } if (event.key === 'End') { event.preventDefault(); this.hue = 360; this.syncValues(); } } handleGridKeyDown(event: KeyboardEvent) { const increment = event.shiftKey ? 10 : 1; if (event.key === 'ArrowLeft') { event.preventDefault(); this.saturation = clamp(this.saturation - increment, 0, 100); this.syncValues(); } if (event.key === 'ArrowRight') { event.preventDefault(); this.saturation = clamp(this.saturation + increment, 0, 100); this.syncValues(); } if (event.key === 'ArrowUp') { event.preventDefault(); this.lightness = clamp(this.lightness + increment, 0, 100); this.syncValues(); } if (event.key === 'ArrowDown') { event.preventDefault(); this.lightness = clamp(this.lightness - increment, 0, 100); this.syncValues(); } } handleInputChange(event: CustomEvent) { const target = event.target as HTMLInputElement; this.setColor(target.value); target.value = this.value; event.stopPropagation(); } handleInputKeyDown(event: KeyboardEvent) { if (event.key === 'Enter') { this.setColor(this.input.value); this.input.value = this.value; setTimeout(() => this.input.select()); } } handleDocumentMouseDown(event: MouseEvent) { const target = event.target as HTMLElement; // Close when clicking outside of the dropdown if (target.closest('sl-color-picker') !== this.host) { this.dropdown.hide(); } } handleDropdownShow(event: CustomEvent) { event.stopPropagation(); this.slShow.emit(); } handleDropdownAfterShow(event: CustomEvent) { event.stopPropagation(); this.slAfterShow.emit(); } handleDropdownHide(event: CustomEvent) { event.stopPropagation(); this.slHide.emit(); } handleDropdownAfterHide(event: CustomEvent) { event.stopPropagation(); this.slAfterHide.emit(); } normalizeColorString(colorString: string) { // // The color module we're using doesn't parse % values for the alpha channel in RGBA and HSLA. It also doesn't parse // hex colors when the # is missing. This pre-parser tries to normalize these edge cases to provide a better // experience for users who type in color values. // if (/rgba?/i.test(colorString)) { const rgba = colorString .replace(/[^\d.%]/g, ' ') .split(' ') .map(val => val.trim()) .filter(val => val.length); if (rgba.length < 4) { rgba[3] = '1'; } if (rgba[3].indexOf('%') > -1) { rgba[3] = (Number(rgba[3].replace(/%/g, '')) / 100).toString(); } return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${rgba[3]})`; } if (/hsla?/i.test(colorString)) { const hsla = colorString .replace(/[^\d.%]/g, ' ') .split(' ') .map(val => val.trim()) .filter(val => val.length); if (hsla.length < 4) { hsla[3] = '1'; } if (hsla[3].indexOf('%') > -1) { hsla[3] = (Number(hsla[3].replace(/%/g, '')) / 100).toString(); } return `hsla(${hsla[0]}, ${hsla[1]}, ${hsla[2]}, ${hsla[3]})`; } if (/^[0-9a-f]+$/i.test(colorString)) { return `#${colorString}`; } return colorString; } parseColor(colorString: string) { function toHex(value: number) { const hex = Math.round(value).toString(16); return hex.length === 1 ? `0${hex}` : hex; } let parsed: any; // The color module has a weak parser, so we normalize certain things to make the user experience better colorString = this.normalizeColorString(colorString); try { parsed = color(colorString); } catch { return false; } const hsl = { h: parsed.hsl().color[0], s: parsed.hsl().color[1], l: parsed.hsl().color[2], a: parsed.hsl().valpha }; const rgb = { r: parsed.rgb().color[0], g: parsed.rgb().color[1], b: parsed.rgb().color[2], a: parsed.rgb().valpha }; const hex = { r: toHex(parsed.rgb().color[0]), g: toHex(parsed.rgb().color[1]), b: toHex(parsed.rgb().color[2]), a: toHex(parsed.rgb().valpha * 255) }; return { hsl: { h: hsl.h, s: hsl.s, l: hsl.l, string: this.setLetterCase(`hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%)`) }, hsla: { h: hsl.h, s: hsl.s, l: hsl.l, a: hsl.a, string: this.setLetterCase( `hsla(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%, ${Number( hsl.a.toFixed(2).toString() )})` ) }, rgb: { r: rgb.r, g: rgb.g, b: rgb.b, string: this.setLetterCase(`rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`) }, rgba: { r: rgb.r, g: rgb.g, b: rgb.b, a: rgb.a, string: this.setLetterCase( `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${Number( rgb.a.toFixed(2).toString() )})` ) }, hex: this.setLetterCase(`#${hex.r}${hex.g}${hex.b}`), hexa: this.setLetterCase(`#${hex.r}${hex.g}${hex.b}${hex.a}`) }; } setColor(colorString: string) { const newColor = this.parseColor(colorString); if (!newColor) { return false; } this.hue = newColor.hsla.h; this.saturation = newColor.hsla.s; this.lightness = newColor.hsla.l; this.alpha = this.opacity ? newColor.hsla.a * 100 : 100; this.syncValues(); return true; } setLetterCase(string: string) { return this.uppercase ? string.toUpperCase() : string.toLowerCase(); } syncValues() { const currentColor = this.parseColor( `hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})` ); if (!currentColor) { return false; } // Update the value if (this.format === 'hsl') { this.inputValue = this.opacity ? currentColor.hsla.string : currentColor.hsl.string; } else if (this.format === 'rgb') { this.inputValue = this.opacity ? currentColor.rgba.string : currentColor.rgb.string; } else { this.inputValue = this.opacity ? currentColor.hexa : currentColor.hex; } // Setting this.value will trigger the watcher which parses the new color. We want to bypass that behavior because // a) we've already done it in this function and b) conversion/rounding can lead to values changing slightly. this.bypassValueParse = true; this.value = this.inputValue; this.bypassValueParse = false; } render() { const x = this.saturation; const y = 100 - this.lightness; const ColorPicker = () => { return (
{this.opacity && (
)}
(this.input = el)} part="input" size="small" type="text" name={this.name} autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck={false} value={this.inputValue} disabled={this.disabled} onKeyDown={this.handleInputKeyDown} onSl-change={this.handleInputChange} /> (this.copyButton = el)} exportparts="base:copy-button" slot="suffix" class="color-picker__copy-button" size="small" circle onClick={this.handleCopy} >
{this.swatches && (
{this.swatches.map(swatch => (
!this.disabled && this.setColor(swatch)} onKeyDown={event => !this.disabled && event.key === 'Enter' && this.setColor(swatch)} >
))}
)}
); }; // Render inline if (this.inline) { return ; } // Render as a dropdown return ( (this.dropdown = el)} class="color-dropdown" aria-disabled={this.disabled} containingElement={this.host} hoist={this.hoist} onSl-show={this.handleDropdownShow} onSl-after-show={this.handleDropdownAfterShow} onSl-hide={this.handleDropdownHide} onSl-after-hide={this.handleDropdownAfterHide} >