color-elements
Version:
A set of web components for working with color. A Color.js project.
479 lines (405 loc) • 12 kB
JavaScript
import ColorElement from "../common/color-element.js";
import { getStep } from "../common/util.js";
import { getGamutBoundaries } from "./gamut-boundaries.js";
let supports = {
inSpace: CSS?.supports("background", "linear-gradient(in oklab, red, tan)"),
fieldSizing: CSS?.supports("field-sizing", "content"),
};
const Self = class ColorSlider extends ColorElement {
static tagName = "color-slider";
static url = import.meta.url;
static styles = import("./color-slider.css", { with: { type: "css" } }).catch(
() => new URL("./color-slider.css", import.meta.url),
);
static shadowTemplate = `
<input type="range" class="color-slider" part="slider" min="0" max="1" step="0.01" />
<slot name="tooltip" class="slider-tooltip">
<input type="number" part="spinner" min="0" max="1" step="0.01" />
</slot>
<slot></slot>`;
constructor () {
super();
this._el = {
slider: this.shadowRoot.querySelector(".color-slider"),
spinner: this.shadowRoot.querySelector("input[type=number]"),
};
}
connectedCallback () {
super.connectedCallback?.();
this._el.slider.addEventListener("input", this);
this._el.spinner.addEventListener("input", this);
}
disconnectedCallback () {
this._el.slider.removeEventListener("input", this);
this._el.spinner.removeEventListener("input", this);
}
handleEvent (event) {
if (event.type === "input") {
if (this.tooltip === "progress" && event.target === this._el.spinner) {
// Convert to value
let value = this._el.spinner.value;
this.value = this.valueAt(value / 100);
}
else {
this.value = event.target.value;
}
this.dispatchEvent(new event.constructor(event.type, { ...event }));
}
}
updated ({ changed }) {
for (let name of ["min", "max", "step", "value", "defaultValue"]) {
if (!changed.has(name)) {
continue;
}
this._el.slider[name] = this[name];
let value = this[name];
if (this.tooltip === "progress") {
if (name === "value" || name === "defaultValue") {
value = +(this.progress * 100).toPrecision(4);
}
else {
// Spinner values when tooltip is "progress"
value = { min: 1, max: 100, step: 1 }[name];
}
}
this._el.spinner[name] = +(+value).toPrecision(4);
}
if (changed.has("stops")) {
// FIXME will fail if there are none values
let stops = this.stops;
let supported = stops.every(color => CSS.supports("color", color));
// CSS does not support (yet?) a raw hue interpolation,
// so we need to fake it with tessellateStops() in cases of polar space and far-apart stops.
let farApart = false;
let space = this.space;
if (space.isPolar) {
for (let i = 1; i < stops.length; i++) {
// Even though space is polar, color stops might be in non-polar spaces
let first = stops[i - 1].to(space);
let second = stops[i].to(space);
let firstHue = first.get("h");
let secondHue = second.get("h");
if (Math.abs(firstHue - secondHue) >= 180) {
farApart = true;
break;
}
}
}
if (!supported || farApart) {
stops = this.tessellateStops({ steps: 3 });
}
stops = stops.map(color => color.display()).join(", ");
this.style.setProperty("--slider-color-stops", stops);
}
// Paint a separate overlay over the parts of the band that fall outside the target gamut.
// The color band itself is left untouched; this just layers --_oog-pattern on top of each
// out-of-gamut range. Set on the slider (not the host) so the nested var(--_oog-pattern)
// in the overlay resolves where it's defined.
if (changed.has("stops") || changed.has("space") || changed.has("gamut")) {
let overlay = this.#buildGamutOverlay();
if (overlay) {
this._el.slider.style.setProperty("--slider-gamut-overlay", overlay);
}
else {
this._el.slider.style.removeProperty("--slider-gamut-overlay");
}
}
// Reflect whether the currently selected color is within the target gamut,
// so it can be styled via :state(in-gamut).
if (changed.has("inGamut")) {
this.toggleState("in-gamut", this.inGamut);
}
if (changed.has("space") && supports.inSpace) {
let space = this.space;
let spaceId = space.id;
let supported = CSS.supports("background", `linear-gradient(in ${spaceId}, red, tan)`);
if (!supported) {
spaceId = this.space.isPolar ? "oklch" : "oklab";
}
this.style.setProperty("--color-space", spaceId);
}
if (changed.has("color") || changed.has("defaultColor")) {
let color = this.color;
if (color) {
let displayedColor = color.display();
this.style.setProperty("--color", displayedColor);
}
}
if (changed.has("value") || changed.has("min") || changed.has("max")) {
this.style.setProperty("--progress", this.progress);
if (changed.has("value") && !supports.fieldSizing) {
let valueStr = this.value + "";
this._el.spinner.style.setProperty("--value-length", valueStr.length);
}
}
if (changed.has("tooltip")) {
if (this.tooltip !== undefined) {
let values = this;
if (this.tooltip === "progress") {
values = {
min: 1,
max: 100,
step: 1,
value: +(this.progress * 100).toPrecision(4),
};
}
["min", "max", "step", "value"].forEach(name => {
this._el.spinner[name] = values[name];
});
}
}
}
tessellateStops (options = {}) {
let stops = this.stops;
let tessellated = [];
for (let i = 1; i < stops.length; i++) {
let start = stops[i - 1];
let end = stops[i];
let steps = start.steps(end, { space: this.space, ...options });
tessellated.push(...steps);
if (i < stops.length - 1) {
tessellated.pop();
}
}
return tessellated;
}
/**
* Build the value of the `--slider-gamut-overlay` custom property from {@link ColorSlider#oogRanges}:
* one background layer per out-of-gamut range, each painting `--_oog-pattern` confined to that range.
* The range is sized and positioned in `cqi` (1cqi = 1% of the track width), so a `[start, end]`
* fraction maps straight to background-position/-size with no percentage math. The layers end in a
* trailing comma so the stylesheet can splice them ahead of the color band.
*
* @returns {string | null} Comma-separated background layers (with a trailing comma), or null when
* nothing is out of gamut.
*/
#buildGamutOverlay () {
let oogRanges = this.oogRanges;
// The whole band is in gamut (or no gamut set): nothing to paint.
if (oogRanges.length === 0) {
return null;
}
// 1cqi = 1% of the track width, so each range maps directly to a no-repeat layer sized and
// positioned along the track. Length offsets avoid the background-position percentage rule
// (which is relative to area − image, not the area).
let cqi = fraction => `${+(fraction * 100).toPrecision(4)}cqi`;
let layers = oogRanges.map(
([start, end]) =>
`var(--_oog-pattern) ${cqi(start)} / ${cqi(end - start)} 100% no-repeat`,
);
// Trailing comma so the layers splice in ahead of the color band.
return layers.join(", ") + ",";
}
get progress () {
return this.progressAt(this.value);
}
progressAt (value) {
return (value - this.min) / (this.max - this.min);
}
valueAt (progress) {
return this.min + progress * (this.max - this.min);
}
colorAt (value) {
let progress = this.progressAt(value);
return this.colorAtProgress(progress);
}
colorAtProgress (progress) {
let bands = this.scales?.length;
if (bands <= 0) {
return null;
}
// FIXME the values outside of [0, 1] should be scaled
if (progress >= 1) {
return this.scales.at(-1)(progress);
}
else if (progress <= 0) {
return this.scales[0](progress);
}
let band = 1 / bands;
let scaleIndex = Math.max(0, Math.min(Math.floor(progress / band), bands - 1));
let scale = this.scales[scaleIndex];
let color = scale((progress % band) * bands);
return color;
}
static props = {
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 1,
},
step: {
type: Number,
default () {
return getStep(this.max, this.min, { minSteps: 100 });
},
},
stops: {
type: {
is: Array,
get values () {
return Self.Color;
},
},
default: el => [],
},
defaultValue: {
type: Number,
default () {
return (this.min + this.max) / 2;
},
reflect: {
from: "value",
},
},
value: {
type: Number,
defaultProp: "defaultValue",
reflect: false,
},
space: {
default () {
return this.stops[0]?.space ?? "oklab";
},
parse (value) {
if (value instanceof Self.Color.Space || value === null || value === undefined) {
return value;
}
value += "";
return Self.Color.Space.get(value);
},
stringify (value) {
return value?.id;
},
},
gamut: {
type: String,
default: "",
// Resolve "auto" to the display's gamut, once, when the value is set.
convert (value) {
if (value !== "auto") {
if (value && value !== "none" && !(value in Self.Color.spaces)) {
return this.gamut;
}
return value;
}
if (!globalThis.matchMedia) {
return "srgb";
}
// TODO listen to updates (can happen if window is moved to another screen)
if (matchMedia("(color-gamut: rec2020)").matches) {
return "rec2020";
}
if (matchMedia("(color-gamut: p3)").matches) {
return "p3";
}
return "srgb";
},
},
color: {
get type () {
return Self.Color;
},
get () {
return this.colorAt(this.value);
},
dependencies: ["scales", "value"],
},
inGamut: {
get () {
if (!this.gamut || this.gamut === "none" || !this.color) {
return true;
}
try {
return this.color.inGamut(this.gamut);
}
catch (error) {
// NOTE: invalid gamut id; treat the color as in-gamut.
return true;
}
},
dependencies: ["color", "gamut"],
},
scales: {
get () {
let stops = this.stops;
let scales = [];
for (let i = 1; i < stops.length; i++) {
let start = stops[i - 1];
let end = stops[i];
let range = start.range(end, { space: this.space });
scales.push(range);
}
return scales;
},
dependencies: ["stops", "space"],
},
oogRanges: {
get () {
let scales = this.scales;
let bands = scales.length;
let gamut = this.gamut;
if (bands === 0 || !gamut) {
return [];
}
// Scan each band (one interpolation between two stops) independently. A non-polar one is
// ~monotone, so its endpoints + midpoint (samples = 2) suffice; a polar one can weave in
// and out of gamut, so it needs dense sampling.
// NOTE: an out-of-gamut → in → out island that misses the midpoint is still under-sampled.
let samples = this.space.isPolar ? 100 : 2;
let ranges = [];
for (let i = 0; i < bands; i++) {
let bandRanges = getGamutBoundaries(gamut, scales[i], { samples });
if (!bandRanges) {
// gamut is "none" or an unknown id: nothing out of gamut.
return [];
}
// Place each band-local [0, 1] range into its slot in the whole track, merging with
// the previous range when they touch across a band boundary (both out of gamut there).
for (let [start, end] of bandRanges) {
let from = (i + start) / bands;
let to = (i + end) / bands;
let last = ranges.at(-1);
if (last && last[1] === from) {
last[1] = to;
}
else {
ranges.push([from, to]);
}
}
}
return ranges;
},
dependencies: ["scales", "gamut"],
},
tooltip: {
type: String,
},
};
static events = {
change: {
from () {
return this._el.slider;
},
},
valuechange: {
propchange: "value",
},
colorchange: {
propchange: "color",
},
ingamutchange: {
propchange: "inGamut",
},
};
static formBehavior = {
like: el => el._el.slider,
role: "slider",
valueProp: "value",
changeEvent: "valuechange",
};
};
Self.define();
export default Self;