fix(widgets): support compact stat sizes

This commit is contained in:
ajnart committed 2026-08-27 09:18:27 +02:00
1 parent bba20e4561
commit a7a6cb6e07
9 files changed
+252 -21

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
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
@@ -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 (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
<div className={`${classes.root} ${rootClassByCompact[compactKey]} ${rootClassByLayout[layout.state]}`}>
{visibleStats.length > 0 && (
<div
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
style={{ "--stat-cols": gridCols } as CSSProperties}
className={`${classes.grid} ${gridClassByCompact[compactKey]} ${gridClassByLayout[layout.state]}`}
style={{ "--stat-cols": layout.columns } as CSSProperties}
>
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
<div
key={optionKey}
className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]}`}
className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]} ${statTileClassByLayout[layout.state]}`}
>
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
<span className={classes.statValue}>{value}</span>
<span className={classes.statLabel}>{t(statKey as never)}</span>
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>{value}</span>
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>
{t(statKey as never)}
</span>
</div>
))}
</div>
@@ -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;
@@ -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 <WidgetEmptyState />;
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") {
@@ -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,
};
}
@@ -34,7 +34,7 @@ export const WifiVariant = ({
<>
<Group gap="xs" wrap="nowrap" mb={titleMargin}>
<IconWifi style={zoomCompensatedSize(iconSize)} />
<Text size="md" fw="bold">
<Text size="md" fw="bold" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("variants.wifi.name")}
</Text>
</Group>
@@ -34,7 +34,7 @@ export const WiredVariant = ({
<>
<Group gap="xs" wrap="nowrap" mb={titleMargin}>
<IconNetwork style={zoomCompensatedSize(iconSize)} />
<Text size="md" fw="bold">
<Text size="md" fw="bold" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("variants.wired.name")}
</Text>
</Group>
@@ -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;
@@ -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 (
<div className={classes.root}>
<div className={`${classes.root} ${rootClassByLayout[layout.state]}`}>
{showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
{visibleHeroParts.map(([partKey]) => (
@@ -184,14 +225,19 @@ export default function PaperlessNgxWidget({
)}
{visibleStatKeys.length > 0 && (
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
<div
className={`${classes.grid} ${gridClassByLayout[layout.state]}`}
style={{ "--stat-cols": layout.columns } as CSSProperties}
>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
return (
<div key={statKey} className={classes.statTile}>
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
<span className={classes.statValue}>{statValues[statKey]}</span>
<span className={classes.statLabel}>{t(statKey)}</span>
<div key={statKey} className={`${classes.statTile} ${statTileClassByLayout[layout.state]}`}>
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>
{statValues[statKey]}
</span>
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>{t(statKey)}</span>
</div>
);
})}