fix: refine widget modernization

This commit is contained in:
Thomas Camlong committed 2026-08-01 21:45:01 +02:00
1 parent d421046d15
commit b28beec2c6
50 files changed
+292 -386

No files matched your search

@@ -22,6 +22,7 @@
overscroll-behavior: contain;
animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: top left;
padding-top: 60px;
}
.backdropClosing {
@@ -57,6 +58,21 @@
pointer-events: none;
}
.surfaceControlsAdvanced {
right: 0;
bottom: auto;
height: 60px;
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
border-bottom: 1px solid var(--mantine-color-default-border);
}
.surfaceTitle {
position: absolute;
top: 20px;
right: 120px;
left: 16px;
}
.closeButton {
position: absolute;
top: 8px;
@@ -68,7 +84,7 @@
.pinButton {
position: absolute;
top: 8px;
right: 52px;
right: 60px;
z-index: 2;
pointer-events: auto;
}
@@ -82,7 +98,7 @@
pointer-events: auto;
}
@media (pointer: coarse) {
@media (any-pointer: coarse) {
.touchButton {
display: inline-flex;
}
@@ -4,12 +4,12 @@ import { getAdvancedFocusRect } from "./geometry";
describe("getAdvancedFocusRect", () => {
it("expands a widget and keeps it inside the viewport", () => {
expect(getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 })).toEqual({
left: 240,
top: 190,
width: 720,
height: 480,
});
const rect = getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 });
expect(rect.left).toBe(120);
expect(rect.top).toBeCloseTo(118.6);
expect(rect.width).toBe(960);
expect(rect.height).toBeCloseTo(622.8);
});
it("uses the available viewport on small screens", () => {
@@ -11,8 +11,10 @@ export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: numbe
const margin = viewport.width < 640 ? 8 : 24;
const availableWidth = Math.max(0, viewport.width - margin * 2);
const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
const width = Math.min(availableWidth, Math.max(source.width, Math.min(960, source.width * 2.4)));
const height = Math.min(availableHeight, Math.max(source.height, Math.min(720, source.height * 2.4)));
const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(960, availableWidth * 0.9);
const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(720, availableHeight * 0.9);
const width = Math.min(availableWidth, Math.max(source.width, preferredWidth));
const height = Math.min(availableHeight, Math.max(source.height, preferredHeight));
return {
left: Math.max(margin, (viewport.width - width) / 2),
@@ -3,7 +3,6 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board } from "~/app/[locale]/boards/_types";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
@@ -33,17 +32,7 @@ interface UpdateItemIntegrations {
export const useItemActions = () => {
const { updateBoard } = useUpdateBoard();
const createItem = useCallback(
(input: CreateItemInput) => {
let nextBoard: Board | undefined;
updateBoard((previous) => {
nextBoard = createItemCallback(input)(previous);
return nextBoard;
});
return nextBoard;
},
[updateBoard],
);
const createItem = useCallback((input: CreateItemInput) => updateBoard(createItemCallback(input)), [updateBoard]);
const duplicateItem = useCallback(
({ itemId }: DuplicateItemInput) => {
@@ -1,6 +1,6 @@
import type { CSSProperties, KeyboardEvent, MutableRefObject, PointerEvent } from "react";
import { useEffect, useRef } from "react";
import { ActionIcon, Badge, Box, Card, FocusTrap } from "@mantine/core";
import { ActionIcon, Badge, Box, Card, FocusTrap, Text } from "@mantine/core";
import { useElementSize, useMergedRef, useViewportSize } from "@mantine/hooks";
import { IconMaximize, IconPin, IconX } from "@tabler/icons-react";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
@@ -43,6 +43,8 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const board = useRequiredBoard();
const t = useI18n();
const widgetName = t(`widget.${item.kind}.name`);
const advancedDialogLabel = t("item.advancedFocus.dialog", { widget: widgetName });
const [isEditMode] = useEditMode();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const { active, open, close, dismiss, pin, hover, leave } = useAdvancedFocus();
@@ -86,6 +88,8 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
<Box
ref={mergedRef}
tabIndex={isEditMode ? undefined : 0}
role={isEditMode ? undefined : "group"}
aria-label={isEditMode ? undefined : `${widgetName}: ${t("item.advancedFocus.open")}`}
aria-haspopup="dialog"
aria-expanded={isAdvanced}
aria-keyshortcuts="Shift+Enter"
@@ -104,11 +108,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
<Card
role={isAdvanced ? "dialog" : undefined}
aria-modal={activeFocus?.pinned || undefined}
aria-label={
isAdvanced ? t("item.advancedFocus.dialog", { widget: t(`widget.${item.kind}.name`) }) : undefined
}
aria-label={isAdvanced ? advancedDialogLabel : undefined}
radius={board.itemRadius}
p={0}
p={isAdvanced ? undefined : 0}
h={isAdvanced ? undefined : "100%"}
w={isAdvanced ? undefined : "100%"}
className={combineClasses(
@@ -143,13 +145,23 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
: undefined
}
>
<div className={advancedFocusClasses.surfaceControls}>
<div
className={combineClasses(
advancedFocusClasses.surfaceControls,
isAdvanced && advancedFocusClasses.surfaceControlsAdvanced,
)}
>
{isAdvanced && (
<Text className={advancedFocusClasses.surfaceTitle} size="sm" fw={600} truncate>
{advancedDialogLabel}
</Text>
)}
{isAdvanced && (
<ActionIcon
data-autofocus={activeFocus.pinned && activeFocus.autofocusClose ? true : undefined}
className={advancedFocusClasses.closeButton}
variant="default"
size="lg"
size={44}
aria-label={t("item.advancedFocus.close")}
onClick={() => close()}
>
@@ -160,6 +172,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
<ActionIcon
className={advancedFocusClasses.pinButton}
variant={activeFocus.pinned ? "filled" : "default"}
size={44}
aria-label={activeFocus.pinned ? t("item.advancedFocus.pinned") : t("item.advancedFocus.pin")}
aria-pressed={activeFocus.pinned}
onClick={(event) => pin(event.detail === 0)}
@@ -171,7 +184,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
<ActionIcon
className={advancedFocusClasses.touchButton}
variant="default"
size="lg"
size={44}
aria-label={t("item.advancedFocus.open")}
onClick={openPinned}
>
@@ -2,7 +2,7 @@
import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
@@ -242,13 +242,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
</Menu.CheckboxItem>
))}
</>
)}