fix(friendbar): auto-fit visible friend count so the bar stops clipping

The online-friends bar is portaled into the right toolbar nav, which sits
inside `tb-nav-clip` (fixed, `max-w-[calc(50vw-242px)]`, `overflow-x: clip`).
Each online friend adds a fixed `w-[132px]` chip, so the bar grew with every
friend up to MAX_DISPLAY_COUNT (3). Once it exceeded the clip width the right
edge was silently cut off - the scroll arrow and part of the search button
disappeared. The portal slot is `shrink-0`, so the chips never compressed;
they just overflowed and got clipped. Net effect: "more friends online =
broken bar".

Measure the room actually available between the bar's left edge and the
viewport's right edge (re-measured on resize / ResizeObserver) and derive an
effective visible count clamped 1..3, always reserving space for both arrows
and the search chip so nothing clips at any width or friend count. The bar's
left edge is stable (it follows fixed-width toolbar icons), so changing the
chip count never moves it - no measurement feedback loop.

Scroll offset now derives a clamped safeOffset used by every read, so a
stale indexOffset after the list shrinks / the fit grows renders correctly
and self-corrects on the next arrow click (no write-back effect).
This commit is contained in:
simoleo89
2026-06-06 19:01:19 +02:00
parent 110363ab1c
commit d7fabcd777
@@ -1,11 +1,23 @@
import { FC, useRef, useState } from 'react'; import { FC, useLayoutEffect, useRef, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa'; import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { LocalizeText, MessengerFriend } from '../../../../api'; import { LocalizeText, MessengerFriend } from '../../../../api';
import { FriendBarItemView } from './FriendBarItemView'; import { FriendBarItemView } from './FriendBarItemView';
import { motion, AnimatePresence, Variants } from 'framer-motion'; import { motion, AnimatePresence, Variants } from 'framer-motion';
// Hard cap on simultaneously-shown friend chips. The effective count is
// reduced below this when the bar would otherwise overflow its (clipped)
// slot in the toolbar — see the width measurement below.
const MAX_DISPLAY_COUNT = 3; const MAX_DISPLAY_COUNT = 3;
// Layout constants mirrored from FriendBarItemView / the flex gaps here, used
// to compute how many friend chips fit in the available width. A "slot" is one
// w-[132px] button plus the gap-[6px] that precedes it.
const ITEM_SLOT = 138; // 132px chip + 6px gap (friend chip and search chip)
const ARROWS_WIDTH = 52; // two w-[20px] arrows, each + 6px gap
const REQUEST_SLOT = 120; // requests chip (only present when requestsCount > 0)
const BASE_PAD = 8; // container px-[2px] + a little slack
const RIGHT_SAFE = 24; // right inset (right-0/right-3) + pr-3 safety margin
// Mirrored from Toolbar to keep physics identical // Mirrored from Toolbar to keep physics identical
const containerVariants: Variants = { const containerVariants: Variants = {
hidden: {}, hidden: {},
@@ -23,9 +35,55 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
{ {
const { onlineFriends = [], requestsCount = 0 } = props; const { onlineFriends = [], requestsCount = 0 } = props;
const [ indexOffset, setIndexOffset ] = useState(0); const [ indexOffset, setIndexOffset ] = useState(0);
const [ maxVisible, setMaxVisible ] = useState(MAX_DISPLAY_COUNT);
const elementRef = useRef<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>(null);
const hasScrollableFriends = (onlineFriends.length > MAX_DISPLAY_COUNT);
const visibleFriends = onlineFriends.slice(indexOffset, (indexOffset + MAX_DISPLAY_COUNT)); // Auto-fit the visible friend count to the room actually available between
// the bar's left edge and the right side of the viewport. The bar lives in
// a `overflow-x: clip` toolbar slot, so anything that doesn't fit would be
// silently cut off (the scroll arrow / search button disappear). The bar's
// left edge is stable (it sits after fixed-width toolbar icons), so growing
// or shrinking the chip count never moves it — no measurement feedback loop.
useLayoutEffect(() =>
{
const element = elementRef.current;
if(!element) return;
const measure = () =>
{
const left = element.getBoundingClientRect().left;
const available = window.innerWidth - left - RIGHT_SAFE;
const fixed = ARROWS_WIDTH + ITEM_SLOT /* search chip */ + BASE_PAD + ((requestsCount > 0) ? REQUEST_SLOT : 0);
const fit = Math.floor((available - fixed) / ITEM_SLOT);
const next = Math.max(1, Math.min(MAX_DISPLAY_COUNT, fit));
setMaxVisible(prev => ((prev === next) ? prev : next));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(document.documentElement);
window.addEventListener('resize', measure);
return () =>
{
observer.disconnect();
window.removeEventListener('resize', measure);
};
}, [ requestsCount, onlineFriends.length ]);
// `safeOffset` is the offset clamped to the current list/fit. Every read
// below uses it, so a stale `indexOffset` (after the list shrinks or the fit
// grows) renders correctly and self-corrects on the next arrow click — no
// write-back effect needed.
const maxOffset = Math.max(0, (onlineFriends.length - maxVisible));
const safeOffset = Math.min(indexOffset, maxOffset);
const canScrollLeft = (safeOffset > 0);
const canScrollRight = (safeOffset < maxOffset);
const visibleFriends = onlineFriends.slice(safeOffset, (safeOffset + maxVisible));
return ( return (
<motion.div <motion.div
@@ -44,10 +102,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
</motion.div> } </motion.div> }
<motion.div variants={itemVariants}> <motion.div variants={itemVariants}>
<div <div
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || (indexOffset <= 0)) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` } className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ !canScrollLeft ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
onClick={ () => onClick={ () =>
{ {
if(indexOffset > 0) setIndexOffset(indexOffset - 1); if(canScrollLeft) setIndexOffset(safeOffset - 1);
} } } }
> >
<FaChevronLeft className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" /> <FaChevronLeft className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
@@ -94,10 +152,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
<motion.div variants={itemVariants}> <motion.div variants={itemVariants}>
<div <div
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || !((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1)))) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` } className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ !canScrollRight ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
onClick={ () => onClick={ () =>
{ {
if((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1))) setIndexOffset(indexOffset + 1); if(canScrollRight) setIndexOffset(safeOffset + 1);
} } } }
> >
<FaChevronRight className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" /> <FaChevronRight className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />