@charcuterie/ui 3.29.0 → 3.30.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.
@@ -1,11 +1,14 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useEffect, useState } from "react"
2
3
 
3
4
  import { toStoryChoice } from "../argTypes.storyHelpers.ts"
5
+ import { Button } from "../Button/Button.tsx"
4
6
  import { Card } from "../Card/Card.tsx"
5
7
  import { Header } from "../Header/Header.tsx"
6
8
  import { Rail } from "../Rail/Rail.tsx"
7
9
  import { toMaxInlineSize } from "../Shell/contentWidth.ts"
8
10
  import { Shell } from "../Shell/Shell.tsx"
11
+ import { Spinner } from "../Spinner/Spinner.tsx"
9
12
  import {
10
13
  HeaderSchemeToggle,
11
14
  OverflowingContent,
@@ -162,3 +165,152 @@ export const Interactive: Story = {
162
165
  </Shell>
163
166
  ),
164
167
  }
168
+
169
+ const EPISODE_TITLES = Array.from(
170
+ { length: 40 },
171
+ (_unused, index) => `Episode ${index + 1}`,
172
+ )
173
+
174
+ type HistoryEntry = {
175
+ episode: string | null
176
+ key: string
177
+ }
178
+
179
+ /**
180
+ * A history stack, small enough to read and honest about the one
181
+ * thing that matters: **Back returns to the entry it left, with
182
+ * the key it had.** That key is what the offset is filed under,
183
+ * and reusing it is the whole mechanism.
184
+ *
185
+ * `contentDelayMs` is the second half of the problem. A real list
186
+ * is fetched, so the commit that changes the route draws an empty
187
+ * scrollport and the rows land later — an offset applied against
188
+ * that clamps to `0`. Set it and the restore has to wait for the
189
+ * content, which is the case the `ResizeObserver` in
190
+ * `useScrollMemory` exists for.
191
+ */
192
+ const ScrollMemoryDemo = ({
193
+ contentDelayMs,
194
+ }: {
195
+ contentDelayMs: number
196
+ }) => {
197
+ const [entries, setEntries] = useState<HistoryEntry[]>([
198
+ { episode: null, key: "entry-1" },
199
+ ])
200
+ const [entryIndex, setEntryIndex] = useState(0)
201
+ const [isContentReady, setIsContentReady] = useState(
202
+ contentDelayMs === 0,
203
+ )
204
+
205
+ const entry = entries[entryIndex]
206
+
207
+ useEffect(() => {
208
+ if (contentDelayMs === 0) {
209
+ return undefined
210
+ }
211
+
212
+ setIsContentReady(false)
213
+
214
+ const timer = globalThis.setTimeout(() => {
215
+ setIsContentReady(true)
216
+ }, contentDelayMs)
217
+
218
+ return () => {
219
+ globalThis.clearTimeout(timer)
220
+ }
221
+ }, [contentDelayMs])
222
+
223
+ const open = (episode: string) => {
224
+ const opened = [
225
+ ...entries.slice(0, entryIndex + 1),
226
+ { episode, key: `entry-${entries.length + 1}` },
227
+ ]
228
+
229
+ setEntries(opened)
230
+ setEntryIndex(opened.length - 1)
231
+ }
232
+
233
+ return (
234
+ <Shell>
235
+ <Header
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
+ />
250
+
251
+ <Main scrollKey={entry?.key}>
252
+ {!isContentReady && <Spinner label="Loading" />}
253
+
254
+ {isContentReady && entry?.episode == null && (
255
+ <div className="flex flex-col gap-3">
256
+ {EPISODE_TITLES.map((title) => (
257
+ <Card
258
+ heading={title}
259
+ key={title}
260
+ padding="sm"
261
+ >
262
+ <Button
263
+ appearance="ghost"
264
+ onClick={() => {
265
+ open(title)
266
+ }}
267
+ size="sm"
268
+ >
269
+ Open {title}
270
+ </Button>
271
+ </Card>
272
+ ))}
273
+ </div>
274
+ )}
275
+
276
+ {isContentReady && entry?.episode != null && (
277
+ <Card heading={entry.episode}>
278
+ <p className="text-content-secondary text-sm">
279
+ A page short enough that the scrollport
280
+ collapses behind it. Press Back: the list
281
+ returns to where it was, not to the top.
282
+ </p>
283
+ </Card>
284
+ )}
285
+ </Main>
286
+ </Shell>
287
+ )
288
+ }
289
+
290
+ /**
291
+ * The reader's place, kept.
292
+ *
293
+ * `Shell` makes `<main>` the page's only vertical scrollport, and
294
+ * a browser restores the **document** scroller and nothing else —
295
+ * so without `scrollKey` this list comes back at the top every
296
+ * time, in every browser, with no setting that changes it.
297
+ *
298
+ * Scroll the list, open an episode, then press Back.
299
+ */
300
+ export const ScrollMemory: Story = {
301
+ render: () => <ScrollMemoryDemo contentDelayMs={0} />,
302
+ }
303
+
304
+ /**
305
+ * The same thing, with the rows arriving 250ms late — which is
306
+ * what a fetched list does, and what makes a naive
307
+ * `scrollTop = offset` do nothing at all.
308
+ *
309
+ * Back lands on an empty scrollport with no room for the offset,
310
+ * so the browser clamps it to `0`. The restore therefore re-applies
311
+ * as the content grows and stops the moment the offset lands, or
312
+ * the moment the reader scrolls.
313
+ */
314
+ export const ScrollMemoryWithLateContent: Story = {
315
+ render: () => <ScrollMemoryDemo contentDelayMs={250} />,
316
+ }
@@ -1,6 +1,6 @@
1
1
  import { composeStories } from "@storybook/react"
2
- import { expect, userEvent } from "storybook/test"
3
- import { afterAll, test } from "vitest"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { afterAll, afterEach, test } from "vitest"
4
4
 
5
5
  import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
6
  import { mountStory } from "../mountStory.testHelpers.ts"
@@ -11,14 +11,26 @@ import {
11
11
  setViewport,
12
12
  } from "../viewport.testHelpers.ts"
13
13
  import * as stories from "./Main.stories.tsx"
14
+ import { forgetScrollOffsets } from "./scrollMemory.ts"
14
15
 
15
- const { Default, Interactive, Responsive } =
16
- composeStories(stories)
16
+ const {
17
+ Default,
18
+ Interactive,
19
+ Responsive,
20
+ ScrollMemory,
21
+ ScrollMemoryWithLateContent,
22
+ } = composeStories(stories)
17
23
 
18
24
  afterAll(async () => {
19
25
  await setViewport(DESKTOP)
20
26
  })
21
27
 
28
+ // The offsets are module state, so they outlive a mount. A test
29
+ // must not.
30
+ afterEach(() => {
31
+ forgetScrollOffsets()
32
+ })
33
+
22
34
  test("the content column is capped at the token, not at the window", async () => {
23
35
  await setViewport(DESKTOP)
24
36
 
@@ -165,3 +177,182 @@ test("main takes focus from a skip link without joining the tab order", async ()
165
177
 
166
178
  await expectNoAxeViolations(canvasElement)
167
179
  })
180
+
181
+ /**
182
+ * A `scroll` event is dispatched at the next rendering step, not
183
+ * at the assignment, so a test that scrolls and immediately
184
+ * navigates measures the listener before it has been told
185
+ * anything.
186
+ */
187
+ const scrollTo = async (
188
+ element: HTMLElement,
189
+ offset: number,
190
+ ) => {
191
+ element.scrollTop = offset
192
+
193
+ await waitFor(async () => {
194
+ await expect(element.scrollTop).toBe(offset)
195
+ })
196
+
197
+ await new Promise((resolve) => {
198
+ globalThis.requestAnimationFrame(() => {
199
+ globalThis.requestAnimationFrame(resolve)
200
+ })
201
+ })
202
+ }
203
+
204
+ /**
205
+ * The whole point, and the thing no browser does for you.
206
+ *
207
+ * `Shell` makes `<main>` the page's only vertical scrollport.
208
+ * `history.scrollRestoration` governs the **document** scroller, so
209
+ * it has nothing to say about this element — and it says nothing at
210
+ * all about a same-document navigation, which is every link press
211
+ * in a single-page app.
212
+ */
213
+ test("back returns the list to where it was scrolled, not to the top", async () => {
214
+ await setViewport(DESKTOP)
215
+
216
+ const { canvas, canvasElement } =
217
+ await mountStory(ScrollMemory)
218
+
219
+ const main = canvas.getByRole("main")
220
+
221
+ // The fixture genuinely scrolls, or the assertion below would
222
+ // pass against a page that was never long enough to lose a
223
+ // place in.
224
+ await expect(main.scrollHeight).toBeGreaterThan(
225
+ main.clientHeight + 900,
226
+ )
227
+
228
+ await scrollTo(main, 900)
229
+
230
+ await userEvent.click(
231
+ expectAgentDrivable(canvas, {
232
+ name: "Open Episode 12",
233
+ role: "button",
234
+ }),
235
+ )
236
+
237
+ await expect(
238
+ canvas.getByRole("heading", { name: "Episode 12" }),
239
+ ).toBeVisible()
240
+
241
+ await userEvent.click(
242
+ expectAgentDrivable(canvas, {
243
+ name: "Back",
244
+ role: "button",
245
+ }),
246
+ )
247
+
248
+ await waitFor(async () => {
249
+ await expect(main.scrollTop).toBe(900)
250
+ })
251
+
252
+ await expectNoAxeViolations(canvasElement)
253
+ })
254
+
255
+ /**
256
+ * The failure a naive `scrollTop = offset` produces, and the
257
+ * reason `useScrollMemory` keeps a `ResizeObserver` rather than
258
+ * writing the number once.
259
+ *
260
+ * Back commits before the rows arrive, so the scrollport has no
261
+ * room for the offset and the browser clamps the write to `0`.
262
+ * Every assertion a one-shot restore could make still passes — the
263
+ * offset was remembered, the write happened — and the reader is
264
+ * looking at the top of the list.
265
+ */
266
+ test("a restore waits for content that arrives after the navigation", async () => {
267
+ await setViewport(DESKTOP)
268
+
269
+ const { canvas } = await mountStory(
270
+ ScrollMemoryWithLateContent,
271
+ )
272
+
273
+ const main = await waitFor(() => canvas.getByRole("main"))
274
+
275
+ await waitFor(async () => {
276
+ await expect(
277
+ canvas.getByRole("heading", { name: "Episode 1" }),
278
+ ).toBeVisible()
279
+ })
280
+
281
+ await scrollTo(main, 900)
282
+
283
+ await userEvent.click(
284
+ expectAgentDrivable(canvas, {
285
+ name: "Open Episode 12",
286
+ role: "button",
287
+ }),
288
+ )
289
+
290
+ await userEvent.click(
291
+ expectAgentDrivable(canvas, {
292
+ name: "Back",
293
+ role: "button",
294
+ }),
295
+ )
296
+
297
+ // No assertion on the intermediate `0`: whether the rows have
298
+ // landed by this line depends on how loaded the machine is, and
299
+ // a test that reads the clock is a test that fails on CI. What
300
+ // proves the wait is load-bearing is that deleting the
301
+ // `ResizeObserver` from `useScrollMemory` fails **this** test and
302
+ // no other.
303
+ await waitFor(async () => {
304
+ await expect(main.scrollTop).toBe(900)
305
+ })
306
+ })
307
+
308
+ /**
309
+ * A restore that outlives the reader's first scroll is a page that
310
+ * fights them. Anything the reader does to the scrollport ends it.
311
+ */
312
+ test("a reader scrolling during a restore keeps their own position", async () => {
313
+ await setViewport(DESKTOP)
314
+
315
+ const { canvas } = await mountStory(
316
+ ScrollMemoryWithLateContent,
317
+ )
318
+
319
+ await waitFor(async () => {
320
+ await expect(
321
+ canvas.getByRole("heading", { name: "Episode 1" }),
322
+ ).toBeVisible()
323
+ })
324
+
325
+ const main = canvas.getByRole("main")
326
+
327
+ await scrollTo(main, 900)
328
+
329
+ await userEvent.click(
330
+ expectAgentDrivable(canvas, {
331
+ name: "Open Episode 12",
332
+ role: "button",
333
+ }),
334
+ )
335
+
336
+ await userEvent.click(
337
+ expectAgentDrivable(canvas, {
338
+ name: "Back",
339
+ role: "button",
340
+ }),
341
+ )
342
+
343
+ // Before the rows land, so the restore is still pending.
344
+ main.dispatchEvent(
345
+ new WheelEvent("wheel", { bubbles: true }),
346
+ )
347
+
348
+ await waitFor(async () => {
349
+ await expect(
350
+ canvas.getByRole("heading", { name: "Episode 1" }),
351
+ ).toBeVisible()
352
+ })
353
+
354
+ await scrollTo(main, 200)
355
+
356
+ // Given up, rather than dragging the reader back to 900.
357
+ await expect(main.scrollTop).toBe(200)
358
+ })
package/src/Main/Main.tsx CHANGED
@@ -1,7 +1,12 @@
1
- import { useUniqueId } from "@charcuterie/logic"
1
+ import {
2
+ type MergeableRef,
3
+ mergeRefs,
4
+ useUniqueId,
5
+ } from "@charcuterie/logic"
2
6
  import {
3
7
  type ComponentPropsWithRef,
4
8
  type ReactNode,
9
+ type RefCallback,
5
10
  useContext,
6
11
  } from "react"
7
12
 
@@ -12,9 +17,20 @@ import {
12
17
  } from "../Shell/contentWidth.ts"
13
18
  import { ShellContext } from "../Shell/shellContext.ts"
14
19
  import { toClassName } from "../toClassName.ts"
20
+ import { useScrollMemory } from "./useScrollMemory.ts"
15
21
 
16
22
  export type MainProps = ComponentPropsWithRef<"main"> & {
17
23
  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
18
34
  }
19
35
 
20
36
  /**
@@ -94,6 +110,30 @@ export type MainProps = ComponentPropsWithRef<"main"> & {
94
110
  * an app's own fixed chrome belongs in `Shell`, beside `Main`
95
111
  * rather than in it.
96
112
  *
113
+ * ## It remembers where each history entry was scrolled to
114
+ *
115
+ * `Shell` gives this element the page's only vertical scrollport,
116
+ * so the header and the rails stay put while the content moves.
117
+ * The browser's own scroll restoration does not follow it there: a
118
+ * browser remembers **the document scroller's** offset per history
119
+ * entry, and `<main>` is not the document scroller. It also does
120
+ * not restore anything at all across a same-document `pushState`
121
+ * navigation, which is every link press in a single-page app. Back
122
+ * therefore lands at the top of a list the reader had scrolled
123
+ * half-way down, and no browser setting changes that.
124
+ *
125
+ * Pass `scrollKey` — react-router's `useLocation().key` — and the
126
+ * offset comes back. `useScrollMemory.ts` holds the mechanism and
127
+ * `scrollMemory.ts` the reasoning.
128
+ *
129
+ * ```tsx
130
+ * const location = useLocation()
131
+ *
132
+ * <Main scrollKey={location.key}>
133
+ * <Outlet />
134
+ * </Main>
135
+ * ```
136
+ *
97
137
  * ## Two more things it does, both one line
98
138
  *
99
139
  * `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
@@ -128,9 +168,12 @@ export const Main = ({
128
168
  className,
129
169
  contentWidth,
130
170
  id,
171
+ ref,
172
+ scrollKey,
131
173
  ...mainProps
132
174
  }: MainProps): ReactNode => {
133
175
  const shell = useContext(ShellContext)
176
+ const setScrollport = useScrollMemory(scrollKey)
134
177
 
135
178
  // Always called, never conditionally: a `Main` outside a `Shell`
136
179
  // still needs an id an app can point its own skip link at.
@@ -150,6 +193,20 @@ export const Main = ({
150
193
  className,
151
194
  )}
152
195
  id={id ?? shell?.mainId ?? fallbackId}
196
+ // A caller's `ref` is a subscription, not a value, so the
197
+ // scroll memory composes with it rather than replacing it.
198
+ //
199
+ // The cast is `mergeRefs`' prop-merging signature, where
200
+ // every value is `unknown`; a caller's `ref` is narrower than
201
+ // that, never wider.
202
+ ref={
203
+ ref
204
+ ? (mergeRefs(
205
+ ref as MergeableRef,
206
+ setScrollport,
207
+ ) as RefCallback<HTMLElement>)
208
+ : setScrollport
209
+ }
153
210
  tabIndex={-1}
154
211
  >
155
212
  {/*
@@ -0,0 +1,59 @@
1
+ import { afterEach, expect, test } from "vitest"
2
+
3
+ import {
4
+ forgetScrollOffsets,
5
+ recallScrollOffset,
6
+ rememberScrollOffset,
7
+ } from "./scrollMemory.ts"
8
+
9
+ afterEach(() => {
10
+ forgetScrollOffsets()
11
+ })
12
+
13
+ test("an entry nobody has scrolled recalls the top", () => {
14
+ expect(recallScrollOffset("entry-never-seen")).toBe(0)
15
+ })
16
+
17
+ test("an entry recalls the offset it was last left at", () => {
18
+ rememberScrollOffset("entry-1", 900)
19
+ rememberScrollOffset("entry-1", 1240)
20
+
21
+ expect(recallScrollOffset("entry-1")).toBe(1240)
22
+ })
23
+
24
+ /**
25
+ * A long session pushes a new history entry per link press, and
26
+ * every one of them would otherwise keep a number forever. The
27
+ * eviction is least-recently-*written*, which is why re-writing an
28
+ * entry has to move it to the end — a plain `Map.set` on an
29
+ * existing key keeps its original position, so the entry the
30
+ * reader is using would age out while it was in use.
31
+ */
32
+ test("a long session evicts the least recently written entry", () => {
33
+ for (let index = 0; index < 60; index += 1) {
34
+ rememberScrollOffset(`entry-${index}`, index * 10)
35
+ }
36
+
37
+ // Written first, never re-written: gone.
38
+ expect(recallScrollOffset("entry-0")).toBe(0)
39
+
40
+ // Inside the last fifty: kept.
41
+ expect(recallScrollOffset("entry-59")).toBe(590)
42
+ expect(recallScrollOffset("entry-10")).toBe(100)
43
+ })
44
+
45
+ test("re-writing an entry saves it from eviction", () => {
46
+ rememberScrollOffset("entry-old", 500)
47
+
48
+ for (let index = 0; index < 49; index += 1) {
49
+ rememberScrollOffset(`entry-${index}`, index * 10)
50
+ }
51
+
52
+ rememberScrollOffset("entry-old", 640)
53
+
54
+ for (let index = 49; index < 60; index += 1) {
55
+ rememberScrollOffset(`entry-${index}`, index * 10)
56
+ }
57
+
58
+ expect(recallScrollOffset("entry-old")).toBe(640)
59
+ })
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What the browser will not remember for you.
3
+ *
4
+ * `Shell` gives `<main>` the page's only vertical scrollport, so
5
+ * the header and the rails stay put while the content moves —
6
+ * that is the whole reason `Shell.test.tsx` asserts "only main
7
+ * scrolls vertically, keeping the rail available".
8
+ *
9
+ * It costs exactly one thing, and this module is it. A browser
10
+ * remembers **the document scroller's** offset per history entry
11
+ * and puts it back on Back. It does that for a cross-document
12
+ * load, and `history.scrollRestoration` is the switch for it. It
13
+ * does **not** do it for an arbitrary element with
14
+ * `overflow-y: auto`, and it does not do it at all for a
15
+ * same-document `pushState` navigation, which is every link press
16
+ * in a single-page app. So a page that scrolls its own `<main>`
17
+ * loses the reader's place on Back, and no browser setting brings
18
+ * it back:
19
+ *
20
+ * > *"If I scroll, click on an item, then go back, it resets me to
21
+ * > the top. […] It should remember where the scroll position was
22
+ * > previously. Isn't this handled by Chrome already?"*
23
+ *
24
+ * The offsets are held **in memory**, not in `sessionStorage`. A
25
+ * history key survives a reload, but the DOM it described does
26
+ * not: the app re-fetches, re-measures and often re-orders, so a
27
+ * pixel offset from before the reload points at a different row.
28
+ * Restoring a stale number is worse than restoring nothing,
29
+ * because it looks deliberate.
30
+ */
31
+
32
+ /**
33
+ * How many history entries keep an offset. A reader goes back
34
+ * through a handful of screens, never fifty, so this is a ceiling
35
+ * against a long session rather than a working set — the map holds
36
+ * one small number per entry and evicts the least recently
37
+ * written.
38
+ */
39
+ const REMEMBERED_ENTRY_LIMIT = 50
40
+
41
+ const rememberedOffsets = new Map<string, number>()
42
+
43
+ export const rememberScrollOffset = (
44
+ key: string,
45
+ offset: number,
46
+ ) => {
47
+ // Delete before set, so insertion order is least-recent-first
48
+ // and the eviction below takes the right one. A plain `set` on
49
+ // an existing key keeps its original position.
50
+ rememberedOffsets.delete(key)
51
+ rememberedOffsets.set(key, offset)
52
+
53
+ for (const oldestKey of rememberedOffsets.keys()) {
54
+ if (rememberedOffsets.size <= REMEMBERED_ENTRY_LIMIT) {
55
+ break
56
+ }
57
+
58
+ rememberedOffsets.delete(oldestKey)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * `0` for an entry nobody has scrolled, which is also the right
64
+ * answer for an entry nobody has *seen* — a link press lands at
65
+ * the top of the new page.
66
+ */
67
+ export const recallScrollOffset = (key: string) =>
68
+ rememberedOffsets.get(key) ?? 0
69
+
70
+ /** Test-only. Module state outlives a mount; a test must not. */
71
+ export const forgetScrollOffsets = () => {
72
+ rememberedOffsets.clear()
73
+ }