/** * Oklume — OKLCH Color Picker by Anton Ipatov * @version 1.0.0 * @author Anton Ipatov (https://twitter.com/ipatovanton) * @license MIT (with attribution requirement) * * Production-ready OKLCH color picker with zero dependencies * Works with perceptually uniform colors and supports multiple color formats */ class Oklume { /** * Create a new Oklume color picker instance * @param {string|HTMLElement} container - CSS selector or DOM element * @param {Object} options - Configuration options * @param {string} [options.mode='expanded'] - Display mode: 'expanded' or 'compact' * @param {string|HTMLElement} [options.trigger] - Trigger element for compact mode (required for compact mode) * @param {Array<{l: number, c: number, h: number}>} [options.palette] - Custom color palette * @param {Function} [options.onChange] - Callback fired when color changes * @param {boolean} [options.showPreview=true] - Show/hide preview section * @param {boolean} [options.showSliders=true] - Show/hide sliders section * @param {Array|boolean} [options.showFormats=['oklch','rgb','hex','hsl']] - Array of formats to show or false to hide all */ constructor(container, options = {}) { // Check browser support for OKLCH if (!this.checkBrowserSupport()) { console.warn('Oklume: Your browser may not fully support OKLCH colors. Please use Chrome 111+, Safari 15.4+, Firefox 113+, or Edge 111+'); } // Validate and set container this.container = this._validateContainer(container); if (!this.container) { throw new Error('Oklume: Invalid container element'); } // Initialize options this.palette = options.palette || this.getDefaultPalette(); this.onChange = typeof options.onChange === 'function' ? options.onChange : null; this.mode = options.mode === 'compact' ? 'compact' : 'expanded'; this.showPreview = options.showPreview !== false; // default true this.showSliders = options.showSliders !== false; // default true this.showFormats = this._normalizeFormats(options.showFormats); // Validate trigger for compact mode if (this.mode === 'compact') { this.triggerEl = this._validateContainer(options.trigger); if (!this.triggerEl) { throw new Error('Oklume: Trigger element is required and must be valid for compact mode'); } } // Current color state (default: warm orange) this.current = { l: 0.7, c: 0.15, h: 30 }; // UI state this.isOpen = this.mode === 'expanded'; // Event handler references for cleanup this.boundTriggerClick = null; this.boundEscKey = null; this.boundBackdropClick = null; this.boundSliderLInput = null; this.boundSliderCInput = null; this.boundSliderHInput = null; this.boundPaletteClick = null; this.boundSaveClick = null; this.boundPopupClick = null; this.valueEditHandlers = []; // Array to store {element, keydown, blur} objects // DOM references this.modalContainer = null; // For compact mode: reference to modal element appended to body this.oklume = null; this.popup = null; this.previewBox = null; this.previewEl = null; this.paletteEl = null; this.sliderL = null; this.sliderC = null; this.sliderH = null; this.sliderLValue = null; this.sliderCValue = null; this.sliderHValue = null; this.saveBtnEl = null; // Save button element for compact mode // Initialize this.init(); } /** * Validate container element * @param {string|HTMLElement} element - CSS selector or DOM element * @returns {HTMLElement|null} * @private */ _validateContainer(element) { if (!element) return null; const el = typeof element === 'string' ? document.querySelector(element) : element; return el instanceof HTMLElement ? el : null; } /** * Normalize showFormats option * @param {Array|boolean|undefined} formats - Formats option * @returns {Array} Array of format names to show * @private */ _normalizeFormats(formats) { if (formats === false || (Array.isArray(formats) && formats.length === 0)) { return []; } if (Array.isArray(formats)) { return formats.filter(f => ['oklch', 'rgb', 'hex', 'hsl'].includes(f)); } // Default: show all formats return ['oklch', 'rgb', 'hex', 'hsl']; } /** * Check if the browser supports OKLCH colors * @returns {boolean} True if OKLCH is supported * @private */ checkBrowserSupport() { if (typeof CSS === 'undefined' || !CSS.supports) { return false; } return CSS.supports('color', 'oklch(0.5 0.2 180)'); } /** * Get default color palette with 200 carefully curated OKLCH colors * @returns {Array<{l: number, c: number, h: number}>} Array of OKLCH color objects * @private */ getDefaultPalette() { return [ // RED family (H: 10-15°) - 8 colors, sorted light to dark { l: 0.90, c: 0.10, h: 10 }, { l: 0.75, c: 0.20, h: 10 }, { l: 0.60, c: 0.25, h: 10 }, { l: 0.50, c: 0.26, h: 15 }, { l: 0.40, c: 0.22, h: 15 }, { l: 0.30, c: 0.17, h: 15 }, { l: 0.25, c: 0.14, h: 15 }, { l: 0.20, c: 0.11, h: 15 }, // ORANGE-RED (H: 20-40°) - 8 colors { l: 0.90, c: 0.09, h: 30 }, { l: 0.80, c: 0.16, h: 30 }, { l: 0.70, c: 0.20, h: 30 }, { l: 0.62, c: 0.24, h: 32 }, { l: 0.50, c: 0.20, h: 32 }, { l: 0.40, c: 0.16, h: 32 }, { l: 0.28, c: 0.11, h: 32 }, { l: 0.35, c: 0.13, h: 32 }, // ORANGE (H: 40-60°) - 8 colors { l: 0.92, c: 0.08, h: 50 }, { l: 0.82, c: 0.14, h: 50 }, { l: 0.72, c: 0.18, h: 50 }, { l: 0.65, c: 0.22, h: 52 }, { l: 0.52, c: 0.18, h: 52 }, { l: 0.42, c: 0.15, h: 52 }, { l: 0.30, c: 0.10, h: 52 }, { l: 0.37, c: 0.12, h: 52 }, // YELLOW-ORANGE (H: 60-80°) - 8 colors { l: 0.95, c: 0.10, h: 70 }, { l: 0.92, c: 0.16, h: 70 }, { l: 0.88, c: 0.20, h: 70 }, { l: 0.85, c: 0.24, h: 72 }, { l: 0.72, c: 0.19, h: 72 }, { l: 0.58, c: 0.15, h: 72 }, { l: 0.42, c: 0.10, h: 72 }, { l: 0.48, c: 0.12, h: 72 }, // YELLOW (H: 80-100°) - 8 colors { l: 0.97, c: 0.12, h: 90 }, { l: 0.95, c: 0.18, h: 90 }, { l: 0.92, c: 0.22, h: 90 }, { l: 0.90, c: 0.26, h: 92 }, { l: 0.78, c: 0.21, h: 92 }, { l: 0.63, c: 0.17, h: 92 }, { l: 0.47, c: 0.12, h: 92 }, { l: 0.52, c: 0.13, h: 92 }, // YELLOW-GREEN (H: 100-120°) - 8 colors { l: 0.95, c: 0.14, h: 110 }, { l: 0.92, c: 0.20, h: 110 }, { l: 0.88, c: 0.24, h: 110 }, { l: 0.85, c: 0.28, h: 112 }, { l: 0.72, c: 0.22, h: 112 }, { l: 0.58, c: 0.18, h: 112 }, { l: 0.42, c: 0.12, h: 112 }, { l: 0.48, c: 0.14, h: 112 }, // GREEN (H: 120-140°) - 8 colors { l: 0.92, c: 0.16, h: 130 }, { l: 0.85, c: 0.22, h: 130 }, { l: 0.75, c: 0.26, h: 130 }, { l: 0.70, c: 0.30, h: 132 }, { l: 0.58, c: 0.24, h: 132 }, { l: 0.45, c: 0.19, h: 132 }, { l: 0.32, c: 0.13, h: 132 }, { l: 0.38, c: 0.15, h: 132 }, // TEAL-GREEN (H: 140-160°) - 8 colors { l: 0.93, c: 0.12, h: 150 }, { l: 0.87, c: 0.18, h: 150 }, { l: 0.78, c: 0.22, h: 150 }, { l: 0.72, c: 0.26, h: 152 }, { l: 0.60, c: 0.21, h: 152 }, { l: 0.47, c: 0.16, h: 152 }, { l: 0.34, c: 0.11, h: 152 }, { l: 0.40, c: 0.13, h: 152 }, // CYAN (H: 160-180°) - 8 colors { l: 0.94, c: 0.10, h: 180 }, { l: 0.88, c: 0.16, h: 180 }, { l: 0.80, c: 0.20, h: 180 }, { l: 0.75, c: 0.24, h: 182 }, { l: 0.62, c: 0.19, h: 182 }, { l: 0.48, c: 0.15, h: 182 }, { l: 0.35, c: 0.10, h: 182 }, { l: 0.41, c: 0.12, h: 182 }, // SKY BLUE (H: 180-200°) - 8 colors { l: 0.92, c: 0.09, h: 200 }, { l: 0.85, c: 0.15, h: 200 }, { l: 0.75, c: 0.19, h: 200 }, { l: 0.68, c: 0.22, h: 202 }, { l: 0.56, c: 0.18, h: 202 }, { l: 0.43, c: 0.14, h: 202 }, { l: 0.30, c: 0.09, h: 202 }, { l: 0.36, c: 0.11, h: 202 }, // BLUE (H: 200-220°) - 8 colors { l: 0.88, c: 0.12, h: 240 }, { l: 0.78, c: 0.18, h: 240 }, { l: 0.65, c: 0.22, h: 240 }, { l: 0.55, c: 0.26, h: 242 }, { l: 0.45, c: 0.21, h: 242 }, { l: 0.35, c: 0.16, h: 242 }, { l: 0.24, c: 0.11, h: 242 }, { l: 0.28, c: 0.13, h: 242 }, // INDIGO (H: 220-240°) - 8 colors { l: 0.85, c: 0.14, h: 265 }, { l: 0.75, c: 0.20, h: 265 }, { l: 0.62, c: 0.24, h: 265 }, { l: 0.50, c: 0.28, h: 267 }, { l: 0.40, c: 0.23, h: 267 }, { l: 0.30, c: 0.17, h: 267 }, { l: 0.20, c: 0.11, h: 267 }, { l: 0.24, c: 0.13, h: 267 }, // PURPLE (H: 240-260°) - 8 colors { l: 0.87, c: 0.13, h: 285 }, { l: 0.77, c: 0.19, h: 285 }, { l: 0.65, c: 0.23, h: 285 }, { l: 0.53, c: 0.27, h: 287 }, { l: 0.43, c: 0.22, h: 287 }, { l: 0.33, c: 0.16, h: 287 }, { l: 0.23, c: 0.11, h: 287 }, { l: 0.27, c: 0.13, h: 287 }, // MAGENTA (H: 260-280°) - 8 colors { l: 0.89, c: 0.12, h: 305 }, { l: 0.80, c: 0.18, h: 305 }, { l: 0.68, c: 0.22, h: 305 }, { l: 0.58, c: 0.26, h: 307 }, { l: 0.47, c: 0.21, h: 307 }, { l: 0.36, c: 0.16, h: 307 }, { l: 0.26, c: 0.11, h: 307 }, { l: 0.30, c: 0.13, h: 307 }, // PINK-MAGENTA (H: 280-300°) - 8 colors { l: 0.90, c: 0.11, h: 325 }, { l: 0.82, c: 0.17, h: 325 }, { l: 0.70, c: 0.21, h: 325 }, { l: 0.62, c: 0.25, h: 327 }, { l: 0.50, c: 0.20, h: 327 }, { l: 0.38, c: 0.15, h: 327 }, { l: 0.28, c: 0.10, h: 327 }, { l: 0.32, c: 0.12, h: 327 }, // PINK (H: 300-320°) - 8 colors { l: 0.91, c: 0.10, h: 345 }, { l: 0.83, c: 0.16, h: 345 }, { l: 0.72, c: 0.20, h: 345 }, { l: 0.63, c: 0.24, h: 347 }, { l: 0.51, c: 0.19, h: 347 }, { l: 0.40, c: 0.15, h: 347 }, { l: 0.29, c: 0.10, h: 347 }, { l: 0.33, c: 0.12, h: 347 }, // HOT PINK (H: 320-340°) - 8 colors { l: 0.89, c: 0.09, h: 355 }, { l: 0.80, c: 0.15, h: 355 }, { l: 0.68, c: 0.19, h: 355 }, { l: 0.58, c: 0.23, h: 357 }, { l: 0.47, c: 0.18, h: 357 }, { l: 0.36, c: 0.14, h: 357 }, { l: 0.27, c: 0.09, h: 357 }, { l: 0.31, c: 0.11, h: 357 }, // ROSE (H: 340-360°) - 8 colors { l: 0.90, c: 0.08, h: 5 }, { l: 0.81, c: 0.14, h: 5 }, { l: 0.70, c: 0.18, h: 5 }, { l: 0.53, c: 0.26, h: 5 }, { l: 0.42, c: 0.21, h: 5 }, { l: 0.32, c: 0.16, h: 5 }, { l: 0.30, c: 0.17, h: 5 }, { l: 0.25, c: 0.12, h: 5 }, // GRAYSCALE column 1 (8 shades: white to mid-gray) { l: 1.00, c: 0, h: 0 }, { l: 0.93, c: 0, h: 0 }, { l: 0.86, c: 0, h: 0 }, { l: 0.79, c: 0, h: 0 }, { l: 0.72, c: 0, h: 0 }, { l: 0.65, c: 0, h: 0 }, { l: 0.58, c: 0, h: 0 }, { l: 0.51, c: 0, h: 0 }, // GRAYSCALE column 2 (8 shades: mid-gray to black) { l: 0.44, c: 0, h: 0 }, { l: 0.37, c: 0, h: 0 }, { l: 0.30, c: 0, h: 0 }, { l: 0.23, c: 0, h: 0 }, { l: 0.16, c: 0, h: 0 }, { l: 0.09, c: 0, h: 0 }, { l: 0.05, c: 0, h: 0 }, { l: 0.00, c: 0, h: 0 } ]; } /** * Generate HTML structure based on mode * @returns {string} HTML string * @private */ _generateHTML() { // Generate preview HTML const previewHTML = this.showPreview ? `
` : ''; // Generate sliders HTML const slidersHTML = this.showSliders ? `
` : ''; // Generate output formats HTML const formatsHTML = this.showFormats.map(format => { const labels = { oklch: 'OKLCH', rgb: 'RGB', hex: 'HEX', hsl: 'HSL' }; return `
`; }).join(''); const outputHTML = this.showFormats.length > 0 ? `
${formatsHTML}
` : ''; const pickerContent = `${previewHTML}
${slidersHTML}${outputHTML}
`; if (this.mode === 'expanded') { return `
${pickerContent}
`; } else { return `
${pickerContent}
`; } } /** * Initialize the color picker widget * @private */ init() { // Generate and insert HTML if (this.mode === 'compact') { // For compact mode, append directly to body to avoid positioning issues // with parent containers that have transforms or other CSS properties const tempDiv = document.createElement('div'); tempDiv.innerHTML = this._generateHTML(); this.modalContainer = tempDiv.firstElementChild; document.body.appendChild(this.modalContainer); // Keep a reference marker in the original container this.container.dataset.oklumeCompact = 'true'; } else { // For expanded mode, insert into the specified container this.container.innerHTML = this._generateHTML(); } // Cache DOM references this._cacheDOM(); // Initialize UI this.renderPalette(); this.attachEvents(); this.updateOutput(); this.updateSliderBackgrounds(); // Apply initial state for compact mode if (this.mode === 'compact' && this.isOpen) { this.oklume.classList.add('oklume--open'); } } /** * Cache DOM element references * @private */ _cacheDOM() { // For compact mode, modalContainer IS the .oklume element // For expanded mode, find .oklume inside container if (this.mode === 'compact') { this.oklume = this.modalContainer; this.popup = this.modalContainer.querySelector('.oklume-popup'); this.previewBox = this.modalContainer.querySelector('.oklume-preview-box'); this.previewEl = this.modalContainer.querySelector('.oklume-preview'); this.paletteEl = this.modalContainer.querySelector('.oklume-palette'); this.sliderL = this.modalContainer.querySelector('.oklume-slider-l'); this.sliderC = this.modalContainer.querySelector('.oklume-slider-c'); this.sliderH = this.modalContainer.querySelector('.oklume-slider-h'); this.sliderLValue = this.modalContainer.querySelector('.oklume-slider-l-value'); this.sliderCValue = this.modalContainer.querySelector('.oklume-slider-c-value'); this.sliderHValue = this.modalContainer.querySelector('.oklume-slider-h-value'); } else { this.oklume = this.container.querySelector('.oklume'); this.popup = this.container.querySelector('.oklume-popup'); this.previewBox = this.container.querySelector('.oklume-preview-box'); this.previewEl = this.container.querySelector('.oklume-preview'); this.paletteEl = this.container.querySelector('.oklume-palette'); this.sliderL = this.container.querySelector('.oklume-slider-l'); this.sliderC = this.container.querySelector('.oklume-slider-c'); this.sliderH = this.container.querySelector('.oklume-slider-h'); this.sliderLValue = this.container.querySelector('.oklume-slider-l-value'); this.sliderCValue = this.container.querySelector('.oklume-slider-c-value'); this.sliderHValue = this.container.querySelector('.oklume-slider-h-value'); } } /** * Toggle picker open/closed state (only works in compact mode) * @public */ togglePicker() { if (this.mode !== 'compact') return; this.isOpen = !this.isOpen; this.oklume.classList.toggle('oklume--open', this.isOpen); } /** * Render color palette swatches * @private */ renderPalette() { if (!this.paletteEl) return; this.paletteEl.innerHTML = ''; this.palette.forEach(({ l, c, h }) => { const swatch = document.createElement('button'); swatch.className = 'oklume-swatch'; swatch.type = 'button'; swatch.style.backgroundColor = this.oklchToCss(l, c, h); swatch.dataset.l = String(l); swatch.dataset.c = String(c); swatch.dataset.h = String(h); swatch.setAttribute('aria-label', `Color: oklch(${l} ${c} ${h})`); this.paletteEl.appendChild(swatch); }); } /** * Attach event listeners to UI elements * @private */ attachEvents() { // Trigger click (for compact mode only) if (this.mode === 'compact' && this.triggerEl) { this.boundTriggerClick = () => this.togglePicker(); this.triggerEl.addEventListener('click', this.boundTriggerClick); // Save button click const root = this.mode === 'compact' ? this.modalContainer : this.container; this.saveBtnEl = root.querySelector('.oklume-save'); if (this.saveBtnEl) { this.boundSaveClick = () => this.togglePicker(); this.saveBtnEl.addEventListener('click', this.boundSaveClick); } // ESC key to close this.boundEscKey = (e) => { if (e.key === 'Escape' && this.isOpen) { this.togglePicker(); } }; document.addEventListener('keydown', this.boundEscKey); // Click on backdrop to close this.boundBackdropClick = (e) => { if (this.isOpen && e.target === this.oklume) { this.togglePicker(); } }; this.oklume.addEventListener('click', this.boundBackdropClick); // Prevent clicks inside popup from closing if (this.popup) { this.boundPopupClick = (e) => e.stopPropagation(); this.popup.addEventListener('click', this.boundPopupClick); } } // Palette click if (this.paletteEl) { this.boundPaletteClick = (e) => { if (e.target.classList.contains('oklume-swatch')) { this._updateColorFromSwatch(e.target); } }; this.paletteEl.addEventListener('click', this.boundPaletteClick); } // Slider events this._attachSliderEvents(); // Make color values editable this._attachValueEditEvents(); } /** * Update color from clicked swatch * @param {HTMLElement} swatch - Swatch element * @private */ _updateColorFromSwatch(swatch) { this.current.l = parseFloat(swatch.dataset.l) || 0; this.current.c = parseFloat(swatch.dataset.c) || 0; this.current.h = parseFloat(swatch.dataset.h) || 0; this._updateSliders(); this.updateOutput(); this.updateSliderBackgrounds(); } /** * Update slider UI values * @private */ _updateSliders() { if (!this.sliderL || !this.sliderC || !this.sliderH) return; const lValue = (this.current.l * 100).toFixed(1); const cValue = this.current.c.toFixed(3); const hValue = this.current.h.toFixed(1); this.sliderL.value = lValue; this.sliderC.value = cValue; this.sliderH.value = hValue; if (this.sliderLValue) this.sliderLValue.textContent = `${lValue}%`; if (this.sliderCValue) this.sliderCValue.textContent = cValue; if (this.sliderHValue) this.sliderHValue.textContent = `${hValue}°`; } /** * Attach slider event listeners * @private */ _attachSliderEvents() { // Lightness if (this.sliderL) { this.boundSliderLInput = (e) => { this.current.l = parseFloat(e.target.value) / 100; if (this.sliderLValue) { this.sliderLValue.textContent = `${parseFloat(e.target.value).toFixed(1)}%`; } this.updateOutput(); this.updateSliderBackgrounds(); }; this.sliderL.addEventListener('input', this.boundSliderLInput); } // Chroma if (this.sliderC) { this.boundSliderCInput = (e) => { this.current.c = parseFloat(e.target.value); if (this.sliderCValue) { this.sliderCValue.textContent = parseFloat(e.target.value).toFixed(3); } this.updateOutput(); this.updateSliderBackgrounds(); }; this.sliderC.addEventListener('input', this.boundSliderCInput); } // Hue if (this.sliderH) { this.boundSliderHInput = (e) => { this.current.h = parseFloat(e.target.value); if (this.sliderHValue) { this.sliderHValue.textContent = `${parseFloat(e.target.value).toFixed(1)}°`; } this.updateOutput(); this.updateSliderBackgrounds(); }; this.sliderH.addEventListener('input', this.boundSliderHInput); } } /** * Attach value edit event listeners * @private */ _attachValueEditEvents() { const root = this.mode === 'compact' ? this.modalContainer : this.container; root.querySelectorAll('.oklume-value').forEach(el => { el.contentEditable = 'true'; el.spellcheck = false; const keydownHandler = (e) => { if (e.key === 'Enter') { e.preventDefault(); el.blur(); } }; const blurHandler = (e) => { const value = e.target.textContent.trim(); this.parseColorInput(value); }; el.addEventListener('keydown', keydownHandler); el.addEventListener('blur', blurHandler); // Store handlers for cleanup this.valueEditHandlers.push({ element: el, keydown: keydownHandler, blur: blurHandler }); }); } /** * Parse user color input from editable fields and update current color * Supports: OKLCH, HEX (3 or 6 digits), RGB, HSL * @param {string} value - Color value to parse * @private */ parseColorInput(value) { try { // HEX short: #fff or fff → #ffffff if (value.match(/^#?[0-9a-fA-F]{3}$/)) { const clean = value.replace('#', ''); const hex = '#' + clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2]; const rgb = this.hexToRgb(hex); const oklch = this.rgbToOklch(rgb.r, rgb.g, rgb.b); this.setColor(oklch.l, oklch.c, oklch.h); return; } // HEX: #ff5500 or ff5500 if (value.match(/^#?[0-9a-fA-F]{6}$/)) { const hex = value.startsWith('#') ? value : '#' + value; const rgb = this.hexToRgb(hex); const oklch = this.rgbToOklch(rgb.r, rgb.g, rgb.b); this.setColor(oklch.l, oklch.c, oklch.h); return; } // RGB: rgb(255, 85, 0) or 255, 85, 0 const rgbMatch = value.match(/rgba?\(?\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); if (rgbMatch) { const r = Math.min(255, Math.max(0, parseInt(rgbMatch[1], 10))); const g = Math.min(255, Math.max(0, parseInt(rgbMatch[2], 10))); const b = Math.min(255, Math.max(0, parseInt(rgbMatch[3], 10))); const oklch = this.rgbToOklch(r, g, b); this.setColor(oklch.l, oklch.c, oklch.h); return; } // HSL: hsl(20°, 100%, 50%) or hsl(20, 100%, 50%) const hslMatch = value.match(/hsla?\(?\s*(\d+)°?\s*,\s*(\d+)%?\s*,\s*(\d+)%?/); if (hslMatch) { const h = parseInt(hslMatch[1], 10); const s = parseInt(hslMatch[2], 10); const l = parseInt(hslMatch[3], 10); const rgb = this.hslToRgb(h, s, l); const oklch = this.rgbToOklch(rgb.r, rgb.g, rgb.b); this.setColor(oklch.l, oklch.c, oklch.h); return; } // OKLCH: oklch(0.70 0.15 30) or 0.70 0.15 30 const oklchMatch = value.match(/oklch\(?\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)/); if (oklchMatch) { const l = parseFloat(oklchMatch[1]); const c = parseFloat(oklchMatch[2]); const h = parseFloat(oklchMatch[3]); this.setColor(l, c, h); return; } // If nothing matched, restore current value this.updateOutput(); } catch (err) { console.warn('Oklume: Failed to parse color input:', err); // On error, restore current value this.updateOutput(); } } /** * Set color programmatically and update UI * @param {number} l - Lightness (0-1) * @param {number} c - Chroma (0-0.4) * @param {number} h - Hue (0-360) * @public */ setColor(l, c, h) { this.current.l = Math.max(0, Math.min(1, l)); this.current.c = Math.max(0, Math.min(0.4, c)); this.current.h = h % 360; if (this.current.h < 0) this.current.h += 360; this._updateSliders(); this.updateOutput(); this.updateSliderBackgrounds(); } /** * Convert HEX color to RGB * @param {string} hex - HEX color (e.g. '#ff5500') * @returns {{r: number, g: number, b: number}} RGB object * @private */ hexToRgb(hex) { const clean = hex.replace('#', ''); return { r: parseInt(clean.substring(0, 2), 16), g: parseInt(clean.substring(2, 4), 16), b: parseInt(clean.substring(4, 6), 16) }; } /** * Convert RGB to OKLCH color space * @param {number} r - Red (0-255) * @param {number} g - Green (0-255) * @param {number} b - Blue (0-255) * @returns {{l: number, c: number, h: number}} OKLCH object * @private */ rgbToOklch(r, g, b) { const rNorm = r / 255; const gNorm = g / 255; const bNorm = b / 255; const toLinear = (c) => { return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; const rLinear = toLinear(rNorm); const gLinear = toLinear(gNorm); const bLinear = toLinear(bNorm); const l_ = 0.4122214708 * rLinear + 0.5363325363 * gLinear + 0.0514459929 * bLinear; const m_ = 0.2119034982 * rLinear + 0.6806995451 * gLinear + 0.1073969566 * bLinear; const s_ = 0.0883024619 * rLinear + 0.2817188376 * gLinear + 0.6299787005 * bLinear; const l = Math.cbrt(l_); const m = Math.cbrt(m_); const s = Math.cbrt(s_); const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s; const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s; const b_ = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s; const C = Math.sqrt(a * a + b_ * b_); let H = Math.atan2(b_, a) * 180 / Math.PI; if (H < 0) H += 360; // Achromatic colors have undefined hue, set to 0 if (C < 0.0001) H = 0; return { l: L, c: C, h: H }; } /** * Convert HSL to RGB * @param {number} h - Hue (0-360) * @param {number} s - Saturation (0-100) * @param {number} l - Lightness (0-100) * @returns {{r: number, g: number, b: number}} RGB object * @private */ hslToRgb(h, s, l) { h = h / 360; s = s / 100; l = l / 100; const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1/6) return p + (q - p) * 6 * t; if (t < 1/2) return q; if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; return p; }; let r, g, b; if (s === 0) { r = g = b = l; } else { const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1/3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1/3); } return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }; } /** * Update all output formats (OKLCH, RGB, HEX, HSL) and preview * @private */ updateOutput() { const { l, c, h } = this.current; const colorCss = this.oklchToCss(l, c, h); // Preview box if (this.previewBox) { this.previewBox.style.backgroundColor = colorCss; } // Trigger element (for compact mode) if (this.mode === 'compact' && this.triggerEl) { this.triggerEl.style.backgroundColor = colorCss; } // Get correct root element const root = this.mode === 'compact' ? this.modalContainer : this.container; // OKLCH const oklchEl = root.querySelector('.oklume-oklch'); if (oklchEl) { oklchEl.textContent = `oklch(${+l.toFixed(3)} ${+c.toFixed(3)} ${+h.toFixed(1)})`; } // RGB const rgb = this.oklchToRgb(l, c, h); const rgbEl = root.querySelector('.oklume-rgb'); if (rgbEl) { rgbEl.textContent = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; } // HEX const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b); const hexEl = root.querySelector('.oklume-hex'); if (hexEl) { hexEl.textContent = hex; } // HSL const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b); const hslEl = root.querySelector('.oklume-hsl'); if (hslEl) { hslEl.textContent = `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`; } // Callback if (this.onChange) { try { this.onChange({ oklch: colorCss, // CSS-ready OKLCH string rgb: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, // CSS-ready RGB string hex: hex, // Already a string hsl: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)` // CSS-ready HSL string }); } catch (err) { console.error('Oklume: Error in onChange callback:', err); } } } /** * Update slider backgrounds with dynamic OKLCH gradients * @private */ updateSliderBackgrounds() { const { l, c, h } = this.current; if (!this.sliderH || !this.sliderL || !this.sliderC) return; // Hue slider: show full spectrum at current L and C const hueStops = []; for (let hue = 0; hue <= 360; hue += 30) { hueStops.push(`oklch(${l} ${c} ${hue})`); } this.sliderH.style.background = `linear-gradient(to right, ${hueStops.join(', ')})`; // Lightness slider: from black to white at current C and H const lightnessStops = []; for (let lightness = 0; lightness <= 1; lightness += 0.1) { lightnessStops.push(`oklch(${lightness} ${c} ${h})`); } this.sliderL.style.background = `linear-gradient(to right, ${lightnessStops.join(', ')})`; // Chroma slider: from gray to max chroma at current L and H const chromaStops = []; for (let chroma = 0; chroma <= 0.4; chroma += 0.05) { chromaStops.push(`oklch(${l} ${chroma} ${h})`); } this.sliderC.style.background = `linear-gradient(to right, ${chromaStops.join(', ')})`; } /** * Convert OKLCH values to CSS oklch() string * @param {number} l - Lightness (0-1) * @param {number} c - Chroma (0-0.4) * @param {number} h - Hue (0-360) * @returns {string} CSS oklch() string * @private */ oklchToCss(l, c, h) { return `oklch(${l} ${c} ${h})`; } /** * Convert OKLCH to RGB color space * @param {number} l - Lightness (0-1) * @param {number} c - Chroma (0-0.4) * @param {number} h - Hue (0-360) * @returns {{r: number, g: number, b: number}} RGB object * @private */ oklchToRgb(l, c, h) { const hRad = (h * Math.PI) / 180; const a = c * Math.cos(hRad); const b = c * Math.sin(hRad); const l_ = l + 0.3963377774 * a + 0.2158037573 * b; const m_ = l - 0.1055613458 * a - 0.0638541728 * b; const s_ = l - 0.0894841775 * a - 1.2914855480 * b; const l3 = l_ * l_ * l_; const m3 = m_ * m_ * m_; const s3 = s_ * s_ * s_; const r = +4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3; const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3; const b_ = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3; const toSrgb = (c) => { c = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1/2.4) - 0.055; return Math.max(0, Math.min(255, Math.round(c * 255))); }; return { r: toSrgb(r), g: toSrgb(g), b: toSrgb(b_) }; } /** * Convert RGB to HEX color * @param {number} r - Red (0-255) * @param {number} g - Green (0-255) * @param {number} b - Blue (0-255) * @returns {string} HEX color (e.g. '#ff5500') * @private */ rgbToHex(r, g, b) { const toHex = n => n.toString(16).padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } /** * Convert RGB to HSL * @param {number} r - Red (0-255) * @param {number} g - Green (0-255) * @param {number} b - Blue (0-255) * @returns {{h: number, s: number, l: number}} HSL object * @private */ rgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const delta = max - min; let h = 0; let s = 0; let l = (max + min) / 2; if (delta !== 0) { s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); switch (max) { case r: h = ((g - b) / delta + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / delta + 2) / 6; break; case b: h = ((r - g) / delta + 4) / 6; break; } } return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }; } /** * Get current color in all supported formats (CSS-ready strings) * @returns {{oklch: string, rgb: string, hex: string, hsl: string}} Color object with all CSS-ready format strings * @public */ getColor() { const { l, c, h } = this.current; const rgb = this.oklchToRgb(l, c, h); const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b); const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b); return { oklch: this.oklchToCss(l, c, h), rgb: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, hex: hex, hsl: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)` }; } /** * Destroy the color picker instance and clean up event listeners * @public */ destroy() { // Remove trigger click listener for compact mode if (this.boundTriggerClick && this.triggerEl) { this.triggerEl.removeEventListener('click', this.boundTriggerClick); this.boundTriggerClick = null; } // Remove ESC key listener for compact mode if (this.boundEscKey) { document.removeEventListener('keydown', this.boundEscKey); this.boundEscKey = null; } // Remove backdrop click listener for compact mode if (this.boundBackdropClick && this.oklume) { this.oklume.removeEventListener('click', this.boundBackdropClick); this.boundBackdropClick = null; } // Remove save button click listener for compact mode if (this.boundSaveClick && this.saveBtnEl) { this.saveBtnEl.removeEventListener('click', this.boundSaveClick); this.boundSaveClick = null; } // Remove popup click listener for compact mode if (this.boundPopupClick && this.popup) { this.popup.removeEventListener('click', this.boundPopupClick); this.boundPopupClick = null; } // Remove slider event listeners if (this.boundSliderLInput && this.sliderL) { this.sliderL.removeEventListener('input', this.boundSliderLInput); this.boundSliderLInput = null; } if (this.boundSliderCInput && this.sliderC) { this.sliderC.removeEventListener('input', this.boundSliderCInput); this.boundSliderCInput = null; } if (this.boundSliderHInput && this.sliderH) { this.sliderH.removeEventListener('input', this.boundSliderHInput); this.boundSliderHInput = null; } // Remove palette click listener if (this.boundPaletteClick && this.paletteEl) { this.paletteEl.removeEventListener('click', this.boundPaletteClick); this.boundPaletteClick = null; } // Remove all value edit event listeners this.valueEditHandlers.forEach(({ element, keydown, blur }) => { if (element) { element.removeEventListener('keydown', keydown); element.removeEventListener('blur', blur); } }); this.valueEditHandlers = []; // Clear container if (this.mode === 'compact') { // For compact mode, remove modalContainer from body if (this.modalContainer && this.modalContainer.parentNode) { this.modalContainer.parentNode.removeChild(this.modalContainer); } // Clean up marker in original container if (this.container) { delete this.container.dataset.oklumeCompact; } } else { // For expanded mode, clear container innerHTML if (this.container) { this.container.innerHTML = ''; } } // Clear all references this.container = null; this.modalContainer = null; this.oklume = null; this.popup = null; this.previewBox = null; this.previewEl = null; this.paletteEl = null; this.sliderL = null; this.sliderC = null; this.sliderH = null; this.sliderLValue = null; this.sliderCValue = null; this.sliderHValue = null; this.saveBtnEl = null; this.onChange = null; this.palette = null; this.triggerEl = null; } } // Export for different module systems if (typeof module !== 'undefined' && module.exports) { module.exports = Oklume; } else { window.Oklume = Oklume; }