Skip to content
Open
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
49 changes: 49 additions & 0 deletions src/components/ImageOverlay.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import ImageOverlayPanel from "./ImageOverlay";
import type { OverlayPosition } from "../lib/types";

function ImageOverlayStory({ withImage = true }: { withImage?: boolean }) {
const [file, setFile] = useState<File | null>(() =>
withImage
? new File(
[
Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=="
),
(char) => char.charCodeAt(0)
),
],
"overlay.png",
{ type: "image/png" }
)
: null
);
const [position, setPosition] = useState<OverlayPosition>("bottom-right");
const [size, setSize] = useState(150);
const [opacity, setOpacity] = useState(75);
return (
<ImageOverlayPanel
overlayFile={file}
setOverlayFile={setFile}
overlayPosition={position}
setOverlayPosition={setPosition}
overlaySize={size}
setOverlaySize={setSize}
overlayOpacity={opacity}
setOverlayOpacity={setOpacity}
/>
);
}

const meta = {
title: "Editor/Controls/ImageOverlay",
component: ImageOverlayStory,
parameters: { layout: "padded" },
} satisfies Meta<typeof ImageOverlayStory>;

export default meta;
type Story = StoryObj<typeof meta>;
export const SelectPresets: Story = {};
export const NoImage: Story = { args: { withImage: false } };
8 changes: 8 additions & 0 deletions src/components/ImageOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export default function ImageOverlayPanel({
key={value}
type="button"
onClick={() => setOverlayPosition(value)}
aria-label={`Position overlay at ${value.replace("-", " ")}`}
aria-pressed={overlayPosition === value}
className={`rounded border py-0.5 text-center text-[10px] transition flex items-center justify-center gap-0.5 ${
overlayPosition === value
? "border-[var(--accent)] text-[var(--text)] bg-[var(--accent-muted)]"
Expand All @@ -167,6 +169,7 @@ export default function ImageOverlayPanel({
<button
type="button"
onClick={() => setOverlaySize(100)}
aria-pressed={isSmallSize}
className={`rounded border py-0.5 text-center text-[10px] transition ${
isSmallSize
? "border-[var(--accent)] text-[var(--text)] bg-[var(--accent-muted)]"
Expand All @@ -178,6 +181,7 @@ export default function ImageOverlayPanel({
<button
type="button"
onClick={() => setOverlaySize(250)}
aria-pressed={isMediumSize}
className={`rounded border py-0.5 text-center text-[10px] transition ${
isMediumSize
? "border-[var(--accent)] text-[var(--text)] bg-[var(--accent-muted)]"
Expand All @@ -189,6 +193,7 @@ export default function ImageOverlayPanel({
<button
type="button"
onClick={() => setOverlaySize(450)}
aria-pressed={isLargeSize}
className={`rounded border py-0.5 text-center text-[10px] transition ${
isLargeSize
? "border-[var(--accent)] text-[var(--text)] bg-[var(--accent-muted)]"
Expand All @@ -207,6 +212,7 @@ export default function ImageOverlayPanel({
<button
type="button"
onClick={() => setOverlayOpacity(25)}
aria-pressed={isFaintOpacity}
className={`rounded border py-0.5 text-center text-[10px] transition ${
isFaintOpacity
? "border-[var(--accent)] text-[var(--text)] bg-[var(--accent-muted)]"
Expand All @@ -218,6 +224,7 @@ export default function ImageOverlayPanel({
<button
type="button"
onClick={() => setOverlayOpacity(60)}
aria-pressed={isMediumOpacity}
className={`rounded border py-0.5 text-center text-[10px] transition ${
isMediumOpacity
? "border-[var(--accent)] text-[var(--text)] bg-[var(--accent-muted)]"
Expand All @@ -229,6 +236,7 @@ export default function ImageOverlayPanel({
<button
type="button"
onClick={() => setOverlayOpacity(100)}
aria-pressed={isSolidOpacity}
className={`rounded border py-0.5 text-center text-[10px] transition ${
isSolidOpacity
? "border-[var(--accent)] text-[var(--text)] bg-[var(--accent-muted)]"
Expand Down
45 changes: 45 additions & 0 deletions src/components/TextControls.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import TextControls from "./TextControls";
import { RecipeHarness, makeRecipe } from "./__stories__/RecipeHarness";

function TextControlsStory() {
const [selected, setSelected] = useState<string | null>("sample-text");
return (
<RecipeHarness
initial={makeRecipe({
textOverlays: [
{
id: "sample-text",
text: "Sample title",
x: 50,
y: 20,
fontSize: 48,
color: "#ffffff",
fontWeight: "normal",
fontFamily: "Arial",
},
],
})}
>
{(recipe, onChange) => (
<TextControls
recipe={recipe}
onChange={onChange}
selectedTextId={selected}
onSelectText={setSelected}
/>
)}
</RecipeHarness>
);
}

const meta = {
title: "Editor/Controls/TextControls",
component: TextControlsStory,
parameters: { layout: "padded" },
} satisfies Meta<typeof TextControlsStory>;

export default meta;
type Story = StoryObj<typeof meta>;
export const SelectFontWeight: Story = {};
1 change: 1 addition & 0 deletions src/components/TextControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export default function TextControls({
<button
key={weight}
type="button"
aria-pressed={selectedOverlay.fontWeight === weight}
onClick={() =>
handleUpdateText(selectedTextId!, { fontWeight: weight })
}
Expand Down
183 changes: 183 additions & 0 deletions src/components/__tests__/PresetButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ImageOverlayPanel from "../ImageOverlay";
import TextControls from "../TextControls";
import { DEFAULT_RECIPE } from "../../lib/constants";
import { createDefaultTextOverlay } from "../../lib/text-overlay";
import type { OverlayPosition } from "../../lib/types";

const positions = ["top-left", "top-right", "bottom-left", "bottom-right"] as const;
const sizes = ["Small", "Medium", "Large"];
const opacities = ["25%", "60%", "100%"];
const weights = ["Normal", "Bold", "Heavy"];

function ImageHarness({ size = 100, opacity = 100 }: { size?: number; opacity?: number }) {
const [file, setFile] = useState<File | null>(
() => new File(["image"], "overlay.png", { type: "image/png" })
);
const [position, setPosition] = useState<OverlayPosition>("top-left");
const [currentSize, setSize] = useState(size);
const [currentOpacity, setOpacity] = useState(opacity);
return (
<ImageOverlayPanel
overlayFile={file}
setOverlayFile={setFile}
overlayPosition={position}
setOverlayPosition={setPosition}
overlaySize={currentSize}
setOverlaySize={setSize}
overlayOpacity={currentOpacity}
setOverlayOpacity={setOpacity}
/>
);
}

function TextHarness() {
const [recipe, setRecipe] = useState(() => ({
...DEFAULT_RECIPE,
textOverlays: [
{ ...createDefaultTextOverlay(), id: "first", text: "First overlay" },
{
...createDefaultTextOverlay(),
id: "second",
text: "Second overlay",
fontWeight: "900" as const,
},
],
}));
const [selected, setSelected] = useState<string | null>("first");
return (
<TextControls
recipe={recipe}
onChange={(patch) => setRecipe((prev) => ({ ...prev, ...patch }))}
selectedTextId={selected}
onSelectText={setSelected}
/>
);
}

function expectSelection(names: readonly string[], selected: string) {
for (const name of names) {
expect(screen.getByRole("button", { name })).toHaveAttribute(
"aria-pressed",
String(name === selected)
);
}
}

beforeEach(() => {
vi.stubGlobal(
"URL",
class extends URL {
static createObjectURL = vi.fn(() => "blob:overlay");
static revokeObjectURL = vi.fn();
}
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe("image overlay preset accessibility", () => {
it("names every position and updates its pressed state after selection", async () => {
const user = userEvent.setup();
render(<ImageHarness />);
const names = positions.map((position) => `Position overlay at ${position.replace("-", " ")}`);
expectSelection(names, "Position overlay at top left");
for (const name of [...names.slice(1), "Position overlay at top left"]) {
await user.click(screen.getByRole("button", { name }));
expectSelection(names, name);
}
});

it.each([
["size", sizes, "Small"],
["opacity", opacities, "100%"],
] as const)(
"updates all %s pressed states when a preset changes",
async (_group, names, initial) => {
const user = userEvent.setup();
render(<ImageHarness />);
expectSelection(names, initial);
for (const name of names) {
await user.click(screen.getByRole("button", { name }));
expectSelection(names, name);
}
}
);

it.each([
[100, "Small"],
[150, "Small"],
[151, "Medium"],
[250, "Medium"],
[300, "Medium"],
[301, "Large"],
[450, "Large"],
])("matches the highlighted size range at %s", (size, selected) => {
render(<ImageHarness size={Number(size)} />);
expectSelection(sizes, String(selected));
});

it.each([
[0, "25%"],
[25, "25%"],
[35, "25%"],
[36, "60%"],
[60, "60%"],
[75, "60%"],
[76, "100%"],
[100, "100%"],
])("matches the highlighted opacity range at %s", (opacity, selected) => {
render(<ImageHarness opacity={Number(opacity)} />);
expectSelection(opacities, String(selected));
});

it("supports keyboard activation and removes presets when the image is removed", async () => {
const user = userEvent.setup();
render(<ImageHarness />);
screen.getByRole("button", { name: "Small" }).focus();
await user.tab();
expect(screen.getByRole("button", { name: "Medium" })).toHaveFocus();
await user.keyboard(" ");
expectSelection(sizes, "Medium");
await user.tab();
await user.keyboard("{Enter}");
expectSelection(sizes, "Large");
await user.click(screen.getByRole("button", { name: "Remove overlay image" }));
expect(screen.queryByRole("button", { pressed: true })).not.toBeInTheDocument();
});
});

describe("font weight preset accessibility", () => {
it("updates pressed states on click and follows the selected text overlay", async () => {
const user = userEvent.setup();
render(<TextHarness />);
expectSelection(weights, "Normal");
for (const name of ["Bold", "Heavy", "Normal"]) {
await user.click(screen.getByRole("button", { name }));
expectSelection(weights, name);
}
await user.click(screen.getByRole("button", { name: "Second overlay" }));
expectSelection(weights, "Heavy");
await user.click(screen.getByRole("button", { name: "First overlay" }));
expectSelection(weights, "Normal");
});

it("supports Tab, Space and Enter without changing selection on focus", async () => {
const user = userEvent.setup();
render(<TextHarness />);
screen.getByRole("button", { name: "Normal" }).focus();
await user.tab();
expect(screen.getByRole("button", { name: "Bold" })).toHaveFocus();
expectSelection(weights, "Normal");
await user.keyboard(" ");
expectSelection(weights, "Bold");
await user.tab();
await user.keyboard("{Enter}");
expectSelection(weights, "Heavy");
});
});
Loading