Dialogs: the profile card, room search and the three events panes are built from Modal (own heading supported); one scrim, one Escape stack (#315)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-08 22:54:50 -04:00
co-authored by Claude Sonnet 5.5
parent dad55432b4
commit 6a329c61e9
5 changed files with 36 additions and 58 deletions
-5
View File
@@ -8945,11 +8945,6 @@ video.lightbox-media {
background: var(--surface-container);
}
/* "Invite friends to event": over the events pane. */
.share-event-backdrop {
z-index: var(--z-sheet);
}
.share-event {
width: min(480px, 94vw);
}
+12 -23
View File
@@ -1,7 +1,6 @@
import { useMediaUrl } from '../lib/route'
import { useEscapeLayer } from '../lib/layers'
import { Modal } from './Modal'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon, IconButton } from './Icon'
import { Avatar } from './Avatar'
import { ContextMenu, type MenuEntry } from './ContextMenu'
@@ -90,8 +89,6 @@ export function EventsPane(): JSX.Element | null {
if (count !== undefined) load()
}, [count, load])
useEscapeLayer(() => store.closeEvents(), !!pane && !menu && !sharing)
if (!pane) return null
const act = (event: DiscordEvent, method: string, params: Record<string, unknown>, failed: string): void => {
@@ -127,9 +124,8 @@ export function EventsPane(): JSX.Element | null {
]
const n = events?.length ?? 0
return createPortal(
<div className="modal-scrim" onClick={() => store.closeEvents()}>
<div className="events-pane" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Events">
return (
<Modal title="Events" className="events-pane" onClose={() => store.closeEvents()} header={
<div className="events-pane-head">
<Icon name="calendar_month" size={22} />
<span className="events-pane-count">
@@ -142,6 +138,7 @@ export function EventsPane(): JSX.Element | null {
<span className="events-pane-spacer" />
<IconButton name="close" title="Close" onClick={() => store.closeEvents()} />
</div>
}>
<div className="events-pane-list">
{events === null && <p className="small muted">Loading…</p>}
@@ -204,11 +201,9 @@ export function EventsPane(): JSX.Element | null {
</div>
))}
</div>
</div>
{menu && <ContextMenu x={menu.x} y={menu.y} entries={menuFor(menu.event)} onClose={() => setMenu(null)} />}
{sharing && <ShareEvent accountId={pane.accountId} event={sharing} onClose={() => setSharing(null)} />}
</div>,
document.body
</Modal>
)
}
@@ -270,8 +265,6 @@ export function EventCreatePanel(): JSX.Element | null {
.catch(() => setChannels([]))
}, [target])
useEscapeLayer(() => store.closeEventCreate(), !!target)
if (!target) return null
const offered = channels.filter((c) => (kind === 'stage' ? c.stage : !c.stage))
@@ -336,15 +329,15 @@ export function EventCreatePanel(): JSX.Element | null {
</label>
)
return createPortal(
<div className="modal-scrim" onClick={() => store.closeEventCreate()}>
<div className="events-pane event-create" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Create an event">
return (
<Modal title="Create an event" className="events-pane event-create" onClose={() => store.closeEventCreate()} header={
<div className="events-pane-head">
<Icon name="calendar_add_on" size={22} />
<span className="events-pane-count">Create an event</span>
<span className="events-pane-spacer" />
<IconButton name="close" title="Close" onClick={() => store.closeEventCreate()} />
</div>
}>
<div className="event-create-body">
<div className="event-create-section">
@@ -429,9 +422,7 @@ export function EventCreatePanel(): JSX.Element | null {
{sending ? 'Creating…' : 'Create Event'}
</button>
</div>
</div>
</div>,
document.body
</Modal>
)
}
@@ -476,7 +467,6 @@ function ShareEvent({
.catch(() => setTargets([]))
}, [accountId, event.guildId, event.id])
useEscapeLayer(onClose)
const q = query.trim().toLowerCase()
const shown = (targets ?? []).filter(
@@ -532,8 +522,7 @@ function ShareEvent({
}
return (
<div className="modal-scrim share-event-backdrop" onClick={onClose}>
<div className="events-pane share-event" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Invite friends to event">
<Modal title="Invite friends to event" className="events-pane share-event" onClose={onClose} header={
<div className="share-event-head">
<div>
<div className="events-pane-count">Invite friends to event</div>
@@ -546,6 +535,7 @@ function ShareEvent({
<span className="events-pane-spacer" />
<IconButton name="close" title="Close" onClick={onClose} />
</div>
}>
<div className="emoji-search share-event-search">
<Icon name="search" size={16} />
<input autoFocus placeholder="Search for friends" value={query} onChange={(e) => setQuery(e.target.value)} />
@@ -576,7 +566,6 @@ function ShareEvent({
: ''}
</div>
</div>
</div>
</div>
</Modal>
)
}
+14 -5
View File
@@ -18,6 +18,7 @@ export function Modal({
iconColor,
onClose,
className,
header,
children
}: {
title: string
@@ -25,6 +26,12 @@ export function Modal({
iconColor?: string
onClose: () => void
className?: string
/**
* A heading of the dialog's own, in place of the title row: for a dialog whose
* head is a person's face, or a search box. It brings its own way out; the
* title is still what a screen reader calls the dialog.
*/
header?: React.ReactNode
children: React.ReactNode
}): JSX.Element {
useEscapeLayer(onClose)
@@ -37,11 +44,13 @@ export function Modal({
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<div className="dialog-head">
{icon && <Icon name={icon} size={18} color={iconColor} />}
<span className="dialog-title ellipsis">{title}</span>
<IconButton name="close" size={16} title="Close" onClick={onClose} />
</div>
{header ?? (
<div className="dialog-head">
{icon && <Icon name={icon} size={18} color={iconColor} />}
<span className="dialog-title ellipsis">{title}</span>
<IconButton name="close" size={16} title="Close" onClick={onClose} />
</div>
)}
{children}
</div>
</div>,
+5 -10
View File
@@ -1,5 +1,4 @@
import { useEscapeLayer } from '../lib/layers'
import { createPortal } from 'react-dom'
import { Modal } from './Modal'
import { Avatar } from './Avatar'
import { Icon, IconButton } from './Icon'
import { useChat, useStore } from '../state/hooks'
@@ -23,8 +22,6 @@ export function ProfileCard(): JSX.Element | null {
const profile = useChat((s) => s.profile)
const store = useStore()
useEscapeLayer(() => store.closeProfile(), !!profile)
if (!profile) return null
const rows: [string, string][] = []
@@ -36,9 +33,8 @@ export function ProfileCard(): JSX.Element | null {
if (profile.idleSeconds !== undefined) rows.push(['Idle', describeIdle(profile.idleSeconds)])
for (const extra of profile.extra ?? []) rows.push([extra.label, extra.value])
return createPortal(
<div className="modal-scrim" onClick={() => store.closeProfile()}>
<div className="profile-card" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
return (
<Modal title={profile.name} className="profile-card" onClose={() => store.closeProfile()} header={
<div className="profile-head">
<Avatar name={profile.name} url={profile.avatarUrl} size={40} status={profile.status} accountId={profile.accountId} />
<div className="profile-title">
@@ -50,6 +46,7 @@ export function ProfileCard(): JSX.Element | null {
</div>
<IconButton name="close" title="Close" onClick={() => store.closeProfile()} />
</div>
}>
{/* Their standing, said plainly. A role list is the service's own
words for it - "Operator in #channel", "Admin", a subscriber
@@ -129,9 +126,7 @@ export function ProfileCard(): JSX.Element | null {
<Icon name="info" size={14} /> {profile.service} keeps nothing else about them.
</p>
)}
</div>
</div>,
document.body
</Modal>
)
}
+5 -15
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEscapeLayer } from '../lib/layers'
import { ReasonPrompt } from './ReasonPrompt'
import { createPortal } from 'react-dom'
import { Modal } from './Modal'
import { Avatar } from './Avatar'
import { Icon, IconButton } from './Icon'
import { usePref, useStore } from '../state/hooks'
@@ -124,7 +123,6 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
// for what they are actually looking at now.
const generation = useRef(0)
useEscapeLayer(onClose)
const servers = extraServers
@@ -287,15 +285,8 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
const rooms = (answer?.rooms ?? []).filter((room) => !disabled.has(room.via))
const morePages = Object.keys(answer?.next ?? {}).length > 0
return createPortal(
<div className="modal-scrim" onClick={onClose}>
<div
className="room-search"
role="dialog"
aria-modal="true"
aria-label="Find a room"
onClick={(e) => e.stopPropagation()}
>
return (
<Modal title="Find a room" className="room-search" onClose={onClose} header={
<div className="room-search-head">
<Icon name="search" size={18} />
<input
@@ -323,6 +314,7 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
/>
<IconButton name="close" title="Close" onClick={onClose} />
</div>
}>
{/* Which servers, by name, on their own row so a dozen of them fit -
and each one a switch, because "search everywhere" and "search
@@ -545,8 +537,6 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
</p>
)}
</div>
</div>
</div>,
document.body
</Modal>
)
}