Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { act, type ReactNode } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

vi.mock("../ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => children,
PopoverPopup: ({ children }: { children: ReactNode }) => children,
PopoverTrigger: () => null,
PopoverClose: () => null,
}));

import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";

let renderer: ReactTestRenderer | undefined;

beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true));

afterEach(async () => {
await act(async () => renderer?.unmount());
renderer = undefined;
vi.unstubAllGlobals();
});

function control(label: string) {
return renderer!.root.find(
(node) =>
(node.props.role === "slider" || node.props.type === "range") &&
node.props["aria-label"] === label,
);
}

async function press(label: string, key: string, shiftKey = false) {
const preventDefault = vi.fn();
await act(async () => control(label).props.onKeyDown({ key, shiftKey, preventDefault }));
return preventDefault;
}

describe("provider accent color keyboard controls", () => {
it("adjusts hue and both plane axes through focusable controls without changing unrelated axes", async () => {
const onCommit = vi.fn();
await act(async () => {
renderer = create(
<ProviderAccentColorPicker displayName="Codex" value="#ff0000" onCommit={onCommit} />,
);
});
const hue = "Accent color hue";
const saturation = "Accent color saturation";
const brightness = "Accent color brightness";
expect(control(hue).props.tabIndex).toBe(0);
expect(control(saturation).props.type).toBe("range");
expect(control(brightness).props.type).toBe("range");

expect(await press(hue, "ArrowLeft")).toHaveBeenCalledOnce();
expect(control(hue).props["aria-valuenow"]).toBe(359);
expect(await press(hue, "ArrowRight")).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenLastCalledWith("#ff0000");

await press(saturation, "ArrowLeft", true);
expect(control(saturation).props["aria-valuetext"]).toBe("90%");
expect(control(brightness).props["aria-valuetext"]).toBe("100%");
expect(onCommit).toHaveBeenLastCalledWith("#ff1919");
await press(brightness, "ArrowDown", true);
expect(control(saturation).props["aria-valuetext"]).toBe("90%");
expect(control(brightness).props["aria-valuetext"]).toBe("90%");
expect(onCommit).toHaveBeenLastCalledWith("#e61717");
expect(control(hue).props["aria-valuenow"]).toBe(0);

onCommit.mockClear();
expect(await press(saturation, "Tab")).not.toHaveBeenCalled();
expect(onCommit).not.toHaveBeenCalled();
});

it("supports native range changes and Home/End per color channel", async () => {
const onCommit = vi.fn();
await act(async () => {
renderer = create(
<ProviderAccentColorPicker displayName="Codex" value="#ff0000" onCommit={onCommit} />,
);
});
const saturation = "Accent color saturation";
const brightness = "Accent color brightness";
await press(saturation, "Home");
expect(control(saturation).props.value).toBe(0);
expect(onCommit).toHaveBeenLastCalledWith("#ffffff");
await act(async () =>
control(brightness).props.onChange({ currentTarget: { valueAsNumber: 50 } }),
);
expect(control(brightness).props.value).toBe(50);
expect(onCommit).toHaveBeenLastCalledWith("#808080");
await press(saturation, "End");
expect(control(saturation).props.value).toBe(100);
await press("Accent color hue", "End");
expect(control("Accent color hue").props["aria-valuenow"]).toBe(359);
});
});
80 changes: 77 additions & 3 deletions apps/web/src/components/settings/ProviderAccentColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
"use client";

import { PipetteIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
} from "react";

import { Button } from "../ui/button";
import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
Expand Down Expand Up @@ -106,6 +114,36 @@ function ProviderCustomColorPanel(props: {
[commitHsv, hsv],
);

const handlePlaneAxisKeyDown = (event: KeyboardEvent<HTMLInputElement>, axis: "s" | "v") => {
const step = event.shiftKey ? 0.1 : 0.02;
let next: number;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = clamp(hsv[axis] - step);
else if (event.key === "ArrowRight" || event.key === "ArrowUp") next = clamp(hsv[axis] + step);
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = 1;
else return;
event.preventDefault();
commitHsv({ ...hsv, [axis]: next });
};

const handleHueKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Home") {
event.preventDefault();
commitHsv({ ...hsv, h: 0 });
return;
}
if (event.key === "End") {
event.preventDefault();
commitHsv({ ...hsv, h: 359 });
return;
}
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault();
const direction = event.key === "ArrowLeft" || event.key === "ArrowDown" ? -1 : 1;
const step = event.shiftKey ? 10 : 1;
commitHsv({ ...hsv, h: (hsv.h + direction * step + 360) % 360 });
};

const handlePointerDown = (handler: (event: PointerEvent<HTMLDivElement>) => void) => {
return (event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
Expand All @@ -116,7 +154,9 @@ function ProviderCustomColorPanel(props: {
return (
<div className="w-56 bg-popover">
<div
className="relative h-36 cursor-crosshair touch-none"
aria-label="Accent color saturation and brightness"
role="group"
className="relative h-36 cursor-crosshair touch-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-popover"
style={{
backgroundColor: `hsl(${hsv.h} 100% 50%)`,
backgroundImage:
Expand All @@ -129,18 +169,52 @@ function ProviderCustomColorPanel(props: {
}
}}
>
{(["s", "v"] as const).map((axis) => {
const label = axis === "s" ? "Saturation" : "Brightness";
return (
<label key={axis} className="contents">
<input
type="range"
min={0}
max={100}
step="any"
value={hsv[axis] * 100}
aria-label={`Accent color ${label.toLowerCase()}`}
aria-valuetext={`${Math.round(hsv[axis] * 100)}%`}
className="peer sr-only"
onKeyDown={(event) => handlePlaneAxisKeyDown(event, axis)}
onChange={(event) =>
commitHsv({ ...hsv, [axis]: clamp(event.currentTarget.valueAsNumber / 100) })
}
/>
<span
aria-hidden
className="pointer-events-none invisible absolute bottom-2 left-2 rounded bg-popover px-1.5 py-0.5 text-xs text-popover-foreground peer-focus-visible:visible"
>
{label} {Math.round(hsv[axis] * 100)}%
</span>
</label>
);
})}
<span
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.35)]"
style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` }}
/>
</div>
<div className="grid gap-3 p-3">
<div
className="relative h-3 cursor-pointer touch-none rounded-full"
aria-label="Accent color hue"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hsv.h)}
role="slider"
tabIndex={0}
className="relative h-3 cursor-pointer touch-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover"
style={{
background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
}}
onPointerDown={handlePointerDown(updateFromHue)}
onKeyDown={handleHueKeyDown}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
updateFromHue(event);
Expand Down
Loading