import { LitElement, html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { getCssText } from './nile-color-picker.css'; import '../nile-dropdown/nile-dropdown'; import '../nile-menu/nile-menu'; import '../nile-menu-item/nile-menu-item'; import '../nile-color-swatch/nile-color-swatch'; export type ColorPickerType = 'swatches' | 'picker'; export type ColorPickerMode = 'inline' | 'popover'; export type SwatchTooltip = 'name' | 'value' | 'both'; export type PaletteEntry = { color: string; name: string }; export const DEFAULT_PALETTE: PaletteEntry[] = [ { color: '#131316', name: 'Black' }, { color: '#26272B', name: 'Dark Gray' }, { color: '#525252', name: 'Gray' }, { color: '#737373', name: 'Medium Gray' }, { color: '#A3A3A3', name: 'Silver' }, { color: '#D6D6D6', name: 'Light Gray' }, { color: '#F5F5F5', name: 'Off White' }, { color: '#FFFFFF', name: 'White' }, { color: '#CA8504', name: 'Yellow 3' }, { color: '#C01048', name: 'Red 3' }, { color: '#DD2590', name: 'Pink 3' }, { color: '#155EEF', name: 'Blue Dark 3' }, { color: '#444CE7', name: 'Indigo 3' }, { color: '#7A5AF8', name: 'Purple 3' }, { color: '#3E4784', name: 'Gray Blue 3' }, { color: '#087443', name: 'Green 3' }, { color: '#FDE272', name: 'Yellow 2' }, { color: '#FEA3B4', name: 'Red 2' }, { color: '#FAA7E0', name: 'Pink 2' }, { color: '#528BFF', name: 'Blue Dark 2' }, { color: '#A4BCFD', name: 'Indigo 2' }, { color: '#BDB4FE', name: 'Purple 2' }, { color: '#B3B8DB', name: 'Gray Blue 2' }, { color: '#73E2A3', name: 'Green 2' }, { color: '#FEFBE8', name: 'Yellow 1' }, { color: '#FFF1F3', name: 'Red 1' }, { color: '#FDF2FA', name: 'Pink 1' }, { color: '#EFF4FF', name: 'Blue Dark 1' }, { color: '#EEF4FF', name: 'Indigo 1' }, { color: '#F4F3FF', name: 'Purple 1' }, { color: '#F8F9FC', name: 'Gray Blue 1' }, { color: '#EDFCF2', name: 'Green 1' }, ]; /* ── color math helpers ──────────────────────────────────────────────── */ export function hexToRgb(hex: string): [number, number, number] { const h = hex.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; } export function rgbToHex(r: number, g: number, b: number): string { return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase(); } export function rgbToHsv(r: number, g: number, b: number): [number, number, number] { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min; let h = 0; if (d !== 0) { if (max === r) h = ((g - b) / d + 6) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h *= 60; } return [h, max === 0 ? 0 : d / max, max]; } export function hsvToRgb(h: number, s: number, v: number): [number, number, number] { const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c; let r = 0, g = 0, b = 0; if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; } else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; } return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; } @customElement('nile-color-picker') export class NileColorPicker extends LitElement { protected createRenderRoot() { return this; } @property({ type: String, attribute: true, reflect: true }) type: ColorPickerType = 'swatches'; /** Currently selected hex color value. */ @property({ type: String, attribute: true, reflect: true }) value = '#000000'; /** What to show in swatch tooltips. */ @property({ type: String, reflect: true }) swatchTooltip: SwatchTooltip = 'name'; /** How many recent/custom colors to display. */ @property({ type: Number, reflect: true }) recentColorsCount = 3; /** Show a "No Fill" button (useful for background-color use cases). */ @property({ type: Boolean, reflect: true }) noFill = false; @property({ type: String, reflect: true })mode: ColorPickerMode = 'inline'; /** Show the SV gradient canvas (the large colour area). Applies when type="picker". */ @property({ type: Boolean, reflect: true }) showCanvas = true; /** Show the hue slider row (rainbow bar + eyedropper). Applies when type="picker". */ @property({ type: Boolean, reflect: true }) showHue = true; /** Show the HEX / RGB input row. Applies when type="picker". */ @property({ type: Boolean, reflect: true }) showInputs = true; /** Show the Cancel / Okay action buttons. Applies when type="picker". */ @property({ type: Boolean, reflect: true }) showActions = true; @property({ type: Boolean, reflect: true, attribute: true}) open = false; /** Custom palette. Accepts a JSON array of `{ color, name }` objects or plain * hex strings. Falls back to the built-in palette when empty. */ @property({ attribute: 'palette', converter: { fromAttribute: (value: string): PaletteEntry[] => { try { const parsed = JSON.parse(value); if (!Array.isArray(parsed)) return []; return parsed.map((e: any) => { if (typeof e === 'string') return { color: e, name: e }; if (e && typeof e.color === 'string') return { color: e.color, name: e.name || e.color }; return null; }).filter(Boolean) as PaletteEntry[]; } catch { return []; } }, toAttribute: (v: PaletteEntry[]) => JSON.stringify(v), }, }) palette: PaletteEntry[] = []; /* ── internal state ──────────────────────────────────────────────── */ @state() private _view: ColorPickerType = 'swatches'; @state() private _recentColors: PaletteEntry[] = []; // picker canvas state @state() private _hue = 220; @state() private _sat = 0.7; @state() private _val = 0.93; @state() private _colorMode: 'HEX' | 'RGB' = 'HEX'; @state() private _hexInput = '#366AEE'; @state() private _r = 54; @state() private _g = 106; @state() private _b = 238; private _svCanvas: HTMLCanvasElement | null = null; private _hueCanvas: HTMLCanvasElement | null = null; private _draggingSV = false; private _draggingHue = false; private _pendingColor = '#366AEE'; /* ── lifecycle ───────────────────────────────────────────────────── */ connectedCallback() { super.connectedCallback(); this._view = this.type; if (this.type === 'picker') { const [r, g, b] = hexToRgb(this._norm(this.value)); [this._hue, this._sat, this._val] = rgbToHsv(r, g, b); this._syncInputs(); } this._injectCss(); document.addEventListener('mousemove', this._onGlobalMouseMove); document.addEventListener('mouseup', this._onGlobalMouseUp); } disconnectedCallback() { document.removeEventListener('mousemove', this._onGlobalMouseMove); document.removeEventListener('mouseup', this._onGlobalMouseUp); super.disconnectedCallback(); } protected updated(changed: Map) { if (changed.has('type')) { this._view = this.type; if (this.type === 'picker') { const [r, g, b] = hexToRgb(this._norm(this.value)); [this._hue, this._sat, this._val] = rgbToHsv(r, g, b); this._syncInputs(); } } if (changed.has('value') && this.type === 'picker' && this._view === 'picker' && !this._draggingSV && !this._draggingHue) { const [r, g, b] = hexToRgb(this._norm(this.value)); [this._hue, this._sat, this._val] = rgbToHsv(r, g, b); this._syncInputs(); } if (this._view === 'picker') { requestAnimationFrame(() => { this._svCanvas = this.querySelector('.ncp-sv-canvas') as HTMLCanvasElement; this._hueCanvas = this.querySelector('.ncp-hue-canvas') as HTMLCanvasElement; this._drawSV(); this._drawHue(); }); } } /* ── public API ──────────────────────────────────────────────────── */ /** Reset the view back to the default type (call this when the host popover closes). */ reset() { this._view = this.type; } private _injectCss() { if (this.querySelector('style[data-nile-color-picker]')) return; const style = document.createElement('style'); style.setAttribute('data-nile-color-picker', ''); style.textContent = getCssText(); this.insertBefore(style, this.firstChild); } private _norm(hex: string) { const t = hex.trim(); if (/^#[0-9a-f]{6}$/i.test(t)) return t.toUpperCase(); if (/^#[0-9a-f]{3}$/i.test(t)) { const [a, b, c] = t.slice(1).toUpperCase().split(''); return `#${a}${a}${b}${b}${c}${c}`; } return '#000000'; } private get _paletteColors(): PaletteEntry[] { return this.palette.length > 0 ? this.palette : DEFAULT_PALETTE; } private _emit(value: string) { this.dispatchEvent(new CustomEvent('nile-change', { detail: { value }, bubbles: true, composed: true, })); } private _apply(color: string) { const hex = this._norm(color); this.value = hex; const entry: PaletteEntry = { color: hex, name: hex }; this._recentColors = [entry, ...this._recentColors.filter(e => this._norm(e.color) !== hex)].slice(0, this.recentColorsCount); this._emit(hex); if (this.mode === 'popover' && this._view === 'swatches') { this._closePopover(); } } private _closePopover() { const pop = this.querySelector('nile-popover') as any; if (pop) pop.isShow = false; } private _openPicker() { const [r, g, b] = hexToRgb(this._norm(this.value)); const [h, s, v] = rgbToHsv(r, g, b); this._hue = h; this._sat = s; this._val = v; this._syncInputs(); this._view = 'picker'; } private _syncInputs() { const [r, g, b] = hsvToRgb(this._hue, this._sat, this._val); const hex = rgbToHex(r, g, b); this._hexInput = hex; this._r = r; this._g = g; this._b = b; this._pendingColor = hex; } private _drawSV() { const canvas = this._svCanvas; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const { width: w, height: h } = canvas; const [hr, hg, hb] = hsvToRgb(this._hue, 1, 1); ctx.fillStyle = `rgb(${hr},${hg},${hb})`; ctx.fillRect(0, 0, w, h); const wg = ctx.createLinearGradient(0, 0, w, 0); wg.addColorStop(0, 'rgba(255,255,255,1)'); wg.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = wg; ctx.fillRect(0, 0, w, h); const bg = ctx.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, 'rgba(0,0,0,0)'); bg.addColorStop(1, 'rgba(0,0,0,1)'); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); } private _drawHue() { const canvas = this._hueCanvas; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const grad = ctx.createLinearGradient(0, 0, canvas.width, 0); ['#FF0000','#FFFF00','#00FF00','#00FFFF','#0000FF','#FF00FF','#FF0000'] .forEach((c, i, a) => grad.addColorStop(i / (a.length - 1), c)); ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height); } private _onSVMouseDown = (e: MouseEvent) => { e.preventDefault(); this._draggingSV = true; this._updateSV(e); }; private _onHueMouseDown = (e: MouseEvent) => { e.preventDefault(); this._draggingHue = true; this._updateHue(e); }; private _onGlobalMouseMove = (e: MouseEvent) => { if (this._draggingSV) this._updateSV(e); if (this._draggingHue) this._updateHue(e); }; private _onGlobalMouseUp = () => { const wasDraggingSV = this._draggingSV; const wasDraggingHue = this._draggingHue; if (!this.showActions && this._view === 'picker' && (wasDraggingSV || wasDraggingHue)) { this._commitPickerIfNoActions(); } this._draggingSV = false; this._draggingHue = false; }; private _commitPickerIfNoActions() { if (this.showActions) return; this._apply(this._pendingColor); if (this.mode === 'popover') { if (this.type === 'swatches') this._view = 'swatches'; this._closePopover(); } } private _updateSV(e: MouseEvent) { const canvas = this._svCanvas; if (!canvas) return; const rect = canvas.getBoundingClientRect(); this._sat = Math.max(0, Math.min(e.clientX - rect.left, rect.width)) / rect.width; this._val = 1 - Math.max(0, Math.min(e.clientY - rect.top, rect.height)) / rect.height; this._syncInputs(); } private _updateHue(e: MouseEvent) { const canvas = this._hueCanvas; if (!canvas) return; const rect = canvas.getBoundingClientRect(); this._hue = (Math.max(0, Math.min(e.clientX - rect.left, rect.width)) / rect.width) * 360; this._syncInputs(); } private _onHexInput = (e: Event) => { let val = ((e as CustomEvent).detail?.value ?? (e.target as HTMLInputElement).value ?? '').trim(); if (!val.startsWith('#')) val = '#' + val; if (/^#[0-9a-f]{6}$/i.test(val)) { const [r, g, b] = hexToRgb(val); [this._hue, this._sat, this._val] = rgbToHsv(r, g, b); this._syncInputs(); if (this._view === 'picker') this._commitPickerIfNoActions(); } }; private _onRgbInput = (ch: 'r' | 'g' | 'b', e: Event) => { const raw = (e as CustomEvent).detail?.value ?? (e.target as HTMLInputElement).value; const v = Math.max(0, Math.min(255, parseInt(String(raw), 10) || 0)); if (ch === 'r') this._r = v; else if (ch === 'g') this._g = v; else this._b = v; [this._hue, this._sat, this._val] = rgbToHsv(this._r, this._g, this._b); this._syncInputs(); if (this._view === 'picker') this._commitPickerIfNoActions(); }; private _onColorModeSelect = (e: CustomEvent<{ value: string }>) => { e.stopPropagation(); const v = e.detail?.value; if (v === 'HEX' || v === 'RGB') { this._colorMode = v; } }; private _pickEyedropper = async () => { const Ctor = (window as any).EyeDropper; if (!Ctor) { this._openPicker(); return; } try { const result = await new Ctor().open(); if (result?.sRGBHex) this._apply(result.sRGBHex); } catch { /* cancelled */ } }; private _renderSwatch(entry: PaletteEntry) { const norm = this._norm(entry.color); const isActive = this._norm(this.value) === norm; const tip = this.swatchTooltip === 'name' ? (entry.name || norm) : this.swatchTooltip === 'value' ? norm : entry.name ? `${entry.name} (${norm})` : norm; return html` this._apply(norm)} > `; } private _renderSwatchesView() { const recent = this._recentColors.slice(0, this.recentColorsCount); return html`
${this._paletteColors.map(e => this._renderSwatch(e))}
${this.noFill ? html` ` : nothing}

Custom

${recent.map(e => this._renderSwatch(e))}
`; } private _renderPickerView() { const svX = this._sat * 100; const svY = (1 - this._val) * 100; const hueX = (this._hue / 360) * 100; return html`
${this.showCanvas ? html`
` : nothing} ${this.showHue ? html`
` : nothing} ${this.showInputs ? html`
${this._colorMode} HEX RGB ${this._colorMode === 'HEX' ? html`` : html` this._onRgbInput('r', e)}> this._onRgbInput('g', e)}> this._onRgbInput('b', e)}> `}
` : nothing} ${this.showActions ? html`
${this.type === 'swatches' ? html` { this._view = 'swatches'; }}>Cancel` : nothing} { this._apply(this._pendingColor); if (this.type === 'swatches') this._view = 'swatches'; this._closePopover(); }}>Okay
` : nothing}
`; } private _renderTrigger() { const isNoFill = this.value === 'none'; const displayColor = isNoFill ? null : this._norm(this.value); return html` `; } render() { const panel = this._view === 'picker' ? this._renderPickerView() : this._renderSwatchesView(); if (this.mode === 'popover') { return html` this.reset()} > ${this._renderTrigger()} ${panel} `; } return panel; } } declare global { interface HTMLElementTagNameMap { 'nile-color-picker': NileColorPicker; } }