@remit/ui 0.0.160 → 0.0.162
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/package.json
CHANGED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who reaches the end of the strip.
|
|
3
|
+
*
|
|
4
|
+
* A sparse diary draws shorter than the distance either end is fetched at, so
|
|
5
|
+
* an end measured off the content alone is reached on the first layout pass and
|
|
6
|
+
* stays reached however many days arrive. That is how a two-event calendar
|
|
7
|
+
* walked its range out to 2032 and took the address with it: every prepend took
|
|
8
|
+
* the scroll offset back, every take-back raised a scroll event, and every
|
|
9
|
+
* scroll event asked for another fortnight at both ends.
|
|
10
|
+
*
|
|
11
|
+
* So reaching an end is something a reader does. The claims here are about who
|
|
12
|
+
* moved the strip — a mount, a resize and a font swap all move it and none of
|
|
13
|
+
* them may fetch — and they are asserted against a stubbed layout, because
|
|
14
|
+
* jsdom has none of its own and the bug lives entirely in the numbers.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import "@remit/test-dom";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
20
|
+
import { act, createElement } from "react";
|
|
21
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
22
|
+
import { buildCalendarDay } from "../lib/agenda-time.js";
|
|
23
|
+
import { AgendaFlow, type AgendaFlowProps } from "./agenda-flow.js";
|
|
24
|
+
import type {
|
|
25
|
+
CalendarDescriptor,
|
|
26
|
+
CalendarEventData,
|
|
27
|
+
} from "./calendar-types.js";
|
|
28
|
+
|
|
29
|
+
const TODAY = "2026-06-10";
|
|
30
|
+
const OFFSET = "+02:00";
|
|
31
|
+
|
|
32
|
+
const dates = [TODAY, "2026-06-11", "2026-06-12", "2026-06-13", "2026-06-14"];
|
|
33
|
+
|
|
34
|
+
const calendars: CalendarDescriptor[] = [
|
|
35
|
+
{
|
|
36
|
+
id: "c1",
|
|
37
|
+
accountId: "a1",
|
|
38
|
+
accountLabel: "Work",
|
|
39
|
+
name: "Northwind",
|
|
40
|
+
color: "cal-3",
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** One booking a day, so every day keeps a row of its own to anchor against. */
|
|
45
|
+
const event = (date: string): CalendarEventData => ({
|
|
46
|
+
id: `evt_${date}`,
|
|
47
|
+
calendarId: "c1",
|
|
48
|
+
title: `Standup ${date}`,
|
|
49
|
+
start: `${date}T10:00:00${OFFSET}`,
|
|
50
|
+
end: `${date}T10:30:00${OFFSET}`,
|
|
51
|
+
allDay: false,
|
|
52
|
+
location: "",
|
|
53
|
+
notes: "",
|
|
54
|
+
attendees: [],
|
|
55
|
+
myRsvp: "accepted",
|
|
56
|
+
threadId: "",
|
|
57
|
+
threadSubject: "",
|
|
58
|
+
timeZone: "Europe/Amsterdam",
|
|
59
|
+
zoneCertainty: "explicit",
|
|
60
|
+
recurrenceRule: "",
|
|
61
|
+
seriesId: "",
|
|
62
|
+
seriesException: false,
|
|
63
|
+
status: "confirmed",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const days = dates.map((date) => buildCalendarDay(date, [event(date)], TODAY));
|
|
67
|
+
|
|
68
|
+
let container: HTMLElement;
|
|
69
|
+
let root: Root;
|
|
70
|
+
let reachedStart: number;
|
|
71
|
+
let reachedEnd: number;
|
|
72
|
+
let visited: string[];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A gesture goes stale, so the wall clock is one of the strip's inputs and the
|
|
76
|
+
* cases that turn on it hold it themselves.
|
|
77
|
+
*/
|
|
78
|
+
const realNow = Date.now;
|
|
79
|
+
let clock = 0;
|
|
80
|
+
const advance = (ms: number) => {
|
|
81
|
+
clock += ms;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
clock = 1_700_000_000_000;
|
|
86
|
+
Date.now = () => clock;
|
|
87
|
+
container = document.createElement("div");
|
|
88
|
+
document.body.appendChild(container);
|
|
89
|
+
root = createRoot(container);
|
|
90
|
+
reachedStart = 0;
|
|
91
|
+
reachedEnd = 0;
|
|
92
|
+
visited = [];
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
act(() => root.unmount());
|
|
97
|
+
container.remove();
|
|
98
|
+
Date.now = realNow;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const props = (extra: Partial<AgendaFlowProps> = {}): AgendaFlowProps => ({
|
|
102
|
+
days,
|
|
103
|
+
calendars,
|
|
104
|
+
density: "pills",
|
|
105
|
+
today: TODAY,
|
|
106
|
+
focusDate: TODAY,
|
|
107
|
+
selectedEventId: "",
|
|
108
|
+
onSelectEvent: () => {},
|
|
109
|
+
onPickSlot: () => {},
|
|
110
|
+
onZoomDay: () => {},
|
|
111
|
+
onReachStart: () => {
|
|
112
|
+
reachedStart += 1;
|
|
113
|
+
},
|
|
114
|
+
onReachEnd: () => {
|
|
115
|
+
reachedEnd += 1;
|
|
116
|
+
},
|
|
117
|
+
onVisibleDayChange: (date) => {
|
|
118
|
+
visited.push(date);
|
|
119
|
+
},
|
|
120
|
+
...extra,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const render = (extra: Partial<AgendaFlowProps> = {}) => {
|
|
124
|
+
act(() => root.render(createElement(AgendaFlow, props(extra))));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* jsdom has no layout, so the strip is given one. `scrollTop` clamps the way a
|
|
129
|
+
* browser's does, which is what makes a pane taller than its content — the
|
|
130
|
+
* whole of the bug — reproducible here.
|
|
131
|
+
*/
|
|
132
|
+
interface Strip {
|
|
133
|
+
element: HTMLElement;
|
|
134
|
+
/** An input the reader made on the strip, whatever kind it was. */
|
|
135
|
+
input: (event: Event) => void;
|
|
136
|
+
/** Where the strip ended up, and the event the browser raises to say so. */
|
|
137
|
+
moveTo: (top: number) => void;
|
|
138
|
+
/** The reader's own scroll: a wheel, then the offset it landed at. */
|
|
139
|
+
scroll: (top: number) => void;
|
|
140
|
+
/** The same movement with nobody behind it: a resize, a reflow, a landing. */
|
|
141
|
+
settle: (top: number) => void;
|
|
142
|
+
resize: (size: { scrollHeight: number; clientHeight: number }) => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const stripWithLayout = (scrollHeight: number, clientHeight: number): Strip => {
|
|
146
|
+
const element = container.querySelector<HTMLElement>(
|
|
147
|
+
'[data-testid="agenda-strip"]',
|
|
148
|
+
);
|
|
149
|
+
assert.ok(element, "the strip mounted");
|
|
150
|
+
|
|
151
|
+
const size = { scrollHeight, clientHeight };
|
|
152
|
+
let top = 0;
|
|
153
|
+
Object.defineProperty(element, "scrollHeight", {
|
|
154
|
+
configurable: true,
|
|
155
|
+
get: () => size.scrollHeight,
|
|
156
|
+
});
|
|
157
|
+
Object.defineProperty(element, "clientHeight", {
|
|
158
|
+
configurable: true,
|
|
159
|
+
get: () => size.clientHeight,
|
|
160
|
+
});
|
|
161
|
+
Object.defineProperty(element, "scrollTop", {
|
|
162
|
+
configurable: true,
|
|
163
|
+
get: () => top,
|
|
164
|
+
set: (next: number) => {
|
|
165
|
+
top = Math.max(0, Math.min(next, size.scrollHeight - size.clientHeight));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
for (const [index, child] of [...element.children].entries()) {
|
|
169
|
+
Object.defineProperty(child, "offsetTop", {
|
|
170
|
+
configurable: true,
|
|
171
|
+
value: index * 200,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const raise = () => {
|
|
176
|
+
act(() => {
|
|
177
|
+
element.dispatchEvent(new Event("scroll"));
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const input = (event: Event) => {
|
|
182
|
+
act(() => {
|
|
183
|
+
element.dispatchEvent(event);
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const moveTo = (next: number) => {
|
|
188
|
+
element.scrollTop = next;
|
|
189
|
+
raise();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
element,
|
|
194
|
+
input,
|
|
195
|
+
moveTo,
|
|
196
|
+
scroll: (next) => {
|
|
197
|
+
input(new Event("wheel", { bubbles: true }));
|
|
198
|
+
moveTo(next);
|
|
199
|
+
},
|
|
200
|
+
settle: moveTo,
|
|
201
|
+
resize: (next) => {
|
|
202
|
+
Object.assign(size, next);
|
|
203
|
+
// A pane that shrank around a scroller takes its offset back with it,
|
|
204
|
+
// which is a scroll event nobody asked for.
|
|
205
|
+
const held = top;
|
|
206
|
+
element.scrollTop = held;
|
|
207
|
+
act(() => {
|
|
208
|
+
window.dispatchEvent(new Event("resize"));
|
|
209
|
+
});
|
|
210
|
+
raise();
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Five days with one booking each. The strip stands barely taller than the pane
|
|
217
|
+
* and well inside the distance both ends are fetched at, which is the case the
|
|
218
|
+
* runaway needs: every end is reached, from the first layout pass onwards.
|
|
219
|
+
*/
|
|
220
|
+
const SPARSE = { scrollHeight: 800, clientHeight: 640 };
|
|
221
|
+
/** Enough days that both ends are somewhere the reader has to go to reach. */
|
|
222
|
+
const LONG = { scrollHeight: 5_000, clientHeight: 640 };
|
|
223
|
+
|
|
224
|
+
describe("a strip shorter than the pane holding it", () => {
|
|
225
|
+
it("asks for no days at all when it mounts on the day the address named", () => {
|
|
226
|
+
render();
|
|
227
|
+
const strip = stripWithLayout(SPARSE.scrollHeight, SPARSE.clientHeight);
|
|
228
|
+
strip.settle(0);
|
|
229
|
+
assert.equal(reachedEnd, 0);
|
|
230
|
+
assert.equal(reachedStart, 0);
|
|
231
|
+
assert.deepEqual(visited, []);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("asks for none as the layout settles under it", () => {
|
|
235
|
+
render();
|
|
236
|
+
const strip = stripWithLayout(SPARSE.scrollHeight, SPARSE.clientHeight);
|
|
237
|
+
// A font swaps, the rows re-measure, and the landing is re-applied against
|
|
238
|
+
// the height it now has. Every one of these raises a scroll event.
|
|
239
|
+
for (const top of [0, 24, 8, 0]) strip.settle(top);
|
|
240
|
+
assert.equal(reachedEnd, 0);
|
|
241
|
+
assert.equal(reachedStart, 0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("asks for none when the pane is resized around it", () => {
|
|
245
|
+
render();
|
|
246
|
+
const strip = stripWithLayout(SPARSE.scrollHeight, SPARSE.clientHeight);
|
|
247
|
+
strip.settle(120);
|
|
248
|
+
strip.resize({ scrollHeight: 800, clientHeight: 240 });
|
|
249
|
+
strip.resize({ scrollHeight: 800, clientHeight: 900 });
|
|
250
|
+
assert.equal(reachedEnd, 0);
|
|
251
|
+
assert.equal(reachedStart, 0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("leaves the address where it is, having never moved for a reader", () => {
|
|
255
|
+
render();
|
|
256
|
+
const strip = stripWithLayout(SPARSE.scrollHeight, SPARSE.clientHeight);
|
|
257
|
+
strip.settle(120);
|
|
258
|
+
strip.resize({ scrollHeight: 800, clientHeight: 900 });
|
|
259
|
+
assert.deepEqual(visited, []);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("a reader scrolling the strip", () => {
|
|
264
|
+
it("asks for the days ahead once, on reaching the end", () => {
|
|
265
|
+
render();
|
|
266
|
+
const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
|
|
267
|
+
strip.scroll(LONG.scrollHeight - LONG.clientHeight);
|
|
268
|
+
assert.equal(reachedEnd, 1);
|
|
269
|
+
assert.equal(reachedStart, 0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("moves the day under the header with them, once", () => {
|
|
273
|
+
render();
|
|
274
|
+
const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
|
|
275
|
+
strip.scroll(LONG.scrollHeight - LONG.clientHeight);
|
|
276
|
+
assert.equal(visited.length, 1);
|
|
277
|
+
assert.equal(visited[0], dates[dates.length - 1]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("asks again for nothing while it stands still at that end", () => {
|
|
281
|
+
render();
|
|
282
|
+
const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
|
|
283
|
+
strip.scroll(LONG.scrollHeight - LONG.clientHeight);
|
|
284
|
+
strip.scroll(LONG.scrollHeight - LONG.clientHeight);
|
|
285
|
+
strip.settle(LONG.scrollHeight - LONG.clientHeight);
|
|
286
|
+
assert.equal(reachedEnd, 1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Every way a reader moves a scroller counts, and the strip knows nothing
|
|
291
|
+
* about which one it was: a wheel, a key held on a focused row, a scrollbar
|
|
292
|
+
* dragged for as long as the reader likes. What it rejects is movement with
|
|
293
|
+
* nobody behind it, not movement of the wrong kind.
|
|
294
|
+
*/
|
|
295
|
+
it("takes a key press for a gesture", () => {
|
|
296
|
+
render();
|
|
297
|
+
const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
|
|
298
|
+
strip.input(
|
|
299
|
+
new KeyboardEvent("keydown", { key: "PageDown", bubbles: true }),
|
|
300
|
+
);
|
|
301
|
+
strip.moveTo(LONG.scrollHeight - LONG.clientHeight);
|
|
302
|
+
assert.equal(reachedEnd, 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("takes a scrollbar drag, however long the reader holds it", () => {
|
|
306
|
+
render();
|
|
307
|
+
const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
|
|
308
|
+
strip.input(new PointerEvent("pointerdown", { bubbles: true }));
|
|
309
|
+
strip.moveTo(2_000);
|
|
310
|
+
// Long enough that the press alone has gone stale; the drag is what is
|
|
311
|
+
// still keeping the strip theirs.
|
|
312
|
+
advance(5_000);
|
|
313
|
+
strip.input(new PointerEvent("pointermove", { bubbles: true, buttons: 1 }));
|
|
314
|
+
strip.moveTo(LONG.scrollHeight - LONG.clientHeight);
|
|
315
|
+
assert.equal(reachedEnd, 1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("does not take a cursor merely resting over it", () => {
|
|
319
|
+
render();
|
|
320
|
+
const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
|
|
321
|
+
strip.input(new PointerEvent("pointermove", { bubbles: true, buttons: 0 }));
|
|
322
|
+
strip.moveTo(LONG.scrollHeight - LONG.clientHeight);
|
|
323
|
+
assert.equal(reachedEnd, 0);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("lets a gesture go stale rather than holding it open", () => {
|
|
327
|
+
render();
|
|
328
|
+
const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
|
|
329
|
+
strip.input(new Event("wheel", { bubbles: true }));
|
|
330
|
+
advance(5_000);
|
|
331
|
+
strip.moveTo(LONG.scrollHeight - LONG.clientHeight);
|
|
332
|
+
assert.equal(reachedEnd, 0);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("asks for the days behind only on the way back to the start", () => {
|
|
336
|
+
render();
|
|
337
|
+
const strip = stripWithLayout(LONG.scrollHeight, LONG.clientHeight);
|
|
338
|
+
strip.scroll(LONG.scrollHeight - LONG.clientHeight);
|
|
339
|
+
assert.equal(reachedStart, 0);
|
|
340
|
+
strip.scroll(80);
|
|
341
|
+
assert.equal(reachedStart, 1);
|
|
342
|
+
assert.equal(reachedEnd, 1);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("the end of what the strip grows to on its own", () => {
|
|
347
|
+
it("says so and offers the next stretch, rather than fetching it", () => {
|
|
348
|
+
let asked = 0;
|
|
349
|
+
render({
|
|
350
|
+
atEndCap: true,
|
|
351
|
+
onLoadLater: () => {
|
|
352
|
+
asked += 1;
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const later = container.querySelector<HTMLElement>(
|
|
356
|
+
'[data-testid="agenda-load-later"]',
|
|
357
|
+
);
|
|
358
|
+
assert.ok(later, "the strip offered the days past the cap");
|
|
359
|
+
assert.match(later.textContent ?? "", /Show later days/);
|
|
360
|
+
|
|
361
|
+
act(() => {
|
|
362
|
+
later.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
363
|
+
});
|
|
364
|
+
assert.equal(asked, 1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("offers the days behind the same way", () => {
|
|
368
|
+
render({ atStartCap: true, onLoadEarlier: () => {} });
|
|
369
|
+
const earlier = container.querySelector(
|
|
370
|
+
'[data-testid="agenda-load-earlier"]',
|
|
371
|
+
);
|
|
372
|
+
assert.ok(earlier, "the strip offered the days before the cap");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("shows neither while the run still has room to grow", () => {
|
|
376
|
+
render();
|
|
377
|
+
assert.equal(
|
|
378
|
+
container.querySelector('[data-testid^="agenda-load-"]'),
|
|
379
|
+
null,
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
@@ -176,6 +176,22 @@ export const EmptyRun: Story = {
|
|
|
176
176
|
args: { ...base, density: "pills", focusDate: "2026-06-18" },
|
|
177
177
|
};
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A year either way is as far as the strip grows on the scroll. Past that the
|
|
181
|
+
* reader says so, rather than the strip fetching its way across a decade
|
|
182
|
+
* because a sparse diary never fills the pane.
|
|
183
|
+
*/
|
|
184
|
+
export const AtTheCap: Story = {
|
|
185
|
+
args: {
|
|
186
|
+
...base,
|
|
187
|
+
density: "pills",
|
|
188
|
+
atStartCap: true,
|
|
189
|
+
atEndCap: true,
|
|
190
|
+
onLoadEarlier: () => {},
|
|
191
|
+
onLoadLater: () => {},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
179
195
|
/** The selection is a state of the row, not a colour laid over it. */
|
|
180
196
|
export const Selected: Story = {
|
|
181
197
|
args: { ...base, density: "detail", selectedEventId: "evt_roadmap" },
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
* Scrolling never paginates. Reaching either end asks the owner for more days
|
|
12
12
|
* and the scroll position is held across the insert, so the strip has no seams
|
|
13
13
|
* and no page boundaries to lose your place at.
|
|
14
|
+
*
|
|
15
|
+
* Reaching an end is something a reader does, never something a layout is. A
|
|
16
|
+
* sparse diary draws shorter than the distance either end is fetched at, so an
|
|
17
|
+
* end measured off the content alone is reached the moment the strip mounts and
|
|
18
|
+
* stays reached however many days arrive — which walked the range, and the
|
|
19
|
+
* address with it, out into empty years. What asks for more days is a scroll
|
|
20
|
+
* the reader drove, moving toward the end it asks about.
|
|
14
21
|
*/
|
|
15
22
|
import { CalendarOff, ChevronRight, Layers, MapPin, Users } from "lucide-react";
|
|
16
23
|
import {
|
|
@@ -64,6 +71,17 @@ const LONG_FREE_MINUTES = 150;
|
|
|
64
71
|
/** The hue every calendar the caller did not describe falls back to. */
|
|
65
72
|
const FALLBACK_COLOR: CalendarColorId = "cal-1";
|
|
66
73
|
|
|
74
|
+
/** How close to either end a scroll has to come before more days are asked for. */
|
|
75
|
+
const REACH_BEHIND = 600;
|
|
76
|
+
const REACH_AHEAD = 900;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How long after a wheel, a drag, a touch or a key a scroll is still the
|
|
80
|
+
* reader's. Momentum and a held key both keep scrolling after the input stops;
|
|
81
|
+
* a layout pass or a resize minutes later does not get to borrow the gesture.
|
|
82
|
+
*/
|
|
83
|
+
const INTENT_MS = 1_500;
|
|
84
|
+
|
|
67
85
|
/** Nothing outstanding, which is what a caller that never fetches has. */
|
|
68
86
|
const EMPTY_DATES: ReadonlySet<string> = new Set();
|
|
69
87
|
|
|
@@ -105,6 +123,15 @@ export interface AgendaFlowProps {
|
|
|
105
123
|
onReachEnd: () => void;
|
|
106
124
|
/** The day under the sticky header, for the position map. */
|
|
107
125
|
onVisibleDayChange: (date: string) => void;
|
|
126
|
+
/**
|
|
127
|
+
* The run has grown as far as it goes without being asked. The strip says so
|
|
128
|
+
* at that end and offers the reader the next stretch, rather than fetching
|
|
129
|
+
* its way across a decade nobody scrolled to.
|
|
130
|
+
*/
|
|
131
|
+
atStartCap?: boolean;
|
|
132
|
+
atEndCap?: boolean;
|
|
133
|
+
onLoadEarlier?: () => void;
|
|
134
|
+
onLoadLater?: () => void;
|
|
108
135
|
/**
|
|
109
136
|
* Days whose events have not arrived. They draw as a skeleton and are never
|
|
110
137
|
* collapsed into a run: a day nobody has heard back about is not a day with
|
|
@@ -142,6 +169,10 @@ export function AgendaFlow({
|
|
|
142
169
|
onReachStart,
|
|
143
170
|
onReachEnd,
|
|
144
171
|
onVisibleDayChange,
|
|
172
|
+
atStartCap = false,
|
|
173
|
+
atEndCap = false,
|
|
174
|
+
onLoadEarlier,
|
|
175
|
+
onLoadLater,
|
|
145
176
|
loadingDates = EMPTY_DATES,
|
|
146
177
|
freeOn = freeStretchesOn,
|
|
147
178
|
scrollTarget,
|
|
@@ -156,6 +187,15 @@ export function AgendaFlow({
|
|
|
156
187
|
const askedStart = useRef("");
|
|
157
188
|
const askedEnd = useRef("");
|
|
158
189
|
const landed = useRef(false);
|
|
190
|
+
/*
|
|
191
|
+
* Where the scroller was left, so the next scroll event can be read as a
|
|
192
|
+
* movement and in which direction. Every offset this component writes itself
|
|
193
|
+
* records itself here, which is what makes the take-back after a prepend, and
|
|
194
|
+
* the landing, read as no movement at all.
|
|
195
|
+
*/
|
|
196
|
+
const resting = useRef(0);
|
|
197
|
+
/** When the reader last touched the strip. */
|
|
198
|
+
const reached = useRef(0);
|
|
159
199
|
const [visibleDate, setVisibleDate] = useState(focusDate);
|
|
160
200
|
|
|
161
201
|
const lookup = useMemo(() => lookupOf(calendars), [calendars]);
|
|
@@ -172,6 +212,7 @@ export function AgendaFlow({
|
|
|
172
212
|
element.scrollTop += element.scrollHeight - previousHeight.current;
|
|
173
213
|
previousFirst.current = firstDate;
|
|
174
214
|
previousHeight.current = element.scrollHeight;
|
|
215
|
+
resting.current = element.scrollTop;
|
|
175
216
|
});
|
|
176
217
|
|
|
177
218
|
/* Landing puts you on the focused day; after that the strip is yours. Rows
|
|
@@ -188,6 +229,7 @@ export function AgendaFlow({
|
|
|
188
229
|
const anchor = anchors.current.get(focusDate);
|
|
189
230
|
if (dropped || !anchor) return;
|
|
190
231
|
element.scrollTop = anchor.offsetTop - HEADER_HEIGHT;
|
|
232
|
+
resting.current = element.scrollTop;
|
|
191
233
|
};
|
|
192
234
|
settle();
|
|
193
235
|
setVisibleDate(focusDate);
|
|
@@ -205,20 +247,66 @@ export function AgendaFlow({
|
|
|
205
247
|
const anchor = anchors.current.get(scrollTarget.date);
|
|
206
248
|
if (!element || !anchor) return;
|
|
207
249
|
element.scrollTop = anchor.offsetTop - HEADER_HEIGHT;
|
|
250
|
+
resting.current = element.scrollTop;
|
|
208
251
|
setVisibleDate(scrollTarget.date);
|
|
209
252
|
onVisibleDayChange(scrollTarget.date);
|
|
210
253
|
}, [scrollTarget, onVisibleDayChange]);
|
|
211
254
|
|
|
255
|
+
/*
|
|
256
|
+
* A wheel, a drag, a touch or a key: the strip is the reader's from here.
|
|
257
|
+
* Listened for natively rather than through React, because a `div` carrying
|
|
258
|
+
* key and pointer handlers has to claim a role to be one, and the strip is a
|
|
259
|
+
* scroller rather than a control — announcing it as one would be a lie told
|
|
260
|
+
* to a screen reader for the sake of a lint rule.
|
|
261
|
+
*/
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
const element = scroller.current;
|
|
264
|
+
if (!element) return;
|
|
265
|
+
const note = () => {
|
|
266
|
+
reached.current = Date.now();
|
|
267
|
+
};
|
|
268
|
+
const kinds = [
|
|
269
|
+
"wheel",
|
|
270
|
+
"pointerdown",
|
|
271
|
+
"touchstart",
|
|
272
|
+
"touchmove",
|
|
273
|
+
"keydown",
|
|
274
|
+
];
|
|
275
|
+
// A scrollbar dragged slowly is one press and then nothing but movement,
|
|
276
|
+
// which would otherwise run out of gesture halfway down the strip. Held
|
|
277
|
+
// buttons only: a cursor resting over the strip is not scrolling it.
|
|
278
|
+
const drag = (event: PointerEvent) => {
|
|
279
|
+
if (event.buttons !== 0) note();
|
|
280
|
+
};
|
|
281
|
+
for (const kind of kinds)
|
|
282
|
+
element.addEventListener(kind, note, { passive: true });
|
|
283
|
+
element.addEventListener("pointermove", drag, { passive: true });
|
|
284
|
+
return () => {
|
|
285
|
+
for (const kind of kinds) element.removeEventListener(kind, note);
|
|
286
|
+
element.removeEventListener("pointermove", drag);
|
|
287
|
+
};
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
212
290
|
const handleScroll = () => {
|
|
213
291
|
const element = scroller.current;
|
|
214
292
|
if (!element) return;
|
|
215
293
|
|
|
294
|
+
const top = element.scrollTop;
|
|
295
|
+
const moved = top - resting.current;
|
|
296
|
+
resting.current = top;
|
|
297
|
+
|
|
298
|
+
/* Everything below follows the reader and only the reader. A mount, a
|
|
299
|
+
resize, a font swap and the offset taken back after a prepend all raise
|
|
300
|
+
this event without anybody having scrolled, and answering them is how
|
|
301
|
+
the range and the address walked off on their own. */
|
|
302
|
+
if (moved === 0 || Date.now() - reached.current > INTENT_MS) return;
|
|
303
|
+
|
|
216
304
|
const seen = [...anchors.current.entries()]
|
|
217
305
|
.filter(([, node]) => node.isConnected)
|
|
218
306
|
.sort((a, b) => a[1].offsetTop - b[1].offsetTop);
|
|
219
307
|
let current = "";
|
|
220
308
|
for (const [date, node] of seen) {
|
|
221
|
-
if (node.offsetTop - HEADER_HEIGHT - 8 >
|
|
309
|
+
if (node.offsetTop - HEADER_HEIGHT - 8 > top) break;
|
|
222
310
|
current = date;
|
|
223
311
|
}
|
|
224
312
|
if (current !== "" && current !== visibleDate) {
|
|
@@ -226,12 +314,13 @@ export function AgendaFlow({
|
|
|
226
314
|
onVisibleDayChange(current);
|
|
227
315
|
}
|
|
228
316
|
|
|
229
|
-
if (
|
|
317
|
+
if (moved < 0 && top < REACH_BEHIND && askedStart.current !== firstDate) {
|
|
230
318
|
askedStart.current = firstDate;
|
|
231
319
|
onReachStart();
|
|
232
320
|
}
|
|
233
321
|
if (
|
|
234
|
-
|
|
322
|
+
moved > 0 &&
|
|
323
|
+
top + element.clientHeight > element.scrollHeight - REACH_AHEAD &&
|
|
235
324
|
askedEnd.current !== lastDate
|
|
236
325
|
) {
|
|
237
326
|
askedEnd.current = lastDate;
|
|
@@ -248,6 +337,7 @@ export function AgendaFlow({
|
|
|
248
337
|
<div
|
|
249
338
|
ref={scroller}
|
|
250
339
|
onScroll={handleScroll}
|
|
340
|
+
data-testid="agenda-strip"
|
|
251
341
|
className={cn(
|
|
252
342
|
"relative min-h-0 flex-1 overflow-y-auto bg-surface",
|
|
253
343
|
className,
|
|
@@ -267,6 +357,15 @@ export function AgendaFlow({
|
|
|
267
357
|
)}
|
|
268
358
|
</div>
|
|
269
359
|
|
|
360
|
+
{atStartCap && onLoadEarlier && (
|
|
361
|
+
<CapEdge
|
|
362
|
+
label="Show earlier days"
|
|
363
|
+
testId="agenda-load-earlier"
|
|
364
|
+
onLoad={onLoadEarlier}
|
|
365
|
+
touch={touch}
|
|
366
|
+
/>
|
|
367
|
+
)}
|
|
368
|
+
|
|
270
369
|
{rows.map((row) => (
|
|
271
370
|
<FlowRow
|
|
272
371
|
key={row.key}
|
|
@@ -287,10 +386,51 @@ export function AgendaFlow({
|
|
|
287
386
|
touch={touch}
|
|
288
387
|
/>
|
|
289
388
|
))}
|
|
389
|
+
|
|
390
|
+
{atEndCap && onLoadLater && (
|
|
391
|
+
<CapEdge
|
|
392
|
+
label="Show later days"
|
|
393
|
+
testId="agenda-load-later"
|
|
394
|
+
onLoad={onLoadLater}
|
|
395
|
+
touch={touch}
|
|
396
|
+
/>
|
|
397
|
+
)}
|
|
290
398
|
</div>
|
|
291
399
|
);
|
|
292
400
|
}
|
|
293
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Where the run stops growing on its own. A year either way is further than a
|
|
404
|
+
* reader scrolls in one sitting, so this is rarely on screen — and when it is,
|
|
405
|
+
* the next stretch costs a click rather than arriving because the strip decided
|
|
406
|
+
* it had reached the end of itself.
|
|
407
|
+
*/
|
|
408
|
+
function CapEdge({
|
|
409
|
+
label,
|
|
410
|
+
testId,
|
|
411
|
+
onLoad,
|
|
412
|
+
touch,
|
|
413
|
+
}: {
|
|
414
|
+
label: string;
|
|
415
|
+
testId: string;
|
|
416
|
+
onLoad: () => void;
|
|
417
|
+
touch?: boolean;
|
|
418
|
+
}) {
|
|
419
|
+
return (
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={onLoad}
|
|
423
|
+
data-testid={testId}
|
|
424
|
+
className={cn(
|
|
425
|
+
"flex w-full items-center justify-center gap-2 border-y border-dashed border-line bg-surface-sunken px-row-inset text-xs font-medium text-fg-muted outline-none transition-colors hover:border-accent hover:text-accent focus-visible:ring-2 focus-visible:ring-ring",
|
|
426
|
+
touch ? "min-h-14 py-3" : "py-2",
|
|
427
|
+
)}
|
|
428
|
+
>
|
|
429
|
+
{label}
|
|
430
|
+
</button>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
294
434
|
/* ------------------------------------------------------------------ */
|
|
295
435
|
/* Rows */
|
|
296
436
|
/* ------------------------------------------------------------------ */
|