From 56c52c6afff01fe10b3bf386330299a5bedb03ba Mon Sep 17 00:00:00 2001 From: codecalm Date: Tue, 29 Sep 2026 12:06:00 +0200 Subject: [PATCH] Open the Colorpicker inside modals and size the swatch with the field - Escape closes the panel before the dialog around it - demo cards for sizes, states and a modal --- core/js/src/colorpicker.ts | 26 ++++++++---- core/js/tests/unit/colorpicker.spec.ts | 34 +++++++++++++++ core/scss/_variables.scss | 3 ++ core/scss/ui/forms/_colorpicker.scss | 13 ++++++ docs/content/ui/forms/color-picker.mdx | 25 ++++++++++- preview/pages/colorpicker.astro | 58 ++++++++++++++++++++++++++ shared/ui/Colorpicker.astro | 9 +++- 7 files changed, 157 insertions(+), 11 deletions(-) diff --git a/core/js/src/colorpicker.ts b/core/js/src/colorpicker.ts index 2d0fcce5c..1ddc664cb 100644 --- a/core/js/src/colorpicker.ts +++ b/core/js/src/colorpicker.ts @@ -37,8 +37,8 @@ type ComponentConfig = { 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 + /** element the panel is appended to; null picks the modal or offcanvas the element is in, or `` */ + container: string | HTMLElement | null /** colour the panel shows while the field is empty */ defaultColor: string /** notation written to the field; 'auto' keeps the one the value came in */ @@ -112,6 +112,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_DIALOG = '.modal, .offcanvas' 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"]' @@ -161,7 +162,7 @@ const Default: ComponentConfig = { clearButton: false, closeButton: false, colorpickerTheme: null, - container: 'body', + container: null, defaultColor: '#000000', format: 'hex', formatToggle: false, @@ -179,7 +180,7 @@ const DefaultType: Record = { clearButton: 'boolean', closeButton: 'boolean', colorpickerTheme: '(null|string)', - container: '(string|element)', + container: '(null|string|element)', defaultColor: 'string', format: 'string', formatToggle: 'boolean', @@ -284,7 +285,7 @@ class Colorpicker extends BaseComponent { } const panel = this._getPanel() - const container = getElement(this._config.container) ?? document.body + const container = this._getContainer() if (panel.parentElement !== container) { container.append(panel) } @@ -298,7 +299,7 @@ class Colorpicker extends BaseComponent { EventHandler.on(document, EVENT_CLICK, this._onDocumentClick) EventHandler.on(document, EVENT_FOCUSIN, this._onDocumentFocusIn) - EventHandler.on(document, EVENT_KEYDOWN, this._onDocumentKeydown) + document.addEventListener('keydown', this._onDocumentKeydown, true) EventHandler.trigger(this._element, EVENT_SHOWN) } @@ -322,7 +323,7 @@ class Colorpicker extends BaseComponent { EventHandler.off(document, EVENT_CLICK, this._onDocumentClick) EventHandler.off(document, EVENT_FOCUSIN, this._onDocumentFocusIn) - EventHandler.off(document, EVENT_KEYDOWN, this._onDocumentKeydown) + document.removeEventListener('keydown', this._onDocumentKeydown, true) if (focusWasInside) { this._isRestoringFocus = true @@ -361,7 +362,7 @@ class Colorpicker extends BaseComponent { if (this._isShown) { EventHandler.off(document, EVENT_CLICK, this._onDocumentClick) EventHandler.off(document, EVENT_FOCUSIN, this._onDocumentFocusIn) - EventHandler.off(document, EVENT_KEYDOWN, this._onDocumentKeydown) + document.removeEventListener('keydown', this._onDocumentKeydown, true) } this._popper?.destroy() @@ -798,9 +799,12 @@ class Colorpicker extends BaseComponent { } } + // Runs in the capture phase and stops the event, so Escape closes the panel + // and not the modal or offcanvas around it _handleDocumentKeydown(event: KeyboardEvent): void { if (event.key === 'Escape') { event.preventDefault() + event.stopPropagation() this.hide() } } @@ -827,6 +831,12 @@ class Colorpicker extends BaseComponent { // Position and theme + // Inside a modal or an offcanvas the panel has to live in it: on `` it + // would sit under the dialog, and the dialog's focus trap would take focus back + _getContainer(): HTMLElement { + return getElement(this._config.container) ?? this._element.closest(SELECTOR_DIALOG) ?? document.body + } + _getPositionElement(): HTMLElement { const { positionElement } = this._config return (typeof positionElement === 'string' ? getElement(positionElement) : positionElement) ?? this._wrapper ?? this._element diff --git a/core/js/tests/unit/colorpicker.spec.ts b/core/js/tests/unit/colorpicker.spec.ts index 71f0f1a43..362504242 100644 --- a/core/js/tests/unit/colorpicker.spec.ts +++ b/core/js/tests/unit/colorpicker.spec.ts @@ -426,6 +426,40 @@ describe('Colorpicker', () => { expect(panels()[0]!.getAttribute('data-bs-theme')).toBe('light') }) + it('opens inside the modal the field is in', async () => { + fixtureEl.innerHTML = '' + const instance = new Colorpicker(fixtureEl.querySelector('input')!) + + await instance.show() + + expect(panels()[0]!.parentElement).toBe(fixtureEl.querySelector('.modal')) + }) + + it('honours an explicit container over the modal', async () => { + fixtureEl.innerHTML = '' + const instance = new Colorpicker(fixtureEl.querySelector('input')!) + + await instance.show() + + expect(panels()[0]!.parentElement).toBe(document.body) + }) + + it('keeps Escape from the elements around it', async () => { + const input = field() + const instance = new Colorpicker(input) + const outer = vi.fn() + fixtureEl.addEventListener('keydown', outer) + + await instance.show() + keydown(input, 'Escape') + expect(panels()[0]!.classList.contains('show')).toBe(false) + expect(outer).not.toHaveBeenCalled() + + keydown(input, 'Escape') + expect(outer).toHaveBeenCalledTimes(1) + fixtureEl.removeEventListener('keydown', outer) + }) + it('replaces the labels', async () => { const input = field() const instance = new Colorpicker(input, { labels: { dialog: 'Kolor', hue: 'Odcień' } }) diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index aec3cca56..7c766c3eb 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -2323,6 +2323,9 @@ $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-size-sm: 1rem !default; +$colorpicker-swatch-size-lg: 1.75rem !default; +$colorpicker-disabled-opacity: 0.5 !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; diff --git a/core/scss/ui/forms/_colorpicker.scss b/core/scss/ui/forms/_colorpicker.scss index 33c8f1f37..9fef666fd 100644 --- a/core/scss/ui/forms/_colorpicker.scss +++ b/core/scss/ui/forms/_colorpicker.scss @@ -23,6 +23,7 @@ Color picker --colorpicker-swatch-border-radius: #{$colorpicker-swatch-border-radius}; --colorpicker-checkerboard: #{$colorpicker-checkerboard}; --colorpicker-checkerboard-size: #{$colorpicker-checkerboard-size}; + --colorpicker-disabled-opacity: #{$colorpicker-disabled-opacity}; position: relative; display: block; @@ -45,6 +46,18 @@ Color picker > .form-control { padding-inline-start: calc(var(--colorpicker-swatch-size) * 5 / 3); } + + &:has(> .form-control-sm) { + --colorpicker-swatch-size: #{$colorpicker-swatch-size-sm}; + } + + &:has(> .form-control-lg) { + --colorpicker-swatch-size: #{$colorpicker-swatch-size-lg}; + } + + &:has(> :disabled)::before { + opacity: var(--colorpicker-disabled-opacity); + } } // A button trigger shows the value in a swatch of its own diff --git a/docs/content/ui/forms/color-picker.mdx b/docs/content/ui/forms/color-picker.mdx index c5cb69f88..e9385cf99 100644 --- a/docs/content/ui/forms/color-picker.mdx +++ b/docs/content/ui/forms/color-picker.mdx @@ -154,6 +154,29 @@ Set `data-bs-inline="true"` to render the panel in place, right under the field. +### Sizes and states + +The swatch follows `.form-control-sm` and `.form-control-lg`, and the field keeps its disabled and validation styles. + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +### In a modal + +Inside a modal or an offcanvas the panel is appended to that dialog instead of ``, so it opens above it and stays inside its focus trap. Escape closes the panel first, and the dialog on the next press. + ### Dark mode The panel is appended to ``, outside the cascade of the field, so the plugin copies the nearest `data-bs-theme` to it and follows later changes. Set `data-bs-colorpicker-theme` to force a theme for the panel alone. @@ -214,7 +237,7 @@ const colorpicker = new tabler.Colorpicker(document.getElementById('my-color'), | `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. | +| `container` | string, element | `null` | Element the popup is appended to. By default the modal or offcanvas the field is in, or ``. | | `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. | diff --git a/preview/pages/colorpicker.astro b/preview/pages/colorpicker.astro index cf774fe1d..01eadbcb2 100644 --- a/preview/pages/colorpicker.astro +++ b/preview/pages/colorpicker.astro @@ -8,6 +8,9 @@ import Colorpicker from '@ui/Colorpicker.astro' import FormGroup from '@ui/FormGroup.astro' import site from '@data/site.json' import DocsLink from '@ui/DocsLink.astro' +import Button from '@ui/Button.astro' +import Modal from '@shared/components/modals/Modal.astro' +import CaptureModal from '@shared/components/CaptureModal.astro' const colors = Object.values(site.colors) as { hex: string; title: string }[] @@ -129,5 +132,60 @@ const alphaFormats = [ +
+ + + Sizes and states + The swatch follows the size of the field, and the field keeps its disabled and invalid styles. + + + + + + + + + + + + +
+ + +
This color does not have enough contrast with the background.
+
+
+
+
+
+ + + In a modal + The panel opens over the modal and closes before the modal does. +
+ + + + + + + + diff --git a/shared/ui/Colorpicker.astro b/shared/ui/Colorpicker.astro index f86629941..d88f1bbc8 100644 --- a/shared/ui/Colorpicker.astro +++ b/shared/ui/Colorpicker.astro @@ -22,12 +22,16 @@ interface Props { /** render the panel in place instead of a popup */ inline?: boolean disabled?: boolean + /** field size: .form-control-sm / .form-control-lg */ + size?: 'sm' | 'lg' + /** marks the field invalid (.is-invalid + aria-invalid) */ + invalid?: 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 */ ariaLabel?: string } -const { id, value, name, placeholder, format, alpha, swatches, swatchesOnly, formatToggle, clearButton, closeButton, inline, disabled, class: className, ariaLabel } = Astro.props +const { id, value, name, placeholder, format, alpha, swatches, swatchesOnly, formatToggle, clearButton, closeButton, inline, disabled, size, invalid, class: className, ariaLabel } = Astro.props const flag = (enabled: boolean | undefined): 'true' | undefined => (enabled ? 'true' : undefined) --- @@ -35,12 +39,13 @@ const flag = (enabled: boolean | undefined): 'true' | undefined => (enabled ? 't