From dc4b6ae755b883e9719b9dc9aabc0df3c495f281 Mon Sep 17 00:00:00 2001 From: Alex Chaplinsky Date: Sat, 10 Oct 2026 22:37:59 +0700 Subject: [PATCH 1/2] fix(compact): layer screens so swipe back reveals the screen underneath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone shell rendered one screen at a time, so an edge swipe dragged the entry screen over a blank ground and the list only mounted once the pop landed. Compact/Stack now keeps the screen a push came from mounted and inert under the pushed one — the tab root (with its bar) under the entry, the settings root under a pane, the pane under its sub-page — and drives its recede and dim through one CSS number (--pop) that the push transitions and the swipe writes per frame, the way iOS's interactive pop moves both views. Pushed screens now slide in across the whole width with an edge shadow; the form only dims the root under it. --- docs/compact-shell.md | 26 ++++-- src/components/Main/Compact/Detail/Read.tsx | 4 +- src/components/Main/Compact/Settings/Pane.tsx | 66 ++++++++------- .../Main/Compact/Settings/index.tsx | 28 ++++--- src/components/Main/Compact/Stack.tsx | 72 ++++++++++++++++ src/components/Main/Compact/chrome.ts | 9 ++ src/components/Main/Compact/index.tsx | 48 +++++------ src/components/Main/Compact/useSwipeBack.ts | 82 +++++++++++++------ src/styles/theme.css | 60 ++++++++++++++ src/test/compact.test.tsx | 62 ++++++++++++-- 10 files changed, 348 insertions(+), 109 deletions(-) create mode 100644 src/components/Main/Compact/Stack.tsx diff --git a/docs/compact-shell.md b/docs/compact-shell.md index a503c3a4b..cc0bff54f 100644 --- a/docs/compact-shell.md +++ b/docs/compact-shell.md @@ -151,15 +151,27 @@ Tabs (labels are the desktop's i18n keys): **All Items · Favorites · Generator settings and the standalone generator and calls `setView`; Generator and Settings are one screen slot between them, so each closes the other. -Transitions are mount animations only (`animate-sheet` slides in from the -right, `animate-rise` slides up, `animate-fade` for roots). No exit animations. +Screens are layered, not swapped: `Compact/Stack` keeps the screen a push came +from mounted under the pushed one — the tab root (with its tab bar) under the +entry or the form, the settings root under a section pane, the pane under its +sub-page — so it keeps its scroll and is there to come back to, and so the +motion has something to happen over. Under a screen the layer is `inert`, and +its geometry is one number, `--pop` (`.stack-under` in `theme.css`): how far +the screen over it has been popped. A push (`animate-push`, the whole width +from the right) transitions it from 1 to 0, which recedes the layer a third of +its width and dims it, as iOS's stack does; a form (`animate-rise`, from the +bottom) only dims it. There are no exit animations: the way back by a tap is a +cut, since the store has already dropped what the screen was showing. A screen pushed from the right can also be popped the iOS way: a drag in from -the left edge takes it with the finger, and letting go past a third of the width -(or a flick) slides it out and runs the same handler its back control does -(`Compact/useSwipeBack`, on the entry screen and both settings panes, under the -same `settingsLocked`). The form is not among them: it rose from the bottom and -its only exit is the guarded Cancel. +the left edge takes it with the finger, and the layer under it comes along — +`--pop` written per frame — out of its receded rest and up to full width and +brightness as the screen clears. Letting go past a third of the width (or a +flick) slides it the rest of the way out and runs the same handler its back +control does (`Compact/useSwipeBack`, on the entry screen and both settings +panes, under the same `settingsLocked`); short of that, both spring back. The +form is not among them: it rose from the bottom and its only exit is the +guarded Cancel. ## iOS conventions to follow diff --git a/src/components/Main/Compact/Detail/Read.tsx b/src/components/Main/Compact/Detail/Read.tsx index b3f40cb8e..4ef4b21bf 100644 --- a/src/components/Main/Compact/Detail/Read.tsx +++ b/src/components/Main/Compact/Detail/Read.tsx @@ -6,7 +6,7 @@ import Eyebrow from '../../Body/Aside/Show/Eyebrow' import Favorite from '../../Body/Aside/Show/Favorite' import Identity from '../../Body/Aside/Show/Identity' import { useDelete } from '../../Body/Aside/Show/useDelete' -import { PRIMARY_CLEARANCE, TOUCH } from '../chrome' +import { PRIMARY_CLEARANCE, PUSHED_SCREEN, TOUCH } from '../chrome' import { useSwipeBack } from '../useSwipeBack' import NavRow from './NavRow' import PrimaryAction from './PrimaryAction' @@ -40,7 +40,7 @@ export default function Read({ entry, revealed }: Props) {
diff --git a/src/components/Main/Compact/Settings/Pane.tsx b/src/components/Main/Compact/Settings/Pane.tsx index 3bd318bbb..bc1616cb4 100644 --- a/src/components/Main/Compact/Settings/Pane.tsx +++ b/src/components/Main/Compact/Settings/Pane.tsx @@ -13,17 +13,19 @@ import { } from '../../Sidebar/Settings/subpages' import BackButton from '../BackButton' import NavBar from '../NavBar' -import { TAB_BAR_CLEARANCE } from '../chrome' +import Stack from '../Stack' +import { PUSHED_SCREEN, TAB_BAR_CLEARANCE } from '../chrome' import { useSwipeBack } from '../useSwipeBack' /** - * One settings section, pushed from the root — or a sub-page of it, pushed - * from the section. + * One settings section, pushed from the root — and, pushed from it in turn, + * any sub-page of it. * * One level deep rather than modal: the tab bar stays up, so the scroller * reserves its clearance, and the way back is the shared `NavBar` carrying the * previous screen's name, exactly as the entry screen's row does — and, as * there, an edge swipe is that same control by gesture, held by the same lock. + * The section stays under its sub-page (`Stack`), as the root stays under it. */ export default function Pane({ section, @@ -39,35 +41,35 @@ export default function Pane({ const { subpage } = useSubpage() const swipe = useSwipeBack(onBack, locked) - if (subpage) return - return ( -
- + + } /> - } - /> -
- {/* The pane names itself the way a root does — there is no room for a - centred nav title beside a back control that already carries one. */} -

- {titleOf(section)} -

-

{descriptionOf(section)}

-
-
-
+
+ {/* The pane names itself the way a root does — there is no room for + a centred nav title beside a back control that already carries + one. */} +

+ {titleOf(section)} +

+

{descriptionOf(section)}

+
+
+
+ } + over={subpage && } + /> ) } @@ -78,11 +80,7 @@ function SubpagePane({ subpage, locked }: { subpage: Subpage; locked: boolean }) const swipe = useSwipeBack(close, locked) return ( -
+
- { - if (useUi.getState().settingsLocked) return - nav.drop() - setSection(null) - }} + } + over={ + section && ( + { + if (useUi.getState().settingsLocked) return + nav.drop() + setSection(null) + }} + /> + ) + } /> - ) : ( - ) } diff --git a/src/components/Main/Compact/Stack.tsx b/src/components/Main/Compact/Stack.tsx new file mode 100644 index 000000000..4f8b53496 --- /dev/null +++ b/src/components/Main/Compact/Stack.tsx @@ -0,0 +1,72 @@ +import { useLayoutEffect, useRef, type ReactNode } from 'react' +import { UnderContext, type Recede } from './useSwipeBack' + +interface Props { + /** The screen that stays: a tab root, or the pane a sub-page was pushed from. */ + under: ReactNode + /** The screen over it, or nothing. */ + over?: ReactNode + /** + * How the screen over arrives. `push` is iOS's navigation push: in from the + * right edge, with the layer under it receding a third of the way and + * dimming. `rise` is a modal form coming up from the bottom: the layer under + * it only dims, since a form is not a step along a path. + */ + motion?: 'push' | 'rise' +} + +/** + * Two screens, one over the other — the thing a navigation stack is made of. + * + * The lower one stays mounted, and keeps its scroll and its state, while a + * screen is pushed over it. That is what every native stack does, and what the + * motion needs: a push slides in *over something*, and an edge swipe drags the + * screen off *something* — the screen the finger is going back to, receding + * and dimmed under it, rather than a blank ground. Under a screen the layer is + * `inert`, so nothing on it can be tapped, focused or read out. + * + * The layer's geometry is `.stack-under`'s (`theme.css`), written as one + * number: `--pop`, how far the screen over it has been popped. The data + * attributes set its resting value and the push transitions it; a drag writes + * it inline per frame through the `Recede` handed to the screen over, and + * whatever the drag leaves inline is cleared the moment the layer is uncovered + * — before paint, so the layer is where its attributes say and the next push + * transitions from there. + */ +export default function Stack({ under, over, motion = 'push' }: Props) { + const ref = useRef(null) + const covered = Boolean(over) + + useLayoutEffect(() => { + const el = ref.current + if (covered || !el) return + el.style.removeProperty('--pop') + el.style.removeProperty('transition') + }, [covered]) + + const recede: Recede = (share, ms) => { + const el = ref.current + if (!el) return + el.style.transition = ms ? `--pop ${ms}ms var(--ease-swift)` : 'none' + el.style.setProperty('--pop', String(share)) + } + + return ( +
+
+ {under} +
+ {covered && ( + +
{over}
+
+ )} +
+ ) +} diff --git a/src/components/Main/Compact/chrome.ts b/src/components/Main/Compact/chrome.ts index 915211cdd..4b40cb4c7 100644 --- a/src/components/Main/Compact/chrome.ts +++ b/src/components/Main/Compact/chrome.ts @@ -44,6 +44,15 @@ export const ADD_DISC = 'bottom-14 h-14 w-14' export const TAB_BAR_NOTCH = 'mask-[radial-gradient(circle_at_50%_0,transparent_34px,#000_35px)]' +/** + * The face of a screen pushed from the right — the entry, a settings pane, a + * sub-page. It arrives across the whole width as the `Stack` layer under it + * recedes, and throws a shadow off its left edge onto that layer: unseen at + * rest, where it lies past the screen's edge, and the seam between the two + * screens while an edge swipe holds them apart. + */ +export const PUSHED_SCREEN = 'flex min-h-0 flex-1 flex-col animate-push bg-screen shadow-push' + /** * What a root screen's scroller reserves under its content so the last row can * be scrolled clear of the bar it slides under (the disc's crown at 112px + a diff --git a/src/components/Main/Compact/index.tsx b/src/components/Main/Compact/index.tsx index fc07fa0e2..a829e1f4f 100644 --- a/src/components/Main/Compact/index.tsx +++ b/src/components/Main/Compact/index.tsx @@ -6,11 +6,13 @@ import Generator from './Generator' import Settings from './Settings' import Vault from './Vault' import TabBar from './TabBar' +import Stack from './Stack' /** - * The phone shell: one screen at a time, derived from the store. + * The phone shell: a tab root, and at most one screen pushed over it, both + * derived from the store. * - * There is no router. The screen is the first row of the navigation model + * There is no router. The screens are the rows of the navigation model * (docs/compact-shell.md) that the store makes true — a draft or an edit is the * form, a surface opened over the vault is that surface, a selection is the * detail, otherwise a tab root. Nothing here is new state, so ⌘K, the tray and @@ -22,8 +24,10 @@ import TabBar from './TabBar' * visible. A draft still wins over all of it — it is the one screen with * unsaved work on it. Tabs clear the selection through `setView` anyway. * - * The two pushed screens (form, detail) hide the tab bar: switching view from - * inside one would drop the selection, and any draft, out from under it. + * The root stays mounted under a pushed screen (`Stack`), with its tab bar, + * so it keeps its scroll and is there to come back to — receding under a push + * and sliding back under an edge swipe. It is inert the while: switching view + * from under a form would drop the draft out from under it. */ export default function Compact() { const writing = useVault(state => state.creating !== null || state.editing) @@ -44,27 +48,25 @@ export default function Compact() {
- {/* Both entry branches are the same component in the same slot, so - React keeps the instance — and with it the one reveal — across the - step from reading to writing. */} - {writing ? ( - - ) : settings ? ( - - ) : generator ? ( - - ) : entry ? ( - - ) : ( - - )} - {!pushed && } + {/* The entry screen is one component for reading and writing, in one + slot, so React keeps the instance — and with it the one reveal — + across the step from one to the other. The form rises rather than + pushes: it is a mode, not a step along a path. */} + + {settings ? : generator ? : } + {/* Pinned to the root's layer, so it goes where the root goes. */} + + + } + over={pushed && } + />
{/* Outside the shell, like every fixed overlay: the shell carries `viewportStyle`'s translate, which makes it the containing block of diff --git a/src/components/Main/Compact/useSwipeBack.ts b/src/components/Main/Compact/useSwipeBack.ts index d2e3170d9..292f813b1 100644 --- a/src/components/Main/Compact/useSwipeBack.ts +++ b/src/components/Main/Compact/useSwipeBack.ts @@ -1,4 +1,12 @@ -import { useEffect, useRef, type PointerEvent } from 'react' +import { createContext, useContext, useEffect, useRef, type PointerEvent } from 'react' + +/** + * What a `Stack` gives the screen over its lower layer: a way to put that + * layer `share` of the way popped (0 at rest under the screen, 1 clear of it), + * over `ms` — or at once, under a finger. Null off a stack. + */ +export type Recede = (share: number, ms: number) => void +export const UnderContext = createContext(null) // The strip along the left edge a swipe back has to start in, as iOS's own // interactive pop does: a drag from anywhere else is a scroll or a row. @@ -12,9 +20,12 @@ const SLOP_PX = 8 const COMMIT_SHARE = 1 / 3 const FLICK_SPEED = 0.5 const FLICK_MIN_PX = 16 -// The rest of the way out once a drag commits: the mount animation's own 160ms, -// and the spring back is `BottomSheet`'s 200ms. -const OUT_MS = 160 +// The rest of the way out once a drag commits: the push's own 320ms over the +// width still to go, so a screen let go near the edge does not crawl, with a +// floor under it so one let go late does not cut. The spring back is +// `BottomSheet`'s 200ms. +const PUSH_MS = 320 +const OUT_MIN_MS = 120 const SPRING_MS = 200 interface Origin { @@ -23,6 +34,8 @@ interface Origin { x: number y: number at: number + /** The screen's width, measured once: the drag is read as a share of it. */ + width: number /** Null until the drag has left the slop: then it is a pop, or a scroll. */ pop: boolean | null } @@ -41,17 +54,24 @@ const reducedMotion = () => * began: a lock that arrives mid-swipe (a restore starting under a settings * pane) puts the screen back rather than leaving it off the edge. * - * The screen is moved by hand rather than through state: a pointermove at - * 60Hz re-rendering the whole entry screen is a cost the drag would show. Only - * the pointer handlers and `ref` are React's; the ref callback also sets + * The screen the finger is going back to is the `Stack` layer under this one. + * It is moved in step with the drag — out of its receded, dimmed rest and up + * to full width and brightness as the screen clears — through the `Recede` + * the stack provides, which writes `--pop`, the one number that layer's + * geometry is a function of (see `.stack-under`). + * + * Both are moved by hand rather than through state: a pointermove at 60Hz + * re-rendering the whole entry screen is a cost the drag would show. Only the + * pointer handlers and `ref` are React's; the ref callback also sets * `touch-action: pan-y`, which is what keeps the webview from claiming a * horizontal pan as its own gesture while vertical ones still scroll. * - * There is no exit animation in the shell (the screen under this one simply - * mounts), so the commit finishes the slide itself before calling back. + * There is no exit animation in the shell (the screen simply unmounts), so + * the commit finishes the slide itself before calling back. */ export function useSwipeBack(onBack: () => void, disabled = false) { const screen = useRef(null) + const recede = useContext(UnderContext) const origin = useRef(null) const leaving = useRef | null>(null) // What the back control would do *now*, for a pop that lands after the @@ -69,18 +89,23 @@ export function useSwipeBack(onBack: () => void, disabled = false) { [] ) - const move = (x: number | string, ms: number) => { + // The screen to `x`, and the layer under it to `share` of the way popped, + // over `ms` — or at once, under the finger. + const move = (x: string, share: number, ms: number) => { const el = screen.current - if (!el) return - el.style.transition = ms ? `translate ${ms}ms ease-out` : 'none' - el.style.translate = `${x} 0` + if (el) { + el.style.transition = ms ? `translate ${ms}ms var(--ease-swift)` : 'none' + el.style.translate = `${x} 0` + } + recede?.(share, ms) } + const settle = () => move('0', 0, SPRING_MS) // Lets go the way the back control would: through it, or — if it refuses // by now — back to where the screen was. const pop = () => { leaving.current = null - if (latest.current.disabled) return move('0', SPRING_MS) + if (latest.current.disabled) return settle() latest.current.onBack() } @@ -92,8 +117,16 @@ export function useSwipeBack(onBack: () => void, disabled = false) { const onPointerDown = (e: PointerEvent) => { // A mouse at the edge of a narrow window is selecting text, not popping. if (disabled || leaving.current || origin.current || e.pointerType === 'mouse') return - if (e.clientX - e.currentTarget.getBoundingClientRect().left > EDGE_PX) return - origin.current = { id: e.pointerId, x: e.clientX, y: e.clientY, at: e.timeStamp, pop: null } + const { left, width } = e.currentTarget.getBoundingClientRect() + if (e.clientX - left > EDGE_PX) return + origin.current = { + id: e.pointerId, + x: e.clientX, + y: e.clientY, + at: e.timeStamp, + width: Math.max(1, width), + pop: null + } } const onPointerMove = (e: PointerEvent) => { @@ -108,7 +141,9 @@ export function useSwipeBack(onBack: () => void, disabled = false) { from.pop = dx > Math.abs(dy) if (from.pop) e.currentTarget.setPointerCapture(e.pointerId) } - if (from.pop) move(`${Math.max(0, dx)}px`, 0) + if (!from.pop) return + const x = Math.max(0, dx) + move(`${x}px`, Math.min(1, x / from.width), 0) } const onPointerUp = (e: PointerEvent) => { @@ -118,13 +153,14 @@ export function useSwipeBack(onBack: () => void, disabled = false) { if (!from.pop) return const dx = e.clientX - from.x const speed = dx / Math.max(1, e.timeStamp - from.at) - const width = e.currentTarget.getBoundingClientRect().width - if (dx > width * COMMIT_SHARE || (dx > FLICK_MIN_PX && speed > FLICK_SPEED)) { + if (dx > from.width * COMMIT_SHARE || (dx > FLICK_MIN_PX && speed > FLICK_SPEED)) { if (reducedMotion()) return pop() - move('100%', OUT_MS) - leaving.current = setTimeout(pop, OUT_MS) + const left = 1 - Math.min(1, Math.max(0, dx) / from.width) + const ms = Math.max(OUT_MIN_MS, Math.round(PUSH_MS * left)) + move('100%', 1, ms) + leaving.current = setTimeout(pop, ms) } else { - move('0', SPRING_MS) + settle() } } @@ -132,7 +168,7 @@ export function useSwipeBack(onBack: () => void, disabled = false) { const from = origin.current if (from?.id !== e.pointerId) return origin.current = null - if (from.pop) move('0', SPRING_MS) + if (from.pop) settle() } return { ref, onPointerDown, onPointerMove, onPointerUp, onPointerCancel } diff --git a/src/styles/theme.css b/src/styles/theme.css index 71ab034f7..76da11ee1 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -66,6 +66,20 @@ transform: none; } } +/* + * A screen pushed over another in the phone shell (Compact/Stack): in from the + * right edge across the whole width, the way a navigation push lands, while the + * layer under it recedes — see `.stack-under` below. No fade: it is a surface + * sliding over a surface, and the one under it is in view the whole way. + */ +@keyframes push { + from { + transform: translateX(100%); + } + to { + transform: none; + } +} /* * A setup step arriving under the mascot: from the right going forward, from * the left coming back. Further and slower than the phone sheet — this is a @@ -396,6 +410,7 @@ --shadow-menu: var(--menu-shadow); --shadow-lockfield: var(--lockfield-shadow); --shadow-lockfield-deep: var(--lockfield-shadow-deep); + --shadow-push: var(--push-shadow); /* * The lock field's own state ink (see elements/Masterpass): a fixed blue * that does not follow the user's accent. The field is the one bespoke @@ -519,12 +534,54 @@ --animate-pop: pop 160ms var(--ease-swift) both; --animate-drop: drop 140ms var(--ease-swift) both; --animate-sheet: sheet 160ms var(--ease-swift) both; + --animate-push: push 320ms var(--ease-swift) both; --animate-step-forward: step-forward 240ms var(--ease-swift) both; --animate-step-back: step-back 240ms var(--ease-swift) both; --animate-rise: rise 300ms var(--ease-swift) both; --animate-unfold: unfold 300ms var(--ease-swift) both; } +/* + * The layer under a pushed screen (Compact/Stack). `--pop` is how far the + * screen over it has been popped: 0 while it is covered, 1 once it is gone. + * Everything the layer does under that screen is a function of this one + * number — it recedes a third of its width, as iOS's does under a push, and + * dims — so a push transitions one property and an edge swipe writes one + * value per frame. Registered so the transition can interpolate it; where it + * cannot be, the layer still lands where it should, just without the glide. + */ +@property --pop { + syntax: ''; + inherits: true; + initial-value: 1; +} +@layer components { + .stack-under { + --pop: 1; + /* Its own stacking context, so the dim below stays over this layer and + never over the screen on top of it. */ + isolation: isolate; + } + .stack-under[data-covered] { + --pop: 0; + transition: --pop 320ms var(--ease-swift); + } + .stack-under[data-recede] { + translate: calc((var(--pop) - 1) * 30%) 0; + } + /* The dim: the scrim at a third of its strength, over everything on the + layer including its floating chrome. */ + .stack-under::after { + content: ''; + position: absolute; + inset: 0; + z-index: 50; + pointer-events: none; + background: var(--scrim); + opacity: calc((1 - var(--pop)) * 0.35); + } +} + /* * The orbiting border light, whole: the container clips down to the card's * 2px border ring (two-layer mask keeps the interior clear) and its ::before @@ -755,6 +812,8 @@ 0 18px 40px -10px rgba(22, 28, 48, 0.28), 0 4px 12px rgba(22, 28, 48, 0.08), 0 0 0 0.5px rgba(22, 28, 48, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.9); --scrim: rgba(40, 43, 48, 0.3); + /* What a pushed screen's left edge throws on the one it is dragged off. */ + --push-shadow: -6px 0 20px rgba(24, 26, 30, 0.14); --thumb: rgba(20, 22, 26, 0.22); /* * The lock screen's signature field (see Masterpass): a white surface set @@ -898,6 +957,7 @@ 0 18px 40px -8px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 0.5px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.07); --scrim: rgba(8, 9, 10, 0.7); + --push-shadow: -6px 0 20px rgba(0, 0, 0, 0.5); --thumb: rgba(255, 255, 255, 0.14); --lockfield-shadow: inset 0 1.5px 3px rgba(0, 0, 0, 0.5), diff --git a/src/test/compact.test.tsx b/src/test/compact.test.tsx index 970733bb2..48b38aa97 100644 --- a/src/test/compact.test.tsx +++ b/src/test/compact.test.tsx @@ -81,9 +81,13 @@ describe('compact shell', () => { // The way back is named after where it goes, iOS-style — the list root's // own title, from the same hook the list root draws it with. expect(screen.getByTestId('compact-back')).toHaveTextContent('All Items') - // One screen at a time: the list and the tab bar are gone while it is up. - expect(screen.queryByTestId('entry-item')).not.toBeInTheDocument() - expect(screen.queryByTestId('tab-bar')).not.toBeInTheDocument() + // The list and its tab bar stay under it — there to come back to, and in + // view under an edge swipe — but inert: nothing on them can be tapped, + // focused or read out while the entry is up. (jsdom implements no `inert` + // semantics, so the attribute itself is the assertion.) + expect(screen.getAllByTestId('entry-item')).toHaveLength(2) + expect(screen.getByTestId('tab-bar').closest('[inert]')).not.toBeNull() + expect(screen.getByTestId('entry-screen').closest('[inert]')).toBeNull() // The corner holds one control: the star sits by the entry's name, and // Edit waits under the menu. expect(screen.getByTestId('favorite-toggle')).toBeInTheDocument() @@ -95,6 +99,7 @@ describe('compact shell', () => { await userEvent.click(screen.getByTestId('compact-back')) expect(useVault.getState().currentId).toBeNull() expect(screen.getAllByTestId('entry-item')).toHaveLength(2) + expect(screen.getByTestId('tab-bar').closest('[inert]')).toBeNull() }) it('lands a new entry on the form screen, titled by its kind', async () => { @@ -384,7 +389,9 @@ describe('compact shell', () => { act(() => startEntry('login')) act(() => openSettings()) expect(screen.getByTestId('save-entry-button')).toBeInTheDocument() - expect(screen.queryByTestId('settings-nav-security')).not.toBeInTheDocument() + // The root opened is there under the form, but out of reach. + expect(screen.getByTestId('settings-nav-security').closest('[inert]')).not.toBeNull() + expect(screen.getByTestId('save-entry-button').closest('[inert]')).toBeNull() }) it('shows the audit score under the groups on the health view', () => { @@ -410,12 +417,14 @@ describe('compact shell', () => { await userEvent.click(screen.getByTestId('settings-nav-security')) expect(screen.getByRole('heading', { name: 'Security' })).toBeInTheDocument() - // One level deep, not a modal: the tab bar is still there. + // One level deep, not a modal: the tab bar is still there, and so is the + // root under the pane — inert until the pane is popped. expect(screen.getByTestId('tab-bar')).toBeInTheDocument() - expect(screen.queryByTestId('settings-nav-security')).not.toBeInTheDocument() + expect(screen.getByTestId('tab-bar').closest('[inert]')).toBeNull() + expect(screen.getByTestId('settings-nav-security').closest('[inert]')).not.toBeNull() await userEvent.click(screen.getByTestId('settings-back')) - expect(screen.getByTestId('settings-nav-security')).toBeInTheDocument() + expect(screen.getByTestId('settings-nav-security').closest('[inert]')).toBeNull() }) // A section body that sends the user elsewhere (the export pane's "save a @@ -468,11 +477,13 @@ describe('compact shell', () => { expect(screen.getByRole('heading', { name: 'New workspace' })).toBeInTheDocument() expect(screen.getByTestId('settings-subpage-back')).toHaveTextContent('Workspaces') - expect(screen.queryByTestId('settings-back')).not.toBeInTheDocument() + // The section stays under its sub-page, inert, as the root stays under it. + expect(screen.getByTestId('settings-back').closest('[inert]')).not.toBeNull() + expect(screen.getByTestId('settings-subpage-back').closest('[inert]')).toBeNull() await userEvent.click(screen.getByTestId('settings-subpage-back')) expect(screen.getByRole('heading', { name: 'Workspaces' })).toBeInTheDocument() - expect(screen.getByTestId('settings-back')).toBeInTheDocument() + expect(screen.getByTestId('settings-back').closest('[inert]')).toBeNull() }) // The chip's default is the wide modal's section state, which a pushed pane @@ -673,6 +684,39 @@ describe('swipe back', () => { expect(screen.getAllByTestId('entry-item')).toHaveLength(2) }) + // The layer under the screen — the list root, with the tab bar on it — is + // where the finger is going: it comes along, as `--pop` (the share of the + // width the screen has cleared; see `.stack-under`), held at rest under the + // screen while it is up, and left to its own geometry once it is gone. + it('brings the screen under it along, and lets it go when the pop lands', async () => { + const entry = await openEntry() + const under = entry.parentElement!.previousElementSibling as HTMLElement + expect(under).toContainElement(screen.getByTestId('tab-bar')) + expect(under).toHaveAttribute('data-covered') + expect(under).toHaveAttribute('data-recede') + + entry.setPointerCapture = () => {} + entry.getBoundingClientRect = () => ({ left: 0, width: 390 }) as DOMRect + const touch = { pointerId: 1, pointerType: 'touch' } + fireEvent.pointerDown(entry, { ...touch, clientX: 0, clientY: 100 }) + fireEvent.pointerMove(entry, { ...touch, clientX: 195, clientY: 100 }) + expect(under.style.getPropertyValue('--pop')).toBe('0.5') + + // A short drag puts both back. (Let go within the flick's minimum: jsdom's + // events are all stamped at once, so any longer drag reads as a flick.) + fireEvent.pointerUp(entry, { ...touch, clientX: 10, clientY: 100 }) + expect(under.style.getPropertyValue('--pop')).toBe('0') + expect(entry.style.translate).toBe('0 0') + + swipe(entry, { dx: 200 }) + expect(under.style.getPropertyValue('--pop')).toBe('1') + await waitFor(() => expect(useVault.getState().currentId).toBeNull()) + expect(under).not.toHaveAttribute('data-covered') + expect(under).not.toHaveAttribute('inert') + expect(under.style.getPropertyValue('--pop')).toBe('') + expect(under.style.transition).toBe('') + }) + it('springs back from a short drag, and ignores one that is not from the edge', async () => { const entry = await openEntry() swipe(entry, { dx: 10 }) From 26183ee21e7681f826646601216bd190fc7215c2 Mon Sep 17 00:00:00 2001 From: Alex Chaplinsky Date: Sun, 11 Oct 2026 00:47:15 +0700 Subject: [PATCH 2/2] fix(compact): land a search request made while the list was covered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the list staying mounted under a pushed entry, ⌘F pressed there asked an inert input for focus, which it refused, and nothing retried on Back — before, mounting the list on Back was what applied the request. The field now holds a request until it has applied it, checking after every render rather than only when the tick moves, so the uncover's render is the one it lands on. --- .../Main/Body/ListColumn/Search.tsx | 17 +++++++++++++++-- src/test/compact.test.tsx | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/Main/Body/ListColumn/Search.tsx b/src/components/Main/Body/ListColumn/Search.tsx index 4695180f4..bc0c57b18 100644 --- a/src/components/Main/Body/ListColumn/Search.tsx +++ b/src/components/Main/Body/ListColumn/Search.tsx @@ -29,10 +29,23 @@ export default function Search({ className = DESKTOP }: { className?: string }) // reaching into the DOM for a field it holds no ref to (`focusSearch`). The // request is a tick, so two presses running are two requests; the 0 it starts // at is the one value that is nobody asking. + // + // A request can arrive while the field cannot take it: on the phone the list + // stays mounted, inert, under a pushed entry (`Compact/Stack`), and an inert + // input refuses focus. So a request is held until it has been applied, and + // the check runs after every render rather than only when the tick moves — + // the uncover re-renders the column (the shell re-renders the root it keeps + // under the stack) without touching the tick, and that is the render the + // held request lands on. const requested = useUi(state => state.searchFocus) + const applied = useRef(0) useEffect(() => { - if (requested > 0) input.current?.focus() - }, [requested]) + if (requested <= applied.current) return + const el = input.current + if (!el || el.closest('[inert]')) return + applied.current = requested + el.focus() + }) // Clearing hands the caret back to the field: the button that was clicked // is about to fade out and leave the tab order, and a focus left on it would diff --git a/src/test/compact.test.tsx b/src/test/compact.test.tsx index 48b38aa97..7a58e7416 100644 --- a/src/test/compact.test.tsx +++ b/src/test/compact.test.tsx @@ -12,6 +12,7 @@ import { useVault, selectCurrent, editEntry, + focusSearch, lockSettings, openPalette, openSettings, @@ -237,6 +238,24 @@ describe('compact shell', () => { ) }) + // ⌘F with an entry up asks for a caret the list cannot take yet — it is + // inert under the entry — so the request waits, and lands the moment the + // list is back. Before the list stayed mounted, mounting it was what applied + // the request; now nothing remounts, so the field has to notice the uncover. + it('lands a search request made under an entry once the list is back', async () => { + mockCommand('reveal_entry', () => loginEntry({ id: 'l1' })) + seed() + render(
) + + await userEvent.click(screen.getByText('Google')) + const input = screen.getByTestId('search-input') + act(() => focusSearch()) + expect(input).not.toHaveFocus() + + await userEvent.click(screen.getByTestId('compact-back')) + expect(input).toHaveFocus() + }) + it('copies a field value when the value itself is tapped', async () => { mockCommand('reveal_entry', () => loginEntry({ id: 'l1', username: 'copyme' })) seed()