diff --git a/package.json b/package.json index 8d57afe..2d8d2c9 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,7 @@ "^react(.*)$", "^@(?!(components|hooks|providers|/))(.*)$", "^(?!@|[.])(.*)$", - "^@/(.*)$", - "^@components(.*)$", - "^@hooks(.*)$", - "^@providers(.*)$", + "^@(/|components|hooks|providers)(.*)$", "^[./]" ], "importOrderSeparation": true, diff --git a/src/components/ColorValues/ColorValuesTest.cy.tsx b/src/components/ColorValues/ColorValuesTest.cy.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/ColorValues/ValueEditor.module.css b/src/components/ColorValues/ValueEditor.module.css new file mode 100644 index 0000000..510bc69 --- /dev/null +++ b/src/components/ColorValues/ValueEditor.module.css @@ -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; +} diff --git a/src/components/ColorValues/ValueEditor.tsx b/src/components/ColorValues/ValueEditor.tsx new file mode 100644 index 0000000..498523d --- /dev/null +++ b/src/components/ColorValues/ValueEditor.tsx @@ -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; +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>; +}) { + // Set up component state + const direction = Direction.HORIZONTAL; + const [origin, setOrigin] = useState({ x: 0, y: 0 }); + const [dimensions, setDimensions] = useState({ x: 0, y: 0 }); + const [position, setPosition] = useState(0); + + // Handler functions + const handleInputChange = (e: ChangeEvent) => { + 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 ( +
+
+ ); +} + +// Subcomponents +function Label({ componentSymbol }: { componentSymbol: string }) { + return ( +
+ {componentSymbol} +
+ ); +} + +function Slider({ + sliderRef, + position, + value, + range, + componentSymbol, +}: { + sliderRef: RefObject; + position: number; + value: number; + range: { min: number; max: number }; + componentSymbol: string; +}) { + return ( +
+
+
+ ); +} + +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 ( +
+ +
+ ); +} + +function Value({ + value, + onChange, + range, + componentSymbol, + handleValueStep, +}: { + value: number; + onChange: (e: ChangeEvent) => 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 ( +
+ e.target.select()} + aria-label={`${componentSymbol} value input`} + aria-valuemin={range.min} + aria-valuemax={range.max} + aria-valuenow={value} + data-cy={`${componentSymbol}-value-input`} + /> +
+ ); +} + +// Hooks +function useLongPressRepeat( + callback: () => void, + startDelay = 650, + repeatInterval = 150, +) { + const [pressing, setPressing] = useState(false); + const timerRef = useRef(null); + const intervalRef = useRef(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; diff --git a/src/components/ColorValues/ValueEditorTest.cy.tsx b/src/components/ColorValues/ValueEditorTest.cy.tsx new file mode 100644 index 0000000..dd6b34d --- /dev/null +++ b/src/components/ColorValues/ValueEditorTest.cy.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; + +import ValueEditor from "./ValueEditor"; + +function TestWrapper() { + const [value, setValue] = useState(0); + return ( +
+ +
+ ); +} + +describe("component editor tests", () => { + beforeEach(() => { + cy.clock(); + cy.mount(); + }); + + 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"); + }); +}); diff --git a/src/hooks/scroll.ts b/src/hooks/scroll.ts index b9670de..c6ebfc8 100644 --- a/src/hooks/scroll.ts +++ b/src/hooks/scroll.ts @@ -49,6 +49,7 @@ export function useScroll({ const handleWheelEvent = useCallback((event: WheelEvent) => { event.preventDefault(); + console.log("Handling wheel event."); setScrollLength((prev) => handleScroll( diff --git a/src/hooks/slider.tsx b/src/hooks/slider.tsx index aed15f5..fa6a0fe 100644 --- a/src/hooks/slider.tsx +++ b/src/hooks/slider.tsx @@ -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]); diff --git a/src/hooks/tests/sliderTest.cy.tsx b/src/hooks/tests/sliderTest.cy.tsx index dc4c77b..53bd5da 100644 --- a/src/hooks/tests/sliderTest.cy.tsx +++ b/src/hooks/tests/sliderTest.cy.tsx @@ -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); }); }); diff --git a/src/hooks/window.ts b/src/hooks/window.ts new file mode 100644 index 0000000..da357b6 --- /dev/null +++ b/src/hooks/window.ts @@ -0,0 +1,4 @@ +export function useResize(callback: () => void): () => void { + window.addEventListener("resize", callback); + return () => window.removeEventListener("resize", callback); +} diff --git a/src/util.ts b/src/util.ts index 4348d42..e05b1f7 100644 --- a/src/util.ts +++ b/src/util.ts @@ -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, + 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 }); + } +}