From a7a6cb6e07dd2421de952b952a7b06189fd871dd Mon Sep 17 00:00:00 2001 From: ajnart Date: Wed, 26 Aug 2026 20:14:39 +0200 Subject: [PATCH] fix(widgets): support compact stat sizes --- apps/docs/docs/widgets/timetable/index.mdx | 2 +- .../src/audio-stats/audio-stats-content.tsx | 62 ++++++++++++++++--- .../src/audio-stats/component.module.css | 43 +++++++++++++ .../widgets/src/audio-stats/component.tsx | 4 ++ .../widgets/src/common/compact-stat-layout.ts | 53 ++++++++++++++++ .../network-status/variants/wifi-variant.tsx | 2 +- .../network-status/variants/wired-variant.tsx | 2 +- .../src/paperless-ngx/component.module.css | 43 +++++++++++++ .../widgets/src/paperless-ngx/component.tsx | 62 ++++++++++++++++--- 9 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 packages/widgets/src/common/compact-stat-layout.ts diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx index 0855906ee..f016c234b 100644 --- a/apps/docs/docs/widgets/timetable/index.mdx +++ b/apps/docs/docs/widgets/timetable/index.mdx @@ -13,7 +13,7 @@ import { timetableWidget } from "."; -The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible regardless of widget height or width. +The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible while adapting dense rows to the available space. ### Screenshots diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx index 72d9c292f..26294e908 100644 --- a/packages/widgets/src/audio-stats/audio-stats-content.tsx +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -7,6 +7,7 @@ import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homar import { useI18n } from "@homarr/translation/client"; import { zoomCompensatedSize } from "@homarr/ui"; +import { getCompactStatLayout } from "../common/compact-stat-layout"; import classes from "./component.module.css"; import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared"; import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared"; @@ -31,6 +32,36 @@ const statTileClassByAdvanced = { false: "", } as const; +const rootClassByLayout = { + default: "", + short: classes.rootShort, + narrowShort: classes.rootShort, +} as const; + +const gridClassByLayout = { + default: "", + short: classes.gridShort, + narrowShort: classes.gridShort, +} as const; + +const statTileClassByLayout = { + default: "", + short: classes.statTileShort, + narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`, +} as const; + +const statLabelClassByLayout = { + default: "", + short: classes.statLabelShort, + narrowShort: classes.statLabelShort, +} as const; + +const statValueClassByLayout = { + default: "", + short: classes.statValueShort, + narrowShort: `${classes.statValueShort} ${classes.statValueNarrowShort}`, +} as const; + const COMPACT_SINGLE_COLUMN_BREAKPOINT = 220; interface AudioStatsContentProps { @@ -40,6 +71,7 @@ interface AudioStatsContentProps { showAllStats?: boolean; advanced?: boolean; width: number; + height: number; } export function AudioStatsContent({ @@ -49,33 +81,43 @@ export function AudioStatsContent({ showAllStats = false, advanced = false, width, + height, }: AudioStatsContentProps) { const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE); const compact = options.compactMode ?? false; const visibleStats = getVisibleStats(backend, options, stats, showAllStats); const compactKey = String(compact) as keyof typeof rootClassByCompact; const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced; - let gridCols = getGridCols(width, visibleStats.length, compact); + let defaultGridCols = getGridCols(width, visibleStats.length, compact); if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) { - gridCols = 1; + defaultGridCols = 1; } - const iconSize = getIconSize(width, compact); + const layout = getCompactStatLayout({ + width, + height, + visibleCount: visibleStats.length, + compactDisplay: !advanced, + defaultColumns: defaultGridCols, + defaultIconSize: getIconSize(width, compact), + }); return ( -
+
{visibleStats.length > 0 && (
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
- - {value} - {t(statKey as never)} + + {value} + + {t(statKey as never)} +
))}
diff --git a/packages/widgets/src/audio-stats/component.module.css b/packages/widgets/src/audio-stats/component.module.css index 46813a1e0..428323bb4 100644 --- a/packages/widgets/src/audio-stats/component.module.css +++ b/packages/widgets/src/audio-stats/component.module.css @@ -14,6 +14,11 @@ gap: clamp(2px, 1cqi, 4px); } +.rootShort { + padding: 4px; + gap: 2px; +} + .grid { display: grid; flex: 1; @@ -29,6 +34,10 @@ gap: clamp(2px, 1cqi, 4px); } +.gridShort { + gap: 2px; +} + .statTile { display: flex; flex-direction: column; @@ -54,6 +63,17 @@ border-radius: var(--mantine-radius-sm); } +.statTileShort { + gap: 2px; + padding: 3px; +} + +.statTileNarrowShort { + flex-direction: row; + gap: 2px; + padding: 1px 3px; +} + .statIcon { color: var(--mantine-color-dimmed); flex-shrink: 0; @@ -61,6 +81,7 @@ } .statLabel { + flex-shrink: 0; font-size: clamp( calc(8px * var(--board-canvas-ui-scale, 1)), calc(2.5cqi * var(--board-canvas-ui-scale, 1)), @@ -83,7 +104,20 @@ ); } +.statLabelShort { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .statValue { + flex-shrink: 0; font-size: clamp( calc(13px * var(--board-canvas-ui-scale, 1)), calc(5cqi * var(--board-canvas-ui-scale, 1)), @@ -107,6 +141,15 @@ ); } +.statValueShort { + font-size: calc(12px * var(--board-canvas-ui-scale, 1)); + line-height: 1; +} + +.statValueNarrowShort { + font-size: calc(11px * var(--board-canvas-ui-scale, 1)); +} + .emptyState { display: flex; flex: 1; diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index 2ad3eaad5..4571f5533 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -17,6 +17,7 @@ export default function AudioStatsWidget({ integrationIds, options, width, + height, displayScale = 1, displayMode = "compact", widgetRuntimeRef, @@ -40,8 +41,10 @@ export default function AudioStatsWidget({ if (!response) return ; let responsiveWidth = width; + let responsiveHeight = height; if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) { responsiveWidth *= displayScale; + responsiveHeight *= displayScale; } const summary = ( @@ -52,6 +55,7 @@ export default function AudioStatsWidget({ showAllStats={displayMode === "advanced"} advanced={displayMode === "advanced"} width={responsiveWidth} + height={responsiveHeight} /> ); if (displayMode === "compact") { diff --git a/packages/widgets/src/common/compact-stat-layout.ts b/packages/widgets/src/common/compact-stat-layout.ts new file mode 100644 index 000000000..f69ea57d9 --- /dev/null +++ b/packages/widgets/src/common/compact-stat-layout.ts @@ -0,0 +1,53 @@ +const SHORT_HEIGHT_BREAKPOINT = 120; +const NARROW_SHORT_WIDTH_BREAKPOINT = 120; +const SHORT_ICON_SIZE = 14; +const NARROW_SHORT_ICON_SIZE = 12; + +export type CompactStatLayoutState = "default" | "short" | "narrowShort"; + +interface CompactStatLayoutInput { + width: number; + height: number; + visibleCount: number; + /** Compact board display mode, independent of widget-specific compact styling options. */ + compactDisplay: boolean; + defaultColumns: number; + defaultIconSize: number; +} + +interface CompactStatLayout { + state: CompactStatLayoutState; + columns: number; + iconSize: number; +} + +export function getCompactStatLayout({ + width, + height, + visibleCount, + compactDisplay, + defaultColumns, + defaultIconSize, +}: CompactStatLayoutInput): CompactStatLayout { + if (!compactDisplay || height >= SHORT_HEIGHT_BREAKPOINT) { + return { + state: "default", + columns: defaultColumns, + iconSize: defaultIconSize, + }; + } + + if (width < NARROW_SHORT_WIDTH_BREAKPOINT) { + return { + state: "narrowShort", + columns: 1, + iconSize: NARROW_SHORT_ICON_SIZE, + }; + } + + return { + state: "short", + columns: Math.max(visibleCount, 1), + iconSize: SHORT_ICON_SIZE, + }; +} diff --git a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx index f7af08c59..93657f267 100644 --- a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx @@ -34,7 +34,7 @@ export const WifiVariant = ({ <> - + {t("variants.wifi.name")} diff --git a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx index 25b32f73c..cb5db36cd 100644 --- a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx @@ -34,7 +34,7 @@ export const WiredVariant = ({ <> - + {t("variants.wired.name")} diff --git a/packages/widgets/src/paperless-ngx/component.module.css b/packages/widgets/src/paperless-ngx/component.module.css index 3a677e9ca..033d57420 100644 --- a/packages/widgets/src/paperless-ngx/component.module.css +++ b/packages/widgets/src/paperless-ngx/component.module.css @@ -9,6 +9,11 @@ overflow: hidden; } +.rootShort { + padding: 4px; + gap: 2px; +} + .hero { display: flex; align-items: center; @@ -88,6 +93,10 @@ align-content: stretch; } +.gridShort { + gap: 2px; +} + .statTile { display: flex; flex-direction: column; @@ -102,6 +111,17 @@ overflow: hidden; } +.statTileShort { + gap: 2px; + padding: 3px; +} + +.statTileNarrowShort { + flex-direction: row; + gap: 2px; + padding: 1px 3px; +} + .statIcon { color: var(--mantine-color-dimmed); flex-shrink: 0; @@ -109,6 +129,7 @@ } .statLabel { + flex-shrink: 0; font-size: clamp( calc(8px * var(--board-canvas-ui-scale, 1)), calc(2.5cqi * var(--board-canvas-ui-scale, 1)), @@ -123,7 +144,20 @@ white-space: nowrap; } +.statLabelShort { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .statValue { + flex-shrink: 0; font-size: clamp( calc(13px * var(--board-canvas-ui-scale, 1)), calc(5cqi * var(--board-canvas-ui-scale, 1)), @@ -139,6 +173,15 @@ white-space: nowrap; } +.statValueShort { + font-size: calc(12px * var(--board-canvas-ui-scale, 1)); + line-height: 1; +} + +.statValueNarrowShort { + font-size: calc(11px * var(--board-canvas-ui-scale, 1)); +} + .emptyState { display: flex; flex: 1; diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx index c08ccf804..d15b94049 100644 --- a/packages/widgets/src/paperless-ngx/component.tsx +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; import { zoomCompensatedSize } from "@homarr/ui"; +import { getCompactStatLayout } from "../common/compact-stat-layout"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; @@ -37,6 +38,36 @@ const gridColsByWidth = [ { minWidth: 0, cols: 1 }, ] as const; +const rootClassByLayout = { + default: "", + short: classes.rootShort, + narrowShort: classes.rootShort, +} as const; + +const gridClassByLayout = { + default: "", + short: classes.gridShort, + narrowShort: classes.gridShort, +} as const; + +const statTileClassByLayout = { + default: "", + short: classes.statTileShort, + narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`, +} as const; + +const statLabelClassByLayout = { + default: "", + short: classes.statLabelShort, + narrowShort: classes.statLabelShort, +} as const; + +const statValueClassByLayout = { + default: "", + short: classes.statValueShort, + narrowShort: `${classes.statValueShort} ${classes.statValueNarrowShort}`, +} as const; + const ringSizeByWidth = [ { minWidth: 400, size: 88 }, { minWidth: 320, size: 72 }, @@ -75,6 +106,7 @@ export default function PaperlessNgxWidget({ integrationIds, options, width, + height, displayScale = 1, displayMode = "compact", }: WidgetComponentProps<"paperlessNgx">) { @@ -118,12 +150,21 @@ export default function PaperlessNgxWidget({ .filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey))); let responsiveWidth = width; + let responsiveHeight = height; if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) { responsiveWidth *= displayScale; + responsiveHeight *= displayScale; } - const gridCols = getGridCols(responsiveWidth); + const advanced = displayMode === "advanced"; + const layout = getCompactStatLayout({ + width: responsiveWidth, + height: responsiveHeight, + visibleCount: visibleStatKeys.length, + compactDisplay: !advanced, + defaultColumns: getGridCols(responsiveWidth), + defaultIconSize: getIconSize(responsiveWidth), + }); const ringSize = getRingSize(responsiveWidth); - const iconSize = getIconSize(responsiveWidth); const ringLabelSize = getRingLabelSize(ringSize); const hasContent = showHero || visibleStatKeys.length > 0; @@ -166,7 +207,7 @@ export default function PaperlessNgxWidget({ } as const; return ( -
+
{showHero && (
{visibleHeroParts.map(([partKey]) => ( @@ -184,14 +225,19 @@ export default function PaperlessNgxWidget({ )} {visibleStatKeys.length > 0 && ( -
+
{visibleStatKeys.map((statKey) => { const Icon = statIcons[statKey]; return ( -
- - {statValues[statKey]} - {t(statKey)} +
+ + + {statValues[statKey]} + + {t(statKey)}
); })}