Wrote color values component.

This commit is contained in:
Jay
2025-08-09 16:16:06 -04:00
parent 0d08d805a3
commit 105e66b30b
15 changed files with 415 additions and 166 deletions
@@ -1,29 +1,6 @@
.container {
.colorValuesWrapper {
width: 100%;
height: 100%;
}
.valueItem {
}
/* Component Editor */
.componentWrapper {
}
/* Large - Landscape Tablets / Desktops */
/* Medium - Portrait Tablets */
/* Horizontal layout, vertically scrolling picker and palette content */
@media (min-width: 992px),
(min-width: 568px) and (max-width: 991px) and (orientation: portrait) {
}
/* Medium - Landscape Phones */
/* Horizontal layout, side menu, vertical tabbed picker */
@media (min-width: 568px) and (max-width: 991px) and (orientation: landscape) {
}
/* Small - Portrait Phones*/
/* Vertical layout, side menu, horizontal tabbed picker */
@media (max-width: 567px) {
display: flex;
flex-direction: column;
}
+67 -2
View File
@@ -1,10 +1,75 @@
import { useRef } from "react";
import type { KeyboardEvent } from "react";
import * as colorlib from "colorlib";
import type { ColorActions } from "@hooks/color";
import styles from "./ColorValues.module.css";
import SpaceEditor from "./SpaceEditor";
import { HexEditor } from "./ValueEditor";
function ColorValues({ selectedColor }: { selectedColor: colorlib.Color }) {
return <div className={styles.wrapper}></div>;
function ColorValues({
color,
actions,
}: {
color: colorlib.Color;
actions: ColorActions;
}) {
const wrapperRef = useRef<HTMLDivElement>(null);
const handleKeyDown = (e: KeyboardEvent) => {
// Cycle through inputs on Enter / Shift+Enter
if (e.key === "Enter") {
e.preventDefault();
const wrapper = wrapperRef.current;
if (!wrapper) return;
const inputs = Array.from(wrapper.querySelectorAll("input"));
const currentIndex = inputs.indexOf(e.target as HTMLInputElement);
if (currentIndex === -1) return;
if (e.shiftKey) {
// Go to previous input
const prevIndex = (currentIndex - 1 + inputs.length) % inputs.length;
inputs[prevIndex]?.focus();
} else {
// Go to next input
const nextIndex = (currentIndex + 1) % inputs.length;
inputs[nextIndex]?.focus();
}
}
};
return (
<div className={styles.colorValuesWrapper} ref={wrapperRef}>
<SpaceEditor
space="HCL"
color={color.hcl}
actions={actions.hcl}
onKeyDown={handleKeyDown}
/>
<SpaceEditor
space="HSV"
color={color.hsv}
actions={actions.hsv}
onKeyDown={handleKeyDown}
/>
<SpaceEditor
space="RGB"
color={color.rgb}
actions={actions.rgb}
onKeyDown={handleKeyDown}
/>
<HexEditor
color={color.hex}
actions={actions.hex}
onKeyDown={handleKeyDown}
/>
</div>
);
}
export default ColorValues;
@@ -0,0 +1,86 @@
import { useReducer } from "react";
import { Color } from "colorlib";
import { colorReducer, createColorActions } from "@hooks/color";
import ColorValues from "./ColorValues";
const initialState = {
color: Color.from_hex("2edd9d"),
};
function TestWrapper() {
const [state, dispatch] = useReducer(colorReducer, initialState);
const actions = createColorActions(dispatch);
return (
<div>
<div
style={{
height: "75vh",
width: "100%",
marginBottom: 35,
}}
>
<ColorValues color={state.color} actions={actions} />
</div>
<div
style={{
width: "100%",
height: 40,
backgroundColor: `rgb(${Math.round(state.color.rgb.r)},${Math.round(state.color.rgb.g)},${Math.round(state.color.rgb.b)})`,
}}
></div>
</div>
);
}
describe("color values component tests", () => {
beforeEach(() => {
cy.mount(<TestWrapper />);
});
it("can cycle through inputs with Enter", () => {
cy.dataCy("HSV-editor").within(() => {
cy.dataCy("S-value-input").focus();
cy.dataCy("S-value-input").type("{enter}");
cy.dataCy("V-value-input").should("have.focus");
cy.dataCy("V-value-input").type("{enter}");
});
cy.dataCy("RGB-editor").within(() => {
cy.dataCy("R-value-input").should("have.focus");
cy.dataCy("B-value-input").focus();
cy.dataCy("B-value-input").type("{enter}");
});
cy.dataCy("hex-value-input").should("have.focus");
cy.dataCy("hex-value-input").type("{enter}");
cy.dataCy("HCL-editor").within(() => {
cy.dataCy("H-value-input").should("have.focus");
cy.dataCy("H-value-input").type("{shift}{enter}");
});
cy.dataCy("hex-value-input").should("have.focus");
cy.dataCy("hex-value-input").type("{shift}{enter}");
cy.dataCy("RGB-editor").within(() => {
cy.dataCy("B-value-input").should("have.focus");
cy.dataCy("B-value-input").type("{esc}");
cy.dataCy("B-value-input").should("not.have.focus");
});
});
it("can change color values", () => {
cy.dataCy("RGB-editor").within(() => {
cy.dataCy("R-value-input").type("120");
cy.dataCy("G-value-input").type("60");
cy.dataCy("B-value-input").type("220");
});
cy.dataCy("hex-value-input").should("have.value", "#783CDC");
});
});
@@ -1,106 +0,0 @@
import { useReducer } from "react";
import { Color } from "colorlib";
import { roundTo } from "@/util";
import { colorReducer, createColorActions } from "@hooks/color";
import SpaceEditor from "./SpaceEditor";
const initialState = {
color: Color.from_hex("2edd9d"),
};
function TestWrapper() {
const [state, dispatch] = useReducer(colorReducer, initialState);
const actions = createColorActions(dispatch);
return (
<>
<div
style={{
width: "100%",
height: 300,
display: "flex",
flexDirection: "column",
gap: "20px",
}}
>
<SpaceEditor
space="HCL"
color={state.color.hcl}
actions={actions.hcl}
/>
<SpaceEditor
space="HSV"
color={state.color.hsv}
actions={actions.hsv}
/>
<SpaceEditor
space="RGB"
color={state.color.rgb}
actions={actions.rgb}
/>
</div>
<div style={{ fontFamily: "monospace" }}>
<p data-cy="hcl-value">
HCL ({roundTo(state.color.hcl.h, 0)}, {roundTo(state.color.hcl.c, 2)},{" "}
{roundTo(state.color.hcl.l, 2)})
</p>
<p data-cy="hsv-value">
HSV ({roundTo(state.color.hsv.h, 0)}, {roundTo(state.color.hsv.s, 2)},{" "}
{roundTo(state.color.hsv.v, 2)})
</p>
<p data-cy="rgb-value">
RGB ({roundTo(state.color.rgb.r, 0)}, {roundTo(state.color.rgb.g, 0)},{" "}
{roundTo(state.color.rgb.b, 0)})
</p>
<p data-cy="hex-value">HEX: #{state.color.hex.to_code()}</p>
</div>
</>
);
}
describe("space editor tests", () => {
it("can edit color values", () => {
cy.mount(<TestWrapper />);
// Confirm initial values
cy.dataCy("RGB-editor").within(() => {
cy.dataCy("R-value-input").should("have.value", 46);
cy.dataCy("G-value-input").should("have.value", 221);
cy.dataCy("B-value-input").should("have.value", 157);
});
cy.dataCy("HSV-editor").within(() => {
cy.dataCy("H-value-input").should("have.value", 158);
cy.dataCy("S-value-input").should("have.value", 79);
cy.dataCy("V-value-input").should("have.value", 87);
});
cy.dataCy("HCL-editor").within(() => {
cy.dataCy("H-value-input").should("have.value", 158);
cy.dataCy("C-value-input").should("have.value", 79);
cy.dataCy("L-value-input").should("have.value", 70);
});
cy.dataCy("rgb-value").should("have.text", "RGB (46, 221, 157)");
cy.dataCy("hsv-value").should("have.text", "HSV (158, 0.79, 0.87)");
cy.dataCy("hcl-value").should("have.text", "HCL (158, 0.79, 0.7)");
cy.dataCy("hex-value").should("have.text", "HEX: #2EDD9D");
// Update the color values
cy.wait(50); // ensure render
cy.dataCy("HCL-editor").within(() => {
cy.dataCy("H-slider").click();
cy.dataCy("C-decrement-button").click();
cy.dataCy("L-value-input").type("25");
});
cy.dataCy("rgb-value").should("have.text", "RGB (17, 76, 74)");
cy.dataCy("hsv-value").should("have.text", "HSV (179, 0.78, 0.3)");
cy.dataCy("hcl-value").should("have.text", "HCL (179, 0.78, 0.25)");
cy.dataCy("hex-value").should("have.text", "HEX: #104B4A");
});
});
+31 -6
View File
@@ -7,23 +7,33 @@ import type {
} from "@hooks/color";
import styles from "./SpaceEditor.module.css";
import ValueEditor from "./ValueEditor";
import { ValueEditor } from "./ValueEditor";
type SpaceEditorProps =
type ColorSpaceProps =
| { space: "RGB"; color: colorlib.RGB; actions: RGBColorActions }
| { space: "HSV"; color: colorlib.HSV; actions: HSVColorActions }
| { space: "HCL"; color: colorlib.HCL; actions: HCLColorActions };
function SpaceEditor({ space, color, actions }: SpaceEditorProps) {
type SpaceEditorProps = ColorSpaceProps & {
onKeyDown?: (e: React.KeyboardEvent) => void;
};
function SpaceEditor({ space, color, actions, onKeyDown }: SpaceEditorProps) {
switch (space) {
case "RGB":
return <RGBSpaceEditor color={color} actions={actions} />;
return (
<RGBSpaceEditor color={color} actions={actions} onKeyDown={onKeyDown} />
);
case "HSV":
return <HSVSpaceEditor color={color} actions={actions} />;
return (
<HSVSpaceEditor color={color} actions={actions} onKeyDown={onKeyDown} />
);
case "HCL":
return <HCLSpaceEditor color={color} actions={actions} />;
return (
<HCLSpaceEditor color={color} actions={actions} onKeyDown={onKeyDown} />
);
default:
return <></>;
@@ -68,9 +78,11 @@ const COLOR_SPACES = {
function RGBSpaceEditor({
color,
actions,
onKeyDown,
}: {
color: colorlib.RGB;
actions: RGBColorActions;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
return (
<div data-cy="RGB-editor" className={styles.spaceWrapper}>
@@ -79,18 +91,21 @@ function RGBSpaceEditor({
valueRange={COLOR_SPACES.RGB.ranges.r}
value={color.r}
setValue={actions.setR}
onKeyDown={onKeyDown}
/>
<ValueEditor
componentSymbol={COLOR_SPACES.RGB.symbols.g}
valueRange={COLOR_SPACES.RGB.ranges.g}
value={color.g}
setValue={actions.setG}
onKeyDown={onKeyDown}
/>
<ValueEditor
componentSymbol={COLOR_SPACES.RGB.symbols.b}
valueRange={COLOR_SPACES.RGB.ranges.b}
value={color.b}
setValue={actions.setB}
onKeyDown={onKeyDown}
/>
</div>
);
@@ -99,9 +114,11 @@ function RGBSpaceEditor({
function HSVSpaceEditor({
color,
actions,
onKeyDown,
}: {
color: colorlib.HSV;
actions: HSVColorActions;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
return (
<div data-cy="HSV-editor" className={styles.spaceWrapper}>
@@ -110,12 +127,14 @@ function HSVSpaceEditor({
valueRange={COLOR_SPACES.HSV.ranges.h}
value={color.h}
setValue={actions.setH}
onKeyDown={onKeyDown}
/>
<ValueEditor
componentSymbol={COLOR_SPACES.HSV.symbols.s}
valueRange={COLOR_SPACES.HSV.ranges.s}
value={color.s}
setValue={actions.setS}
onKeyDown={onKeyDown}
scale={COLOR_SPACES.HSV.scales.s}
/>
<ValueEditor
@@ -123,6 +142,7 @@ function HSVSpaceEditor({
valueRange={COLOR_SPACES.HSV.ranges.v}
value={color.v}
setValue={actions.setV}
onKeyDown={onKeyDown}
scale={COLOR_SPACES.HSV.scales.v}
/>
</div>
@@ -132,9 +152,11 @@ function HSVSpaceEditor({
function HCLSpaceEditor({
color,
actions,
onKeyDown,
}: {
color: colorlib.HCL;
actions: HCLColorActions;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
return (
<div data-cy="HCL-editor" className={styles.spaceWrapper}>
@@ -143,12 +165,14 @@ function HCLSpaceEditor({
valueRange={COLOR_SPACES.HCL.ranges.h}
value={color.h}
setValue={actions.setH}
onKeyDown={onKeyDown}
/>
<ValueEditor
componentSymbol={COLOR_SPACES.HCL.symbols.c}
valueRange={COLOR_SPACES.HCL.ranges.c}
value={color.c}
setValue={actions.setC}
onKeyDown={onKeyDown}
scale={COLOR_SPACES.HCL.scales.c}
/>
<ValueEditor
@@ -156,6 +180,7 @@ function HCLSpaceEditor({
valueRange={COLOR_SPACES.HCL.ranges.l}
value={color.l}
setValue={actions.setL}
onKeyDown={onKeyDown}
scale={COLOR_SPACES.HCL.scales.l}
/>
</div>
@@ -2,7 +2,7 @@
display: flex;
align-items: stretch;
width: 100%;
min-height: 0;
height: 25px;
font-family: monospace;
border: 1px solid black;
border-top: none;
@@ -85,3 +85,34 @@
font-size: 14px;
text-align: right;
}
.hexEditor {
display: flex;
align-items: stretch;
font-family: monospace;
border: 1px solid black;
height: 25px;
max-width: 150px;
}
.hexLabel {
font-family: monospace;
font-size: 14px;
padding: 0 10px;
}
.hexValueWrapper {
flex: 1;
}
.hexValueWrapper input {
height: 100%;
width: 100%;
background: none;
border: none;
padding: 0 5px;
font-family: monospace;
font-size: 14px;
letter-spacing: 0.1em;
text-align: center;
}
+132 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent, RefObject } from "react";
import type { ChangeEvent, KeyboardEvent, RefObject } from "react";
import {
faChevronLeft,
@@ -8,29 +8,37 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
import * as colorlib from "colorlib";
import type { CartesianSpace, Range, Setter, Timeout } from "@/types";
import { Direction } from "@/types";
import { minmax, setMeasurements, valueToPosition } from "@/util";
import type { HexColorActions } from "@hooks/color";
import { useScroll } from "@hooks/scroll";
import { useSlider } from "@hooks/slider";
import { useResize } from "@hooks/window";
import styles from "./ValueEditor.module.css";
// ------------ //
// Value Editor //
// ------------ //
// Component
function ValueEditor({
export function ValueEditor({
componentSymbol,
valueRange,
value,
setValue,
scale = 1,
onKeyDown,
}: {
componentSymbol: string;
valueRange: Range;
value: number;
setValue: Setter<number>;
scale?: number;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
// Set up component state
const direction = Direction.HORIZONTAL;
@@ -100,12 +108,14 @@ function ValueEditor({
position={position}
dimensions={dimensions}
componentSymbol={componentSymbol}
onKeyDown={onKeyDown}
/>
<Button
direction="decrease"
handleValueStep={handleValueStep}
componentSymbol={componentSymbol}
onKeyDown={onKeyDown}
/>
<Value
@@ -115,12 +125,14 @@ function ValueEditor({
componentSymbol={componentSymbol}
handleValueStep={handleValueStep}
scale={scale}
onKeyDown={onKeyDown}
/>
<Button
direction="increase"
handleValueStep={handleValueStep}
componentSymbol={componentSymbol}
onKeyDown={onKeyDown}
/>
</div>
);
@@ -144,12 +156,22 @@ function Slider({
position,
dimensions,
componentSymbol,
onKeyDown,
}: {
sliderRef: RefObject<HTMLDivElement | null>;
position: number;
dimensions: CartesianSpace;
componentSymbol: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
(e.target as HTMLElement).blur();
} else if (onKeyDown) {
onKeyDown(e);
}
};
return (
<div className={clsx(styles.section, styles.sliderSection)}>
<div
@@ -162,6 +184,7 @@ function Slider({
aria-labelledby={`${componentSymbol}-label`}
tabIndex={0}
data-cy={`${componentSymbol}-slider`}
onKeyDown={handleKeyDown}
>
<div
className={styles.sliderBar}
@@ -177,10 +200,12 @@ function Button({
direction,
componentSymbol,
handleValueStep,
onKeyDown,
}: {
direction: "increase" | "decrease";
componentSymbol: string;
handleValueStep: (step: number) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
const isIncrease = direction === "increase";
const label = isIncrease ? "Increase" : "Decrease";
@@ -191,6 +216,14 @@ function Button({
const onClick = () => handleValueStep(step);
const longPressProps = useLongPressRepeat(onClick);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
(e.target as HTMLElement).blur();
} else if (onKeyDown) {
onKeyDown(e);
}
};
return (
<div className={clsx(styles.section, styles.buttonWrapper)}>
<button
@@ -199,6 +232,7 @@ function Button({
{...longPressProps}
aria-label={`${label} ${componentSymbol}`}
data-cy={dataCy}
onKeyDown={handleKeyDown}
>
<FontAwesomeIcon icon={icon} transform="shrink-2 down-1" />
</button>
@@ -213,6 +247,7 @@ function Value({
componentSymbol,
handleValueStep,
scale,
onKeyDown,
}: {
value: number;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
@@ -220,6 +255,7 @@ function Value({
componentSymbol: string;
handleValueStep: (step: number) => void;
scale: number;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
const valueRef = useRef(null);
const valueScroller = useScroll({
@@ -228,6 +264,14 @@ function Value({
onScrollDown: () => handleValueStep(-1),
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
(e.target as HTMLElement).blur();
} else if (onKeyDown) {
onKeyDown(e);
}
};
useEffect(() => {
if (valueRef.current) {
valueScroller.addScrollListener();
@@ -252,6 +296,7 @@ function Value({
aria-valuemax={valueRange.max}
aria-valuenow={value}
data-cy={`${componentSymbol}-value-input`}
onKeyDown={handleKeyDown}
/>
</div>
);
@@ -298,4 +343,88 @@ function useLongPressRepeat(
};
}
export default ValueEditor;
// ---------- //
// Hex Editor //
// ---------- //
const extractHexValue = (value: string): string | null => {
const match = value.match(/^#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/);
return match ? match[1] : null;
};
const formatHexString = (
color: colorlib.Hex,
preserveShortFormat: boolean = false,
): string => {
const hexValue = color.to_code();
if (preserveShortFormat) {
if (
hexValue[0] === hexValue[1] &&
hexValue[2] === hexValue[3] &&
hexValue[4] === hexValue[5]
) {
return `#${hexValue[0]}${hexValue[2]}${hexValue[4]}`;
}
}
return `#${color.to_code()}`;
};
export function HexEditor({
color,
actions,
onKeyDown,
}: {
color: colorlib.Hex;
actions: HexColorActions;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) {
const [inputValue, setInputValue] = useState(formatHexString(color));
const [isShortHex, setIsShortHex] = useState(false);
useEffect(() => {
setInputValue(formatHexString(color, isShortHex));
}, [color]);
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value);
const hex = extractHexValue(value);
if (hex) {
setIsShortHex(hex.length === 3);
const newColor = colorlib.Hex.from_code(hex);
actions.setHex(newColor);
}
};
const onBlur = () => {
setInputValue(formatHexString(color));
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
(e.target as HTMLElement).blur();
} else if (onKeyDown) {
onKeyDown(e);
}
};
return (
<div data-cy="hex-editor" className={styles.hexEditor}>
<div className={clsx(styles.section, styles.hexLabel)}>HEX</div>
<div className={clsx(styles.section, styles.hexValueWrapper)}>
<input
type="text"
data-cy="hex-value-input"
value={inputValue}
onChange={onChange}
onBlur={onBlur}
onFocus={(e) => e.target.select()}
onKeyDown={handleKeyDown}
/>
</div>
</div>
);
}
@@ -4,7 +4,7 @@ import { Color } from "colorlib";
import { colorReducer, createColorActions } from "@hooks/color";
import ValueEditor from "./ValueEditor";
import { ValueEditor } from "./ValueEditor";
const initialState = {
color: Color.from_hex("000"),
@@ -18,7 +18,6 @@ function TestWrapper() {
<div
style={{
width: 400,
height: 27,
display: "flex",
flexDirection: "column",
}}
@@ -43,7 +42,7 @@ describe("component editor tests", () => {
cy.clock().then((clock) => clock.restore());
});
it.only("works with mouse events", () => {
it("works with mouse events", () => {
// Check initial state
cy.dataCy("R-slider-bar")
.should("have.css", "width", "0px")
@@ -159,4 +158,35 @@ describe("component editor tests", () => {
.dataCy("R-value-input")
.should("have.value", "127");
});
it("works with keyboard events", () => {
// Tab through components
cy.press(Cypress.Keyboard.Keys.TAB);
cy.dataCy("R-slider").should("have.focus");
cy.press(Cypress.Keyboard.Keys.TAB);
cy.dataCy("R-decrement-button").should("have.focus");
cy.press(Cypress.Keyboard.Keys.TAB);
cy.dataCy("R-value-input").should("have.focus");
cy.press(Cypress.Keyboard.Keys.TAB);
cy.dataCy("R-increment-button").should("have.focus");
// Pressing Escape should blur focused element
cy.dataCy("R-increment-button").type("{esc}");
cy.dataCy("R-increment-button").should("not.have.focus");
cy.dataCy("R-slider").focus();
cy.dataCy("R-slider").type("{esc}");
cy.dataCy("R-slider").should("not.have.focus");
cy.dataCy("R-decrement-button").focus();
cy.dataCy("R-decrement-button").type("{esc}");
cy.dataCy("R-decrement-button").should("not.have.focus");
cy.dataCy("R-value-input").focus();
cy.dataCy("R-value-input").type("{esc}");
cy.dataCy("R-value-input").should("not.have.focus");
});
});