refactor(board): modernize item and application select modals with SelectableCard (#6642)

Modernize board item, application, and integration selection flows with shared Mantine components, batch board actions, improved accessibility, and hardened persistence.
This commit is contained in:
Thomas authored and GitHub committed 2026-08-21 03:19:18 +02:00
1 parent bd4f6a92c6
commit f3014fe271
60 files changed
+3791 -1839

No files matched your search

+7
View File
@@ -13,6 +13,13 @@ export { CustomPasswordInput } from "./password-input/password-input";
export { IntegrationAvatar } from "./integration-avatar";
export { BetaBadge } from "./beta-badge";
export { CatalogItem } from "./catalog-item";
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon";
@@ -0,0 +1,7 @@
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
@@ -0,0 +1,79 @@
.root {
cursor: pointer;
text-align: start;
display: flex;
flex-direction: column;
position: relative;
width: 100%;
min-height: 175px;
height: 100%;
padding: var(--mantine-spacing-sm);
border-radius: var(--selectable-card-radius, var(--mantine-radius-md));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border: 1px solid var(--mantine-color-default-border);
box-shadow: var(--mantine-shadow-xs);
transition:
transform 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
overflow: hidden;
font-family: inherit;
color: inherit;
text-decoration: none;
}
.root:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--mantine-shadow-sm);
border-color: var(--mantine-primary-color-filled);
}
.root:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.root[data-selected] {
border-color: var(--mantine-primary-color-filled);
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
}
.root:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.header {
border-bottom: 1px solid var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
margin: calc(var(--mantine-spacing-sm) * -1);
margin-bottom: 0;
padding-left: var(--mantine-spacing-sm);
padding-right: var(--mantine-spacing-sm);
}
.body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-xs);
margin-bottom: var(--mantine-spacing-xs);
min-height: 0;
}
.footer {
border-top: 1px dashed var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
margin: calc(var(--mantine-spacing-sm) * -1);
margin-top: auto;
padding-left: var(--mantine-spacing-sm);
padding-right: var(--mantine-spacing-sm);
min-height: 40px;
display: flex;
align-items: center;
}
@@ -0,0 +1,7 @@
declare const classes: {
readonly root: string;
readonly header: string;
readonly body: string;
readonly footer: string;
};
export default classes;
@@ -0,0 +1,370 @@
// @vitest-environment jsdom
import { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { Badge, MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SelectableCard } from "./selectable-card";
import classes from "./selectable-card.module.css";
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(async () => root.unmount());
host.remove();
});
describe("SelectableCard", () => {
describe("rendering slots", () => {
it("renders as a native button with default button type and accessible slots", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
aria-label="Sonarr Card"
icon={<span data-testid="card-icon">Icon</span>}
title="Sonarr"
topRight={<Badge data-testid="card-topright">Media</Badge>}
description="Manage TV series downloads"
footerLeft={<span data-testid="card-footer-left">Left Meta</span>}
footerRight={<span data-testid="card-footer-right">Right Meta</span>}
onClick={onClick}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button).not.toBeNull();
expect(button?.type).toBe("button");
expect(button?.getAttribute("aria-label")).toBe("Sonarr Card");
expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull();
expect(host.textContent).toContain("Sonarr");
expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull();
expect(host.textContent).toContain("Manage TV series downloads");
expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull();
expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull();
await act(async () => button?.click());
expect(onClick).toHaveBeenCalledOnce();
});
it("renders only header when only header slots (icon, title, topRight) are provided", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
icon={<span data-testid="only-icon">I</span>}
title="Only Header"
topRight={<span data-testid="only-topright">TR</span>}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.querySelector(`.${classes.header}`)).not.toBeNull();
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
expect(host.querySelector("[data-testid='only-icon']")).not.toBeNull();
expect(host.querySelector("[data-testid='only-topright']")).not.toBeNull();
});
it("renders footer when only footerLeft or only footerRight is provided", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" footerLeft={<span data-testid="left-only">Left</span>} />
</MantineProvider>,
),
);
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
expect(host.querySelector("[data-testid='left-only']")).not.toBeNull();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" footerRight={<span data-testid="right-only">Right</span>} />
</MantineProvider>,
),
);
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
expect(host.querySelector("[data-testid='right-only']")).not.toBeNull();
});
it("applies Styles API classes for root, header, body, and footer", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Plex"
description="Media Server"
footerLeft={<span>Footer</span>}
classNames={{
root: "custom-root",
header: "custom-header",
body: "custom-body",
footer: "custom-footer",
}}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.classList.contains(classes.root)).toBe(true);
expect(button?.classList.contains("custom-root")).toBe(true);
const header = button?.querySelector(`.${classes.header}`);
expect(header).not.toBeNull();
expect(header?.classList.contains("custom-header")).toBe(true);
const body = button?.querySelector(`.${classes.body}`);
expect(body).not.toBeNull();
expect(body?.classList.contains("custom-body")).toBe(true);
const footer = button?.querySelector(`.${classes.footer}`);
expect(footer).not.toBeNull();
expect(footer?.classList.contains("custom-footer")).toBe(true);
});
it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Very Long Title Name That Should Not Wrap"
topRight={<Badge>Category Badge</Badge>}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
const header = button?.querySelector(`.${classes.header}`);
expect(header).not.toBeNull();
const titleText = header?.querySelector("p, span, div");
expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title={<span data-testid="custom-title-node">Custom Title Element</span>}
topRight={<span data-testid="custom-top-right">Top Right</span>}
/>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull();
expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull();
});
it("renders custom children in the body slot over description", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Test" description="This should not appear">
<div data-testid="custom-body-children">Custom Child Content</div>
</SelectableCard>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull();
expect(host.textContent).toContain("Custom Child Content");
expect(host.textContent).not.toContain("This should not appear");
});
it("renders an empty description without fallback copy", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="No Desc Item" description="" />
</MantineProvider>,
),
);
expect(host.textContent).toContain("No Desc Item");
expect(host.textContent).not.toContain("No description provided");
});
it("omits header, body, or footer when corresponding props are omitted", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.querySelector(`.${classes.header}`)).toBeNull();
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
});
});
describe("states", () => {
it("handles selected state via data-selected attribute", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Selected Item" selected />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.getAttribute("data-selected")).toBe("true");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Unselected Item" selected={false} />
</MantineProvider>,
),
);
expect(button?.hasAttribute("data-selected")).toBe(false);
});
it("handles disabled state and prevents click handlers", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Disabled Item" disabled onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.disabled).toBe(true);
await act(async () => button?.click());
expect(onClick).not.toHaveBeenCalled();
});
it("handles loading state by disabling the button and showing LoadingOverlay", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Loading Item" loading onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.disabled).toBe(true);
await act(async () => button?.click());
expect(onClick).not.toHaveBeenCalled();
});
});
describe("interactions and accessibility", () => {
it("supports focus and click activation", async () => {
const onClick = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Interactive Card" onClick={onClick} />
</MantineProvider>,
),
);
const button = host.querySelector("button");
button?.focus();
expect(document.activeElement).toBe(button);
await act(async () => {
button?.click();
});
expect(onClick).toHaveBeenCalledOnce();
});
it("forwards ref to the underlying HTMLButtonElement", async () => {
const ref = createRef<HTMLButtonElement>();
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard ref={ref} title="Ref Card" />
</MantineProvider>,
),
);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
expect(ref.current?.tagName).toBe("BUTTON");
});
it("resolves radius CSS variables via varsResolver", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Radius Card" radius="lg" />
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)");
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard title="Radius Card Sm" radius="sm" />
</MantineProvider>,
),
);
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-sm)");
});
it("passes arbitrary HTML attributes down to the button element", async () => {
await act(async () =>
root.render(
<MantineProvider>
<SelectableCard
title="Custom Props Card"
data-testid="my-selectable-card"
data-custom="custom-value"
tabIndex={0}
/>
</MantineProvider>,
),
);
const button = host.querySelector("button");
expect(button?.getAttribute("data-testid")).toBe("my-selectable-card");
expect(button?.getAttribute("data-custom")).toBe("custom-value");
expect(button?.tabIndex).toBe(0);
});
});
});
@@ -0,0 +1,177 @@
"use client";
import type { ReactNode } from "react";
import {
Box,
createVarsResolver,
factory,
Group,
LoadingOverlay,
Text,
Tooltip,
useProps,
useStyles,
} from "@mantine/core";
import type { BoxProps, ElementProps, Factory, MantineRadius, StylesApiProps } from "@mantine/core";
import classes from "./selectable-card.module.css";
export type SelectableCardStylesNames = "root" | "header" | "body" | "footer";
export type SelectableCardCssVariables = {
root: "--selectable-card-radius";
};
export interface SelectableCardProps
extends BoxProps, StylesApiProps<SelectableCardFactory>, Omit<ElementProps<"button">, "title"> {
icon?: ReactNode;
title?: ReactNode;
topRight?: ReactNode;
description?: string | null;
footerLeft?: ReactNode;
footerRight?: ReactNode;
selected?: boolean;
loading?: boolean;
disabled?: boolean;
radius?: MantineRadius;
children?: ReactNode;
}
export type SelectableCardFactory = Factory<{
props: SelectableCardProps;
ref: HTMLButtonElement;
stylesNames: SelectableCardStylesNames;
vars: SelectableCardCssVariables;
}>;
const defaultProps: Partial<SelectableCardProps> = {
type: "button",
};
const varsResolver = createVarsResolver<SelectableCardFactory>((_theme, { radius }) => ({
root: {
"--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined,
},
}));
export const SelectableCard = factory<SelectableCardFactory>((_props) => {
const props = useProps("SelectableCard", defaultProps, _props);
const {
ref,
classNames,
className,
style,
styles,
unstyled,
vars,
attributes,
icon,
title,
topRight,
description,
footerLeft,
footerRight,
selected,
loading = false,
disabled = false,
children,
type = "button",
...others
} = props;
const getStyles = useStyles<SelectableCardFactory>({
name: "SelectableCard",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Box
component="button"
{...others}
type={type}
ref={ref}
disabled={disabled || loading}
data-selected={selected || undefined}
aria-pressed={selected !== undefined ? selected : undefined}
{...getStyles("root")}
>
<LoadingOverlay visible={loading} loaderProps={{ size: "sm" }} />
{/* Header Section: Inset background with Title that never elides before the category */}
{(icon || title || topRight) && (
<Box {...getStyles("header")}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
gap="xs"
style={{ width: "100%", overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
{icon}
{typeof title === "string" ? (
<Text
fw={700}
size="md"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</Text>
) : (
title
)}
</Group>
{topRight && (
<Box
style={{
minWidth: 0,
flexShrink: 0,
}}
>
{topRight}
</Box>
)}
</Group>
</Box>
)}
{/* Body: Custom Children or Default Description */}
{children ? (
<Box {...getStyles("body")}>{children}</Box>
) : description !== undefined ? (
<Box {...getStyles("body")}>
<Tooltip label={description} multiline w={260} disabled={!description}>
<Text size="sm" c="dimmed" lineClamp={2} lh={1.35}>
{description}
</Text>
</Tooltip>
</Box>
) : null}
{/* Footer Section: Inset Dashed Bottom Meta */}
{(footerLeft || footerRight) && (
<Box {...getStyles("footer")}>
<Group justify="space-between" align="center" wrap="nowrap" gap="xs" style={{ width: "100%" }}>
<Box style={{ minWidth: 0, flexShrink: 1 }}>{footerLeft}</Box>
<Box style={{ minWidth: 0, flexShrink: 0 }}>{footerRight}</Box>
</Group>
</Box>
)}
</Box>
);
});
SelectableCard.displayName = "@homarr/ui/SelectableCard";
SelectableCard.classes = classes;