fix(ui): AvatarStack — radial overlap layout for 3-10 members (replaces 2x2 grid)
The old 2x2 grid for 3+ members produced a cramped, misaligned look
(circles cut off inside their own border, no overlap, just four small
disks in a grid). The 2-member overlap aesthetic — equal-size tiles
that overlap diagonally — was the visual gold standard but only
existed for that one count.
Extend the same "huddle of overlapping faces" aesthetic to all
member counts:
• 3 members → equilateral triangle of 62%-size tiles (top, bl, br)
• 4 members → diamond of 58%-size tiles (top, right, bottom, left)
• 5-10 members → diamond with `+N` overflow occupying the bottom
slot (z-index boosted above neighbors so the digit is never clipped)
Tiles are positioned radially around the box center on a circle of
radius (S − T) / 2 so the farthest edges of each tile graze the
bounding rect — no clipping, no wasted whitespace. Z-index descends
clockwise from the top slot so each tile tucks slightly under its
clockwise neighbor, mirroring the 2-member z-stack.
The 0/1/2-member cases and the icon-override branch are unchanged.
Props interface unchanged — no call-site updates needed.
Tests updated to assert the new layout markers (`triangle`, `diamond`)
and to verify the geometry (top tile in triangle has smallest `top`;
overflow tile in diamond has largest `top`). 11/11 AvatarStack tests
pass; 360/360 web tests pass; typecheck clean.
design-system.md spec updated with the new layout table + geometry
section.
This commit is contained in:
@@ -182,13 +182,16 @@ interface AvatarStackProps {
|
|||||||
|
|
||||||
**Layout rules** (chosen by `members.length`, ignored entirely when `iconUrl` is set):
|
**Layout rules** (chosen by `members.length`, ignored entirely when `iconUrl` is set):
|
||||||
|
|
||||||
| Member count | Layout |
|
| Member count | Layout | `data-avatar-stack-layout` |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| 0 | Empty placeholder + small 12×12 group badge bottom-right |
|
| 0 | Empty placeholder + small 12×12 group badge bottom-right | — |
|
||||||
| 1 | Single avatar centered in the box + 12×12 group badge bottom-right (distinguishes a 1-other-member group from a 1-on-1 DM) |
|
| 1 | Single avatar centered in the box + 12×12 group badge bottom-right (distinguishes a 1-other-member group from a 1-on-1 DM) | — |
|
||||||
| 2 | Two avatars at 70% size with a 30% offset overlap (z-stacked) |
|
| 2 | Two avatars at 70% size with a 30% offset overlap (z-stacked) | `overlap` |
|
||||||
| 3 | 2×2 grid: three avatar tiles + one empty bottom-right slot |
|
| 3 | Equilateral-triangle huddle: three 62%-size tiles arranged radially (top, bottom-right, bottom-left), neighbors overlap | `triangle` |
|
||||||
| 4-10 | 2×2 grid: three avatar tiles + a `+N` overflow tile, where `N = members.length - 3` |
|
| 4 | Diamond huddle: four 58%-size tiles at the four cardinal points (top, right, bottom, left), neighbors overlap | `diamond` |
|
||||||
|
| 5-10 | Diamond huddle: three 58%-size tiles at the top/right/left points + a `+N` overflow tile occupying the bottom point, where `N = members.length - 3` | `diamond` |
|
||||||
|
|
||||||
|
**3+ member geometry.** Tiles are positioned radially around the box center on a circle of radius `R = (S − T) / 2`, where `S` is the box edge length and `T` is the tile size (`0.62·S` for 3 members, `0.58·S` for 4+). The first slot starts at `−90°` (top) and remaining slots are evenly spaced clockwise (`360° / slotCount` apart). This makes the farthest edges of each tile graze the box's bounding rect — no clipping, no wasted whitespace — and produces the same "huddle of overlapping faces" aesthetic as the 2-member overlap pattern at every member count. Z-index descends clockwise from the top slot so each tile tucks slightly under its clockwise neighbor (mirrors the 2-member case where the first tile sits on top of the second). The `+N` overflow tile always occupies the bottom diamond slot — reads as "more members behind these three" rather than "+N is one of the people".
|
||||||
|
|
||||||
`iconUrl` accepts a bare filename (resolved to `/api/uploads/<filename>`) or an absolute URL (`http`, `blob:`, `data:`, or `/`-prefixed) — passed through unchanged. When set, the entire box renders as a single rounded `<img>` filling the box.
|
`iconUrl` accepts a bare filename (resolved to `/api/uploads/<filename>`) or an absolute URL (`http`, `blob:`, `data:`, or `/`-prefixed) — passed through unchanged. When set, the entire box renders as a single rounded `<img>` filling the box.
|
||||||
|
|
||||||
|
|||||||
@@ -85,17 +85,27 @@ describe('AvatarStack', () => {
|
|||||||
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
|
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders 2x2 grid with three tiles and no +N for three members', () => {
|
it('renders triangular layout with three tiles and no +N for three members', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<AvatarStack members={makeUsers(3)} size={40} border="channel" />
|
<AvatarStack members={makeUsers(3)} size={40} border="channel" />
|
||||||
);
|
);
|
||||||
const tiles = container.querySelectorAll('[data-avatar-stack-tile]');
|
const tiles = container.querySelectorAll('[data-avatar-stack-tile]');
|
||||||
expect(tiles.length).toBe(3);
|
expect(tiles.length).toBe(3);
|
||||||
expect(container.querySelector('[data-avatar-stack-layout="grid"]')).toBeTruthy();
|
expect(container.querySelector('[data-avatar-stack-layout="triangle"]')).toBeTruthy();
|
||||||
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
|
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders three tiles + "+2" overflow for five members', () => {
|
it('renders diamond layout with four tiles and no +N for four members', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AvatarStack members={makeUsers(4)} size={40} border="channel" />
|
||||||
|
);
|
||||||
|
const tiles = container.querySelectorAll('[data-avatar-stack-tile]');
|
||||||
|
expect(tiles.length).toBe(4);
|
||||||
|
expect(container.querySelector('[data-avatar-stack-layout="diamond"]')).toBeTruthy();
|
||||||
|
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders three tiles + "+2" overflow in diamond layout for five members', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<AvatarStack members={makeUsers(5)} size={40} border="channel" />
|
<AvatarStack members={makeUsers(5)} size={40} border="channel" />
|
||||||
);
|
);
|
||||||
@@ -104,10 +114,10 @@ describe('AvatarStack', () => {
|
|||||||
const overflow = container.querySelector('[data-avatar-stack-overflow]');
|
const overflow = container.querySelector('[data-avatar-stack-overflow]');
|
||||||
expect(overflow).toBeTruthy();
|
expect(overflow).toBeTruthy();
|
||||||
expect(overflow!.textContent).toBe('+2');
|
expect(overflow!.textContent).toBe('+2');
|
||||||
expect(container.querySelector('[data-avatar-stack-layout="grid"]')).toBeTruthy();
|
expect(container.querySelector('[data-avatar-stack-layout="diamond"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders three tiles + "+7" overflow for ten members (cap)', () => {
|
it('renders three tiles + "+7" overflow in diamond layout for ten members (cap)', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<AvatarStack members={makeUsers(10)} size={40} border="channel" />
|
<AvatarStack members={makeUsers(10)} size={40} border="channel" />
|
||||||
);
|
);
|
||||||
@@ -116,6 +126,42 @@ describe('AvatarStack', () => {
|
|||||||
const overflow = container.querySelector('[data-avatar-stack-overflow]');
|
const overflow = container.querySelector('[data-avatar-stack-overflow]');
|
||||||
expect(overflow).toBeTruthy();
|
expect(overflow).toBeTruthy();
|
||||||
expect(overflow!.textContent).toBe('+7');
|
expect(overflow!.textContent).toBe('+7');
|
||||||
|
expect(container.querySelector('[data-avatar-stack-layout="diamond"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places tiles on the correct radial slots (3-member triangle)', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AvatarStack members={makeUsers(3)} size={40} border="channel" />,
|
||||||
|
);
|
||||||
|
const tiles = Array.from(
|
||||||
|
container.querySelectorAll('[data-avatar-stack-tile]'),
|
||||||
|
) as HTMLElement[];
|
||||||
|
// Triangle: top, bottom-right (~+30°), bottom-left (~+150° from -90 start)
|
||||||
|
// Top tile must have the smallest `top` value.
|
||||||
|
const tops = tiles.map((t) => parseInt(t.style.top, 10));
|
||||||
|
const minTop = Math.min(...tops);
|
||||||
|
expect(tops.filter((t) => t === minTop).length).toBe(1);
|
||||||
|
// The two non-top tiles share roughly the same `top` (bottom row of triangle).
|
||||||
|
const others = tops.filter((t) => t !== minTop).sort((a, b) => a - b);
|
||||||
|
expect(others.length).toBe(2);
|
||||||
|
expect(Math.abs(others[0]! - others[1]!)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places overflow tile in the bottom slot of the diamond', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AvatarStack members={makeUsers(5)} size={40} border="channel" />,
|
||||||
|
);
|
||||||
|
const overflow = container.querySelector(
|
||||||
|
'[data-avatar-stack-overflow]',
|
||||||
|
) as HTMLElement;
|
||||||
|
const tiles = Array.from(
|
||||||
|
container.querySelectorAll('[data-avatar-stack-tile]'),
|
||||||
|
) as HTMLElement[];
|
||||||
|
const overflowTop = parseInt(overflow.style.top, 10);
|
||||||
|
// Overflow sits at bottom of diamond — its `top` should be the
|
||||||
|
// largest among all four positioned elements.
|
||||||
|
const tileTops = tiles.map((t) => parseInt(t.style.top, 10));
|
||||||
|
expect(overflowTop).toBeGreaterThan(Math.max(...tileTops) - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders icon override and ignores the stack', () => {
|
it('renders icon override and ignores the stack', () => {
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ import { parseFederatedUsername } from '../../utils/identity';
|
|||||||
* - 0 members → empty placeholder + group badge
|
* - 0 members → empty placeholder + group badge
|
||||||
* - 1 member → centered avatar + group badge (12×12, bottom-right)
|
* - 1 member → centered avatar + group badge (12×12, bottom-right)
|
||||||
* - 2 members → equal-size offset overlap
|
* - 2 members → equal-size offset overlap
|
||||||
* - 3 members → 2×2 grid, three tiles
|
* - 3 members → equilateral triangle of overlapping tiles (top/bl/br)
|
||||||
* - 4+ members → 2×2 grid, three tiles + `+N` overflow tile
|
* - 4 members → diamond of overlapping tiles (top/right/bottom/left)
|
||||||
|
* - 5-10 members → diamond with `+N` overflow occupying the bottom slot
|
||||||
|
*
|
||||||
|
* The 3+ layouts deliberately mirror the 2-member overlap aesthetic:
|
||||||
|
* tiles are positioned radially around the box center so neighboring
|
||||||
|
* circles overlap by roughly the same amount as the 2-member case. This
|
||||||
|
* keeps the "huddle of overlapping faces" look consistent across all
|
||||||
|
* member counts and avoids the cramped 2×2-grid look that the previous
|
||||||
|
* implementation produced at small sizes.
|
||||||
*
|
*
|
||||||
* Status dots are never rendered — group identity wins regardless of
|
* Status dots are never rendered — group identity wins regardless of
|
||||||
* member count.
|
* member count.
|
||||||
@@ -209,36 +217,90 @@ export function AvatarStack({ members, size, border, iconUrl }: AvatarStackProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 3+ members: 2×2 grid (three avatar tiles, fourth slot empty or +N) ─
|
// ─── 3 members: equilateral triangle of overlapping tiles ───────────────
|
||||||
const tileSize = Math.round((size - 2) / 2); // half the box, with a 2px gutter
|
// ─── 4-10 members: diamond of overlapping tiles (+N in bottom slot) ─────
|
||||||
const tileGap = size - tileSize * 2;
|
//
|
||||||
// Four grid positions: top-left, top-right, bottom-left, bottom-right.
|
// Math: each tile has size T = ratio · S. Tile centers sit on a circle of
|
||||||
const positions = [
|
// radius R around the box center; we choose R = (S - T) / 2 so the
|
||||||
{ left: 0, top: 0 },
|
// farthest edges of the tiles graze the box's bounding box (no clipping,
|
||||||
{ left: tileSize + tileGap, top: 0 },
|
// no wasted whitespace). Angles are spaced evenly, starting at -90° (top)
|
||||||
{ left: 0, top: tileSize + tileGap },
|
// so the layout always has a tile pointing up — visually anchors the
|
||||||
{ left: tileSize + tileGap, top: tileSize + tileGap },
|
// composition the same way the 2-member case anchors top-left.
|
||||||
];
|
//
|
||||||
|
// Ratio choice:
|
||||||
|
// • 3 tiles at 0.62 → adjacent tiles overlap by ~12% of S edge-to-edge
|
||||||
|
// (visually similar to the 2-member 30% offset overlap once you
|
||||||
|
// account for the triangular spacing being larger than diagonal).
|
||||||
|
// • 4 tiles at 0.58 → cardinal positions, neighbors overlap by ~16% of S.
|
||||||
|
//
|
||||||
|
// Both ratios keep the tiles large enough to read at size 32 while
|
||||||
|
// leaving enough gap for the 2px borders to read clearly.
|
||||||
|
const isTriangle = members.length === 3;
|
||||||
|
const tileRatio = isTriangle ? 0.62 : 0.58;
|
||||||
|
const tileSize = Math.round(size * tileRatio);
|
||||||
|
const radius = (size - tileSize) / 2;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
|
||||||
const visibleMembers = members.slice(0, 3);
|
// Angles in radians, -90° (top) start. For triangle: 3 evenly spaced.
|
||||||
const overflow = members.length > 3 ? members.length - 3 : 0;
|
// For diamond: 4 cardinal points (top, right, bottom, left).
|
||||||
const overflowFontSize = Math.max(9, Math.round(tileSize * 0.45));
|
const slotCount = isTriangle ? 3 : 4;
|
||||||
|
const startAngle = -Math.PI / 2;
|
||||||
|
const angles = Array.from({ length: slotCount }, (_, i) =>
|
||||||
|
startAngle + (i * 2 * Math.PI) / slotCount,
|
||||||
|
);
|
||||||
|
const positions = angles.map((a) => ({
|
||||||
|
left: Math.round(cx + radius * Math.cos(a) - tileSize / 2),
|
||||||
|
top: Math.round(cy + radius * Math.sin(a) - tileSize / 2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Visible avatar tiles + overflow slot logic:
|
||||||
|
// • 3 members → 3 tiles, no overflow
|
||||||
|
// • 4 members → 4 tiles, no overflow
|
||||||
|
// • 5+ members → 3 tiles + `+N` in the 4th (bottom) slot
|
||||||
|
// Overflow lives in the bottom slot of the diamond so it reads as
|
||||||
|
// "more behind these three" rather than "+N is one of the people".
|
||||||
|
const overflow = members.length > 4 ? members.length - 3 : 0;
|
||||||
|
const visibleCount = overflow > 0 ? 3 : members.length;
|
||||||
|
const visibleMembers = members.slice(0, visibleCount);
|
||||||
|
// Bottom slot of diamond is index 2 (top, right, bottom, left).
|
||||||
|
const overflowSlot = 2;
|
||||||
|
const overflowFontSize = Math.max(9, Math.round(tileSize * 0.42));
|
||||||
|
|
||||||
|
// Z-stack: top tile sits highest so the upward-pointing avatar is fully
|
||||||
|
// visible; remaining tiles descend by angle so each one tucks slightly
|
||||||
|
// under its clockwise neighbor — mirrors the 2-member case where the
|
||||||
|
// first tile sits on top of the second.
|
||||||
|
const zIndexFor = (slotIndex: number) => slotCount - slotIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-avatar-stack-layout="grid"
|
data-avatar-stack-layout={isTriangle ? 'triangle' : 'diamond'}
|
||||||
className="relative flex-shrink-0"
|
className="relative flex-shrink-0"
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
>
|
>
|
||||||
{visibleMembers.map((m, i) => (
|
{visibleMembers.map((m, i) => {
|
||||||
<AvatarTile
|
// Skip the overflow slot when rendering visible tiles in the
|
||||||
key={m.id}
|
// overflow case — only relevant when overflow > 0 and i would
|
||||||
member={m}
|
// collide with overflowSlot. With visibleCount=3 and overflowSlot=2
|
||||||
size={tileSize}
|
// (bottom), this never collides because i ∈ {0,1,2} maps to
|
||||||
borderClass={borderClass}
|
// slotIndex ∈ {0,1,3} — see assignment below.
|
||||||
style={positions[i]}
|
const slotIndex = overflow > 0 && i >= overflowSlot ? i + 1 : i;
|
||||||
/>
|
const pos = positions[slotIndex]!;
|
||||||
))}
|
return (
|
||||||
|
<AvatarTile
|
||||||
|
key={m.id}
|
||||||
|
member={m}
|
||||||
|
size={tileSize}
|
||||||
|
borderClass={borderClass}
|
||||||
|
style={{
|
||||||
|
left: pos.left,
|
||||||
|
top: pos.top,
|
||||||
|
zIndex: zIndexFor(slotIndex),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<div
|
<div
|
||||||
data-avatar-stack-overflow="true"
|
data-avatar-stack-overflow="true"
|
||||||
@@ -246,12 +308,16 @@ export function AvatarStack({ members, size, border, iconUrl }: AvatarStackProps
|
|||||||
style={{
|
style={{
|
||||||
width: tileSize,
|
width: tileSize,
|
||||||
height: tileSize,
|
height: tileSize,
|
||||||
left: positions[3]!.left,
|
left: positions[overflowSlot]!.left,
|
||||||
top: positions[3]!.top,
|
top: positions[overflowSlot]!.top,
|
||||||
fontSize: overflowFontSize,
|
fontSize: overflowFontSize,
|
||||||
|
// Overflow sits on top of all visible tiles so its `+N` text is
|
||||||
|
// always fully readable, even when neighboring slot circles
|
||||||
|
// would otherwise clip the right/left edge of the digit.
|
||||||
|
zIndex: slotCount + 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`+${members.length - 3}`}
|
{`+${overflow}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user