diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx
index 615032add..b30e6a8f4 100644
--- a/app/features/components-showcase/routes/components.tsx
+++ b/app/features/components-showcase/routes/components.tsx
@@ -1617,10 +1617,8 @@ function UserCardSection({ id }: { id: string }) {
User Card
-
-
- Sendou
-
+
+ Sendou
diff --git a/app/features/user-card/components/UserCard.module.css b/app/features/user-card/components/UserCard.module.css
index ec69232fc..de24e5bd0 100644
--- a/app/features/user-card/components/UserCard.module.css
+++ b/app/features/user-card/components/UserCard.module.css
@@ -1,6 +1,14 @@
-.triggerWrapper {
+.trigger {
display: inline-flex;
width: fit-content;
+ padding: 0;
+ margin: 0;
+ border: none;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: inherit;
+ cursor: pointer;
}
.popover {
@@ -8,6 +16,10 @@
background: none;
}
+.dialog {
+ outline: none;
+}
+
.card {
position: relative;
display: flex;
@@ -184,7 +196,7 @@
.bio {
font-size: var(--font-xs);
- color: var(--color-text-high);
+ color: var(--color-text);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
@@ -206,7 +218,7 @@
.noteHeaderGroup {
display: flex;
flex-direction: column;
- line-height: 1.1;
+ gap: var(--s-0-5);
}
.noteHeader {
@@ -222,14 +234,15 @@
.noteText {
font-size: var(--font-xs);
- color: var(--color-text-high);
+ color: var(--color-text);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.noteViewActions {
+ margin-block-start: var(--s-2);
display: flex;
align-items: center;
justify-content: center;
- gap: var(--s-2);
+ gap: var(--s-4);
}
diff --git a/app/features/user-card/components/UserCard.tsx b/app/features/user-card/components/UserCard.tsx
index 0a5a4f402..423bf1331 100644
--- a/app/features/user-card/components/UserCard.tsx
+++ b/app/features/user-card/components/UserCard.tsx
@@ -10,7 +10,7 @@ import {
UserRoundCheck,
} from "lucide-react";
import * as React from "react";
-import { Popover } from "react-aria-components";
+import { Button, Dialog, DialogTrigger, Popover } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useFetcher, useLocation, useMatches } from "react-router";
import { Avatar } from "~/components/Avatar";
@@ -48,9 +48,6 @@ import styles from "./UserCard.module.css";
// xxx: also secondary action? e.g. "View tournament" from sidebar
-const HOVER_OPEN_DELAY_MS = 150;
-const HOVER_CLOSE_DELAY_MS = 200;
-
const TENTATEK_BRAND_ID: BrandId = "B10";
const STAT_ORDER: Record = {
@@ -61,16 +58,14 @@ const STAT_ORDER: Record = {
};
/**
- * Hover/focus wrapper that opens a popover with the user's card. Card data is resolved from the
+ * Click-to-open trigger that shows a popover with the user's card. Card data is resolved from the
* route tree by `userId` (a parent loader spreads `{ userCards }` from `UserCardRepository.userCards`);
* pass `data` directly to bypass the lookup (e.g. the components showcase). When no card data exists
- * for the user, the `children` are rendered plain without a popover.
+ * for the user, the `children` are rendered plain without a trigger.
*
* Viewer-relative friendship data (`isFriend` + `mutualFriends`) is lazy-loaded from the
* `/user-card/:id/friendship` route the first time the card opens.
*/
-
-// xxx: make click to open, arrows to scroll which one is selected (logical order on the page?)
export function UserCard({
userId,
data: dataProp,
@@ -78,7 +73,6 @@ export function UserCard({
}: {
userId?: number;
data?: UserCardData;
- // xxx: should this be a button or not?
children: React.ReactNode;
}) {
const { t } = useTranslation(["common", "q"]);
@@ -88,16 +82,9 @@ export function UserCard({
const user = useUser();
const isOwnCard = user?.id === data?.id;
- const triggerRef = React.useRef(null);
- const popoverRef = React.useRef(null);
- const openTimeout = React.useRef>(undefined);
- const closeTimeout = React.useRef>(undefined);
- const lastPointerType =
- React.useRef("mouse");
const [isOpen, setIsOpen] = React.useState(false);
- const [openedByTouch, setOpenedByTouch] = React.useState(false);
- // kept at this level (outside the hover popover) so the modals survive the popover closing
- // when they take focus; the note view inside the card opens them
+ // kept at this level (outside the popover) so the modals survive the popover closing when they
+ // take focus; the note view inside the card opens them
const [isNoteDialogOpen, setIsNoteDialogOpen] = React.useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false);
@@ -116,98 +103,14 @@ export function UserCard({
const friendship = fetcher.data;
- // xxx: probably not the play
- React.useEffect(
- () => () => {
- clearTimeout(openTimeout.current);
- clearTimeout(closeTimeout.current);
- },
- [],
- );
-
- // a non-modal popover does not close on interact outside; for touch-opened cards we close it
- // ourselves so the page stays interactive without making the popover modal (which would steal focus)
- React.useEffect(() => {
- if (!isOpen || !openedByTouch) return;
-
- const onPointerDownOutside = (event: PointerEvent) => {
- const target = event.target as Node;
- if (triggerRef.current?.contains(target)) return;
- if (popoverRef.current?.contains(target)) return;
- setIsOpen(false);
- setOpenedByTouch(false);
- };
-
- document.addEventListener("pointerdown", onPointerDownOutside);
- return () =>
- document.removeEventListener("pointerdown", onPointerDownOutside);
- }, [isOpen, openedByTouch]);
-
- const scheduleOpen = () => {
- clearTimeout(closeTimeout.current);
- openTimeout.current = setTimeout(
- () => setIsOpen(true),
- HOVER_OPEN_DELAY_MS,
- );
- };
-
- const scheduleClose = () => {
- clearTimeout(openTimeout.current);
- closeTimeout.current = setTimeout(
- () => setIsOpen(false),
- HOVER_CLOSE_DELAY_MS,
- );
- };
-
- const cancelClose = () => clearTimeout(closeTimeout.current);
-
- const onPointerEnter = (event: React.PointerEvent) => {
- if (event.pointerType !== "mouse") return;
- scheduleOpen();
- };
-
- const onPointerLeave = (event: React.PointerEvent) => {
- if (event.pointerType !== "mouse") return;
- // A full-page view transition (e.g. a toast animating in) momentarily swaps the
- // live DOM for snapshot pseudo-elements, firing a spurious pointerleave even though
- // the cursor never moved. Ignore leaves whose coordinates are still over the trigger
- // or popover so the card does not close itself when a toast appears.
- if (
- pointerWithin(triggerRef.current, event) ||
- pointerWithin(popoverRef.current, event)
- ) {
- return;
- }
- scheduleClose();
- };
-
- const onPointerDown = (event: React.PointerEvent) => {
- lastPointerType.current = event.pointerType;
- };
-
- const onClick = (event: React.MouseEvent) => {
- if (lastPointerType.current === "mouse") return;
- // on touch/pen open the card instead of activating the child (e.g. following a link)
- event.preventDefault();
- setOpenedByTouch(true);
- setIsOpen((prev) => !prev);
- };
-
- // close the hover popover so only the modal is shown
- const closePopover = () => {
- clearTimeout(openTimeout.current);
- clearTimeout(closeTimeout.current);
- setIsOpen(false);
- setOpenedByTouch(false);
- };
-
+ // close the popover so only the modal is shown
const openNoteDialog = () => {
- closePopover();
+ setIsOpen(false);
setIsNoteDialogOpen(true);
};
const openDeleteConfirm = () => {
- closePopover();
+ setIsOpen(false);
setIsDeleteConfirmOpen(true);
};
@@ -215,45 +118,20 @@ export function UserCard({
return (
<>
- {/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus/tap wrapper delegating to the interactive child trigger; the card opens on hover/focus (mouse) or tap (touch) */}
- setIsOpen(true)}
- onBlur={(event) => {
- if (!event.currentTarget.contains(event.relatedTarget)) {
- setIsOpen(false);
- }
- }}
- >
- {children}
-
- {
- setIsOpen(open);
- if (!open) setOpenedByTouch(false);
- }}
- isNonModal
- placement="right"
- className={styles.popover}
- >
-
-
+
+
+
+
+
+
{isNoteDialogOpen ? (
= rect.left &&
- event.clientX <= rect.right &&
- event.clientY >= rect.top &&
- event.clientY <= rect.bottom
- );
-}
-
/**
* Resolves a user's `UserCardData` from any matched route loader that spread `{ userCards }`
* (see `UserCardRepository.userCards`). Returns `undefined` when no loader on the current route
@@ -319,8 +183,6 @@ function CardContent({
isOwnCard,
onEditNote,
onDeleteNote,
- onPointerEnter,
- onPointerLeave,
}: {
data: UserCardData;
/** Lazy-loaded; `undefined` while the friendship fetch is in flight. */
@@ -328,8 +190,6 @@ function CardContent({
isOwnCard: boolean;
onEditNote: () => void;
onDeleteNote: () => void;
- onPointerEnter: () => void;
- onPointerLeave: (event: React.PointerEvent) => void;
}) {
const { t } = useTranslation(["common", "user"]);
const location = useLocation();
@@ -355,12 +215,7 @@ function CardContent({
};
return (
-
+
{data.freeAgentPostId !== null ? (
}
onPress={onEdit}
>
@@ -484,7 +339,7 @@ function NoteView({
}
onPress={onDelete}
>