mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix(client): style the user profile popup
The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.
app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.
Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.
Two latent bugs fixed while there:
- Placement guessed a 300px card height and clamped only the top edge,
so a member clicked low in the list opened a card that ran off the
bottom of the window. Measure the card and clamp both edges.
- The avatar has to hang off the body's top edge, but the body scrolls,
and `overflow-y: auto` clips horizontally too. Make it a child of the
card rather than the body.
The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<UserStatus, string> = {
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <body>,
|
||||
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 ── */
|
||||
|
||||
Reference in New Issue
Block a user