This commit is contained in:
1 parent
e3a713d2df
commit
e842177d9b
163 files changed
+7390
-1240
No files matched your search
@@ -0,0 +1,29 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.root[data-selected] {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.root:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.root:focus-visible,
|
||||
.root[data-selected] {
|
||||
outline: 2px solid Highlight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
declare const classes: Record<string, string>;
|
||||
export default classes;
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CatalogItem, getCatalogKeyboardDirection } from "./catalog-item";
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("CatalogItem", () => {
|
||||
it("uses one native button surface with selected and status semantics", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CatalogItem label="Sonarr" status="Ready" selected onSelect={onSelect}>
|
||||
Sonarr
|
||||
</CatalogItem>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.type).toBe("button");
|
||||
expect(button?.getAttribute("aria-label")).toBe("Sonarr, Ready");
|
||||
expect(button?.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("exposes busy state and blocks duplicate selection", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CatalogItem label="Weather" busy onSelect={onSelect}>
|
||||
Weather
|
||||
</CatalogItem>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(button?.getAttribute("aria-busy")).toBe("true");
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes grid navigation keys for catalog roving focus", () => {
|
||||
expect(getCatalogKeyboardDirection("ArrowRight")).toBe("next");
|
||||
expect(getCatalogKeyboardDirection("ArrowDown")).toBe("next");
|
||||
expect(getCatalogKeyboardDirection("ArrowLeft")).toBe("previous");
|
||||
expect(getCatalogKeyboardDirection("Home")).toBe("first");
|
||||
expect(getCatalogKeyboardDirection("End")).toBe("last");
|
||||
expect(getCatalogKeyboardDirection("Enter")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves focus only for navigation keys", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onMoveFocus = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<CatalogItem label="Sonarr" onSelect={onSelect} onMoveFocus={onMoveFocus}>
|
||||
Sonarr
|
||||
</CatalogItem>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
await act(async () => button?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
expect(onMoveFocus).toHaveBeenCalledWith("next");
|
||||
|
||||
await act(async () => button?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
|
||||
await act(async () => button?.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })));
|
||||
expect(onMoveFocus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("moves focus between enabled sibling catalog items by default", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<div>
|
||||
<CatalogItem label="First" onSelect={vi.fn()}>
|
||||
First
|
||||
</CatalogItem>
|
||||
<CatalogItem label="Disabled" disabled onSelect={vi.fn()}>
|
||||
Disabled
|
||||
</CatalogItem>
|
||||
<CatalogItem label="Last" onSelect={vi.fn()}>
|
||||
Last
|
||||
</CatalogItem>
|
||||
</div>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const [first, , last] = host.querySelectorAll("button");
|
||||
first?.focus();
|
||||
await act(async () => first?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
|
||||
expect(document.activeElement).toBe(last);
|
||||
|
||||
await act(async () => last?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })));
|
||||
expect(document.activeElement).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { Card } from "@mantine/core";
|
||||
|
||||
import classes from "./catalog-item.module.css";
|
||||
|
||||
interface CatalogItemProps {
|
||||
label: string;
|
||||
status?: string;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
height?: number;
|
||||
onSelect: () => void;
|
||||
onFocus?: () => void;
|
||||
onPointerEnter?: () => void;
|
||||
onMoveFocus?: (direction: "next" | "previous" | "first" | "last") => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const CatalogItem = ({
|
||||
label,
|
||||
status,
|
||||
selected,
|
||||
disabled = false,
|
||||
busy = false,
|
||||
height,
|
||||
onSelect,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
onMoveFocus,
|
||||
children,
|
||||
}: CatalogItemProps) => {
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const direction = getCatalogKeyboardDirection(event.key);
|
||||
if (!direction) return;
|
||||
event.preventDefault();
|
||||
if (onMoveFocus) onMoveFocus(direction);
|
||||
else moveCatalogFocus(event.currentTarget, direction);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
component="button"
|
||||
type="button"
|
||||
className={classes.root}
|
||||
h={height}
|
||||
withBorder
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
aria-label={status ? `${label}, ${status}` : label}
|
||||
aria-pressed={selected}
|
||||
data-catalog-item
|
||||
data-selected={selected || undefined}
|
||||
onClick={onSelect}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const moveCatalogFocus = (current: HTMLButtonElement, direction: "next" | "previous" | "first" | "last") => {
|
||||
const items = Array.from(
|
||||
current.parentElement?.querySelectorAll<HTMLButtonElement>("button[data-catalog-item]:not(:disabled)") ?? [],
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = items.indexOf(current);
|
||||
const targetIndex =
|
||||
direction === "first"
|
||||
? 0
|
||||
: direction === "last"
|
||||
? items.length - 1
|
||||
: direction === "next"
|
||||
? (currentIndex + 1) % items.length
|
||||
: (currentIndex - 1 + items.length) % items.length;
|
||||
items[targetIndex]?.focus();
|
||||
};
|
||||
|
||||
export const getCatalogKeyboardDirection = (key: string) => {
|
||||
if (key === "ArrowRight" || key === "ArrowDown") return "next" as const;
|
||||
if (key === "ArrowLeft" || key === "ArrowUp") return "previous" as const;
|
||||
if (key === "Home") return "first" as const;
|
||||
if (key === "End") return "last" as const;
|
||||
return null;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ export { UserAvatarGroup } from "./user-avatar-group";
|
||||
export { CustomPasswordInput } from "./password-input/password-input";
|
||||
export { IntegrationAvatar } from "./integration-avatar";
|
||||
export { BetaBadge } from "./beta-badge";
|
||||
export { CatalogItem } from "./catalog-item";
|
||||
export { MaskedImage } from "./masked-image";
|
||||
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
||||
export { LanguageIcon } from "./language-icon";
|
||||
|
||||
Reference in new issue
Block a user