Files
paseo/packages/app/src/components/sidebar/host-badge.tsx
T
Mohamed Boudra 4406bf588e fix(app): preserve host badge identity
Keep host badges visible under row pressure and apply the selected host color consistently to the icon, label, and tinted background.
2026-08-03 14:08:59 +02:00

117 lines
3.5 KiB
TypeScript

import { Text, View, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Server } from "lucide-react-native";
import { HOST_COLORS, type HostColor } from "@/hosts/appearance";
import { identityColor, identityTint } from "@/styles/identity-colors";
import type { Theme } from "@/styles/theme";
const ThemedServer = withUnistyles(Server);
const MAX_NAMED_BADGE_WIDTH = 96;
interface HostBadgeColorOverride {
badge: ViewStyle | null;
text: TextStyle | null;
}
function buildColorOverrides(): Record<HostColor, HostBadgeColorOverride> {
const byColor = {} as Record<HostColor, HostBadgeColorOverride>;
for (const color of HOST_COLORS) {
byColor[color] =
color === "none"
? { badge: null, text: null }
: {
badge: { backgroundColor: identityTint(color) },
text: { color: identityColor(color) },
};
}
return byColor;
}
// The identity table is theme-independent, so its icon mapping is fixed for the life of the
// module. It must not be rebuilt per render: a fresh `uniProps` identity makes withUnistyles
// re-subscribe every pass.
function buildIconMappings(): Record<HostColor, (theme: Theme) => { color: string }> {
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
for (const color of HOST_COLORS) {
byColor[color] =
color === "none"
? (theme: Theme) => ({ color: theme.colors.foregroundMuted })
: () => ({ color: identityColor(color) });
}
return byColor;
}
const COLOR_OVERRIDES = buildColorOverrides();
const ICON_MAPPINGS = buildIconMappings();
/**
* Which host a workspace lives on, as a pill after the title. Only rendered in multi-host
* setups — see selectHostBadges. It's a badge rather than a second line of text because the
* row has to stay scannable at one line per workspace.
*
*/
export function HostBadge({
serverId,
label,
color,
showLabel,
}: {
serverId: string;
label: string;
color: HostColor;
showLabel: boolean;
}) {
const override = COLOR_OVERRIDES[color];
return (
<View
style={[
hostBadgeStyles.badge,
showLabel ? null : hostBadgeStyles.badgeIconOnly,
override.badge,
]}
testID={`sidebar-host-badge-${serverId}`}
>
<ThemedServer size={9} uniProps={ICON_MAPPINGS[color]} />
{showLabel ? (
<Text style={[hostBadgeStyles.text, override.text]} numberOfLines={1}>
{label}
</Text>
) : null}
</View>
);
}
const hostBadgeStyles = StyleSheet.create((theme) => ({
// Sized off the title's 20pt line box so the pill never grows the row. A named badge must
// leave room for the workspace title and trailing actions, even when a host has an arbitrary
// custom label. The full host name remains in the row's accessible label and hover card.
badge: {
flexDirection: "row",
alignItems: "center",
gap: 3,
height: 16,
paddingLeft: 4,
paddingRight: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
maxWidth: MAX_NAMED_BADGE_WIDTH,
minWidth: 0,
flexShrink: 0,
},
// Icon-only drops the label, so the asymmetric padding that made room for text leaves a
// lopsided pill. Symmetric padding recenters the icon.
badgeIconOnly: {
paddingLeft: 4,
paddingRight: 4,
flexShrink: 0,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 0,
},
}));