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} >