@charcuterie/ui 3.34.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ActionTiles/ActionTiles.d.ts +66 -8
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
- package/dist/ActionTiles/ActionTiles.js +88 -11
- package/dist/ActionTiles/ActionTiles.js.map +1 -1
- package/dist/Main/Main.d.ts +18 -19
- package/dist/Main/Main.d.ts.map +1 -1
- package/dist/Main/Main.js +21 -10
- package/dist/Main/Main.js.map +1 -1
- package/dist/Main/ScrollMemoryProvider.d.ts +38 -0
- package/dist/Main/ScrollMemoryProvider.d.ts.map +1 -0
- package/dist/Main/ScrollMemoryProvider.js +30 -0
- package/dist/Main/ScrollMemoryProvider.js.map +1 -0
- package/dist/Main/scrollMemory.d.ts +54 -28
- package/dist/Main/scrollMemory.d.ts.map +1 -1
- package/dist/Main/scrollMemory.js +81 -40
- package/dist/Main/scrollMemory.js.map +1 -1
- package/dist/Main/scrollMemoryContext.d.ts +18 -0
- package/dist/Main/scrollMemoryContext.d.ts.map +1 -0
- package/dist/Main/scrollMemoryContext.js +18 -0
- package/dist/Main/scrollMemoryContext.js.map +1 -0
- package/dist/Main/useScrollMemory.d.ts +20 -18
- package/dist/Main/useScrollMemory.d.ts.map +1 -1
- package/dist/Main/useScrollMemory.js +69 -42
- package/dist/Main/useScrollMemory.js.map +1 -1
- package/dist/PortraitTiles/PortraitAvatar.d.ts +21 -0
- package/dist/PortraitTiles/PortraitAvatar.d.ts.map +1 -0
- package/dist/PortraitTiles/PortraitAvatar.js +29 -0
- package/dist/PortraitTiles/PortraitAvatar.js.map +1 -0
- package/dist/PortraitTiles/PortraitTiles.d.ts +162 -0
- package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -0
- package/dist/PortraitTiles/PortraitTiles.js +263 -0
- package/dist/PortraitTiles/PortraitTiles.js.map +1 -0
- package/dist/PortraitTiles/portraitColour.d.ts +49 -0
- package/dist/PortraitTiles/portraitColour.d.ts.map +1 -0
- package/dist/PortraitTiles/portraitColour.js +55 -0
- package/dist/PortraitTiles/portraitColour.js.map +1 -0
- package/dist/categoricalStyles.d.ts +27 -0
- package/dist/categoricalStyles.d.ts.map +1 -1
- package/dist/categoricalStyles.js +49 -0
- package/dist/categoricalStyles.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/reactRouter/ReactRouterAdapter.d.ts +43 -0
- package/dist/reactRouter/ReactRouterAdapter.d.ts.map +1 -0
- package/dist/reactRouter/ReactRouterAdapter.js +50 -0
- package/dist/reactRouter/ReactRouterAdapter.js.map +1 -0
- package/dist/reactRouter/index.d.ts +14 -8
- package/dist/reactRouter/index.d.ts.map +1 -1
- package/dist/reactRouter/index.js +13 -8
- package/dist/reactRouter/index.js.map +1 -1
- package/dist/tileStyles.d.ts +15 -0
- package/dist/tileStyles.d.ts.map +1 -1
- package/dist/tileStyles.js +26 -1
- package/dist/tileStyles.js.map +1 -1
- package/package.json +2 -2
- package/src/ActionTiles/ActionTiles.mdx +64 -6
- package/src/ActionTiles/ActionTiles.stories.tsx +107 -2
- package/src/ActionTiles/ActionTiles.test.tsx +205 -5
- package/src/ActionTiles/ActionTiles.tsx +274 -110
- package/src/Main/Main.mdx +35 -7
- package/src/Main/Main.stories.tsx +129 -64
- package/src/Main/Main.test.tsx +58 -0
- package/src/Main/Main.tsx +20 -20
- package/src/Main/ScrollMemoryProvider.tsx +48 -0
- package/src/Main/scrollMemory.test.ts +70 -16
- package/src/Main/scrollMemory.ts +98 -40
- package/src/Main/scrollMemoryContext.ts +21 -0
- package/src/Main/useScrollMemory.ts +83 -43
- package/src/PortraitTiles/PortraitAvatar.tsx +49 -0
- package/src/PortraitTiles/PortraitTiles.mdx +153 -0
- package/src/PortraitTiles/PortraitTiles.stories.tsx +403 -0
- package/src/PortraitTiles/PortraitTiles.test.tsx +399 -0
- package/src/PortraitTiles/PortraitTiles.tsx +577 -0
- package/src/PortraitTiles/portraitColour.test.ts +39 -0
- package/src/PortraitTiles/portraitColour.ts +67 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +39 -0
- package/src/RouterLink/RouterLink.mdx +53 -16
- package/src/categoricalStyles.ts +57 -0
- package/src/index.ts +12 -0
- package/src/reactRouter/ReactRouterAdapter.stories.tsx +120 -0
- package/src/reactRouter/ReactRouterAdapter.test.tsx +92 -0
- package/src/reactRouter/ReactRouterAdapter.tsx +66 -0
- package/src/reactRouter/index.ts +14 -8
- package/src/sourceRules.test.ts +19 -3
- package/src/tileStyles.ts +30 -1
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
RailNavigation,
|
|
17
17
|
} from "../shell.storyHelpers.tsx"
|
|
18
18
|
import { Main } from "./Main.tsx"
|
|
19
|
+
import { ScrollMemoryProvider } from "./ScrollMemoryProvider.tsx"
|
|
19
20
|
|
|
20
21
|
const CONTENT_WIDTH_OPTIONS = [
|
|
21
22
|
"sm",
|
|
@@ -173,17 +174,42 @@ const EPISODE_TITLES = Array.from(
|
|
|
173
174
|
|
|
174
175
|
type HistoryEntry = {
|
|
175
176
|
episode: string | null
|
|
177
|
+
isExpanded: boolean
|
|
176
178
|
key: string
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
const SPECIAL_TITLES = Array.from(
|
|
182
|
+
{ length: 8 },
|
|
183
|
+
(_unused, index) => `Special ${index + 1}`,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const toPath = (entry: HistoryEntry) =>
|
|
187
|
+
entry.episode === null
|
|
188
|
+
? "/library"
|
|
189
|
+
: `/library/${entry.episode}`
|
|
190
|
+
|
|
179
191
|
/**
|
|
180
|
-
* A history stack, small enough to read and honest about the
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
192
|
+
* A history stack, small enough to read and honest about the two
|
|
193
|
+
* things that matter.
|
|
194
|
+
*
|
|
195
|
+
* **Back returns to the entry it left, with the key it had.** That
|
|
196
|
+
* key is what the offset is filed under, and reusing it is the
|
|
197
|
+
* whole mechanism.
|
|
198
|
+
*
|
|
199
|
+
* **Expanding the specials is a NEW entry on the SAME path.** That
|
|
200
|
+
* is what `setSearchParams` does for a filter chip or an expanded
|
|
201
|
+
* group, and it is the case that has to leave the scrollport alone.
|
|
202
|
+
* The key is one the memory has never seen, so a memory that read
|
|
203
|
+
* "unseen ⇒ top" would throw the reader to the top of the list
|
|
204
|
+
* every time they opened a group.
|
|
205
|
+
*
|
|
206
|
+
* The toggle sits in the **header**, not in the list, and that is
|
|
207
|
+
* not decoration: a control inside the scrollport is scrolled into
|
|
208
|
+
* view before it is pressed, which moves the very offset the demo
|
|
209
|
+
* is about. It cost an hour of chasing a bug that was the driver's.
|
|
184
210
|
*
|
|
185
|
-
* `contentDelayMs` is the
|
|
186
|
-
*
|
|
211
|
+
* `contentDelayMs` is the other half of the problem. A real list is
|
|
212
|
+
* fetched, so the commit that changes the route draws an empty
|
|
187
213
|
* scrollport and the rows land later — an offset applied against
|
|
188
214
|
* that clamps to `0`. Set it and the restore has to wait for the
|
|
189
215
|
* content, which is the case the `ResizeObserver` in
|
|
@@ -195,7 +221,7 @@ const ScrollMemoryDemo = ({
|
|
|
195
221
|
contentDelayMs: number
|
|
196
222
|
}) => {
|
|
197
223
|
const [entries, setEntries] = useState<HistoryEntry[]>([
|
|
198
|
-
{ episode: null, key: "entry-1" },
|
|
224
|
+
{ episode: null, isExpanded: false, key: "entry-1" },
|
|
199
225
|
])
|
|
200
226
|
const [entryIndex, setEntryIndex] = useState(0)
|
|
201
227
|
const [isContentReady, setIsContentReady] = useState(
|
|
@@ -220,70 +246,103 @@ const ScrollMemoryDemo = ({
|
|
|
220
246
|
}
|
|
221
247
|
}, [contentDelayMs])
|
|
222
248
|
|
|
223
|
-
const
|
|
224
|
-
const
|
|
249
|
+
const push = (pushed: Omit<HistoryEntry, "key">) => {
|
|
250
|
+
const stack = [
|
|
225
251
|
...entries.slice(0, entryIndex + 1),
|
|
226
|
-
{
|
|
252
|
+
{ ...pushed, key: `entry-${entries.length + 1}` },
|
|
227
253
|
]
|
|
228
254
|
|
|
229
|
-
setEntries(
|
|
230
|
-
setEntryIndex(
|
|
255
|
+
setEntries(stack)
|
|
256
|
+
setEntryIndex(stack.length - 1)
|
|
231
257
|
}
|
|
232
258
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
actions={
|
|
237
|
-
<Button
|
|
238
|
-
appearance="outline"
|
|
239
|
-
isDisabled={entryIndex === 0}
|
|
240
|
-
onClick={() => {
|
|
241
|
-
setEntryIndex(entryIndex - 1)
|
|
242
|
-
}}
|
|
243
|
-
size="sm"
|
|
244
|
-
>
|
|
245
|
-
Back
|
|
246
|
-
</Button>
|
|
247
|
-
}
|
|
248
|
-
heading="Library"
|
|
249
|
-
/>
|
|
259
|
+
const titles = entry?.isExpanded
|
|
260
|
+
? [...EPISODE_TITLES, ...SPECIAL_TITLES]
|
|
261
|
+
: EPISODE_TITLES
|
|
250
262
|
|
|
251
|
-
|
|
252
|
-
|
|
263
|
+
return (
|
|
264
|
+
<ScrollMemoryProvider
|
|
265
|
+
entry={{
|
|
266
|
+
key: entry?.key ?? "entry-1",
|
|
267
|
+
path: entry ? toPath(entry) : "/library",
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
<Shell>
|
|
271
|
+
<Header
|
|
272
|
+
actions={
|
|
273
|
+
<>
|
|
274
|
+
<Button
|
|
275
|
+
appearance="outline"
|
|
276
|
+
isDisabled={entryIndex === 0}
|
|
277
|
+
onClick={() => {
|
|
278
|
+
setEntryIndex(entryIndex - 1)
|
|
279
|
+
}}
|
|
280
|
+
size="sm"
|
|
281
|
+
>
|
|
282
|
+
Back
|
|
283
|
+
</Button>
|
|
253
284
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
285
|
+
<Button
|
|
286
|
+
appearance="outline"
|
|
287
|
+
isDisabled={entry?.episode != null}
|
|
288
|
+
onClick={() => {
|
|
289
|
+
push({
|
|
290
|
+
episode: null,
|
|
291
|
+
isExpanded: !entry?.isExpanded,
|
|
292
|
+
})
|
|
293
|
+
}}
|
|
294
|
+
size="sm"
|
|
261
295
|
>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
296
|
+
{entry?.isExpanded
|
|
297
|
+
? "Collapse the specials"
|
|
298
|
+
: "Expand the specials"}
|
|
299
|
+
</Button>
|
|
300
|
+
</>
|
|
301
|
+
}
|
|
302
|
+
heading="Library"
|
|
303
|
+
/>
|
|
304
|
+
|
|
305
|
+
<Main>
|
|
306
|
+
{!isContentReady && <Spinner label="Loading" />}
|
|
307
|
+
|
|
308
|
+
{isContentReady && entry?.episode == null && (
|
|
309
|
+
<div className="flex flex-col gap-3">
|
|
310
|
+
{titles.map((title) => (
|
|
311
|
+
<Card
|
|
312
|
+
heading={title}
|
|
313
|
+
key={title}
|
|
314
|
+
padding="sm"
|
|
268
315
|
>
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
316
|
+
<Button
|
|
317
|
+
appearance="ghost"
|
|
318
|
+
onClick={() => {
|
|
319
|
+
push({
|
|
320
|
+
episode: title,
|
|
321
|
+
isExpanded:
|
|
322
|
+
entry?.isExpanded ?? false,
|
|
323
|
+
})
|
|
324
|
+
}}
|
|
325
|
+
size="sm"
|
|
326
|
+
>
|
|
327
|
+
Open {title}
|
|
328
|
+
</Button>
|
|
329
|
+
</Card>
|
|
330
|
+
))}
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
|
|
334
|
+
{isContentReady && entry?.episode != null && (
|
|
335
|
+
<Card heading={entry.episode}>
|
|
336
|
+
<p className="text-content-secondary text-sm">
|
|
337
|
+
A page short enough that the scrollport
|
|
338
|
+
collapses behind it. Press Back: the list
|
|
339
|
+
returns to where it was, not to the top.
|
|
340
|
+
</p>
|
|
341
|
+
</Card>
|
|
342
|
+
)}
|
|
343
|
+
</Main>
|
|
344
|
+
</Shell>
|
|
345
|
+
</ScrollMemoryProvider>
|
|
287
346
|
)
|
|
288
347
|
}
|
|
289
348
|
|
|
@@ -292,10 +351,16 @@ const ScrollMemoryDemo = ({
|
|
|
292
351
|
*
|
|
293
352
|
* `Shell` makes `<main>` the page's only vertical scrollport, and
|
|
294
353
|
* a browser restores the **document** scroller and nothing else —
|
|
295
|
-
* so without `
|
|
296
|
-
* time, in every browser, with no setting that changes
|
|
354
|
+
* so without a `ScrollMemoryProvider` this list comes back at the
|
|
355
|
+
* top every time, in every browser, with no setting that changes
|
|
356
|
+
* it.
|
|
297
357
|
*
|
|
298
358
|
* Scroll the list, open an episode, then press Back.
|
|
359
|
+
*
|
|
360
|
+
* Then scroll again and press **Expand the specials**. That is a
|
|
361
|
+
* new history entry on the same page, and the scrollport does not
|
|
362
|
+
* move — a filter is not a navigation, however the router files
|
|
363
|
+
* it.
|
|
299
364
|
*/
|
|
300
365
|
export const ScrollMemory: Story = {
|
|
301
366
|
render: () => <ScrollMemoryDemo contentDelayMs={0} />,
|
package/src/Main/Main.test.tsx
CHANGED
|
@@ -305,6 +305,64 @@ test("a restore waits for content that arrives after the navigation", async () =
|
|
|
305
305
|
})
|
|
306
306
|
})
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The regression the first release of this shipped, and the reason
|
|
310
|
+
* an entry remembers its path.
|
|
311
|
+
*
|
|
312
|
+
* A filter chip, an expanded group and a selected tab each write a
|
|
313
|
+
* search param, and `setSearchParams` **navigates** — so pressing
|
|
314
|
+
* one mints a history entry the memory has never seen, on the page
|
|
315
|
+
* the reader is already looking at. Read as "unseen ⇒ start at the
|
|
316
|
+
* top", the list jumped to the top every time the reader opened a
|
|
317
|
+
* group. Measured live on Docket: scrolled to 1200, pressed a group
|
|
318
|
+
* toggle, landed at 0.
|
|
319
|
+
*
|
|
320
|
+
* The toggle is in the header rather than in the list on purpose.
|
|
321
|
+
* A control inside the scrollport is scrolled into view before it
|
|
322
|
+
* is pressed, so the driver moves the offset and the test proves
|
|
323
|
+
* nothing.
|
|
324
|
+
*
|
|
325
|
+
* Leaving the scrollport alone is also exactly what the page did
|
|
326
|
+
* before any of this existed, so this answer cannot itself be a
|
|
327
|
+
* regression.
|
|
328
|
+
*/
|
|
329
|
+
test("expanding a group is a new history entry that does not move the scrollport", async () => {
|
|
330
|
+
await setViewport(DESKTOP)
|
|
331
|
+
|
|
332
|
+
const { canvas } = await mountStory(ScrollMemory)
|
|
333
|
+
|
|
334
|
+
const main = canvas.getByRole("main")
|
|
335
|
+
|
|
336
|
+
await scrollTo(main, 900)
|
|
337
|
+
|
|
338
|
+
await userEvent.click(
|
|
339
|
+
expectAgentDrivable(canvas, {
|
|
340
|
+
name: "Expand the specials",
|
|
341
|
+
role: "button",
|
|
342
|
+
}),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
// The expansion happened, so this is a real new entry rather than
|
|
346
|
+
// a press that did nothing.
|
|
347
|
+
await expect(
|
|
348
|
+
expectAgentDrivable(canvas, {
|
|
349
|
+
name: "Collapse the specials",
|
|
350
|
+
role: "button",
|
|
351
|
+
}),
|
|
352
|
+
).toBeVisible()
|
|
353
|
+
|
|
354
|
+
// Still where the reader put it. `waitFor` would hide a jump that
|
|
355
|
+
// corrects itself, so this is read directly after two rendering
|
|
356
|
+
// steps.
|
|
357
|
+
await new Promise((resolve) => {
|
|
358
|
+
globalThis.requestAnimationFrame(() => {
|
|
359
|
+
globalThis.requestAnimationFrame(resolve)
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
await expect(main.scrollTop).toBe(900)
|
|
364
|
+
})
|
|
365
|
+
|
|
308
366
|
/**
|
|
309
367
|
* A restore that outlives the reader's first scroll is a page that
|
|
310
368
|
* fights them. Anything the reader does to the scrollport ends it.
|
package/src/Main/Main.tsx
CHANGED
|
@@ -17,20 +17,11 @@ import {
|
|
|
17
17
|
} from "../Shell/contentWidth.ts"
|
|
18
18
|
import { ShellContext } from "../Shell/shellContext.ts"
|
|
19
19
|
import { toClassName } from "../toClassName.ts"
|
|
20
|
+
import { ScrollMemoryContext } from "./scrollMemoryContext.ts"
|
|
20
21
|
import { useScrollMemory } from "./useScrollMemory.ts"
|
|
21
22
|
|
|
22
23
|
export type MainProps = ComponentPropsWithRef<"main"> & {
|
|
23
24
|
contentWidth?: ContentWidth
|
|
24
|
-
/**
|
|
25
|
-
* The history entry this page belongs to — react-router's
|
|
26
|
-
* `useLocation().key`, and the same value under a different
|
|
27
|
-
* router. Given it, `<main>` remembers where each entry was
|
|
28
|
-
* scrolled to and puts the offset back on Back and Forward.
|
|
29
|
-
*
|
|
30
|
-
* Omit it and nothing is remembered, which is the right default
|
|
31
|
-
* for a page with no history to remember.
|
|
32
|
-
*/
|
|
33
|
-
scrollKey?: string
|
|
34
25
|
}
|
|
35
26
|
|
|
36
27
|
/**
|
|
@@ -122,18 +113,27 @@ export type MainProps = ComponentPropsWithRef<"main"> & {
|
|
|
122
113
|
* therefore lands at the top of a list the reader had scrolled
|
|
123
114
|
* half-way down, and no browser setting changes that.
|
|
124
115
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* `
|
|
116
|
+
* So this element remembers it, and **there is no prop for it**.
|
|
117
|
+
* The history entry arrives on `ScrollMemoryContext`, which
|
|
118
|
+
* `ReactRouterAdapter` puts at the app root:
|
|
128
119
|
*
|
|
129
120
|
* ```tsx
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* </
|
|
121
|
+
* <BrowserRouter>
|
|
122
|
+
* <ReactRouterAdapter>
|
|
123
|
+
* <Routes>…</Routes>
|
|
124
|
+
* </ReactRouterAdapter>
|
|
125
|
+
* </BrowserRouter>
|
|
135
126
|
* ```
|
|
136
127
|
*
|
|
128
|
+
* It was a prop for one release — `scrollKey` — and that release is
|
|
129
|
+
* the argument against a prop. One app wired it; the other three
|
|
130
|
+
* carried on losing the reader's place with the library sitting
|
|
131
|
+
* right there supporting it. Render no provider and nothing is
|
|
132
|
+
* remembered, which is what an app with no router should get.
|
|
133
|
+
*
|
|
134
|
+
* `useScrollMemory.ts` holds the mechanism and `scrollMemory.ts`
|
|
135
|
+
* the reasoning.
|
|
136
|
+
*
|
|
137
137
|
* ## Two more things it does, both one line
|
|
138
138
|
*
|
|
139
139
|
* `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
|
|
@@ -169,11 +169,11 @@ export const Main = ({
|
|
|
169
169
|
contentWidth,
|
|
170
170
|
id,
|
|
171
171
|
ref,
|
|
172
|
-
scrollKey,
|
|
173
172
|
...mainProps
|
|
174
173
|
}: MainProps): ReactNode => {
|
|
175
174
|
const shell = useContext(ShellContext)
|
|
176
|
-
const
|
|
175
|
+
const scrollEntry = useContext(ScrollMemoryContext)
|
|
176
|
+
const setScrollport = useScrollMemory(scrollEntry)
|
|
177
177
|
|
|
178
178
|
// Always called, never conditionally: a `Main` outside a `Shell`
|
|
179
179
|
// still needs an id an app can point its own skip link at.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import type { ScrollEntry } from "./scrollMemory.ts"
|
|
4
|
+
import { ScrollMemoryContext } from "./scrollMemoryContext.ts"
|
|
5
|
+
|
|
6
|
+
export type ScrollMemoryProviderProps = {
|
|
7
|
+
children?: ReactNode
|
|
8
|
+
/**
|
|
9
|
+
* The history entry the page is showing, and the path it belongs
|
|
10
|
+
* to. Under react-router both come straight off `useLocation()`.
|
|
11
|
+
*/
|
|
12
|
+
entry: ScrollEntry
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tell every `Main` below this which history entry it is showing,
|
|
17
|
+
* so Back and Forward return the reader to where they were.
|
|
18
|
+
*
|
|
19
|
+
* An app that uses react-router should not render this directly —
|
|
20
|
+
* `ReactRouterAdapter` in the `./react-router` subpath wires it,
|
|
21
|
+
* along with the link seam, in one component at the root. This is
|
|
22
|
+
* the router-free half, for an app on a different router:
|
|
23
|
+
*
|
|
24
|
+
* ```tsx
|
|
25
|
+
* const [location] = useLocation() // wouter, say
|
|
26
|
+
*
|
|
27
|
+
* <ScrollMemoryProvider
|
|
28
|
+
* entry={{ key: historyKey, path: location }}
|
|
29
|
+
* >
|
|
30
|
+
* <App />
|
|
31
|
+
* </ScrollMemoryProvider>
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* **`key` must identify the history entry, not the URL.** Two visits
|
|
35
|
+
* to the same list are two entries and the reader may leave them at
|
|
36
|
+
* different offsets; a key derived from the path would give the
|
|
37
|
+
* second visit the first one's position and could not tell Back from
|
|
38
|
+
* Forward. A router that exposes no such key can pass a counter it
|
|
39
|
+
* increments on every push and restores on every pop.
|
|
40
|
+
*/
|
|
41
|
+
export const ScrollMemoryProvider = ({
|
|
42
|
+
children,
|
|
43
|
+
entry,
|
|
44
|
+
}: ScrollMemoryProviderProps): ReactNode => (
|
|
45
|
+
<ScrollMemoryContext.Provider value={entry}>
|
|
46
|
+
{children}
|
|
47
|
+
</ScrollMemoryContext.Provider>
|
|
48
|
+
)
|
|
@@ -1,24 +1,66 @@
|
|
|
1
1
|
import { afterEach, expect, test } from "vitest"
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
enterScrollEntry,
|
|
4
5
|
forgetScrollOffsets,
|
|
5
|
-
recallScrollOffset,
|
|
6
6
|
rememberScrollOffset,
|
|
7
|
+
resolveScrollOffset,
|
|
7
8
|
} from "./scrollMemory.ts"
|
|
8
9
|
|
|
9
10
|
afterEach(() => {
|
|
10
11
|
forgetScrollOffsets()
|
|
11
12
|
})
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
const toEntry = (key: string, path = "/backlog") => ({
|
|
15
|
+
key,
|
|
16
|
+
path,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("the first page a reader opens starts at the top", () => {
|
|
20
|
+
expect(resolveScrollOffset(toEntry("entry-1"))).toBe(0)
|
|
15
21
|
})
|
|
16
22
|
|
|
17
23
|
test("an entry recalls the offset it was last left at", () => {
|
|
18
|
-
rememberScrollOffset("entry-1", 900)
|
|
19
|
-
rememberScrollOffset("entry-1", 1240)
|
|
24
|
+
rememberScrollOffset(toEntry("entry-1"), 900)
|
|
25
|
+
rememberScrollOffset(toEntry("entry-1"), 1240)
|
|
26
|
+
|
|
27
|
+
expect(resolveScrollOffset(toEntry("entry-1"))).toBe(1240)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("a page the reader has not seen starts at the top", () => {
|
|
31
|
+
enterScrollEntry(toEntry("entry-1", "/backlog"))
|
|
32
|
+
|
|
33
|
+
expect(
|
|
34
|
+
resolveScrollOffset(toEntry("entry-2", "/tasks/9")),
|
|
35
|
+
).toBe(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The defect this whole field exists for, and it shipped: a filter
|
|
40
|
+
* chip, an expanded group and a selected tab all write a search
|
|
41
|
+
* param, `setSearchParams` navigates, and the new entry is one the
|
|
42
|
+
* memory has never seen. Read as "unseen ⇒ top", opening a group
|
|
43
|
+
* threw the reader to the top of the list they were reading.
|
|
44
|
+
*
|
|
45
|
+
* `null` is a third answer — **leave the scrollport alone** — and
|
|
46
|
+
* it is what the page did before any of this existed, so it cannot
|
|
47
|
+
* be a regression.
|
|
48
|
+
*/
|
|
49
|
+
test("a new entry on the same page leaves the scrollport alone", () => {
|
|
50
|
+
enterScrollEntry(toEntry("entry-1", "/backlog"))
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
resolveScrollOffset(toEntry("entry-2", "/backlog")),
|
|
54
|
+
).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("a remembered entry beats the page comparison", () => {
|
|
58
|
+
rememberScrollOffset(toEntry("entry-1", "/backlog"), 1240)
|
|
59
|
+
enterScrollEntry(toEntry("entry-2", "/backlog"))
|
|
20
60
|
|
|
21
|
-
expect(
|
|
61
|
+
expect(
|
|
62
|
+
resolveScrollOffset(toEntry("entry-1", "/backlog")),
|
|
63
|
+
).toBe(1240)
|
|
22
64
|
})
|
|
23
65
|
|
|
24
66
|
/**
|
|
@@ -31,29 +73,41 @@ test("an entry recalls the offset it was last left at", () => {
|
|
|
31
73
|
*/
|
|
32
74
|
test("a long session evicts the least recently written entry", () => {
|
|
33
75
|
for (let index = 0; index < 60; index += 1) {
|
|
34
|
-
rememberScrollOffset(
|
|
76
|
+
rememberScrollOffset(
|
|
77
|
+
toEntry(`entry-${index}`),
|
|
78
|
+
index * 10,
|
|
79
|
+
)
|
|
35
80
|
}
|
|
36
81
|
|
|
37
|
-
// Written first, never re-written: gone
|
|
38
|
-
|
|
82
|
+
// Written first, never re-written: gone, so it falls through to
|
|
83
|
+
// the page comparison and starts at the top.
|
|
84
|
+
expect(resolveScrollOffset(toEntry("entry-0"))).toBe(0)
|
|
39
85
|
|
|
40
86
|
// Inside the last fifty: kept.
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
87
|
+
expect(resolveScrollOffset(toEntry("entry-59"))).toBe(590)
|
|
88
|
+
expect(resolveScrollOffset(toEntry("entry-10"))).toBe(100)
|
|
43
89
|
})
|
|
44
90
|
|
|
45
91
|
test("re-writing an entry saves it from eviction", () => {
|
|
46
|
-
rememberScrollOffset("entry-old", 500)
|
|
92
|
+
rememberScrollOffset(toEntry("entry-old"), 500)
|
|
47
93
|
|
|
48
94
|
for (let index = 0; index < 49; index += 1) {
|
|
49
|
-
rememberScrollOffset(
|
|
95
|
+
rememberScrollOffset(
|
|
96
|
+
toEntry(`entry-${index}`),
|
|
97
|
+
index * 10,
|
|
98
|
+
)
|
|
50
99
|
}
|
|
51
100
|
|
|
52
|
-
rememberScrollOffset("entry-old", 640)
|
|
101
|
+
rememberScrollOffset(toEntry("entry-old"), 640)
|
|
53
102
|
|
|
54
103
|
for (let index = 49; index < 60; index += 1) {
|
|
55
|
-
rememberScrollOffset(
|
|
104
|
+
rememberScrollOffset(
|
|
105
|
+
toEntry(`entry-${index}`),
|
|
106
|
+
index * 10,
|
|
107
|
+
)
|
|
56
108
|
}
|
|
57
109
|
|
|
58
|
-
expect(
|
|
110
|
+
expect(resolveScrollOffset(toEntry("entry-old"))).toBe(
|
|
111
|
+
640,
|
|
112
|
+
)
|
|
59
113
|
})
|