diff --git a/core/js/src/colorpicker.ts b/core/js/src/colorpicker.ts index 16c69a533..2d0fcce5c 100644 --- a/core/js/src/colorpicker.ts +++ b/core/js/src/colorpicker.ts @@ -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(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 diff --git a/core/js/tests/unit/colorpicker.spec.ts b/core/js/tests/unit/colorpicker.spec.ts index 3c872f8d1..71f0f1a43 100644 --- a/core/js/tests/unit/colorpicker.spec.ts +++ b/core/js/tests/unit/colorpicker.spec.ts @@ -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 = '' + 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 = '
' 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 = '' + 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 = '' + 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 = '' + 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 = '' const instance = new Colorpicker(fixtureEl.querySelector('button')!) diff --git a/core/scss/ui/forms/_colorpicker.scss b/core/scss/ui/forms/_colorpicker.scss index 0dcb46b4f..33c8f1f37 100644 --- a/core/scss/ui/forms/_colorpicker.scss +++ b/core/scss/ui/forms/_colorpicker.scss @@ -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); } } diff --git a/docs/content/ui/forms/color-picker.mdx b/docs/content/ui/forms/color-picker.mdx index a656d23fb..c5cb69f88 100644 --- a/docs/content/ui/forms/color-picker.mdx +++ b/docs/content/ui/forms/color-picker.mdx @@ -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.
@@ -130,14 +130,23 @@ Any element other than an input opens the panel on click. Put a hidden input ins +Add an element with `data-bs-colorpicker-display` to show the value as text. It keeps its own text while nothing is picked. + + + + + ### 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.
@@ -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 `