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 })