Fix Colorpicker inline panel, swatches-only footer and button trigger

- opacity thumb stays visible at low alpha
- button trigger gets ARIA state and a display element
This commit is contained in:
codecalm committed 2026-09-29 11:42:11 +02:00
1 parent 98aad4f82d
commit 0a15656006
5 files changed
+129 -18

No files matched your search

+33 -7
View File
@@ -113,6 +113,7 @@ 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_DISPLAY = `[data-bs-${NAME}-display], [data-tblr-${NAME}-display]`
const SELECTOR_FOCUSABLE = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex="0"]'
// Shipped (`--tblr-`-prefixed) custom properties. The build prefixes the SCSS
@@ -217,6 +218,7 @@ class Colorpicker extends BaseComponent {
_isInput = false
_input: HTMLInputElement | null = null
_wrapper: HTMLElement | null = null
_displayElement: HTMLElement | null = null
_panel: HTMLElement | null = null
_area: HTMLElement | null = null
_marker: HTMLElement | null = null
@@ -292,6 +294,7 @@ class Colorpicker extends BaseComponent {
panel.classList.add(CLASS_NAME_SHOW)
this._popper = Popper.createPopper(this._getPositionElement(), panel, this._getPopperConfig())
this._isShown = true
this._element.setAttribute('aria-expanded', 'true')
EventHandler.on(document, EVENT_CLICK, this._onDocumentClick)
EventHandler.on(document, EVENT_FOCUSIN, this._onDocumentFocusIn)
@@ -315,6 +318,7 @@ class Colorpicker extends BaseComponent {
this._popper?.destroy()
this._popper = null
this._isShown = false
this._element.setAttribute('aria-expanded', 'false')
EventHandler.off(document, EVENT_CLICK, this._onDocumentClick)
EventHandler.off(document, EVENT_FOCUSIN, this._onDocumentFocusIn)
@@ -392,6 +396,12 @@ class Colorpicker extends BaseComponent {
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._displayElement = this._isInput ? null : SelectorEngine.findOne(SELECTOR_DISPLAY, this._element)
if (!this._config.inline) {
this._element.setAttribute('aria-haspopup', 'dialog')
this._element.setAttribute('aria-expanded', 'false')
}
this._format = this._config.format === 'auto' ? 'hex' : this._config.format
const initial = this._input?.value || this._config.value || ''
@@ -421,7 +431,9 @@ class Colorpicker extends BaseComponent {
const panel = this._getPanel()
panel.classList.add(CLASS_NAME_PANEL_INLINE, CLASS_NAME_SHOW)
if (this._isInput) {
this._element.after(panel)
// After the wrapper, not inside it: the wrapper centres the swatch on
// its own height and pads its `.form-control`, which must stay the field's
;(this._wrapper ?? this._element).after(panel)
} else {
this._element.append(panel)
}
@@ -484,12 +496,17 @@ class Colorpicker extends BaseComponent {
}
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) {
// A fixed list of colours leaves nothing to type: the value field and the
// notation select would let the user leave the list.
if (!swatchesOnly) {
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 && !swatchesOnly) {
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')
@@ -519,7 +536,10 @@ class Colorpicker extends BaseComponent {
footer.append(button)
}
panel.append(footer)
if (footer.childElementCount > 0) {
panel.append(footer)
}
EventHandler.on(panel, EVENT_KEYDOWN, (event: KeyboardEvent) => this._handlePanelKeydown(event))
this._panel = panel
@@ -623,6 +643,12 @@ class Colorpicker extends BaseComponent {
/** The swatch in the field and the button variant read the value from this custom property. */
_writePreview(): void {
const target = this._wrapper ?? this._element
// An empty value leaves the display element with the text from the markup
if (this._displayElement && !this._isEmpty) {
this._displayElement.textContent = this.getValue()
}
if (this._isEmpty) {
target.style.removeProperty(PROPERTY_VALUE)
return
+58 -2
View File
@@ -354,9 +354,23 @@ describe('Colorpicker', () => {
expect(panel.querySelector('.colorpicker-area')).toBeNull()
expect(panel.querySelector('.colorpicker-hue')).toBeNull()
expect(panel.querySelector('.colorpicker-input')).toBeNull()
expect(panel.querySelector('.colorpicker-footer')).toBeNull()
expect(panel.querySelectorAll('.colorpicker-swatch').length).toBeGreaterThan(0)
})
it('keeps the buttons but not the format select with swatchesOnly', async () => {
const input = field('data-bs-swatches-only="true" data-bs-format-toggle="true" data-bs-close-button="true"')
const instance = new Colorpicker(input)
await instance.show()
const panel = panels()[0]!
expect(panel.querySelector('.colorpicker-format')).toBeNull()
expect(panel.querySelector('.colorpicker-input')).toBeNull()
expect(panel.querySelector('.colorpicker-close')).not.toBeNull()
})
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)
@@ -369,11 +383,12 @@ describe('Colorpicker', () => {
expect(panels()[0]!.classList.contains('show')).toBe(false)
})
it('renders inline right after the field and never hides', async () => {
it('renders inline right after the wrapper and never hides', async () => {
const input = field('data-bs-inline="true"')
const instance = new Colorpicker(input)
const panel = input.nextElementSibling as HTMLElement
expect(input.nextElementSibling).toBeNull()
const panel = input.closest('.colorpicker')!.nextElementSibling as HTMLElement
expect(panel.classList.contains('colorpicker-panel-inline')).toBe(true)
expect(panel.classList.contains('show')).toBe(true)
@@ -381,6 +396,14 @@ describe('Colorpicker', () => {
expect(panel.classList.contains('show')).toBe(true)
})
it('renders inline right after a field without the wrapper', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="colorpicker" data-bs-inline="true" value="#fff">'
const input = fixtureEl.querySelector('input')!
new Colorpicker(input)
expect(input.nextElementSibling!.classList.contains('colorpicker-panel-inline')).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')!
@@ -436,6 +459,39 @@ describe('Colorpicker', () => {
expect(panels()[0]!.classList.contains('show')).toBe(false)
})
it('tells assistive technology about the popup and its state', async () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="colorpicker" data-bs-value="#00f">Color</button>'
const button = fixtureEl.querySelector('button')!
const instance = new Colorpicker(button)
expect(button.getAttribute('aria-haspopup')).toBe('dialog')
expect(button.getAttribute('aria-expanded')).toBe('false')
await instance.show()
expect(button.getAttribute('aria-expanded')).toBe('true')
await instance.hide()
expect(button.getAttribute('aria-expanded')).toBe('false')
})
it('writes the value to the display element', () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="colorpicker" data-bs-value="#00f"><span data-bs-colorpicker-display>Pick</span></button>'
const display = fixtureEl.querySelector('span')!
const instance = new Colorpicker(fixtureEl.querySelector('button')!)
expect(display.textContent).toBe('#0000ff')
instance.setValue('#ff0000')
expect(display.textContent).toBe('#ff0000')
})
it('leaves the display text alone while there is no value', () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="colorpicker"><span data-bs-colorpicker-display>Pick a color</span></button>'
new Colorpicker(fixtureEl.querySelector('button')!)
expect(fixtureEl.querySelector('span')!.textContent).toBe('Pick a color')
})
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')!)
+18 -3
View File
@@ -42,7 +42,7 @@ Color picker
@include border-radius(var(--colorpicker-swatch-border-radius));
}
.form-control {
> .form-control {
padding-inline-start: calc(var(--colorpicker-swatch-size) * 5 / 3);
}
}
@@ -62,6 +62,18 @@ Color picker
@include border-radius(var(--colorpicker-swatch-border-radius));
}
// In a button the swatch is sized and spaced like an icon, so the button
// keeps its height and needs no spacing utility
.btn .colorpicker-preview {
--colorpicker-swatch-size: var(--btn-icon-size);
margin: 0 calc(var(--btn-padding-x) / 2) 0 calc(var(--btn-padding-x) / -4);
vertical-align: bottom;
}
.btn-icon .colorpicker-preview {
margin: calc(-1 * var(--btn-padding-x));
}
//
// Panel
//
@@ -105,6 +117,7 @@ Color picker
.colorpicker-panel-inline {
position: static;
z-index: auto;
margin-block-start: 0.5rem;
border: var(--border-width) var(--border-style) var(--border-color);
box-shadow: none;
}
@@ -182,12 +195,14 @@ Color picker
.colorpicker-alpha {
--colorpicker-track: linear-gradient(to right, transparent, var(--colorpicker-color));
// The thumb takes the colour without its alpha, or it would fade out
// together with the value it sets
&::-webkit-slider-thumb {
background-color: var(--colorpicker-value);
background-color: var(--colorpicker-color);
}
&::-moz-range-thumb {
background-color: var(--colorpicker-value);
background-color: var(--colorpicker-color);
}
}
+14 -4
View File
@@ -105,7 +105,7 @@ The panel shows the Tabler palette by default, read from the `--tblr-*` custom p
### 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.
Set `data-bs-swatches-only="true"` to hide the gradient, the sliders and the value field in the panel. Users can then pick only from your list. Use it when a design system allows a fixed set of colors.
<Example>
<div class="colorpicker">
@@ -130,14 +130,23 @@ Any element other than an input opens the panel on click. Put a hidden input ins
<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>
<span class="colorpicker-preview"></span>
Brand color
</button>
</Example>
Add an element with `data-bs-colorpicker-display` to show the value as text. It keeps its own text while nothing is picked.
<Example>
<button type="button" class="btn" data-bs-toggle="colorpicker" data-bs-value="#2fb344">
<span class="colorpicker-preview"></span>
<span data-bs-colorpicker-display>Pick a color</span>
</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.
Set `data-bs-inline="true"` to render the panel in place, right under 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">
@@ -196,7 +205,7 @@ const colorpicker = new tabler.Colorpicker(document.getElementById('my-color'),
| `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. |
| `swatchesOnly` | boolean | `false` | Hide the gradient, the sliders, the value field and the format select, leaving the swatches. |
| `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. |
@@ -245,6 +254,7 @@ document.getElementById('my-color').addEventListener('change.bs.colorpicker', (e
- 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 field or button gets `aria-haspopup="dialog"` and `aria-expanded`, kept in sync with the panel.
- 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.
+6 -2
View File
@@ -111,13 +111,17 @@ const alphaFormats = [
<Card>
<CardBody>
<CardTitle>Button trigger</CardTitle>
<CardSubtitle>Any element opens the picker on click. The value lives in the hidden input.</CardSubtitle>
<CardSubtitle>Any element opens the picker on click. The value lives in a hidden input, and can be shown in the button.</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>
<span class="colorpicker-preview"></span>
Brand color
</button>
<button type="button" class="btn" data-bs-toggle="colorpicker" data-bs-value="#2fb344">
<span class="colorpicker-preview"></span>
<span data-bs-colorpicker-display>Pick a color</span>
</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>