diff --git a/.changeset/colorpicker-plugin.md b/.changeset/colorpicker-plugin.md new file mode 100644 index 000000000..bfb1e78df --- /dev/null +++ b/.changeset/colorpicker-plugin.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": patch +"@tabler/docs": minor +--- + +Added `Colorpicker` plugin (`data-bs-toggle="colorpicker"`) to `tabler.js`, replacing Coloris, with `oklch()` and swatches. diff --git a/core/js/src/colorpicker.ts b/core/js/src/colorpicker.ts new file mode 100644 index 000000000..16c69a533 --- /dev/null +++ b/core/js/src/colorpicker.ts @@ -0,0 +1,883 @@ +/** + * -------------------------------------------------------------------------- + * Tabler colorpicker.ts + * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) + * -------------------------------------------------------------------------- + */ + +import * as Popper from '@popperjs/core' +import BaseComponent from './bootstrap/base-component' +import EventHandler from './bootstrap/dom/event-handler' +import SelectorEngine from './bootstrap/dom/selector-engine' +import { initAll } from './bootstrap/util/component-functions' +import { getElement, isDisabled, isRTL } from './bootstrap/util/index' +import type { ComponentConfig as BaseConfig, ElementSelector } from './bootstrap/types' +import { detectColorFormat, formatColor, hsvaToRgba, parseColor, rgbaToHsva, type ColorFormat, type HSVA, type RGBA } from './util/color' + +type OutputFormat = ColorFormat | 'auto' + +type Labels = { + dialog: string + instruction: string + /** `{s}` and `{v}` are replaced with the saturation and brightness */ + marker: string + hue: string + alpha: string + input: string + format: string + swatch: string + clear: string + close: string +} + +type ComponentConfig = { + /** show the opacity slider and keep the alpha channel of the value */ + alpha: boolean + clearButton: boolean + closeButton: boolean + /** 'light', 'dark' or 'auto' for the panel only; null inherits from the nearest `[data-bs-theme]` */ + colorpickerTheme: string | null + /** element the panel is appended to */ + container: string | HTMLElement + /** colour the panel shows while the field is empty */ + defaultColor: string + /** notation written to the field; 'auto' keeps the one the value came in */ + format: OutputFormat + /** selector in the panel to switch the notation */ + formatToggle: boolean + /** render the panel in place instead of a popup */ + inline: boolean + /** texts read by assistive technology, merged with the defaults */ + labels: Labels + /** Popper placement; `start` and `end` follow the text direction */ + placement: string + /** element the popup is aligned with; defaults to the `.colorpicker` wrapper or the element */ + positionElement: string | HTMLElement | null + /** preset colours; `null` reads the Tabler palette from CSS custom properties */ + swatches: string[] | null + /** hide the gradient and sliders, leaving the swatches */ + swatchesOnly: boolean + /** initial value for an element that is not an input */ + value: string | null +} + +type ComponentConfigInput = Partial> & { + labels?: Partial + swatches?: string[] | string | null +} & Record + +type ChangeEventArgs = { value: string; format: ColorFormat; rgba: RGBA | null; hsva: HSVA | null } + +/** + * Constants + */ + +const NAME = 'colorpicker' +const DATA_KEY = `bs.${NAME}` +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const EVENT_CHANGE = `change${EVENT_KEY}` +const EVENT_SHOW = `show${EVENT_KEY}` +const EVENT_SHOWN = `shown${EVENT_KEY}` +const EVENT_HIDE = `hide${EVENT_KEY}` +const EVENT_HIDDEN = `hidden${EVENT_KEY}` +const EVENT_CLICK = `click${EVENT_KEY}` +const EVENT_FOCUSIN = `focusin${EVENT_KEY}` +const EVENT_KEYDOWN = `keydown${EVENT_KEY}` +const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}` +const EVENT_POINTERMOVE = `pointermove${EVENT_KEY}` +const EVENT_POINTERUP = `pointerup${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` +const EVENT_FOCUSIN_DATA_API = `focusin${EVENT_KEY}${DATA_API_KEY}` +// `input` is not in EventHandler's native-event list, so it cannot be namespaced +const EVENT_INPUT = 'input' +const EVENT_NATIVE_CHANGE = 'change' + +const CLASS_NAME_SHOW = 'show' +const CLASS_NAME_PANEL = `${NAME}-panel` +const CLASS_NAME_PANEL_INLINE = `${NAME}-panel-inline` +const CLASS_NAME_AREA = `${NAME}-area` +const CLASS_NAME_MARKER = `${NAME}-marker` +const CLASS_NAME_SLIDERS = `${NAME}-sliders` +const CLASS_NAME_HUE = `${NAME}-hue` +const CLASS_NAME_ALPHA = `${NAME}-alpha` +const CLASS_NAME_SWATCHES = `${NAME}-swatches` +const CLASS_NAME_SWATCH = `${NAME}-swatch` +const CLASS_NAME_FOOTER = `${NAME}-footer` +const CLASS_NAME_INPUT = `${NAME}-input` +const CLASS_NAME_FORMAT = `${NAME}-format` +const CLASS_NAME_CLEAR = `${NAME}-clear` +const CLASS_NAME_CLOSE = `${NAME}-close` + +const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]` +const SELECTOR_WRAPPER = `.${NAME}` +const SELECTOR_BOUND_INPUT = 'input[type="hidden"], input[name]' +const SELECTOR_FOCUSABLE = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex="0"]' + +// Shipped (`--tblr-`-prefixed) custom properties. The build prefixes the SCSS +// tokens, so the plugin writes the prefixed names to meet the rendered CSS. +const PROPERTY_VALUE = '--tblr-colorpicker-value' +const PROPERTY_COLOR = '--tblr-colorpicker-color' +const PROPERTY_HUE = '--tblr-colorpicker-hue' + +const FORMATS: ColorFormat[] = ['hex', 'rgb', 'hsl', 'oklch'] +const KEY_STEP = 1 +const KEY_STEP_LARGE = 10 + +// Palette names looked up as `--tblr-` on the root element; the hex +// values are the fallback for a page that loads the script without the CSS. +const PALETTE: [string, string][] = [ + ['blue', '#066fd1'], + ['azure', '#4299e1'], + ['indigo', '#4263eb'], + ['purple', '#ae3ec9'], + ['pink', '#d6336c'], + ['red', '#d63939'], + ['orange', '#f76707'], + ['yellow', '#f59f00'], + ['lime', '#74b816'], + ['green', '#2fb344'], + ['teal', '#0ca678'], + ['cyan', '#17a2b8'], +] + +const DefaultLabels: Labels = { + dialog: 'Color picker', + instruction: 'Saturation and brightness selector. Use the arrow keys to select.', + marker: 'Saturation: {s}%. Brightness: {v}%.', + hue: 'Hue', + alpha: 'Opacity', + input: 'Color value', + format: 'Color format', + swatch: 'Color swatch', + clear: 'Clear', + close: 'Close', +} + +const Default: ComponentConfig = { + alpha: false, + clearButton: false, + closeButton: false, + colorpickerTheme: null, + container: 'body', + defaultColor: '#000000', + format: 'hex', + formatToggle: false, + inline: false, + labels: DefaultLabels, + placement: 'bottom-start', + positionElement: null, + swatches: null, + swatchesOnly: false, + value: null, +} + +const DefaultType: Record = { + alpha: 'boolean', + clearButton: 'boolean', + closeButton: 'boolean', + colorpickerTheme: '(null|string)', + container: '(string|element)', + defaultColor: 'string', + format: 'string', + formatToggle: 'boolean', + inline: 'boolean', + labels: 'object', + placement: 'string', + positionElement: '(null|string|element)', + swatches: '(array|null)', + swatchesOnly: 'boolean', + value: '(null|string)', +} + +const create = (tag: K, className: string, attributes: Record = {}): HTMLElementTagNameMap[K] => { + const element = document.createElement(tag) + element.className = className + for (const [name, value] of Object.entries(attributes)) { + element.setAttribute(name, value) + } + + return element +} + +const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) + +/** + * Class definition + * + * Same lifecycle as the Datepicker: a text input or a button carries the + * toggle, the panel is built on the first show and appended to the container, + * and the value stays in the field so the form works without the plugin. + */ + +class Colorpicker extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig + _isInput = false + _input: HTMLInputElement | null = null + _wrapper: HTMLElement | null = null + _panel: HTMLElement | null = null + _area: HTMLElement | null = null + _marker: HTMLElement | null = null + _hueInput: HTMLInputElement | null = null + _alphaInput: HTMLInputElement | null = null + _valueInput: HTMLInputElement | null = null + _formatSelect: HTMLSelectElement | null = null + _popper: Popper.Instance | null = null + _themeObserver: MutationObserver | null = null + _isShown = false + _isEmpty = true + _isTyping = false + _isWriting = false + _isRestoringFocus = false + _hsva: HSVA = { h: 0, s: 0, v: 0, a: 1 } + _format: ColorFormat = 'hex' + _onDocumentClick = (event: Event): void => this._handleDocumentClick(event) + _onDocumentFocusIn = (event: Event): void => this._handleDocumentFocusIn(event) + _onDocumentKeydown = (event: KeyboardEvent): void => this._handleDocumentKeydown(event) + _onAreaPointerMove = (event: PointerEvent): void => this._pickAtPointer(event) + _onAreaPointerUp = (): void => this._stopAreaDrag() + + constructor(element: ElementSelector, config?: ComponentConfigInput) { + super(element, config) + + if (!this._element) { + return + } + + this._init() + } + + // Getters + static get Default(): ComponentConfig { + return Default + } + + static get DefaultType(): Record { + return DefaultType + } + + static get NAME(): string { + return NAME + } + + // Public + toggle(): Promise { + if (this._config.inline) { + return Promise.resolve() + } + + return this._isShown ? this.hide() : this.show() + } + + async show(): Promise { + // Focus handed back by `hide()` reaches the Data API too, which must not reopen the panel + if (this._config.inline || this._isShown || this._isRestoringFocus || isDisabled(this._element)) { + return + } + + if (EventHandler.trigger(this._element, EVENT_SHOW)?.defaultPrevented) { + return + } + + const panel = this._getPanel() + const container = getElement(this._config.container) ?? document.body + if (panel.parentElement !== container) { + container.append(panel) + } + + this._syncTheme() + this._render() + panel.classList.add(CLASS_NAME_SHOW) + this._popper = Popper.createPopper(this._getPositionElement(), panel, this._getPopperConfig()) + this._isShown = true + + EventHandler.on(document, EVENT_CLICK, this._onDocumentClick) + EventHandler.on(document, EVENT_FOCUSIN, this._onDocumentFocusIn) + EventHandler.on(document, EVENT_KEYDOWN, this._onDocumentKeydown) + + EventHandler.trigger(this._element, EVENT_SHOWN) + } + + async hide(): Promise { + if (this._config.inline || !this._isShown) { + return + } + + if (EventHandler.trigger(this._element, EVENT_HIDE)?.defaultPrevented) { + return + } + + const focusWasInside = this._panel?.contains(document.activeElement) ?? false + + this._panel?.classList.remove(CLASS_NAME_SHOW) + this._popper?.destroy() + this._popper = null + this._isShown = false + + EventHandler.off(document, EVENT_CLICK, this._onDocumentClick) + EventHandler.off(document, EVENT_FOCUSIN, this._onDocumentFocusIn) + EventHandler.off(document, EVENT_KEYDOWN, this._onDocumentKeydown) + + if (focusWasInside) { + this._isRestoringFocus = true + this._element.focus() + this._isRestoringFocus = false + } + + EventHandler.trigger(this._element, EVENT_HIDDEN) + } + + /** The value in the configured notation, or an empty string while the field is empty. */ + getValue(): string { + return this._isEmpty ? '' : formatColor(hsvaToRgba(this._hsva), this._format, this._config.alpha) + } + + /** Any CSS colour; `null` or an empty string clears the field. */ + setValue(value: string | null): void { + if (!value || !value.trim()) { + this._clear() + return + } + + const rgba = parseColor(value) + if (!rgba) { + return + } + + this._adoptFormat(value) + this._setColor(rgbaToHsva(rgba)) + } + + dispose(): void { + this._themeObserver?.disconnect() + this._stopAreaDrag() + + if (this._isShown) { + EventHandler.off(document, EVENT_CLICK, this._onDocumentClick) + EventHandler.off(document, EVENT_FOCUSIN, this._onDocumentFocusIn) + EventHandler.off(document, EVENT_KEYDOWN, this._onDocumentKeydown) + } + + this._popper?.destroy() + this._panel?.remove() + + if (this._input && this._input !== this._element) { + EventHandler.off(this._input, EVENT_KEY) + } + + super.dispose() + } + + // Private + _configAfterMerge(config: BaseConfig): BaseConfig { + if (typeof config.swatches === 'string') { + config.swatches = config.swatches + .split(',') + .map((swatch) => swatch.trim()) + .filter(Boolean) + } + + config.labels = { ...DefaultLabels, ...(typeof config.labels === 'object' && config.labels !== null ? config.labels : {}) } + + if (typeof config.format === 'string' && !FORMATS.includes(config.format as ColorFormat) && config.format !== 'auto') { + config.format = 'hex' + } + + return config + } + + _init(): void { + this._isInput = this._element.tagName === 'INPUT' + this._input = this._isInput ? (this._element as HTMLInputElement) : (SelectorEngine.findOne(SELECTOR_BOUND_INPUT, this._element) as HTMLInputElement | null) + this._wrapper = this._element.closest(SELECTOR_WRAPPER) + this._format = this._config.format === 'auto' ? 'hex' : this._config.format + + const initial = this._input?.value || this._config.value || '' + const rgba = initial ? parseColor(initial) : null + if (rgba) { + this._adoptFormat(initial) + this._hsva = rgbaToHsva(this._config.alpha ? rgba : { ...rgba, a: 1 }) + this._isEmpty = false + } else { + this._hsva = rgbaToHsva(parseColor(this._config.defaultColor) ?? { r: 0, g: 0, b: 0, a: 1 }) + } + + this._writePreview() + + if (this._input) { + EventHandler.on(this._input, EVENT_INPUT, () => this._handleFieldInput()) + EventHandler.on(this._input, EVENT_NATIVE_CHANGE, () => this._handleFieldChange()) + } + + if (this._isInput) { + EventHandler.on(this._element, EVENT_KEYDOWN, (event: KeyboardEvent) => this._handleFieldKeydown(event)) + } + + this._setupThemeObserver() + + if (this._config.inline) { + const panel = this._getPanel() + panel.classList.add(CLASS_NAME_PANEL_INLINE, CLASS_NAME_SHOW) + if (this._isInput) { + this._element.after(panel) + } else { + this._element.append(panel) + } + + this._syncTheme() + this._render() + } + } + + // Panel + + _getPanel(): HTMLElement { + if (this._panel) { + return this._panel + } + + const { labels, alpha, swatchesOnly, formatToggle, clearButton, closeButton } = this._config + const panel = create('div', CLASS_NAME_PANEL, { 'role': 'dialog', 'aria-label': labels.dialog, 'tabindex': '-1' }) + + if (!swatchesOnly) { + this._area = create('div', CLASS_NAME_AREA, { 'role': 'application', 'aria-label': labels.instruction }) + this._marker = create('div', CLASS_NAME_MARKER, { tabindex: '0', role: 'img' }) + this._area.append(this._marker) + + const sliders = create('div', CLASS_NAME_SLIDERS) + this._hueInput = create('input', `form-range ${CLASS_NAME_HUE}`, { 'type': 'range', 'min': '0', 'max': '360', 'step': '1', 'aria-label': labels.hue }) + sliders.append(this._hueInput) + + if (alpha) { + this._alphaInput = create('input', `form-range ${CLASS_NAME_ALPHA}`, { 'type': 'range', 'min': '0', 'max': '100', 'step': '1', 'aria-label': labels.alpha }) + sliders.append(this._alphaInput) + } + + panel.append(this._area, sliders) + + EventHandler.on(this._area, EVENT_POINTERDOWN, (event: PointerEvent) => this._startAreaDrag(event)) + EventHandler.on(this._marker, EVENT_KEYDOWN, (event: KeyboardEvent) => this._handleMarkerKeydown(event)) + EventHandler.on(this._hueInput, EVENT_INPUT, () => this._setColor({ ...this._hsva, h: Number(this._hueInput!.value) })) + if (this._alphaInput) { + EventHandler.on(this._alphaInput, EVENT_INPUT, () => this._setColor({ ...this._hsva, a: Number(this._alphaInput!.value) / 100 })) + } + } + + const swatches = this._getSwatches() + if (swatches.length > 0) { + const list = create('div', CLASS_NAME_SWATCHES) + for (const swatch of swatches) { + const rgba = parseColor(swatch) + if (!rgba) { + continue + } + + const button = create('button', CLASS_NAME_SWATCH, { 'type': 'button', 'aria-label': `${labels.swatch} ${swatch}` }) + button.style.setProperty(PROPERTY_VALUE, formatColor(rgba, 'rgb')) + EventHandler.on(button, EVENT_CLICK, () => this._setColor(rgbaToHsva(this._config.alpha ? rgba : { ...rgba, a: 1 }))) + list.append(button) + } + + panel.append(list) + } + + const footer = create('div', CLASS_NAME_FOOTER) + this._valueInput = create('input', `form-control form-control-sm ${CLASS_NAME_INPUT}`, { 'type': 'text', 'spellcheck': 'false', 'autocomplete': 'off', 'aria-label': labels.input }) + footer.append(this._valueInput) + EventHandler.on(this._valueInput, EVENT_INPUT, () => this._handlePanelInput()) + EventHandler.on(this._valueInput, EVENT_NATIVE_CHANGE, () => this._render()) + + if (formatToggle) { + this._formatSelect = create('select', `form-select form-select-sm ${CLASS_NAME_FORMAT}`, { 'aria-label': labels.format }) + for (const format of FORMATS) { + const option = document.createElement('option') + option.value = format + option.textContent = format.toUpperCase() + this._formatSelect.append(option) + } + + EventHandler.on(this._formatSelect, EVENT_NATIVE_CHANGE, () => { + this._format = this._formatSelect!.value as ColorFormat + this._setColor(this._hsva) + }) + footer.append(this._formatSelect) + } + + if (clearButton) { + const button = create('button', `btn btn-sm ${CLASS_NAME_CLEAR}`, { type: 'button' }) + button.textContent = labels.clear + EventHandler.on(button, EVENT_CLICK, () => this._clear()) + footer.append(button) + } + + if (closeButton) { + const button = create('button', `btn btn-sm btn-primary ${CLASS_NAME_CLOSE}`, { type: 'button' }) + button.textContent = labels.close + EventHandler.on(button, EVENT_CLICK, () => this.hide()) + footer.append(button) + } + + panel.append(footer) + EventHandler.on(panel, EVENT_KEYDOWN, (event: KeyboardEvent) => this._handlePanelKeydown(event)) + + this._panel = panel + return panel + } + + _getSwatches(): string[] { + const { swatches } = this._config + if (swatches) { + return swatches + } + + const style = getComputedStyle(document.documentElement) + return PALETTE.map(([name, fallback]) => style.getPropertyValue(`--tblr-${name}`).trim() || fallback) + } + + /** Paints the panel from the current state; the field is written by `_setColor`. */ + _render(): void { + if (!this._panel) { + return + } + + const { h, s, v, a } = this._hsva + const rgba = hsvaToRgba(this._hsva) + const { labels } = this._config + + this._panel.style.setProperty(PROPERTY_HUE, `${Math.round(h)}`) + this._panel.style.setProperty(PROPERTY_VALUE, formatColor(rgba, 'rgb')) + this._panel.style.setProperty(PROPERTY_COLOR, formatColor(rgba, 'rgb', false)) + + if (this._marker) { + this._marker.style.insetInlineStart = `${s}%` + this._marker.style.top = `${100 - v}%` + this._marker.setAttribute('aria-label', labels.marker.replace('{s}', `${Math.round(s)}`).replace('{v}', `${Math.round(v)}`)) + } + + if (this._hueInput) { + this._hueInput.value = `${Math.round(h)}` + } + + if (this._alphaInput) { + this._alphaInput.value = `${Math.round(a * 100)}` + } + + if (this._valueInput && document.activeElement !== this._valueInput) { + this._valueInput.value = this._isEmpty ? '' : formatColor(rgba, this._format, this._config.alpha) + } + + if (this._formatSelect) { + this._formatSelect.value = this._format + } + } + + // State + + _setColor(hsva: HSVA): void { + this._hsva = { h: clamp(hsva.h, 0, 360), s: clamp(hsva.s, 0, 100), v: clamp(hsva.v, 0, 100), a: this._config.alpha ? clamp(hsva.a, 0, 1) : 1 } + this._isEmpty = false + + this._render() + this._writePreview() + + const value = this.getValue() + if (!this._isTyping) { + this._writeField(value) + } + + this._triggerChange(value, hsvaToRgba(this._hsva)) + } + + _clear(): void { + this._isEmpty = true + this._render() + this._writePreview() + + if (!this._isTyping) { + this._writeField('') + } + + this._triggerChange('', null) + } + + /** Writes the field and lets the form know, the way a user's edit would. */ + _writeField(value: string): void { + if (!this._input || this._input.value === value) { + return + } + + this._isWriting = true + this._input.value = value + this._input.dispatchEvent(new Event(EVENT_INPUT, { bubbles: true })) + this._input.dispatchEvent(new Event(EVENT_NATIVE_CHANGE, { bubbles: true })) + this._isWriting = false + } + + _triggerChange(value: string, rgba: RGBA | null): void { + const args: ChangeEventArgs = { value, format: this._format, rgba, hsva: rgba ? { ...this._hsva } : null } + EventHandler.trigger(this._element, EVENT_CHANGE, args) + } + + /** The swatch in the field and the button variant read the value from this custom property. */ + _writePreview(): void { + const target = this._wrapper ?? this._element + if (this._isEmpty) { + target.style.removeProperty(PROPERTY_VALUE) + return + } + + target.style.setProperty(PROPERTY_VALUE, formatColor(hsvaToRgba(this._hsva), 'rgb')) + } + + _adoptFormat(value: string): void { + if (this._config.format === 'auto') { + this._format = detectColorFormat(value) ?? this._format + } + } + + // Field + + _handleFieldInput(): void { + if (this._isWriting) { + return + } + + const value = this._input!.value + if (!value.trim()) { + this._isTyping = true + this._clear() + this._isTyping = false + return + } + + const rgba = parseColor(value) + if (!rgba) { + return + } + + this._adoptFormat(value) + this._isTyping = true + this._setColor(rgbaToHsva(this._config.alpha ? rgba : { ...rgba, a: 1 })) + this._isTyping = false + } + + /** On commit the field gets the value written back in the configured notation. */ + _handleFieldChange(): void { + if (this._isWriting) { + return + } + + const value = this.getValue() + if (this._input!.value !== value && (value || !parseColor(this._input!.value))) { + this._input!.value = value + } + } + + _handleFieldKeydown(event: KeyboardEvent): void { + if (!this._isShown || event.key !== 'Tab' || event.shiftKey) { + return + } + + const first = this._getFocusable()[0] + if (first) { + event.preventDefault() + first.focus() + } + } + + _handlePanelInput(): void { + const value = this._valueInput!.value + if (!value.trim()) { + return + } + + const rgba = parseColor(value) + if (!rgba) { + return + } + + this._adoptFormat(value) + this._setColor(rgbaToHsva(this._config.alpha ? rgba : { ...rgba, a: 1 })) + } + + // Gradient area + + _startAreaDrag(event: PointerEvent): void { + if (event.button !== 0) { + return + } + + event.preventDefault() + this._pickAtPointer(event) + this._marker?.focus({ preventScroll: true }) + + EventHandler.on(document, EVENT_POINTERMOVE, this._onAreaPointerMove) + EventHandler.on(document, EVENT_POINTERUP, this._onAreaPointerUp) + } + + _stopAreaDrag(): void { + EventHandler.off(document, EVENT_POINTERMOVE, this._onAreaPointerMove) + EventHandler.off(document, EVENT_POINTERUP, this._onAreaPointerUp) + } + + _pickAtPointer(event: PointerEvent): void { + const rect = this._area!.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) { + return + } + + const x = clamp((event.clientX - rect.left) / rect.width, 0, 1) + const y = clamp((event.clientY - rect.top) / rect.height, 0, 1) + + this._setColor({ ...this._hsva, s: (isRTL() ? 1 - x : x) * 100, v: (1 - y) * 100 }) + } + + _handleMarkerKeydown(event: KeyboardEvent): void { + const step = event.shiftKey ? KEY_STEP_LARGE : KEY_STEP + const inlineStep = isRTL() ? -step : step + const moves: Record = { + ArrowLeft: [-inlineStep, 0], + ArrowRight: [inlineStep, 0], + ArrowUp: [0, step], + ArrowDown: [0, -step], + } + + const move = moves[event.key] + if (!move) { + return + } + + event.preventDefault() + this._setColor({ ...this._hsva, s: this._hsva.s + move[0], v: this._hsva.v + move[1] }) + } + + // Keyboard and dismissal + + _handlePanelKeydown(event: KeyboardEvent): void { + if (event.key !== 'Tab' || this._config.inline) { + return + } + + const focusable = this._getFocusable() + const target = event.target as HTMLElement + + if (event.shiftKey && target === focusable[0]) { + event.preventDefault() + this._element.focus() + } else if (!event.shiftKey && target === focusable[focusable.length - 1]) { + event.preventDefault() + this.hide() + } + } + + _handleDocumentKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault() + this.hide() + } + } + + _handleDocumentClick(event: Event): void { + if (!this._isOwnTarget(event.target)) { + this.hide() + } + } + + _handleDocumentFocusIn(event: Event): void { + if (!this._isOwnTarget(event.target)) { + this.hide() + } + } + + _isOwnTarget(target: EventTarget | null): boolean { + return target instanceof Node && (this._element.contains(target) || (this._panel?.contains(target) ?? false) || (this._wrapper?.contains(target) ?? false)) + } + + _getFocusable(): HTMLElement[] { + return this._panel ? SelectorEngine.find(SELECTOR_FOCUSABLE, this._panel) : [] + } + + // Position and theme + + _getPositionElement(): HTMLElement { + const { positionElement } = this._config + return (typeof positionElement === 'string' ? getElement(positionElement) : positionElement) ?? this._wrapper ?? this._element + } + + _getPopperConfig(): Partial { + let { placement } = this._config + if (isRTL()) { + placement = placement.endsWith('-start') ? placement.replace('-start', '-end') : placement.endsWith('-end') ? placement.replace('-end', '-start') : placement + } + + return { + placement: placement as Popper.Placement, + modifiers: [{ name: 'flip' }, { name: 'offset', options: { offset: [0, 4] } }, { name: 'preventOverflow', options: { boundary: 'clippingParents' } }], + } + } + + _getThemeAncestor(): Element | null { + return this._element.closest('[data-bs-theme]') + } + + _syncTheme(): void { + if (!this._panel) { + return + } + + const theme = this._config.colorpickerTheme || this._getThemeAncestor()?.getAttribute('data-bs-theme') || null + if (theme) { + this._panel.setAttribute('data-bs-theme', theme) + } else { + this._panel.removeAttribute('data-bs-theme') + } + } + + /** The panel lives in the container, outside the theme's cascade, so it copies the attribute. */ + _setupThemeObserver(): void { + const ancestor = this._getThemeAncestor() + if (!ancestor || this._config.colorpickerTheme) { + return + } + + this._themeObserver = new MutationObserver(() => this._syncTheme()) + this._themeObserver.observe(ancestor, { attributes: true, attributeFilter: ['data-bs-theme'] }) + } +} + +/** + * Data API implementation + */ + +// js-docs-start colorpicker-init +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) { + // Inputs open on focus, and inline panels are always visible + if (this.tagName === 'INPUT' || this.dataset.bsInline === 'true' || this.dataset.tblrInline === 'true') { + return + } + + event.preventDefault() + ;(Colorpicker.getOrCreateInstance(this) as Colorpicker).toggle() +}) + +EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement) { + if (this.tagName !== 'INPUT') { + return + } + + ;(Colorpicker.getOrCreateInstance(this) as Colorpicker).show() +}) + +// Render on load what cannot wait for a focus or a click: inline panels, and +// fields with a value, so the swatch in the field shows it right away. +initAll(SELECTOR_DATA_TOGGLE, Colorpicker, (element) => { + const { dataset } = element + + return dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsValue' in dataset || 'tblrValue' in dataset || Boolean((element as HTMLInputElement).value) || Boolean(SelectorEngine.findOne(SELECTOR_BOUND_INPUT, element)) +}) +// js-docs-end colorpicker-init + +export default Colorpicker +export type { ComponentConfig as ColorpickerConfig, ChangeEventArgs as ColorpickerChangeEventArgs, Labels as ColorpickerLabels } diff --git a/core/js/src/util/color.ts b/core/js/src/util/color.ts new file mode 100644 index 000000000..6d83633cf --- /dev/null +++ b/core/js/src/util/color.ts @@ -0,0 +1,363 @@ +/** + * -------------------------------------------------------------------------- + * Tabler util/color.ts + * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) + * -------------------------------------------------------------------------- + */ + +/** sRGB channels 0-255, alpha 0-1 */ +export type RGBA = { r: number; g: number; b: number; a: number } + +/** hue 0-360, saturation and value 0-100, alpha 0-1 */ +export type HSVA = { h: number; s: number; v: number; a: number } + +/** hue 0-360, saturation and lightness 0-100, alpha 0-1 */ +export type HSLA = { h: number; s: number; l: number; a: number } + +/** lightness 0-100, chroma 0-0.4, hue 0-360, alpha 0-1 */ +export type OKLCHA = { l: number; c: number; h: number; a: number } + +export type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'oklch' + +const HEX_PATTERN = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i +const FUNCTION_PATTERN = /^(rgba?|hsla?|oklch)\(\s*([^)]*?)\s*\)$/i + +const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) + +const round = (value: number, decimals = 0): number => { + const factor = 10 ** decimals + return Math.round(value * factor) / factor +} + +/** Reads one `rgb()` / `hsl()` / `oklch()` argument, scaling a percentage to `scale` and a bare number to itself. */ +const channel = (token: string, scale: number, max: number): number | null => { + if (token === 'none') { + return 0 + } + + const percent = token.endsWith('%') + const number = Number.parseFloat(percent ? token.slice(0, -1) : token) + if (Number.isNaN(number)) { + return null + } + + return clamp(percent ? (number / 100) * scale : number, 0, max) +} + +const hue = (token: string): number | null => { + if (token === 'none') { + return 0 + } + + const match = /^(-?[\d.]+)(deg|grad|rad|turn)?$/i.exec(token) + if (!match) { + return null + } + + const number = Number.parseFloat(match[1]!) + const factor = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 }[(match[2] ?? 'deg').toLowerCase() as 'deg' | 'grad' | 'rad' | 'turn'] + + return (((number * factor) % 360) + 360) % 360 +} + +const alphaChannel = (token: string | undefined): number | null => { + if (token === undefined || token === '') { + return 1 + } + + return channel(token, 1, 1) +} + +/** Splits `r, g, b, a`, `r g b / a` and `r g b` into channel tokens and an alpha token. */ +const splitArguments = (body: string): [string[], string | undefined] | null => { + const [channels, alpha] = body.split('/').map((part) => part.trim()) + const tokens = channels! + .split(/[\s,]+/) + .map((token) => token.trim().toLowerCase()) + .filter(Boolean) + + if (tokens.length === 4 && alpha === undefined) { + return [tokens.slice(0, 3), tokens[3]] + } + + if (tokens.length !== 3) { + return null + } + + return [tokens, alpha?.toLowerCase()] +} + +const parseHex = (hex: string): RGBA => { + let digits = hex.slice(1) + if (digits.length < 6) { + digits = [...digits].map((digit) => digit + digit).join('') + } + + const value = Number.parseInt(digits.slice(0, 6), 16) + + return { + r: (value >> 16) & 255, + g: (value >> 8) & 255, + b: value & 255, + a: digits.length === 8 ? round(Number.parseInt(digits.slice(6), 16) / 255, 3) : 1, + } +} + +let canvasContext: CanvasRenderingContext2D | null | undefined + +/** + * Lets the browser read what the parser above does not know: named colours, + * `color()`, `lab()`, ... A colour it rejects leaves the fill style untouched. + */ +const parseWithCanvas = (value: string): RGBA | null => { + if (canvasContext === undefined) { + canvasContext = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d', { willReadFrequently: true }) + } + + if (!canvasContext) { + return null + } + + // A rejected value leaves the previous fill in place, so the read-back is + // trusted only once it differs from the probe; black and white each match + // one probe, no colour matches both. + for (const probe of ['#000000', '#ffffff']) { + canvasContext.fillStyle = probe + canvasContext.fillStyle = value + const result = String(canvasContext.fillStyle) + + if (result !== probe) { + return HEX_PATTERN.test(result) ? parseHex(result) : parseFunction(result) + } + } + + return null +} + +const parseFunction = (value: string): RGBA | null => { + const match = FUNCTION_PATTERN.exec(value) + if (!match) { + return null + } + + const split = splitArguments(match[2]!) + if (!split) { + return null + } + + const [tokens, alphaToken] = split + const a = alphaChannel(alphaToken) + if (a === null) { + return null + } + + const name = match[1]!.toLowerCase() + + if (name.startsWith('rgb')) { + const r = channel(tokens[0]!, 255, 255) + const g = channel(tokens[1]!, 255, 255) + const b = channel(tokens[2]!, 255, 255) + return r === null || g === null || b === null ? null : { r: round(r), g: round(g), b: round(b), a } + } + + if (name.startsWith('hsl')) { + const h = hue(tokens[0]!) + const s = channel(tokens[1]!, 100, 100) + const l = channel(tokens[2]!, 100, 100) + return h === null || s === null || l === null ? null : hslaToRgba({ h, s, l, a }) + } + + const l = channel(tokens[0]!, 100, 100) + const c = channel(tokens[1]!, 0.4, 0.5) + const h = hue(tokens[2]!) + // A bare lightness in `oklch()` is 0-1, a percentage is 0-100 + return l === null || c === null || h === null ? null : oklchaToRgba({ l: tokens[0]!.endsWith('%') ? l : l * 100, c, h, a }) +} + +/** + * Reads any CSS colour into sRGB. Returns `null` when the string is not a colour. + */ +export const parseColor = (value: string): RGBA | null => { + const trimmed = value.trim() + if (!trimmed) { + return null + } + + if (HEX_PATTERN.test(trimmed)) { + return parseHex(trimmed) + } + + return parseFunction(trimmed) ?? parseWithCanvas(trimmed) +} + +/** + * Names the notation a colour string is written in, or `null` for a string + * whose notation the picker cannot write back (named colours, `color()`). + */ +export const detectColorFormat = (value: string): ColorFormat | null => { + const trimmed = value.trim().toLowerCase() + + if (HEX_PATTERN.test(trimmed)) { + return 'hex' + } + + const match = FUNCTION_PATTERN.exec(trimmed) + if (!match) { + return null + } + + const name = match[1]! + return name.startsWith('rgb') ? 'rgb' : name.startsWith('hsl') ? 'hsl' : 'oklch' +} + +const hex2 = (value: number): string => + Math.round(clamp(value, 0, 255)) + .toString(16) + .padStart(2, '0') + +/** + * Writes a colour in the given notation. Alpha is left out when it is 1, or + * always when `alpha` is false. + */ +export const formatColor = (rgba: RGBA, format: ColorFormat, alpha = true): string => { + const a = alpha ? clamp(rgba.a, 0, 1) : 1 + const opaque = a >= 1 + + switch (format) { + case 'rgb': { + const rgb = `${Math.round(rgba.r)} ${Math.round(rgba.g)} ${Math.round(rgba.b)}` + return opaque ? `rgb(${rgb})` : `rgb(${rgb} / ${round(a, 3)})` + } + + case 'hsl': { + const { h, s, l } = rgbaToHsla(rgba) + const hsl = `${round(h)} ${round(s)}% ${round(l)}%` + return opaque ? `hsl(${hsl})` : `hsl(${hsl} / ${round(a, 3)})` + } + + case 'oklch': { + const { l, c, h } = rgbaToOklcha(rgba) + const oklch = `${round(l, 2)}% ${round(c, 4)} ${round(h, 2)}` + return opaque ? `oklch(${oklch})` : `oklch(${oklch} / ${round(a, 3)})` + } + + default: { + const hex = `#${hex2(rgba.r)}${hex2(rgba.g)}${hex2(rgba.b)}` + return opaque ? hex : `${hex}${hex2(a * 255)}` + } + } +} + +export const rgbaToHsva = ({ r, g, b, a }: RGBA): HSVA => { + const red = r / 255 + const green = g / 255 + const blue = b / 255 + const max = Math.max(red, green, blue) + const min = Math.min(red, green, blue) + const delta = max - min + + let h = 0 + if (delta !== 0) { + if (max === red) { + h = ((green - blue) / delta) % 6 + } else if (max === green) { + h = (blue - red) / delta + 2 + } else { + h = (red - green) / delta + 4 + } + + h = (((h * 60) % 360) + 360) % 360 + } + + return { h, s: max === 0 ? 0 : (delta / max) * 100, v: max * 100, a } +} + +export const hsvaToRgba = ({ h, s, v, a }: HSVA): RGBA => { + const saturation = clamp(s, 0, 100) / 100 + const value = clamp(v, 0, 100) / 100 + const chroma = value * saturation + const sector = (((h % 360) + 360) % 360) / 60 + const x = chroma * (1 - Math.abs((sector % 2) - 1)) + const m = value - chroma + + const [red, green, blue] = sector < 1 ? [chroma, x, 0] : sector < 2 ? [x, chroma, 0] : sector < 3 ? [0, chroma, x] : sector < 4 ? [0, x, chroma] : sector < 5 ? [x, 0, chroma] : [chroma, 0, x] + + return { r: round((red + m) * 255), g: round((green + m) * 255), b: round((blue + m) * 255), a } +} + +export const rgbaToHsla = (rgba: RGBA): HSLA => { + const { h, s, v, a } = rgbaToHsva(rgba) + const l = (v * (200 - s)) / 200 + const divisor = Math.min(l, 100 - l) + + return { h, s: divisor === 0 ? 0 : ((v - l) / divisor) * 100, l, a } +} + +export const hslaToRgba = ({ h, s, l, a }: HSLA): RGBA => { + const saturation = clamp(s, 0, 100) / 100 + const lightness = clamp(l, 0, 100) / 100 + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation + const sector = (((h % 360) + 360) % 360) / 60 + const x = chroma * (1 - Math.abs((sector % 2) - 1)) + const m = lightness - chroma / 2 + + const [red, green, blue] = sector < 1 ? [chroma, x, 0] : sector < 2 ? [x, chroma, 0] : sector < 3 ? [0, chroma, x] : sector < 4 ? [0, x, chroma] : sector < 5 ? [x, 0, chroma] : [chroma, 0, x] + + return { r: round((red + m) * 255), g: round((green + m) * 255), b: round((blue + m) * 255), a } +} + +const toLinear = (channel: number): number => { + const c = channel / 255 + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 +} + +const fromLinear = (channel: number): number => { + const c = channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055 + return c * 255 +} + +export const rgbaToOklcha = ({ r, g, b, a }: RGBA): OKLCHA => { + const red = toLinear(r) + const green = toLinear(g) + const blue = toLinear(b) + + const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue) + const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue) + const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue) + + const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const aAxis = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + + const chroma = Math.hypot(aAxis, bAxis) + // Grey has no hue; a tiny chroma is rounding noise from the matrices + const h = chroma < 0.0001 ? 0 : ((Math.atan2(bAxis, aAxis) * 180) / Math.PI + 360) % 360 + + return { l: clamp(lightness * 100, 0, 100), c: chroma < 0.0001 ? 0 : chroma, h, a } +} + +/** + * Converts to sRGB and clips what is out of gamut channel by channel, so a + * vivid `oklch()` still lands on its nearest displayable colour. + */ +export const oklchaToRgba = ({ l, c, h, a }: OKLCHA): RGBA => { + const lightness = clamp(l, 0, 100) / 100 + const radians = (h * Math.PI) / 180 + const aAxis = c * Math.cos(radians) + const bAxis = c * Math.sin(radians) + + const l_ = (lightness + 0.3963377774 * aAxis + 0.2158037573 * bAxis) ** 3 + const m_ = (lightness - 0.1055613458 * aAxis - 0.0638541728 * bAxis) ** 3 + const s_ = (lightness - 0.0894841775 * aAxis - 1.291485548 * bAxis) ** 3 + + const red = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_ + const green = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_ + const blue = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_ + + return { + r: round(clamp(fromLinear(clamp(red, 0, 1)), 0, 255)), + g: round(clamp(fromLinear(clamp(green, 0, 1)), 0, 255)), + b: round(clamp(fromLinear(clamp(blue, 0, 1)), 0, 255)), + a, + } +} diff --git a/core/js/tabler.ts b/core/js/tabler.ts index ef8eadf58..955c2ae39 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -1,5 +1,6 @@ import './src/autosize' import './src/clipboard' +import './src/colorpicker' import './src/confetti' import './src/countup' import './src/datepicker' @@ -22,6 +23,7 @@ export * from './src/bootstrap' // Tabler's own components export { default as Autosize } from './src/autosize' export { default as Clipboard } from './src/clipboard' +export { default as Colorpicker } from './src/colorpicker' export { default as Confetti } from './src/confetti' export { default as CountUp } from './src/countup' export { default as Datepicker } from './src/datepicker' diff --git a/core/js/tests/unit/colorpicker.spec.ts b/core/js/tests/unit/colorpicker.spec.ts new file mode 100644 index 000000000..3c872f8d1 --- /dev/null +++ b/core/js/tests/unit/colorpicker.spec.ts @@ -0,0 +1,482 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest' +import { clearFixture, getFixture } from '../helpers/fixture' +import Colorpicker from '../../src/colorpicker' +import EventHandler from '../../src/bootstrap/dom/event-handler' + +const PROPERTY_VALUE = '--tblr-colorpicker-value' + +const typeInto = (input: HTMLInputElement, value: string): void => { + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +const commit = (input: HTMLInputElement): void => { + input.dispatchEvent(new Event('change', { bubbles: true })) +} + +const keydown = (element: Element, key: string, init: KeyboardEventInit = {}): void => { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })) +} + +const panels = (): HTMLElement[] => [...document.querySelectorAll('.colorpicker-panel')] + +describe('Colorpicker', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + for (const element of fixtureEl.querySelectorAll('[data-bs-toggle="colorpicker"]')) { + Colorpicker.getInstance(element)?.dispose() + } + + for (const panel of panels()) { + panel.remove() + } + + clearFixture() + }) + + const mount = (html: string): HTMLInputElement => { + fixtureEl.innerHTML = html + return fixtureEl.querySelector('input')! + } + + const field = (attributes = '', value = '#066fd1'): HTMLInputElement => mount(`
`) + + describe('constructor', () => { + it('reads the value from the field and paints the swatch', () => { + const input = field() + new Colorpicker(input) + + const wrapper = fixtureEl.querySelector('.colorpicker')! + expect(wrapper.style.getPropertyValue(PROPERTY_VALUE)).toBe('rgb(6 111 209)') + expect(Colorpicker.getInstance(input)!.getValue()).toBe('#066fd1') + }) + + it('stays empty on an empty field and paints nothing', () => { + const input = field('', '') + const instance = new Colorpicker(input) + + expect(instance.getValue()).toBe('') + expect(fixtureEl.querySelector('.colorpicker')!.style.getPropertyValue(PROPERTY_VALUE)).toBe('') + }) + + it('does not build the panel before the first show', () => { + new Colorpicker(field()) + + expect(panels()).toHaveLength(0) + }) + + it('drops the alpha channel unless alpha is on', () => { + const withoutAlpha = new Colorpicker(field('', '#066fd180')) + expect(withoutAlpha.getValue()).toBe('#066fd1') + withoutAlpha.dispose() + + const withAlpha = new Colorpicker(field('data-bs-alpha="true"', '#066fd180')) + expect(withAlpha.getValue()).toBe('#066fd180') + }) + }) + + describe('show and hide', () => { + it('opens the panel in the body with the dialog role and fires the events', async () => { + const input = field() + const instance = new Colorpicker(input) + const show = vi.fn() + const shown = vi.fn() + EventHandler.on(input, 'show.bs.colorpicker', show) + EventHandler.on(input, 'shown.bs.colorpicker', shown) + + await instance.show() + + const panel = panels()[0]! + expect(panel.parentElement).toBe(document.body) + expect(panel.classList.contains('show')).toBe(true) + expect(panel.getAttribute('role')).toBe('dialog') + expect(show).toHaveBeenCalledTimes(1) + expect(shown).toHaveBeenCalledTimes(1) + expect(panel.querySelector('.colorpicker-area')).not.toBeNull() + expect(panel.querySelector('.colorpicker-hue')).not.toBeNull() + expect(panel.querySelector('.colorpicker-alpha')).toBeNull() + expect(panel.querySelectorAll('.colorpicker-swatch')).toHaveLength(12) + }) + + it('can be prevented', async () => { + const input = field() + const instance = new Colorpicker(input) + EventHandler.on(input, 'show.bs.colorpicker', (event: Event) => event.preventDefault()) + + await instance.show() + + expect(panels()).toHaveLength(0) + }) + + it('hides on Escape, on a click outside and on focus leaving', async () => { + const input = field() + const instance = new Colorpicker(input) + const hidden = vi.fn() + EventHandler.on(input, 'hidden.bs.colorpicker', hidden) + + await instance.show() + keydown(document.body, 'Escape') + expect(panels()[0]!.classList.contains('show')).toBe(false) + expect(hidden).toHaveBeenCalledTimes(1) + + await instance.show() + document.body.click() + expect(hidden).toHaveBeenCalledTimes(2) + + await instance.show() + const other = document.createElement('button') + fixtureEl.append(other) + other.focus() + expect(hidden).toHaveBeenCalledTimes(3) + }) + + it('keeps the panel open on a click or focus inside', async () => { + const input = field() + const instance = new Colorpicker(input) + + await instance.show() + panels()[0]!.click() + input.click() + panels()[0]!.querySelector('.colorpicker-input')!.focus() + + expect(panels()[0]!.classList.contains('show')).toBe(true) + }) + + it('returns focus to the field when the panel had it', async () => { + const input = field() + const instance = new Colorpicker(input) + + await instance.show() + panels()[0]!.querySelector('.colorpicker-input')!.focus() + await instance.hide() + + expect(document.activeElement).toBe(input) + }) + + it('opens on focus and toggles on click through the Data API', () => { + const input = field() + input.focus() + input.dispatchEvent(new Event('focusin', { bubbles: true })) + + expect(Colorpicker.getInstance(input)).not.toBeNull() + expect(panels()[0]!.classList.contains('show')).toBe(true) + }) + }) + + describe('picking', () => { + it('writes a swatch to the field and fires change with the native events', async () => { + const input = field() + const instance = new Colorpicker(input, { swatches: ['#ff0000', 'rgb(0 255 0)'] }) + const change = vi.fn() + const nativeInput = vi.fn() + const nativeChange = vi.fn() + input.addEventListener('change.bs.colorpicker', change) + input.addEventListener('input', nativeInput) + input.addEventListener('change', nativeChange) + + await instance.show() + const swatches = panels()[0]!.querySelectorAll('.colorpicker-swatch') + expect(swatches).toHaveLength(2) + swatches[1]!.click() + + expect(input.value).toBe('#00ff00') + expect(change).toHaveBeenCalledTimes(1) + expect((change.mock.calls[0]![0] as CustomEvent & { value: string; rgba: { r: number } }).value).toBe('#00ff00') + expect(nativeInput).toHaveBeenCalledTimes(1) + expect(nativeChange).toHaveBeenCalledTimes(1) + }) + + it('follows the hue slider', async () => { + const input = field('', '#ff0000') + const instance = new Colorpicker(input) + + await instance.show() + const hue = panels()[0]!.querySelector('.colorpicker-hue')! + expect(hue.value).toBe('0') + typeInto(hue, '120') + + expect(input.value).toBe('#00ff00') + expect(panels()[0]!.style.getPropertyValue('--tblr-colorpicker-hue')).toBe('120') + }) + + it('moves the marker with the arrow keys', async () => { + const input = field('', '#ff0000') + const instance = new Colorpicker(input) + + await instance.show() + const marker = panels()[0]!.querySelector('.colorpicker-marker')! + expect(marker.style.insetInlineStart).toBe('100%') + keydown(marker, 'ArrowLeft', { shiftKey: true }) + keydown(marker, 'ArrowDown') + + expect(marker.style.insetInlineStart).toBe('90%') + expect(marker.style.top).toBe('1%') + expect(marker.getAttribute('aria-label')).toBe('Saturation: 90%. Brightness: 99%.') + expect(input.value).toBe('#fc1919') + }) + + it('picks at the pointer position in the area', async () => { + const input = field('', '#ff0000') + const instance = new Colorpicker(input) + + await instance.show() + const area = panels()[0]!.querySelector('.colorpicker-area')! + area.style.width = '200px' + area.style.height = '100px' + const rect = area.getBoundingClientRect() + area.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: rect.left + 100, clientY: rect.top + 100, bubbles: true, cancelable: true })) + document.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + + expect(instance.getValue()).toBe('#000000') + }) + + it('keeps the alpha from the opacity slider in hex8', async () => { + const input = field('data-bs-alpha="true"', '#ff0000') + const instance = new Colorpicker(input) + + await instance.show() + typeInto(panels()[0]!.querySelector('.colorpicker-alpha')!, '50') + + expect(input.value).toBe('#ff000080') + }) + + it('switches the notation with the format select', async () => { + const input = field('data-bs-format-toggle="true"') + const instance = new Colorpicker(input) + + await instance.show() + const select = panels()[0]!.querySelector('.colorpicker-format')! + select.value = 'rgb' + commit(select as unknown as HTMLInputElement) + + expect(input.value).toBe('rgb(6 111 209)') + }) + + it('reads a colour typed in the panel', async () => { + const input = field() + const instance = new Colorpicker(input) + + await instance.show() + typeInto(panels()[0]!.querySelector('.colorpicker-input')!, 'hsl(120 100% 50%)') + + expect(input.value).toBe('#00ff00') + }) + }) + + describe('the field', () => { + it('follows what is typed and leaves the text alone until commit', () => { + const input = field() + const instance = new Colorpicker(input) + const change = vi.fn() + input.addEventListener('change.bs.colorpicker', change) + + typeInto(input, 'RGB(255, 0, 0)') + expect(input.value).toBe('RGB(255, 0, 0)') + expect(instance.getValue()).toBe('#ff0000') + expect(change).toHaveBeenCalledTimes(1) + + commit(input) + expect(input.value).toBe('#ff0000') + }) + + it('ignores an invalid entry and restores the value on commit', () => { + const input = field() + const instance = new Colorpicker(input) + + typeInto(input, '#12') + expect(instance.getValue()).toBe('#066fd1') + + commit(input) + expect(input.value).toBe('#066fd1') + }) + + it('clears the value when the field is emptied', () => { + const input = field() + const instance = new Colorpicker(input) + const change = vi.fn() + input.addEventListener('change.bs.colorpicker', change) + + typeInto(input, '') + + expect(instance.getValue()).toBe('') + expect(fixtureEl.querySelector('.colorpicker')!.style.getPropertyValue(PROPERTY_VALUE)).toBe('') + expect((change.mock.calls[0]![0] as CustomEvent & { rgba: unknown }).rgba).toBeNull() + }) + + it('keeps the notation the value came in with format auto', () => { + const input = field('data-bs-format="auto"', 'rgb(6 111 209)') + const instance = new Colorpicker(input) + + expect(instance.getValue()).toBe('rgb(6 111 209)') + typeInto(input, 'oklch(54.6% 0.1724 254.2)') + expect(instance.getValue()).toMatch(/^oklch\(/) + }) + + it('moves focus into the panel on Tab and back out at the end', async () => { + const input = field('data-bs-close-button="true"') + const instance = new Colorpicker(input) + + await instance.show() + input.focus() + keydown(input, 'Tab') + const marker = panels()[0]!.querySelector('.colorpicker-marker')! + expect(document.activeElement).toBe(marker) + + const close = panels()[0]!.querySelector('.colorpicker-close')! + close.focus() + keydown(close, 'Tab') + expect(panels()[0]!.classList.contains('show')).toBe(false) + expect(document.activeElement).toBe(input) + }) + }) + + describe('options', () => { + it('reads swatches from a comma list', async () => { + const input = field('data-bs-swatches="#ff0000, #00ff00"') + const instance = new Colorpicker(input) + + await instance.show() + + expect(panels()[0]!.querySelectorAll('.colorpicker-swatch')).toHaveLength(2) + }) + + it('leaves only the swatches with swatchesOnly', async () => { + const input = field('data-bs-swatches-only="true"') + const instance = new Colorpicker(input) + + await instance.show() + const panel = panels()[0]! + + expect(panel.querySelector('.colorpicker-area')).toBeNull() + expect(panel.querySelector('.colorpicker-hue')).toBeNull() + expect(panel.querySelectorAll('.colorpicker-swatch').length).toBeGreaterThan(0) + }) + + it('adds the clear and close buttons on request', async () => { + const input = field('data-bs-clear-button="true" data-bs-close-button="true"') + const instance = new Colorpicker(input) + + await instance.show() + panels()[0]!.querySelector('.colorpicker-clear')!.click() + expect(input.value).toBe('') + + panels()[0]!.querySelector('.colorpicker-close')!.click() + expect(panels()[0]!.classList.contains('show')).toBe(false) + }) + + it('renders inline right after the field and never hides', async () => { + const input = field('data-bs-inline="true"') + const instance = new Colorpicker(input) + + const panel = input.nextElementSibling as HTMLElement + expect(panel.classList.contains('colorpicker-panel-inline')).toBe(true) + expect(panel.classList.contains('show')).toBe(true) + + await instance.hide() + expect(panel.classList.contains('show')).toBe(true) + }) + + it('copies the theme of the nearest ancestor to the panel and follows changes', async () => { + fixtureEl.innerHTML = '
' + const input = fixtureEl.querySelector('input')! + const instance = new Colorpicker(input) + + await instance.show() + expect(panels()[0]!.getAttribute('data-bs-theme')).toBe('dark') + + fixtureEl.firstElementChild!.setAttribute('data-bs-theme', 'light') + await new Promise((resolve) => setTimeout(resolve)) + expect(panels()[0]!.getAttribute('data-bs-theme')).toBe('light') + }) + + it('honours colorpickerTheme over the ancestor', async () => { + fixtureEl.innerHTML = '
' + const instance = new Colorpicker(fixtureEl.querySelector('input')!) + + await instance.show() + + expect(panels()[0]!.getAttribute('data-bs-theme')).toBe('light') + }) + + it('replaces the labels', async () => { + const input = field() + const instance = new Colorpicker(input, { labels: { dialog: 'Kolor', hue: 'Odcień' } }) + + await instance.show() + const panel = panels()[0]! + + expect(panel.getAttribute('aria-label')).toBe('Kolor') + expect(panel.querySelector('.colorpicker-hue')!.getAttribute('aria-label')).toBe('Odcień') + expect(panel.querySelector('.colorpicker-input')!.getAttribute('aria-label')).toBe('Color value') + }) + }) + + describe('button trigger', () => { + it('keeps the value in the hidden input and paints the button', async () => { + fixtureEl.innerHTML = '' + const button = fixtureEl.querySelector('button')! + const hidden = fixtureEl.querySelector('input')! + + button.click() + const instance = Colorpicker.getInstance(button) as Colorpicker + expect(instance.getValue()).toBe('#ff0000') + expect(button.style.getPropertyValue(PROPERTY_VALUE)).toBe('rgb(255 0 0)') + expect(panels()[0]!.classList.contains('show')).toBe(true) + + instance.setValue('#00ff00') + expect(hidden.value).toBe('#00ff00') + expect(button.style.getPropertyValue(PROPERTY_VALUE)).toBe('rgb(0 255 0)') + + button.click() + expect(panels()[0]!.classList.contains('show')).toBe(false) + }) + + it('starts from the value option without an input', () => { + fixtureEl.innerHTML = '' + const instance = new Colorpicker(fixtureEl.querySelector('button')!) + + expect(instance.getValue()).toBe('#0000ff') + }) + }) + + describe('API', () => { + it('sets and clears the value', () => { + const input = field() + const instance = new Colorpicker(input) + + instance.setValue('oklch(100% 0 0)') + expect(input.value).toBe('#ffffff') + + instance.setValue('nope') + expect(input.value).toBe('#ffffff') + + instance.setValue(null) + expect(input.value).toBe('') + expect(instance.getValue()).toBe('') + }) + + it('disposes the panel and the document listeners', async () => { + const input = field() + const instance = new Colorpicker(input) + + await instance.show() + instance.dispose() + + expect(panels()).toHaveLength(0) + expect(Colorpicker.getInstance(input)).toBeNull() + document.body.click() + }) + + it('exposes the defaults', () => { + expect(Colorpicker.NAME).toBe('colorpicker') + expect(Colorpicker.Default.format).toBe('hex') + expect(Colorpicker.Default.alpha).toBe(false) + expect(Object.keys(Colorpicker.DefaultType)).toEqual(Object.keys(Colorpicker.Default)) + }) + }) +}) diff --git a/core/js/tests/unit/util/color.spec.ts b/core/js/tests/unit/util/color.spec.ts new file mode 100644 index 000000000..f1296fc59 --- /dev/null +++ b/core/js/tests/unit/util/color.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import { detectColorFormat, formatColor, hslaToRgba, hsvaToRgba, oklchaToRgba, parseColor, rgbaToHsla, rgbaToHsva, rgbaToOklcha } from '../../../src/util/color' + +const blue = { r: 6, g: 111, b: 209, a: 1 } + +describe('util/color', () => { + describe('parseColor', () => { + it('reads hex in every length', () => { + expect(parseColor('#066fd1')).toEqual(blue) + expect(parseColor('#FFF')).toEqual({ r: 255, g: 255, b: 255, a: 1 }) + expect(parseColor('#0008')).toEqual({ r: 0, g: 0, b: 0, a: 0.533 }) + expect(parseColor('#066fd180')).toEqual({ ...blue, a: 0.502 }) + }) + + it('reads rgb() in legacy and modern syntax', () => { + expect(parseColor('rgb(6, 111, 209)')).toEqual(blue) + expect(parseColor('rgba(6, 111, 209, 0.5)')).toEqual({ ...blue, a: 0.5 }) + expect(parseColor('rgb(6 111 209 / 50%)')).toEqual({ ...blue, a: 0.5 }) + expect(parseColor('rgb(100% 0% 0%)')).toEqual({ r: 255, g: 0, b: 0, a: 1 }) + }) + + it('reads hsl() with hue units', () => { + expect(parseColor('hsl(120, 100%, 50%)')).toEqual({ r: 0, g: 255, b: 0, a: 1 }) + expect(parseColor('hsl(0.5turn 100% 50% / 0.25)')).toEqual({ r: 0, g: 255, b: 255, a: 0.25 }) + expect(parseColor('hsla(-120deg, 100%, 50%, 1)')).toEqual({ r: 0, g: 0, b: 255, a: 1 }) + }) + + it('reads oklch() as written in the Tabler tokens', () => { + const parsed = parseColor('oklch(54.6% 0.1724 254.2deg)')! + expect(Math.abs(parsed.r - blue.r)).toBeLessThanOrEqual(2) + expect(Math.abs(parsed.g - blue.g)).toBeLessThanOrEqual(2) + expect(Math.abs(parsed.b - blue.b)).toBeLessThanOrEqual(2) + expect(parseColor('oklch(0 0 0)')).toEqual({ r: 0, g: 0, b: 0, a: 1 }) + expect(parseColor('oklch(100% 0 0 / 0.5)')).toEqual({ r: 255, g: 255, b: 255, a: 0.5 }) + }) + + it('clips an out of gamut oklch() to sRGB', () => { + const parsed = parseColor('oklch(90% 0.4 140)')! + expect(parsed.g).toBe(255) + expect(parsed.r).toBeGreaterThanOrEqual(0) + }) + + it('falls back to the browser for named colours', () => { + expect(parseColor('red')).toEqual({ r: 255, g: 0, b: 0, a: 1 }) + expect(parseColor('transparent')).toEqual({ r: 0, g: 0, b: 0, a: 0 }) + expect(parseColor('black')).toEqual({ r: 0, g: 0, b: 0, a: 1 }) + }) + + it('rejects what is not a colour', () => { + expect(parseColor('')).toBeNull() + expect(parseColor('nope')).toBeNull() + expect(parseColor('#12345')).toBeNull() + expect(parseColor('rgb(1 2)')).toBeNull() + expect(parseColor('var(--tblr-blue)')).toBeNull() + }) + }) + + describe('detectColorFormat', () => { + it('names the notation', () => { + expect(detectColorFormat('#fff')).toBe('hex') + expect(detectColorFormat(' RGBA(1,2,3,1) ')).toBe('rgb') + expect(detectColorFormat('hsl(1 2% 3%)')).toBe('hsl') + expect(detectColorFormat('oklch(50% 0.1 20)')).toBe('oklch') + expect(detectColorFormat('red')).toBeNull() + }) + }) + + describe('formatColor', () => { + it('writes hex, with alpha only when it is not 1', () => { + expect(formatColor(blue, 'hex')).toBe('#066fd1') + expect(formatColor({ ...blue, a: 0.5 }, 'hex')).toBe('#066fd180') + expect(formatColor({ ...blue, a: 0.5 }, 'hex', false)).toBe('#066fd1') + }) + + it('writes rgb and hsl in modern syntax', () => { + expect(formatColor(blue, 'rgb')).toBe('rgb(6 111 209)') + expect(formatColor({ ...blue, a: 0.25 }, 'rgb')).toBe('rgb(6 111 209 / 0.25)') + expect(formatColor({ r: 0, g: 255, b: 0, a: 1 }, 'hsl')).toBe('hsl(120 100% 50%)') + expect(formatColor({ r: 0, g: 255, b: 0, a: 0.5 }, 'hsl')).toBe('hsl(120 100% 50% / 0.5)') + }) + + it('writes oklch that parses back to the same colour', () => { + const written = formatColor(blue, 'oklch') + expect(written).toMatch(/^oklch\(\d+(\.\d+)?% 0\.\d+ \d+(\.\d+)?\)$/) + expect(parseColor(written)).toEqual(blue) + expect(formatColor({ ...blue, a: 0.5 }, 'oklch')).toMatch(/ \/ 0\.5\)$/) + }) + }) + + describe('conversions', () => { + it('round-trips through HSV', () => { + const hsva = rgbaToHsva(blue) + expect(Math.round(hsva.h)).toBe(209) + expect(hsvaToRgba(hsva)).toEqual(blue) + expect(rgbaToHsva({ r: 128, g: 128, b: 128, a: 1 })).toEqual({ h: 0, s: 0, v: (128 / 255) * 100, a: 1 }) + }) + + it('round-trips through HSL', () => { + expect(hslaToRgba(rgbaToHsla(blue))).toEqual(blue) + expect(rgbaToHsla({ r: 255, g: 255, b: 255, a: 1 })).toEqual({ h: 0, s: 0, l: 100, a: 1 }) + }) + + it('round-trips through OKLCH', () => { + for (const color of [blue, { r: 255, g: 0, b: 0, a: 1 }, { r: 0, g: 0, b: 0, a: 1 }, { r: 255, g: 255, b: 255, a: 1 }, { r: 40, g: 200, b: 90, a: 0.3 }]) { + expect(oklchaToRgba(rgbaToOklcha(color))).toEqual(color) + } + + expect(rgbaToOklcha({ r: 128, g: 128, b: 128, a: 1 }).c).toBe(0) + }) + }) +}) diff --git a/core/package.json b/core/package.json index 37e580c94..c229c7a62 100644 --- a/core/package.json +++ b/core/package.json @@ -212,19 +212,19 @@ }, { "path": "./dist/js/tabler.js", - "maxSize": "58 kB" + "maxSize": "68 kB" }, { "path": "./dist/js/tabler.min.js", - "maxSize": "40 kB" + "maxSize": "46 kB" }, { "path": "./dist/js/tabler.esm.js", - "maxSize": "58 kB" + "maxSize": "68 kB" }, { "path": "./dist/js/tabler.esm.min.js", - "maxSize": "40 kB" + "maxSize": "46 kB" }, { "path": "./dist/js/tabler-theme.js", diff --git a/core/scss/_core.scss b/core/scss/_core.scss index ca30366c8..0c24a917a 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -70,6 +70,7 @@ @forward 'ui/forms/form-otp'; @forward 'ui/forms/validation'; @forward 'ui/forms/strength'; +@forward 'ui/forms/colorpicker'; @forward 'ui/legend'; @forward 'ui/lists'; @forward 'ui/loaders'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index caa133799..aec3cca56 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -2312,6 +2312,22 @@ $datepicker-box-shadow: $dropdown-box-shadow !default; $datepicker-min-width: 280px !default; // scss-docs-end datepicker-variables +// Color picker +// scss-docs-start colorpicker-variables +$colorpicker-width: 240px !default; +$colorpicker-padding: 0.75rem !default; +$colorpicker-gap: 0.75rem !default; +$colorpicker-bg: $dropdown-bg !default; +$colorpicker-border-radius: $dropdown-border-radius !default; +$colorpicker-box-shadow: $dropdown-box-shadow !default; +$colorpicker-area-height: 140px !default; +$colorpicker-marker-size: 1rem !default; +$colorpicker-swatch-size: 1.5rem !default; +$colorpicker-swatch-border-radius: var(--border-radius) !default; +$colorpicker-checkerboard: repeating-conic-gradient(var(--border-color) 0 25%, var(--bg-surface) 0 50%) !default; +$colorpicker-checkerboard-size: 0.5rem !default; +// scss-docs-end colorpicker-variables + // HR text $hr-text-gap: 0.5rem !default; $hr-text-spaceless-margin-y: -0.5rem !default; diff --git a/core/scss/tabler-vendors.scss b/core/scss/tabler-vendors.scss index cce656e31..4b0b45864 100644 --- a/core/scss/tabler-vendors.scss +++ b/core/scss/tabler-vendors.scss @@ -9,6 +9,8 @@ @forward 'vendor/plyr'; @forward 'vendor/wysiwyg'; @forward 'vendor/stars-rating'; +// deprecated(2.0): Coloris is replaced by the Colorpicker plugin; its styles +// stay for projects that still load it. @forward 'vendor/coloris'; @forward 'vendor/typed'; @forward 'vendor/fullcalendar'; diff --git a/core/scss/ui/forms/_colorpicker.scss b/core/scss/ui/forms/_colorpicker.scss new file mode 100644 index 000000000..0dcb46b4f --- /dev/null +++ b/core/scss/ui/forms/_colorpicker.scss @@ -0,0 +1,230 @@ +/* +Color picker + */ +@use '../../config' as *; + +// The plugin writes the chosen colour to `--colorpicker-value` (and its opaque +// twin `--colorpicker-color`) on the `.colorpicker` wrapper, the button and the +// panel, so every swatch below is drawn by CSS alone. +@mixin colorpicker-fill() { + background-color: var(--bg-surface); + background-image: linear-gradient(var(--colorpicker-value, transparent), var(--colorpicker-value, transparent)), var(--colorpicker-checkerboard); + background-size: + auto, + var(--colorpicker-checkerboard-size) var(--colorpicker-checkerboard-size); +} + +// +// Field +// + +.colorpicker { + --colorpicker-swatch-size: #{$colorpicker-swatch-size}; + --colorpicker-swatch-border-radius: #{$colorpicker-swatch-border-radius}; + --colorpicker-checkerboard: #{$colorpicker-checkerboard}; + --colorpicker-checkerboard-size: #{$colorpicker-checkerboard-size}; + position: relative; + display: block; + + // The swatch sits over the start of the field + &::before { + position: absolute; + inset-inline-start: calc(var(--colorpicker-swatch-size) / 3); + top: 50%; + z-index: 5; + width: var(--colorpicker-swatch-size); + height: var(--colorpicker-swatch-size); + pointer-events: none; + content: ''; + box-shadow: inset 0 0 0 1px var(--border-color-translucent); + translate: 0 -50%; + @include colorpicker-fill(); + @include border-radius(var(--colorpicker-swatch-border-radius)); + } + + .form-control { + padding-inline-start: calc(var(--colorpicker-swatch-size) * 5 / 3); + } +} + +// A button trigger shows the value in a swatch of its own +.colorpicker-preview { + --colorpicker-swatch-size: #{$colorpicker-swatch-size}; + --colorpicker-swatch-border-radius: #{$colorpicker-swatch-border-radius}; + --colorpicker-checkerboard: #{$colorpicker-checkerboard}; + --colorpicker-checkerboard-size: #{$colorpicker-checkerboard-size}; + display: inline-block; + width: var(--colorpicker-swatch-size); + height: var(--colorpicker-swatch-size); + vertical-align: middle; + box-shadow: inset 0 0 0 1px var(--border-color-translucent); + @include colorpicker-fill(); + @include border-radius(var(--colorpicker-swatch-border-radius)); +} + +// +// Panel +// + +.colorpicker-panel { + // scss-docs-start colorpicker-css-vars + --colorpicker-width: #{$colorpicker-width}; + --colorpicker-padding: #{$colorpicker-padding}; + --colorpicker-gap: #{$colorpicker-gap}; + --colorpicker-bg: #{$colorpicker-bg}; + --colorpicker-border-radius: #{$colorpicker-border-radius}; + --colorpicker-box-shadow: #{$colorpicker-box-shadow}; + --colorpicker-zindex: #{$zindex-dropdown}; + --colorpicker-area-height: #{$colorpicker-area-height}; + --colorpicker-marker-size: #{$colorpicker-marker-size}; + --colorpicker-swatch-size: #{$colorpicker-swatch-size}; + --colorpicker-swatch-border-radius: #{$colorpicker-swatch-border-radius}; + --colorpicker-checkerboard: #{$colorpicker-checkerboard}; + --colorpicker-checkerboard-size: #{$colorpicker-checkerboard-size}; + // Set by the plugin: the hue in degrees, the colour, and the colour without alpha + --colorpicker-hue: 0; + --colorpicker-value: transparent; + --colorpicker-color: transparent; + // scss-docs-end colorpicker-css-vars + position: absolute; + z-index: var(--colorpicker-zindex); + display: none; + flex-direction: column; + gap: var(--colorpicker-gap); + width: var(--colorpicker-width); + padding: var(--colorpicker-padding); + background-color: var(--colorpicker-bg); + box-shadow: var(--colorpicker-box-shadow); + @include border-radius(var(--colorpicker-border-radius)); + + &.show { + display: flex; + } +} + +.colorpicker-panel-inline { + position: static; + z-index: auto; + border: var(--border-width) var(--border-style) var(--border-color); + box-shadow: none; +} + +// Saturation on the inline axis, brightness on the block axis +.colorpicker-area { + position: relative; + height: var(--colorpicker-area-height); + touch-action: none; + cursor: crosshair; + background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), hsl(calc(var(--colorpicker-hue) * 1deg) 100% 50%); + @include border-radius(var(--colorpicker-swatch-border-radius)); +} + +.colorpicker-marker { + position: absolute; + inset-inline-start: 0; + top: 0; + width: var(--colorpicker-marker-size); + height: var(--colorpicker-marker-size); + background-color: var(--colorpicker-color); + border: 2px var(--border-style) var(--white); + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); + + /* rtl:ignore */ + translate: calc(var(--dir) * -50%) -50%; + @include focus-ring-transition(); + + &:focus-visible { + @include focus-ring(true); + } +} + +.colorpicker-sliders { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +// The hue and opacity ranges reuse `.form-range` and only repaint the track +.colorpicker-hue, +.colorpicker-alpha { + --colorpicker-track: transparent; + + &::-webkit-slider-runnable-track { + background-color: transparent; + background-image: var(--colorpicker-track), var(--colorpicker-checkerboard); + background-size: + auto, + var(--colorpicker-checkerboard-size) var(--colorpicker-checkerboard-size); + } + + &::-moz-range-track { + background-color: transparent; + background-image: var(--colorpicker-track), var(--colorpicker-checkerboard); + background-size: + auto, + var(--colorpicker-checkerboard-size) var(--colorpicker-checkerboard-size); + } +} + +.colorpicker-hue { + --colorpicker-track: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); + + &::-webkit-slider-thumb { + background-color: hsl(calc(var(--colorpicker-hue) * 1deg) 100% 50%); + } + + &::-moz-range-thumb { + background-color: hsl(calc(var(--colorpicker-hue) * 1deg) 100% 50%); + } +} + +.colorpicker-alpha { + --colorpicker-track: linear-gradient(to right, transparent, var(--colorpicker-color)); + + &::-webkit-slider-thumb { + background-color: var(--colorpicker-value); + } + + &::-moz-range-thumb { + background-color: var(--colorpicker-value); + } +} + +.colorpicker-swatches { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.colorpicker-swatch { + width: var(--colorpicker-swatch-size); + height: var(--colorpicker-swatch-size); + padding: 0; + cursor: pointer; + border: 0; + box-shadow: inset 0 0 0 1px var(--border-color-translucent); + @include colorpicker-fill(); + @include border-radius(var(--colorpicker-swatch-border-radius)); + @include focus-ring-transition(); + + &:focus-visible { + @include focus-ring(true); + } +} + +.colorpicker-footer { + display: flex; + gap: 0.5rem; + align-items: center; + + .form-control { + flex: 1; + min-width: 0; + } + + .form-select { + flex: 0 0 auto; + width: auto; + } +} diff --git a/core/scss/vendor/_coloris.scss b/core/scss/vendor/_coloris.scss index b6a82da9a..bf6b86273 100644 --- a/core/scss/vendor/_coloris.scss +++ b/core/scss/vendor/_coloris.scss @@ -1,5 +1,9 @@ @use '../config' as *; +// deprecated(2.0): Coloris is replaced by the Colorpicker plugin in `tabler.js` +// (`data-bs-toggle="colorpicker"`); these overrides stay for projects that +// still load it. + .clr-picker { background-color: var(--bg-surface); box-shadow: var(--shadow-dropdown); diff --git a/docs/content/ui/forms/color-picker.mdx b/docs/content/ui/forms/color-picker.mdx new file mode 100644 index 000000000..a656d23fb --- /dev/null +++ b/docs/content/ui/forms/color-picker.mdx @@ -0,0 +1,258 @@ +--- +title: Color picker +order: 14 +summary: A color picker lets users pick a color from a gradient, a set of swatches or a typed value, for theme settings and tag colors. +description: Let users pick a color in a form with the Colorpicker plugin, as a text input, a button or an inline panel. +related: [/ui/forms/elements, /ui/forms/color-check, /ui/plugins/date-picker] +source: core/scss/ui/forms/_colorpicker.scss +css-vars: colorpicker +sass-vars: colorpicker +classnames: + component: + - class: colorpicker + desc: Wrapper around the text input; draws the swatch at the start of the field + part: + - class: colorpicker-preview + desc: Swatch inside a button trigger, painted with the picked color + - class: colorpicker-panel + desc: The panel the plugin renders, as a popup or inline + - class: colorpicker-area + desc: Saturation and brightness gradient + - class: colorpicker-marker + desc: The handle in the gradient + - class: colorpicker-hue + desc: Hue slider, a `.form-range` + - class: colorpicker-alpha + desc: Opacity slider, a `.form-range` + - class: colorpicker-swatches + desc: Row of preset colors + - class: colorpicker-swatch + desc: One preset color + - class: colorpicker-footer + desc: Value input, format select and the buttons + modifier: + - class: colorpicker-panel-inline + desc: Panel rendered in place, without the popup surface +--- + +import Example from '@components/Example.astro'; +import CodeDocs from '@components/CodeDocs.astro'; + +## Overview + +The color picker is a normal text input with the Colorpicker plugin from `tabler.js` attached. Add `data-bs-toggle="colorpicker"` to the input and wrap it in `.colorpicker`, and Tabler draws a swatch at the start of the field and opens a panel on focus. The value stays in the input, so the field works in a form like any other, and the plugin needs no extra script or stylesheet. + + +
+ +
+
+ +The panel has a gradient for saturation and brightness, a hue slider, the Tabler palette as swatches, and a field to type a value. Every part works with the keyboard. + +## Usage + +### Basic field + +Wrap a text input in `.colorpicker` and add `data-bs-toggle="colorpicker"`. The wrapper is what draws the swatch, so keep it even when you style the field yourself. + + +
+ +
+
+ +### Color format + +Set `data-bs-format` to choose what the input stores. Use `hex` when you save the value in a database, because it is short and easy to read. Any CSS color can be typed into the field, and the plugin writes it back in the chosen notation when the field loses focus. + +| Format | Value in the input | +| --- | --- | +| `hex` | `#066fd1` | +| `rgb` | `rgb(6 111 209)` | +| `hsl` | `hsl(209 94% 42%)` | +| `oklch` | `oklch(54.6% 0.1724 254.2)` | +| `auto` | Keeps the notation the value came in. | + + +
+ +
+
+ +
+
+ +### Alpha channel + +Alpha is off by default and the value is always opaque. Set `data-bs-alpha="true"` to add the opacity slider. A hex value then gets eight digits, and the other notations a `/ alpha` part. + + +
+ +
+
+ +### Swatches + +The panel shows the Tabler palette by default, read from the `--tblr-*` custom properties, so it follows your theme. Pass your own list in `data-bs-swatches`, as a comma-separated list or a JSON array. Any CSS color works, including `oklch()`. + + +
+ +
+
+ +### Swatches only + +Set `data-bs-swatches-only="true"` to hide the gradient and the sliders. Users can then pick only from your list. Use it when a design system allows a fixed set of colors. + + +
+ +
+
+ +### Format toggle, clear and close + +Add a select to switch the notation inside the panel, a button that empties the field, and a button that closes the panel. + + +
+ +
+
+ +### Button trigger + +Any element other than an input opens the panel on click. Put a hidden input inside it to keep the value in the form, and a `.colorpicker-preview` to show the picked color. Without an input, `data-bs-value` sets the starting color. + + + + + +### Inline panel + +Set `data-bs-inline="true"` to render the panel in place, right after the field. It is always open, so use it in a settings page or a sidebar where the picker is the main content. + + +
+ +
+
+ +### Dark mode + +The panel is appended to ``, outside the cascade of the field, so the plugin copies the nearest `data-bs-theme` to it and follows later changes. Set `data-bs-colorpicker-theme` to force a theme for the panel alone. + +## Examples + +### Field with a label + +Use the picker like any other form field: a label, the input, and a hint below it. + + +
+
+
+ +
+ +
+
This color is used for buttons and links.
+
+
+
+
+ +## JavaScript + +### Via data attributes + +Add `data-bs-toggle="colorpicker"` and Tabler does the rest. Inputs open on focus, other elements open on click, and inline panels and fields with a value render on page load so the swatch shows right away. Every option below can be set as a `data-bs-*` attribute, for example `data-bs-swatches-only="true"`. + + + +### Via JavaScript + +Create the picker yourself when you need options that do not fit in an attribute, such as translated labels: + +```js +const colorpicker = new tabler.Colorpicker(document.getElementById('my-color'), { + format: 'rgb', + alpha: true, + labels: { dialog: 'Kolor', hue: 'Odcień' }, +}); +``` + +### Options + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `format` | string | `'hex'` | Notation written to the field: `'hex'`, `'rgb'`, `'hsl'`, `'oklch'` or `'auto'`. | +| `alpha` | boolean | `false` | Show the opacity slider and keep the alpha channel. | +| `swatches` | array | `null` | Preset colors. `null` reads the Tabler palette from the CSS custom properties. | +| `swatchesOnly` | boolean | `false` | Hide the gradient and the sliders. | +| `formatToggle` | boolean | `false` | Add a select to switch the notation in the panel. | +| `clearButton` | boolean | `false` | Add a button that empties the field. | +| `closeButton` | boolean | `false` | Add a button that closes the panel. | +| `inline` | boolean | `false` | Render the panel in place instead of a popup. | +| `defaultColor` | string | `'#000000'` | Color the panel shows while the field is empty. | +| `value` | string | `null` | Starting color for a trigger that is not an input and has no hidden input. | +| `placement` | string | `'bottom-start'` | Where the popup lines up with the field, as a Popper placement. `start` and `end` follow the text direction. | +| `positionElement` | string, element | `null` | Element the popup lines up with. Defaults to the `.colorpicker` wrapper, or the field itself. | +| `container` | string, element | `'body'` | Element the popup is appended to. | +| `colorpickerTheme` | string | `null` | Force the panel's theme to `'light'`, `'dark'` or `'auto'`. By default it follows the nearest `[data-bs-theme]`. | +| `labels` | object | English texts | Texts read by assistive technology: `dialog`, `instruction`, `marker`, `hue`, `alpha`, `input`, `format`, `swatch`, `clear` and `close`. Keys you leave out keep the default. | + +### Methods + +| Method | Description | +| --- | --- | +| `show()` | Opens the popup. Does nothing for an inline panel. | +| `hide()` | Closes the popup. | +| `toggle()` | Opens or closes the popup. | +| `getValue()` | Returns the color in the configured notation, or an empty string. | +| `setValue(color)` | Sets the color from any CSS color string. `null` or an empty string clears the field. | +| `dispose()` | Removes the panel and the instance. | +| `Colorpicker.getInstance(element)` | Returns the instance for an element, or `null`. | +| `Colorpicker.getOrCreateInstance(element)` | Returns the instance, or creates one. | + +### Events + +| Event | Description | +| --- | --- | +| `show.bs.colorpicker` | Fires before the popup opens. Call `preventDefault()` to keep it closed. | +| `shown.bs.colorpicker` | Fires once the popup is open. | +| `hide.bs.colorpicker` | Fires before the popup closes. Call `preventDefault()` to keep it open. | +| `hidden.bs.colorpicker` | Fires once the popup is closed. | +| `change.bs.colorpicker` | Fires on every change. The event has `value`, the string written to the field, `format`, and `rgba` and `hsva` objects, both `null` when the field was cleared. | + +The field also gets native `input` and `change` events whenever the panel changes it, so form libraries and frameworks see the new value without listening to the plugin. + +```js +document.getElementById('my-color').addEventListener('change.bs.colorpicker', (event) => { + console.log(event.value, event.rgba); +}); +``` + +## Accessibility + +- Always add a `