From 105e66b30bab6674690226bd18436e52b3be39d2 Mon Sep 17 00:00:00 2001 From: Jay Date: Sat, 9 Aug 2025 16:16:06 -0400 Subject: [PATCH] Wrote color values component. --- package.json | 1 + src/App.tsx | 12 +- .../ColorValues/ColorValues.module.css | 29 +--- src/components/ColorValues/ColorValues.tsx | 69 ++++++++- .../ColorValues/ColorValuesTest.cy.tsx | 86 +++++++++++ src/components/ColorValues/SpaceEditor.cy.tsx | 106 -------------- src/components/ColorValues/SpaceEditor.tsx | 37 ++++- .../ColorValues/ValueEditor.module.css | 33 ++++- src/components/ColorValues/ValueEditor.tsx | 135 +++++++++++++++++- .../ColorValues/ValueEditorTest.cy.tsx | 36 ++++- src/hooks/scroll.ts | 2 +- src/main.tsx | 6 +- src/providers/MediaQueryProvider.tsx | 13 +- src/providers/hooks.ts | 11 +- src/providers/index.ts | 5 +- 15 files changed, 415 insertions(+), 166 deletions(-) delete mode 100644 src/components/ColorValues/SpaceEditor.cy.tsx diff --git a/package.json b/package.json index f009d34..350815b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "build": "tsc -b && vite build", "build:wasm": "wasm-pack build colorlib -t bundler -d pkg --release", + "check": "tsc --noEmit -p tsconfig.app.json", "clean": "rm -rf dist colorlib/pkg*", "cypress:open": "1>/dev/null 2>/dev/null cypress open -d &", "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index bb2d556..d350bb5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,12 @@ -import { useState } from "react"; +import { useReducer, useState } from "react"; import clsx from "clsx"; import ColorPicker from "@components/ColorPicker/ColorPicker"; import ColorValues from "@components/ColorValues/ColorValues"; import { LeftMenu, RightMenu } from "@components/SideMenu"; - import { useMediaQuery } from "@providers/hooks"; +import { useSelectedColor } from "@providers/hooks"; import styles from "./App.module.css"; @@ -90,6 +90,8 @@ function MobileRightNav({ onClick, isOpen }: MenuButtonProps) { } function MobileFirstZone() { + const { selectedColor, selectedColorActions } = useSelectedColor(); + return (
- +
@@ -190,6 +192,8 @@ function MobileContent({ // Desktop Layout Components function FirstZone() { + const { selectedColor, selectedColorActions } = useSelectedColor(); + return (
@@ -197,7 +201,7 @@ function FirstZone() {
- +
); diff --git a/src/components/ColorValues/ColorValues.module.css b/src/components/ColorValues/ColorValues.module.css index a6a2a2b..5fbec1b 100644 --- a/src/components/ColorValues/ColorValues.module.css +++ b/src/components/ColorValues/ColorValues.module.css @@ -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; } diff --git a/src/components/ColorValues/ColorValues.tsx b/src/components/ColorValues/ColorValues.tsx index 9145c56..ae4a46f 100644 --- a/src/components/ColorValues/ColorValues.tsx +++ b/src/components/ColorValues/ColorValues.tsx @@ -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
; +function ColorValues({ + color, + actions, +}: { + color: colorlib.Color; + actions: ColorActions; +}) { + const wrapperRef = useRef(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 ( +
+ + + + +
+ ); } export default ColorValues; diff --git a/src/components/ColorValues/ColorValuesTest.cy.tsx b/src/components/ColorValues/ColorValuesTest.cy.tsx index e69de29..a16214b 100644 --- a/src/components/ColorValues/ColorValuesTest.cy.tsx +++ b/src/components/ColorValues/ColorValuesTest.cy.tsx @@ -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 ( +
+
+ +
+
+
+ ); +} + +describe("color values component tests", () => { + beforeEach(() => { + cy.mount(); + }); + + 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"); + }); +}); diff --git a/src/components/ColorValues/SpaceEditor.cy.tsx b/src/components/ColorValues/SpaceEditor.cy.tsx deleted file mode 100644 index c66875e..0000000 --- a/src/components/ColorValues/SpaceEditor.cy.tsx +++ /dev/null @@ -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 ( - <> -
- - - -
- -
-

- HCL ({roundTo(state.color.hcl.h, 0)}, {roundTo(state.color.hcl.c, 2)},{" "} - {roundTo(state.color.hcl.l, 2)}) -

-

- HSV ({roundTo(state.color.hsv.h, 0)}, {roundTo(state.color.hsv.s, 2)},{" "} - {roundTo(state.color.hsv.v, 2)}) -

-

- RGB ({roundTo(state.color.rgb.r, 0)}, {roundTo(state.color.rgb.g, 0)},{" "} - {roundTo(state.color.rgb.b, 0)}) -

-

HEX: #{state.color.hex.to_code()}

-
- - ); -} - -describe("space editor tests", () => { - it("can edit color values", () => { - cy.mount(); - - // 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"); - }); -}); diff --git a/src/components/ColorValues/SpaceEditor.tsx b/src/components/ColorValues/SpaceEditor.tsx index 500baaf..c4c4034 100644 --- a/src/components/ColorValues/SpaceEditor.tsx +++ b/src/components/ColorValues/SpaceEditor.tsx @@ -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 ; + return ( + + ); case "HSV": - return ; + return ( + + ); case "HCL": - return ; + return ( + + ); 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 (
@@ -79,18 +91,21 @@ function RGBSpaceEditor({ valueRange={COLOR_SPACES.RGB.ranges.r} value={color.r} setValue={actions.setR} + onKeyDown={onKeyDown} />
); @@ -99,9 +114,11 @@ function RGBSpaceEditor({ function HSVSpaceEditor({ color, actions, + onKeyDown, }: { color: colorlib.HSV; actions: HSVColorActions; + onKeyDown?: (e: React.KeyboardEvent) => void; }) { return (
@@ -110,12 +127,14 @@ function HSVSpaceEditor({ valueRange={COLOR_SPACES.HSV.ranges.h} value={color.h} setValue={actions.setH} + onKeyDown={onKeyDown} />
@@ -132,9 +152,11 @@ function HSVSpaceEditor({ function HCLSpaceEditor({ color, actions, + onKeyDown, }: { color: colorlib.HCL; actions: HCLColorActions; + onKeyDown?: (e: React.KeyboardEvent) => void; }) { return (
@@ -143,12 +165,14 @@ function HCLSpaceEditor({ valueRange={COLOR_SPACES.HCL.ranges.h} value={color.h} setValue={actions.setH} + onKeyDown={onKeyDown} />
diff --git a/src/components/ColorValues/ValueEditor.module.css b/src/components/ColorValues/ValueEditor.module.css index abcb415..78142b7 100644 --- a/src/components/ColorValues/ValueEditor.module.css +++ b/src/components/ColorValues/ValueEditor.module.css @@ -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; +} diff --git a/src/components/ColorValues/ValueEditor.tsx b/src/components/ColorValues/ValueEditor.tsx index 4ca07c5..cd88550 100644 --- a/src/components/ColorValues/ValueEditor.tsx +++ b/src/components/ColorValues/ValueEditor.tsx @@ -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; 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} /> @@ -213,6 +247,7 @@ function Value({ componentSymbol, handleValueStep, scale, + onKeyDown, }: { value: number; onChange: (e: ChangeEvent) => 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} /> ); @@ -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) => { + 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 ( +
+
HEX
+
+ e.target.select()} + onKeyDown={handleKeyDown} + /> +
+
+ ); +} diff --git a/src/components/ColorValues/ValueEditorTest.cy.tsx b/src/components/ColorValues/ValueEditorTest.cy.tsx index bfed38b..f5d886a 100644 --- a/src/components/ColorValues/ValueEditorTest.cy.tsx +++ b/src/components/ColorValues/ValueEditorTest.cy.tsx @@ -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() {
{ 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"); + }); }); diff --git a/src/hooks/scroll.ts b/src/hooks/scroll.ts index 20ebad4..fbfe498 100644 --- a/src/hooks/scroll.ts +++ b/src/hooks/scroll.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { RefObject } from "react"; export function handleScroll( diff --git a/src/main.tsx b/src/main.tsx index cf16e0a..c3c8241 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,12 +4,14 @@ import { createRoot } from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; -import { MediaQueryProvider } from "./providers"; +import { MediaQueryProvider, SelectedColorProvider } from "./providers"; createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/src/providers/MediaQueryProvider.tsx b/src/providers/MediaQueryProvider.tsx index f6a425d..bf73dd4 100644 --- a/src/providers/MediaQueryProvider.tsx +++ b/src/providers/MediaQueryProvider.tsx @@ -14,11 +14,11 @@ interface MediaQueryContextType { isMobilePortrait: boolean; } -const MediaQueryContext = createContext( - undefined, -); +export const MediaQueryContext = createContext< + MediaQueryContextType | undefined +>(undefined); -function MediaQueryProvider({ children }: { children: ReactNode }) { +export const MediaQueryProvider = ({ children }: { children: ReactNode }) => { const [viewportMode, setViewportMode] = useState( ViewportMode.DESKTOP, ); @@ -71,7 +71,4 @@ function MediaQueryProvider({ children }: { children: ReactNode }) { {children} ); -} - -export default MediaQueryProvider; -export { MediaQueryContext }; +}; diff --git a/src/providers/hooks.ts b/src/providers/hooks.ts index ac06ce8..2bc43b4 100644 --- a/src/providers/hooks.ts +++ b/src/providers/hooks.ts @@ -1,8 +1,9 @@ import { useContext } from "react"; import { MediaQueryContext } from "./MediaQueryProvider"; +import { SelectedColorContext } from "./SelectedColorProvider"; -function useMediaQuery() { +export function useMediaQuery() { const context = useContext(MediaQueryContext); if (context === undefined) { throw new Error("useMediaQuery must be used within a MediaQueryProvider"); @@ -10,4 +11,10 @@ function useMediaQuery() { 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; +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 62a9952..dc20403 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,3 +1,4 @@ -import MediaQueryProvider from "./MediaQueryProvider"; +import { MediaQueryProvider } from "./MediaQueryProvider"; +import { SelectedColorProvider } from "./SelectedColorProvider"; -export { MediaQueryProvider }; +export { MediaQueryProvider, SelectedColorProvider };