refactor(profile): rebuild mobile horseshoe as single silhouette wrapper with Vaul ease curves replacing the two-radius emerge handoff
This commit is contained in:
parent
4dfd00c289
commit
50c58a1726
2 changed files with 280 additions and 195 deletions
|
|
@ -6,12 +6,11 @@ import { color, toRem } from 'folds';
|
|||
export const HORSESHOE_RADIUS_PX = 32;
|
||||
export const HORSESHOE_GAP_PX = 12;
|
||||
|
||||
// Outer container — establishes the positioning context for the
|
||||
// absolutely-positioned panel and the under-panel chat column.
|
||||
// `overflow: hidden` clips the panel when it slides above the
|
||||
// visible area (translateY(-railHeight)) so it disappears cleanly,
|
||||
// and clips the chat's animated rounded top corners against the
|
||||
// container's own edges.
|
||||
// Outer container — flex column hosting the silhouette (panel +
|
||||
// header live inside) and the chat body below. `overflow: hidden`
|
||||
// clips the chat body's rounded top corners against the container's
|
||||
// own edges; `background` paints the dark «void» behind the gap
|
||||
// between silhouette and chat body when the drawer is open.
|
||||
export const container = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
|
|
@ -22,31 +21,57 @@ export const container = style({
|
|||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
// === Panel === Mobile-only top horseshoe.
|
||||
// === Silhouette === Single-element wrapper that owns the
|
||||
// rounded-bottom geometry shared by the user-card panel and the chat
|
||||
// header. Both live as direct children of this one box, so a single
|
||||
// `border-bottom-{left,right}-radius` cuts the bottom of the
|
||||
// combined block — there's no «hand-off» from header-bottom-radius
|
||||
// to panel-bottom-radius mid-drag because there's only one radius
|
||||
// to begin with. Replaces the legacy `horseshoeEmerge` curve that
|
||||
// ramped two separate radii in the last 15% of drag.
|
||||
//
|
||||
// Full-width sheet with both bottom corners rounded, mirroring the
|
||||
// bottom call rail's rounded top corners. `position: absolute` lets
|
||||
// it slide in from above (translateY) without disturbing the chat
|
||||
// column's flex layout below.
|
||||
export const panel = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 2,
|
||||
width: '100%',
|
||||
// The bottom-corner radius is interpolated inline so the panel
|
||||
// bottom stays flush against the chat-column top during drag (both
|
||||
// square-cornered, zero gap), and only blossoms into the rounded
|
||||
// horseshoe shape once the gesture commits and CSS transitions
|
||||
// kick in alongside the gap.
|
||||
// `overflow: hidden` is what makes the radius actually crop content
|
||||
// (without it the rounded bottom would just be an SVG-like outline
|
||||
// behind opaque children). The flex column stacks panelViewport (top)
|
||||
// over headerViewport (bottom), so the silhouette's bottom edge
|
||||
// always coincides with whichever child is currently last-visible —
|
||||
// which is exactly the "bottom edge of the silhouette" the rounding
|
||||
// sits on.
|
||||
export const silhouette = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Background.Container,
|
||||
willChange: 'transform',
|
||||
// Allow vertical pan inside the panel (Scroll content); the
|
||||
// explicit drag-up handler will preventDefault on close gestures.
|
||||
willChange: 'border-bottom-left-radius, border-bottom-right-radius',
|
||||
});
|
||||
|
||||
// === Panel viewport === Variable-height clipping window for the
|
||||
// fixed-height panel content (railHeight tall, anchored bottom:0).
|
||||
// As the viewport grows from 0 to railHeight during drag, more of
|
||||
// panelContent is revealed from its bottom edge upward
|
||||
// (handle → scroll content → avatar top) — equivalent visual to the
|
||||
// legacy `transform: translateY(expandedPx - railHeight)` trick that
|
||||
// shifted the whole panel down from above viewport.
|
||||
//
|
||||
// `touchAction: pan-y` keeps the panel-internal scroll responsive;
|
||||
// the drag-up close gesture preventDefault's explicitly inside the
|
||||
// touchmove handler.
|
||||
export const panelViewport = style({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
willChange: 'height',
|
||||
touchAction: 'pan-y',
|
||||
});
|
||||
|
||||
export const panelContent = style({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
});
|
||||
|
||||
export const panelInner = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
@ -54,11 +79,11 @@ export const panelInner = style({
|
|||
});
|
||||
|
||||
// Functional overflow without a visible scrollbar. The card's
|
||||
// content (hero + ~4 info rows + chip row) almost always fits
|
||||
// inside the rail height, but moderation alerts can push it past
|
||||
// — we keep the panel scrollable for that case while suppressing
|
||||
// the scrollbar chrome (it's not a useful affordance on a 42vh
|
||||
// rail and the user explicitly asked us to drop it).
|
||||
// content (hero + ~4 info rows + chip row) almost always fits inside
|
||||
// the rail height, but moderation alerts can push it past — we keep
|
||||
// the panel scrollable for that case while suppressing the
|
||||
// scrollbar chrome (it's not a useful affordance on a 42vh rail and
|
||||
// the user explicitly asked us to drop it).
|
||||
export const panelScroll = style({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
|
@ -95,9 +120,9 @@ export const panelHandleBar = style({
|
|||
backgroundColor: color.Surface.ContainerLine,
|
||||
});
|
||||
|
||||
// Avatar full-view mode — fills the panel with the user's avatar at
|
||||
// full size when the user taps the avatar inside the open panel.
|
||||
// Click-to-revert switches back to the regular profile content.
|
||||
// Avatar full-view mode — fills the panel content area with the
|
||||
// user's avatar at full size when the user taps the avatar inside
|
||||
// the open panel. Click reverts to the regular profile content.
|
||||
export const avatarFullView = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
@ -106,6 +131,9 @@ export const avatarFullView = style({
|
|||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
});
|
||||
|
||||
export const avatarFullImage = style({
|
||||
|
|
@ -126,30 +154,34 @@ export const avatarFullFallback = style({
|
|||
textTransform: 'uppercase',
|
||||
});
|
||||
|
||||
// === Under-panel chat column ===
|
||||
//
|
||||
// `margin-top` is interpolated inline (panel height + 8px gap) so its
|
||||
// rounded top edge sits right under the panel's rounded bottom. The
|
||||
// rounded corners themselves are inline-styled on this same element.
|
||||
export const chatColumn = style({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
willChange: 'margin-top',
|
||||
});
|
||||
|
||||
// CSS-grid `1fr → 0fr` is animatable, unlike `height: auto`. The
|
||||
// outer wrap holds the row track; the inner div is the actual
|
||||
// scroll/clip surface.
|
||||
export const headerWrap = style({
|
||||
// === Header viewport === Holds the chat header as the silhouette's
|
||||
// bottom child. Same CSS-grid `1fr → 0fr` row-track trick the legacy
|
||||
// `headerWrap` used so the row animates smoothly without measuring
|
||||
// the header's intrinsic height in JS. The inner div clips the
|
||||
// header content as the track collapses.
|
||||
export const headerViewport = style({
|
||||
display: 'grid',
|
||||
flexShrink: 0,
|
||||
willChange: 'grid-template-rows',
|
||||
});
|
||||
|
||||
export const headerWrapInner = style({
|
||||
export const headerViewportInner = style({
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
// === Chat body === Timeline + input below the silhouette. The
|
||||
// rounded top corners and the `margin-top` void gap appear together
|
||||
// with the silhouette's rounded bottom — all three are driven by the
|
||||
// same `horseshoeRamp` (live formula while finger-dragging, binary
|
||||
// with CSS transition during release animation), so the two horseshoes
|
||||
// and the void emerge in lockstep at drag-start without any per-element
|
||||
// «emerge curve» of their own.
|
||||
export const chatBody = style({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
willChange: 'margin-top, border-top-left-radius, border-top-right-radius',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
// Wrapper around the room's chat column that adds the user-profile
|
||||
// surface on top.
|
||||
//
|
||||
// Mobile (≤ 750): the top «horseshoe» rail. The chat header collapses
|
||||
// to height 0 while the rail is open so the visible gap between the
|
||||
// rail's rounded bottom and the chat's rounded top is exactly the
|
||||
// shared `HORSESHOE_GAP_PX`, matching the bottom call horseshoe.
|
||||
// Mobile (≤ 750): the top «horseshoe» rail. Panel and chat header
|
||||
// live as siblings inside a single `silhouette` wrapper whose own
|
||||
// `overflow:hidden + border-bottom-radius` cuts the bottom of the
|
||||
// combined block. There's no separate header-radius / panel-radius
|
||||
// hand-off mid-drag — the rounding is a property of the silhouette
|
||||
// itself, so it stays visually anchored to «the bottom of whatever's
|
||||
// currently last-visible» throughout the gesture without seams or
|
||||
// blips. Replaces the legacy two-element layout where panel was
|
||||
// `position: absolute` over a chat column that owned the header in
|
||||
// flow; each had its own radius animated by the now-deleted
|
||||
// `horseshoeEmerge` curve.
|
||||
//
|
||||
// Tablet + Desktop (> 750): no top horseshoe. The wrapper is a thin
|
||||
// passthrough; the profile renders as a right-side pane via
|
||||
|
|
@ -38,6 +45,39 @@ const RAIL_HEIGHT_FRACTION = 0.42;
|
|||
// Past this many pixels of drag the gesture commits (open or close).
|
||||
const COMMIT_THRESHOLD_PX = 80;
|
||||
const ANIMATION_MS = 250;
|
||||
// Drag distance over which the horseshoe-radius + chat-gap +
|
||||
// chat-radius ramp from 0 to their full value during finger-drag.
|
||||
// Matched to COMMIT_THRESHOLD_PX so the silhouette is fully formed
|
||||
// exactly when the gesture qualifies to commit — the user sees the
|
||||
// rounding "earn" itself across the whole gesture instead of
|
||||
// snapping in immediately. Combined with the easeInOutCubic curve
|
||||
// below, gives a gentle start (corners barely emerge in first
|
||||
// ~10px) → fast middle (the bulk of the round-in happens around
|
||||
// the visual halfway point) → soft settle near commit.
|
||||
const HORSESHOE_EMERGE_PX = 80;
|
||||
|
||||
// Vaul / iOS system curve (asymmetric ease-out — steep accelerate,
|
||||
// long decelerate). Reads as "spring-like" without the overshoot of
|
||||
// an actual spring, which is what makes Apple sheet transitions feel
|
||||
// non-mechanical. Used on the CSS release transitions only — the
|
||||
// release phase wants instant feedback + soft settle. Vaul itself
|
||||
// also reserves this curve for release and tracks the finger linearly
|
||||
// during drag; we intentionally diverge from that during drag (see
|
||||
// `easeInOutCubic` below) because the user explicitly asked for a
|
||||
// gentle-start ramp on the rounding, not direct-manipulation.
|
||||
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
|
||||
// Symmetric cubic in-out S-curve — slow start, fast middle, slow
|
||||
// finish. NOT a Vaul-curve approximation: Vaul's curve is asymmetric
|
||||
// (steep accelerate, long decelerate) so it grows fast from the
|
||||
// first pixel of drag, which would defeat the explicit user ask of
|
||||
// «не сразу скругление». Symmetric in-out gives the rounding a
|
||||
// barely-visible start that only blossoms around the halfway point,
|
||||
// which is the felt-feel the user signed off on. Release animations
|
||||
// keep the asymmetric Vaul curve in CSS where direct manipulation
|
||||
// is over and the auto-animation can take centre stage.
|
||||
const easeInOutCubic = (t: number): number =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
type RoomViewProfilePanelProps = {
|
||||
header: ReactNode;
|
||||
|
|
@ -52,9 +92,10 @@ type DragState = {
|
|||
|
||||
// Mobile-only top horseshoe. Slides down from above on drag-down on
|
||||
// the chat header (1:1 DM only); slides up to close on drag-up
|
||||
// anywhere on the panel. While the rail is open the chat header
|
||||
// collapses to height 0 so there's no transparent ~50px void below
|
||||
// the rail — the perceived gap matches the call horseshoe's gap.
|
||||
// anywhere on the panel. Panel and chat header share a single
|
||||
// `silhouette` wrapper whose `overflow:hidden + border-bottom-radius`
|
||||
// owns the visible rounded bottom — see file header for the
|
||||
// architectural rationale.
|
||||
function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const profileState = useAtomValue(userRoomProfileAtom);
|
||||
|
|
@ -106,6 +147,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
|||
: baseExpanded;
|
||||
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
||||
const isDragging = drag !== null;
|
||||
const horseshoeActive = expandedPx > 0;
|
||||
|
||||
const liveUserId =
|
||||
profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined);
|
||||
|
|
@ -220,163 +262,155 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
|||
};
|
||||
}, [headerDragEnabled, headerDragPeer]);
|
||||
|
||||
const panelTransition = isDragging
|
||||
// Horseshoe geometry — single ramp factor drives the silhouette
|
||||
// bottom radius, the chat top radius, AND the chat margin-top gap.
|
||||
//
|
||||
// • Finger-drag: live formula over the first HORSESHOE_EMERGE_PX
|
||||
// (80px = COMMIT_THRESHOLD_PX) of drag, so the rounding and
|
||||
// gap visibly emerge across the whole commit gesture and finish
|
||||
// exactly when the gesture qualifies to commit.
|
||||
// • Release animation: binary on/off, paired with a CSS
|
||||
// transition so the values decay smoothly in lockstep with the
|
||||
// 250ms panel-height / header-collapse animations.
|
||||
//
|
||||
// The same ramp factor feeds all three properties so they always
|
||||
// appear and disappear together — no per-element timing skew that
|
||||
// could create a perceived «blip».
|
||||
const horseshoeRamp = isDragging
|
||||
? easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX))
|
||||
: horseshoeActive
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||
const chatRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||
const chatGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
|
||||
|
||||
const panelViewportTransition = isDragging
|
||||
? 'none'
|
||||
: `transform ${ANIMATION_MS}ms ease, border-bottom-left-radius ${ANIMATION_MS}ms ease, border-bottom-right-radius ${ANIMATION_MS}ms ease`;
|
||||
const chatColumnTransition = isDragging
|
||||
: `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const headerViewportTransition = isDragging
|
||||
? 'none'
|
||||
: `margin-top ${ANIMATION_MS}ms ease, border-top-left-radius ${ANIMATION_MS}ms ease, border-top-right-radius ${ANIMATION_MS}ms ease`;
|
||||
: `grid-template-rows ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const silhouetteTransition = isDragging
|
||||
? 'none'
|
||||
: `border-bottom-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-bottom-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const chatBodyTransition = isDragging
|
||||
? 'none'
|
||||
: `margin-top ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
backgroundColor: expandedPx > 0 ? '#090909' : undefined,
|
||||
backgroundColor: horseshoeActive ? '#090909' : undefined,
|
||||
};
|
||||
|
||||
// The «two horseshoes» visual (HORSESHOE_GAP_PX gap +
|
||||
// HORSESHOE_RADIUS_PX bottom/top radii) is gated by an emerge
|
||||
// curve that stays at 0 for most of the
|
||||
// open progress and only ramps to 1 in the last 15%. Two reasons:
|
||||
//
|
||||
// 1) Drag-down to open — the chat header is still visible under
|
||||
// the descending rail. A linear gap would show a black `#090909`
|
||||
// strip between rail and header («экстра геп между старой
|
||||
// маленьким хедером чата и карточкой-подковой»). With the
|
||||
// curve the rail sits flush against the (collapsing) header
|
||||
// until the very end, and the gap blossoms when the header
|
||||
// has fully retracted.
|
||||
//
|
||||
// 2) Drag-up to close — touchstart begins at expandedFraction=1,
|
||||
// so the gap is already 8px. As expandedFraction dips even
|
||||
// slightly the curve drops sharply, so the gap closes
|
||||
// smoothly with the user's gesture instead of jolting to 0
|
||||
// at touchstart.
|
||||
const horseshoeEmerge = Math.max(0, (expandedFraction - 0.85) / 0.15);
|
||||
const chatRadius = horseshoeEmerge * css.HORSESHOE_RADIUS_PX;
|
||||
const chatGap = horseshoeEmerge * css.HORSESHOE_GAP_PX;
|
||||
const panelBottomRadius = chatRadius;
|
||||
|
||||
return (
|
||||
<div className={css.container} style={containerStyle}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
// Mobile: only swipe-up dismisses the rail. Click on chat /
|
||||
// scroll on chat must NOT close it AND must remain
|
||||
// interactive.
|
||||
clickOutsideDeactivates: false,
|
||||
allowOutsideClick: () => true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
onDeactivate: () => {
|
||||
if (profileStateRef.current) close();
|
||||
},
|
||||
checkCanFocusTrap: () => Promise.resolve(),
|
||||
<div
|
||||
className={css.silhouette}
|
||||
style={{
|
||||
borderBottomLeftRadius: `${silhouetteRadiusPx}px`,
|
||||
borderBottomRightRadius: `${silhouetteRadiusPx}px`,
|
||||
transition: silhouetteTransition,
|
||||
}}
|
||||
// Don't toggle `active` on drag — flipping it to false makes
|
||||
// focus-trap-react fire `onDeactivate` synthetically, so a
|
||||
// plain panel tap (deltaY=0) closes the rail. Trap stays
|
||||
// active for the entire open lifetime.
|
||||
active={open}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={css.panel}
|
||||
style={{
|
||||
height: `${railHeightPx}px`,
|
||||
transform: `translateY(${expandedPx - railHeightPx}px)`,
|
||||
borderBottomLeftRadius: panelBottomRadius
|
||||
? `${panelBottomRadius}px`
|
||||
: 0,
|
||||
borderBottomRightRadius: panelBottomRadius
|
||||
? `${panelBottomRadius}px`
|
||||
: 0,
|
||||
transition: panelTransition,
|
||||
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
// Mobile: only swipe-up dismisses the rail. Click on chat /
|
||||
// scroll on chat must NOT close it AND must remain
|
||||
// interactive.
|
||||
clickOutsideDeactivates: false,
|
||||
allowOutsideClick: () => true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
onDeactivate: () => {
|
||||
if (profileStateRef.current) close();
|
||||
},
|
||||
checkCanFocusTrap: () => Promise.resolve(),
|
||||
}}
|
||||
// Don't toggle `active` on drag — flipping it to false makes
|
||||
// focus-trap-react fire `onDeactivate` synthetically, so a
|
||||
// plain panel tap (deltaY=0) closes the rail. Trap stays
|
||||
// active for the entire open lifetime.
|
||||
active={open}
|
||||
>
|
||||
{avatarMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.avatarFullView}
|
||||
onClick={() => setAvatarMode(false)}
|
||||
aria-label={t('Room.collapse_avatar')}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={css.panelViewport}
|
||||
style={{
|
||||
height: `${expandedPx}px`,
|
||||
transition: panelViewportTransition,
|
||||
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={css.panelContent}
|
||||
style={{ height: `${railHeightPx}px` }}
|
||||
>
|
||||
{renderUserAvatarUrl ? (
|
||||
<img
|
||||
className={css.avatarFullImage}
|
||||
src={renderUserAvatarUrl}
|
||||
alt={renderUserId ?? ''}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={css.avatarFullFallback}
|
||||
style={{ backgroundColor: colorMXID(renderUserId ?? '') }}
|
||||
{avatarMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.avatarFullView}
|
||||
onClick={() => setAvatarMode(false)}
|
||||
aria-label={t('Room.collapse_avatar')}
|
||||
>
|
||||
{(renderUserId && getMxIdLocalPart(renderUserId)?.[0]) ?? '?'}
|
||||
{renderUserAvatarUrl ? (
|
||||
<img
|
||||
className={css.avatarFullImage}
|
||||
src={renderUserAvatarUrl}
|
||||
alt={renderUserId ?? ''}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={css.avatarFullFallback}
|
||||
style={{ backgroundColor: colorMXID(renderUserId ?? '') }}
|
||||
>
|
||||
{(renderUserId && getMxIdLocalPart(renderUserId)?.[0]) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className={css.panelInner}>
|
||||
{/* No visible scrollbar — the card content (hero +
|
||||
~4 info rows + a chip row) almost always fits,
|
||||
and on the rare overflow case (lots of
|
||||
moderation alerts) we keep functional scrolling
|
||||
via the `panelScroll` class which sets
|
||||
`overflow: auto` + `scrollbar-width: none` /
|
||||
`::-webkit-scrollbar` hidden. */}
|
||||
<div className={css.panelScroll}>
|
||||
<div style={{ padding: config.space.S400 }}>
|
||||
{renderUserId && (
|
||||
<UserRoomProfile
|
||||
userId={renderUserId}
|
||||
onAvatarClick={() => setAvatarMode(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.panelHandle} aria-label={t('Room.drag_to_close')}>
|
||||
<div className={css.panelHandleBar} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className={css.panelInner}>
|
||||
{/* No visible scrollbar — the card content (hero +
|
||||
~4 info rows + a chip row) almost always fits, and
|
||||
on the rare overflow case (lots of moderation
|
||||
alerts) we keep functional scrolling via the
|
||||
`panelScroll` class which sets `overflow: auto` +
|
||||
`scrollbar-width: none` / `::-webkit-scrollbar`
|
||||
hidden. */}
|
||||
<div className={css.panelScroll}>
|
||||
<div style={{ padding: config.space.S400 }}>
|
||||
{renderUserId && (
|
||||
<UserRoomProfile
|
||||
userId={renderUserId}
|
||||
onAvatarClick={() => setAvatarMode(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.panelHandle} aria-label={t('Room.drag_to_close')}>
|
||||
<div className={css.panelHandleBar} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
|
||||
<div
|
||||
className={css.chatColumn}
|
||||
style={{
|
||||
// Push the chat column down by `panel + gap` so its rounded
|
||||
// top edge sits right below the panel's rounded bottom.
|
||||
// The panel is `position: absolute`, so without this margin
|
||||
// the chat would sit behind the panel and the top radius
|
||||
// would be invisible.
|
||||
marginTop:
|
||||
expandedPx > 0 ? `${expandedPx + chatGap}px` : 0,
|
||||
borderTopLeftRadius: chatRadius ? `${chatRadius}px` : undefined,
|
||||
borderTopRightRadius: chatRadius ? `${chatRadius}px` : undefined,
|
||||
// Clip the chat content to the rounded top corners. Folds
|
||||
// popouts use Portals → escape this clip; the timeline
|
||||
// keeps its own scroll container.
|
||||
overflow: chatRadius > 0 ? 'hidden' : undefined,
|
||||
transition: chatColumnTransition,
|
||||
// Suppress browser pull-to-refresh / scroll-chaining so a
|
||||
// header-drag-down doesn't fire pull-to-refresh briefly
|
||||
// before the touchmove handler intercepts.
|
||||
overscrollBehaviorY: 'contain',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={css.headerWrap}
|
||||
className={css.headerViewport}
|
||||
style={{
|
||||
// Collapse the chat header to 0 height while the rail is
|
||||
// open so the only visible chrome between the rail's
|
||||
// rounded bottom and the chat's rounded top is the
|
||||
// HORSESHOE_GAP_PX gap. CSS-grid's `1fr → 0fr` is animatable, unlike
|
||||
// `height: auto`, and avoids needing to measure the
|
||||
// header's intrinsic height in JS.
|
||||
// Collapse the chat header to 0 height as the panel
|
||||
// expands — same `1fr → 0fr` row-track trick the legacy
|
||||
// layout used. CSS-grid track interpolation is animatable
|
||||
// (unlike `height: auto`) and avoids measuring the
|
||||
// header's intrinsic height in JS. As the row collapses,
|
||||
// the silhouette's bottom edge moves from header-bottom
|
||||
// up to panel-bottom; the rounded corners stay anchored
|
||||
// there because they belong to the silhouette wrapper.
|
||||
gridTemplateRows: `${1 - expandedFraction}fr`,
|
||||
transition: isDragging ? 'none' : `grid-template-rows ${ANIMATION_MS}ms ease`,
|
||||
transition: headerViewportTransition,
|
||||
// While the rail is mostly open the (collapsed) header
|
||||
// shouldn't intercept drag-back-to-open gestures —
|
||||
// `pan-x` lets horizontal Android-back-edge gestures
|
||||
|
|
@ -384,8 +418,27 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
|||
touchAction: headerDragEnabled ? 'pan-x' : undefined,
|
||||
}}
|
||||
>
|
||||
<div className={css.headerWrapInner}>{header}</div>
|
||||
<div className={css.headerViewportInner}>{header}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css.chatBody}
|
||||
style={{
|
||||
marginTop: `${chatGapPx}px`,
|
||||
borderTopLeftRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
|
||||
borderTopRightRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
|
||||
// Clip chat content to the rounded top corners. Folds
|
||||
// popouts use Portals → escape this clip; the timeline
|
||||
// keeps its own scroll container.
|
||||
overflow: chatRadiusPx > 0 ? 'hidden' : undefined,
|
||||
transition: chatBodyTransition,
|
||||
// Suppress browser pull-to-refresh / scroll-chaining so a
|
||||
// header-drag-down doesn't fire pull-to-refresh briefly
|
||||
// before the touchmove handler intercepts.
|
||||
overscrollBehaviorY: 'contain',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue