fix: refine widget interactions
This commit is contained in:
1 parent
836a4cd46b
commit
56cc021f98
21 files changed
+163
-85
No files matched your search
@@ -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'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;
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1871,6 +1871,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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user