Completed color value editor component.

This commit is contained in:
Jay
2025-08-03 15:24:25 -04:00
parent 33f6a7e0c2
commit c27a5258d3
10 changed files with 613 additions and 36 deletions
+1 -4
View File
@@ -49,10 +49,7 @@
"^react(.*)$",
"^@(?!(components|hooks|providers|/))(.*)$",
"^(?!@|[.])(.*)$",
"^@/(.*)$",
"^@components(.*)$",
"^@hooks(.*)$",
"^@providers(.*)$",
"^@(/|components|hooks|providers)(.*)$",
"^[./]"
],
"importOrderSeparation": true,
@@ -0,0 +1,80 @@
.componentWrapper {
display: flex;
align-items: stretch;
width: 100%;
height: 100%;
font-family: monospace;
border: 1px solid black;
border-top: none;
}
.componentWrapper:first-of-type {
border-top: 1px solid black;
}
.section {
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid black;
}
.section:last-of-type {
border-right: none;
}
.symbol {
aspect-ratio: 1 / 1;
}
.sliderWrapper {
position: relative;
flex-grow: 1;
}
.sliderBar {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: #aaa;
pointer-events: none;
}
.buttonWrapper {
aspect-ratio: 1 / 1;
}
.button {
height: 100%;
width: 100%;
cursor: pointer;
user-select: none;
background: none;
border: none;
}
.button:hover {
background-color: #ddd;
}
.button:active {
background-color: #bbb;
}
.valueWrapper {
aspect-ratio: 1.5 / 1;
min-width: 40px;
}
.value {
height: 100%;
width: 100%;
background: none;
border: none;
padding: 0 5px;
font-family: monospace;
font-size: 14px;
text-align: right;
box-sizing: border-box;
}
+338
View File
@@ -0,0 +1,338 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, Dispatch, RefObject, SetStateAction } from "react";
import clsx from "clsx";
import type { CartesianSpace } from "@/types";
import { minmax, setMeasurements } from "@/util";
import { useScroll } from "@hooks/scroll";
import { Direction, useSlider } from "@hooks/slider";
import { useResize } from "@hooks/window";
import styles from "./ValueEditor.module.css";
// Types
type Timeout = ReturnType<typeof setTimeout>;
interface Range {
min: number;
max: number;
}
// Calculation functions
const getPositionFromValue = (
newValue: number,
maxValue: number,
range: Range,
) => {
const newPosition = parseFloat(
(
((newValue + Math.abs(range.min)) /
(Math.abs(range.min) + Math.abs(range.max))) *
maxValue
).toFixed(0),
);
return newPosition;
};
const getValueFromPosition = (
newPosition: number,
maxValue: number,
range: Range,
) => {
const newValue = parseFloat(
(
(newPosition / maxValue) * (Math.abs(range.min) + Math.abs(range.max)) -
Math.abs(range.min)
).toFixed(0),
);
return newValue;
};
// Component
function ValueEditor({
componentSymbol,
range,
value,
setValue,
}: {
componentSymbol: string;
range: Range;
value: number;
setValue: Dispatch<SetStateAction<number>>;
}) {
// Set up component state
const direction = Direction.HORIZONTAL;
const [origin, setOrigin] = useState<CartesianSpace>({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<CartesianSpace>({ x: 0, y: 0 });
const [position, setPosition] = useState(0);
// Handler functions
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = parseInt(e.target.value, 10);
if (!isNaN(inputValue)) {
const newValue = minmax(inputValue, range.min, range.max);
const newPosition = getPositionFromValue(newValue, dimensions.x, range);
setValue(newValue);
setPosition(newPosition);
} else {
setValue(range.min);
setPosition(0);
}
};
const handleValueStep = (step: number) => {
setValue((prev) => {
const newValue = minmax(prev + step, range.min, range.max);
const newPosition = getPositionFromValue(newValue, dimensions.x, range);
setPosition(newPosition);
return newValue;
});
};
// Set up slider hook
const { sliderRef } = useSlider({
direction,
origin,
dimensions,
setPosition,
});
// Set component dimensions for slider hook
useEffect(() => {
setMeasurements(sliderRef, setOrigin, setDimensions);
return useResize(() =>
setMeasurements(sliderRef, setOrigin, setDimensions),
);
}, [sliderRef, setOrigin, setDimensions]);
// Update value when position, etc. changes
useEffect(() => {
const maxValue = dimensions.x;
if (maxValue > 0) {
const newValue = getValueFromPosition(position, maxValue, range);
setValue(newValue);
} else {
setValue(range.min);
}
}, [dimensions, position, range, setValue]);
return (
<div
className={styles.componentWrapper}
role="group"
aria-labelledby={`${componentSymbol}-label`}
data-cy={`${componentSymbol}-editor`}
>
<Label componentSymbol={componentSymbol} />
<Slider
sliderRef={sliderRef}
position={position}
value={value}
range={range}
componentSymbol={componentSymbol}
/>
<Button
direction="decrease"
handleValueStep={handleValueStep}
componentSymbol={componentSymbol}
/>
<Value
value={value}
onChange={handleInputChange}
range={range}
componentSymbol={componentSymbol}
handleValueStep={handleValueStep}
/>
<Button
direction="increase"
handleValueStep={handleValueStep}
componentSymbol={componentSymbol}
/>
</div>
);
}
// Subcomponents
function Label({ componentSymbol }: { componentSymbol: string }) {
return (
<div
id={`${componentSymbol}-label`}
className={clsx(styles.section, styles.symbol)}
data-cy={`${componentSymbol}-label`}
>
{componentSymbol}
</div>
);
}
function Slider({
sliderRef,
position,
value,
range,
componentSymbol,
}: {
sliderRef: RefObject<HTMLDivElement | null>;
position: number;
value: number;
range: { min: number; max: number };
componentSymbol: string;
}) {
return (
<div
className={clsx(styles.section, styles.sliderWrapper)}
ref={sliderRef}
role="slider"
aria-valuemin={range.min}
aria-valuemax={range.max}
aria-valuenow={value}
aria-labelledby={`${componentSymbol}-label`}
tabIndex={0}
data-cy={`${componentSymbol}-slider`}
>
<div
className={styles.sliderBar}
style={{ width: position }}
data-cy={`${componentSymbol}-slider-bar`}
></div>
</div>
);
}
function Button({
direction,
componentSymbol,
handleValueStep,
}: {
direction: "increase" | "decrease";
componentSymbol: string;
handleValueStep: (step: number) => void;
}) {
const isIncrease = direction === "increase";
const label = isIncrease ? "Increase" : "Decrease";
const symbol = isIncrease ? ">" : "<";
const dataCy = `${componentSymbol}-${isIncrease ? "increment" : "decrement"}-button`;
const step = isIncrease ? 1 : -1;
const onClick = () => handleValueStep(step);
const longPressProps = useLongPressRepeat(onClick);
return (
<div className={clsx(styles.section, styles.buttonWrapper)}>
<button
className={styles.button}
onClick={onClick}
{...longPressProps}
aria-label={`${label} ${componentSymbol}`}
data-cy={dataCy}
>
{symbol}
</button>
</div>
);
}
function Value({
value,
onChange,
range,
componentSymbol,
handleValueStep,
}: {
value: number;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
range: { min: number; max: number };
componentSymbol: string;
handleValueStep: (step: number) => void;
}) {
const valueRef = useRef(null);
const valueScroller = useScroll({
targetRef: valueRef,
onScrollUp: () => handleValueStep(1),
onScrollDown: () => handleValueStep(-1),
});
useEffect(() => {
if (valueRef.current) {
valueScroller.addScrollListener();
}
return () => {
if (valueRef.current) {
valueScroller.removeScrollListener();
}
};
}, [valueScroller]);
return (
<div className={clsx(styles.section, styles.valueWrapper)}>
<input
type="text"
ref={valueRef}
value={value}
onChange={onChange}
className={styles.value}
onFocus={(e) => e.target.select()}
aria-label={`${componentSymbol} value input`}
aria-valuemin={range.min}
aria-valuemax={range.max}
aria-valuenow={value}
data-cy={`${componentSymbol}-value-input`}
/>
</div>
);
}
// Hooks
function useLongPressRepeat(
callback: () => void,
startDelay = 650,
repeatInterval = 150,
) {
const [pressing, setPressing] = useState(false);
const timerRef = useRef<Timeout>(null);
const intervalRef = useRef<Timeout>(null);
const start = useCallback(
(e: Event | any) => {
e.preventDefault();
setPressing(true);
timerRef.current = setTimeout(() => {
callback();
intervalRef.current = setInterval(callback, repeatInterval);
}, startDelay);
},
[callback, startDelay, repeatInterval],
);
const stop = useCallback(() => {
setPressing(false);
if (timerRef.current) clearTimeout(timerRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
return {
onMouseDown: start,
onMouseUp: stop,
onMouseLeave: stop,
onTouchStart: start,
onTouchEnd: stop,
onContextMenu: (e: Event | any) => e.preventDefault(),
pressing: pressing.toString(),
};
}
export default ValueEditor;
@@ -0,0 +1,143 @@
import { useState } from "react";
import ValueEditor from "./ValueEditor";
function TestWrapper() {
const [value, setValue] = useState(0);
return (
<div style={{ width: 400, height: 25 }}>
<ValueEditor
componentSymbol="R"
range={{ min: 0, max: 255 }}
value={value}
setValue={setValue}
/>
</div>
);
}
describe("component editor tests", () => {
beforeEach(() => {
cy.clock();
cy.mount(<TestWrapper />);
});
afterEach(() => {
cy.clock().then((clock) => clock.restore());
});
it("works with mouse events", () => {
// Check initial state
cy.dataCy("R-slider-bar")
.should("have.css", "width", "0px")
.dataCy("R-value-input")
.should("have.value", "0");
// Test slider click
cy.dataCy("R-slider")
.click()
.dataCy("R-slider-bar")
.should("have.css", "width", "141px")
.dataCy("R-value-input")
.should("have.value", "128");
// Test slider scroll
cy.dataCy("R-slider")
// scroll twice to ensure position moves enough to trigger value change.
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.dataCy("R-value-input")
.should("have.value", "129");
// Input focus should select text
cy.dataCy("R-value-input")
.focus()
.should("have.prop", "selectionStart", 0)
.should("have.prop", "selectionEnd")
.should("be.gt", 0);
// Input value should update slider
cy.dataCy("R-value-input")
.type("100")
.should("have.value", "100")
.dataCy("R-slider-bar")
.should("have.css", "width", "111px");
// Scrolling input should update value
cy.dataCy("R-value-input")
.trigger("wheel", { deltaY: -100, eventConstructor: "WheelEvent" })
.should("have.value", "101")
.dataCy("R-slider-bar")
.should("have.css", "width", "112px");
// Test increment/decrement buttons
cy.dataCy("R-decrement-button")
.click()
.dataCy("R-value-input")
.should("have.value", "100")
.dataCy("R-increment-button")
.click()
.dataCy("R-value-input")
.should("have.value", "101");
// Test button long press repeat
cy.dataCy("R-decrement-button")
.trigger("mousedown")
.then(() => {
cy.tick(650);
cy.tick(150 * 2);
})
.dataCy("R-decrement-button")
.trigger("mouseup")
.dataCy("R-value-input")
.should("have.value", "98")
.dataCy("R-increment-button")
.trigger("mousedown")
.then(() => {
cy.tick(650);
cy.tick(150 * 2);
})
.dataCy("R-increment-button")
.trigger("mouseup")
.dataCy("R-value-input")
.should("have.value", "101");
});
it("works with touch events", () => {
// Check initial state
cy.dataCy("R-slider-bar")
.should("have.css", "width", "0px")
.dataCy("R-value-input")
.should("have.value", "0");
// Test slider click
cy.dataCy("R-slider")
.click()
.dataCy("R-slider-bar")
.should("have.css", "width", "141px")
.dataCy("R-value-input")
.should("have.value", "128");
// Test button long press repeat
cy.dataCy("R-decrement-button")
.trigger("touchstart")
.then(() => {
cy.tick(650);
cy.tick(150 * 2);
})
.dataCy("R-decrement-button")
.trigger("touchend")
.dataCy("R-value-input")
.should("have.value", "125")
.dataCy("R-increment-button")
.trigger("touchstart")
.then(() => {
cy.tick(650);
cy.tick(150 * 2);
})
.dataCy("R-increment-button")
.trigger("touchend")
.dataCy("R-value-input")
.should("have.value", "128");
});
});
+1
View File
@@ -49,6 +49,7 @@ export function useScroll<T extends HTMLElement>({
const handleWheelEvent = useCallback((event: WheelEvent) => {
event.preventDefault();
console.log("Handling wheel event.");
setScrollLength((prev) =>
handleScroll(
+12 -13
View File
@@ -87,17 +87,14 @@ export function useSlider({
[calculatePosition],
);
const handleEnd = useCallback(
(event: MouseEvent | TouchEvent) => {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
document.removeEventListener("touchcancel", handleEnd);
setIsDragging(false);
},
[handleMove],
);
const handleEnd = useCallback(() => {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
document.removeEventListener("touchcancel", handleEnd);
setIsDragging(false);
}, [handleMove]);
const handleStart = useCallback(
(event: MouseEvent | TouchEvent) => {
@@ -121,18 +118,20 @@ export function useSlider({
const handleScrollUp = useCallback(() => {
const dir = directionRef.current;
const dims = dimensionsRef.current;
const inc = chooseValueByDirection(dir, 1, -1);
setPosition((prev: number) =>
minmax(prev - 1, 0, chooseValueByDirection(dir, dims.x, dims.y)),
minmax(prev + inc, 0, chooseValueByDirection(dir, dims.x, dims.y)),
);
}, [setPosition]);
const handleScrollDown = useCallback(() => {
const dir = directionRef.current;
const dims = dimensionsRef.current;
const inc = chooseValueByDirection(dir, -1, 1);
setPosition((prev: number) =>
minmax(prev + 1, 0, chooseValueByDirection(dir, dims.x, dims.y)),
minmax(prev + inc, 0, chooseValueByDirection(dir, dims.x, dims.y)),
);
}, [setPosition]);
+15 -19
View File
@@ -142,10 +142,6 @@ function createTestUtils(isHorizontal = true) {
};
}
const isTouchSupported = () => {
return typeof TouchEvent !== "undefined";
};
// Tests
describe("horizontal slider hook tests", () => {
@@ -201,25 +197,25 @@ describe("horizontal slider hook tests", () => {
it("moves the slider with mouse wheel scrolling", () => {
assertPosition(0);
triggerWheelEvent(100);
assertPosition(1);
triggerWheelEvent(100);
assertPosition(2);
triggerWheelEvent(-100);
assertPosition(1);
triggerWheelEvent(-100);
assertPosition(2);
triggerWheelEvent(100);
assertPosition(1);
// Many smaller scrolls, to simulate touchpads
triggerWheelEvent(20);
triggerWheelEvent(20);
triggerWheelEvent(20);
triggerWheelEvent(20);
triggerWheelEvent(20);
triggerWheelEvent(20);
triggerWheelEvent(20);
triggerWheelEvent(20);
triggerWheelEvent(20);
triggerWheelEvent(-20);
triggerWheelEvent(-20);
triggerWheelEvent(-20);
triggerWheelEvent(-20);
triggerWheelEvent(-20);
triggerWheelEvent(-20);
triggerWheelEvent(-20);
triggerWheelEvent(-20);
triggerWheelEvent(-20);
assertPosition(4);
});
});
+4
View File
@@ -0,0 +1,4 @@
export function useResize(callback: () => void): () => void {
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
}
+19
View File
@@ -1,3 +1,7 @@
import type { RefObject } from "react";
import type { CartesianSpace } from "./types";
export function minmax(number: number, min: number, max: number) {
return Math.min(max, Math.max(min, number));
}
@@ -25,3 +29,18 @@ export function extractEventCoordinates(event: MouseEvent | TouchEvent): {
clientY: event.clientY,
};
}
export function setMeasurements(
ref: RefObject<HTMLElement | null>,
setOrigin: (newOrigin: CartesianSpace) => void,
setDimensions: (newDimensions: CartesianSpace) => void,
) {
const el = ref.current;
if (el) {
const rect = el.getBoundingClientRect();
setOrigin({ x: rect.left, y: rect.top });
setDimensions({ x: rect.width, y: rect.height });
}
}