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
View File
@@ -5,6 +5,7 @@
"scripts": { "scripts": {
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:wasm": "wasm-pack build colorlib -t bundler -d pkg --release", "build:wasm": "wasm-pack build colorlib -t bundler -d pkg --release",
"check": "tsc --noEmit -p tsconfig.app.json",
"clean": "rm -rf dist colorlib/pkg*", "clean": "rm -rf dist colorlib/pkg*",
"cypress:open": "1>/dev/null 2>/dev/null cypress open -d &", "cypress:open": "1>/dev/null 2>/dev/null cypress open -d &",
"dev": "vite", "dev": "vite",
+8 -4
View File
@@ -1,12 +1,12 @@
import { useState } from "react"; import { useReducer, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import ColorPicker from "@components/ColorPicker/ColorPicker"; import ColorPicker from "@components/ColorPicker/ColorPicker";
import ColorValues from "@components/ColorValues/ColorValues"; import ColorValues from "@components/ColorValues/ColorValues";
import { LeftMenu, RightMenu } from "@components/SideMenu"; import { LeftMenu, RightMenu } from "@components/SideMenu";
import { useMediaQuery } from "@providers/hooks"; import { useMediaQuery } from "@providers/hooks";
import { useSelectedColor } from "@providers/hooks";
import styles from "./App.module.css"; import styles from "./App.module.css";
@@ -90,6 +90,8 @@ function MobileRightNav({ onClick, isOpen }: MenuButtonProps) {
} }
function MobileFirstZone() { function MobileFirstZone() {
const { selectedColor, selectedColorActions } = useSelectedColor();
return ( return (
<section className={styles.mobileFirstZone} aria-label="Color tools"> <section className={styles.mobileFirstZone} aria-label="Color tools">
<div <div
@@ -112,7 +114,7 @@ function MobileFirstZone() {
aria-roledescription="slide" aria-roledescription="slide"
aria-label="Color values" aria-label="Color values"
> >
<ColorValues /> <ColorValues color={selectedColor} actions={selectedColorActions} />
</div> </div>
</div> </div>
</section> </section>
@@ -190,6 +192,8 @@ function MobileContent({
// Desktop Layout Components // Desktop Layout Components
function FirstZone() { function FirstZone() {
const { selectedColor, selectedColorActions } = useSelectedColor();
return ( return (
<section className={styles.firstZone} aria-label="Color tools"> <section className={styles.firstZone} aria-label="Color tools">
<div className={styles.colorPickerWrapper} aria-label="Color picker"> <div className={styles.colorPickerWrapper} aria-label="Color picker">
@@ -197,7 +201,7 @@ function FirstZone() {
</div> </div>
<div className={styles.colorValuesWrapper} aria-label="Color values"> <div className={styles.colorValuesWrapper} aria-label="Color values">
<ColorValues /> <ColorValues color={selectedColor} actions={selectedColorActions} />
</div> </div>
</section> </section>
); );
@@ -1,29 +1,6 @@
.container { .colorValuesWrapper {
width: 100%; width: 100%;
height: 100%; height: 100%;
} display: flex;
flex-direction: column;
.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) {
} }
+67 -2
View File
@@ -1,10 +1,75 @@
import { useRef } from "react";
import type { KeyboardEvent } from "react";
import * as colorlib from "colorlib"; import * as colorlib from "colorlib";
import type { ColorActions } from "@hooks/color";
import styles from "./ColorValues.module.css"; import styles from "./ColorValues.module.css";
import SpaceEditor from "./SpaceEditor"; import SpaceEditor from "./SpaceEditor";
import { HexEditor } from "./ValueEditor";
function ColorValues({ selectedColor }: { selectedColor: colorlib.Color }) { function ColorValues({
return <div className={styles.wrapper}></div>; 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; 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"; } from "@hooks/color";
import styles from "./SpaceEditor.module.css"; 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: "RGB"; color: colorlib.RGB; actions: RGBColorActions }
| { space: "HSV"; color: colorlib.HSV; actions: HSVColorActions } | { space: "HSV"; color: colorlib.HSV; actions: HSVColorActions }
| { space: "HCL"; color: colorlib.HCL; actions: HCLColorActions }; | { 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) { switch (space) {
case "RGB": case "RGB":
return <RGBSpaceEditor color={color} actions={actions} />; return (
<RGBSpaceEditor color={color} actions={actions} onKeyDown={onKeyDown} />
);
case "HSV": case "HSV":
return <HSVSpaceEditor color={color} actions={actions} />; return (
<HSVSpaceEditor color={color} actions={actions} onKeyDown={onKeyDown} />
);
case "HCL": case "HCL":
return <HCLSpaceEditor color={color} actions={actions} />; return (
<HCLSpaceEditor color={color} actions={actions} onKeyDown={onKeyDown} />
);
default: default:
return <></>; return <></>;
@@ -68,9 +78,11 @@ const COLOR_SPACES = {
function RGBSpaceEditor({ function RGBSpaceEditor({
color, color,
actions, actions,
onKeyDown,
}: { }: {
color: colorlib.RGB; color: colorlib.RGB;
actions: RGBColorActions; actions: RGBColorActions;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) { }) {
return ( return (
<div data-cy="RGB-editor" className={styles.spaceWrapper}> <div data-cy="RGB-editor" className={styles.spaceWrapper}>
@@ -79,18 +91,21 @@ function RGBSpaceEditor({
valueRange={COLOR_SPACES.RGB.ranges.r} valueRange={COLOR_SPACES.RGB.ranges.r}
value={color.r} value={color.r}
setValue={actions.setR} setValue={actions.setR}
onKeyDown={onKeyDown}
/> />
<ValueEditor <ValueEditor
componentSymbol={COLOR_SPACES.RGB.symbols.g} componentSymbol={COLOR_SPACES.RGB.symbols.g}
valueRange={COLOR_SPACES.RGB.ranges.g} valueRange={COLOR_SPACES.RGB.ranges.g}
value={color.g} value={color.g}
setValue={actions.setG} setValue={actions.setG}
onKeyDown={onKeyDown}
/> />
<ValueEditor <ValueEditor
componentSymbol={COLOR_SPACES.RGB.symbols.b} componentSymbol={COLOR_SPACES.RGB.symbols.b}
valueRange={COLOR_SPACES.RGB.ranges.b} valueRange={COLOR_SPACES.RGB.ranges.b}
value={color.b} value={color.b}
setValue={actions.setB} setValue={actions.setB}
onKeyDown={onKeyDown}
/> />
</div> </div>
); );
@@ -99,9 +114,11 @@ function RGBSpaceEditor({
function HSVSpaceEditor({ function HSVSpaceEditor({
color, color,
actions, actions,
onKeyDown,
}: { }: {
color: colorlib.HSV; color: colorlib.HSV;
actions: HSVColorActions; actions: HSVColorActions;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) { }) {
return ( return (
<div data-cy="HSV-editor" className={styles.spaceWrapper}> <div data-cy="HSV-editor" className={styles.spaceWrapper}>
@@ -110,12 +127,14 @@ function HSVSpaceEditor({
valueRange={COLOR_SPACES.HSV.ranges.h} valueRange={COLOR_SPACES.HSV.ranges.h}
value={color.h} value={color.h}
setValue={actions.setH} setValue={actions.setH}
onKeyDown={onKeyDown}
/> />
<ValueEditor <ValueEditor
componentSymbol={COLOR_SPACES.HSV.symbols.s} componentSymbol={COLOR_SPACES.HSV.symbols.s}
valueRange={COLOR_SPACES.HSV.ranges.s} valueRange={COLOR_SPACES.HSV.ranges.s}
value={color.s} value={color.s}
setValue={actions.setS} setValue={actions.setS}
onKeyDown={onKeyDown}
scale={COLOR_SPACES.HSV.scales.s} scale={COLOR_SPACES.HSV.scales.s}
/> />
<ValueEditor <ValueEditor
@@ -123,6 +142,7 @@ function HSVSpaceEditor({
valueRange={COLOR_SPACES.HSV.ranges.v} valueRange={COLOR_SPACES.HSV.ranges.v}
value={color.v} value={color.v}
setValue={actions.setV} setValue={actions.setV}
onKeyDown={onKeyDown}
scale={COLOR_SPACES.HSV.scales.v} scale={COLOR_SPACES.HSV.scales.v}
/> />
</div> </div>
@@ -132,9 +152,11 @@ function HSVSpaceEditor({
function HCLSpaceEditor({ function HCLSpaceEditor({
color, color,
actions, actions,
onKeyDown,
}: { }: {
color: colorlib.HCL; color: colorlib.HCL;
actions: HCLColorActions; actions: HCLColorActions;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) { }) {
return ( return (
<div data-cy="HCL-editor" className={styles.spaceWrapper}> <div data-cy="HCL-editor" className={styles.spaceWrapper}>
@@ -143,12 +165,14 @@ function HCLSpaceEditor({
valueRange={COLOR_SPACES.HCL.ranges.h} valueRange={COLOR_SPACES.HCL.ranges.h}
value={color.h} value={color.h}
setValue={actions.setH} setValue={actions.setH}
onKeyDown={onKeyDown}
/> />
<ValueEditor <ValueEditor
componentSymbol={COLOR_SPACES.HCL.symbols.c} componentSymbol={COLOR_SPACES.HCL.symbols.c}
valueRange={COLOR_SPACES.HCL.ranges.c} valueRange={COLOR_SPACES.HCL.ranges.c}
value={color.c} value={color.c}
setValue={actions.setC} setValue={actions.setC}
onKeyDown={onKeyDown}
scale={COLOR_SPACES.HCL.scales.c} scale={COLOR_SPACES.HCL.scales.c}
/> />
<ValueEditor <ValueEditor
@@ -156,6 +180,7 @@ function HCLSpaceEditor({
valueRange={COLOR_SPACES.HCL.ranges.l} valueRange={COLOR_SPACES.HCL.ranges.l}
value={color.l} value={color.l}
setValue={actions.setL} setValue={actions.setL}
onKeyDown={onKeyDown}
scale={COLOR_SPACES.HCL.scales.l} scale={COLOR_SPACES.HCL.scales.l}
/> />
</div> </div>
@@ -2,7 +2,7 @@
display: flex; display: flex;
align-items: stretch; align-items: stretch;
width: 100%; width: 100%;
min-height: 0; height: 25px;
font-family: monospace; font-family: monospace;
border: 1px solid black; border: 1px solid black;
border-top: none; border-top: none;
@@ -85,3 +85,34 @@
font-size: 14px; font-size: 14px;
text-align: right; 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 { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent, RefObject } from "react"; import type { ChangeEvent, KeyboardEvent, RefObject } from "react";
import { import {
faChevronLeft, faChevronLeft,
@@ -8,29 +8,37 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx"; import clsx from "clsx";
import * as colorlib from "colorlib";
import type { CartesianSpace, Range, Setter, Timeout } from "@/types"; import type { CartesianSpace, Range, Setter, Timeout } from "@/types";
import { Direction } from "@/types"; import { Direction } from "@/types";
import { minmax, setMeasurements, valueToPosition } from "@/util"; import { minmax, setMeasurements, valueToPosition } from "@/util";
import type { HexColorActions } from "@hooks/color";
import { useScroll } from "@hooks/scroll"; import { useScroll } from "@hooks/scroll";
import { useSlider } from "@hooks/slider"; import { useSlider } from "@hooks/slider";
import { useResize } from "@hooks/window"; import { useResize } from "@hooks/window";
import styles from "./ValueEditor.module.css"; import styles from "./ValueEditor.module.css";
// ------------ //
// Value Editor //
// ------------ //
// Component // Component
function ValueEditor({ export function ValueEditor({
componentSymbol, componentSymbol,
valueRange, valueRange,
value, value,
setValue, setValue,
scale = 1, scale = 1,
onKeyDown,
}: { }: {
componentSymbol: string; componentSymbol: string;
valueRange: Range; valueRange: Range;
value: number; value: number;
setValue: Setter<number>; setValue: Setter<number>;
scale?: number; scale?: number;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) { }) {
// Set up component state // Set up component state
const direction = Direction.HORIZONTAL; const direction = Direction.HORIZONTAL;
@@ -100,12 +108,14 @@ function ValueEditor({
position={position} position={position}
dimensions={dimensions} dimensions={dimensions}
componentSymbol={componentSymbol} componentSymbol={componentSymbol}
onKeyDown={onKeyDown}
/> />
<Button <Button
direction="decrease" direction="decrease"
handleValueStep={handleValueStep} handleValueStep={handleValueStep}
componentSymbol={componentSymbol} componentSymbol={componentSymbol}
onKeyDown={onKeyDown}
/> />
<Value <Value
@@ -115,12 +125,14 @@ function ValueEditor({
componentSymbol={componentSymbol} componentSymbol={componentSymbol}
handleValueStep={handleValueStep} handleValueStep={handleValueStep}
scale={scale} scale={scale}
onKeyDown={onKeyDown}
/> />
<Button <Button
direction="increase" direction="increase"
handleValueStep={handleValueStep} handleValueStep={handleValueStep}
componentSymbol={componentSymbol} componentSymbol={componentSymbol}
onKeyDown={onKeyDown}
/> />
</div> </div>
); );
@@ -144,12 +156,22 @@ function Slider({
position, position,
dimensions, dimensions,
componentSymbol, componentSymbol,
onKeyDown,
}: { }: {
sliderRef: RefObject<HTMLDivElement | null>; sliderRef: RefObject<HTMLDivElement | null>;
position: number; position: number;
dimensions: CartesianSpace; dimensions: CartesianSpace;
componentSymbol: string; 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 ( return (
<div className={clsx(styles.section, styles.sliderSection)}> <div className={clsx(styles.section, styles.sliderSection)}>
<div <div
@@ -162,6 +184,7 @@ function Slider({
aria-labelledby={`${componentSymbol}-label`} aria-labelledby={`${componentSymbol}-label`}
tabIndex={0} tabIndex={0}
data-cy={`${componentSymbol}-slider`} data-cy={`${componentSymbol}-slider`}
onKeyDown={handleKeyDown}
> >
<div <div
className={styles.sliderBar} className={styles.sliderBar}
@@ -177,10 +200,12 @@ function Button({
direction, direction,
componentSymbol, componentSymbol,
handleValueStep, handleValueStep,
onKeyDown,
}: { }: {
direction: "increase" | "decrease"; direction: "increase" | "decrease";
componentSymbol: string; componentSymbol: string;
handleValueStep: (step: number) => void; handleValueStep: (step: number) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) { }) {
const isIncrease = direction === "increase"; const isIncrease = direction === "increase";
const label = isIncrease ? "Increase" : "Decrease"; const label = isIncrease ? "Increase" : "Decrease";
@@ -191,6 +216,14 @@ function Button({
const onClick = () => handleValueStep(step); const onClick = () => handleValueStep(step);
const longPressProps = useLongPressRepeat(onClick); const longPressProps = useLongPressRepeat(onClick);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
(e.target as HTMLElement).blur();
} else if (onKeyDown) {
onKeyDown(e);
}
};
return ( return (
<div className={clsx(styles.section, styles.buttonWrapper)}> <div className={clsx(styles.section, styles.buttonWrapper)}>
<button <button
@@ -199,6 +232,7 @@ function Button({
{...longPressProps} {...longPressProps}
aria-label={`${label} ${componentSymbol}`} aria-label={`${label} ${componentSymbol}`}
data-cy={dataCy} data-cy={dataCy}
onKeyDown={handleKeyDown}
> >
<FontAwesomeIcon icon={icon} transform="shrink-2 down-1" /> <FontAwesomeIcon icon={icon} transform="shrink-2 down-1" />
</button> </button>
@@ -213,6 +247,7 @@ function Value({
componentSymbol, componentSymbol,
handleValueStep, handleValueStep,
scale, scale,
onKeyDown,
}: { }: {
value: number; value: number;
onChange: (e: ChangeEvent<HTMLInputElement>) => void; onChange: (e: ChangeEvent<HTMLInputElement>) => void;
@@ -220,6 +255,7 @@ function Value({
componentSymbol: string; componentSymbol: string;
handleValueStep: (step: number) => void; handleValueStep: (step: number) => void;
scale: number; scale: number;
onKeyDown?: (e: React.KeyboardEvent) => void;
}) { }) {
const valueRef = useRef(null); const valueRef = useRef(null);
const valueScroller = useScroll({ const valueScroller = useScroll({
@@ -228,6 +264,14 @@ function Value({
onScrollDown: () => handleValueStep(-1), onScrollDown: () => handleValueStep(-1),
}); });
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
(e.target as HTMLElement).blur();
} else if (onKeyDown) {
onKeyDown(e);
}
};
useEffect(() => { useEffect(() => {
if (valueRef.current) { if (valueRef.current) {
valueScroller.addScrollListener(); valueScroller.addScrollListener();
@@ -252,6 +296,7 @@ function Value({
aria-valuemax={valueRange.max} aria-valuemax={valueRange.max}
aria-valuenow={value} aria-valuenow={value}
data-cy={`${componentSymbol}-value-input`} data-cy={`${componentSymbol}-value-input`}
onKeyDown={handleKeyDown}
/> />
</div> </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 { colorReducer, createColorActions } from "@hooks/color";
import ValueEditor from "./ValueEditor"; import { ValueEditor } from "./ValueEditor";
const initialState = { const initialState = {
color: Color.from_hex("000"), color: Color.from_hex("000"),
@@ -18,7 +18,6 @@ function TestWrapper() {
<div <div
style={{ style={{
width: 400, width: 400,
height: 27,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
@@ -43,7 +42,7 @@ describe("component editor tests", () => {
cy.clock().then((clock) => clock.restore()); cy.clock().then((clock) => clock.restore());
}); });
it.only("works with mouse events", () => { it("works with mouse events", () => {
// Check initial state // Check initial state
cy.dataCy("R-slider-bar") cy.dataCy("R-slider-bar")
.should("have.css", "width", "0px") .should("have.css", "width", "0px")
@@ -159,4 +158,35 @@ describe("component editor tests", () => {
.dataCy("R-value-input") .dataCy("R-value-input")
.should("have.value", "127"); .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");
});
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
export function handleScroll( export function handleScroll(
+3 -1
View File
@@ -4,12 +4,14 @@ import { createRoot } from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import "./index.css"; import "./index.css";
import { MediaQueryProvider } from "./providers"; import { MediaQueryProvider, SelectedColorProvider } from "./providers";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<MediaQueryProvider> <MediaQueryProvider>
<SelectedColorProvider>
<App /> <App />
</SelectedColorProvider>
</MediaQueryProvider> </MediaQueryProvider>
</StrictMode>, </StrictMode>,
); );
+5 -8
View File
@@ -14,11 +14,11 @@ interface MediaQueryContextType {
isMobilePortrait: boolean; isMobilePortrait: boolean;
} }
const MediaQueryContext = createContext<MediaQueryContextType | undefined>( export const MediaQueryContext = createContext<
undefined, MediaQueryContextType | undefined
); >(undefined);
function MediaQueryProvider({ children }: { children: ReactNode }) { export const MediaQueryProvider = ({ children }: { children: ReactNode }) => {
const [viewportMode, setViewportMode] = useState<ViewportMode>( const [viewportMode, setViewportMode] = useState<ViewportMode>(
ViewportMode.DESKTOP, ViewportMode.DESKTOP,
); );
@@ -71,7 +71,4 @@ function MediaQueryProvider({ children }: { children: ReactNode }) {
{children} {children}
</MediaQueryContext.Provider> </MediaQueryContext.Provider>
); );
} };
export default MediaQueryProvider;
export { MediaQueryContext };
+9 -2
View File
@@ -1,8 +1,9 @@
import { useContext } from "react"; import { useContext } from "react";
import { MediaQueryContext } from "./MediaQueryProvider"; import { MediaQueryContext } from "./MediaQueryProvider";
import { SelectedColorContext } from "./SelectedColorProvider";
function useMediaQuery() { export function useMediaQuery() {
const context = useContext(MediaQueryContext); const context = useContext(MediaQueryContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useMediaQuery must be used within a MediaQueryProvider"); throw new Error("useMediaQuery must be used within a MediaQueryProvider");
@@ -10,4 +11,10 @@ function useMediaQuery() {
return context; return context;
} }
export { useMediaQuery }; export function useSelectedColor() {
const context = useContext(SelectedColorContext);
if (!context) {
throw new Error("useColor must be used within a ColorProvider");
}
return context;
}
+3 -2
View File
@@ -1,3 +1,4 @@
import MediaQueryProvider from "./MediaQueryProvider"; import { MediaQueryProvider } from "./MediaQueryProvider";
import { SelectedColorProvider } from "./SelectedColorProvider";
export { MediaQueryProvider }; export { MediaQueryProvider, SelectedColorProvider };