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/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/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..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, @@ -81,9 +82,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 +100,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 () => { @@ -232,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() @@ -384,7 +408,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 +436,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 +496,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 +703,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 })