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
This commit is contained in:
codecalm committed 2026-09-29 12:06:00 +02:00
1 parent 0a15656006
commit 56c52c6aff
7 files changed
+157 -11

No files matched your search

+18 -8
View File
@@ -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 `<body>` */
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<keyof ComponentConfig, string> = {
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 `<body>` 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<HTMLElement>(SELECTOR_DIALOG) ?? document.body
}
_getPositionElement(): HTMLElement {
const { positionElement } = this._config
return (typeof positionElement === 'string' ? getElement(positionElement) : positionElement) ?? this._wrapper ?? this._element
+34
View File
@@ -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 = '<div class="modal"><div class="colorpicker"><input type="text" data-bs-toggle="colorpicker" value="#fff"></div></div>'
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 = '<div class="modal"><input type="text" data-bs-toggle="colorpicker" data-bs-container="body" value="#fff"></div>'
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ń' } })
+3
View File
@@ -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;
+13
View File
@@ -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
+24 -1
View File
@@ -154,6 +154,29 @@ Set `data-bs-inline="true"` to render the panel in place, right under the field.
</div>
</Example>
### Sizes and states
The swatch follows `.form-control-sm` and `.form-control-lg`, and the field keeps its disabled and validation styles.
<Example column>
<div class="colorpicker">
<input type="text" class="form-control form-control-sm" data-bs-toggle="colorpicker" value="#4263eb" aria-label="Small color field" />
</div>
<div class="colorpicker">
<input type="text" class="form-control form-control-lg" data-bs-toggle="colorpicker" value="#d6336c" aria-label="Large color field" />
</div>
<div class="colorpicker">
<input type="text" class="form-control" data-bs-toggle="colorpicker" value="#f59f00" aria-label="Disabled color field" disabled />
</div>
<div class="colorpicker">
<input type="text" class="form-control is-invalid" data-bs-toggle="colorpicker" value="#d63939" aria-label="Invalid color field" aria-invalid="true" />
</div>
</Example>
### In a modal
Inside a modal or an offcanvas the panel is appended to that dialog instead of `<body>`, so it opens above it and stays inside its focus trap. <kbd>Escape</kbd> closes the panel first, and the dialog on the next press.
### Dark mode
The panel is appended to `<body>`, 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 `<body>`. |
| `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. |
+58
View File
@@ -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 = [
</CardBody>
</Card>
</div>
<div class="col-md-6">
<Card>
<CardBody>
<CardTitle>Sizes and states</CardTitle>
<CardSubtitle>The swatch follows the size of the field, and the field keeps its disabled and invalid styles.</CardSubtitle>
<FormGroup label="Small" for="colorpicker-size-sm">
<Colorpicker value="#4263eb" size="sm" id="colorpicker-size-sm" />
</FormGroup>
<FormGroup label="Default" for="colorpicker-size-md">
<Colorpicker value="#ae3ec9" id="colorpicker-size-md" />
</FormGroup>
<FormGroup label="Large" for="colorpicker-size-lg">
<Colorpicker value="#d6336c" size="lg" id="colorpicker-size-lg" />
</FormGroup>
<FormGroup label="Disabled" for="colorpicker-disabled">
<Colorpicker value="#f59f00" disabled id="colorpicker-disabled" />
</FormGroup>
<div class="mb-0">
<label class="form-label" for="colorpicker-invalid">Invalid</label>
<Colorpicker value="#d63939" invalid id="colorpicker-invalid" />
<div class="invalid-feedback d-block">This color does not have enough contrast with the background.</div>
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6">
<Card>
<CardBody>
<CardTitle>In a modal</CardTitle>
<CardSubtitle>The panel opens over the modal and closes before the modal does.</CardSubtitle>
<Button text="Edit label" modalId="colorpicker" />
</CardBody>
</Card>
</div>
</div>
<CaptureModal>
<Modal modalId="colorpicker">
<div class="modal-header">
<h4 class="modal-title" id="modal-colorpicker-title">Edit label</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<FormGroup label="Name" for="colorpicker-modal-name">
<input type="text" class="form-control" id="colorpicker-modal-name" value="Bug" />
</FormGroup>
<FormGroup label="Color" for="colorpicker-modal-color" class="mb-0">
<Colorpicker value="#d63939" id="colorpicker-modal-color" />
</FormGroup>
</div>
<div class="modal-footer">
<button type="button" class="btn me-auto" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Save</button>
</div>
</Modal>
</CaptureModal>
</DefaultLayout>
+7 -2
View File
@@ -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
<div class:list={['colorpicker', className]}>
<input
type="text"
class="form-control"
class:list={['form-control', size && `form-control-${size}`, invalid && 'is-invalid']}
id={id}
name={name}
value={value}
placeholder={placeholder}
disabled={disabled}
aria-invalid={invalid ? 'true' : undefined}
aria-label={ariaLabel ?? 'Color value'}
data-bs-toggle="colorpicker"
data-bs-format={format}