feat: refine widget interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent a8a7545f40
commit 842e86012d
107 files changed
+3144 -1233

No files matched your search

@@ -2,6 +2,12 @@
background-color: var(--mantine-color-primaryColor-light-hover);
}
.bookmarkButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-sm);
}
[data-mantine-color-scheme="light"] .card-grid {
background-color: var(--mantine-color-gray-1);
}
@@ -13,3 +19,7 @@
.card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
.bookmarkButton:focus-visible .bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
+115 -36
View File
@@ -1,6 +1,18 @@
"use client";
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import {
Anchor,
Box,
Card,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
@@ -12,7 +24,45 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"bookmarks">) {
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
export interface CompactBookmarkLayout {
columns: number;
hideHostname: boolean;
hideTitle: boolean;
minimumItemSize: number;
}
export function getCompactBookmarkLayout(
width: number,
height: number,
itemCount: number,
layout: BookmarkLayout,
): CompactBookmarkLayout {
const count = Math.max(1, itemCount);
const isGrid = layout === "grid" || layout === "gridHorizontal";
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1;
const rows = Math.max(1, Math.ceil(count / columns));
const cellWidth = width / columns;
const cellHeight = height / rows;
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
return {
columns,
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
hideTitle: cellWidth < 64 || cellHeight < 40,
minimumItemSize: usesHorizontalItems ? 48 : 96,
};
}
export default function BookmarksWidget({
options,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(apps) {
@@ -41,10 +91,15 @@ export default function BookmarksWidget({ options, itemId, width, displayMode }:
[data],
);
const compactLayout = getCompactBookmarkLayout(width, height, data.length, options.layout);
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
const compactHideHostname =
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
return (
<Stack h="100%" gap="sm" p="sm">
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
{options.title.length > 0 && (
<Title order={4} px="0.25rem">
<Title order={4} px="0.25rem" lineClamp={1}>
{options.title}
</Title>
)}
@@ -57,29 +112,37 @@ export default function BookmarksWidget({ options, itemId, width, displayMode }:
hasIconColor={board.iconColor !== null}
/>
)}
{displayMode !== "advanced" && (options.layout === "grid" || options.layout === "gridHorizontal") && (
<GridLayout
data={data}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
{displayMode !== "advanced" && options.layout !== "grid" && options.layout !== "gridHorizontal" && (
<FlexLayout
data={data}
direction={options.layout}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
{displayMode !== "advanced" && (
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
<Box mih="100%">
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
<GridLayout
data={data}
columns={compactLayout.columns}
minimumItemHeight={compactLayout.minimumItemSize}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
) : (
<FlexLayout
data={data}
direction={options.layout}
minimumItemSize={compactLayout.minimumItemSize}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
</Box>
</ScrollArea>
)}
</Stack>
);
@@ -94,6 +157,7 @@ interface FlexLayoutProps {
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
minimumItemSize: number;
}
const FlexLayout = ({
@@ -105,13 +169,15 @@ const FlexLayout = ({
openNewTab,
withBorder,
hasIconColor,
minimumItemSize,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex direction={direction} gap="0" w="100%">
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
{data.map((app) => (
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
@@ -188,6 +254,7 @@ const AdvancedBookmarksLayout = ({
{data.map((app) => (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
@@ -233,6 +300,8 @@ interface GridLayoutProps {
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
columns: number;
minimumItemHeight: number;
}
const GridLayout = ({
@@ -244,19 +313,29 @@ const GridLayout = ({
withBorder,
itemDirection,
hasIconColor,
columns,
minimumItemHeight,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex miw="100%" gap={4} wrap="wrap" style={{ flex: 1 }}>
<SimpleGrid
cols={columns}
spacing={4}
verticalSpacing={4}
miw="100%"
mih="100%"
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
>
{data.map((app) => (
<UnstyledButton
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
flex="1"
h="100%"
>
<Card
h="100%"
@@ -285,7 +364,7 @@ const GridLayout = ({
</Card>
</UnstyledButton>
))}
</Flex>
</SimpleGrid>
);
};
@@ -305,7 +384,7 @@ const VerticalItem = ({
return (
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
{!hideTitle && (
<Text fw={700} ta="center" size="xs">
<Text fw={700} ta="center" size="xs" lineClamp={2}>
{app.name}
</Text>
)}
@@ -326,7 +405,7 @@ const VerticalItem = ({
/>
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs">
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
{getBookmarkHostname(app.href)}
</Anchor>
)}
@@ -365,7 +444,7 @@ const HorizontalItem = ({
)}
{!(hideTitle && hideHostname) && (
<>
<Stack justify="space-between" gap={0}>
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
{!hideTitle && (
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
{app.name}
@@ -373,7 +452,7 @@ const HorizontalItem = ({
)}
{!hideHostname && (
<Anchor component="span" size="xs">
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
)}
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getAdvancedBookmarkColumns, getBookmarkHostname } from "./component";
import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } from "./component";
describe("bookmark display helpers", () => {
test("uses bounded advanced columns", () => {
@@ -9,6 +9,22 @@ describe("bookmark display helpers", () => {
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
});
test("uses container geometry to keep compact cards legible", () => {
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
columns: 2,
hideHostname: false,
hideTitle: false,
minimumItemSize: 48,
});
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
columns: 4,
hideHostname: true,
hideTitle: true,
minimumItemSize: 96,
});
});
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBe("not a URL");