fix: address widget review findings

This commit is contained in:
Thomas Camlong committed 2026-08-02 14:35:01 +02:00
1 parent 1e0bf40040
commit 7b7045202d
26 files changed
+247 -94

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ import { weatherWidget } from ".";
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
The Weather Widget uses your location to display the current, highest and lowest temperature.
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
The advanced view always combines current conditions with the full seven-day forecast, including humidity, wind,
gusts, sunrise and sunset. Both views reuse the same cached Open Meteo response.
@@ -45,6 +45,7 @@ export const categoryTranslationKeys: Record<string, string> = {
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",
+7
View File
@@ -1012,6 +1012,7 @@
"smartHomeServer": "Smart Home",
"indexerManager": "Indexer",
"healthMonitoring": "Health",
"beszel": "Beszel",
"search": "Search",
"mediaTranscoding": "Transcoding",
"networkController": "Network",
@@ -2299,6 +2300,7 @@
"previousMonth": "Show previous month",
"nextMonth": "Show next month",
"eventCount": "{count, plural, one {# event} other {# events}}",
"loading": "Loading calendar events...",
"noEvents": "No events in this month"
},
"option": {
@@ -2357,6 +2359,8 @@
}
},
"currentWindSpeed": "{currentWindSpeed} {unit}",
"details": "Show weather details",
"detailsFor": "Show weather details for {date}",
"advanced": {
"forecast": "7-day forecast",
"day": "Day",
@@ -3684,6 +3688,8 @@
"starsCount": "Stars",
"forksCount": "Forks",
"issuesCount": "Open Issues",
"expandDetails": "Show details for {name}",
"collapseDetails": "Hide details for {name}",
"markViewed": "Mark as viewed",
"openProjectPage": "Open Project Page",
"openReleasePage": "Open Release Page",
@@ -4087,6 +4093,7 @@
"executeSuccess": "Action executed successfully",
"executeFailed": "Action failed. Check the widget configuration.",
"actions": {
"openJsonViewer": "Open in browser JSON viewer",
"pausePolling": "Pause polling",
"resumePolling": "Resume polling",
"togglePolling": "Pause or resume polling"
@@ -18,6 +18,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import "react-quill-new/dist/quill.snow.css";
import "./anchor-note.css";
@@ -353,7 +354,14 @@ const AnchorNoteWidgetContent = ({
</Button>
) : (
<Tooltip label={t("edit")}>
<ActionIcon aria-label={t("edit")} size="md" variant="light" onClick={handleEdit} disabled={isUpdating}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t("edit")}
size="md"
variant="light"
onClick={handleEdit}
disabled={isUpdating}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
@@ -14,7 +14,7 @@
pointer-events: auto;
}
@media (hover: none) {
@media (any-pointer: coarse) {
.beszelStatsSystemToggle {
opacity: 1;
pointer-events: auto;
+35 -6
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { ActionIcon, Box, Group, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core";
import { ActionIcon, Box, Center, Group, Loader, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
@@ -15,6 +15,7 @@ import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -34,7 +35,16 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
if (props.integrationIds.length === 0) {
setWidgetRuntimeQueries(props.widgetStateRef, []);
return <CalendarBase {...props} events={[]} failedIntegrations={[]} month={month} setMonth={setMonth} />;
return (
<CalendarBase
{...props}
events={[]}
failedIntegrations={[]}
isPending={false}
month={month}
setMonth={setMonth}
/>
);
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
@@ -62,7 +72,7 @@ const FetchCalendar = ({
showUnmonitored: options.showUnmonitored,
};
setWidgetRuntimeQueries(widgetStateRef, [getQueryKey(clientApi.widget.calendar.findAllEvents, input, "query")]);
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const { data, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
const failedIntegrations =
@@ -75,6 +85,7 @@ const FetchCalendar = ({
isEditMode={isEditMode}
events={events}
failedIntegrations={failedIntegrations}
isPending={isPending}
month={month}
setMonth={setMonth}
options={options}
@@ -87,6 +98,7 @@ interface CalendarBaseProps {
isEditMode: boolean;
events: CalendarEvent[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
isPending: boolean;
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
@@ -97,6 +109,7 @@ const CalendarBase = ({
isEditMode,
events,
failedIntegrations,
isPending,
month,
setMonth,
options,
@@ -133,6 +146,7 @@ const CalendarBase = ({
<CalendarAgenda
events={agendaEvents}
failedIntegrations={failedIntegrations}
isPending={isPending}
isEditMode={isEditMode}
locale={locale}
month={month}
@@ -227,13 +241,22 @@ const CalendarBase = ({
interface CalendarAgendaProps {
events: CalendarEvent[];
failedIntegrations: CalendarBaseProps["failedIntegrations"];
isPending: boolean;
isEditMode: boolean;
locale: string;
month: Date;
setMonth: (date: Date) => void;
}
const CalendarAgenda = ({ events, failedIntegrations, isEditMode, locale, month, setMonth }: CalendarAgendaProps) => {
const CalendarAgenda = ({
events,
failedIntegrations,
isPending,
isEditMode,
locale,
month,
setMonth,
}: CalendarAgendaProps) => {
const t = useScopedI18n("widget.calendar.advanced");
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
@@ -243,6 +266,7 @@ const CalendarAgenda = ({ events, failedIntegrations, isEditMode, locale, month,
<Group gap="xs" wrap="nowrap">
<Tooltip label={t("previousMonth")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
size="lg"
aria-label={t("previousMonth")}
@@ -257,11 +281,12 @@ const CalendarAgenda = ({ events, failedIntegrations, isEditMode, locale, month,
{monthLabel}
</Text>
<Text size="xs" c="dimmed">
{t("eventCount", { count: events.length })}
{isPending ? t("loading") : t("eventCount", { count: events.length })}
</Text>
</Stack>
<Tooltip label={t("nextMonth")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
size="lg"
aria-label={t("nextMonth")}
@@ -276,7 +301,11 @@ const CalendarAgenda = ({ events, failedIntegrations, isEditMode, locale, month,
</Group>
<Box style={{ flex: 1, minHeight: 0 }}>
{events.length > 0 ? (
{isPending ? (
<Center component="output" h="100%" aria-label={t("loading")}>
<Loader size="sm" />
</Center>
) : events.length > 0 ? (
<CalendarEventList events={events} groupByDate locale={locale} fillHeight />
) : (
<Stack h="100%" align="center" justify="center" p="md">
@@ -0,0 +1,11 @@
.root {
min-width: 24px;
min-height: 24px;
}
@media (any-pointer: coarse) {
.root {
min-width: 44px;
min-height: 44px;
}
}
@@ -3,6 +3,7 @@
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconFileText, IconLink } from "@tabler/icons-react";
import actionTargetClasses from "../common/action-target.module.css";
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
interface ResourceRowProps {
@@ -58,6 +59,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
<Group wrap="nowrap" gap={4} ml={16}>
{cleanFqdn(item.fqdn) && (
<ActionIcon
className={actionTargetClasses.root}
component="a"
href={cleanFqdn(item.fqdn)}
target="_blank"
@@ -72,6 +74,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
)}
{!isTiny && logsUrl && (
<ActionIcon
className={actionTargetClasses.root}
component="a"
href={logsUrl}
target="_blank"
@@ -6,6 +6,7 @@ import { IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getBadgeColor } from "./coolify-utils";
interface ServersSectionProps {
@@ -40,6 +41,7 @@ export function ServersSection({
</Badge>
</Group>
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${showIp ? tCommon("action.hide") : tCommon("action.show")} IP`}
size="xs"
variant="subtle"
+11 -2
View File
@@ -32,6 +32,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
@@ -343,14 +344,21 @@ function openJsonInBrowser(json: unknown) {
}
function RawDisplay({ data, displayMode, height }: CustomDisplayProps) {
const t = useScopedI18n("widget.customApi");
const maxHeight = displayMode === "advanced" ? Math.max(160, height - 80) : ((data.maxHeight as number) ?? 300);
const jsonString = JSON.stringify(data.data, null, 2);
return (
<Stack gap={4} p="xs" h="100%">
<Group justify="flex-end">
<Tooltip label="Open in browser JSON viewer">
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
<Tooltip label={t("actions.openJsonViewer")}>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
size="sm"
aria-label={t("actions.openJsonViewer")}
onClick={() => openJsonInBrowser(data.data)}
>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
@@ -595,6 +603,7 @@ function CustomApiWidgetInner({
{dataType !== "actionButton" && (
<Tooltip label={pollingLabel}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={pollingLabel}
variant="subtle"
loading={isFetching}
@@ -33,6 +33,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
@@ -353,6 +354,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
<Group gap="xs" grow wrap="nowrap" w="100%">
{!isEnabled ? (
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.enableAll")}: ${data.integration.name}`}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
@@ -364,6 +366,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</ActionIcon>
) : (
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.disableAll")}: ${data.integration.name}`}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
@@ -375,6 +378,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</ActionIcon>
)}
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
@@ -440,7 +444,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
{layout === "md" && (
<ActionIcon
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
className="dns-hole-controls-item-timer-button"
className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)}
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
color="yellow"
@@ -43,6 +43,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
@@ -409,6 +410,7 @@ export default function DockerWidget({
)}
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
className={actionTargetClasses.root}
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
@@ -443,6 +445,7 @@ function ContainerMenuButton({
<Menu opened={opened} onChange={setOpened} closeOnItemClick={false} withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
color="gray"
size="sm"
@@ -25,7 +25,7 @@
pointer-events: auto;
}
@media (hover: none) {
@media (any-pointer: coarse) {
.infoActionCompact {
opacity: 1;
pointer-events: auto;
@@ -42,6 +42,7 @@ import { useI18n } from "@homarr/translation/client";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring";
import { GpuRing } from "./rings/gpu-ring";
@@ -118,7 +119,7 @@ export const SystemHealthMonitoring = ({
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
>
<ActionIcon
className="health-monitoring-information-icon-avatar"
className={combineClasses("health-monitoring-information-icon-avatar", actionTargetClasses.root)}
variant={"light"}
color="var(--mantine-color-text)"
size="sm"
@@ -35,7 +35,7 @@
}
}
@media (hover: none) {
@media (any-pointer: coarse) {
.testAction {
opacity: 1;
}
@@ -10,6 +10,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import classes from "./component.module.css";
export default function IndexerManagerWidget({
@@ -83,7 +84,7 @@ export default function IndexerManagerWidget({
)}
<Tooltip label={t("widget.indexerManager.testAll")}>
<ActionIcon
className={combineClasses("indexer-manager-test-action-icon", classes.testAction)}
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
size="sm"
radius={board.itemRadius}
variant="light"
@@ -24,19 +24,12 @@
opacity: 1;
}
@media (hover: none) {
@media (any-pointer: coarse) {
.pendingActions {
opacity: 1;
}
}
@media (pointer: coarse) {
.pendingActions button {
min-width: 36px;
min-height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.card,
.pendingActions {
@@ -14,6 +14,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
@@ -83,7 +84,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
return (
<Tooltip label={t("action.search.label")}>
<ActionIcon
className={searchClasses.searchButton}
className={`${searchClasses.searchButton} ${actionTargetClasses.root}`}
variant="light"
size="sm"
aria-label={t("action.search.label")}
@@ -255,7 +256,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
>
<Tooltip label={t("pending.approve")}>
<ActionIcon
className="mediaRequests-list-item-pending-button-approve"
className={`mediaRequests-list-item-pending-button-approve ${actionTargetClasses.root}`}
variant="light"
color="green"
size="sm"
@@ -270,7 +271,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
</Tooltip>
<Tooltip label={t("pending.decline")}>
<ActionIcon
className="mediaRequests-list-item-pending-button-decline"
className={`mediaRequests-list-item-pending-button-decline ${actionTargetClasses.root}`}
variant="light"
color="red"
size="sm"
@@ -17,7 +17,11 @@
opacity: 1;
}
@media (hover: none) {
@media (any-pointer: coarse) {
.searchRoot {
padding-top: 52px;
}
.searchButton {
opacity: 1;
}
@@ -22,6 +22,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
@@ -220,7 +221,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
return (
<Tooltip label={t("action.search.label")}>
<ActionIcon
className={searchClasses.searchButton}
className={`${searchClasses.searchButton} ${actionTargetClasses.root}`}
variant="light"
size="sm"
aria-label={t("action.search.label")}
@@ -6,6 +6,8 @@ import { IconHeartbeat } from "@tabler/icons-react";
import type { TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
interface HealthCheckStatusProps {
statistics: TdarrStatistics;
}
@@ -23,7 +25,7 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
return (
<Popover position="bottom" width={250} shadow="sm" withArrow>
<Popover.Target>
<ActionIcon variant="subtle" size="sm" aria-label={t("title")}>
<ActionIcon className={actionTargetClasses.root} variant="subtle" size="sm" aria-label={t("title")}>
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
<IconHeartbeat size={16} />
</Indicator>
+26 -19
View File
@@ -68,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
@@ -526,18 +527,12 @@ export function Notebook({
</Group>
)}
{canChange && (
<>
<Stack pos="absolute" top={7} right={7} gap={7} style={{ zIndex: 1 }}>
<ActionIcon
className="homarr-notebook-action"
className={`homarr-notebook-action ${actionTargetClasses.root}`}
data-visible={isEditing || displayMode === "advanced" || undefined}
title={isEditing ? t("common.action.save") : t("common.action.edit")}
aria-label={isEditing ? t("common.action.save") : t("common.action.edit")}
style={{
zIndex: 1,
}}
top={7}
right={7}
pos="absolute"
color={primaryColor}
variant="light"
size={30}
@@ -549,16 +544,10 @@ export function Notebook({
</ActionIcon>
{isEditing && (
<ActionIcon
className="homarr-notebook-action"
className={`homarr-notebook-action ${actionTargetClasses.root}`}
data-visible
title={t("common.action.cancel")}
aria-label={t("common.action.cancel")}
style={{
zIndex: 1,
}}
top={44}
right={7}
pos="absolute"
color={primaryColor}
variant="light"
size={30}
@@ -568,7 +557,7 @@ export function Notebook({
<IconX {...iconProps} />
</ActionIcon>
)}
</>
</Stack>
)}
</Box>
);
@@ -746,13 +735,31 @@ const ColorControl = ({ defaultColor, getCurrent, update, icon: Icon, ariaLabel
<Stack gap={8}>
<ColorPicker value={color} onChange={setColor} format="hexa" swatches={palette} swatchesPerRow={6} />
<Group justify="right" gap={8}>
<ActionIcon title={t("common.action.cancel")} variant="default" onClick={close}>
<ActionIcon
className={actionTargetClasses.root}
title={t("common.action.cancel")}
aria-label={t("common.action.cancel")}
variant="default"
onClick={close}
>
<IconX stroke={1.5} size="1rem" />
</ActionIcon>
<ActionIcon title={t("common.action.apply")} variant="default" onClick={handleApplyColor}>
<ActionIcon
className={actionTargetClasses.root}
title={t("common.action.apply")}
aria-label={t("common.action.apply")}
variant="default"
onClick={handleApplyColor}
>
<IconCheck stroke={1.5} size="1rem" />
</ActionIcon>
<ActionIcon title={t("widget.notebook.popover.clearColor")} variant="default" onClick={handleClearColor}>
<ActionIcon
className={actionTargetClasses.root}
title={t("widget.notebook.popover.clearColor")}
aria-label={t("widget.notebook.popover.clearColor")}
variant="default"
onClick={handleClearColor}
>
<IconCircleOff stroke={1.5} size="1rem" />
</ActionIcon>
</Group>
+16 -2
View File
@@ -221,6 +221,10 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
})}
onClick={() => toggleExpandedDisplay(repository)}
aria-expanded={isActive}
aria-label={t(isActive ? "collapseDetails" : "expandDetails", {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: repository.name || repository.identifier,
})}
>
<Group p="xs">
<MaskedOrNormalImage
@@ -321,7 +325,11 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
</Group>
</UnstyledButton>
{(options.showDetails || isAdvanced) && (
<DetailsDisplay repository={repository} toggleExpandedDisplay={toggleExpandedDisplay} />
<DetailsDisplay
repository={repository}
isExpanded={isActive}
toggleExpandedDisplay={toggleExpandedDisplay}
/>
)}
{isActive && (
<ExpandedDisplay
@@ -342,10 +350,11 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
interface DetailsDisplayProps {
repository: ReleasesRepositoryResponse;
isExpanded: boolean;
toggleExpandedDisplay: (repository: ReleasesRepositoryResponse) => void;
}
const DetailsDisplay = ({ repository, toggleExpandedDisplay }: DetailsDisplayProps) => {
const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: DetailsDisplayProps) => {
const t = useScopedI18n("widget.releases");
const formatter = useFormatter();
@@ -355,6 +364,11 @@ const DetailsDisplay = ({ repository, toggleExpandedDisplay }: DetailsDisplayPro
<UnstyledButton
className={combineClasses("releases-repository-details", classes.releasesRepositoryDetails)}
onClick={() => toggleExpandedDisplay(repository)}
aria-expanded={isExpanded}
aria-label={t(isExpanded ? "collapseDetails" : "expandDetails", {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: repository.name || repository.identifier,
})}
>
<Group justify="space-between" p={5}>
<Group className="releases-repository-details-icon-wrapper">
+4 -2
View File
@@ -2,7 +2,7 @@
import { Sparkline } from "@mantine/charts";
import { Flex, Stack, Text, Title, useMantineTheme } from "@mantine/core";
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
@@ -105,8 +105,10 @@ export default function StockPriceWidget({ options, width, height, displayMode }
<Text size="xl" fw={700} lh="0.715">
{stockValuesChange > 0 ? (
<IconTrendingUp size="1.5rem" color={theme.colors.green[7]} />
) : (
) : stockValuesChange < 0 ? (
<IconTrendingDown size="1.5rem" color={theme.colors.red[7]} />
) : (
<IconMinus size="1.5rem" color={theme.colors.gray[6]} />
)}
{data.symbol}
</Text>
@@ -0,0 +1,31 @@
.detailsButton,
.forecastButton {
color: inherit;
border-radius: var(--mantine-radius-sm);
}
.detailsButton {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
}
.forecastButton {
display: block;
}
.detailsButton:focus-visible,
.forecastButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@media (any-pointer: coarse) {
.detailsButton,
.forecastButton {
min-width: 44px;
min-height: 44px;
}
}
+60 -42
View File
@@ -1,7 +1,7 @@
"use client";
import { useId } from "react";
import { Box, Group, HoverCard, Stack, Table, Text } from "@mantine/core";
import { Group, Popover, Stack, Table, Text, UnstyledButton } from "@mantine/core";
import {
IconArrowDownRight,
IconArrowUpRight,
@@ -23,6 +23,7 @@ import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "./animated-icon";
import { WeatherDescription } from "./icon";
import classes from "./component.module.css";
export default function WeatherWidget({
isEditMode,
@@ -56,10 +57,11 @@ export default function WeatherWidget({
<WeeklyForecast
weather={weather}
options={options}
isEditMode={isEditMode}
maxDays={getVisibleForecastDays(options.forecastDayCount, width, height)}
/>
) : (
<DailyWeather weather={weather} options={options} />
<DailyWeather weather={weather} options={options} isEditMode={isEditMode} />
)}
</Stack>
);
@@ -69,6 +71,10 @@ interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options">
weather: RouterOutputs["widget"]["weather"]["atLocation"];
}
interface CompactWeatherProps extends WeatherProps {
isEditMode: boolean;
}
const AdvancedWeather = ({ options, weather }: WeatherProps) => {
const forecastHeadingId = useId();
const t = useScopedI18n("widget.weather");
@@ -234,23 +240,23 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => {
);
};
const DailyWeather = ({ options, weather }: WeatherProps) => {
const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) => {
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
return (
<>
<Group className="weather-day-group" gap="sm">
<HoverCard>
<HoverCard.Target>
<Box>
<Popover position="bottom" withArrow shadow="md">
<Popover.Target>
<UnstyledButton className={classes.detailsButton} aria-label={t("details")} disabled={isEditMode}>
<AnimatedWeatherIcon size={30} code={weather.current.weathercode} />
</Box>
</HoverCard.Target>
<HoverCard.Dropdown>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</HoverCard.Dropdown>
</HoverCard>
</Popover.Dropdown>
</Popover>
<Text fz={30}>
{getPreferredUnit(
weather.current.temperature,
@@ -319,7 +325,9 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
);
};
const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays: number }) => {
const WeeklyForecast = ({ options, weather, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) => {
const t = useScopedI18n("widget.weather");
return (
<>
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="md">
@@ -332,16 +340,16 @@ const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays:
</Group>
)}
<Group gap="xs" wrap="nowrap">
<HoverCard>
<HoverCard.Target>
<Box>
<Popover position="bottom" withArrow shadow="md">
<Popover.Target>
<UnstyledButton className={classes.detailsButton} aria-label={t("details")} disabled={isEditMode}>
<AnimatedWeatherIcon size={16} code={weather.current.weathercode} />
</Box>
</HoverCard.Target>
<HoverCard.Dropdown>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</HoverCard.Dropdown>
</HoverCard>
</Popover.Dropdown>
</Popover>
<Text fz={16}>
{getPreferredUnit(
weather.current.temperature,
@@ -351,35 +359,45 @@ const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays:
</Text>
</Group>
</Group>
<Forecast weather={weather} options={options} maxDays={maxDays} />
<Forecast weather={weather} options={options} isEditMode={isEditMode} maxDays={maxDays} />
</>
);
};
function Forecast({ weather, options, maxDays }: WeatherProps & { maxDays: number }) {
function Forecast({ weather, options, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) {
const dateFormat = options.dateFormat;
const t = useScopedI18n("widget.weather");
return (
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="sm">
{weather.daily.slice(0, maxDays).map((dayWeather, index) => (
<HoverCard key={dayWeather.time} withArrow shadow="md">
<HoverCard.Target>
<Stack
className={combineClasses(
"weather-forecast-day-stack",
`weather-forecast-day${index}`,
`weather-forecast-weekday${dayjs(dayWeather.time).day()}`,
)}
gap="0"
align="center"
<Popover key={dayWeather.time} position="bottom" withArrow shadow="md">
<Popover.Target>
<UnstyledButton
className={classes.forecastButton}
aria-label={t("detailsFor", { date: dayjs(dayWeather.time).format(dateFormat) })}
disabled={isEditMode}
>
<Text fz="xl">{dayjs(dayWeather.time).format("dd")}</Text>
<AnimatedWeatherIcon size={16} code={dayWeather.weatherCode} />
<Text fz={16}>
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
</Stack>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack
component="span"
className={combineClasses(
"weather-forecast-day-stack",
`weather-forecast-day${index}`,
`weather-forecast-weekday${dayjs(dayWeather.time).day()}`,
)}
gap="0"
align="center"
>
<Text component="span" fz="xl">
{dayjs(dayWeather.time).format("dd")}
</Text>
<AnimatedWeatherIcon size={16} code={dayWeather.weatherCode} />
<Text component="span" fz={16}>
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
</Stack>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<WeatherDescription
useImperialSpeed={options.useImperialSpeed}
dateFormat={dateFormat}
@@ -401,8 +419,8 @@ function Forecast({ weather, options, maxDays }: WeatherProps & { maxDays: numbe
maxWindGusts={dayWeather.maxWindGusts}
humidity={dayWeather.humidity}
/>
</HoverCard.Dropdown>
</HoverCard>
</Popover.Dropdown>
</Popover>
))}
</Group>
);