feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

+3 -3
View File
@@ -3,17 +3,17 @@
import type { BadgeProps } from "@mantine/core";
import { Badge } from "@mantine/core";
import { useTranslations } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
interface BetaBadgeProps {
size: BadgeProps["size"];
}
export const BetaBadge = ({ size }: BetaBadgeProps) => {
const t = useTranslations();
const t = useI18n("common");
return (
<Badge size={size} color="green" variant="outline">
{t("common.beta")}
{t("beta")}
</Badge>
);
};
@@ -0,0 +1,18 @@
"use client";
import { ColorInput, useMantineTheme } from "@mantine/core";
import type { ColorInputProps } from "@mantine/core";
interface BoardColorInputProps extends ColorInputProps {
defaultColor?: string;
}
export const BoardColorInput = ({ defaultColor, ...props }: BoardColorInputProps) => {
const theme = useMantineTheme();
const themeSwatches = Object.values(theme.colors).map((color) => color[6]);
const swatches = defaultColor
? [defaultColor, ...themeSwatches.filter((color) => color.toLowerCase() !== defaultColor.toLowerCase())]
: themeSwatches;
return <ColorInput {...props} format="hex" swatches={swatches} />;
};
@@ -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;
};
@@ -0,0 +1,86 @@
"use client";
import React from "react";
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
const schemeIcons: Record<ColorScheme, React.ElementType> = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
interface ColorSchemeComboboxProps {
label?: string;
value: ColorScheme;
onChange: (value: ColorScheme) => void;
width?: string;
}
export const ColorSchemeCombobox = ({ label, value, onChange, width }: ColorSchemeComboboxProps) => {
const tOptions = useI18n("common.colorScheme.options");
const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption() });
return (
<Combobox
store={combobox}
onOptionSubmit={(nextValue) => {
onChange(nextValue as ColorScheme);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
label={label}
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
onClick={() => combobox.toggleDropdown()}
variant="filled"
w={width}
>
<OptionItem scheme={value} currentScheme={value} tOptions={tOptions} />
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{colorSchemes.map((scheme) => (
<Combobox.Option value={scheme} key={scheme}>
<OptionItem scheme={scheme} currentScheme={value} tOptions={tOptions} showCheck />
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
const OptionItem = ({
scheme,
currentScheme,
tOptions,
showCheck,
}: {
scheme: ColorScheme;
currentScheme: ColorScheme;
tOptions: (key: ColorScheme) => string;
showCheck?: boolean;
}) => {
const Icon = schemeIcons[scheme];
return (
<Group wrap="nowrap" justify="space-between">
<Group wrap="nowrap">
<Icon size={16} stroke={1.5} />
<Text>{tOptions(scheme)}</Text>
</Group>
{showCheck && scheme === currentScheme && <IconCheck color="currentColor" size={16} />}
</Group>
);
};
@@ -0,0 +1,66 @@
"use client";
import { Box, SimpleGrid, Stack, Text } from "@mantine/core";
import { SelectableCard } from "./selectable-card";
export const cornerStyleValues = ["xs", "sm", "md", "lg", "xl"] as const;
export type CornerStyle = (typeof cornerStyleValues)[number];
interface CornerStylePickerProps {
label: string;
description?: string;
value: CornerStyle;
labels: Record<CornerStyle, string>;
onChange: (value: CornerStyle) => void;
compact?: boolean;
}
export const CornerStylePicker = ({
label,
description,
value,
labels,
onChange,
compact = false,
}: CornerStylePickerProps) => {
const previewHeight = compact ? 24 : 34;
return (
<Box component="fieldset" m={0} p={0} style={{ border: 0, minWidth: 0 }}>
<Text component="legend" fw={500} size="sm" p={0}>
{label}
</Text>
<Stack gap="xs" mt={2}>
{description ? (
<Text size="sm" c="dimmed">
{description}
</Text>
) : null}
<SimpleGrid cols={{ base: 3, xs: 5 }} spacing="xs">
{cornerStyleValues.map((cornerStyle) => (
<SelectableCard
key={cornerStyle}
selected={value === cornerStyle}
onClick={() => onChange(cornerStyle)}
radius={cornerStyle}
p="xs"
aria-label={labels[cornerStyle]}
style={compact ? { minHeight: "auto", height: "auto" } : undefined}
>
<Box
h={previewHeight}
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: `var(--mantine-radius-${cornerStyle})` }}
/>
<Text size="xs" ta="center" mt="xs">
{labels[cornerStyle]}
</Text>
</SelectableCard>
))}
</SimpleGrid>
</Stack>
</Box>
);
};
@@ -0,0 +1,17 @@
.root {
position: fixed;
inset-inline: var(--mantine-spacing-md);
bottom: var(--floating-tip-bottom);
z-index: var(--floating-tip-z-index);
display: flex;
justify-content: center;
pointer-events: none;
}
.alert {
width: fit-content;
max-width: min(var(--floating-tip-max-width), calc(100vw - 2 * var(--mantine-spacing-md)));
pointer-events: auto;
box-shadow: var(--mantine-shadow-xl);
backdrop-filter: blur(12px);
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly root: string;
readonly alert: string;
};
export default classes;
@@ -0,0 +1,96 @@
// @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 { FloatingTip } from "./floating-tip";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.useFakeTimers();
window.localStorage.clear();
(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();
document.body.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
vi.useRealTimers();
});
describe("FloatingTip", () => {
it("shows after the configured delay and auto-dismisses", async () => {
await act(async () =>
root.render(
<MantineProvider>
<FloatingTip opened showDelay={2_000} dismissAfter={3_000} transitionDuration={0}>
Select several apps
</FloatingTip>
</MantineProvider>,
),
);
expect(document.body.textContent).not.toContain("Select several apps");
await act(async () => vi.advanceTimersByTime(2_000));
expect(document.body.textContent).toContain("Select several apps");
await act(async () => vi.advanceTimersByTime(3_000));
expect(document.body.textContent).not.toContain("Select several apps");
});
it("provides an accessible close button and remembers dismissal", async () => {
const onDismiss = vi.fn();
const renderTip = (opened: boolean) =>
root.render(
<MantineProvider>
<FloatingTip
opened={opened}
showDelay={0}
transitionDuration={0}
closable
closeButtonLabel="Dismiss tip"
rememberDismissal
storageKey="selection"
onDismiss={onDismiss}
>
Select several apps
</FloatingTip>
</MantineProvider>,
);
await act(async () => renderTip(true));
await act(async () => vi.advanceTimersByTime(0));
const closeButton = document.body.querySelector<HTMLButtonElement>('button[aria-label="Dismiss tip"]');
expect(closeButton).not.toBeNull();
await act(async () => closeButton?.click());
expect(onDismiss).toHaveBeenCalledOnce();
expect(window.localStorage.getItem("homarr:floating-tip:selection")).toBe("dismissed");
await act(async () => renderTip(false));
await act(async () => renderTip(true));
await act(async () => vi.advanceTimersByTime(0));
expect(document.body.textContent).not.toContain("Select several apps");
});
});
@@ -0,0 +1,205 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Box, createVarsResolver, factory, Portal, rem, Transition, useProps, useStyles } from "@mantine/core";
import type { AlertProps, BoxProps, ElementProps, Factory, MantineTransition, StylesApiProps } from "@mantine/core";
import classes from "./floating-tip.module.css";
export type FloatingTipStylesNames = "root" | "alert";
export type FloatingTipCssVariables = {
root: "--floating-tip-bottom" | "--floating-tip-max-width" | "--floating-tip-z-index";
};
interface FloatingTipBaseProps
extends BoxProps, StylesApiProps<FloatingTipFactory>, Omit<ElementProps<"div">, "title"> {
/** Controls whether this tip cycle should run. */
opened: boolean;
/** Delay before the tip appears, in milliseconds. */
showDelay?: number;
/** Time the tip remains visible, in milliseconds. Set to `null` to keep it open. */
dismissAfter?: number | null;
/** Enter and exit animation duration, in milliseconds. */
transitionDuration?: number;
/** Mantine transition used to show and hide the tip. */
transition?: MantineTransition;
/** Distance from the bottom edge of the viewport. */
bottomOffset?: number | string;
/** Maximum width of the alert. */
maxWidth?: number | string;
/** Stack order of the floating tip. */
zIndex?: number;
/** Props forwarded to the underlying Mantine Alert. */
alertProps?: Omit<
AlertProps,
"children" | "classNames" | "closeButtonLabel" | "onClose" | "styles" | "withCloseButton"
>;
/** Called when the user closes the tip. */
onDismiss?: () => void;
}
type FloatingTipCloseProps =
| { closable: true; closeButtonLabel: string }
| { closable?: false; closeButtonLabel?: never };
type FloatingTipPersistenceProps =
| { rememberDismissal: true; storageKey: string }
| { rememberDismissal?: false; storageKey?: never };
export type FloatingTipProps = FloatingTipBaseProps & FloatingTipCloseProps & FloatingTipPersistenceProps;
export type FloatingTipFactory = Factory<{
props: FloatingTipProps;
ref: HTMLDivElement;
stylesNames: FloatingTipStylesNames;
vars: FloatingTipCssVariables;
}>;
const defaultProps = {
showDelay: 0,
dismissAfter: null,
transitionDuration: 200,
transition: "slide-up",
bottomOffset: 24,
maxWidth: 520,
zIndex: 1000,
closable: false,
rememberDismissal: false,
} satisfies Partial<FloatingTipProps>;
const toCssSize = (value: number | string | undefined) => (typeof value === "number" ? rem(value) : value);
const varsResolver = createVarsResolver<FloatingTipFactory>((_theme, { bottomOffset, maxWidth, zIndex }) => ({
root: {
"--floating-tip-bottom": toCssSize(bottomOffset),
"--floating-tip-max-width": toCssSize(maxWidth),
"--floating-tip-z-index": zIndex?.toString(),
},
}));
const getDismissalStorageKey = (storageKey: string) => `homarr:floating-tip:${storageKey}`;
export const FloatingTip = factory<FloatingTipFactory>((_props) => {
const props = useProps("FloatingTip", defaultProps, _props);
const {
ref,
classNames,
className,
style,
styles,
unstyled,
vars,
attributes,
opened,
showDelay,
dismissAfter,
transitionDuration,
transition,
bottomOffset: _bottomOffset,
maxWidth: _maxWidth,
zIndex: _zIndex,
closable,
closeButtonLabel,
rememberDismissal,
storageKey,
alertProps,
onDismiss,
children,
...others
} = props;
const [visible, setVisible] = useState(false);
const showTimerRef = useRef<number | null>(null);
const dismissTimerRef = useRef<number | null>(null);
const clearTimers = useCallback(() => {
if (showTimerRef.current !== null) window.clearTimeout(showTimerRef.current);
if (dismissTimerRef.current !== null) window.clearTimeout(dismissTimerRef.current);
showTimerRef.current = null;
dismissTimerRef.current = null;
}, []);
useEffect(() => {
clearTimers();
setVisible(false);
if (!opened) return;
if (rememberDismissal) {
try {
if (window.localStorage.getItem(getDismissalStorageKey(storageKey)) === "dismissed") return;
} catch {
// Storage can be unavailable in private browsing or hardened environments.
}
}
showTimerRef.current = window.setTimeout(() => {
setVisible(true);
showTimerRef.current = null;
if (dismissAfter !== null) {
dismissTimerRef.current = window.setTimeout(() => {
setVisible(false);
dismissTimerRef.current = null;
}, dismissAfter);
}
}, showDelay);
return clearTimers;
}, [clearTimers, dismissAfter, opened, rememberDismissal, showDelay, storageKey]);
const handleDismiss = () => {
clearTimers();
setVisible(false);
if (rememberDismissal) {
try {
window.localStorage.setItem(getDismissalStorageKey(storageKey), "dismissed");
} catch {
// The tip still closes for this render even when persistence is unavailable.
}
}
onDismiss?.();
};
const getStyles = useStyles<FloatingTipFactory>({
name: "FloatingTip",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Portal>
<Transition mounted={visible} transition={transition} duration={transitionDuration} timingFunction="ease">
{(transitionStyle) => {
const rootStyles = getStyles("root");
return (
<Box ref={ref} {...others} {...rootStyles} style={[rootStyles.style, transitionStyle]}>
<Alert
{...alertProps}
{...getStyles("alert")}
withCloseButton={closable}
closeButtonLabel={closeButtonLabel}
onClose={handleDismiss}
>
{children}
</Alert>
</Box>
);
}}
</Transition>
</Portal>
);
});
FloatingTip.displayName = "@homarr/ui/FloatingTip";
FloatingTip.classes = classes;
@@ -0,0 +1,7 @@
export { FloatingTip } from "./floating-tip";
export type {
FloatingTipCssVariables,
FloatingTipFactory,
FloatingTipProps,
FloatingTipStylesNames,
} from "./floating-tip";
+16
View File
@@ -1,5 +1,6 @@
export * from "./count-badge";
export { OverflowBadge } from "./overflow-badge";
export { InlineConfirmActionIcon, InlineConfirmButton, InlineConfirmMenuItem } from "./inline-confirm-action";
export { SearchInput } from "./search-input";
export * from "./select-with-description";
export * from "./select-with-description-and-badge";
@@ -12,9 +13,24 @@ 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 { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
export * from "./floating-tip";
export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon";
export { LanguageCombobox } from "./language-combobox";
export { ColorSchemeCombobox } from "./color-scheme-combobox";
export { BoardColorInput } from "./board-color-input";
export { CornerStylePicker, cornerStyleValues } from "./corner-style-picker";
export type { CornerStyle } from "./corner-style-picker";
export { IntegrationMarquee } from "./integration-marquee";
export { Link } from "./link";
export {
SelectGridLayout,
@@ -0,0 +1,245 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode, TouchEventHandler } from "react";
import { ActionIcon, Button, Menu } from "@mantine/core";
import type { ActionIconProps, ButtonProps, ElementProps, MenuItemProps } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
const confirmationTimeout = 4_000;
const visuallyHidden = {
border: 0,
clip: "rect(0 0 0 0)",
height: 1,
margin: -1,
overflow: "hidden",
padding: 0,
position: "absolute" as const,
whiteSpace: "nowrap" as const,
width: 1,
};
interface InlineConfirmActionOptions {
confirmLabel: ReactNode;
onConfirm: () => void | Promise<void>;
disabled?: boolean;
pending?: boolean;
timeout?: number;
}
const useInlineConfirmAction = ({
confirmLabel,
onConfirm,
disabled = false,
pending = false,
timeout = confirmationTimeout,
}: InlineConfirmActionOptions) => {
const [confirming, setConfirming] = useState(false);
const [submitting, setSubmitting] = useState(false);
const actionRef = useRef<HTMLButtonElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reset = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setConfirming(false);
}, []);
useEffect(() => {
if (!confirming) return;
const handlePointerDown = (event: PointerEvent) => {
if (event.target instanceof Node && !actionRef.current?.contains(event.target)) reset();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") reset();
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown, true);
timeoutRef.current = setTimeout(reset, timeout);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
};
}, [confirming, reset, timeout]);
useEffect(() => {
if (disabled || pending) reset();
}, [disabled, pending, reset]);
const handleClick = useCallback(async () => {
if (disabled || pending || submitting) return;
if (!confirming) {
setConfirming(true);
return;
}
setSubmitting(true);
try {
await onConfirm();
} finally {
setSubmitting(false);
reset();
}
}, [confirming, disabled, onConfirm, pending, reset, submitting]);
const handleBlur = useCallback(() => reset(), [reset]);
return {
actionRef,
confirming,
confirmLabel,
handleBlur,
handleClick,
reset,
submitting,
};
};
type InlineConfirmButtonProps = Omit<ButtonProps, "children" | "disabled" | "loading" | "onClick"> &
ElementProps<"button", "color" | "disabled" | "onClick"> &
InlineConfirmActionOptions & {
children: ReactNode;
loading?: boolean;
};
export const InlineConfirmButton = ({
children,
confirmLabel,
onConfirm,
disabled,
pending,
loading,
timeout,
variant,
...props
}: InlineConfirmButtonProps) => {
const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout });
const label = action.confirming ? confirmLabel : children;
let nextVariant = variant;
if (action.confirming) nextVariant = "filled";
return (
<>
<Button
{...props}
ref={action.actionRef}
variant={nextVariant}
disabled={disabled || pending || action.submitting}
loading={loading || action.submitting}
onClick={() => void action.handleClick().catch(() => undefined)}
onBlur={action.handleBlur}
aria-label={typeof label === "string" ? label : undefined}
data-confirming={action.confirming || undefined}
>
{label}
</Button>
<span aria-live="polite" style={visuallyHidden}>
{action.confirming ? confirmLabel : null}
</span>
</>
);
};
type InlineConfirmActionIconProps = Omit<ActionIconProps, "children" | "disabled" | "loading" | "onClick"> &
InlineConfirmActionOptions & {
"aria-label"?: string;
children: ReactNode;
confirmationAriaLabel?: string;
confirmationChildren?: ReactNode;
loading?: boolean;
title?: string;
};
export const InlineConfirmActionIcon = ({
children,
confirmLabel,
confirmationAriaLabel,
confirmationChildren,
onConfirm,
disabled,
pending,
loading,
timeout,
variant,
"aria-label": ariaLabel,
...props
}: InlineConfirmActionIconProps) => {
const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout });
const nextAriaLabel = action.confirming
? (confirmationAriaLabel ?? (typeof confirmLabel === "string" ? confirmLabel : ariaLabel))
: ariaLabel;
let nextVariant = variant;
if (action.confirming) nextVariant = "filled";
let nextChildren = children;
if (action.confirming) nextChildren = confirmationChildren ?? <IconCheck size="1rem" />;
return (
<>
<ActionIcon
{...props}
ref={action.actionRef}
variant={nextVariant}
disabled={disabled || pending || action.submitting}
loading={loading || action.submitting}
onClick={() => void action.handleClick().catch(() => undefined)}
onBlur={action.handleBlur}
aria-label={nextAriaLabel}
data-confirming={action.confirming || undefined}
>
{nextChildren}
</ActionIcon>
<span aria-live="polite" style={visuallyHidden}>
{action.confirming ? confirmLabel : null}
</span>
</>
);
};
type InlineConfirmMenuItemProps = Omit<MenuItemProps, "children" | "closeMenuOnClick" | "disabled" | "onClick"> &
InlineConfirmActionOptions & {
children: ReactNode;
onTouchStart?: TouchEventHandler<HTMLButtonElement>;
};
export const InlineConfirmMenuItem = ({
children,
confirmLabel,
onConfirm,
disabled,
pending,
timeout,
onTouchStart,
...props
}: InlineConfirmMenuItemProps) => {
const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending, timeout });
const label = action.confirming ? confirmLabel : children;
return (
<>
<Menu.Item
{...props}
ref={action.actionRef}
disabled={disabled || pending || action.submitting}
closeMenuOnClick={action.confirming}
onClick={() => void action.handleClick().catch(() => undefined)}
onTouchStart={(event) => {
event.stopPropagation();
onTouchStart?.(event);
}}
aria-label={typeof label === "string" ? label : undefined}
data-confirming={action.confirming || undefined}
>
{label}
</Menu.Item>
<span aria-live="polite" style={visuallyHidden}>
{action.confirming ? confirmLabel : null}
</span>
</>
);
};
@@ -0,0 +1,9 @@
.marquee {
mask-image: linear-gradient(to bottom, transparent, black 14%, black 86%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.content {
animation: none;
}
}
@@ -0,0 +1,2 @@
declare const classes: Record<string, string>;
export default classes;
@@ -0,0 +1,44 @@
import { Image, Marquee, SimpleGrid } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import classes from "./integration-marquee.module.css";
const iconGroups = splitToNChunks(
Object.values(integrationDefs)
.filter((integration) => integration.name !== "Mock")
.map((integration) => integration.iconUrl),
3,
);
const animationDuration = iconGroups.flat().length * 2;
export const IntegrationMarquee = ({ className }: { className?: string }) => (
<SimpleGrid
className={className}
cols={3}
spacing="xl"
h="calc(100% + 6rem)"
my="-3rem"
style={{ transform: "rotate(10deg)" }}
aria-hidden
>
{iconGroups.map((icons, columnIndex) => (
<Marquee
key={columnIndex}
orientation="vertical"
repeat={2}
duration={(animationDuration - columnIndex * 3) * 1000}
gap="xl"
fadeEdges={false}
h="100%"
className={classes.marquee}
classNames={{ content: classes.content }}
>
{icons.map((icon) => (
<Image key={`${icon}-${columnIndex}`} src={icon} alt="" fit="contain" w={52} h={52} />
))}
</Marquee>
))}
</SimpleGrid>
);
@@ -0,0 +1,92 @@
"use client";
import React from "react";
import { Combobox, Group, InputBase, Loader, ScrollArea, Text, useCombobox } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { SupportedLanguage } from "@homarr/translation";
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
import { LanguageIcon } from "./language-icon";
interface LanguageComboboxProps {
label?: string;
value: SupportedLanguage;
onChange: (value: SupportedLanguage) => void;
isPending?: boolean;
width?: string;
withinPortal?: boolean;
}
export const LanguageCombobox = ({
label,
value,
onChange,
isPending,
width,
withinPortal = false,
}: LanguageComboboxProps) => {
const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption() });
return (
<Combobox
store={combobox}
withinPortal={withinPortal}
onOptionSubmit={(nextValue) => {
onChange(nextValue as SupportedLanguage);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
label={label}
leftSection={isPending ? <Loader size={16} /> : null}
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
onClick={() => combobox.toggleDropdown()}
variant="filled"
w={width}
>
<OptionItem currentLocale={value} localeKey={value} />
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<ScrollArea h={300}>
<Combobox.Options>
{supportedLanguages.map((locale) => (
<Combobox.Option value={locale} key={locale}>
<OptionItem currentLocale={value} localeKey={locale} showCheck />
</Combobox.Option>
))}
</Combobox.Options>
</ScrollArea>
</Combobox.Dropdown>
</Combobox>
);
};
const OptionItem = ({
currentLocale,
localeKey,
showCheck,
}: {
currentLocale: SupportedLanguage;
localeKey: SupportedLanguage;
showCheck?: boolean;
}) => (
<Group wrap="nowrap" justify="space-between">
<Group wrap="nowrap">
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
<Group wrap="nowrap" gap="xs">
<Text>{localeConfigurations[localeKey].name}</Text>
<Text size="xs" c="dimmed" inherit>
({localeConfigurations[localeKey].translatedName})
</Text>
</Group>
</Group>
{showCheck && localeKey === currentLocale && <IconCheck color="currentColor" size={16} />}
</Group>
);
+28 -16
View File
@@ -19,23 +19,35 @@ export function OverflowBadge({
radius: "sm",
...props,
};
return (
<Popover width="content" shadow="md" disabled={disablePopover}>
const hasOverflow = data.length > overflowCount;
const badges = data.slice(0, overflowCount).map((item) => (
<Badge key={item} px="xs" {...badgeProps}>
{item}
</Badge>
));
const overflowTrigger = hasOverflow ? (
<UnstyledButton display="flex">
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
+{data.length - overflowCount}
</Badge>
</UnstyledButton>
) : null;
if (disablePopover || !hasOverflow) {
return (
<Group gap={groupGap}>
{data.slice(0, overflowCount).map((item) => (
<Badge key={item} px="xs" {...badgeProps}>
{item}
</Badge>
))}
{data.length > overflowCount && (
<Popover.Target>
<UnstyledButton display="flex">
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
+{data.length - overflowCount}
</Badge>
</UnstyledButton>
</Popover.Target>
)}
{badges}
{overflowTrigger}
</Group>
);
}
return (
<Popover width="content" shadow="md">
<Group gap={groupGap}>
{badges}
<Popover.Target>{overflowTrigger}</Popover.Target>
</Group>
<Popover.Dropdown>
<Stack>
@@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react";
import { useState } from "react";
import { Popover, Progress, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { passwordRequirements } from "@homarr/validation/user";
import { PasswordRequirement } from "./password-requirement";
@@ -14,7 +14,7 @@ const strengthColorMap = [
] as const;
export const PasswordRequirementsPopover = ({ password, children }: PropsWithChildren<{ password: string }>) => {
const tPassword = useScopedI18n("user.field.password");
const tPassword = useI18n("user.field.password");
const requirements = useRequirements();
const strength = useStrength(password);
const [popoverOpened, setPopoverOpened] = useState(false);
@@ -46,7 +46,7 @@ export const PasswordRequirementsPopover = ({ password, children }: PropsWithChi
};
const useRequirements = () => {
const t = useScopedI18n("user.field.password.suggestion");
const t = useI18n("user.field.password.suggestion");
return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) }));
};
+40 -9
View File
@@ -1,46 +1,77 @@
"use client";
import type { ChangeEvent } from "react";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Loader, TextInput } from "@mantine/core";
import { Input, Loader, TextInput } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface SearchInputProps {
defaultValue?: string;
placeholder: string;
ariaLabel: string;
flexExpand?: boolean;
}
export const SearchInput = ({ placeholder, defaultValue, flexExpand = false }: SearchInputProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
export const SearchInput = ({ placeholder, ariaLabel, defaultValue, flexExpand = false }: SearchInputProps) => {
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const t = useI18n("common.action");
const [loading, setLoading] = useState(false);
const handleSearchDebounced = useDebouncedCallback((value: string) => {
const [value, setValue] = useState(defaultValue ?? "");
useEffect(() => {
setValue(defaultValue ?? "");
setLoading(false);
}, [defaultValue]);
const handleSearchDebounced = useDebouncedCallback((nextValue: string) => {
const params = new URLSearchParams(searchParams);
params.set("search", value.toString());
const normalizedValue = nextValue.trim();
if (normalizedValue.length === 0) {
params.delete("search");
} else {
params.set("search", normalizedValue);
}
if (params.has("page")) params.set("page", "1"); // Reset page to 1
replace(`${pathName}?${params.toString()}`);
const query = params.toString();
router.replace(query ? `${pathName}?${query}` : pathName);
setLoading(false);
}, 250);
const handleSearch = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setValue(event.currentTarget.value);
setLoading(true);
handleSearchDebounced(event.currentTarget.value);
},
[setLoading, handleSearchDebounced],
);
const clearSearch = useCallback(() => {
setValue("");
setLoading(true);
handleSearchDebounced("");
handleSearchDebounced.flush();
}, [handleSearchDebounced]);
return (
<TextInput
leftSection={<LeftSection loading={loading} />}
defaultValue={defaultValue}
rightSection={value ? <Input.ClearButton aria-label={t("clearSearch")} onClick={clearSearch} /> : undefined}
rightSectionPointerEvents="all"
value={value}
onChange={handleSearch}
onKeyDown={(event) => {
if (event.key !== "Escape" || !value) return;
event.preventDefault();
clearSearch();
}}
placeholder={placeholder}
aria-label={ariaLabel}
style={{ flex: flexExpand ? "1" : undefined }}
/>
);
@@ -1,8 +1,8 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Combobox, ComboboxClearButton, Input, InputBase, useCombobox } from "@mantine/core";
import { Combobox, ComboboxClearButton, Input, InputBase, ScrollArea, useCombobox } from "@mantine/core";
import { useUncontrolled } from "@mantine/hooks";
interface BaseSelectItem {
@@ -21,10 +21,13 @@ export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
w?: string;
withinPortal?: boolean;
searchable?: boolean;
searchPlaceholder?: string;
nothingFoundMessage?: string;
}
type Props<TSelectItem extends BaseSelectItem> = SelectWithCustomItemsProps<TSelectItem> & {
SelectOption: React.ComponentType<TSelectItem>;
SelectOption: React.ComponentType<TSelectItem & { checked?: boolean; selected?: boolean }>;
};
export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
@@ -37,63 +40,103 @@ export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
w,
clearable,
withinPortal = false,
searchable = false,
searchPlaceholder,
nothingFoundMessage,
...props
}: Props<TSelectItem>) => {
const [search, setSearch] = useState("");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
},
onDropdownOpen: () => {
if (searchable) combobox.focusSearchInput();
},
});
const [_value, setValue] = useUncontrolled({
const [selectedValue, setValue] = useUncontrolled({
value,
defaultValue,
finalValue: null,
onChange,
});
const selectedOption = useMemo(() => data.find((item) => item.value === _value), [data, _value]);
const selectedOption = useMemo(() => data.find((item) => item.value === selectedValue), [data, selectedValue]);
const options = data.map((item) => (
<Combobox.Option value={item.value} key={item.value}>
<SelectOption {...item} />
const normalizedSearch = search.toLocaleLowerCase().trim();
const filteredData = data.filter((item) => !searchable || item.label.toLocaleLowerCase().includes(normalizedSearch));
const options = filteredData.map((item) => (
<Combobox.Option value={item.value} key={item.value} active={item.value === selectedValue}>
<SelectOption {...item} checked={item.value === selectedValue} />
</Combobox.Option>
));
const toggle = useCallback(() => combobox.toggleDropdown(), [combobox]);
const onOptionSubmit = useCallback(
(value: string) => {
(nextValue: string) => {
setValue(
value,
data.find((item) => item.value === value),
nextValue,
data.find((item) => item.value === nextValue),
);
combobox.closeDropdown();
if (searchable) combobox.focusTarget();
},
[setValue, data, combobox],
[setValue, data, combobox, searchable],
);
const _clearable = clearable && Boolean(_value);
const isClearable = clearable && Boolean(selectedValue);
return (
<Combobox store={combobox} withinPortal={withinPortal} onOptionSubmit={onOptionSubmit}>
<Combobox.Target>
<Combobox.Target targetType="button">
<InputBase
{...props}
component="button"
type="button"
pointer
__clearSection={<ComboboxClearButton onClear={() => setValue(null, null)} />}
__clearable={_clearable}
__clearable={isClearable}
__defaultRightSection={<Combobox.Chevron />}
onClick={toggle}
rightSectionPointerEvents={_clearable ? "all" : "none"}
rightSectionPointerEvents={isClearable ? "all" : "none"}
multiline
w={w}
>
{selectedOption ? <SelectOption {...selectedOption} /> : <Input.Placeholder>{placeholder}</Input.Placeholder>}
{selectedOption ? (
<SelectOption {...selectedOption} selected />
) : (
<Input.Placeholder>{placeholder}</Input.Placeholder>
)}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>{options}</Combobox.Options>
{searchable && (
<>
<Combobox.Search
value={search}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
}}
onBlur={() => combobox.closeDropdown()}
onKeyDown={(event) => {
if (event.key === "Escape") combobox.focusTarget();
}}
/>
<Combobox.Options>
<ScrollArea.Autosize mah="min(250px, 50dvh)" type="scroll">
{options}
{options.length === 0 && <Combobox.Empty>{nothingFoundMessage}</Combobox.Empty>}
</ScrollArea.Autosize>
</Combobox.Options>
</>
)}
{!searchable && <Combobox.Options>{options}</Combobox.Options>}
</Combobox.Dropdown>
</Combobox>
);
@@ -18,20 +18,25 @@ export const SelectWithDescriptionBadge = (props: Props) => {
return <SelectWithCustomItems<SelectItemWithDescriptionBadge> {...props} SelectOption={SelectOption} />;
};
const SelectOption = ({ label, description, badge }: SelectItemWithDescriptionBadge) => {
const SelectOption = ({
label,
description,
badge,
selected = false,
}: SelectItemWithDescriptionBadge & { selected?: boolean }) => {
return (
<Group justify="space-between">
<div>
<Text fz="sm" fw={500}>
<Group justify="space-between" wrap="nowrap" gap="xs" w="100%">
<div style={{ minWidth: 0, flex: 1 }}>
<Text fz="sm" fw={500} truncate={selected}>
{label}
</Text>
<Text fz="xs" opacity={0.6}>
<Text fz="xs" opacity={0.6} truncate={selected}>
{description}
</Text>
</div>
{badge && (
<Badge color={badge.color} variant="outline" size="sm">
<Badge color={badge.color} variant="outline" size="sm" style={{ flexShrink: 0 }}>
{badge.label}
</Badge>
)}
@@ -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,350 @@
// @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("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;
@@ -3,8 +3,9 @@
import { useCallback } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { PaginationProps } from "@mantine/core";
import { Pagination } from "@mantine/core";
import { Box, Group, Pagination } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
interface TablePaginationProps {
@@ -16,7 +17,8 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
const { replace } = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const current = Number(searchParams.get("page")) || 1;
const t = useI18n("common.pagination");
const current = parsePage(searchParams.getAll("page"));
const getItemProps = useCallback(
(page: number) => {
@@ -47,11 +49,41 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
[pathName, replace, searchParams],
);
if (total <= 0) return null;
return (
<Pagination total={total} getItemProps={getItemProps} getControlProps={getControlProps} onChange={handleChange} />
<Pagination.Root
total={total}
value={current}
layout="responsive"
w="100%"
getItemProps={getItemProps}
onChange={handleChange}
>
<Group gap={8} justify="end" wrap="nowrap">
<Pagination.Previous {...getControlProps("previous")} />
<Box className={Pagination.classes.items}>
<Pagination.Items />
</Box>
<Pagination.Label formatLabel={({ page, totalPages }) => t("pageOf", { page, totalPages })} />
<Pagination.Next {...getControlProps("next")} />
</Group>
</Pagination.Root>
);
};
const parsePage = (pageValues: string[]) => {
if (pageValues.length !== 1) return 1;
const [pageValue] = pageValues;
if (!pageValue || !/^[1-9]\d*$/u.test(pageValue)) return 1;
const page = Number(pageValue);
if (!Number.isSafeInteger(page)) return 1;
return page;
};
type ControlType = Parameters<Exclude<PaginationProps["getControlProps"], undefined>>[0];
const calculatePageFor = (type: ControlType, current: number, total: number) => {
switch (type) {
+1 -1
View File
@@ -9,7 +9,7 @@ import { useSettings } from "@homarr/settings";
export interface UserProps {
name: string | null;
image: string | null;
email: string | null;
email?: string | null;
}
interface UserAvatarProps {
+1
View File
@@ -1 +1,2 @@
export * from "./use-is-mobile";
export * from "./use-translated-mantine-react-table";
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { useMantineTheme } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
export const useIsMobile = () => {
const theme = useMantineTheme();
return useMediaQuery(`(max-width: ${theme.breakpoints.sm})`);
};
+1 -1
View File
@@ -2,7 +2,7 @@ import type { MantineProviderProps } from "@mantine/core";
import { theme } from "./theme";
export { theme } from "./theme";
export { scrollAreaDefaultProps, theme } from "./theme";
export { modalSizeForm, modalSizeSelect } from "./theme/modal";
export * from "./components";
@@ -0,0 +1,6 @@
export { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
export type { IntegrationGridItem } from "./integration-grid-shared";
export { IntegrationMultiSelectGrid } from "./integration-multi-select-grid";
export type { IntegrationMultiSelectGridProps } from "./integration-multi-select-grid";
export { IntegrationSelectGrid } from "./integration-select-grid";
export type { IntegrationSelectGridProps } from "./integration-select-grid";
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { integrationKinds } from "@homarr/definitions";
import { buildSortedIntegrations } from "./integration-grid-shared";
describe("buildSortedIntegrations", () => {
it("keeps every real integration available during onboarding", () => {
const kinds = buildSortedIntegrations({ onboarding: true }).map((integration) => integration.kind);
expect(kinds).toHaveLength(integrationKinds.filter((kind) => kind !== "mock").length);
expect(kinds).not.toContain("mock");
});
it("limits the grid to the allowed integration kinds", () => {
const kinds = buildSortedIntegrations({ allowedKinds: ["jellyfin", "sonarr"] }).map(
(integration) => integration.kind,
);
expect(kinds).toEqual(expect.arrayContaining(["jellyfin", "sonarr"]));
expect(kinds).toHaveLength(2);
});
});
@@ -0,0 +1,81 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import {
getIntegrationName,
getIntegrationOnboardingMetadata,
getWidgetKindsForIntegration,
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
export const categoryTranslationKeys: Record<string, string> = {
dnsHole: "integration.category.dnsHole",
mediaService: "integration.category.mediaService",
calendar: "integration.category.calendar",
mediaSearch: "integration.category.mediaSearch",
mediaRelease: "integration.category.mediaRelease",
mediaRequest: "integration.category.mediaRequest",
downloadClient: "integration.category.downloadClient",
usenet: "integration.category.usenet",
torrent: "integration.category.torrent",
miscellaneous: "integration.category.miscellaneous",
smartHomeServer: "integration.category.smartHomeServer",
indexerManager: "integration.category.indexerManager",
healthMonitoring: "integration.category.healthMonitoring",
beszel: "integration.category.beszel",
search: "integration.category.search",
mediaTranscoding: "integration.category.mediaTranscoding",
networkController: "integration.category.networkController",
notifications: "integration.category.notifications",
firewall: "integration.category.firewall",
photoService: "integration.category.photoService",
notes: "integration.category.notes",
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
vpn: "integration.category.vpn",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
};
export interface IntegrationGridItem {
kind: IntegrationKind;
name: string;
categories: string[];
widgets: WidgetKind[];
}
export const buildSortedIntegrations = (
options: {
enableMockIntegration?: boolean;
onboarding?: boolean;
allowedKinds?: readonly IntegrationKind[];
} = {},
): IntegrationGridItem[] =>
integrationKinds
.filter((kind) => {
if (options.allowedKinds && !options.allowedKinds.includes(kind)) return false;
if (options.onboarding && getIntegrationOnboardingMetadata(kind).hidden) return false;
if (!options.enableMockIntegration && kind === "mock") return false;
return true;
})
.map((kind) => ({
kind,
name: getIntegrationName(kind),
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
widgets: getWidgetKindsForIntegration(kind),
}))
.toSorted((left, right) => {
const leftFeaturedOrder = getIntegrationOnboardingMetadata(left.kind).featuredOrder;
const rightFeaturedOrder = getIntegrationOnboardingMetadata(right.kind).featuredOrder;
if (leftFeaturedOrder !== null && rightFeaturedOrder !== null) return leftFeaturedOrder - rightFeaturedOrder;
if (leftFeaturedOrder !== null) return -1;
if (rightFeaturedOrder !== null) return 1;
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
});
export const filterIntegrations = (items: IntegrationGridItem[], search: string) => {
const query = search.toLocaleLowerCase().trim();
if (!query) return items;
return items.filter((item) => item.name.toLocaleLowerCase().includes(query));
};
@@ -0,0 +1,141 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
import { getWidgetName } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
import classes from "./integration-select-grid.module.css";
export interface IntegrationMultiSelectGridProps {
selectedKinds: IntegrationKind[];
onSelectionChange: (kinds: IntegrationKind[]) => void;
enableMockIntegration?: boolean;
onboarding?: boolean;
detectedKinds?: Set<IntegrationKind>;
}
export const IntegrationMultiSelectGrid = ({
selectedKinds: selectedKindsArray,
onSelectionChange,
enableMockIntegration = false,
onboarding = false,
detectedKinds,
}: IntegrationMultiSelectGridProps) => {
const [search, setSearch] = useState("");
const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]);
const t = useI18n();
const toggleKind = useCallback(
(kind: IntegrationKind) => {
const current = new Set(selectedKindsArray);
if (current.has(kind)) current.delete(kind);
else current.add(kind);
onSelectionChange(Array.from(current));
},
[onSelectionChange, selectedKindsArray],
);
const integrations = useMemo(
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
[enableMockIntegration, onboarding],
);
const sorted = useMemo(() => {
if (!detectedKinds || detectedKinds.size === 0) return integrations;
return [
...integrations.filter((integration) => detectedKinds.has(integration.kind)),
...integrations.filter((integration) => !detectedKinds.has(integration.kind)),
];
}, [detectedKinds, integrations]);
const filtered = useMemo(() => filterIntegrations(sorted, search), [search, sorted]);
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
ariaLabel={t("integration.page.list.search")}
disableAutoFocus={onboarding}
>
{filtered.map((integration) => {
const isSelected = selectedKinds.has(integration.kind);
const isDetected = detectedKinds?.has(integration.kind) ?? false;
return (
<UnstyledButton
className={classes.choice}
key={integration.kind}
onClick={() => toggleKind(integration.kind)}
aria-label={integration.name}
aria-pressed={isSelected}
>
<Card
h={selectGridCardHeight}
className={classes.card}
style={{
borderColor: isSelected ? "var(--mantine-primary-color-filled)" : undefined,
borderWidth: isSelected ? 2 : undefined,
}}
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2} style={{ flex: 1 }}>
{integration.name}
</Text>
{isSelected && <IconCheck size={18} color="var(--mantine-primary-color-filled)" />}
</Group>
<Stack gap={4} mt="auto">
{isDetected && (
<Badge variant="light" color="teal" size="xs" leftSection={<IconBrandDocker size={12} />}>
{t("integration.grid.detected")}
</Badge>
)}
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((category) => (
<Badge key={category} variant="light" size="xs">
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{getWidgetName(widgetKind, t)}
</Text>
))}
</Stack>
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
{t("integration.grid.noWidgets")}
</Badge>
)}
</Stack>
</Stack>
</Card>
</UnstyledButton>
);
})}
{filtered.length === 0 && (
<Center p="xl" style={{ gridColumn: "1 / -1" }}>
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
};
@@ -0,0 +1,26 @@
.choice {
width: 100%;
border-radius: var(--mantine-radius-lg);
text-align: start;
}
.choice:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.card {
transition:
background-color 150ms ease-out,
border-color 150ms ease-out;
}
.choice:hover .card {
background-color: var(--mantine-color-default-hover);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
@@ -0,0 +1,2 @@
declare const classes: Record<string, string>;
export default classes;
@@ -0,0 +1,279 @@
// @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 type { IntegrationKind } from "@homarr/definitions";
import { IntegrationSelectGrid } from "./integration-select-grid";
vi.mock("@homarr/definitions", async (importOriginal) => {
const actual = await importOriginal<typeof import("@homarr/definitions")>();
return {
...actual,
getWidgetKindsForIntegration: (kind: IntegrationKind) => {
if (kind === "wud") return [];
return actual.getWidgetKindsForIntegration(kind);
},
};
});
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string, values?: { count?: number }) => {
const translations: Record<string, string> = {
"integration.page.list.search": "Search integrations",
"common.noResults": "No results found",
"integration.grid.noWidgets": "No widgets tied to this service",
"integration.grid.tiedWidgets": "Tied Widgets",
"integration.grid.connected": `${values?.count ?? 0} connected`,
"integration.grid.more": `+${values?.count ?? 0} more`,
"widget.mediaServer.name": "Media Server",
"widget.calendar.name": "Calendar",
"widget.downloads.name": "Downloads",
"integration.category.mediaService": "Media Service",
"integration.category.downloadClient": "Download Client",
"integration.category.calendar": "Calendar",
"integration.category.smartHomeServer": "Smart Home",
"integration.category.usenet": "Usenet",
"integration.category.torrent": "Torrent",
};
return translations[key] ?? key;
},
}));
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(),
}));
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("IntegrationSelectGrid", () => {
it("renders a 4-column responsive grid of selectable integration cards", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "sabNzbd"]} />
</MantineProvider>,
),
);
const cards = host.querySelectorAll("button");
expect(cards.length).toBe(4);
expect(host.textContent).toContain("Jellyfin");
expect(host.textContent).toContain("Sonarr");
expect(host.textContent).toContain("Radarr");
expect(host.textContent).toContain("SABnzbd");
});
it("renders tied widgets with icons and labels inside integration cards", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin"]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Tied Widgets");
// Jellyfin is tied to Media Server widget
expect(host.textContent).toContain("Media Server");
});
it("displays fallback message when integration has no tied widgets", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["wud"]} />
</MantineProvider>,
),
);
expect(host.querySelector("button")).not.toBeNull();
expect(host.textContent).toContain("No widgets tied to this service");
});
it("displays '{count} connected' in footerLeft when integration instances exist", async () => {
const onSelect = vi.fn();
const integrationData = [
{ kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" },
{ kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" },
{ kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" },
];
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid
onSelect={onSelect}
allowedKinds={["jellyfin", "radarr"]}
integrationData={integrationData}
/>
</MantineProvider>,
),
);
// Jellyfin has 2 connected
expect(host.textContent).toContain("2 connected");
// Radarr has 0 connected, so it doesn't show "0 connected"
expect(host.textContent).not.toContain("0 connected");
});
it("filters integrations dynamically by search input", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "plex"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
expect(searchInput).not.toBeNull();
// Type "jelly"
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "jelly");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
const cards = host.querySelectorAll("button");
expect(cards.length).toBe(1);
expect(host.textContent).toContain("Jellyfin");
expect(host.textContent).not.toContain("Sonarr");
expect(host.textContent).not.toContain("Radarr");
});
it("displays empty state when no integrations match search query", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("No results found");
expect(host.querySelectorAll("button").length).toBe(0);
});
it("calls onSelect when Enter is pressed with exactly 1 search match", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeInputValueSetter?.call(searchInput, "jelly");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await act(async () => {
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onSelect).toHaveBeenCalledWith("jellyfin");
});
it("calls onSelect when an integration card is clicked", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["sonarr"]} />
</MantineProvider>,
),
);
const cardButton = host.querySelector("button");
await act(async () => cardButton?.click());
expect(onSelect).toHaveBeenCalledWith("sonarr");
});
it("includes mock integration when enableMockIntegration is true", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration allowedKinds={["mock", "sonarr"]} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Mock");
expect(host.textContent).toContain("Sonarr");
});
it("excludes mock integration when enableMockIntegration is false", async () => {
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration={false} />
</MantineProvider>,
),
);
const buttons = Array.from(host.querySelectorAll("button"));
const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock");
expect(mockButton).toBeUndefined();
});
});
@@ -0,0 +1,158 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { getWidgetName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectableCard } from "../components/selectable-card";
import { selectGridCols } from "../select-grid-layout";
import { widgetCatalogIcons } from "../widget-icons";
import { buildSortedIntegrations, filterIntegrations } from "./integration-grid-shared";
import type { IntegrationGridItem } from "./integration-grid-shared";
export interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
integrationData?: { kind: IntegrationKind; name?: string }[];
}
export const IntegrationSelectGrid = ({
onSelect,
enableMockIntegration = false,
allowedKinds,
integrationData,
}: IntegrationSelectGridProps) => {
const [search, setSearch] = useState("");
const t = useI18n();
const integrations = useMemo(
() => buildSortedIntegrations({ enableMockIntegration, allowedKinds }),
[allowedKinds, enableMockIntegration],
);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
<Stack gap="md">
{/* Top Search Input */}
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("integration.page.list.search")}...`}
aria-label={t("integration.page.list.search")}
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
onSelect(filtered[0].kind);
}
}}
/>
{/* Scrollable Container with Integration Cards */}
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
<Stack gap="md" pt="xs" pr="xs" px={4}>
<SimpleGrid cols={selectGridCols} spacing="sm">
{filtered.map((integration) => (
<IntegrationCard
key={integration.kind}
integration={integration}
onSelect={onSelect}
connectedCount={(integrationData ?? []).filter((i) => i.kind === integration.kind).length}
/>
))}
</SimpleGrid>
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
);
};
// =========================================================================
// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
// =========================================================================
const IntegrationCard = ({
integration,
onSelect,
connectedCount = 0,
}: {
integration: IntegrationGridItem;
onSelect: (kind: IntegrationKind) => void;
connectedCount?: number;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
title={
<Text fw={700} size="md" style={{ whiteSpace: "nowrap" }}>
{integration.name}
</Text>
}
footerLeft={
connectedCount > 0 ? (
<Badge variant="light" color="teal" size="xs" radius="xs">
{t("integration.grid.connected", { count: connectedCount })}
</Badge>
) : null
}
>
<Text size="10px" tt="uppercase" fw={700} c="dimmed">
{t("integration.grid.tiedWidgets")}
</Text>
<IntegrationTiedWidgets widgets={integration.widgets} />
</SelectableCard>
);
};
const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
const t = useI18n();
if (widgets.length === 0) {
return (
<Text size="xs" c="dimmed" fs="italic">
{t("integration.grid.noWidgets")}
</Text>
);
}
const items = limit ? widgets.slice(0, limit) : widgets;
const moreCount = limit ? widgets.length - limit : 0;
return (
<Group gap={4} wrap="wrap">
{items.map((widgetKind) => {
const WidgetIcon = widgetCatalogIcons[widgetKind];
return (
<Badge
key={widgetKind}
variant="default"
radius="xs"
size="xs"
leftSection={<WidgetIcon size={12} />}
style={{ fontWeight: 500 }}
>
{getWidgetName(widgetKind, t)}
</Badge>
);
})}
{moreCount > 0 && (
<Badge variant="subtle" color="gray" size="xs" radius="xs">
{t("integration.grid.more", { count: moreCount })}
</Badge>
)}
</Group>
);
};
+4 -1
View File
@@ -4,7 +4,7 @@ import type { KeyboardEvent, ReactNode } from "react";
import { Input, ScrollArea, SimpleGrid, Stack } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
export const selectGridCols = { base: 2, xs: 3, sm: 4, md: 5 };
export const selectGridCols = { base: 1, xs: 2, sm: 3, md: 4, lg: 5 };
export const selectGridCardHeight = 140;
export const selectGridScrollMaxHeight = "70vh";
@@ -12,6 +12,7 @@ interface SelectGridLayoutProps {
search: string;
onSearchChange: (value: string) => void;
placeholder: string;
ariaLabel: string;
onSearchKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
disableScroll?: boolean;
disableAutoFocus?: boolean;
@@ -22,6 +23,7 @@ export const SelectGridLayout = ({
search,
onSearchChange,
placeholder,
ariaLabel,
onSearchKeyDown,
disableScroll = false,
disableAutoFocus = false,
@@ -40,6 +42,7 @@ export const SelectGridLayout = ({
onChange={(event) => onSearchChange(event.currentTarget.value)}
leftSection={<IconSearch />}
placeholder={placeholder}
aria-label={ariaLabel}
data-autofocus={disableAutoFocus ? undefined : true}
onKeyDown={onSearchKeyDown}
/>
+8
View File
@@ -3,3 +3,11 @@
@import "@mantine/dates/styles.css";
@import "@gfazioli/mantine-onboarding-tour/styles.css";
@import "mantine-react-table/styles.css";
:root {
--homarr-z-index-widget-background-tooltip: calc(var(--mantine-z-index-modal) - 4);
--homarr-z-index-widget-preview-backdrop: calc(var(--mantine-z-index-modal) - 3);
--homarr-z-index-board-header: calc(var(--mantine-z-index-modal) - 2);
--homarr-z-index-widget-focus: calc(var(--mantine-z-index-modal) - 1);
--homarr-z-index-modal-tooltip: calc(var(--mantine-z-index-modal) + 2);
}
+14
View File
@@ -2,6 +2,13 @@ import { createTheme, rem } from "@mantine/core";
import { modalComponent } from "./theme/modal";
export const scrollAreaDefaultProps = {
type: "hover",
scrollbarSize: 2,
scrollHideDelay: 500,
offsetScrollbars: true,
} as const;
export const theme = createTheme({
primaryColor: "red",
autoContrast: true,
@@ -25,6 +32,7 @@ export const theme = createTheme({
},
fontSizes: {
xxs: rem(11),
xs: rem(12),
sm: rem(14),
md: rem(16),
@@ -95,6 +103,12 @@ export const theme = createTheme({
style: { borderRadius: 5 },
},
},
ScrollArea: {
defaultProps: scrollAreaDefaultProps,
},
ScrollAreaAutosize: {
defaultProps: scrollAreaDefaultProps,
},
LoadingOverlay: {
defaultProps: {
zIndex: 1000,
+137
View File
@@ -0,0 +1,137 @@
import {
IconActivityHeartbeat,
IconAd,
IconApi,
IconApps,
IconArchive,
IconBadgeCc,
IconBatteryCharging,
IconBell,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartAreaLine,
IconChartBar,
IconClock,
IconClockPlay,
IconCloud,
IconCpu,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconFileText,
IconGraphFilled,
IconHeadphones,
IconHeartRateMonitor,
IconHeartbeat,
IconHourglass,
IconLayoutGrid,
IconMessage,
IconMovie,
IconNotes,
IconPhoto,
IconReportSearch,
IconRocket,
IconRobot,
IconRoute,
IconRss,
IconServer2,
IconShieldCheck,
IconShieldLock,
IconShieldBolt,
IconShieldHalfFilled,
IconDevicesPc,
IconListNumbers,
IconBug,
IconFileAlert,
IconLockExclamation,
IconSpeedboat,
IconTable,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconWind,
IconZoomQuestion,
} from "@tabler/icons-react";
import type { TablerIcon } from "@tabler/icons-react";
import type { WidgetKind } from "@homarr/definitions";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
stats: IconChartBar,
clock: IconClock,
weather: IconCloud,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
"immich-serverStats": IconGraphFilled,
"immich-albumCarousel": IconPhoto,
paperlessNgx: IconFileText,
patchmon: IconShieldCheck,
bazarr: IconBadgeCc,
tracearr: IconActivityHeartbeat,
speedtestTracker: IconSpeedboat,
uptimeKuma: IconHeartbeat,
audioStats: IconHeadphones,
umami: IconChartBar,
vpn: IconShieldLock,
archiveTeamWarrior: IconArchive,
ups: IconBatteryCharging,
beszelSystemTable: IconTable,
beszelSystemGrid: IconLayoutGrid,
beszelAlerts: IconBell,
beszelSystemStats: IconChartAreaLine,
traefik: IconRoute,
customApi: IconApi,
wud: IconBrandDocker,
llamacpp: IconCpu,
wazuhAgents: IconDevicesPc,
wazuhAlerts: IconShieldBolt,
wazuhSummary: IconShieldHalfFilled,
wazuhTimeline: IconChartAreaLine,
wazuhTopList: IconListNumbers,
wazuhVulnerabilities: IconBug,
wazuhFim: IconFileAlert,
wazuhAuthFailures: IconLockExclamation,
};