Add native Colorpicker plugin replacing Coloris

- data-bs-toggle="colorpicker" on inputs and buttons, oklch support, Tabler palette swatches
- docs moved to ui/forms/color-picker, JS size budgets raised
This commit is contained in:
codecalm committed 2026-09-28 23:54:04 +02:00
1 parent a15d5d71a1
commit 06a53b6e7c
19 files changed
+2510 -268

No files matched your search

+7
View File
@@ -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.
+883
View File
@@ -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<Omit<ComponentConfig, 'labels' | 'swatches'>> & {
labels?: Partial<Labels>
swatches?: string[] | string | null
} & Record<string, unknown>
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-<name>` 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<keyof ComponentConfig, string> = {
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 = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, attributes: Record<string, string> = {}): 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<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
// Public
toggle(): Promise<void> {
if (this._config.inline) {
return Promise.resolve()
}
return this._isShown ? this.hide() : this.show()
}
async show(): Promise<void> {
// 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<void> {
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<HTMLElement>(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<string, [number, number]> = {
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<Popper.Options> {
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 }
+363
View File
@@ -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,
}
}
+2
View File
@@ -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'
+482
View File
@@ -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<HTMLElement>('.colorpicker-panel')]
describe('Colorpicker', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
for (const element of fixtureEl.querySelectorAll<HTMLElement>('[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(`<div class="colorpicker"><input type="text" class="form-control" data-bs-toggle="colorpicker" value="${value}" ${attributes}></div>`)
describe('constructor', () => {
it('reads the value from the field and paints the swatch', () => {
const input = field()
new Colorpicker(input)
const wrapper = fixtureEl.querySelector<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('.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<HTMLInputElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLInputElement>('.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<HTMLSelectElement>('.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<HTMLInputElement>('.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<HTMLElement>('.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<HTMLElement>('.colorpicker-marker')!
expect(document.activeElement).toBe(marker)
const close = panels()[0]!.querySelector<HTMLElement>('.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<HTMLElement>('.colorpicker-clear')!.click()
expect(input.value).toBe('')
panels()[0]!.querySelector<HTMLElement>('.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 = '<div data-bs-theme="dark"><input type="text" data-bs-toggle="colorpicker" value="#fff"></div>'
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 = '<div data-bs-theme="dark"><input type="text" data-bs-toggle="colorpicker" data-bs-colorpicker-theme="light" value="#fff"></div>'
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 = '<button type="button" class="btn" data-bs-toggle="colorpicker"><input type="hidden" name="color" value="#ff0000"><span class="colorpicker-preview"></span> Color</button>'
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 = '<button type="button" data-bs-toggle="colorpicker" data-bs-value="#00f">Color</button>'
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))
})
})
})
+111
View File
@@ -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)
})
})
})
+4 -4
View File
@@ -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",
+1
View File
@@ -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';
+16
View File
@@ -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;
+2
View File
@@ -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';
+230
View File
@@ -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;
}
}
+4
View File
@@ -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);
+258
View File
@@ -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.
<Example>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" value="#066fd1" aria-label="Color value" />
</div>
</Example>
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.
<Example>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" value="#4299e1" aria-label="Color value" />
</div>
</Example>
### 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. |
<Example column>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" data-bs-format="rgb" value="rgb(66 153 225)" aria-label="RGB color value" />
</div>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" data-bs-format="oklch" value="oklch(64% 0.22 27)" aria-label="OKLCH color value" />
</div>
</Example>
### 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.
<Example>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" data-bs-alpha="true" value="#ae3ec980" aria-label="Color value with opacity" />
</div>
</Example>
### 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()`.
<Example>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" data-bs-swatches="#066fd1, #4299e1, #4263eb, #ae3ec9, #d6336c, #d63939" value="#4299e1" aria-label="Color value" />
</div>
</Example>
### 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.
<Example>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" data-bs-swatches-only="true" data-bs-swatches="#066fd1, #4299e1, #4263eb, #ae3ec9, #d6336c, #d63939, #f76707, #2fb344" value="#2fb344" aria-label="Color value" />
</div>
</Example>
### 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.
<Example>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" data-bs-format-toggle="true" data-bs-clear-button="true" data-bs-close-button="true" value="#f76707" aria-label="Color value" />
</div>
</Example>
### 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.
<Example>
<button type="button" class="btn" data-bs-toggle="colorpicker">
<input type="hidden" name="brand-color" value="#066fd1" />
<span class="colorpicker-preview me-2"></span>
Brand color
</button>
</Example>
### 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.
<Example>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" data-bs-inline="true" data-bs-alpha="true" value="#0ca678" aria-label="Color value" />
</div>
</Example>
### Dark mode
The panel is appended to `<body>`, 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.
<Example bg="surface-secondary" column>
<div class="card">
<div class="card-body">
<div class="mb-0">
<label class="form-label" for="colorpicker-form">Brand color</label>
<div class="colorpicker">
<input type="text" class="form-control" id="colorpicker-form" data-bs-toggle="colorpicker" value="#d6336c" />
</div>
<div class="form-text">This color is used for buttons and links.</div>
</div>
</div>
</div>
</Example>
## 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"`.
<CodeDocs name="colorpicker-init" file="core/js/src/colorpicker.ts" />
### 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 `<label>` linked to the input with `for` and `id`. The swatch alone does not say what the field is for.
- Keep the text value visible. Users who cannot tell colors apart can still read and type the value.
- Do not use the color as the only meaning. If a color marks a status, repeat the status in text.
- The panel is a `role="dialog"`. <kbd>Tab</kbd> from the field moves into it, the arrow keys move the handle in the gradient (<kbd>Shift</kbd> for bigger steps), the sliders are native range inputs, and <kbd>Escape</kbd> closes the panel and returns focus to the field.
- The panel closes on a click outside and when focus moves to something outside it, so keyboard users are never trapped.
## Migration from Coloris
Until version 1.6 the color picker was the [Coloris](https://github.com/melloware/coloris-npm) library, restyled in `tabler-vendors.css`. It still ships in `dist/libs` and keeps working, but it will be removed in 2.0. To move a field to the plugin:
- Drop the Coloris script and stylesheet, and the `Coloris()` call.
- Wrap the input in `.colorpicker` and add `data-bs-toggle="colorpicker"`.
- Move the options to `data-bs-*` attributes. `el` and `setInstance()` are gone, because every field has its own instance. `themeMode`, `rtl` and `margin` are handled by the theme and the text direction. `selectInput` has no equivalent. `format: 'mixed'` becomes `format="auto"`.
- Replace the `coloris:pick` listener with `change.bs.colorpicker` on the field, or with the native `input` event.
-197
View File
@@ -1,197 +0,0 @@
---
title: Color picker
order: 9
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.
docs-libs: [coloris.js]
description: Let users pick a color in a form.
related: [/ui/plugins/date-picker]
---
import Example from '@components/Example.astro';
import CdnImportPlugin from '@components/CdnImportPlugin.astro';
import { Code } from 'astro:components';
import { site } from '@shared/lib/site.ts';
## Overview
The color picker is a normal text input with the [Coloris](https://github.com/melloware/coloris-npm) plugin attached. Coloris wraps the field, adds a color button inside it, and opens a picker on click. The value stays in the input, so the field works in a form like any other.
<Example>
<input type="text" class="form-control d-block" id="colorpicker-overview" value="#066fd1" />
</Example>
## Installation
Install Coloris with npm:
```shell
npm install @melloware/coloris
```
Or include it from a CDN. You need both the script and its stylesheet:
<Code
lang="html"
code={`<link rel="stylesheet" href="${site.cdnUrl}/dist/libs/@melloware/coloris/dist/coloris.min.css" />
<script src="${site.cdnUrl}/dist/libs/@melloware/coloris/dist/umd/coloris.min.js"></script>`}
/>
Tabler restyles the picker to match the rest of the interface. Those styles live in the vendors plugin, so include `tabler-vendors.css` as well:
<CdnImportPlugin plugins={['vendors']} />
## Usage
### Basic field
Add a text input with the `form-control` class, then attach Coloris to it with a selector.
<Example codeOnly>
<input type="text" class="form-control d-block" id="colorpicker" value="#066fd1" />
</Example>
```js
document.addEventListener('DOMContentLoaded', function () {
Coloris({
el: '#colorpicker',
selectInput: false,
})
})
```
`selectInput: false` stops the plugin from selecting the whole text when the field gets focus.
### Color format
Set `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.
| Format | Value in the input |
| --- | --- |
| `hex` | `#066fd1` |
| `rgb` | `rgb(6, 111, 209)` |
| `hsl` | `hsl(209, 94%, 42%)` |
| `mixed` | Hex when the color is solid, `rgba()` when it is not. |
| `auto` | Guessed from the current value. |
<Example>
<input type="text" class="form-control d-block" id="colorpicker-rgb" value="rgb(66, 153, 225)" />
</Example>
### Alpha channel
Alpha is off by default. Set `alpha: true` to add the opacity slider.
<Example>
<input type="text" class="form-control d-block" id="colorpicker-alpha" value="rgba(174, 62, 201, 0.5)" />
</Example>
```js
Coloris({ el: '#colorpicker-alpha', alpha: true, format: 'mixed' })
```
### Swatches
Pass a list of colors as `swatches`. They show under the picker as quick choices. Tabler theme colors work well here.
<Example>
<input type="text" class="form-control d-block" id="colorpicker-swatches" value="#4299e1" />
</Example>
```js
Coloris({
el: '#colorpicker-swatches',
swatches: ['#066fd1', '#4299e1', '#4263eb', '#ae3ec9', '#d6336c', '#d63939'],
})
```
Write the swatches as hex values. Tabler's CSS variables such as `--tblr-blue` hold `oklch()` colors, which Coloris cannot parse, so do not read the swatches from them.
### Swatches only
Set `swatchesOnly: true` to hide the gradient. Users can then pick only from your list. Use it when a design system allows a fixed set of colors.
<Example>
<input type="text" class="form-control d-block" id="colorpicker-swatches-only" value="#2fb344" />
</Example>
### Common options
These are the options you will need most often. Coloris has more, and they are listed in its [documentation](https://github.com/melloware/coloris-npm#configuration).
| Option | What it does |
| --- | --- |
| `el` | Selector of the field or fields to attach to. |
| `format` | Value format: `hex`, `rgb`, `hsl`, `mixed`, or `auto`. |
| `alpha` | Shows the opacity slider. Off by default. |
| `swatches` | List of colors shown under the picker. |
| `swatchesOnly` | Hides the gradient and leaves only the swatches. |
| `selectInput` | Selects the text when the field gets focus. |
| `formatToggle` | Lets users switch the format inside the picker. |
| `clearButton` | Adds a button that clears the value. |
| `closeButton` | Adds a close button to the picker. |
| `themeMode` | `light`, `dark`, or `auto`. |
| `inline` | Shows the picker on the page instead of in a popup. |
### Read the picked color
Coloris fires a `coloris:pick` event on every change. Use it to update a preview or to save the value.
```js
document.addEventListener('coloris:pick', function (event) {
console.log(event.detail.color)
})
```
### Several fields with different options
`Coloris()` sets the options for all fields it is attached to. When one field needs its own settings, register it with `setInstance()`.
```js
Coloris({ el: '.color-input', alpha: false, swatchesOnly: false, swatches: swatches })
Coloris.setInstance('#brand-color', {
alpha: false,
swatchesOnly: true,
swatches: swatches,
})
```
There is one picker for the whole page, and it is set up again every time it opens. Options you leave out of an instance keep the value from the field opened before it. Repeat the options that matter in every instance, as in the example above, so each field always opens the same way.
## Examples
### Field with a label
Use the picker like any other form field: a label, the input, and a hint below it.
<Example bg="surface-secondary" column>
<div class="card">
<div class="card-body">
<div class="mb-0">
<label class="form-label" for="colorpicker-form">Brand color</label>
<input type="text" class="form-control d-block" id="colorpicker-form" value="#d6336c" />
<div class="form-text">This color is used for buttons and links.</div>
</div>
</div>
</div>
</Example>
## Accessibility
- Always add a `<label>` linked to the input with `for` and `id`. The color button alone does not say what the field is for.
- Keep the text value visible. Users who cannot tell colors apart can still read and type the value.
- Do not use the color as the only meaning. If a color marks a status, repeat the status in text.
- The picker can be used with the keyboard, and the input accepts a typed value, so a mouse is never required.
<script>{`
window.addEventListener('load', function () {
if (typeof Coloris === 'undefined') return;
const swatches = ['#066fd1', '#4299e1', '#4263eb', '#ae3ec9', '#d6336c', '#d63939', '#f76707', '#2fb344'];
Coloris({ el: '.form-control[id^="colorpicker"]', selectInput: false, format: 'hex', alpha: false, swatchesOnly: false, swatches: swatches });
Coloris.setInstance('#colorpicker-rgb', { format: 'rgb', alpha: false, swatchesOnly: false, swatches: swatches });
Coloris.setInstance('#colorpicker-alpha', { format: 'mixed', alpha: true, swatchesOnly: false, swatches: swatches });
Coloris.setInstance('#colorpicker-swatches-only', { format: 'hex', alpha: false, swatchesOnly: true, swatches: swatches });
});
`}</script>
+1 -1
View File
@@ -4,7 +4,7 @@ order: 10
summary: A date picker lets users pick a date from a calendar instead of typing it, for booking forms, deadlines and other date fields.
docs-libs: [vanilla-calendar-pro]
description: Let users pick a date, a range or several dates with the Datepicker plugin, as a text input, a button or an inline calendar.
related: [/ui/forms/elements, /ui/plugins/color-picker, /ui/plugins/fullcalendar]
related: [/ui/forms/elements, /ui/forms/color-picker, /ui/plugins/fullcalendar]
sass-vars: datepicker
---
+3 -1
View File
@@ -9,6 +9,8 @@ export const redirects: Record<string, Redirect> = {
// Payments became its own product section, like icons/illustrations/emails;
// the old plugin page's content lives on /payments/css, keeping anchors valid.
'/ui/plugins/payments': { status: 301, destination: '/payments/css' },
// The color picker is a Tabler plugin since 1.7, not a third-party library.
'/ui/plugins/color-picker': { status: 301, destination: '/ui/forms/color-picker' },
// @tabler/icons-eps is no longer maintained; PDF is the vector format to use.
'/icons/static-files/eps': { status: 301, destination: '/icons/static-files/pdf' },
// pre-Astro plural slugs, still present in the Google index and backlinks
@@ -47,7 +49,7 @@ export const redirects: Record<string, Redirect> = {
'/ui/forms/form-image-check': { status: 301, destination: '/ui/forms/image-check' },
'/ui/forms/form-color-check': { status: 301, destination: '/ui/forms/color-check' },
'/ui/forms/form-select-tomselect': { status: 301, destination: '/ui/plugins/advanced-select' },
'/ui/forms/form-colorpicker': { status: 301, destination: '/ui/plugins/color-picker' },
'/ui/forms/form-colorpicker': { status: 301, destination: '/ui/forms/color-picker' },
'/ui/forms/form-datepicker': { status: 301, destination: '/ui/plugins/date-picker' },
'/ui/forms/form-input-mask': { status: 301, destination: '/ui/plugins/input-mask' },
'/ui/forms/form-validation': { status: 301, destination: '/ui/forms/validation' },
+94 -19
View File
@@ -5,29 +5,104 @@ import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import Colorpicker from '@ui/Colorpicker.astro'
import FormGroup from '@ui/FormGroup.astro'
import site from '@data/site.json'
import DocsLink from '@ui/DocsLink.astro'
const colors = Object.values(site.colors) as { hex: string; title: string }[]
const formats = [
{ format: 'hex', value: '#066fd1' },
{ format: 'rgb', value: 'rgb(66 153 225)' },
{ format: 'hsl', value: 'hsl(291 54% 53%)' },
{ format: 'oklch', value: 'oklch(64% 0.22 27)' },
] as const
---
<DefaultLayout title="Color picker" pageMenu="plugins.colorpicker" pageLibs={['coloris.js']}>
<DocsLink slot="page-header-actions" path="/ui/plugins/color-picker" />
<Card>
<CardBody>
<CardTitle>Basic</CardTitle>
<CardSubtitle>Click a swatch to open the picker and choose any color.</CardSubtitle>
<div class="row g-3">
{
colors.map((color, index) => (
<div class="col-2">
<div>
<Colorpicker value={color.hex} id={`${index + 1}`} format="hex" ariaLabel={`${color.title} color value`} />
</div>
</div>
))
}
</div>
</CardBody>
</Card>
<DefaultLayout title="Color picker" pageMenu="plugins.colorpicker">
<DocsLink slot="page-header-actions" path="/ui/forms/color-picker" />
<div class="row row-cards">
<div class="col-12">
<Card>
<CardBody>
<CardTitle>Basic</CardTitle>
<CardSubtitle>Click a field to open the picker and choose any color.</CardSubtitle>
<div class="row g-3">
{
colors.map((color, index) => (
<div class="col-6 col-md-3 col-lg-2">
<Colorpicker value={color.hex} id={`colorpicker-${index + 1}`} ariaLabel={`${color.title} color value`} />
</div>
))
}
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6">
<Card>
<CardBody>
<CardTitle>Formats</CardTitle>
<CardSubtitle>The value is written back in the notation you pick with <code>data-bs-format</code>.</CardSubtitle>
{
formats.map(({ format, value }) => (
<FormGroup label={format.toUpperCase()} for={`colorpicker-format-${format}`}>
<Colorpicker value={value} format={format} id={`colorpicker-format-${format}`} />
</FormGroup>
))
}
<FormGroup label="Auto" hint="Keeps whatever notation is typed in." for="colorpicker-format-auto" class="mb-0">
<Colorpicker value="rgb(47 179 68)" format="auto" id="colorpicker-format-auto" />
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6">
<Card>
<CardBody>
<CardTitle>Options</CardTitle>
<CardSubtitle>Opacity, a fixed set of swatches, and the extra controls in the panel.</CardSubtitle>
<FormGroup label="Alpha channel" for="colorpicker-alpha">
<Colorpicker value="#ae3ec980" alpha id="colorpicker-alpha" />
</FormGroup>
<FormGroup label="Swatches only" for="colorpicker-swatches-only">
<Colorpicker value="#2fb344" swatchesOnly swatches={['#066fd1', '#4299e1', '#4263eb', '#ae3ec9', '#d6336c', '#d63939', '#f76707', '#2fb344']} id="colorpicker-swatches-only" />
</FormGroup>
<FormGroup label="Format toggle, clear and close" for="colorpicker-controls">
<Colorpicker value="#f76707" formatToggle clearButton closeButton id="colorpicker-controls" />
</FormGroup>
<FormGroup label="Empty" for="colorpicker-empty" hint="No value yet; the panel opens on the default color." class="mb-0">
<Colorpicker placeholder="Pick a color" id="colorpicker-empty" />
</FormGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6">
<Card>
<CardBody>
<CardTitle>Inline</CardTitle>
<CardSubtitle>The panel renders in place, under the field, and never closes.</CardSubtitle>
<Colorpicker value="#0ca678" inline alpha id="colorpicker-inline" ariaLabel="Accent color value" />
</CardBody>
</Card>
</div>
<div class="col-md-6">
<Card>
<CardBody>
<CardTitle>Button trigger</CardTitle>
<CardSubtitle>Any element opens the picker on click. The value lives in the hidden input.</CardSubtitle>
<div class="btn-list">
<button type="button" class="btn" data-bs-toggle="colorpicker">
<input type="hidden" name="brand-color" value="#066fd1" />
<span class="colorpicker-preview me-2"></span>
Brand color
</button>
<button type="button" class="btn btn-icon" data-bs-toggle="colorpicker" data-bs-value="#d6336c" data-bs-swatches-only="true" aria-label="Tag color">
<span class="colorpicker-preview"></span>
</button>
</div>
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+4 -4
View File
@@ -383,6 +383,10 @@
{
"title": "OTP input",
"url": "/ui/forms/otp-input"
},
{
"title": "Color picker",
"url": "/ui/forms/color-picker"
}
]
},
@@ -452,10 +456,6 @@
"title": "Clipboard",
"url": "/ui/plugins/clipboard"
},
{
"title": "Color picker",
"url": "/ui/plugins/color-picker"
},
{
"title": "Countup",
"url": "/ui/plugins/countup"
+45 -42
View File
@@ -1,52 +1,55 @@
---
import site from '@data/site.json'
import CaptureScript from '../components/CaptureScript.astro'
// Color picker field: a text input the Colorpicker plugin in `tabler.js` picks
// up through `data-bs-toggle="colorpicker"`. The `.colorpicker` wrapper draws
// the swatch from the custom property the plugin sets on it.
interface Props {
value?: string
id: string
/** Coloris output format; false leaves the library default */
format?: 'hex' | 'rgb' | 'hsl' | 'auto' | 'mixed' | false
value?: string
name?: string
placeholder?: string
/** notation written to the field; `auto` keeps the one the value came in */
format?: 'hex' | 'rgb' | 'hsl' | 'oklch' | 'auto'
/** opacity slider and alpha channel */
alpha?: boolean
/** preset colours; the Tabler palette when omitted */
swatches?: string[]
/** hide the gradient and sliders, leaving the swatches */
swatchesOnly?: boolean
/** selector in the panel to switch the notation */
formatToggle?: boolean
clearButton?: boolean
closeButton?: boolean
/** render the panel in place instead of a popup */
inline?: boolean
disabled?: boolean
class?: string
/** Accessible name for the input; falls back to a generic label when the caller doesn't wrap this in a labelled FormGroup */
/** accessible name for the input; falls back to a generic label when the caller doesn't wrap this in a labelled FormGroup */
ariaLabel?: string
}
const { value, id, format = false, class: className, ariaLabel } = Astro.props
const alpha = false
const { id, value, name, placeholder, format, alpha, swatches, swatchesOnly, formatToggle, clearButton, closeButton, inline, disabled, class: className, ariaLabel } = Astro.props
// Coloris parses hex, rgb and hsl only, and the CSS tokens are `oklch()`, so the
// swatches use the hex values from site.json.
const colors = site.colors as Record<string, { hex: string }>
const swatches = Object.values(colors).map((color) => color.hex)
const colorpickerId = `colorpicker-${id}`
const colorpickerSelector = `#colorpicker-${id}`
const flag = (enabled: boolean | undefined): 'true' | undefined => (enabled ? 'true' : undefined)
---
<input type="text" class={`form-control d-block${className ? ` ${className}` : ''}`} id={colorpickerId} value={value} aria-label={ariaLabel ?? 'Color value'} />
<CaptureScript>
<!-- BEGIN COLORPICKER -->
<script is:inline define:vars={{ colorpickerId, colorpickerSelector, alpha, format, swatches }}>
function initColorpicker() {
window.tabler_colorpicker ??= {}
if (window.Coloris) {
// `el` can only be configured globally, but Coloris.setInstance() scopes the rest
// (alpha, format, swatches, ...) to this selector so multiple pickers with
// different options don't clobber each other's settings.
Coloris({ el: colorpickerSelector })
Coloris.setInstance(colorpickerSelector, {
selectInput: false,
alpha,
...(format ? { format } : {}),
swatches,
})
window.tabler_colorpicker[colorpickerId] = colorpickerSelector
}
}
document.readyState !== 'loading' ? initColorpicker() : document.addEventListener('DOMContentLoaded', initColorpicker, { once: true })
</script>
<!-- END COLORPICKER -->
</CaptureScript>
<div class:list={['colorpicker', className]}>
<input
type="text"
class="form-control"
id={id}
name={name}
value={value}
placeholder={placeholder}
disabled={disabled}
aria-label={ariaLabel ?? 'Color value'}
data-bs-toggle="colorpicker"
data-bs-format={format}
data-bs-alpha={flag(alpha)}
data-bs-swatches={swatches ? JSON.stringify(swatches) : undefined}
data-bs-swatches-only={flag(swatchesOnly)}
data-bs-format-toggle={flag(formatToggle)}
data-bs-clear-button={flag(clearButton)}
data-bs-close-button={flag(closeButton)}
data-bs-inline={flag(inline)}
/>
</div>