UNPKG

color-elements

Version:

A set of web components for working with color. A Color.js project.

681 lines (589 loc) 20.7 kB
import ColorElement from "../common/color-element.js"; import "../color-swatch/color-swatch.js"; import { getStep } from "../common/util.js"; // Radial resolution of the painted disc: concentric layers whose outer edge maps // to their channel value. More layers = smoother radial banding. Only repainted // when the space / channel / held coord change — never during a drag. const LAYERS = 60; // Hue waypoints per gradient. With native interpolation the space draws the hue // arc itself, so a few cardinal stops suffice; with the oklch fallback we sample // more finely so the (approximate) arc stays smooth. const STOPS = { native: 6, fallback: 12 }; /** Does CSS interpolate gradients in this space? (oklch/lch/hsl/hwb today.) */ function supportsInterp (cssId) { return CSS?.supports("background", `conic-gradient(in ${cssId}, red, tan)`); } const Self = class HueWheel extends ColorElement { static tagName = "hue-wheel"; static url = import.meta.url; static dependencies = new Set(["color-swatch"]); static styles = import("./hue-wheel.css", { with: { type: "css" } }).catch( () => new URL("./hue-wheel.css", import.meta.url), ); static globalStyles = import("./hue-wheel-global.css", { with: { type: "css" } }).catch( () => new URL("./hue-wheel-global.css", import.meta.url), ); static shadowTemplate = ` <div id="wheel" part="wheel"> <div id="disc" part="disc"></div> <slot name="marker"> <color-swatch id="marker" part="marker" hidden tabindex="0" role="slider" aria-label="Color"></color-swatch> </slot> <slot></slot> </div>`; constructor () { super(); this._el = { wheel: this.shadowRoot.getElementById("wheel"), disc: this.shadowRoot.getElementById("disc"), // The built-in marker; used unless the consumer slots their own [slot=marker]. defaultMarker: this.shadowRoot.getElementById("marker"), markerSlot: this.shadowRoot.querySelector("slot[name=marker]"), slot: this.shadowRoot.querySelector("slot:not([name])"), }; } /** * The element acting as the marker: a consumer-slotted `[slot=marker]` if present * (they style/configure it however they like), otherwise the built-in dot. */ get marker () { return this._el.markerSlot.assignedElements()[0] ?? this._el.defaultMarker; } connectedCallback () { super.connectedCallback?.(); for (let type of ["pointerdown", "pointermove", "pointerup", "pointercancel"]) { this._el.wheel.addEventListener(type, this); } this.#wireMarker(); // Slotted <color-swatch>/<color-scale> are the extra points. Re-place them // when the set changes or when a scale recomputes its colors. A separate slot // holds the marker; a change there re-wires the marker (see handleEvent). this._el.markerSlot.addEventListener("slotchange", this); this._el.slot.addEventListener("slotchange", this); this._el.wheel.addEventListener("colorschange", this, { capture: true }); this._el.wheel.addEventListener("colorchange", this, { capture: true }); } disconnectedCallback () { super.disconnectedCallback?.(); this.#dragging = false; for (let type of ["pointerdown", "pointermove", "pointerup", "pointercancel"]) { this._el.wheel.removeEventListener(type, this); } this.#wiredMarker?.removeEventListener("keydown", this); this.#wiredMarker = null; this._el.markerSlot.removeEventListener("slotchange", this); this._el.slot.removeEventListener("slotchange", this); this._el.wheel.removeEventListener("colorschange", this, { capture: true }); this._el.wheel.removeEventListener("colorchange", this, { capture: true }); } // The marker currently wired for keyboard, so we can move the listener if the // consumer swaps the slotted marker in or out. #wiredMarker = null; /** * Make the active marker behave as the slider: hue-wheel owns the role and * keyboard handling; the consumer only supplies the element and its looks. Moves * the wiring off the previous marker when a slotted one is added or removed. */ #wireMarker () { let marker = this.marker; if (marker === this.#wiredMarker) { return; } this.#wiredMarker?.removeEventListener("keydown", this); this.#wiredMarker = marker; marker.addEventListener("keydown", this); marker.setAttribute("role", "slider"); if (!marker.hasAttribute("aria-label")) { marker.setAttribute("aria-label", "Color"); } this.#updateMarker(); } // ───────────────────────── Coordinate model (from metadata) ───────────────────────── /** * The resolved color space. Always polar: non-polar values are rejected by * `spaceId`'s converter (and the `space` setter), so this never throws and is * safe to read from any render path. */ get space () { return Self.Color.Space.get(this.spaceId); } set space (value) { let space = value instanceof Self.Color.Space ? value : Self.Color.Space.get(value); this.spaceId = Self.assertPolar(space).id; } /** Guard that a space is polar (has a hue axis); throws otherwise. */ static assertPolar (space) { if (!space?.isPolar) { throw new TypeError(`<hue-wheel>: color space "${space?.id}" is not polar.`); } return space; } /** * The wheel's axis assignment for the current space + channel: which coord is * the hue (angular axis), which is the radial channel (or null), and which are * held constant — plus each one's value range. Derived from space metadata and * cached, since some render paths read it hundreds of times per paint; it only * changes when `spaceId` or `channel` does. */ get axes () { let key = `${this.spaceId}|${this.channel ?? ""}`; if (key !== this.#axesKey) { this.#axesKey = key; this.#axes = this.#computeAxes(); } return this.#axes; } #axes = null; #axesKey = null; #computeAxes () { let coords = this.space.coords; let ids = Object.keys(coords); // A coord reduced to what the wheel needs: its id, name and value range. let meta = (id, coord = coords[id]) => ({ id, name: coord.name ?? id, type: coord.type, range: coord.refRange ?? coord.range ?? [0, 100], }); // A polar space always has exactly one angle coord. let hue = meta(ids.find(id => coords[id].type === "angle")); let channel = null; if (this.channel) { try { let coord = Self.Color.Space.resolveCoord(this.channel, this.space); if (coord.type === "angle") { console.warn( `<hue-wheel> channel "${this.channel}" is the hue and can't be the radial axis.`, ); } else { channel = meta(coord.id, coord); } } catch (error) { console.warn(`<hue-wheel> ${error.message}`); } } let exclude = new Set([hue.id, channel?.id]); let held = ids.filter(id => !exclude.has(id)).map(id => meta(id)); return { hue, channel, held, order: ids }; } /** The angle coord — the angular axis. */ get hueCoord () { return this.axes.hue; } /** The radial-axis coord, or null when `channel` is unset/invalid. */ get channelCoord () { return this.axes.channel; } /** Coords that are neither hue nor channel — held constant, sourced from `color`. */ get heldCoords () { return this.axes.held; } /** A coord's value: from `color` when present, else the midpoint of its range. */ #valueOf (coord) { if (this.color) { return this.color.get(coord.id); } let [min, max] = coord.range; return (min + max) / 2; } // ───────────────────────── Building colors ───────────────────────── /** Ordered coord array for `new Color(space, …)` from an `{id: value}` map. */ #coordArray (values) { return this.axes.order.map(id => values[id]); } /** A fresh color at the held coords' current values + given hue/channel. */ #colorAt (hue, channel) { let values = {}; for (let coord of this.heldCoords) { values[coord.id] = this.#valueOf(coord); } values[this.hueCoord.id] = hue; if (this.channelCoord) { values[this.channelCoord.id] = channel; } return new Self.Color(this.space, this.#coordArray(values)); } /** * Write a single coord, producing a new Color so change-detection fires. * No-op without a color: hueValue/channelValue describe the current color, and * `#fromPointer` is the only path that creates one from nothing. */ #setCoord (id, value) { if (this.readonly || !this.color) { return; } let color = this.color.clone(); color.set(id, value); this.color = color; } // ───────────────────────── Rendering ───────────────────────── updated ({ changed }) { if (changed.has("spaceId")) { // Keep `color` in this.space. if (this.color && !this.color.space.equals(this.space)) { this.color = this.color.to(this.space); } } if (changed.has("spaceId") || changed.has("channel")) { this.#updateGeometry(); } if (changed.has("spaceId") || changed.has("channel") || changed.has("color")) { // Repaint the disc only when its inputs (space, channel, held values) move. let key = this.#discKey(); if (key !== this.#discKeyCache) { this.#discKeyCache = key; this.#renderDisc(); } this.#updateMarker(); } if (changed.has("readonly")) { this.#updateMarker(); } } /** Reflect the channel axis into CSS and switch between disc and ring modes. */ #updateGeometry () { let channel = this.channelCoord; // Ring mode = no resolved radial axis. A custom state (not a class or // attribute) so it tracks the *resolved* channel and is visible to both the // shadow sheet (:state(ring)) and the document sheet (hue-wheel:state(ring), // for placing slotted points on the ring). this.toggleState("ring", !channel); if (channel) { let [min, max] = channel.range; this.style.setProperty("--channel-min", min); this.style.setProperty("--channel-max", max); } this.#placePoints(); } /** Identity of the painted disc: repaint only when this string changes. */ #discKey () { let held = this.heldCoords.map(c => this.#valueOf(c)).join(","); return `${this.space.id}|${this.channelCoord?.id ?? ""}|${held}`; } #discKeyCache = null; /** * Paint the hue field. With a channel: LAYERS concentric conic-gradient discs, * each at its own channel value, the outer-most largest (like the gamut app). * Without one: a single conic-gradient, masked to a ring in CSS. */ #renderDisc () { let cssId = this.space.cssId; let native = supportsInterp(cssId); let interp = native ? cssId : "oklch"; let stops = native ? STOPS.native : STOPS.fallback; let channel = this.channelCoord; // The held coords are constant across the whole disc — compute them once. let base = {}; for (let coord of this.heldCoords) { base[coord.id] = this.#valueOf(coord); } if (!channel) { this._el.disc.replaceChildren(); this._el.disc.style.background = this.#gradient(base, interp, stops); return; } this._el.disc.style.background = ""; let [min, max] = channel.range; let layers = this.#ensureLayers(); for (let i = 0; i < LAYERS; i++) { // Layer 0 is the outer edge (channel max); inner layers paint on top. let fraction = (LAYERS - i) / LAYERS; let value = min + fraction * (max - min); layers[i].style.setProperty("--fraction", fraction); layers[i].style.background = this.#gradient( { ...base, [channel.id]: value }, interp, stops, ); } } #ensureLayers () { let disc = this._el.disc; while (disc.children.length < LAYERS) { disc.append(Object.assign(document.createElement("div"), { className: "layer" })); } while (disc.children.length > LAYERS) { disc.lastElementChild.remove(); } return disc.children; } /** * A `conic-gradient(...)` sweeping hue, with all non-hue coords fixed by `base`. * Hue 0° sits at 3 o'clock and runs counter-clockwise — matching the pointer math. */ #gradient (base, interp, stops) { let hueId = this.hueCoord.id; let parts = []; for (let k = 0; k <= stops; k++) { let g = (k / stops) * 360; // gradient angle let h = (360 - g) % 360; // math hue at that angle let color = new Self.Color(this.space, this.#coordArray({ ...base, [hueId]: h })); parts.push(`${color.display()} ${g}deg`); } return `conic-gradient(from 90deg in ${interp}, ${parts.join(", ")})`; } /** Position the marker from the current color and toggle its visibility/editability. */ #updateMarker () { let marker = this.marker; let color = this.color; marker.hidden = !color; if (!color) { return; } marker.style.setProperty("--hue", `${this.hueValue ?? 0}deg`); if (this.channelCoord) { marker.style.setProperty("--channel", this.channelValue); } // The marker reflects the current color; a slotted <color-swatch> renders it too. marker.color = color; // Expose the hue as the slider value. The radial axis isn't a second slider // yet (a known a11y gap), so fold its value into aria-valuetext alongside the // hue so screen-reader users still hear what ↑↓ changes. let [hueMin, hueMax] = this.hueCoord.range; marker.setAttribute("aria-valuemin", hueMin); marker.setAttribute("aria-valuemax", hueMax); marker.setAttribute("aria-valuenow", Math.round(this.hueValue ?? 0)); let valueText = `${this.hueCoord.name} ${Math.round(this.hueValue ?? 0)}`; if (this.channelCoord) { valueText += `, ${this.channelCoord.name} ${+this.channelValue.toPrecision(3)}`; } marker.setAttribute("aria-valuetext", valueText); let editable = !this.readonly; // Editable marker is in the tab order; read-only is still programmatically // focusable (so its value is exposed) but skipped by Tab. marker.setAttribute("tabindex", editable ? "0" : "-1"); marker.setAttribute("aria-readonly", String(!editable)); } // ───────────────────────── Extra points (slotted swatches) ───────────────────────── /** Place every slotted point (and wire its tooltip) from its color's hue/channel. */ #placePoints () { for (let el of this._el.slot.assignedElements()) { if (el.localName === "color-swatch") { this.#placeSwatch(el); } else if (el.localName === "color-scale") { // NOTE reaches into color-scale's private swatches (same as color-chart). // A scale's first slotchange precedes its colors; the colorschange // capture listener re-places once they're computed. for (let swatch of el._el?.swatches.children ?? []) { this.#placeSwatch(swatch); } } } } #placeSwatch (swatch) { let color = swatch.color; if (!color) { return; } color = color.to(this.space); swatch.style.setProperty("--hue", `${color.get(this.hueCoord.id) || 0}deg`); if (this.channelCoord) { swatch.style.setProperty("--channel", color.get(this.channelCoord.id)); } } // ───────────────────────── Interaction ───────────────────────── #dragging = false; handleEvent (event) { switch (event.type) { case "slotchange": // The marker slot re-wires the marker; the default slot re-places points. if (event.target === this._el.markerSlot) { this.#wireMarker(); } else { this.#placePoints(); } return; case "colorschange": case "colorchange": this.#placePoints(); return; case "keydown": this.#onKey(event); return; } // Pointer events below — all suppressed while read-only. if (this.readonly) { return; } switch (event.type) { case "pointerdown": if (event.button !== 0) { return; } // preventDefault stops text selection but also the default focus, so // focus the marker ourselves below — otherwise arrow keys would scroll // the page instead of nudging the just-placed colour. event.preventDefault(); this.#dragging = true; // Capture only for the drag, so slotted points still get their own // hover events (for their tooltips) the rest of the time. this._el.wheel.setPointerCapture(event.pointerId); this.#fromPointer(event); this.marker.focus({ preventScroll: true }); this.#emit("input"); break; case "pointermove": if (this.#dragging) { this.#fromPointer(event); this.#emit("input"); } break; case "pointerup": case "pointercancel": if (this.#dragging) { this.#dragging = false; this._el.wheel.releasePointerCapture?.(event.pointerId); this.#emit("change"); } break; } } /** Map a pointer position to hue (angle) + channel (radius) and commit them. */ #fromPointer (event) { let rect = this._el.wheel.getBoundingClientRect(); let radius = rect.width / 2; let dx = event.clientX - rect.left - radius; let dy = event.clientY - rect.top - radius; let r = Math.min(Math.hypot(dx, dy) / radius, 1); let hue = (Math.atan2(-dy, dx) * 180) / Math.PI; hue = (hue + 360) % 360; let channel = this.channelValue; if (this.channelCoord) { let [min, max] = this.channelCoord.range; channel = min + r * (max - min); } this.color = this.#colorAt(hue, channel); // Reposition the marker synchronously: the reactive updated() cycle is // microtask-batched, which lets the marker lag the cursor during a drag. this.#updateMarker(); } /** Arrow keys nudge the focused marker: ←→ hue, ↑↓ channel. */ #onKey (event) { if (this.readonly || !this.color) { return; } let hueCoord = this.hueCoord; let channelCoord = this.channelCoord; let hueStep = getStep(...hueCoord.range); let delta = event.shiftKey ? 10 : 1; let handled = true; switch (event.key) { case "ArrowRight": this.hueValue = (this.hueValue + hueStep * delta) % 360; break; case "ArrowLeft": this.hueValue = (this.hueValue - hueStep * delta + 360) % 360; break; case "ArrowUp": case "ArrowDown": { if (!channelCoord) { handled = false; break; } let [min, max] = channelCoord.range; let step = getStep(min, max) * delta * (event.key === "ArrowUp" ? 1 : -1); this.channelValue = Math.max(min, Math.min(max, this.channelValue + step)); break; } default: handled = false; } if (handled) { event.preventDefault(); this.#emit("input"); this.#emit("change"); } } #emit (type) { this.dispatchEvent(new Event(type, { bubbles: true })); } static props = { spaceId: { default: "oklch", // Resolve and validate here so `get space()` (read from every render path) // always sees a known, polar space. Non-polar or unknown values are // rejected with a warning, keeping the previous space. convert (value) { if (value === null || value === undefined) { return value; } let space; try { space = value instanceof Self.Color.Space ? value : Self.Color.Space.get(value); } catch (error) { console.warn(`<hue-wheel>: ${error.message}`); return this.spaceId ?? "oklch"; } if (!space.isPolar) { console.warn(`<hue-wheel>: color space "${space.id}" is not polar; ignoring.`); return this.spaceId ?? "oklch"; } return space.id; }, reflect: { from: "space" }, }, channel: { default: null, convert (value) { return value == null || value === "" ? null : value + ""; }, }, color: { get type () { return Self.Color; }, default: null, convert (color) { if (!color) { return null; } color = color instanceof Self.Color ? color : Self.Color.get(color); return color.space.equals(this.space) ? color : color.to(this.space); }, reflect: { from: true }, }, hueValue: { type: Number, get () { return this.color?.get(this.hueCoord.id); }, set (value) { this.#setCoord(this.hueCoord.id, value); }, dependencies: ["color", "spaceId"], reflect: false, }, channelValue: { type: Number, get () { return this.channelCoord ? this.color?.get(this.channelCoord.id) : undefined; }, set (value) { if (this.channelCoord) { this.#setCoord(this.channelCoord.id, value); } }, dependencies: ["color", "spaceId", "channel"], reflect: false, }, readonly: { parse (value) { return value === "" || value === "readonly" || value === true || value === "true"; }, default: false, reflect: { from: true }, }, }; static events = { colorchange: { propchange: "color", }, }; }; Self.define(); export default Self;