ENH: Add a custom element for color tint pickers

Functions as some kind of 2D `<input type='range'>` input for selecting the color's saturation and brightness
This commit is contained in:
bananarama92 2025-05-03 16:25:20 +02:00
parent 435dbe1236
commit aa2387770d
No known key found for this signature in database
GPG key ID: E83C7D3B5DA36248
4 changed files with 373 additions and 0 deletions

View file

@ -362,3 +362,29 @@ select:invalid:not(:disabled):not(:read-only) {
[hidden] {
display: none !important;
}
bc-tint-input {
--hue: 0;
display: inline-block;
aspect-ratio: 1 / 0.6;
width: 160px;
position: relative;
border-width: 2px;
border-style: inset;
border-color: gray;
box-sizing: border-box;
background-blend-mode: multiply;
background:
linear-gradient(90deg, white, hsl(var(--hue), 100%, 50%)),
linear-gradient(white, black);
}
bc-tint-input:invalid {
background-color: #fbb;
box-shadow: 0 0 3px 2px #c22;
}
bc-tint-input:disabled {
border-color: black;
}

View file

@ -0,0 +1,37 @@
.knob {
display: grid;
align-items: center;
justify-items: center;
position: absolute;
width: min(5vh, 2.5vw);
height: min(5vh, 2.5vw);
cursor: pointer;
transform: translate(-50%, -50%);
}
.knob-circle {
border: black 2px solid;
border-radius: 100%;
width: 50%;
height: 50%;
box-shadow:
0 0 0 5px white,
0 0 0 7px black;
}
:host(bc-tint-input:disabled) .knob {
cursor: auto;
}
:host(bc-tint-input:disabled) .knob-circle {
box-shadow:
0 0 0 3px gray,
0 0 0 5px black;
}
@supports (height: 100dvh) {
.knob {
width: min(5dvh, 2.5dvw);
height: min(5dvh, 2.5dvw);
}
}

View file

@ -2610,3 +2610,309 @@ function ElementFitText(el) {
if (size < 8) break;
}
}
/**
* HTML element for color tint pickers, functioning as some kind of 2D `<input type='range'>` input for selecting the color's saturation and brightness.
*/
class HTMLColorTintElement extends HTMLElement {
static observedAttributes = ["value", "disabled"];
static formAssociated = true;
/** @type {null | ElementInternals} */
internals_ = null;
/**
* @private
* @type {null | string}
*/
_pressedOldValue = null;
constructor() {
super();
if ("attachInternals" in this && typeof this.attachInternals === "function") {
this.internals_ = this.attachInternals();
}
}
connectedCallback() {
if (this.shadowRoot) {
return;
}
const shadow = this.attachShadow({ mode: "open" });
shadow.append(
ElementCreate({
tag: "div",
classList: ["knob"],
attributes: { "aria-hidden": "true" },
children: [{ tag: "div", classList: ["knob-circle"] }],
}),
ElementCreate({
tag: "link",
attributes: { rel: "stylesheet", href: "CSS/tint-input.css" },
}),
);
this.tabIndex = this.disabled ? -1 : 0;
this.defaultValue = this.value = this.getAttribute("value") ?? "#FFFFFF";
const tintPicker = this;
/** @type {(this: Document, ev: PointerEvent) => void} */
function pointermove(ev) {
if (tintPicker._pressedOldValue == null) {
return;
}
/** @type {null | HTMLElement} */
const knob = tintPicker.shadowRoot.querySelector(".knob");
if (!tintPicker || !knob) {
ev.stopImmediatePropagation();
return;
}
const { top, left, width, height } = tintPicker.getBoundingClientRect();
const saturation = CommonClamp((ev.pageX - left) / width, 0, 1);
const brightness = CommonClamp((ev.pageY - top) / height, 0, 1);
tintPicker._setKnobPosition(100 * saturation, 100 * brightness);
tintPicker.brightness = (1 - brightness) * 255;
tintPicker.saturation = saturation * 255;
tintPicker.dispatchEvent(new InputEvent("input"));
}
/** @type {(this: Document, ev: PointerEvent) => void} */
function pointerup(ev) {
if (tintPicker._pressedOldValue == null) {
return;
}
document.removeEventListener("pointerup", pointerup);
document.removeEventListener("pointercancel", pointerup);
document.removeEventListener("pointermove", pointermove);
if (ev.type !== "pointercancel" && tintPicker._pressedOldValue !== tintPicker.value) {
// Round the knob position such to the nearest 1/255 x- & y-coordinate
const top = (255 - tintPicker.brightness) / (255 / 100);
const left = tintPicker.saturation / (255 / 100);
tintPicker._setKnobPosition(left, top);
tintPicker.dispatchEvent(new Event("change"));
}
tintPicker._pressedOldValue = null;
}
/** @type {(this: HTMLColorTintElement, ev: PointerEvent) => void} */
function pointerdown(ev) {
if (this.disabled || ev.button !== 0) {
return;
}
tintPicker._pressedOldValue = this.value;
document.addEventListener("pointerup", pointerup);
document.addEventListener("pointercancel", pointerup);
document.addEventListener("pointermove", pointermove);
document.dispatchEvent(new PointerEvent("pointermove", ev));
}
this.addEventListener("pointerdown", pointerdown);
}
/**
* @param {string} name
* @param {null | string} oldValue
* @param {null | string} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case "value": {
if (newValue !== oldValue) {
this.defaultValue = this.value = newValue ?? "#FFFFFF";
}
break;
}
case "disabled": {
this.tabIndex = newValue == null ? 0 : -1;
break;
}
}
}
/**
* See {@link HTMLInputElement.disabled}
* @type {boolean}
*/
get disabled() {
return this.hasAttribute("disabled");
}
set disabled(value) {
this.toggleAttribute("disabled", value);
}
/**
* Get or set the color hue on a scale of 0 to 360.
* @type {number}
*/
get hue() {
return Math.round(this._value.H * 360);
}
set hue(value) {
const hsv = this.valueAsHSV;
hsv.H = CommonClamp(value / 360, 0, 1);
this.valueAsHSV = hsv;
}
/**
* Get or set the color saturation on a scale of 0 to 255.
* @type {number}
*/
get saturation() {
return Math.round(this._value.S * 255);
}
set saturation(value) {
const hsv = this.valueAsHSV;
hsv.S = CommonClamp(value / 255, 0, 1);
this.valueAsHSV = hsv;
}
/**
* Get or set the color brightness on a scale of 0 to 255.
* @type {number}
*/
get brightness() {
return Math.round(this._value.V * 255);
}
set brightness(value) {
const hsv = this.valueAsHSV;
hsv.V = CommonClamp(value / 255, 0, 1);
this.valueAsHSV = hsv;
}
/**
* Returns the error message that would be displayed if the user submits the form, or an empty string if no error message.
* It also triggers the standard error message, such as "this is a required field".
* The result is that the user sees validation messages without actually submitting.
*
* See {@link HTMLInputElement.validationMessage}
* @type {string}
*/
get validationMessage() {
return this.internals_?.validationMessage ?? "";
}
/**
* Returns a ValidityState object that represents the validity states of an element.
*
* See {@link HTMLInputElement.validity}
* @returns {ValidityState}
*/
get validity() {
return this.internals_?.validity ?? Object.freeze({
badInput: false,
customError: false,
patternMismatch: false,
rangeOverflow: false,
rangeUnderflow: false,
stepMismatch: false,
tooLong: false,
tooShort: false,
typeMismatch: false,
valid: true,
valueMissing: false,
});
}
/**
* See {@link HTMLInputElement.reportValidity}
* @returns {boolean}
*/
reportValidity() {
return this.internals_?.reportValidity() ?? true;
}
/**
* Sets or retrieves the initial contents of the object.
*
* See {@link HTMLInputElement.defaultValue}
* @type {string}
*/
defaultValue = "#FFFFFF";
/**
* @private
* @type {Readonly<HSVColor>}
*/
_value = { H: 0, S: 0, V: 1 };
/**
* Get or set the color {@link value} via an object with [HSV](https://en.wikipedia.org/wiki/HSL_and_HSV) color values.
* All HSV values are expected to be normalized to the `[0, 1]` range.
* @type {HSVColor}
*/
get valueAsHSV() {
return { ...this._value };
}
set valueAsHSV(value) {
if (
CommonIsObject(value)
&& CommonIsFinite(value.H, 0, 1)
&& CommonIsFinite(value.S, 0, 1)
&& CommonIsFinite(value.V, 0, 1)
) {
value = CommonPick(value, ["H", "S", "V"]);
} else {
value = { H: 0, S: 0, V: 1 };
}
this._value = value;
const rgb = ColorPickerHSVToCSS(value);
/** @type {null | HTMLElement} */
const knob = this.shadowRoot?.querySelector(".knob-circle");
knob?.style.setProperty("background-color", rgb);
this.style.setProperty("--hue", (value.H * 360).toString());
if (this._pressedOldValue == null) {
this._setKnobPosition(100 * value.S, 100 * (1 - value.V));
}
}
/**
* Sets or retrieves the initial contents of the object.
*
* See {@link HTMLInputElement.value}
* @type {string}
*/
get value() {
return ColorPickerHSVToCSS(this._value);
}
set value(value) {
this.valueAsHSV = ColorPickerCSSToHSV(value);
}
/**
* Sets or retrieves the name of the object.
*
* See {@link HTMLInputElement.name}
* @type {string}
*/
get name() {
return this.getAttribute("name");
}
set name(value) {
this.setAttribute("name", value);
}
/**
* Set the position the knob
* @private
* @param {number} left - The relative left position on a scale of 0-100
* @param {number} top - The relative top position on a scale of 0-100
*/
_setKnobPosition(left, top) {
/** @type {null | HTMLElement} */
const knob = this.shadowRoot?.querySelector(".knob");
if (!knob) {
return;
}
knob.style.left = `${left}%`;
knob.style.top = `${top}%`;
}
}
customElements.define("bc-tint-input", HTMLColorTintElement);

View file

@ -134,6 +134,10 @@ interface HTMLElementEventMap {
bcTouchHold: MouseEvent;
}
interface HTMLElementTagNameMap {
"bc-tint-input": HTMLTintInputElement,
}
declare namespace ElementButton {
/** An input type for representing one or more text nodes and/or **non-interactive** DOM elements (_e.g._ `<i>` or `<code>`) */
type StaticNode = null | string | Node | HTMLOptions<any> | readonly (null | undefined | string | Node | HTMLOptions<any>)[];