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
|
// Force desktop rendering in ContextMenuRenderer (mobile path triggers
|
||||||
// document-level long-press listeners we don't need here).
|
// 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', () => ({
|
vi.mock('../../stores/uiStore', () => ({
|
||||||
useUIStore: (selector: (s: { isMobile: boolean; openUserProfile: () => void }) => unknown) =>
|
useUIStore: Object.assign(
|
||||||
selector({ isMobile: false, openUserProfile: () => {} }),
|
(selector: (s: typeof uiStoreState) => unknown) => selector(uiStoreState),
|
||||||
|
{
|
||||||
|
getState: () => uiStoreState,
|
||||||
|
setState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
},
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
||||||
@@ -50,6 +61,7 @@ beforeEach(() => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
useContextMenuStore.getState().close();
|
useContextMenuStore.getState().close();
|
||||||
});
|
});
|
||||||
|
openUserProfileMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderRow(props: Partial<Parameters<typeof DmMemberRow>[0]> = {}) {
|
function renderRow(props: Partial<Parameters<typeof DmMemberRow>[0]> = {}) {
|
||||||
@@ -225,3 +237,87 @@ describe('DmMemberRow — onMenuAction', () => {
|
|||||||
expect(onMenuAction).toHaveBeenCalledWith('transfer', expect.objectContaining({ id: member.id }));
|
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 type { User } from '@backspace/shared';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useContextMenuStore,
|
useContextMenuStore,
|
||||||
type ContextMenuItem,
|
type ContextMenuItem,
|
||||||
} from '../../stores/contextMenuStore';
|
} from '../../stores/contextMenuStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
export type DmMemberRowAction = 'profile' | 'transfer' | 'kick' | 'remove-friend';
|
export type DmMemberRowAction = 'profile' | 'transfer' | 'kick' | 'remove-friend';
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ export function DmMemberRow({
|
|||||||
}: DmMemberRowProps) {
|
}: DmMemberRowProps) {
|
||||||
const canonical = useCanonicalUserView(member);
|
const canonical = useCanonicalUserView(member);
|
||||||
const openContextMenu = useContextMenuStore((s) => s.open);
|
const openContextMenu = useContextMenuStore((s) => s.open);
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { baseName, domain } = parseFederatedUsername(canonical.username);
|
const { baseName, domain } = parseFederatedUsername(canonical.username);
|
||||||
const displayName = canonical.displayName ?? baseName;
|
const displayName = canonical.displayName ?? baseName;
|
||||||
@@ -90,7 +92,22 @@ export function DmMemberRow({
|
|||||||
key: 'profile',
|
key: 'profile',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'View Profile',
|
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;
|
const showTransfer = callerIsOwner && !isSelf;
|
||||||
@@ -148,6 +165,7 @@ export function DmMemberRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rowRef}
|
||||||
data-context-menu
|
data-context-menu
|
||||||
data-dm-member-row
|
data-dm-member-row
|
||||||
data-user-id={canonical.id}
|
data-user-id={canonical.id}
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export function DmRosterPanel() {
|
|||||||
|
|
||||||
const handleMenuAction = async (action: DmMemberRowAction, member: User) => {
|
const handleMenuAction = async (action: DmMemberRowAction, member: User) => {
|
||||||
if (action === 'profile') {
|
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 });
|
openUserProfile(member, { top: 100, left: 100 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user