Forms

ColorPicker

A colour as hex, or picked from a square, a hue slider and swatches; it says when hex is wrong.

#forms#picker#interactive

Demo

Preview

Hex, such as #1e90ff or #19f.

Buttons will be #12a594.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/color_picker.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/color_picker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/color_picker.qwe
component ColorPickerDemoLive() {    state c = "#12a594"    <div.demo-stack>        <ColorPicker name="cp-demo-accent" label="Accent" value="#12a594" on_change={(hex) => { c = hex }}/>        <p.demo-note>Buttons will be <span style={`color: ${c}; font-weight: 600`}>{c}</span>.</p>    </div>}view ColorPickerDemo() =    <div.demo-stack>        <div.demo-row>            <ColorPicker name="cp-demo-brand" label="Brand colour" value="#5b4bdb" required={true}                         hint="Hex, such as #1e90ff or #19f."/>            <ColorPickerDemoLive/>        </div>        <div.demo-row>            <ColorPicker name="cp-demo-label" label="Label colour" area={false} value="#e5484d" size="sm"                         swatches={[{ label: "Red", value: "#e5484d" }, { label: "Orange", value: "#f76b15" },                                    { label: "Green", value: "#46a758" }, { label: "Blue", value: "#0b6bcb" },                                    { label: "Purple", value: "#8e4ec6" }, { label: "Grey", value: "#8d8d8d" }]}/>            <ColorPicker name="cp-demo-bad" label="Text colour" value="#ffffff" size="sm"                         error="White text on a white page cannot be read."/>        </div>    </div>

Source

ui/color_picker.qwe
// ColorPicker: a colour, typed as hex or picked in a panel: a square for how// bright and how strong it is, a hue slider, and swatches. The square follows the// pointer (drag it) and the arrow keys; the hex box takes "#1e90ff", "1e90ff" or// "#19f" and says when what was typed is not a colour. A plain <form> gets// "#rrggbb" as `name`.////   <ColorPicker name="brand" label="Brand colour" value="#5b4bdb"/>//   <ColorPicker name="label_color" label="Label" area={false}//                swatches={[{ label: "Red", value: "#e5484d" }, { label: "Blue", value: "#0b6bcb" }]}/>//   <ColorPicker label="Highlight" on_change={(hex) => { highlight = hex }}/>//// value: the colour to start with ("#5b4bdb"). swatches: colours offered, as hex// strings or { label, value } (a set of twelve by default; [] for none). area:// false leaves out the square and the hue slider, for a choice of swatches.// name ("color"), label, hint, error, required, disabled, field (the hex box's id,// "color-picker-" + name by default) and size ("sm" or "md") as for every field.// The panel closes with Escape, or when the focus leaves it.// on_change(hex): inside one of your components, given "#rrggbb" as it changes.import ui/theme.qwe use ui_clslet color_picker_css = css`.ui-color-picker { position: relative; display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-color-picker-label { font-size: 13px; font-weight: 500; }.ui-color-picker-req { margin-left: 2px; color: var(--ui-danger); }.ui-color-picker-field {  display: flex; align-items: center; gap: 8px; width: 180px; max-width: 100%; height: 38px; padding: 0 10px 0 5px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  transition: border-color .15s, box-shadow .15s;}.ui-color-picker-field:hover { border-color: var(--ui-strong); }.ui-color-picker-field:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-color-picker-invalid .ui-color-picker-field { border-color: var(--ui-danger); }.ui-color-picker-chip {  flex: none; width: 28px; height: 28px; padding: 0; border: 1px solid color-mix(in srgb, var(--ui-fg) 18%, transparent);  border-radius: 4px; background: var(--c); cursor: pointer;}.ui-color-picker-chip:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; }.ui-color-picker-field input {  flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: transparent;  color: var(--ui-fg); font: 14px var(--ui-mono); text-transform: lowercase;}.ui-color-picker-panel {  position: absolute; z-index: 20; top: 100%; left: 0; display: grid; gap: 12px; width: 248px; max-width: calc(100vw - 32px);  margin-top: 4px; padding: 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius);  background: var(--ui-surface); box-shadow: var(--ui-shadow-lg);}.ui-color-picker-panel[hidden] { display: none; }.ui-color-picker-area {  position: relative; height: 150px; border-radius: var(--ui-radius-sm); cursor: crosshair; touch-action: none;  background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(var(--h) 100% 50%));}.ui-color-picker-area:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-color-picker-thumb {  position: absolute; left: var(--x); top: var(--y); width: 16px; height: 16px; margin: -8px 0 0 -8px; border-radius: 50%;  border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0, 0, 0, .4), inset 0 0 0 1px rgba(0, 0, 0, .3); pointer-events: none;}.ui-color-picker-hue {  display: block; width: 100%; height: 14px; margin: 0; appearance: none; border-radius: 999px; cursor: pointer;  background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);}.ui-color-picker-hue:focus { outline: none; }.ui-color-picker-hue::-webkit-slider-thumb {  appearance: none; width: 16px; height: 16px; border-radius: 50%; background: transparent;  border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0, 0, 0, .45);}.ui-color-picker-hue::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: transparent; border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0, 0, 0, .45); }.ui-color-picker-hue:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 1px rgba(0, 0, 0, .45), var(--ui-focus); }.ui-color-picker-swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(24px, 1fr)); gap: 6px; }.ui-color-picker-sw {  aspect-ratio: 1; min-width: 0; padding: 0; border: 1px solid color-mix(in srgb, var(--ui-fg) 15%, transparent);  border-radius: 4px; background: var(--c); cursor: pointer;}.ui-color-picker-sw:hover { transform: scale(1.08); }.ui-color-picker-sw[aria-pressed="true"] { outline: 2px solid var(--ui-fg); outline-offset: 2px; }.ui-color-picker-sw:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; }.ui-color-picker-sm .ui-color-picker-field { height: 30px; width: 150px; }.ui-color-picker-sm .ui-color-picker-chip { width: 22px; height: 22px; }.ui-color-picker-sm .ui-color-picker-field input { font-size: 13px; }.ui-color-picker-hint, .ui-color-picker-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-color-picker-error { color: var(--ui-danger); }.ui-color-picker-disabled { opacity: .55; }.ui-color-picker-disabled .ui-color-picker-chip { cursor: not-allowed; }@media (prefers-reduced-motion: reduce) { .ui-color-picker-field { transition: none; } .ui-color-picker-sw:hover { transform: none; } }`let color_picker_digits = "0123456789abcdef"let color_picker_palette = ["#e5484d", "#f76b15", "#ffc53d", "#46a758", "#12a594", "#0b6bcb", "#3e63dd",                            "#5b4bdb", "#8e4ec6", "#d6409f", "#8d8d8d", "#1c2024"]// "#1e90ff", "1E90FF" or "#19f" as [r, g, b] (0-255), or null for anything else.fn color_picker_parse(text: str) {    let t = lower(trim(text))    let h = starts_with(t, "#") ? slice(t, 1, len(t)) : t    if !re.test(h, "^([0-9a-f]{3}|[0-9a-f]{6})$") { return null }    let full = len(h) == 3 ? join(split(h, "") | filter((c) => c != "") | map((c) => c + c), "") : h    [0, 2, 4] | map((i) => index_of(color_picker_digits, full[i]) * 16 + index_of(color_picker_digits, full[i + 1]))}fn color_picker_hex(rgb: list) -> str =    "#" + join(rgb | map((n) => color_picker_digits[floor(n / 16)] + color_picker_digits[n % 16]), "")// Hue (0-360), saturation and value (0-1) as [r, g, b].fn color_picker_rgb(h: num, s: num, v: num) -> list {    let c = v * s    let hp = (h % 360) / 60    let x = c * (1 - abs(hp % 2 - 1))    let k = floor(hp)    let rgb = k == 0 ? [c, x, 0] : k == 1 ? [x, c, 0] : k == 2 ? [0, c, x] : k == 3 ? [0, x, c] : k == 4 ? [x, 0, c] : [c, 0, x]    rgb | map((n) => round((n + v - c) * 255))}// [r, g, b] as { h, s, v }; a grey keeps the hue `keep`, so the slider does not jump.fn color_picker_hsv(rgb: list, keep: num) {    let [r, g, b] = rgb | map((n) => n / 255)    let hi = max([r, g, b])    let d = hi - min([r, g, b])    let h = d == 0 ? keep : hi == r ? 60 * (((g - b) / d) % 6) : hi == g ? 60 * ((b - r) / d + 2) : 60 * ((r - g) / d + 4)    { h: h < 0 ? h + 360 : h, s: hi == 0 ? 0 : d / hi, v: hi }}// A colour as "#rrggbb" when it is one, else as it is (lower case).fn color_picker_norm(text: str) -> str {    let rgb = color_picker_parse(text)    rgb != null ? color_picker_hex(rgb) : lower(text)}fn color_picker_unit(n: num) -> num = n < 0 ? 0 : n > 1 ? 1 : ncomponent ColorPicker(name: str = "color", label: str? = null, value: str = "#5b4bdb", swatches: list? = null,                      area: bool = true, hint: str? = null, error: str? = null, required: bool = false,                      disabled: bool = false, field: str? = null, size: str = "md", on_change = null,                      class: str? = null) {    style global color_picker_css    let first = color_picker_parse(value) ?? [91, 75, 219]    let start = color_picker_hsv(first, 0)    state hex = color_picker_hex(first)    state text = color_picker_hex(first)    state h = start.h    state s = start.s    state v = start.v    state open = false    state drag = false    state problem = ""    let id = field ?? `color-picker-${name}`    let offered = (swatches ?? color_picker_palette)        | map((w) => type_of(w) == "map" ? { label: w.label ?? w.value, value: color_picker_norm(w.value) }                                         : { label: w, value: color_picker_norm(w) })    let shown_error = problem != "" ? problem : error    let described = ui_cls([hint != null && `${id}-hint`, shown_error != null && `${id}-error`])    let set_hex = (next: str) => {        text = next        problem = ""        if next != hex {            hex = next            on_change && on_change(next)        }    }    let set_hsv = (nh: num, ns: num, nv: num) => {        h = nh        s = ns        v = nv        set_hex(color_picker_hex(color_picker_rgb(nh, ns, nv)))    }    let set_rgb = (rgb: list) => {        let c = color_picker_hsv(rgb, h)        h = c.h        s = c.s        v = c.v        set_hex(color_picker_hex(rgb))    }    let point = (e) => {        let el = e.target.closest(".ui-color-picker-area")        let r = el.getBoundingClientRect()        set_hsv(h, color_picker_unit((e.clientX - r.left) / r.width), 1 - color_picker_unit((e.clientY - r.top) / r.height))    }    let box = (e) => e.target.closest(".ui-color-picker").querySelector(".ui-color-picker-field input")    <div class={ui_cls(["ui-color-picker", size == "sm" && "ui-color-picker-sm", shown_error != null && "ui-color-picker-invalid",                        disabled && "ui-color-picker-disabled", class])}         @focusout={(e) => {             let root = e.target.closest(".ui-color-picker")             if open && root != null && !(e.relatedTarget != null && root.contains(e.relatedTarget)) { open = false }         }}         @keydown={(e) => {             if e.key == "Escape" && open {                 e.preventDefault()                 open = false                 e.target.closest(".ui-color-picker").querySelector(".ui-color-picker-chip").focus()             }         }}>        {label != null && <label.ui-color-picker-label for={id}>            {label}{required && <span.ui-color-picker-req aria-hidden="true">*</span>}        </label>}        <div.ui-color-picker-field>            <button.ui-color-picker-chip type="button" style={`--c: ${hex}`} disabled={disabled}                    aria-label={`${open ? "Close" : "Open"} the colour picker (${hex})`} aria-haspopup="dialog"                    aria-expanded={open ? "true" : "false"} aria-controls={`${id}-panel`}                    @click={() => { open = !open }}></button>            <input type="text" id={id} value={text} disabled={disabled} required={required} maxlength="7"                   autocomplete="off" spellcheck="false" autocapitalize="off"                   aria-invalid={shown_error != null ? "true" : null} aria-describedby={described != "" ? described : null}                   @input={(e) => {                       text = e.value                       let rgb = color_picker_parse(e.value)                       if rgb != null {                           e.target.setCustomValidity("")                           let c = color_picker_hsv(rgb, h)                           h = c.h                           s = c.s                           v = c.v                           problem = ""                           let next = color_picker_hex(rgb)                           if next != hex {                               hex = next                               on_change && on_change(next)                           }                       }                   }}                   @change={(e) => {                       let rgb = color_picker_parse(e.value)                       if rgb == null {                           problem = "Type a colour such as #1e90ff (or #19f)."                           e.target.setCustomValidity(problem)                       } else {                           e.target.setCustomValidity("")                           set_rgb(rgb)                       }                   }}/>        </div>        <div.ui-color-picker-panel id={`${id}-panel`} role="dialog" aria-label={`${label ?? "Colour"}: pick a colour`}             hidden={!open}>            {area && <div.ui-color-picker-area role="slider" tabindex="0" aria-label="Saturation and brightness"                    aria-valuemin="0" aria-valuemax="100" aria-valuenow={round(s * 100)}                    aria-valuetext={`Saturation ${round(s * 100)}%, brightness ${round(v * 100)}%`}                    style={`--h: ${round(h, 1)}; --x: ${round(s * 100, 2)}%; --y: ${round((1 - v) * 100, 2)}%`}                    @pointerdown={(e) => {                        e.preventDefault()                        let el = e.target.closest(".ui-color-picker-area")                        el.focus({ preventScroll: true })                        el.setPointerCapture(e.pointerId)                        drag = true                        point(e)                    }}                    @pointermove={(e) => { if drag { point(e) } }}                    @pointerup={() => { drag = false }}                    @pointercancel={() => { drag = false }}                    @keydown={(e) => {                        let by = e.shiftKey ? 0.1 : 0.01                        let ds = e.key == "ArrowRight" ? by : e.key == "ArrowLeft" ? 0 - by : 0                        let dv = e.key == "ArrowUp" ? by : e.key == "ArrowDown" ? 0 - by                               : e.key == "PageUp" ? 0.1 : e.key == "PageDown" ? -0.1 : 0                        if ds != 0 || dv != 0 {                            e.preventDefault()                            set_hsv(h, color_picker_unit(round(s + ds, 2)), color_picker_unit(round(v + dv, 2)))                        }                    }}>                <span.ui-color-picker-thumb></span>            </div>}            {area && <input.ui-color-picker-hue type="range" min="0" max="359" value={round(h)} aria-label="Hue"                    aria-valuetext={`Hue ${round(h)} degrees`}                    @input={(e) => { set_hsv(float(e.value), s, v) }}/>}            {len(offered) > 0 && <div.ui-color-picker-swatches role="group" aria-label="Swatches">                {for w in offered => <button.ui-color-picker-sw type="button" key={w.value} style={`--c: ${w.value}`}                        aria-label={w.label} title={w.label} aria-pressed={w.value == hex ? "true" : "false"}                        @click={(e) => {                            let rgb = color_picker_parse(w.value)                            if rgb != null {                                set_rgb(rgb)                                box(e).setCustomValidity("")                            }                        }}></button>}            </div>}        </div>        <input type="hidden" name={name} value={hex}/>        {hint != null && <p.ui-color-picker-hint id={`${id}-hint`}>{hint}</p>}        {shown_error != null && <p.ui-color-picker-error id={`${id}-error`} role="alert">{shown_error}</p>}    </div>}
Thread
45Useful? Like it, or add to its thread below.

Thread 0 posts

On ColorPicker: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.