mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 21:27:06 +04:00
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:
1 parent
98aad4f82d
commit
0a15656006
5 files changed
+129
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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')!)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user