mirror of
https://github.com/tabler/tabler.git
synced 2026-10-13 13:47:00 +04:00
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:
1 parent
0a15656006
commit
56c52c6aff
7 files changed
+157
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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ń' } })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user