diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index a4b84b5fa..f36dc68ea 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -14,6 +14,7 @@ import { weatherWidget } from "."; 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. diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 7451af1e7..9b4c6afb6 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -45,6 +45,7 @@ export const categoryTranslationKeys: Record = { 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", diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index badfef807..16f33874c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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" diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 9268bf6f4..24e4de866 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -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 = ({ ) : ( - + diff --git a/packages/widgets/src/beszel-system-stats/component.module.css b/packages/widgets/src/beszel-system-stats/component.module.css index 9d8b2087b..9966f37be 100644 --- a/packages/widgets/src/beszel-system-stats/component.module.css +++ b/packages/widgets/src/beszel-system-stats/component.module.css @@ -14,7 +14,7 @@ pointer-events: auto; } -@media (hover: none) { +@media (any-pointer: coarse) { .beszelStatsSystemToggle { opacity: 1; pointer-events: auto; diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index c4cee758a..614916993 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -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 ; + return ( + + ); } return ; @@ -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 = ({ 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, - {t("eventCount", { count: events.length })} + {isPending ? t("loading") : t("eventCount", { count: events.length })} - {events.length > 0 ? ( + {isPending ? ( +
+ +
+ ) : events.length > 0 ? ( ) : ( diff --git a/packages/widgets/src/common/action-target.module.css b/packages/widgets/src/common/action-target.module.css new file mode 100644 index 000000000..28f6cc672 --- /dev/null +++ b/packages/widgets/src/common/action-target.module.css @@ -0,0 +1,11 @@ +.root { + min-width: 24px; + min-height: 24px; +} + +@media (any-pointer: coarse) { + .root { + min-width: 44px; + min-height: 44px; + } +} diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index 4aaa4b625..fba5279ca 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -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 {cleanFqdn(item.fqdn) && ( 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 ( - - openJsonInBrowser(data.data)}> + + openJsonInBrowser(data.data)} + > @@ -595,6 +603,7 @@ function CustomApiWidgetInner({ {dataType !== "actionButton" && ( = ({ {!isEnabled ? ( void toggleDns(data.integration.id).catch(() => undefined)} disabled={!controlEnabled} @@ -364,6 +366,7 @@ const ControlsCard: React.FC = ({ ) : ( void toggleDns(data.integration.id).catch(() => undefined)} disabled={!controlEnabled} @@ -375,6 +378,7 @@ const ControlsCard: React.FC = ({ )} { setSelectedIntegrationIds([data.integration.id]); @@ -440,7 +444,7 @@ const ControlsCard: React.FC = ({ {layout === "md" && ( - + diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index 53a334189..cede6ffe4 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -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({ )} {canChange && ( - <> + {isEditing && ( )} - + )}
); @@ -746,13 +735,31 @@ const ColorControl = ({ defaultColor, getCurrent, update, icon: Icon, ariaLabel - + - + - + diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index 2c7470875..3481f6172 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -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, + })} > {(options.showDetails || isAdvanced) && ( - + )} {isActive && ( 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 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, + })} > diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx index 4fee07497..d83f409d0 100644 --- a/packages/widgets/src/stocks/component.tsx +++ b/packages/widgets/src/stocks/component.tsx @@ -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 } {stockValuesChange > 0 ? ( - ) : ( + ) : stockValuesChange < 0 ? ( + ) : ( + )} {data.symbol} diff --git a/packages/widgets/src/weather/component.module.css b/packages/widgets/src/weather/component.module.css new file mode 100644 index 000000000..4b7b623be --- /dev/null +++ b/packages/widgets/src/weather/component.module.css @@ -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; + } +} diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 59f16347e..249c09f3c 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -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({ ) : ( - + )} ); @@ -69,6 +71,10 @@ interface WeatherProps extends Pick, "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 ( <> - - - + + + - - - + + + - - + + {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 ( <> @@ -332,16 +340,16 @@ const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays: )} - - - + + + - - - + + + - - + + {getPreferredUnit( weather.current.temperature, @@ -351,35 +359,45 @@ const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { 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 ( {weather.daily.slice(0, maxDays).map((dayWeather, index) => ( - - - + + - {dayjs(dayWeather.time).format("dd")} - - - {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} - - - - + + + {dayjs(dayWeather.time).format("dd")} + + + + {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} + + + + + - - + + ))} );