fix: refine widget interactions

This commit is contained in:
Thomas Camlong committed 2026-08-03 18:45:16 +02:00
1 parent fbe381cbb5
commit 7847eea1a3
27 files changed
+177 -122

No files matched your search

+1 -1
View File
@@ -181,7 +181,7 @@ Widgets expose their routine controls without requiring edit mode:
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
open until you close it, or use the maximize control on touch devices. Other widgets adapt in place instead.
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
- Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run
supported actions. Available controls follow your board and integration permissions.
- If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board
@@ -54,7 +54,7 @@ export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?:
{advancedView && (
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
Advanced view reuses the widget&apos;s live data and cache. The rest of the board stays visible but dimmed.
Touch devices expose a maximize control; compact-only widgets adapt in place instead.
On touch devices, use the widget menu; compact-only widgets adapt in place instead.
</p>
)}
</details>
@@ -76,34 +76,12 @@
pointer-events: none;
}
.surfaceControls {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.closeButton {
position: absolute;
z-index: 2;
pointer-events: auto;
}
.touchButton {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: none;
pointer-events: auto;
}
@media (any-pointer: coarse) {
.touchButton {
display: inline-flex;
}
}
@keyframes focus-backdrop-in {
from {
opacity: 0;
@@ -37,6 +37,8 @@ interface AdvancedFocusContextValue {
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
const HOVER_DELAY_MS = 150;
const CLOSE_DURATION_MS = 180;
const interactiveSelector =
"a[href], button, input, textarea, select, [contenteditable='true'], [role='button'], [role='link'], [role='menuitem'], [role='option']";
const isEditableTarget = (target: EventTarget | null) =>
target instanceof HTMLElement &&
@@ -131,6 +133,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const handlePointerMove = (event: PointerEvent) => {
const current = activeRef.current;
if (!shiftHeldRef.current || current?.activation !== "preview") return;
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
const underlyingSource = document
.elementsFromPoint(event.clientX, event.clientY)
@@ -22,14 +22,5 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null
if (current === root) break;
current = current.parentElement;
}
// A nested control can reach its edge before the widget's main Mantine viewport.
// Hand the gesture to that viewport instead of making Shift+wheel feel stuck.
const viewport = [...root.querySelectorAll<HTMLElement>("[data-scrollbars='y'], [data-scrollbars='xy']")].find(
(element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta),
);
if (!viewport) return false;
viewport.scrollTop += delta;
return true;
return false;
};
@@ -1,9 +1,8 @@
import type { CSSProperties, KeyboardEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ActionIcon, Badge, Box, Card, Portal } from "@mantine/core";
import { Badge, Box, Card, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { IconMaximize } from "@tabler/icons-react";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
@@ -74,11 +73,12 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
// Keep one widget subtree mounted while moving its stable portal target between the grid and viewport layers.
// This preserves iframe/editor state and escapes Gridstack's transformed containing block.
useIsomorphicEffect(() => {
if (!supportsAdvancedFocus) return;
const target = document.createElement("div");
target.className = advancedFocusClasses.surfacePortalTarget ?? "";
setSurfacePortalTarget(target);
return () => target.remove();
}, []);
}, [supportsAdvancedFocus]);
// Responsive layout changes remount grid items. Never leave their focus backdrop orphaned.
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
@@ -166,19 +166,6 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
}}
style={previewStyle}
>
<div className={advancedFocusClasses.surfaceControls}>
{!isEditMode && supportsAdvancedFocus && !isAdvanced && (
<ActionIcon
className={advancedFocusClasses.touchButton}
variant="default"
size={44}
aria-label={t("item.advancedFocus.open")}
onClick={() => openAdvanced()}
>
<IconMaximize size={18} />
</ActionIcon>
)}
</div>
<Box ref={contentRef} w="100%" h="100%" mih={0}>
<InnerContent
item={item}
@@ -214,7 +201,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)}
>
<div ref={(node) => mountPortalTarget(node, "compact")} className={advancedFocusClasses.surfaceHost}>
{!surfacePortalTarget && widgetCard}
{!supportsAdvancedFocus && widgetCard}
</div>
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
{isPreview && (
@@ -289,7 +289,7 @@ export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children
disabled={action.disabled}
color={action.color}
>
{t(action.label as never)}
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
@@ -27,11 +27,16 @@ const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scop
describe("matchesWidgetItemQuery", () => {
test("matches only the current Custom API definition", () => {
const scope = createScope({ options: { definitionId: "definition-1" } });
const input = { boardId: "board-1", itemId: "item-1", definitionId: "definition-1" };
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-1" }, scope)).toBe(
true,
expect(matches("customApi", ["widget", "customApi", "getData"], input, scope)).toBe(true);
expect(
matches("customApi", ["widget", "customApi", "getData"], { ...input, definitionId: "definition-2" }, scope),
).toBe(false);
expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, itemId: "item-2" }, scope)).toBe(
false,
);
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-2" }, scope)).toBe(
expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, boardId: "board-2" }, scope)).toBe(
false,
);
});
+25 -6
View File
@@ -86,6 +86,7 @@ describe("Board advanced interactions", () => {
await expect(previewSurface).toHaveCSS("animation-name", "none");
const compactBounds = await widget.boundingBox();
const advancedBounds = await previewSurface.boundingBox();
if (!compactBounds || !advancedBounds) throw new Error("Advanced widget has no bounds");
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(dimmingOverlay).toBeVisible();
@@ -105,6 +106,24 @@ describe("Board advanced interactions", () => {
const otherBounds = await otherWidget.boundingBox();
expect(otherBounds).not.toBeNull();
if (!otherBounds) throw new Error("Bookmarks widget has no bounds");
await previewSurface.evaluate(
(element, position) => {
const probe = document.createElement("button");
probe.dataset.interactivePreviewProbe = "true";
probe.style.cssText = `position:absolute;z-index:20;width:40px;height:40px;left:${position.left}px;top:${position.top}px`;
element.append(probe);
},
{
left: otherBounds.x + otherBounds.width / 2 - advancedBounds.x - 20,
top: otherBounds.y + otherBounds.height / 2 - advancedBounds.y - 20,
},
);
const interactivePreviewProbe = previewSurface.locator("[data-interactive-preview-probe='true']");
await interactivePreviewProbe.hover();
await expect(previewSurface).toBeVisible();
await expect(bookmarksPreviewSurface).toBeHidden();
await interactivePreviewProbe.evaluate((element) => element.remove());
await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4);
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
await expect(previewSurface).toBeHidden();
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
@@ -130,17 +149,17 @@ describe("Board advanced interactions", () => {
0,
);
await manualSurface.evaluate((element) => {
const mainViewport = document.createElement("div");
mainViewport.dataset.scrollbars = "y";
mainViewport.style.cssText = "height:80px;overflow-y:auto";
const probe = document.createElement("div");
probe.dataset.shiftScrollProbe = "true";
probe.style.cssText = "height:80px;overflow-y:auto";
probe.innerHTML = '<svg data-shift-scroll-target="true"></svg><div style="height:600px"></div>';
probe.addEventListener("wheel", (event) => event.stopPropagation());
element.append(probe);
const mainViewport = document.createElement("div");
mainViewport.dataset.scrollbars = "y";
mainViewport.style.cssText = "height:80px;overflow-y:auto";
mainViewport.innerHTML = '<div style="height:600px"></div>';
mainViewport.append(probe);
mainViewport.insertAdjacentHTML("beforeend", '<div style="height:600px"></div>');
element.append(mainViewport);
const unrelated = document.createElement("div");
+4
View File
@@ -1406,6 +1406,10 @@
"success": "Success",
"beta": "Beta",
"error": "Error",
"widgetQuery": {
"loading": "Loading widget data",
"stale": "Could not refresh {widget}; showing saved data"
},
"action": {
"add": "Add",
"apply": "Apply",
@@ -6,6 +6,7 @@ import { widgetImports } from ".";
const compactOnlyWidgetKinds = [
"app",
"bazarr",
"beszelAlerts",
"beszelSystemGrid",
"beszelSystemStats",
"beszelSystemTable",
@@ -33,6 +33,16 @@
overflow: hidden;
}
.grid[data-short] .statTile {
flex-direction: row;
gap: 3px;
padding: 3px 4px;
}
.grid[data-short] .statLabel {
text-align: left;
}
.statTileWarning {
background-color: light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5));
}
+7 -2
View File
@@ -26,6 +26,7 @@ const statIcons = {
} as const;
const gridColsByWidth = [
{ minWidth: 640, cols: 4 },
{ minWidth: 380, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
@@ -74,7 +75,11 @@ export default function BazarrWidget({ integrationIds, options, width, height }:
return (
<div className={classes.root}>
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
<div
className={classes.grid}
data-short={height < 120 || undefined}
style={{ "--stat-cols": gridCols } as CSSProperties}
>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
const value = statValues[statKey];
@@ -99,7 +104,7 @@ export function getGridCols(width: number, height: number, itemCount: number): n
if (itemCount <= 1) return 1;
const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const maxColumnsByWidth = Math.max(1, Math.floor(width / 160));
const maxColumnsByWidth = Math.max(1, Math.floor(width / 100));
const maxRowsByHeight = Math.max(1, Math.floor(height / 72));
const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight);
return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit));
+2 -2
View File
@@ -4,11 +4,11 @@ import { getGridCols, getIconSize } from "./component";
describe("Bazarr responsive layout", () => {
test("uses two columns for a short widget so all stats remain visible", () => {
expect(getGridCols(220, 100, 4)).toBe(1);
expect(getGridCols(220, 100, 4)).toBe(2);
});
test("uses four columns in a wide advanced surface", () => {
expect(getGridCols(800, 400, 4)).toBe(2);
expect(getGridCols(800, 400, 4)).toBe(4);
});
test("sizes icons from the constrained dimension", () => {
@@ -50,7 +50,6 @@ export default function BeszelAlertsWidget({
integrationIds,
isEditMode,
height,
displayMode = "compact",
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
@@ -85,9 +84,9 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
const showAlertDescriptions = displayMode === "advanced" || height >= 190;
const showOkAlerts = triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && height >= 360;
const showAlertDescriptions = height >= 190;
if (isPending) {
return (
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
supportsAdvancedFocus: false,
queryKey: [["widget", "beszel", "getAlerts"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
@@ -21,6 +21,10 @@ describe("localized widget formatting", () => {
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
});
test("keeps date-only values on the requested calendar day", () => {
expect(formatLocalizedDate("2026-08-03", "en-US", { month: "long", day: "numeric" })).toBe("August 3");
});
test("localizes compact numbers", () => {
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
+10 -2
View File
@@ -1,5 +1,7 @@
export type LocalizedDateValue = Date | number | string;
const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
interface LocalizedTimeOptions {
hour12?: boolean;
includeSeconds?: boolean;
@@ -7,7 +9,7 @@ interface LocalizedTimeOptions {
}
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
const date = value instanceof Date ? value : new Date(value);
const date = value instanceof Date ? value : new Date(typeof value === "string" && dateOnlyPattern.test(value) ? `${value}T00:00:00Z` : value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
@@ -18,7 +20,13 @@ export const formatLocalizedDate = (
fallback = "?",
): string => {
const date = toValidDate(value);
return date ? new Intl.DateTimeFormat(locale, options).format(date) : fallback;
if (!date) return fallback;
const normalizedOptions =
typeof value === "string" && dateOnlyPattern.test(value) && options.timeZone === undefined
? { ...options, timeZone: "UTC" }
: options;
return new Intl.DateTimeFormat(locale, normalizedOptions).format(date);
};
export const formatLocalizedTime = (
@@ -5,10 +5,14 @@ import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryErrorIndicator } from "./query-state-indicator";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
useScopedI18n: () => (key: string, values?: { widget?: string }) => {
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
if (key === "loading") return "Loading widget data";
return key;
},
}));
const matchMedia = (query: string) => ({
@@ -50,7 +54,19 @@ describe("WidgetQueryErrorIndicator", () => {
),
);
expect(host.querySelector("[aria-label='Weather']")).not.toBeNull();
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
test("announces the loading state to assistive technology", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => root?.render(createElement(MantineProvider, null, createElement(WidgetQueryLoadingState))));
expect(host.querySelector("output")?.textContent).toContain("Loading widget data");
});
});
@@ -1,8 +1,10 @@
"use client";
import { Box, Center, Loader, Tooltip } from "@mantine/core";
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
label: string;
@@ -10,29 +12,34 @@ interface WidgetQueryErrorIndicatorProps {
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
const t = useScopedI18n("common.widgetQuery");
if (!error) return null;
const accessibleLabel = label;
const accessibleLabel = t("stale", { widget: label });
return (
<Tooltip label={accessibleLabel} position="left" withArrow>
<Box
<ActionIcon
component="span"
c="orange"
role="img"
color="orange"
variant="subtle"
size="sm"
role="note"
aria-label={accessibleLabel}
tabIndex={0}
style={{ display: "inline-flex", cursor: "help" }}
style={{ cursor: "help" }}
>
<IconAlertTriangle aria-hidden size={14} />
</Box>
</ActionIcon>
</Tooltip>
);
}
export function WidgetQueryLoadingState() {
const t = useScopedI18n("common.widgetQuery");
return (
<Center h="100%" w="100%" p="sm" role="status" aria-live="polite">
<Center component="output" h="100%" w="100%" p="sm" aria-live="polite">
<Loader size="sm" />
<VisuallyHidden>{t("loading")}</VisuallyHidden>
</Center>
);
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getUsableWidgetQueryData, hasStaleWidgetQueryError, isInitialWidgetQueryPending } from "./query-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "./query-state";
describe("getUsableWidgetQueryData", () => {
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
@@ -29,11 +29,3 @@ describe("isInitialWidgetQueryPending", () => {
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
});
});
describe("hasStaleWidgetQueryError", () => {
test("only marks failures that retain usable cached data", () => {
expect(hasStaleWidgetQueryError({ data: [{ id: "cached" }], error: new Error("offline") })).toBe(true);
expect(hasStaleWidgetQueryError({ data: undefined, error: new Error("offline") })).toBe(false);
expect(hasStaleWidgetQueryError({ data: [], error: null })).toBe(false);
});
});
@@ -16,7 +16,3 @@ export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query:
/** True only before the first result; successful empty values and cached data are both ready to render. */
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
isPending && data === undefined && !error;
/** A failed background refresh still has usable cached data, but should remain visible to the user. */
export const hasStaleWidgetQueryError = ({ data, error }: WidgetQueryState): boolean =>
error !== null && error !== undefined && data !== undefined;
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import JsxParser from "react-jsx-parser";
import { Alert, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
@@ -10,15 +10,8 @@ import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const template = String(data.template ?? "");
const apiData = data.data;
const [hasParseError, setHasParseError] = useState(false);
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
useEffect(() => {
setHasParseError(false);
}, [template, bindings]);
const handleError = useCallback(() => setHasParseError(true), []);
if (!template.trim()) {
return (
<Alert color="gray" variant="light" p="xs">
@@ -40,20 +33,12 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
allowUnknownElements={false}
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
onError={handleError}
renderError={() => (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">Invalid widget template</Text>
</Alert>
)}
/>
{hasParseError && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
Invalid widget template
</Text>
</Alert>
)}
</Stack>
);
}
+6 -1
View File
@@ -18,7 +18,12 @@ const createOptions = () =>
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi", "getData"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { definitionId: scope.options.definitionId }),
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
boardId: scope.boardId,
itemId: scope.itemId,
definitionId: scope.options.definitionId,
}),
contextActions: ({ options, widgetRuntimeRef }) => {
return [
{
+1 -1
View File
@@ -15,7 +15,7 @@ import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
label: string;
label: stringOrTranslation;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed, getVisibleForecastDays } from "./component";
import { formatWeatherDate } from "./icon";
describe("weather formatting", () => {
it("preserves valid zero temperatures", () => {
@@ -19,4 +20,17 @@ describe("weather formatting", () => {
expect(getVisibleForecastDays(7, 120, 100)).toBe(2);
expect(getVisibleForecastDays(7, 600, 100)).toBe(3);
});
it.each([
["dddd, MMMM D", "Monday, August 3"],
["dddd, D MMMM", "Monday, 3 August"],
["MMM D", "Aug 3"],
["D MMM", "3 Aug"],
["DD/MM/YYYY", "03/08/2026"],
["MM/DD/YYYY", "08/03/2026"],
["DD/MM", "03/08"],
["MM/DD", "08/03"],
] as const)("honors the saved %s date format", (pattern, expected) => {
expect(formatWeatherDate("2026-08-03", "en-US", pattern)).toBe(expected);
});
});
+34 -9
View File
@@ -19,7 +19,6 @@ import type { TranslationObject } from "@homarr/translation";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { formatLocalizedDate } from "../common/locale";
import type { WidgetProps } from "../definition";
interface WeatherIconProps {
@@ -130,16 +129,42 @@ export const formatWeatherDate = (
pattern: WidgetProps<"weather">["options"]["dateFormat"] | undefined,
) => {
if (!value) return "?";
const resolvedPattern = pattern ?? "dddd, MMMM D";
const date = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value);
if (Number.isNaN(date.getTime())) return "?";
const options: Intl.DateTimeFormatOptions = pattern?.includes("dddd")
? { weekday: "long", month: "long", day: "numeric" }
: pattern?.includes("YYYY")
? { year: "numeric", month: "2-digit", day: "2-digit" }
: pattern?.includes("MMM")
? { month: "short", day: "numeric" }
: { month: "2-digit", day: "2-digit" };
const parts = new Intl.DateTimeFormat(locale, {
weekday: "long",
year: "numeric",
month: resolvedPattern.includes("MMMM") ? "long" : resolvedPattern.includes("MMM") ? "short" : "2-digit",
day: resolvedPattern.includes("DD") ? "2-digit" : "numeric",
timeZone: "UTC",
})
.formatToParts(date)
.reduce<Record<string, string>>((result, part) => {
if (part.type !== "literal") result[part.type] = part.value;
return result;
}, {});
return formatLocalizedDate(value, locale, options);
switch (resolvedPattern) {
case "dddd, D MMMM":
return `${parts.weekday}, ${parts.day} ${parts.month}`;
case "MMM D":
return `${parts.month} ${parts.day}`;
case "D MMM":
return `${parts.day} ${parts.month}`;
case "DD/MM/YYYY":
return `${parts.day}/${parts.month}/${parts.year}`;
case "MM/DD/YYYY":
return `${parts.month}/${parts.day}/${parts.year}`;
case "DD/MM":
return `${parts.day}/${parts.month}`;
case "MM/DD":
return `${parts.month}/${parts.day}`;
case "dddd, MMMM D":
default:
return `${parts.weekday}, ${parts.month} ${parts.day}`;
}
};
interface WeatherDefinitionType {