fix(widgets): support compact stat sizes
This commit is contained in:
1 parent
bba20e4561
commit
a7a6cb6e07
9 files changed
+252
-21
No files matched your search
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user