feat(search): open listing search as a full-stage overlay over the tabs — right-side close ✕, hardware-back/Escape dismiss, flush result clip
This commit is contained in:
parent
80c64e0760
commit
d599c98a62
10 changed files with 331 additions and 213 deletions
|
|
@ -202,7 +202,7 @@ Slate-based. `Editor.tsx` (Slate root — preserve), `Editor.preview.tsx`, `Elem
|
|||
### Navigation / list (mostly NEW dirs)
|
||||
|
||||
- `nav/` (**NEW**, heavily used) — generic list-row primitives (`NavCategory`, `NavCategoryHeader`, `NavItem`/`NavLink`, `NavItemContent`, `NavItemOptions`, `NavEmptyLayout`). The lower-level layer beneath `features/room-nav/`.
|
||||
- `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Segment` + `useCurtain*` gestures + `RefreshMascot`/`RadarPulse` + `forms/InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`. The Plus/new-chat action is **gone on Direct** (people are found in search); the Plus only renders where a tab supplies a `primaryAction` (Channels' create-channel/community). The curtain is a snap machine (`useCurtainState`: `closed` / momentary `refresh` / `form-search`, plus a per-tab `pinned` overlay in `curtainPinnedByTabAtom`); the old chip-peek geometry (`Chip`, `peekTravelPx`) is **gone** (commit 901bec2e) — pulling down past `REFRESH_COMMIT_PX` commits a pull-to-refresh that reveals the dancing-mascot card + radar-pulse rings (`MASCOT_CARD_PX` below the tabs row) and auto-closes after /sync recovers, aligned to the dance-loop boundary. The rings are ONE transparent 2D `<canvas>` redrawn per rAF (`RadarPulse.tsx` — an untiled TextureLayer; the previous CSS-animated SVG circles each became their own peak-scale cc layer and blew the WebView tile budget — corrected root-cause postmortem in that file). In pager mode the mascot/rings render ONCE in a strip-hosted singleton (`mobile-tabs-pager/PagerRefreshSingleton`, counter-translated against the strip via the inline `counterTx` prop, compositor-transitioned in lockstep with the strip) — one screen-static instance for all tabs, shown while ANY tab reveals (`curtainRefreshRevealByTabAtom`), hidden behind active forms/horseshoes; it MUST stay inside the strip subtree — see the tile-memory postmortem on `pagerRefreshSingleton` in `mobile-tabs-pager/style.css.ts`.
|
||||
- `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Segment` + `useCurtain*` gestures + `RefreshMascot`/`RadarPulse` + `forms/InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`. The Plus/new-chat action is **gone on Direct** (people are found in search); the Plus only renders where a tab supplies a `primaryAction` (Channels' create-channel/community). The curtain is a snap machine (`useCurtainState`: `closed` / momentary `refresh` / `form-search`, plus a per-tab `pinned` overlay in `curtainPinnedByTabAtom`). The `form-search` snap no longer drops a panel *below* the tabs row — tapping Search now opens `InlineRoomSearch` as a **full-stage overlay** (`StreamHeader.css.ts::formOverlay`, z above the curtain) that covers the tabs row AND the chat list and fills the stage height; its opaque `SurfaceVariant` backdrop is what hides the tabs (load-bearing in pager mode where the static header would show through), the overlay never covers the safe-top status-bar strip, and it carries its own back/✕ + fade (`StreamHeader.formVisible`). The curtain itself stays at `closed` behind it. The old chip-peek geometry (`Chip`, `peekTravelPx`) is **gone** (commit 901bec2e) — pulling down past `REFRESH_COMMIT_PX` commits a pull-to-refresh that reveals the dancing-mascot card + radar-pulse rings (`MASCOT_CARD_PX` below the tabs row) and auto-closes after /sync recovers, aligned to the dance-loop boundary. The rings are ONE transparent 2D `<canvas>` redrawn per rAF (`RadarPulse.tsx` — an untiled TextureLayer; the previous CSS-animated SVG circles each became their own peak-scale cc layer and blew the WebView tile budget — corrected root-cause postmortem in that file). In pager mode the mascot/rings render ONCE in a strip-hosted singleton (`mobile-tabs-pager/PagerRefreshSingleton`, counter-translated against the strip via the inline `counterTx` prop, compositor-transitioned in lockstep with the strip) — one screen-static instance for all tabs, shown while ANY tab reveals (`curtainRefreshRevealByTabAtom`), hidden behind active forms/horseshoes; it MUST stay inside the strip subtree — see the tile-memory postmortem on `pagerRefreshSingleton` in `mobile-tabs-pager/style.css.ts`.
|
||||
- `mobile-tabs-pager/` (**NEW**) — the mobile swipe pager (see Routing).
|
||||
- `sidebar/` — `Sidebar` (66px wrapper), `SidebarItem`, `SidebarStack`, `SidebarStackSeparator`, `SidebarContent` (currently mounted only via dead `SidebarNav`).
|
||||
- `virtualizer/` (`VirtualTile`), `scroll-top-container/`.
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ export function MobileTabsPagerHeader({
|
|||
const curtainControls = useAtomValue(mobilePagerCurtainAtom);
|
||||
const isFormActive = curtainControls?.isFormActive ?? false;
|
||||
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
|
||||
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
|
||||
const iconsDisabled = curtainControls === null;
|
||||
// Tab-specific create action (Channels publishes «create channel» /
|
||||
// «create community»). When null (Direct) NO Plus renders — new-chat
|
||||
|
|
@ -126,7 +125,12 @@ export function MobileTabsPagerHeader({
|
|||
className={css.pagerStaticHeader}
|
||||
style={elevated ? { zIndex: PAGER_HEADER_ELEVATED_Z } : undefined}
|
||||
>
|
||||
<div className={streamHeaderCss.tabsRow}>
|
||||
{/* While search is active the full-stage overlay (in the active
|
||||
pane's stage, painted by the strip above this header) covers
|
||||
this row AND carries its own close ✕ — so the row is aria-hidden
|
||||
and renders no close here, avoiding a duplicate occluded-but-
|
||||
focusable «Close» in the a11y tree. */}
|
||||
<div className={streamHeaderCss.tabsRow} aria-hidden={isFormActive || undefined}>
|
||||
<div className={streamHeaderCss.tabsCluster}>
|
||||
<Segment
|
||||
active={activeTab === 'direct'}
|
||||
|
|
@ -147,21 +151,7 @@ export function MobileTabsPagerHeader({
|
|||
)}
|
||||
</div>
|
||||
<Box grow="Yes" />
|
||||
{isFormActive ? (
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
onClick={closeForm}
|
||||
aria-label={t('Direct.close')}
|
||||
aria-controls={INLINE_FORM_ID}
|
||||
aria-expanded
|
||||
disabled={iconsDisabled}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
) : (
|
||||
{isFormActive ? null : (
|
||||
<div className={streamHeaderCss.iconsCluster}>
|
||||
{/* Default «new chat» Plus is gone — people are found through
|
||||
search. Only the tab's `primaryAction` (Channels'
|
||||
|
|
|
|||
|
|
@ -433,26 +433,66 @@ export const mascotCaption = style({
|
|||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
// Active form area in the header. Outer is `position: relative`; the
|
||||
// inner mounted form fills it with `top: 0` so the form's input bar
|
||||
// sits flush just below the tabs row.
|
||||
export const formArea = style({
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
// Full-stage search overlay. Replaces the old «form drops below the
|
||||
// tabs row» reveal: tapping the Search icon now opens the search form
|
||||
// AS a layer (zIndex 3, above the curtain's z:2) that covers the tabs
|
||||
// row AND the chat list and fills the whole stage height — the user's
|
||||
// «move the search up, hide the tabs behind the search bar, stretch it
|
||||
// full height» request.
|
||||
//
|
||||
// The opaque `SurfaceVariant.Container` backdrop is what hides the tabs
|
||||
// — load-bearing in PAGER mode, where the per-pane stage/header are
|
||||
// transparent and the static pager header (segments) sits behind the
|
||||
// strip; without an opaque fill the tabs would show through. It matches
|
||||
// the static header tone so the safe-top strip ABOVE the overlay (the
|
||||
// stage starts at y = safe-top, the overlay never covers the status-bar
|
||||
// inset) blends seamlessly into the search surface below it.
|
||||
//
|
||||
// `data-open` (driven by StreamHeader's `formVisible`, flipped one
|
||||
// frame after mount / cleared on close) fades + slides the overlay in
|
||||
// on open and back out on close, before `activeForm` is unmounted by
|
||||
// the curtain-state safety timer. Reduced-motion drops the slide.
|
||||
export const formOverlay = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
opacity: 0,
|
||||
transform: 'translateY(-8px)',
|
||||
transition: `opacity ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}, transform ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
|
||||
// `pointer-events: none` at rest so the closing/invisible overlay
|
||||
// doesn't swallow taps during its fade-out: on close the curtain's
|
||||
// `top` doesn't change (form-search snap == closed), so nothing
|
||||
// unmounts the overlay until the safety timer ~480ms later — and the
|
||||
// opaque z:3 layer would otherwise eat every tap meant for the chat
|
||||
// list / tabs underneath for ~200ms after it has faded to opacity 0.
|
||||
// Restored to `auto` only while open (below) so the form stays
|
||||
// interactive.
|
||||
pointerEvents: 'none',
|
||||
selectors: {
|
||||
'&[data-open="true"]': {
|
||||
opacity: 1,
|
||||
transform: 'none',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: reduce)': {
|
||||
transform: 'none',
|
||||
transition: 'opacity 120ms linear',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const formInner = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
// 12px side padding insets the search bar a little from the column edge.
|
||||
//
|
||||
// Bottom padding is 0 (not 8 like the top): the form's last child is
|
||||
// the scrollable result list, and it must run flush into the curtain's
|
||||
// top edge. Any bottom padding here would reintroduce a light-blue strip
|
||||
// that clips the search results before they reach the curtain. The 8px
|
||||
// top padding stays: it's the gap below the tabs row.
|
||||
// Padded inner column the search form fills. Side inset matches the old
|
||||
// form; the small top inset sits the search bar a touch below the
|
||||
// status-bar safe area; the result list grows to the bottom edge.
|
||||
export const formOverlayInner = style({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: `${toRem(8)} ${toRem(12)} 0`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMatch, useNavigate } from 'react-router-dom';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
|
|
@ -219,6 +220,72 @@ export function StreamHeader({
|
|||
});
|
||||
|
||||
const isActive = isFormSnap(curtain.snap);
|
||||
|
||||
// Entrance / exit fade for the full-stage search overlay. `open()`
|
||||
// sets `activeForm` + the form snap synchronously, so the overlay
|
||||
// mounts already-active; flipping `formVisible` true on the NEXT frame
|
||||
// lets the CSS opacity/translate transition play from the hidden
|
||||
// initial state instead of snapping straight to opaque. On close
|
||||
// (snap leaves `form-search`) it flips back to false so the overlay
|
||||
// fades out before the curtain-state safety timer unmounts `activeForm`.
|
||||
const [formVisible, setFormVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setFormVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
const id = requestAnimationFrame(() => setFormVisible(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isActive]);
|
||||
|
||||
// Hardware-back / Escape closes the search overlay. Mirrors the mobile
|
||||
// sheets (MobileSettingsHorseshoe / ChannelsWorkspaceHorseshoe):
|
||||
//
|
||||
// 1. While the form is the visible-active one, render an invisible
|
||||
// marker into `#portalContainer`. The global `useAndroidBackButton`
|
||||
// handler dispatches a synthetic `Escape` keydown whenever that
|
||||
// container has a child (instead of popping navigation), so
|
||||
// Android's hardware back reaches us as an Escape.
|
||||
// 2. A window `keydown` listener closes the form on Escape — covers
|
||||
// both the synthetic native Escape and a real desktop Escape.
|
||||
//
|
||||
// Gated to the ACTIVE pager pane so a search left open on an off-screen
|
||||
// pane (you can swipe away mid-search) doesn't swallow the back press
|
||||
// for the tab you're actually looking at.
|
||||
const backIntercepts = isActive && (!inPagerMode || isActivePagerPane);
|
||||
const { close: closeForm } = curtain;
|
||||
useEffect(() => {
|
||||
if (!backIntercepts) return undefined;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
// NB: unlike the settings/workspace sheets this otherwise mirrors,
|
||||
// there is deliberately NO INPUT/TEXTAREA target bail here — for a
|
||||
// search surface Escape should close even while the (autofocused)
|
||||
// query field has focus, matching the desktop Search-modal
|
||||
// convention. Don't add the sheet-style input guard back.
|
||||
//
|
||||
// If a real overlay (the start-DM confirm dialog) is open above the
|
||||
// search, let it own Escape — don't tear down the whole search.
|
||||
const portal = document.getElementById('portalContainer');
|
||||
if (
|
||||
portal &&
|
||||
Array.from(portal.children).some(
|
||||
(c) => !(c instanceof HTMLElement && c.dataset.vojoSearchBackMarker)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
closeForm();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [backIntercepts, closeForm]);
|
||||
|
||||
const backMarkerTarget =
|
||||
typeof document !== 'undefined'
|
||||
? document.getElementById('portalContainer') ?? document.body
|
||||
: null;
|
||||
|
||||
// Destructured because these callbacks are individually STABLE
|
||||
// (useCallback inside useCurtainState) while the `curtain` object
|
||||
// changes identity on every drag frame (`liveDragPx` is in its memo
|
||||
|
|
@ -407,12 +474,11 @@ export function StreamHeader({
|
|||
const pagerControls = useMemo<MobilePagerCurtainControls>(
|
||||
() => ({
|
||||
openSearch,
|
||||
closeForm: close,
|
||||
isFormActive: isActive,
|
||||
formMounted,
|
||||
primaryAction: primaryAction ?? null,
|
||||
}),
|
||||
[openSearch, close, isActive, formMounted, primaryAction]
|
||||
[openSearch, isActive, formMounted, primaryAction]
|
||||
);
|
||||
|
||||
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
|
||||
|
|
@ -451,7 +517,7 @@ export function StreamHeader({
|
|||
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
|
||||
const curtainTop = curtain.pinned
|
||||
? 0 + curtain.liveDragPx
|
||||
: snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx;
|
||||
: snapTopPx(curtain.snap) + platformOffset + curtain.liveDragPx;
|
||||
|
||||
// After the curtain settles at `closed`, unmount any lingering form.
|
||||
// Guarded so unrelated transitionend events (e.g. children's own
|
||||
|
|
@ -568,7 +634,7 @@ export function StreamHeader({
|
|||
<div
|
||||
className={css.tabsRow}
|
||||
style={inPagerMode ? { opacity: 0 } : undefined}
|
||||
aria-hidden={inPagerMode || undefined}
|
||||
aria-hidden={inPagerMode || isActive || undefined}
|
||||
>
|
||||
<div className={css.tabsCluster}>
|
||||
<Segment
|
||||
|
|
@ -590,20 +656,13 @@ export function StreamHeader({
|
|||
)}
|
||||
</div>
|
||||
<Box grow="Yes" />
|
||||
{isActive ? (
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
onClick={close}
|
||||
aria-label={t('Direct.close')}
|
||||
aria-controls={INLINE_FORM_ID}
|
||||
aria-expanded
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
) : (
|
||||
{/* While search is active the full-stage overlay covers this row
|
||||
AND carries its own close ✕, so we render NO close here — a
|
||||
second occluded-but-focusable «Close» would duplicate the
|
||||
control in the a11y tree and let Shift+Tab land on an
|
||||
invisible button. The whole row is also aria-hidden while
|
||||
active (it's covered). */}
|
||||
{isActive ? null : (
|
||||
<div className={css.iconsCluster}>
|
||||
{/* The default «new chat» Plus is gone — people are found
|
||||
through search now. Only the tab-specific `primaryAction`
|
||||
|
|
@ -642,31 +701,16 @@ export function StreamHeader({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Mascot-refresh card vs search form ─────────────────
|
||||
Mutually exclusive. While the search form is mounted
|
||||
(including the curtain's close-snap window before
|
||||
`acknowledgeClosed`), the mascot card stays unrendered so the
|
||||
form doesn't visually jump mid-animation. Otherwise the mascot
|
||||
card sits in its fixed header position below the tabs row and
|
||||
the curtain occludes it by z-stacking; dragging the curtain
|
||||
down to `refresh` reveals it from underneath. */}
|
||||
{curtain.activeForm ? (
|
||||
<div
|
||||
id={INLINE_FORM_ID}
|
||||
role="region"
|
||||
aria-label={t('Search.search')}
|
||||
className={css.formArea}
|
||||
style={{
|
||||
height: toRem(curtain.formHeightPx ?? 0),
|
||||
}}
|
||||
>
|
||||
<div ref={curtain.formMeasureRef} className={css.formInner}>
|
||||
<InlineRoomSearch onClose={close} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
refreshSlot
|
||||
)}
|
||||
{/* ── Mascot-refresh card ─────────────────────────────────
|
||||
The search form is no longer hosted here — it's a full-stage
|
||||
overlay below (see `formOverlay`). While a form is mounted
|
||||
(including the close-snap window before the safety timer
|
||||
unmounts it) the mascot card stays unrendered so it can't
|
||||
flash behind the fading overlay. Otherwise the mascot card
|
||||
sits in its fixed header position below the tabs row and the
|
||||
curtain occludes it by z-stacking; dragging the curtain down
|
||||
to `refresh` reveals it from underneath. */}
|
||||
{curtain.activeForm ? null : refreshSlot}
|
||||
</header>
|
||||
|
||||
{/* ── Radar-pulse overlay (non-pager native surfaces only) ─
|
||||
|
|
@ -746,6 +790,44 @@ export function StreamHeader({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Full-stage search overlay ─────────────────────────────
|
||||
Mounted while a form is active. Sits ABOVE the curtain (z:3 vs
|
||||
z:2) and fills the whole stage (`inset: 0`), so the search bar
|
||||
covers the tabs row and the result list takes over the chat
|
||||
column — «search up, tabs hidden behind the bar, full height».
|
||||
The stage starts at y = safe-top, so the overlay never covers
|
||||
the status-bar inset. Its opaque SurfaceVariant backdrop is
|
||||
what hides the tabs (load-bearing in pager mode where the
|
||||
static header would otherwise show through). `formVisible`
|
||||
drives the fade/slide in and out. */}
|
||||
{curtain.activeForm && (
|
||||
<div
|
||||
id={INLINE_FORM_ID}
|
||||
role="region"
|
||||
aria-label={t('Search.search')}
|
||||
className={css.formOverlay}
|
||||
data-open={formVisible || undefined}
|
||||
>
|
||||
<div className={css.formOverlayInner}>
|
||||
<InlineRoomSearch onClose={close} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invisible portal marker so the global `useAndroidBackButton`
|
||||
handler treats hardware-back as an Escape (which the effect
|
||||
above turns into a form close) instead of popping navigation. */}
|
||||
{backIntercepts &&
|
||||
backMarkerTarget &&
|
||||
createPortal(
|
||||
<div
|
||||
data-vojo-search-back-marker="true"
|
||||
aria-hidden="true"
|
||||
style={{ display: 'none' }}
|
||||
/>,
|
||||
backMarkerTarget
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { nameInitials } from '../../../utils/common';
|
|||
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch';
|
||||
import { StartDirectDialog } from '../../../features/search/StartDirectDialog';
|
||||
import { SEARCH_FORM_BASE_PX } from '../geometry';
|
||||
|
||||
// Shared inline result-row chrome (rooms / people / address card).
|
||||
const ROW_BASE_STYLE: React.CSSProperties = {
|
||||
|
|
@ -210,70 +209,91 @@ export function InlineRoomSearch({ onClose }: Props) {
|
|||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="200"
|
||||
// Fill the form's full outer height so the result list runs flush
|
||||
// into the curtain's top edge (the form snap drops the breather —
|
||||
// see `snapTopPx`). `- 8` accounts for `formInner`'s 8px top
|
||||
// padding (StreamHeader.css.ts), so measured `formInner.offsetHeight`
|
||||
// lands at exactly `SEARCH_FORM_BASE_PX`, keeping the curtain at its
|
||||
// resting top and the pre-measure fallback in lockstep.
|
||||
style={{ width: '100%', height: toRem(SEARCH_FORM_BASE_PX - 8) }}
|
||||
grow="Yes"
|
||||
// No column gap between the search bar and the result list: the
|
||||
// scroll viewport's top edge (where rows clip as you scroll) sits
|
||||
// flush under the input's bottom border, so there's no light-blue
|
||||
// strip where text disappears early. Per-row padding still gives
|
||||
// the first result a little air at rest, and it scrolls away under
|
||||
// the input cleanly.
|
||||
//
|
||||
// Fill the full-stage overlay (`StreamHeader.css.ts::formOverlay`)
|
||||
// so the result list runs all the way to the bottom edge. The
|
||||
// overlay box shrinks with the layout viewport when the soft
|
||||
// keyboard rises (`interactive-widget=resizes-content`), so the
|
||||
// form stays fully above the keyboard with no manual offset.
|
||||
style={{ width: '100%', minHeight: 0 }}
|
||||
>
|
||||
{/* ── Input bar (h=48 / r=20 / pad 8/14 pill).
|
||||
`width: 100%` + `minWidth: 0` keep the bar a constant full
|
||||
width whether empty or typed — without it the flex row
|
||||
collapses to its content (the search icon) until text widens
|
||||
it, which read as the bar "growing as you type". */}
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
backgroundColor: color.Background.Container,
|
||||
borderRadius: toRem(20),
|
||||
padding: `${toRem(8)} ${toRem(14)}`,
|
||||
height: toRem(48),
|
||||
gap: toRem(10),
|
||||
}}
|
||||
>
|
||||
<Icon size="50" src={Icons.Search} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={t('Search.search')}
|
||||
autoComplete="off"
|
||||
{/* ── Search-bar row: the input pill with a close ✕ on the RIGHT
|
||||
— restores the original tabs-row ✕ position the overlay now
|
||||
covers. Tapping it (or hardware / Escape back) closes the
|
||||
search and brings the tabs back. */}
|
||||
<Box shrink="No" alignItems="Center" gap="100">
|
||||
{/* `grow` + `minWidth: 0` keep the pill a constant full width
|
||||
whether empty or typed — without it the flex row collapses to
|
||||
its content (the search icon) until text widens it, which read
|
||||
as the bar "growing as you type". */}
|
||||
<Box
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
flex: 1,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
font: 'inherit',
|
||||
fontSize: toRem(14),
|
||||
color: color.Background.OnContainer,
|
||||
minWidth: 0,
|
||||
backgroundColor: color.Background.Container,
|
||||
borderRadius: toRem(20),
|
||||
padding: `${toRem(8)} ${toRem(14)}`,
|
||||
height: toRem(48),
|
||||
gap: toRem(10),
|
||||
}}
|
||||
/>
|
||||
{directoryLoading ? (
|
||||
<Spinner size="200" variant="Secondary" />
|
||||
) : (
|
||||
query.length > 0 && (
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
onClick={clearSearch}
|
||||
aria-label={t('Search.clear')}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<Icon size="50" src={Icons.Search} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={t('Search.search')}
|
||||
autoComplete="off"
|
||||
style={{
|
||||
flex: 1,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
font: 'inherit',
|
||||
fontSize: toRem(14),
|
||||
color: color.Background.OnContainer,
|
||||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
{directoryLoading ? (
|
||||
<Spinner size="200" variant="Secondary" />
|
||||
) : (
|
||||
query.length > 0 && (
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
onClick={clearSearch}
|
||||
aria-label={t('Search.clear')}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
onClick={onClose}
|
||||
aria-label={t('Direct.close')}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* ── Result list ────────────────────────────────────── */}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@
|
|||
// Mental model: the chats card is a curtain layered ABOVE the header
|
||||
// (z-index higher). The curtain's `top` is the visible part of the
|
||||
// header below the always-pinned tabs row. When the curtain is fully
|
||||
// closed it sits flush under the tabs row (covering the mascot card +
|
||||
// form area beneath). Dragging it DOWN reveals more of the header from
|
||||
// closed it sits flush under the tabs row (covering the mascot card
|
||||
// beneath). Dragging it DOWN reveals more of the header from
|
||||
// underneath. Dragging UP raises the curtain back over the header.
|
||||
// (The search form is NOT a header child the curtain reveals — it's a
|
||||
// separate full-stage overlay layered ABOVE the curtain; see the
|
||||
// `form:*` snap below and StreamHeader.css.ts::formOverlay.)
|
||||
//
|
||||
// Snap stops (curtain.top, px):
|
||||
// pinned = 0 (curtain sits flush at top of the stage, tabs row
|
||||
|
|
@ -17,9 +20,11 @@
|
|||
// refresh = TABS_ROW_PX + MASCOT_CARD_PX (dancing-mascot pull-to-
|
||||
// refresh card fully revealed; a MOMENTARY snap — entering
|
||||
// it pokes /sync and auto-closes once sync returns)
|
||||
// form:* = TABS_ROW_PX + formHeight (NO breather — the search
|
||||
// results clip flush at the curtain's top edge; the
|
||||
// form's own height already reaches the curtain)
|
||||
// form:* = TABS_ROW_PX (== closed — the curtain does NOT move
|
||||
// when search opens; the search form is a separate
|
||||
// full-stage overlay layered ABOVE the curtain, z:3,
|
||||
// covering the tabs row + chat list. See
|
||||
// StreamHeader.css.ts::formOverlay)
|
||||
//
|
||||
// Pinned visual contract: at `pinned` the curtain's top edge RESTS at
|
||||
// y = safe-top in viewport coords (because the stage starts after the
|
||||
|
|
@ -121,11 +126,6 @@ export const PULSE_CENTER_Y_PX = TABS_ROW_PX + 53;
|
|||
// clips at the canvas's bottom edge.
|
||||
export const PULSE_EDGE_PX = 2;
|
||||
|
||||
// Initial estimate for the search form's outer height. The actual
|
||||
// height is measured at runtime via ResizeObserver and adapts to the
|
||||
// available viewport so the form never overflows the chats card.
|
||||
export const SEARCH_FORM_BASE_PX = 360;
|
||||
|
||||
// Curtain snap transition. Tuned tight for an in-app reveal —
|
||||
// emphasized-decelerate territory.
|
||||
export const CURTAIN_SNAP_MS = 280;
|
||||
|
|
|
|||
|
|
@ -122,6 +122,11 @@ type Args = {
|
|||
// Skip the scrollable-bail in that case — the body's visible area is
|
||||
// the strip BELOW the form, and a drag there is unambiguously a
|
||||
// form-close intent (the only valid transition from form-* snap).
|
||||
// NB: this is now SHADOWED — the search form is a full-stage opaque
|
||||
// overlay (StreamHeader.css.ts::formOverlay, z:3) covering the whole
|
||||
// curtain body while mounted, so a form-snap drag never reaches here in
|
||||
// practice (the form closes via its own ✕ / hardware-back / Escape).
|
||||
// Retained for completeness / in case the overlay stops fully occluding.
|
||||
//
|
||||
// Pinned override: the body gesture is INERT while the curtain is
|
||||
// pinned. Unpin is exclusively the handle's contract — the user has
|
||||
|
|
|
|||
|
|
@ -104,6 +104,14 @@ export const assertNeverCurtainTransition = (_value: never): void => {};
|
|||
// * refresh + DOWN → no-op (nothing lower to reveal).
|
||||
// * form-* + UP → form-close.
|
||||
// * form-* + DOWN → no-op (form is already the lowest snap).
|
||||
//
|
||||
// NB: the form-* arm is now SHADOWED in practice — the search form is a
|
||||
// full-stage opaque overlay (StreamHeader.css.ts::formOverlay, z:3) that
|
||||
// covers this handle while it's mounted, so touches never reach the
|
||||
// curtain during a form snap and `form-close` can't actually fire (the
|
||||
// form is dismissed via its own ✕ / hardware-back / Escape). The arm is
|
||||
// kept for CurtainSnap exhaustiveness and in case the overlay ever stops
|
||||
// occluding the handle.
|
||||
export function resolveCurtainTransition(
|
||||
snap: CurtainSnap,
|
||||
pinned: boolean,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
|
||||
import type { MobilePagerTab } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import { CURTAIN_SNAP_MS, MASCOT_CARD_PX, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry';
|
||||
import { CURTAIN_SNAP_MS, MASCOT_CARD_PX, TABS_ROW_PX } from './geometry';
|
||||
|
||||
// Discrete snap stops for the curtain. The curtain's resting `top`
|
||||
// is derived from this value plus the live finger drag delta. There
|
||||
|
|
@ -16,10 +16,11 @@ export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' => snap ===
|
|||
|
||||
export const isRefreshSnap = (snap: CurtainSnap): snap is 'refresh' => snap === 'refresh';
|
||||
|
||||
// Whether a form is currently rendered in the header. Stays set during
|
||||
// the curtain's close transition so the form has content to slide
|
||||
// behind; cleared by `acknowledgeClosed` after the snap settles at
|
||||
// `closed`. There is only one form (search) — new-chat was folded
|
||||
// Whether the search overlay is currently mounted. Stays set during
|
||||
// the overlay's fade-out (after `snap` leaves `form-search`) so it has
|
||||
// time to animate away; cleared by the safety timer once the snap has
|
||||
// settled at `closed` (or `acknowledgeClosed` on the rare curtain
|
||||
// transition). There is only one form (search) — new-chat was folded
|
||||
// into search's People section — so this is a simple on/off flag.
|
||||
export type ActiveForm = 'search' | null;
|
||||
|
||||
|
|
@ -45,19 +46,12 @@ export type CurtainState = {
|
|||
// `top` transition: disabled while dragging (curtain tracks finger
|
||||
// 1:1), restored on release so the snap commit animates smoothly.
|
||||
isDragging: boolean;
|
||||
// Live measured height of the active form's outer; used to compute
|
||||
// the curtain's resting `top` when `snap === 'form-search'`. `null`
|
||||
// while no form is mounted.
|
||||
formHeightPx: number | null;
|
||||
// Ref pointing at the rendered form's outer — a ResizeObserver
|
||||
// watches this to feed `formHeightPx`. Consumer attaches it to the
|
||||
// form's wrapping element.
|
||||
formMeasureRef: React.RefObject<HTMLDivElement>;
|
||||
// Open the search form. Sets `snap` and `activeForm` synchronously.
|
||||
open: () => void;
|
||||
// Close the curtain (raise it back to `closed`). Keeps `activeForm`
|
||||
// set until the snap transition lands so the form stays mounted
|
||||
// during the slide-up.
|
||||
// Close: set `snap` back to `closed`. For the search form this does
|
||||
// NOT move the curtain (form-search snap == closed), so there's no
|
||||
// slide-up; `activeForm` is kept set so the full-stage overlay can
|
||||
// fade out before the safety timer unmounts it.
|
||||
close: () => void;
|
||||
// Commit a snap stop directly. Used by the touch gesture on release.
|
||||
// Also resets `liveDragPx` and `isDragging` in one batched update.
|
||||
|
|
@ -74,28 +68,22 @@ export type CurtainState = {
|
|||
acknowledgeClosed: () => void;
|
||||
};
|
||||
|
||||
// Resting `top` (px) of the curtain for a given snap stop and the
|
||||
// currently measured form height (null falls back to the base).
|
||||
// Resting `top` (px) of the curtain for a given snap stop.
|
||||
//
|
||||
// The refresh snap rests exactly `MASCOT_CARD_PX` below the tabs row so
|
||||
// the dancing-mascot card is fully revealed below the boundary.
|
||||
//
|
||||
// The form rests at exactly `TABS_ROW_PX + formHeight` — the search
|
||||
// results are a scroll list, and any strip between the last visible
|
||||
// result and the curtain's top reads as «content cut off early» rather
|
||||
// than as breathing room. So the form's measured height already reaches
|
||||
// the curtain top: the list clips flush at the curtain's edge.
|
||||
// `SEARCH_FORM_BASE_PX` is the pre-measure fallback and is sized (= the
|
||||
// form's full outer height) so it equals the measured `formH`, avoiding
|
||||
// any open-time top jump.
|
||||
export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
|
||||
// `form-search` does NOT move the curtain: the search form is a
|
||||
// full-stage overlay (`StreamHeader.css.ts::formOverlay`, z above the
|
||||
// curtain) that covers the tabs row AND the chat list, so the curtain
|
||||
// just rests at its `closed` position behind the opaque overlay. The
|
||||
// overlay owns its own fade-in/out — see StreamHeader's `formVisible`.
|
||||
export function snapTopPx(snap: CurtainSnap): number {
|
||||
switch (snap) {
|
||||
case 'closed':
|
||||
return TABS_ROW_PX;
|
||||
case 'refresh':
|
||||
return TABS_ROW_PX + MASCOT_CARD_PX;
|
||||
case 'closed':
|
||||
case 'form-search':
|
||||
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX);
|
||||
default:
|
||||
return TABS_ROW_PX;
|
||||
}
|
||||
|
|
@ -108,7 +96,6 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
|
|||
export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
|
||||
const [snap, setSnap] = useState<CurtainSnap>('closed');
|
||||
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
|
||||
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
|
||||
const [liveDragPx, setLiveDragPx] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives
|
||||
|
|
@ -118,8 +105,6 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
|
|||
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
|
||||
const pinned = !!pinnedMap[pinKey];
|
||||
|
||||
const formMeasureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setPinned = useCallback(
|
||||
(next: boolean) => {
|
||||
setPinnedMap((prev) => {
|
||||
|
|
@ -170,8 +155,8 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
|
|||
// search form in place of the mascot during the dance.
|
||||
//
|
||||
// The 'closed' destination intentionally KEEPS `activeForm` set so the
|
||||
// closing transition has form content beneath the curtain as it slides
|
||||
// up — `acknowledgeClosed` clears it once the snap settles at `closed`.
|
||||
// search overlay can play its fade-out before unmount — the safety
|
||||
// timer below clears it once the snap has settled at `closed`.
|
||||
if (next === 'refresh') setActiveForm(null);
|
||||
}, []);
|
||||
|
||||
|
|
@ -184,24 +169,13 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
|
|||
if (snap === 'closed') setActiveForm(null);
|
||||
}, [snap]);
|
||||
|
||||
// Measure the form's outer height while it's mounted. We do NOT
|
||||
// overwrite the measured height to null on unmount — keeping the
|
||||
// last-known height stable lets the close transition target the
|
||||
// same `top` value as the open transition (no jump at SNAP_MS-end).
|
||||
useLayoutEffect(() => {
|
||||
if (!activeForm) return undefined;
|
||||
const el = formMeasureRef.current;
|
||||
if (!el) return undefined;
|
||||
const measure = () => setFormHeightPx(el.offsetHeight);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [activeForm]);
|
||||
|
||||
// Safety-net for missed `transitionend` (route unmount mid-anim,
|
||||
// browser quirks). Once snap settles at `closed`, force-drop the
|
||||
// form after a generous window past the snap duration.
|
||||
// Unmount the search overlay shortly after the curtain settles at
|
||||
// `closed`. The overlay no longer rides the curtain's `top`
|
||||
// transition (it's a separate full-stage layer with its own fade),
|
||||
// so this timer — not `acknowledgeClosed`/`transitionend` — is the
|
||||
// primary path that drops `activeForm` once the fade-out has played.
|
||||
// `acknowledgeClosed` stays wired as a fast-path for the rare case
|
||||
// the curtain DID transition (e.g. a refresh→closed settle).
|
||||
const timerRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (snap !== 'closed') {
|
||||
|
|
@ -230,8 +204,6 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
|
|||
activeForm,
|
||||
liveDragPx,
|
||||
isDragging,
|
||||
formHeightPx,
|
||||
formMeasureRef,
|
||||
open,
|
||||
close,
|
||||
commit,
|
||||
|
|
@ -245,7 +217,6 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
|
|||
activeForm,
|
||||
liveDragPx,
|
||||
isDragging,
|
||||
formHeightPx,
|
||||
open,
|
||||
close,
|
||||
commit,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ export type StreamHeaderPrimaryAction = {
|
|||
// tabs row at the top of MobileTabsPager. The pager hoists the tabs +
|
||||
// action icons OUT of each pane's StreamHeader so the header stays
|
||||
// visually static while the listing content slides horizontally — but
|
||||
// the action icons (Plus / Search / X) still need to drive the active
|
||||
// pane's local curtain, so we proxy them through this atom.
|
||||
// the action icons (Plus / Search) still need to drive the active
|
||||
// pane's local curtain, so we proxy them through this atom. (Close is
|
||||
// NOT proxied: while search is open the full-stage overlay covers this
|
||||
// header and carries its own ✕, so the static header renders no close.)
|
||||
//
|
||||
// Lifecycle: each pane's StreamHeader subscribes itself to a
|
||||
// MobilePagerPaneContext that flags whether it's the active pane. When
|
||||
|
|
@ -29,7 +31,6 @@ export type StreamHeaderPrimaryAction = {
|
|||
// is active at any moment, so writes don't race.
|
||||
export type MobilePagerCurtainControls = {
|
||||
openSearch: () => void;
|
||||
closeForm: () => void;
|
||||
isFormActive: boolean;
|
||||
// True while the inline form is MOUNTED (`activeForm !== null`) —
|
||||
// outlives the snap-based `isFormActive` through the 280–480ms close
|
||||
|
|
@ -38,8 +39,9 @@ export type MobilePagerCurtainControls = {
|
|||
// gating on `isFormActive` would pop a mascot kept alive by another
|
||||
// dwelling tab in behind the still-visible closing form, whose band
|
||||
// has no opaque backdrop in pager mode. `isFormActive` stays
|
||||
// untouched — the static header's Search↔X swap must still flip at
|
||||
// close-start.
|
||||
// untouched — the static header still reads it to hide its action
|
||||
// icons (and aria-hide the covered tabs) the moment search opens, and
|
||||
// it must flip back at close-start.
|
||||
formMounted: boolean;
|
||||
// Optional tab-specific create action (the Plus button). When null
|
||||
// (Direct) the static header renders NO Plus — new-chat is gone and
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue