fix(ui): DmMemberRow anchors profile popout to row bounds (matches MemberSidebar)
This commit is contained in:
@@ -15,9 +15,20 @@ vi.mock('../../audio/AudioManager', () => ({
|
||||
|
||||
// Force desktop rendering in ContextMenuRenderer (mobile path triggers
|
||||
// document-level long-press listeners we don't need here).
|
||||
// DmMemberRow now anchors the profile popout itself via
|
||||
// `useUIStore.getState().openUserProfile(...)`, so we expose a real `getState`
|
||||
// hook that the test below asserts against.
|
||||
const openUserProfileMock = vi.fn();
|
||||
const uiStoreState = { isMobile: false, openUserProfile: openUserProfileMock };
|
||||
vi.mock('../../stores/uiStore', () => ({
|
||||
useUIStore: (selector: (s: { isMobile: boolean; openUserProfile: () => void }) => unknown) =>
|
||||
selector({ isMobile: false, openUserProfile: () => {} }),
|
||||
useUIStore: Object.assign(
|
||||
(selector: (s: typeof uiStoreState) => unknown) => selector(uiStoreState),
|
||||
{
|
||||
getState: () => uiStoreState,
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
||||
@@ -50,6 +61,7 @@ beforeEach(() => {
|
||||
act(() => {
|
||||
useContextMenuStore.getState().close();
|
||||
});
|
||||
openUserProfileMock.mockClear();
|
||||
});
|
||||
|
||||
function renderRow(props: Partial<Parameters<typeof DmMemberRow>[0]> = {}) {
|
||||
@@ -225,3 +237,87 @@ describe('DmMemberRow — onMenuAction', () => {
|
||||
expect(onMenuAction).toHaveBeenCalledWith('transfer', expect.objectContaining({ id: member.id }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DmMemberRow — profile popout anchoring', () => {
|
||||
it('anchors openUserProfile to the row bounding rect (matches MemberSidebar pattern)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, onMenuAction, member } = renderRow();
|
||||
const row = container.querySelector('[data-dm-member-row]') as HTMLElement;
|
||||
|
||||
// Stub the row's getBoundingClientRect so we get a stable anchor target.
|
||||
const rect: DOMRect = {
|
||||
top: 200,
|
||||
left: 1500,
|
||||
right: 1740,
|
||||
bottom: 240,
|
||||
width: 240,
|
||||
height: 40,
|
||||
x: 1500,
|
||||
y: 200,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
row.getBoundingClientRect = () => rect;
|
||||
|
||||
// jsdom defaults innerHeight to 768; make sure that matters for the math.
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
|
||||
|
||||
openMenuByContextMenu(row);
|
||||
const profileBtn = await screen.findByText('View Profile');
|
||||
await user.click(profileBtn);
|
||||
|
||||
expect(openUserProfileMock).toHaveBeenCalledTimes(1);
|
||||
expect(openUserProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: member.id }),
|
||||
// Math.min(200, 800 - 450) = 200; left = 1500 - 316 = 1184.
|
||||
{ top: 200, left: 1184 },
|
||||
);
|
||||
// The row no longer routes 'profile' through onMenuAction.
|
||||
expect(onMenuAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps top to (innerHeight - 450) when the row sits near the bottom of the viewport', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderRow();
|
||||
const row = container.querySelector('[data-dm-member-row]') as HTMLElement;
|
||||
|
||||
const rect: DOMRect = {
|
||||
top: 700,
|
||||
left: 1500,
|
||||
right: 1740,
|
||||
bottom: 740,
|
||||
width: 240,
|
||||
height: 40,
|
||||
x: 1500,
|
||||
y: 700,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
row.getBoundingClientRect = () => rect;
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
|
||||
|
||||
openMenuByContextMenu(row);
|
||||
await user.click(await screen.findByText('View Profile'));
|
||||
|
||||
// Math.min(700, 800 - 450 = 350) → top clamped to 350.
|
||||
expect(openUserProfileMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ top: 350, left: 1184 },
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to onMenuAction("profile", ...) when the row has no bounding rect', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, onMenuAction, member } = renderRow();
|
||||
const row = container.querySelector('[data-dm-member-row]') as HTMLElement;
|
||||
|
||||
// Simulate a missing rect — defensive fallback path.
|
||||
row.getBoundingClientRect = () => null as unknown as DOMRect;
|
||||
|
||||
openMenuByContextMenu(row);
|
||||
await user.click(await screen.findByText('View Profile'));
|
||||
|
||||
expect(openUserProfileMock).not.toHaveBeenCalled();
|
||||
expect(onMenuAction).toHaveBeenCalledTimes(1);
|
||||
expect(onMenuAction).toHaveBeenCalledWith('profile', expect.objectContaining({ id: member.id }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { Username } from '../ui/Username';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useContextMenuStore,
|
||||
type ContextMenuItem,
|
||||
} from '../../stores/contextMenuStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
export type DmMemberRowAction = 'profile' | 'transfer' | 'kick' | 'remove-friend';
|
||||
|
||||
@@ -77,6 +78,7 @@ export function DmMemberRow({
|
||||
}: DmMemberRowProps) {
|
||||
const canonical = useCanonicalUserView(member);
|
||||
const openContextMenu = useContextMenuStore((s) => s.open);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { baseName, domain } = parseFederatedUsername(canonical.username);
|
||||
const displayName = canonical.displayName ?? baseName;
|
||||
@@ -90,7 +92,22 @@ export function DmMemberRow({
|
||||
key: 'profile',
|
||||
type: 'action',
|
||||
label: 'View Profile',
|
||||
onClick: () => onMenuAction('profile', canonical),
|
||||
onClick: () => {
|
||||
// Anchor the popout to this row's bounding rect — matches the
|
||||
// MemberSidebar pattern (see MemberSidebar.tsx:158-165). On mobile
|
||||
// the position arg is ignored by the store (full-screen push).
|
||||
const rect = rowRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
useUIStore.getState().openUserProfile(canonical, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.left - 316,
|
||||
});
|
||||
} else {
|
||||
// Fallback: defer to the consumer if we can't compute a rect
|
||||
// (shouldn't happen in practice, but keeps the contract intact).
|
||||
onMenuAction('profile', canonical);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const showTransfer = callerIsOwner && !isSelf;
|
||||
@@ -148,6 +165,7 @@ export function DmMemberRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
data-context-menu
|
||||
data-dm-member-row
|
||||
data-user-id={canonical.id}
|
||||
|
||||
@@ -89,6 +89,10 @@ export function DmRosterPanel() {
|
||||
|
||||
const handleMenuAction = async (action: DmMemberRowAction, member: User) => {
|
||||
if (action === 'profile') {
|
||||
// Profile popout is anchored by DmMemberRow itself (matches the
|
||||
// MemberSidebar pattern). This branch only fires on the unlikely
|
||||
// fallback path where the row couldn't compute its bounding rect —
|
||||
// in that case, anchor to the top-left of the roster column.
|
||||
openUserProfile(member, { top: 100, left: 100 });
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user