enhance release v2 experience

This commit is contained in:
ajnart committed 2026-08-14 02:18:46 +02:00
1 parent 9b4970583e
commit ef2ed6c602
152 files changed
+7236 -764

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,135 @@
// @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("offers arrow-key movement without replacing native Enter and Space activation", 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 })));
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;
};
+1
View File
@@ -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";