Compare commits

...
Author SHA1 Message Date
ajnart 53fcc6997c style: auto-fix lint and format 2026-06-07 22:04:04 +00:00
Thomas Camlong 69e64356bb feat(widgets): add size-aware widget hook
Introduce useWidgetSize() hook and WidgetSizeProvider context that
exposes grid dimensions (gridWidth, gridHeight) to every widget,
enabling responsive rendering based on how many grid cells a widget
occupies.

Weather widget updated as proof of concept: when placed in a 1x1 or
1xN cell with forecast enabled, it switches to a compact layout that
hides the forecast row, wind speed, and city name to prevent clipping.
2026-06-08 00:02:57 +02:00
4 changed files with 60 additions and 18 deletions

No files matched your search

@@ -1,6 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack"; import type { GridItemHTMLElement } from "@homarr/gridstack";
import { WidgetSizeProvider } from "@homarr/widgets";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content"; import { BoardItemContent } from "../items/item-content";
@@ -18,7 +19,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770 * @see https://github.com/homarr-labs/homarr/pull/1770
*/ */
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => { return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) { if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset; return itemA.xOffset - itemB.xOffset;
} }
@@ -59,7 +60,13 @@ const Item = ({ item, innerRef }: ItemProps) => {
minWidth={minWidth} minWidth={minWidth}
minHeight={minHeight} minHeight={minHeight}
> >
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />} {item.type === "item" ? (
<WidgetSizeProvider gridWidth={item.width} gridHeight={item.height}>
<BoardItemContent item={item} />
</WidgetSizeProvider>
) : (
<BoardDynamicSection section={item} />
)}
</GridStackItem> </GridStackItem>
); );
}; };
+1
View File
@@ -53,6 +53,7 @@ import * as weather from "./weather";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition"; export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps }; export type { WidgetComponentProps };
export { useWidgetSize, WidgetSizeProvider } from "./widget-size";
export const widgetImports = { export const widgetImports = {
clock, clock,
+25 -16
View File
@@ -11,6 +11,7 @@ import { metricToImperial } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { useWidgetSize } from "../widget-size";
import { WeatherDescription, WeatherIcon } from "./icon"; import { WeatherDescription, WeatherIcon } from "./icon";
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) { export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
@@ -29,19 +30,23 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
onData: (data) => utils.widget.weather.atLocation.setData(input, data), onData: (data) => utils.widget.weather.atLocation.setData(input, data),
}); });
const { gridWidth, gridHeight } = useWidgetSize();
const isCompact = gridWidth <= 1 || gridHeight <= 1;
const showForecast = options.hasForecast && !isCompact;
return ( return (
<Stack <Stack
align="center" align="center"
gap="sm" gap={isCompact ? "xs" : "sm"}
justify="center" justify="center"
w="100%" w="100%"
h="100%" h="100%"
style={{ pointerEvents: isEditMode ? "none" : undefined }} style={{ pointerEvents: isEditMode ? "none" : undefined }}
> >
{options.hasForecast ? ( {showForecast ? (
<WeeklyForecast weather={weather} options={options} /> <WeeklyForecast weather={weather} options={options} />
) : ( ) : (
<DailyWeather weather={weather} options={options} /> <DailyWeather weather={weather} options={options} compact={isCompact} />
)} )}
</Stack> </Stack>
); );
@@ -51,24 +56,28 @@ interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options">
weather: RouterOutputs["widget"]["weather"]["atLocation"]; weather: RouterOutputs["widget"]["weather"]["atLocation"];
} }
const DailyWeather = ({ options, weather }: WeatherProps) => { const DailyWeather = ({ options, weather, compact }: WeatherProps & { compact?: boolean }) => {
const t = useScopedI18n("widget.weather"); const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const iconSize = compact ? 20 : 30;
const tempSize = compact ? 20 : 30;
const detailSize = compact ? 13 : 16;
return ( return (
<> <>
<Group className="weather-day-group" gap="sm"> <Group className="weather-day-group" gap="sm">
<HoverCard> <HoverCard>
<HoverCard.Target> <HoverCard.Target>
<Box> <Box>
<WeatherIcon size={30} code={weather.current.weathercode} /> <WeatherIcon size={iconSize} code={weather.current.weathercode} />
</Box> </Box>
</HoverCard.Target> </HoverCard.Target>
<HoverCard.Dropdown> <HoverCard.Dropdown>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} /> <WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</HoverCard.Dropdown> </HoverCard.Dropdown>
</HoverCard> </HoverCard>
<Text fz={30}> <Text fz={tempSize}>
{getPreferredUnit( {getPreferredUnit(
weather.current.temperature, weather.current.temperature,
options.isFormatFahrenheit, options.isFormatFahrenheit,
@@ -77,10 +86,10 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
</Text> </Text>
</Group> </Group>
<Stack gap="xs" align="center"> <Stack gap="xs" align="center">
{options.showCurrentWindSpeed && ( {options.showCurrentWindSpeed && !compact && (
<Group className="weather-current-wind-speed-group" wrap="nowrap" gap="xs"> <Group className="weather-current-wind-speed-group" wrap="nowrap" gap="xs">
<IconWind size={16} /> <IconWind size={detailSize} />
<Text fz={16}> <Text fz={detailSize}>
{t("currentWindSpeed", { {t("currentWindSpeed", {
currentWindSpeed: (options.useImperialSpeed currentWindSpeed: (options.useImperialSpeed
? metricToImperial(weather.current.windspeed) ? metricToImperial(weather.current.windspeed)
@@ -95,8 +104,8 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
)} )}
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm"> <Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<IconArrowUpRight size={16} /> <IconArrowUpRight size={detailSize} />
<Text fz={16}> <Text fz={detailSize}>
{getPreferredUnit( {getPreferredUnit(
weather.daily[0]?.maxTemp, weather.daily[0]?.maxTemp,
options.isFormatFahrenheit, options.isFormatFahrenheit,
@@ -105,8 +114,8 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
</Text> </Text>
</Group> </Group>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<IconArrowDownRight size={16} /> <IconArrowDownRight size={detailSize} />
<Text fz={16}> <Text fz={detailSize}>
{getPreferredUnit( {getPreferredUnit(
weather.daily[0]?.minTemp, weather.daily[0]?.minTemp,
options.isFormatFahrenheit, options.isFormatFahrenheit,
@@ -116,11 +125,11 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
</Group> </Group>
</Group> </Group>
</Stack> </Stack>
{options.showCity && ( {options.showCity && !compact && (
<> <>
<Group className="weather-city-group" wrap="nowrap" gap="xs"> <Group className="weather-city-group" wrap="nowrap" gap="xs">
<IconMapPin size={16} /> <IconMapPin size={detailSize} />
<Text fz={16} style={{ whiteSpace: "nowrap" }}> <Text fz={detailSize} style={{ whiteSpace: "nowrap" }}>
{options.location.name} {options.location.name}
</Text> </Text>
</Group> </Group>
+25
View File
@@ -0,0 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
export interface WidgetSize {
gridWidth: number;
gridHeight: number;
}
const WidgetSizeContext = createContext<WidgetSize | null>(null);
export const useWidgetSize = () => {
const ctx = useContext(WidgetSizeContext);
if (!ctx) throw new Error("useWidgetSize must be used within WidgetSizeProvider");
return ctx;
};
interface WidgetSizeProviderProps extends WidgetSize {
children: ReactNode;
}
export const WidgetSizeProvider = ({ gridWidth, gridHeight, children }: WidgetSizeProviderProps) => (
<WidgetSizeContext.Provider value={{ gridWidth, gridHeight }}>{children}</WidgetSizeContext.Provider>
);