import Color from 'color'; import { html, LitElement } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { styleMap } from 'lit/directives/style-map.js'; import '~/components/button-group/button-group'; import '~/components/button/button'; import '~/components/dropdown/dropdown'; import type SlDropdown from '~/components/dropdown/dropdown'; import '~/components/icon/icon'; import '~/components/input/input'; import type SlInput from '~/components/input/input'; import '~/components/visually-hidden/visually-hidden'; import { drag } from '~/internal/drag'; import { emit } from '~/internal/event'; import { FormSubmitController } from '~/internal/form'; import { clamp } from '~/internal/math'; import { watch } from '~/internal/watch'; import { LocalizeController } from '~/utilities/localize'; import styles from './color-picker.styles'; const hasEyeDropper = 'EyeDropper' in window; interface EyeDropperConstructor { new (): EyeDropperInterface; } interface EyeDropperInterface { open: () => Promise<{ sRGBHex: string }>; } declare const EyeDropper: EyeDropperConstructor; /** * @since 2.0 * @status stable * * @dependency sl-button * @dependency sl-button-group * @dependency sl-dropdown * @dependency sl-input * @dependency sl-visually-hidden * * @slot label - The color picker's label. Alternatively, you can use the label prop. * * @event sl-change Emitted when the color picker's value changes. * * @csspart base - The component's internal wrapper. * @csspart trigger - The color picker's dropdown trigger. * @csspart swatches - The container that holds swatches. * @csspart swatch - Each individual swatch. * @csspart grid - The color grid. * @csspart grid-handle - The color grid's handle. * @csspart hue-slider - The hue slider. * @csspart opacity-slider - The opacity slider. * @csspart slider - Hue and opacity sliders. * @csspart slider-handle - Hue and opacity slider handles. * @csspart preview - The preview color. * @csspart input - The text input. * @csspart eye-dropper-button - The eye dropper button. * @csspart eye-dropper-button__button - The eye dropper button's `button` part. * @csspart eye-dropper-button__prefix - The eye dropper button's `prefix` part. * @csspart eye-dropper-button__label - The eye dropper button's `label` part. * @csspart eye-dropper-button__button-suffix - The eye dropper button's `suffix` part. * @csspart eye-dropper-button__caret - The eye dropper button's `caret` part. * @csspart format-button - The format button. * @csspart format-button__button - The format button's `button` part. * @csspart format-button__prefix - The format button's `prefix` part. * @csspart format-button__label - The format button's `label` part. * @csspart format-button__button-suffix - The format button's `suffix` part. * @csspart format-button__caret - The format button's `caret` part. * * @cssproperty --grid-width - The width of the color grid. * @cssproperty --grid-height - The height of the color grid. * @cssproperty --grid-handle-size - The size of the color grid's handle. * @cssproperty --slider-height - The height of the hue and alpha sliders. * @cssproperty --slider-handle-size - The diameter of the slider's handle. * @cssproperty --swatch-size - The size of each predefined color swatch. */ @customElement('sl-color-picker') export default class SlColorPicker extends LitElement { static styles = styles; @query('[part="input"]') input: SlInput; @query('[part="preview"]') previewButton: HTMLButtonElement; @query('.color-dropdown') dropdown: SlDropdown; // @ts-expect-error -- Controller is currently unused private readonly formSubmitController = new FormSubmitController(this); private isSafeValue = false; private lastValueEmitted: string; private readonly localize = new LocalizeController(this); @state() private inputValue = ''; @state() private hue = 0; @state() private saturation = 100; @state() private lightness = 100; @state() private alpha = 100; /** The current color. */ @property() value = '#ffffff'; /* The color picker's label. This will not be displayed, but it will be announced by assistive devices. */ @property() label = ''; /** * 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. */ @property() format: 'hex' | 'rgb' | 'hsl' = 'hex'; /** Renders the color picker inline rather than inside a dropdown. */ @property({ type: Boolean, reflect: true }) inline = false; /** Determines the size of the color picker's trigger. This has no effect on inline color pickers. */ @property() size: 'small' | 'medium' | 'large' = 'medium'; /** Removes the format toggle. */ @property({ attribute: 'no-format-toggle', type: Boolean }) noFormatToggle = false; /** The input's name attribute. */ @property() name = ''; /** Disables the color picker. */ @property({ type: Boolean, reflect: true }) 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. */ @property({ type: Boolean, 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`. */ @property({ type: Boolean }) hoist = false; /** Whether to show the opacity slider. */ @property({ type: Boolean }) opacity = false; /** By default, the value will be set in lowercase. Set this to true to set it in uppercase instead. */ @property({ type: Boolean }) 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. */ @property({ attribute: false }) swatches: string[] = [ '#d0021b', '#f5a623', '#f8e71c', '#8b572a', '#7ed321', '#417505', '#bd10e0', '#9013fe', '#4a90e2', '#50e3c2', '#b8e986', '#000', '#444', '#888', '#ccc', '#fff' ]; /** The locale to render the component in. */ @property() lang: string; connectedCallback() { super.connectedCallback(); if (!this.setColor(this.value)) { this.setColor(`#ffff`); } this.inputValue = this.value; this.lastValueEmitted = this.value; this.syncValues(); } /** Returns the current value as a string in the specified format. */ getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' = 'hex') { const currentColor = this.parseColor( `hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})` ); if (currentColor === null) { return ''; } switch (format) { case 'hex': return currentColor.hex; case 'hexa': return currentColor.hexa; case 'rgb': return currentColor.rgb.string; case 'rgba': return currentColor.rgba.string; case 'hsl': return currentColor.hsl.string; case 'hsla': return currentColor.hsla.string; default: return ''; } } /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { // If the input is invalid, show the dropdown so the browser can focus on it if (!this.inline && this.input.invalid) { return new Promise(resolve => { this.dropdown.addEventListener( 'sl-after-show', () => { this.input.reportValidity(); resolve(); }, { once: true } ); this.dropdown.show(); }); } return this.input.reportValidity(); } /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { this.input.setCustomValidity(message); this.invalid = this.input.invalid; } handleCopy() { this.input.select(); document.execCommand('copy'); this.previewButton.focus(); // Show copied animation this.previewButton.classList.add('color-picker__preview-color--copied'); this.previewButton.addEventListener('animationend', () => { this.previewButton.classList.remove('color-picker__preview-color--copied'); }); } handleFormatToggle() { const formats = ['hex', 'rgb', 'hsl']; const nextIndex = (formats.indexOf(this.format) + 1) % formats.length; this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl'; } handleAlphaDrag(event: Event) { const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__alpha')!; const handle = container.querySelector('.color-picker__slider-handle')!; const { width } = container.getBoundingClientRect(); handle.focus(); event.preventDefault(); drag(container, x => { this.alpha = clamp((x / width) * 100, 0, 100); this.syncValues(); }); } handleHueDrag(event: Event) { const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__hue')!; const handle = container.querySelector('.color-picker__slider-handle')!; const { width } = container.getBoundingClientRect(); handle.focus(); event.preventDefault(); drag(container, x => { this.hue = clamp((x / width) * 360, 0, 360); this.syncValues(); }); } handleGridDrag(event: Event) { const grid = this.shadowRoot!.querySelector('.color-picker__grid')!; const handle = grid.querySelector('.color-picker__grid-handle')!; const { width, height } = grid.getBoundingClientRect(); handle.focus(); event.preventDefault(); drag(grid, (x, y) => { this.saturation = clamp((x / width) * 100, 0, 100); this.lightness = clamp(100 - (y / height) * 100, 0, 100); this.syncValues(); }); } 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()); } } 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] = (parseFloat(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] = (parseFloat(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) { let parsed: Color; // 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 null; } const hslColor = parsed.hsl(); const hsl = { h: hslColor.hue(), s: hslColor.saturationl(), l: hslColor.lightness(), a: hslColor.alpha() }; const rgbColor = parsed.rgb(); const rgb = { r: rgbColor.red(), g: rgbColor.green(), b: rgbColor.blue(), a: rgbColor.alpha() }; const hex = { r: toHex(rgb.r), g: toHex(rgb.g), b: toHex(rgb.b), a: toHex(rgb.a * 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)}%, ${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)}, ${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 === null) { 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) { if (typeof string !== 'string') { return ''; } return this.uppercase ? string.toUpperCase() : string.toLowerCase(); } async syncValues() { const currentColor = this.parseColor( `hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})` ); if (currentColor === null) { return; } // 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 value. We want to bypass that behavior because // we've already parsed the color here and conversion/rounding can lead to values changing slightly. WHen this // happens, dragging the grid handle becomes jumpy. After the next update, the usual behavior is restored. this.isSafeValue = true; this.value = this.inputValue; await this.updateComplete; this.isSafeValue = false; } handleAfterHide() { this.previewButton.classList.remove('color-picker__preview-color--copied'); } handleEyeDropper() { if (!hasEyeDropper) { return; } const eyeDropper = new EyeDropper(); eyeDropper .open() .then(colorSelectionResult => this.setColor(colorSelectionResult.sRGBHex)) .catch(() => { // The user canceled, do nothing }); } @watch('format') handleFormatChange() { this.syncValues(); } @watch('opacity') handleOpacityChange() { this.alpha = 100; } @watch('value') handleValueChange(oldValue: string | undefined, newValue: string) { if (!this.isSafeValue && oldValue !== undefined) { const newColor = this.parseColor(newValue); if (newColor !== null) { 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) { emit(this, 'sl-change'); this.lastValueEmitted = this.value; } } render() { const x = this.saturation; const y = 100 - this.lightness; const colorPicker = html`
${this.inline ? html` ${this.label} ` : null}
${this.opacity ? html`
` : ''}
${!this.noFormatToggle ? html` ${this.setLetterCase(this.format)} ` : ''} ${hasEyeDropper ? html` ` : ''}
${this.swatches.length > 0 ? html`
${this.swatches.map(swatch => { return html`
!this.disabled && this.setColor(swatch)} @keydown=${(event: KeyboardEvent) => !this.disabled && event.key === 'Enter' && this.setColor(swatch)} >
`; })}
` : ''}
`; // Render inline if (this.inline) { return colorPicker; } // Render as a dropdown return html` ${colorPicker} `; } } function toHex(value: number) { const hex = Math.round(value).toString(16); return hex.length === 1 ? `0${hex}` : hex; } declare global { interface HTMLElementTagNameMap { 'sl-color-picker': SlColorPicker; } }