ColorPicker
A colour as hex, or picked from a square, a hue slider and swatches; it says when hex is wrong.
Demo
Hex, such as #1e90ff or #19f.
Buttons will be #12a594.
White text on a white page cannot be read.
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.qwemain.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>}Related
BirthdayInputDay, month and year drop-downs that give one date in a hidden input, the days following the month and the years the age allowed.5 views · 7 downloadsColorSwatchesPick one colour from a set of round swatches: real radio buttons, so they post with a form and work with the keyboard.5 views · 5 downloadsCountryPickerA drop-down of every country with its flag and ISO code, favourites first, sending the two-letter code with a form.5 views · 7 downloadsDatePickerA date typed as YYYY-MM-DD or picked in a calendar grid, with the keyboard, limits and days that cannot be chosen.7 views · 6 downloadsDateRangePickerA first and last day picked on two months (with a preview as you go) or from presets such as Last 7 days; or a stay in nights, around booked days, with a price under each.4 views · 6 downloadsEmojiPickerPick an emoji from tabs of categories or by searching their names; each is named for screen readers and a form gets the one picked.4 views · 7 downloads
Thread
Thread 0 posts
On ColorPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.