Skip to content

ColorPicker

component

The colour well of the browser, its hexadecimal value and swatches to choose from. The well is the field: it works alone without a script.

Preview

Light

Code
<twig:ColorPicker label="Accent" name="accent" value="#336699" :swatches="[{value: '#336699', label: 'Blue'}, {value: '#993366', label: 'Plum'}]" />

Dark

Code
<twig:ColorPicker label="Accent" name="accent" value="#336699" :swatches="[{value: '#336699', label: 'Blue'}, {value: '#993366', label: 'Plum'}]" />

Try it

Change the props; the example is rendered again by the server.

<twig:ColorPicker label="Accent" name="accent" value="#336699" :swatches="[{value: '#336699', label: 'Blue'}, {value: '#993366', label: 'Plum'}]" />

Variants

disabled

Whether the field cannot be changed for now.

disabled="false"
disabled="true"

Installation

php bin/console ux:install color-picker --kit=github.com/parler-haut-interagir-librement/ux-kit

Props

PropTypeDefaultDescription
id string|null null The identifier of the field.
name string|null null The name of the field.
value string '#000000' The colour, as #rrggbb; anything else is black.
swatches array [] The colours to choose from: #rrggbb, or maps of value and label.
valueLabel string 'Hexadecimal value' The name of the text field.
swatchesLabel string 'Suggested colours' The name of the group of swatches.
disabled bool false Whether the field cannot be changed for now.

Accessibility

Each swatch is a button with a name and aria-pressed; the name of a swatch says the colour in words, which its look alone would not. What is typed and is not a colour is refused, with aria-invalid, and the server validates in any case.

Files to copy

assets/controllers/color_picker_controller.js
import { Controller } from '@hotwired/stimulus';

/** A colour as `#rrggbb`, from three or six hexadecimal digits with or without `#`; null for anything else. */
export function normalize(text) {
    const digits = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text.trim())?.[1].toLowerCase();

    if (digits === undefined) {
        return null;
    }

    return `#${digits.length === 3 ? [...digits].map((digit) => digit + digit).join('') : digits}`;
}

/*
 * Keeps the colour well, its text and its swatches in step. The well stays the field of the form; the text refuses
 * what is not a colour, and says so with `aria-invalid`.
 */
export default class extends Controller {
    static targets = ['well', 'text', 'swatch'];

    connect() {
        this.textTarget.hidden = false;

        for (const swatch of this.swatchTargets) {
            swatch.hidden = false;
            swatch.style.backgroundColor = normalize(this.#colour(swatch)) ?? 'transparent';
        }

        this.#mark();
    }

    pick() {
        this.textTarget.value = this.wellTarget.value;
        this.textTarget.removeAttribute('aria-invalid');
        this.#mark();
    }

    type() {
        const colour = normalize(this.textTarget.value);

        if (colour === null) {
            this.textTarget.setAttribute('aria-invalid', 'true');
        } else {
            this.#set(colour);
        }
    }

    choose(event) {
        const colour = normalize(event.params.color);

        if (colour !== null) {
            this.#set(colour);
        }
    }

    #set(colour) {
        this.wellTarget.value = colour;
        this.wellTarget.dispatchEvent(new Event('input', { bubbles: true }));
        this.wellTarget.dispatchEvent(new Event('change', { bubbles: true }));
    }

    #mark() {
        for (const swatch of this.swatchTargets) {
            swatch.setAttribute('aria-pressed', String(normalize(this.#colour(swatch)) === this.wellTarget.value));
        }
    }

    #colour(swatch) {
        return swatch.getAttribute(`data-${this.identifier}-color-param`) ?? '';
    }
}
templates/components/ColorPicker.html.twig
{#-
 # The colour well of the browser, the hexadecimal value beside it and swatches to choose from. The well is the field:
 # it works alone without a script, which only adds the rest.
 #}
{%- props
    ## string The name the colour well is read by.
    label,
    ## string|null The identifier of the field.
    id = null,
    ## string|null The name of the field.
    name = null,
    ## string The colour, as `#rrggbb`; anything else is black.
    value = '#000000',
    ## array The colours to choose from: `#rrggbb`, or maps of `value` and `label`.
    swatches = [],
    ## string The name of the text field.
    valueLabel = 'Hexadecimal value',
    ## string The name of the group of swatches.
    swatchesLabel = 'Suggested colours',
    ## bool Whether the field cannot be changed for now.
    disabled = false
-%}
{%- set _value = value matches '/^#[0-9a-fA-F]{6}$/' ? value|lower : '#000000' -%}
<div{{ attributes.defaults({class: 'isColorPicker', 'data-controller': 'color-picker'}) }}>
    <input class="isColorPickerWell" type="color" value="{{ _value }}" aria-label="{{ label }}"{% if id is not null %} id="{{ id }}"{% endif %}{% if name is not null %} name="{{ name }}"{% endif %}{% if disabled %} disabled{% endif %} data-color-picker-target="well" data-action="input->color-picker#pick">
    <input class="isColorPickerValue" type="text" hidden value="{{ _value }}" aria-label="{{ valueLabel }}" maxlength="7" spellcheck="false" autocomplete="off"{% if disabled %} disabled{% endif %} data-color-picker-target="text" data-action="change->color-picker#type">
    {%- if swatches is not empty -%}
        <div class="isColorPickerSwatches" role="group" aria-label="{{ swatchesLabel }}">
            {%- for swatch in swatches -%}
                {%- set _swatch = swatch is iterable ? swatch : {value: swatch, label: swatch} -%}
                <button type="button" class="isColorPickerSwatch" hidden aria-label="{{ _swatch.label }}" title="{{ _swatch.label }}" aria-pressed="false"{% if disabled %} disabled{% endif %} data-color-picker-target="swatch" data-color-picker-color-param="{{ _swatch.value }}" data-action="color-picker#choose"></button>
            {%- endfor -%}
        </div>
    {%- endif -%}
</div>