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.
+
+
+
+
+
+
+
+
+
Edit label
+
+
+
+
+
+
+
+
+
+
+
+
+
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