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:
1 parent
bd4f6a92c6
commit
f3014fe271
60 files changed
+3791
-1839
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user