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,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";
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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) }));
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
export * from "./use-is-mobile";
|
||||
export * from "./use-translated-mantine-react-table";
|
||||
@@ -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})`);
|
||||
};
|
||||
@@ -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,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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user