diff --git a/Client/tauri-client/src/components/UserProfilePopup.ts b/Client/tauri-client/src/components/UserProfilePopup.ts index 00423a34..3f577b74 100644 --- a/Client/tauri-client/src/components/UserProfilePopup.ts +++ b/Client/tauri-client/src/components/UserProfilePopup.ts @@ -3,8 +3,9 @@ * in the chat or member list. Shows avatar, username, role badge, status dot, * about section, join date, and Message/Call action buttons. * - * Position: anchored to click point, flips if <100px from viewport edge. - * Animation: fade+scale 100ms. + * Position: anchored to the click point, flipped to the other side and clamped + * against the measured card height so it always lands fully on screen. + * Animation: fade+scale, defined in CSS so reduced-motion can drop it. * Close: outside click or Escape. * A11y: role="dialog", aria-label, focus trap, return focus on close. */ @@ -57,8 +58,10 @@ export type UserProfilePopupComponent = MountableComponent & { // --------------------------------------------------------------------------- const POPUP_WIDTH = 300; -const EDGE_THRESHOLD = 100; -const ANIMATION_DURATION_MS = 100; +/** Keeps the card clear of the window edges on both axes. */ +const VIEWPORT_MARGIN = 8; +/** Breathing room between the click point and the card. */ +const ANCHOR_GAP = 8; const STATUS_COLORS: Record = { online: "#3ba55d", @@ -110,28 +113,37 @@ export function createUserProfilePopup( } } - function computePosition(anchorX: number, anchorY: number): { left: number; top: number } { + /** + * Place the card beside the anchor, flipping and clamping so it always lands + * fully on screen — Discord opens its popout away from whichever edge the + * clicked row is nearest. + * + * The height is measured rather than assumed. The previous version guessed + * 300px and only clamped the top edge, so a member clicked low in the list + * opened a card that ran off the bottom of the window. + */ + function position(el: HTMLElement, anchorX: number, anchorY: number): void { const vw = window.innerWidth; const vh = window.innerHeight; + const height = el.offsetHeight; - let left = anchorX; + // Prefer the right of the anchor and flip left when there is no room. The + // member list sits against the right edge, so flipping is the usual case. + let left = anchorX + ANCHOR_GAP; + if (left + POPUP_WIDTH > vw - VIEWPORT_MARGIN) { + left = anchorX - POPUP_WIDTH - ANCHOR_GAP; + } + left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - POPUP_WIDTH - VIEWPORT_MARGIN)); + + // Align the top with the click, then lift the card just enough to fit. let top = anchorY; - - // Flip horizontally if too close to right edge - if (vw - anchorX < EDGE_THRESHOLD) { - left = anchorX - POPUP_WIDTH; + if (top + height > vh - VIEWPORT_MARGIN) { + top = vh - height - VIEWPORT_MARGIN; } + top = Math.max(VIEWPORT_MARGIN, top); - // Flip vertically if too close to bottom edge - if (vh - anchorY < EDGE_THRESHOLD) { - top = anchorY - 300; // approximate popup height - } - - // Clamp to viewport - left = Math.max(8, Math.min(left, vw - POPUP_WIDTH - 8)); - top = Math.max(8, top); - - return { left, top }; + el.style.left = `${left}px`; + el.style.top = `${top}px`; } function buildAvatar(user: UserProfileData): HTMLDivElement { @@ -182,17 +194,8 @@ export function createUserProfilePopup( "data-testid": "user-profile-popup", }); - // Position the popup - const pos = computePosition(options.anchorX, options.anchorY); - popup.style.left = `${pos.left}px`; - popup.style.top = `${pos.top}px`; popup.style.width = `${POPUP_WIDTH}px`; - // Animation: fade + scale - popup.style.opacity = "0"; - popup.style.transform = "scale(0.95)"; - popup.style.transition = `opacity ${ANIMATION_DURATION_MS}ms ease, transform ${ANIMATION_DURATION_MS}ms ease`; - // --- Content --- // Avatar @@ -298,10 +301,12 @@ export function createUserProfilePopup( actions.appendChild(callBtn); } - // Assemble popup + // Assemble the card: a banner strip and a body, with the avatar straddling + // the seam between them the way Discord's popout does. + const banner = createElement("div", { class: "upp-banner" }); + const body = createElement("div", { class: "upp-body" }); appendChildren( - popup, - avatar, + body, nameEl, handleEl, customStatusEl, @@ -311,17 +316,24 @@ export function createUserProfilePopup( joinSection, ); if (actions.childElementCount > 0) { - appendChildren(popup, divider, actions); + appendChildren(body, divider, actions); } + // The avatar hangs off the body's top edge, so it is a child of the card + // rather than the body — the body scrolls, and a scroll container clips. + // Appending it last puts it over the banner without needing a z-index. + appendChildren(popup, banner, body, avatar); overlay.appendChild(popup); container.appendChild(overlay); - // Trigger animation + // Measure, then place: the card has to be in the document before it has a + // height to clamp against. + position(popup, options.anchorX, options.anchorY); + + // The fade+scale itself lives in CSS so `prefers-reduced-motion` can drop it. requestAnimationFrame(() => { if (popup !== null) { - popup.style.opacity = "1"; - popup.style.transform = "scale(1)"; + popup.classList.add("open"); } }); diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 4bb197cb..d9c9f442 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -2559,90 +2559,179 @@ ul.md-list-nested { filter: saturate(0.3); } -/* ── User Profile Popup ── */ -.user-popup { +/* ── User Profile Popup ── + Discord's popout shape: a floating card anchored to the click, a banner strip + across the top, and the avatar straddling the banner/body seam inside a ring + punched out of the card background. + + `position: fixed` on both layers is load-bearing. These rules replaced an + earlier `.up-*` set that no longer matched the component's markup, so the + card rendered unstyled: `static` positioning discarded the computed + left/top and both layers laid out in normal flow at the end of , + pushing the whole app up the page. */ +.upp-overlay { position: fixed; + inset: 0; z-index: 200; +} +.upp-popup { + position: fixed; + display: flex; + flex-direction: column; background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius-md); - width: 300px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); - display: none; + box-shadow: var(--elevation-high); overflow: hidden; + /* A long "about me" scrolls inside the card instead of growing it off the + bottom of the screen. */ + max-height: calc(100vh - 16px); + opacity: 0; + transform: scale(0.95); + transform-origin: top left; + transition: + opacity var(--transition-fast), + transform var(--transition-fast); } -.user-popup.open { - display: block; +.upp-popup.open { + opacity: 1; + transform: scale(1); } -.up-banner { +.upp-banner { height: 60px; + flex-shrink: 0; + background: var(--accent-gradient); } -.up-body { - padding: 36px 16px 16px; +.upp-body { position: relative; + padding: 44px 16px 16px; + overflow-y: auto; } -.up-avatar { - width: 64px; - height: 64px; - border-radius: var(--radius-circle); +/* Positioned against the card, not the body. The body scrolls, and an + `overflow-y: auto` box also clips horizontally, so an avatar overhanging its + top edge would be sliced in half. Offset = banner height minus half the + avatar, so it straddles the seam. */ +.upp-avatar { position: absolute; - top: -32px; + top: 24px; left: 16px; + box-sizing: border-box; + width: 72px; + height: 72px; + border-radius: var(--radius-circle); + /* The ring is the card background rather than a border colour — that is what + makes the avatar read as punched through the banner. */ + border: 6px solid var(--bg-primary); display: flex; align-items: center; justify-content: center; + font-size: var(--font-size-xxl); font-weight: 700; - font-size: 24px; - color: white; + color: #fff; +} +.upp-status-dot { + position: absolute; + right: -2px; + bottom: -2px; + box-sizing: border-box; + width: 20px; + height: 20px; + border-radius: var(--radius-circle); border: 4px solid var(--bg-primary); } -.up-name { - font-size: 18px; +.upp-username { + font-size: var(--font-size-xl); font-weight: 700; - color: white; + line-height: 1.2; + color: var(--header-primary); + overflow-wrap: anywhere; } -.up-role { - font-size: 12px; - margin-top: 2px; -} -.up-section { - margin-top: 12px; - background: var(--bg-secondary); - border-radius: var(--radius-md); - padding: 12px; -} -.up-section-title { - font-size: 11px; - font-weight: 700; - color: var(--text-faint); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 6px; -} -.up-section-text { - font-size: 13px; - color: var(--text-normal); -} -.up-roles { - display: flex; - gap: 4px; - flex-wrap: wrap; - margin-top: 8px; -} -.up-role-tag { - display: flex; +.upp-role-badge { + display: inline-flex; align-items: center; - gap: 4px; + gap: 6px; + margin-top: 8px; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--bg-hover); - font-size: 11px; + font-size: var(--font-size-xs); color: var(--text-normal); } -.up-role-dot { +.upp-status-line { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + font-size: var(--font-size-sm); + color: var(--text-muted); +} +.upp-role-dot, +.upp-status-dot-inline { + flex-shrink: 0; width: 8px; height: 8px; - border-radius: 50%; + border-radius: var(--radius-circle); +} +/* Both sections are always built and left empty when the user has no data for + them, so they have to collapse rather than render as blank panels. */ +.upp-about, +.upp-join-date { + margin-top: 12px; + padding: 12px; + border-radius: var(--radius-md); + background: var(--bg-secondary); +} +.upp-about:empty, +.upp-join-date:empty { + display: none; +} +.upp-section-title { + margin-bottom: 6px; + font-size: var(--font-size-xxs); + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-faint); +} +.upp-about-text, +.upp-join-text { + font-size: var(--font-size-sm); + color: var(--text-normal); + overflow-wrap: anywhere; +} +.upp-divider { + height: 1px; + margin: 12px 0; + background: var(--border); +} +.upp-actions { + display: flex; + gap: 8px; +} +.upp-action-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + border: none; + border-radius: var(--radius-sm); + background: var(--accent); + color: #fff; + font-family: inherit; + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} +.upp-action-btn:hover { + background: var(--accent-hover); +} +@media (prefers-reduced-motion: reduce) { + .upp-popup { + transition: none; + } } /* ── Emoji Picker ── */