Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions docs/compact-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 15 additions & 2 deletions src/components/Main/Body/ListColumn/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/components/Main/Compact/Detail/Read.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -40,7 +40,7 @@ export default function Read({ entry, revealed }: Props) {
<div
{...swipe}
data-testid="entry-screen"
className="relative flex min-h-0 flex-1 flex-col animate-sheet bg-screen text-text"
className={cx('relative text-text', PUSHED_SCREEN)}
>
<NavRow entry={entry} onDelete={remove} />

Expand Down
66 changes: 32 additions & 34 deletions src/components/Main/Compact/Settings/Pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,35 +41,35 @@ export default function Pane({
const { subpage } = useSubpage()
const swipe = useSwipeBack(onBack, locked)

if (subpage) return <SubpagePane subpage={subpage} locked={locked} />

return (
<div
{...swipe}
data-testid="settings-pane"
className="flex min-h-0 flex-1 flex-col animate-sheet bg-screen"
>
<NavBar
leading={
<BackButton
testid="settings-back"
label={t('Settings')}
disabled={locked}
onClick={onBack}
<Stack
under={
<div {...swipe} data-testid="settings-pane" className={PUSHED_SCREEN}>
<NavBar
leading={
<BackButton
testid="settings-back"
label={t('Settings')}
disabled={locked}
onClick={onBack}
/>
}
/>
}
/>

<div className={cx('min-h-0 flex-1 overflow-y-auto px-4 pt-1', TAB_BAR_CLEARANCE)}>
{/* 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. */}
<h1 className="truncate text-2xl font-semibold tracking-display text-text">
{titleOf(section)}
</h1>
<p className="mt-1 mb-5 text-sm text-text2">{descriptionOf(section)}</p>
<Section section={section} />
</div>
</div>
<div className={cx('min-h-0 flex-1 overflow-y-auto px-4 pt-1', TAB_BAR_CLEARANCE)}>
{/* 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. */}
<h1 className="truncate text-2xl font-semibold tracking-display text-text">
{titleOf(section)}
</h1>
<p className="mt-1 mb-5 text-sm text-text2">{descriptionOf(section)}</p>
<Section section={section} />
</div>
</div>
}
over={subpage && <SubpagePane subpage={subpage} locked={locked} />}
/>
)
}

Expand All @@ -78,11 +80,7 @@ function SubpagePane({ subpage, locked }: { subpage: Subpage; locked: boolean })
const swipe = useSwipeBack(close, locked)

return (
<div
{...swipe}
data-testid="settings-subpage"
className="flex min-h-0 flex-1 flex-col animate-sheet bg-screen"
>
<div {...swipe} data-testid="settings-subpage" className={PUSHED_SCREEN}>
<NavBar
leading={
<BackButton
Expand Down
28 changes: 17 additions & 11 deletions src/components/Main/Compact/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '../../Sidebar/Settings/sectionNav'
import Root from './Root'
import Pane from './Pane'
import Stack from '../Stack'

/**
* Settings as a tab root, not an overlay: it has no close button, because the
Expand Down Expand Up @@ -41,21 +42,26 @@ export default function Settings() {
setSection(next)
}

return section ? (
return (
<SectionNavProvider value={go}>
<SubpageProvider value={nav}>
<Pane
section={section}
locked={locked}
onBack={() => {
if (useUi.getState().settingsLocked) return
nav.drop()
setSection(null)
}}
<Stack
under={<Root onSelect={setSection} />}
over={
section && (
<Pane
section={section}
locked={locked}
onBack={() => {
if (useUi.getState().settingsLocked) return
nav.drop()
setSection(null)
}}
/>
)
}
/>
</SubpageProvider>
</SectionNavProvider>
) : (
<Root onSelect={setSection} />
)
}
72 changes: 72 additions & 0 deletions src/components/Main/Compact/Stack.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(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 (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={ref}
inert={covered}
data-covered={covered || undefined}
data-recede={(covered && motion === 'push') || undefined}
className="stack-under absolute inset-0 flex flex-col"
>
{under}
</div>
{covered && (
<UnderContext.Provider value={recede}>
<div className="absolute inset-0 flex flex-col">{over}</div>
</UnderContext.Provider>
)}
</div>
)
}
9 changes: 9 additions & 0 deletions src/components/Main/Compact/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 25 additions & 23 deletions src/components/Main/Compact/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -44,27 +48,25 @@ export default function Compact() {
<div
data-testid="compact-shell"
style={viewportStyle(viewport)}
// `relative` is what the floating tab bar and its fade are pinned to.
// The ground is painted here as well as on each screen: a screen
// arrives by fading or sliding in, and without a ground under it the
// main root's darker `bg-app` would show through for that beat.
// The ground is painted here as well as on each screen, so nothing of
// the main root's darker `bg-app` shows between them.
className="relative flex h-full min-h-0 flex-col bg-screen"
>
{/* 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 ? (
<Entry />
) : settings ? (
<Settings />
) : generator ? (
<Generator />
) : entry ? (
<Entry />
) : (
<Vault />
)}
{!pushed && <TabBar />}
{/* 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. */}
<Stack
motion={writing ? 'rise' : 'push'}
under={
<>
{settings ? <Settings /> : generator ? <Generator /> : <Vault />}
Comment thread
alchaplinsky marked this conversation as resolved.
{/* Pinned to the root's layer, so it goes where the root goes. */}
<TabBar />
</>
}
over={pushed && <Entry />}
/>
</div>
{/* Outside the shell, like every fixed overlay: the shell carries
`viewportStyle`'s translate, which makes it the containing block of
Expand Down
Loading
Loading