diff --git a/src/chrome/BranchPicker.tsx b/src/chrome/BranchPicker.tsx index 450f34d5..9154ff2b 100644 --- a/src/chrome/BranchPicker.tsx +++ b/src/chrome/BranchPicker.tsx @@ -345,7 +345,7 @@ export function BranchPicker({ ref={search} type="text" value={query} - placeholder="Search or create a branch..." + placeholder="Search or create a branch…" aria-label="Search or create a branch" spellCheck={false} autoComplete="off" diff --git a/src/chrome/ColorPickerPopover.test.ts b/src/chrome/ColorPickerPopover.test.ts new file mode 100644 index 00000000..8a727b62 --- /dev/null +++ b/src/chrome/ColorPickerPopover.test.ts @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ColorPickerPopover } from "./ColorPickerPopover"; + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function key(target: Element, value: string, shiftKey = false) { + act(() => { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: value, + shiftKey, + bubbles: true, + cancelable: true, + }), + ); + }); +} + +describe("ColorPickerPopover keyboard controls", () => { + it("focuses the hex input when requested", () => { + act(() => { + root.render( + createElement(ColorPickerPopover, { + value: "#ff0000", + onChange: vi.fn(), + autoFocus: true, + }), + ); + }); + + expect(document.activeElement).toBe( + container.querySelector('input[aria-label="Hex color"]'), + ); + }); + + it("updates saturation, brightness, and hue with arrow keys", () => { + const onChange = vi.fn(); + act(() => { + root.render( + createElement(ColorPickerPopover, { + value: "#ff0000", + onChange, + }), + ); + }); + + const saturation = container.querySelector( + '[role="slider"][aria-label="Saturation and brightness"]', + )!; + const hue = container.querySelector('[role="slider"][aria-label="Hue"]')!; + key(saturation, "ArrowLeft"); + expect(saturation.getAttribute("aria-valuenow")).toBe("99"); + key(saturation, "ArrowDown", true); + expect(saturation.getAttribute("aria-valuetext")).toContain( + "90% brightness", + ); + key(hue, "ArrowRight"); + expect(hue.getAttribute("aria-valuenow")).toBe("1"); + expect(onChange).toHaveBeenCalledTimes(3); + }); +}); + +describe("ColorPickerPopover pointer controls", () => { + it.each(["Saturation and brightness", "Hue"])( + "stops the %s drag when unmounted", + (label) => { + const onChange = vi.fn(); + const addListener = vi.spyOn(window, "addEventListener"); + const removeListener = vi.spyOn(window, "removeEventListener"); + act(() => { + root.render( + createElement(ColorPickerPopover, { + value: "#ff0000", + onChange, + }), + ); + }); + + const slider = container.querySelector( + `[role="slider"][aria-label="${label}"]`, + )!; + vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + width: 100, + height: 100, + right: 100, + bottom: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(slider, "setPointerCapture").mockImplementation(() => {}); + + act(() => { + slider.dispatchEvent( + new PointerEvent("pointerdown", { + pointerId: 1, + clientX: 50, + clientY: 50, + bubbles: true, + cancelable: true, + }), + ); + }); + onChange.mockClear(); + const pointerTypes = [ + "pointermove", + "pointerup", + "pointercancel", + ] as const; + const pointerListeners = new Map( + addListener.mock.calls.filter(([type]) => + pointerTypes.includes(type as (typeof pointerTypes)[number]), + ), + ); + expect(pointerListeners.size).toBe(pointerTypes.length); + + act(() => root.render(null)); + for (const type of pointerTypes) { + expect(removeListener).toHaveBeenCalledWith( + type, + pointerListeners.get(type), + ); + } + window.dispatchEvent( + new PointerEvent("pointermove", { clientX: 75, clientY: 25 }), + ); + + expect(onChange).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/chrome/ColorPickerPopover.tsx b/src/chrome/ColorPickerPopover.tsx index 32fa0ec4..560f48ba 100644 --- a/src/chrome/ColorPickerPopover.tsx +++ b/src/chrome/ColorPickerPopover.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, + type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { hexToHsv, hsvToHex, normalizeHex, type Hsv } from "../lib/colorUtils"; @@ -11,6 +12,8 @@ import { Pipette } from "./icons"; type Props = { value: string; onChange: (hex: string) => void; + className?: string; + autoFocus?: boolean; }; export function ColorSwatchRow({ @@ -28,9 +31,10 @@ export function ColorSwatchRow({ customPickerOpen: boolean; customHighlighted?: boolean; onPickIndex: (index: number) => void; - onToggleCustom?: () => void; + onToggleCustom?: (anchor: HTMLButtonElement) => void; }) { - const pipetteActive = customHighlighted ?? (customColor != null || customPickerOpen); + const pipetteActive = + customHighlighted ?? (customColor != null || customPickerOpen); return (
{colors.map((color, index) => { @@ -66,7 +70,7 @@ export function ColorSwatchRow({ aria-expanded={customPickerOpen} aria-pressed={customColor != null} onMouseDown={(event) => event.preventDefault()} - onClick={onToggleCustom} + onClick={(event) => onToggleCustom?.(event.currentTarget)} className="grid size-5 place-items-center rounded-full" > {!customColor ? ( - + ) : null} @@ -93,10 +100,39 @@ export function ColorSwatchRow({ ); } -export function ColorPickerPopover({ value, onChange }: Props) { +export function ColorPickerPopover({ + value, + onChange, + className = "mt-2 rounded-lg border border-content/10 bg-content/5 p-2", + autoFocus = false, +}: Props) { const [hsv, setHsv] = useState(() => hexToHsv(value)); const svRef = useRef(null); const hueRef = useRef(null); + const hexRef = useRef(null); + const pointerCleanupRef = useRef<(() => void) | null>(null); + + const startPointerDrag = (onMove: (event: PointerEvent) => void) => { + pointerCleanupRef.current?.(); + const cleanup = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", cleanup); + window.removeEventListener("pointercancel", cleanup); + if (pointerCleanupRef.current === cleanup) { + pointerCleanupRef.current = null; + } + }; + pointerCleanupRef.current = cleanup; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", cleanup); + window.addEventListener("pointercancel", cleanup); + }; + + useEffect(() => () => pointerCleanupRef.current?.(), []); + + useEffect(() => { + if (autoFocus) hexRef.current?.focus(); + }, [autoFocus]); useEffect(() => { const hex = normalizeHex(value); @@ -140,14 +176,7 @@ export function ColorPickerPopover({ value, onChange }: Props) { update(event.clientX, event.clientY); const onMove = (e: PointerEvent) => update(e.clientX, e.clientY); - const onUp = () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - window.removeEventListener("pointercancel", onUp); - }; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - window.addEventListener("pointercancel", onUp); + startPointerDrag(onMove); }; const onHuePointer = (event: ReactPointerEvent) => { @@ -164,21 +193,50 @@ export function ColorPickerPopover({ value, onChange }: Props) { update(event.clientX); const onMove = (e: PointerEvent) => update(e.clientX); - const onUp = () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - window.removeEventListener("pointercancel", onUp); - }; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - window.addEventListener("pointercancel", onUp); + startPointerDrag(onMove); + }; + + const onSvKeyDown = (event: ReactKeyboardEvent) => { + const step = event.shiftKey ? 10 : 1; + if (event.key === "ArrowLeft") { + applyHsv((prev) => ({ ...prev, s: Math.max(0, prev.s - step) })); + } else if (event.key === "ArrowRight") { + applyHsv((prev) => ({ ...prev, s: Math.min(100, prev.s + step) })); + } else if (event.key === "ArrowDown") { + applyHsv((prev) => ({ ...prev, v: Math.max(0, prev.v - step) })); + } else if (event.key === "ArrowUp") { + applyHsv((prev) => ({ ...prev, v: Math.min(100, prev.v + step) })); + } else if (event.key === "Home") { + applyHsv((prev) => ({ ...prev, s: 0 })); + } else if (event.key === "End") { + applyHsv((prev) => ({ ...prev, s: 100 })); + } else { + return; + } + event.preventDefault(); + }; + + const onHueKeyDown = (event: ReactKeyboardEvent) => { + const step = event.shiftKey ? 10 : 1; + if (event.key === "ArrowLeft" || event.key === "ArrowDown") { + applyHsv((prev) => ({ ...prev, h: Math.max(0, prev.h - step) })); + } else if (event.key === "ArrowRight" || event.key === "ArrowUp") { + applyHsv((prev) => ({ ...prev, h: Math.min(360, prev.h + step) })); + } else if (event.key === "Home") { + applyHsv((prev) => ({ ...prev, h: 0 })); + } else if (event.key === "End") { + applyHsv((prev) => ({ ...prev, h: 360 })); + } else { + return; + } + event.preventDefault(); }; const preview = hsvToHex(hsv.h, hsv.s, hsv.v); const hueColor = hsvToHex(hsv.h, 100, 100); return ( -
+