@hearthlog/ui 0.0.0-stage → 0.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.
Files changed (63) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +37 -3
  3. package/src/assets/fonts/fira-code-latin-ext-ui.woff2 +0 -0
  4. package/src/assets/fonts/fira-code-latin-ui.woff2 +0 -0
  5. package/src/assets/fonts/inter-latin-ext-ui.woff2 +0 -0
  6. package/src/assets/fonts/inter-latin-ui.woff2 +0 -0
  7. package/src/components/ConfirmDialog.svelte +104 -0
  8. package/src/components/Drawer.svelte +135 -0
  9. package/src/components/EmptyState.svelte +33 -0
  10. package/src/components/JsonViewer.svelte +113 -0
  11. package/src/components/Modal.svelte +76 -0
  12. package/src/components/NotificationCenter.svelte +106 -0
  13. package/src/components/PageHeader.svelte +26 -0
  14. package/src/components/commands/CommandConfirmDialog.svelte +32 -0
  15. package/src/components/commands/CommandDrawer.svelte +31 -0
  16. package/src/components/commands/CommandFieldset.svelte +18 -0
  17. package/src/components/commands/CommandModal.svelte +25 -0
  18. package/src/components/commands/CommandOnly.svelte +26 -0
  19. package/src/components/data/DataPlaceholder.svelte +8 -0
  20. package/src/components/data/DataRegion.svelte +8 -0
  21. package/src/components/data/DataValue.svelte +11 -0
  22. package/src/components/data-table/DataTable.svelte +139 -0
  23. package/src/components/data-table/DataTableHeader.svelte +78 -0
  24. package/src/components/data-table/DataTablePagination.svelte +91 -0
  25. package/src/components/data-table/table.ts +30 -0
  26. package/src/components/data-table/types.ts +63 -0
  27. package/src/components/endless/DayPager.svelte +355 -0
  28. package/src/components/endless/EndlessList.svelte +498 -0
  29. package/src/components/endless/bucket-store.svelte.ts +81 -0
  30. package/src/components/json-viewer.ts +49 -0
  31. package/src/components/ui/Amount.svelte +17 -0
  32. package/src/components/ui/Badge.svelte +40 -0
  33. package/src/components/ui/Button.svelte +53 -0
  34. package/src/components/ui/Checkbox.svelte +29 -0
  35. package/src/components/ui/ChoiceChips.svelte +53 -0
  36. package/src/components/ui/CollectionToolbar.svelte +29 -0
  37. package/src/components/ui/DatePicker.svelte +375 -0
  38. package/src/components/ui/DateTimePicker.svelte +16 -0
  39. package/src/components/ui/Field.svelte +52 -0
  40. package/src/components/ui/FoldLine.svelte +38 -0
  41. package/src/components/ui/Input.svelte +21 -0
  42. package/src/components/ui/Label.svelte +18 -0
  43. package/src/components/ui/Metric.svelte +34 -0
  44. package/src/components/ui/MonthNavigator.svelte +194 -0
  45. package/src/components/ui/Panel.svelte +37 -0
  46. package/src/components/ui/RouteTabs.svelte +57 -0
  47. package/src/components/ui/Select.svelte +93 -0
  48. package/src/components/ui/Switch.svelte +41 -0
  49. package/src/components/ui/Textarea.svelte +19 -0
  50. package/src/components/ui/Tooltip.svelte +53 -0
  51. package/src/components/ui/tab-indicator.ts +84 -0
  52. package/src/state/commands.ts +22 -0
  53. package/src/state/notifications.svelte.ts +49 -0
  54. package/src/state/page-context.ts +9 -0
  55. package/src/state/readiness.ts +20 -0
  56. package/src/state/sheet-stack.svelte.ts +49 -0
  57. package/src/styles/base.css +243 -0
  58. package/src/styles/index.css +13 -0
  59. package/src/styles/recipes.css +174 -0
  60. package/src/utils/cn.ts +6 -0
  61. package/src/utils/endless-axis.ts +153 -0
  62. package/src/utils/number-format.ts +82 -0
  63. package/README.md +0 -3
@@ -0,0 +1,498 @@
1
+ <script lang="ts" module>
2
+ /** A place in the list: the block in view, and where within it (a day, for example). */
3
+ export interface EndlessPosition {
4
+ key: string;
5
+ at: string;
6
+ }
7
+
8
+ /** A place to scroll to. It counts as the one in view until the list is scrolled by hand. */
9
+ export interface EndlessTarget {
10
+ key: string;
11
+ /** Where within the block; the key when missing. */
12
+ at?: string;
13
+ /** The element to bring to the line; the block's top when missing or not found. */
14
+ find?: (block: HTMLElement) => HTMLElement | null;
15
+ /** Share of the visible height the element settles at; 0 by default. */
16
+ ratio?: number;
17
+ }
18
+
19
+ /** A block on screen, top to bottom. Its element is missing before it renders. */
20
+ export interface EndlessBlock {
21
+ key: string;
22
+ element: HTMLElement | null;
23
+ }
24
+
25
+ /** The visible part of the list in viewport pixels; `edge` sits just below its top. */
26
+ export interface EndlessView {
27
+ edge: number;
28
+ bottom: number;
29
+ }
30
+ </script>
31
+
32
+ <script lang="ts" generics="T">
33
+ import { flushSync, untrack, type Snippet } from "svelte";
34
+ import type { HTMLAttributes } from "svelte/elements";
35
+ import { createVirtualizer, defaultRangeExtractor, measureElement } from "@tanstack/svelte-virtual";
36
+ import { nearestKey, type EndlessAxis } from "../../utils/endless-axis.js";
37
+ import type { BucketStore } from "./bucket-store.svelte.js";
38
+ import IconArrowUp from "~icons/mdi/arrow-up";
39
+ import IconArrowDown from "~icons/mdi/arrow-down";
40
+
41
+ /** The block under this many pixels below the top edge is the one in view. */
42
+ const EDGE = 12;
43
+
44
+ type Aim = { key: string; find?: (block: HTMLElement) => HTMLElement | null; ratio: number; behavior: ScrollBehavior; measured: boolean; tries: number };
45
+
46
+ let {
47
+ axis,
48
+ store,
49
+ opening,
50
+ estimate,
51
+ overscan = 1,
52
+ revision,
53
+ anchorSelector,
54
+ openingPlaceholder,
55
+ home,
56
+ pick = (blocks) => ({ key: blocks[0].key, at: blocks[0].key }),
57
+ onVisibleChange,
58
+ item,
59
+ class: className = "",
60
+ ...attributes
61
+ }: Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
62
+ /** The keys listed. Its end may grow while the list is open; its keys never move. */
63
+ axis: EndlessAxis;
64
+ store: BucketStore<T>;
65
+ /** Where the list opens, read once. It opens at the block's estimated start and settles once it has loaded. */
66
+ opening: EndlessTarget;
67
+ /**
68
+ * A block's height before it is measured, for every block or per key;
69
+ * loading blocks keep roughly this height.
70
+ */
71
+ estimate: number | ((key: string) => number);
72
+ overscan?: number;
73
+ /** Changes after a save or a sync; the blocks on screen then read again. */
74
+ revision?: number;
75
+ /** Stable elements to keep at their screen positions when loaded buckets change. */
76
+ anchorSelector?: string;
77
+ /** Stays visible until the opening window has loaded and been measured. */
78
+ openingPlaceholder?: Snippet;
79
+ /** A place to return to. While it is off screen, a button floats at the bottom of the list. */
80
+ home?: EndlessTarget & { label: string };
81
+ /** Names the place in view from the blocks on screen; the first one crosses the top edge. */
82
+ pick?: (blocks: EndlessBlock[], view: EndlessView) => EndlessPosition;
83
+ onVisibleChange?: (position: EndlessPosition) => void;
84
+ /** Renders a block; `data` is missing while it loads. */
85
+ item: Snippet<[key: string, data: T | undefined]>;
86
+ } = $props();
87
+
88
+ let scroller = $state<HTMLDivElement>();
89
+ let openingReady = $state(false);
90
+
91
+ function estimated(index: number): number {
92
+ return typeof estimate === "number" ? estimate : estimate(axis.keyAt(index));
93
+ }
94
+
95
+ /** Where the block at an index starts before anything is measured. */
96
+ function estimatedStart(index: number): number {
97
+ if (typeof estimate === "number") return index * estimate;
98
+ let start = 0;
99
+ for (let before = 0; before < index; before++) start += estimated(before);
100
+ return start;
101
+ }
102
+
103
+ const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
104
+ count: untrack(() => axis.count),
105
+ getScrollElement: () => scroller ?? null,
106
+ estimateSize: estimated,
107
+ getItemKey: (index) => axis.keyAt(index),
108
+ // A hidden list measures zero; keep the last size so it never renders everything.
109
+ measureElement: (element, entry, instance) => {
110
+ const size = measureElement(element, entry, instance);
111
+ const index = instance.indexFromElement(element);
112
+ return size > 0 ? size : instance.itemSizeCache.get(instance.options.getItemKey(index)) ?? estimated(index);
113
+ },
114
+ overscan: untrack(() => overscan),
115
+ initialOffset: () => untrack(() => estimatedStart(axis.indexOf(opening.key))),
116
+ });
117
+
118
+ /**
119
+ * The part of the list on screen. On smaller screens the page scrolls
120
+ * around the list too, so the fixed header and navigation can cover it.
121
+ */
122
+ function viewport(): { top: number; height: number } | null {
123
+ if (!scroller) return null;
124
+ const rect = scroller.getBoundingClientRect();
125
+ let top = rect.top;
126
+ let bottom = rect.top + scroller.clientHeight;
127
+ const page = scroller.closest<HTMLElement>("[data-stor-main]");
128
+ if (page) {
129
+ const pageRect = page.getBoundingClientRect();
130
+ const style = getComputedStyle(page);
131
+ top = Math.max(top, pageRect.top + parseFloat(style.paddingTop));
132
+ bottom = Math.min(bottom, pageRect.top + page.clientHeight - parseFloat(style.paddingBottom));
133
+ }
134
+ return { top, height: Math.max(0, bottom - top) };
135
+ }
136
+
137
+ /** Whether each block was last measured while still loading. */
138
+ const measuredLoading = new Map<string, boolean>();
139
+ let measuringAnchors = false;
140
+
141
+ $effect(() => {
142
+ if (!scroller) return;
143
+ const instance = untrack(() => $virtualizer);
144
+ // What is on screen stays put when a block above it changes height:
145
+ // blocks entirely above the top edge, and a block straddling the edge
146
+ // while it goes from its loading shape to its content (whichever way
147
+ // the list scrolls). A loaded block that changes around the edge grows
148
+ // below it, so nothing moves.
149
+ instance.shouldAdjustScrollPositionOnItemSizeChange = (entry, _delta, self) => {
150
+ const key = String(entry.key);
151
+ const wasLoading = measuredLoading.get(key) ?? true;
152
+ measuredLoading.set(key, store.get(key) === undefined);
153
+ if (measuringAnchors) return false;
154
+ // While opening, compensate only blocks before the target. Using
155
+ // the moving offset during a batch can pull growing target/future
156
+ // blocks above that offset and scroll the target out of the window.
157
+ if (aiming && openingPlaceholder && !openingReady) return entry.index < axis.indexOf(aiming.key);
158
+ const edge = (self.scrollOffset ?? 0) + self.scrollAdjustments;
159
+ if (entry.start >= edge) return false;
160
+ if (!self.itemSizeCache.has(entry.key)) return true;
161
+ return entry.end <= edge || wasLoading;
162
+ };
163
+ instance._willUpdate();
164
+ });
165
+
166
+ // The axis can grow at its end, for example when done history gains a later month.
167
+ $effect(() => {
168
+ const next = axis.count;
169
+ untrack(() => { if ($virtualizer.options.count !== next) $virtualizer.setOptions({ count: next }); });
170
+ });
171
+
172
+ const items = $derived($virtualizer.getVirtualItems());
173
+ const keys = $derived(items.map((entry) => axis.keyAt(entry.index)));
174
+
175
+ $effect(() => {
176
+ void store.buckets.size;
177
+ store.ensure(keys);
178
+ });
179
+
180
+ function measure(node: HTMLDivElement) {
181
+ untrack(() => $virtualizer).measureElement(node);
182
+ }
183
+
184
+ let anchorRevision = 0;
185
+ let anchorFrame = 0;
186
+ let scrollIntent = 0;
187
+ let disposed = false;
188
+
189
+ $effect(() => {
190
+ const selector = anchorSelector;
191
+ if (!selector) return;
192
+ const unsubscribe = store.beforePublish(() => {
193
+ const view = viewport();
194
+ if (!scroller || !view || aiming || !openingReady) return;
195
+ const origin = scroller.getBoundingClientRect().top;
196
+ const anchors: { element: HTMLElement; top: number }[] = [];
197
+ for (const element of scroller.querySelectorAll<HTMLElement>(selector)) {
198
+ const rect = element.getBoundingClientRect();
199
+ if (rect.top >= view.top + view.height) break;
200
+ if (rect.bottom > view.top + EDGE) anchors.push({ element, top: rect.top - origin });
201
+ }
202
+ if (anchors.length > 0) restoreAnchors(anchors, ++anchorRevision, scrollIntent);
203
+ });
204
+ return () => { unsubscribe(); anchorRevision += 1; };
205
+ });
206
+
207
+ function restoreAnchors(anchors: { element: HTMLElement; top: number }[], revision: number, intent: number): void {
208
+ // Publication completes synchronously after beforePublish. Flush its
209
+ // DOM and measure/correct in the next microtask, before the next paint;
210
+ // tick() can defer to a frame when Svelte's async mode is enabled.
211
+ queueMicrotask(() => {
212
+ const current = () => !disposed && revision === anchorRevision && intent === scrollIntent;
213
+ if (!current()) return;
214
+ if (!scroller) return;
215
+ const instance = untrack(() => $virtualizer);
216
+ const first = Number(anchors[0].element.closest<HTMLElement>("[data-index]")?.dataset.index);
217
+ const last = Number(anchors[anchors.length - 1].element.closest<HTMLElement>("[data-index]")?.dataset.index);
218
+ if (!Number.isFinite(first) || !Number.isFinite(last)) return;
219
+ cancelAnimationFrame(anchorFrame);
220
+ // Shrinking preceding blocks can otherwise remove the anchor from
221
+ // the virtual window before its scroll correction is applied. Keep
222
+ // a contiguous window through its old visible months for this batch.
223
+ measuringAnchors = true;
224
+ try {
225
+ instance.setOptions({ rangeExtractor: (range) => defaultRangeExtractor({
226
+ ...range,
227
+ startIndex: Math.min(range.startIndex, first),
228
+ endIndex: Math.max(range.endIndex, last),
229
+ }) });
230
+ flushSync();
231
+ if (!current() || !scroller) return;
232
+ // Correct against the reading anchor once, rather than letting
233
+ // a moving offset trigger compensation for each growing block.
234
+ flushSync(() => {
235
+ for (const element of scroller!.querySelectorAll<HTMLDivElement>("[data-endless-key]")) {
236
+ const height = Math.round(element.getBoundingClientRect().height);
237
+ if (height > 0) instance.resizeItem(instance.indexFromElement(element), height);
238
+ }
239
+ });
240
+ if (!current() || !scroller) return;
241
+ const anchor = anchors.find(({ element }) => scroller!.contains(element));
242
+ if (!anchor) return;
243
+ const delta = anchor.element.getBoundingClientRect().top - scroller.getBoundingClientRect().top - anchor.top;
244
+ if (Math.abs(delta) > 1) scroller.scrollTop += delta;
245
+ } finally {
246
+ measuringAnchors = false;
247
+ // Let the native scroll event update the virtual offset before
248
+ // releasing the retained months. A newer batch owns its window.
249
+ anchorFrame = requestAnimationFrame(() => {
250
+ if (!disposed && revision === anchorRevision) instance.setOptions({ rangeExtractor: defaultRangeExtractor });
251
+ });
252
+ }
253
+ });
254
+ }
255
+
256
+ /** Re-reads the blocks on screen after a save or a sync. */
257
+ export function refresh(): void {
258
+ store.refresh(keys);
259
+ }
260
+
261
+ let refreshed = untrack(() => revision);
262
+ $effect(() => {
263
+ const current = revision;
264
+ if (current === refreshed) return;
265
+ refreshed = current;
266
+ untrack(() => refresh());
267
+ });
268
+
269
+ // ---------------------------------------------------------------- scrolling
270
+
271
+ let aiming: Aim | null = null;
272
+ let settleFrame = 0;
273
+
274
+ function block(key: string): HTMLElement | null {
275
+ return scroller?.querySelector<HTMLElement>(`[data-endless-key="${CSS.escape(key)}"]`) ?? null;
276
+ }
277
+
278
+ function loaded(key: string): boolean {
279
+ return store.get(key) !== undefined && !!block(key);
280
+ }
281
+
282
+ /**
283
+ * Brings the target to its line once its block has loaded and been
284
+ * measured. Near the block's top the top stays in view instead.
285
+ */
286
+ function settle(): void {
287
+ cancelAnimationFrame(settleFrame);
288
+ settleFrame = requestAnimationFrame(() => {
289
+ const view = viewport();
290
+ if (!aiming || !view || !scroller) return;
291
+ aiming.tries -= 1;
292
+ if (aiming.tries <= 0) { aiming = null; openingReady = true; return; }
293
+ const windowReady = !openingPlaceholder || openingReady || keys.every((key) => {
294
+ const element = block(key);
295
+ return loaded(key) && element && Math.abs(($virtualizer.itemSizeCache.get(key) ?? -1) - element.getBoundingClientRect().height) < 1;
296
+ });
297
+ if (!loaded(aiming.key) || !aiming.measured || !windowReady) {
298
+ // One extra frame after loading lets the list measure the block.
299
+ if (loaded(aiming.key)) aiming.measured = true;
300
+ settle();
301
+ return;
302
+ }
303
+ const element = block(aiming.key)!;
304
+ const top = element.getBoundingClientRect().top - view.top;
305
+ const found = aiming.find?.(element) ?? null;
306
+ const line = found ? found.getBoundingClientRect().top - view.top : top;
307
+ const wanted = Math.min(view.height * aiming.ratio, line - top);
308
+ const delta = line - wanted;
309
+ const before = scroller.scrollTop;
310
+ // Through the virtualizer, so an adjustment it deferred during the jump (iOS WebKit) is
311
+ // dropped instead of landing on top of the place this settles on.
312
+ if (Math.abs(delta) > 1) $virtualizer.scrollToOffset(before + delta, { behavior: aiming.behavior });
313
+ if (openingPlaceholder && !openingReady && Math.abs(scroller.scrollTop - before) > 1) {
314
+ settle();
315
+ return;
316
+ }
317
+ aiming = null;
318
+ openingReady = true;
319
+ });
320
+ }
321
+
322
+ /** Scrolls to a place; a block that has not loaded yet is jumped to, loaded, then settled on. */
323
+ export function scrollTo(target: EndlessTarget, behavior: ScrollBehavior = "auto"): void {
324
+ if (!scroller) return;
325
+ scrollIntent += 1;
326
+ const key = nearestKey(axis, target.key);
327
+ held = { key, at: target.at ?? key };
328
+ const rendered = loaded(key);
329
+ aiming = { key, find: target.find, ratio: target.ratio ?? 0, behavior: rendered ? behavior : "auto", measured: rendered, tries: 300 };
330
+ if (!rendered) {
331
+ // Jump to the block's estimated start; it loads there, then settles.
332
+ const offset = $virtualizer.getOffsetForIndex(axis.indexOf(key), "start");
333
+ if (offset) $virtualizer.scrollToOffset(offset[0]);
334
+ }
335
+ settle();
336
+ }
337
+
338
+ // The list opens at the opening block's estimated start (initialOffset),
339
+ // then settles on the opening place once it has loaded.
340
+ let opened = false;
341
+ $effect(() => {
342
+ if (!scroller || opened) return;
343
+ opened = true;
344
+ untrack(() => scrollTo(opening));
345
+ });
346
+
347
+ // ---------------------------------------------------------------- what is in view
348
+
349
+ let visibleFrame = 0;
350
+ let lastVisible: EndlessPosition = { key: "", at: "" };
351
+ /**
352
+ * After opening or a jump, the place it went to stays the one in view
353
+ * until the list is scrolled by hand; with sparse blocks the top edge can
354
+ * otherwise sit in an empty block far above what was asked for.
355
+ */
356
+ let held: EndlessPosition | null = null;
357
+
358
+ const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
359
+
360
+ function release(event: Event): void {
361
+ if (event instanceof KeyboardEvent && (!SCROLL_KEYS.has(event.key) || (event.key === " " && event.target !== scroller))) return;
362
+ // A pointer on the list itself (not a block) is its scrollbar.
363
+ if (event instanceof PointerEvent && event.target !== scroller) return;
364
+ scrollIntent += 1;
365
+ aiming = null;
366
+ openingReady = true;
367
+ held = null;
368
+ reportVisible();
369
+ }
370
+
371
+ /** Which way home lies when it is off screen. */
372
+ let homeAway = $state<"up" | "down" | null>(null);
373
+
374
+ function reportVisible(): void {
375
+ cancelAnimationFrame(visibleFrame);
376
+ visibleFrame = requestAnimationFrame(() => {
377
+ const view = viewport();
378
+ if (!view || !scroller) return;
379
+ // The rendered blocks are the truth; the virtualizer's sizes can lag them by a frame.
380
+ const edge = view.top + EDGE;
381
+ const rendered = [...scroller.querySelectorAll<HTMLElement>("[data-endless-key]")];
382
+ const start = rendered.findIndex((element) => element.getBoundingClientRect().bottom > edge);
383
+ let blocks: EndlessBlock[] = (start < 0 ? [] : rendered.slice(start))
384
+ .filter((element) => element.getBoundingClientRect().top < view.top + view.height)
385
+ .map((element) => ({ key: element.dataset.endlessKey ?? "", element }));
386
+ if (blocks.length === 0) {
387
+ const entry = $virtualizer.getVirtualItemForOffset(scroller.scrollTop + Math.max(0, view.top - scroller.getBoundingClientRect().top) + EDGE);
388
+ if (!entry) return;
389
+ const key = axis.keyAt(entry.index);
390
+ blocks = [{ key, element: block(key) }];
391
+ }
392
+ // However the list moved (find in page, a scrollbar, an assistive tool), a held place off screen lets go.
393
+ if (held && !aiming) {
394
+ const rect = block(held.key)?.getBoundingClientRect();
395
+ if (!rect || rect.bottom <= view.top || rect.top >= view.top + view.height) held = null;
396
+ }
397
+ const position = held ?? pick(blocks, { edge, bottom: view.top + view.height });
398
+
399
+ if (home) {
400
+ const homeBlock = block(home.key);
401
+ const rect = (homeBlock && home.find ? home.find(homeBlock) : homeBlock)?.getBoundingClientRect();
402
+ // A pixel of slack: a home settled on the bottom edge can land a fraction past it.
403
+ homeAway = rect
404
+ ? rect.bottom < view.top - 1 ? "up" : rect.top > view.top + view.height + 1 ? "down" : null
405
+ : axis.indexOf(home.key) < axis.indexOf(position.key) ? "up" : "down";
406
+ }
407
+
408
+ if (position.key === lastVisible.key && position.at === lastVisible.at) return;
409
+ lastVisible = position;
410
+ onVisibleChange?.(position);
411
+ });
412
+ }
413
+
414
+ // The page can scroll around the list on smaller screens, which moves what is visible.
415
+ $effect(() => {
416
+ const page = scroller?.closest<HTMLElement>("[data-stor-main]");
417
+ if (!page) return;
418
+ page.addEventListener("scroll", reportVisible, { passive: true });
419
+ return () => page.removeEventListener("scroll", reportVisible);
420
+ });
421
+
422
+ $effect(() => {
423
+ void items;
424
+ void store.buckets.size;
425
+ reportVisible();
426
+ });
427
+
428
+ $effect(() => () => {
429
+ disposed = true;
430
+ cancelAnimationFrame(settleFrame);
431
+ cancelAnimationFrame(visibleFrame);
432
+ cancelAnimationFrame(anchorFrame);
433
+ });
434
+ </script>
435
+
436
+ <div class="endless">
437
+ <div
438
+ {...attributes}
439
+ aria-busy={openingPlaceholder && !openingReady ? true : undefined}
440
+ bind:this={scroller}
441
+ class={["scroller", className]}
442
+ onscroll={reportVisible}
443
+ onwheel={release}
444
+ ontouchmove={release}
445
+ onkeydown={release}
446
+ onpointerdown={release}
447
+ >
448
+ <div class="track" style:height={`${$virtualizer.getTotalSize()}px`}>
449
+ <!-- Positioned with top, not a transform: sticky headers inside blocks break under a transform. -->
450
+ <div class="window" style:top={`${items[0]?.start ?? 0}px`} style:visibility={openingPlaceholder && !openingReady ? "hidden" : undefined}>
451
+ {#each items as entry (entry.key)}
452
+ {@const key = axis.keyAt(entry.index)}
453
+ <div data-index={entry.index} data-endless-key={key} {@attach measure}>
454
+ {@render item(key, store.get(key))}
455
+ </div>
456
+ {/each}
457
+ </div>
458
+ </div>
459
+ </div>
460
+ {#if openingPlaceholder && !openingReady}
461
+ <div class="opening-placeholder">{@render openingPlaceholder()}</div>
462
+ {/if}
463
+ {#if home && homeAway}
464
+ <button type="button" class="home-jump" onclick={() => home && scrollTo(home)}>
465
+ {#if homeAway === "up"}<IconArrowUp class="h-4 w-4" />{:else}<IconArrowDown class="h-4 w-4" />{/if}{home.label}
466
+ </button>
467
+ {/if}
468
+ </div>
469
+
470
+ <style>
471
+ .endless { position: relative; height: 100%; }
472
+ .scroller { position: relative; height: 100%; overflow-y: auto; overflow-anchor: none; overscroll-behavior: contain; }
473
+ .track { position: relative; width: 100%; }
474
+ .window { position: absolute; left: 0; width: 100%; }
475
+ .opening-placeholder { position: absolute; top: 0; left: 0; width: 100%; padding: 1rem; pointer-events: none; }
476
+ .home-jump {
477
+ position: absolute;
478
+ /* A page can move the way back, for example clear of an action floating over the list. */
479
+ bottom: var(--endless-home-bottom, 1rem);
480
+ left: var(--endless-home-left, 50%);
481
+ z-index: 3;
482
+ display: inline-flex;
483
+ height: var(--endless-home-height, 2.25rem);
484
+ align-items: center;
485
+ gap: 0.25rem;
486
+ padding: 0 0.875rem 0 0.625rem;
487
+ border: 0;
488
+ border-radius: 999px;
489
+ background: var(--stor-surface-raised);
490
+ box-shadow: var(--stor-shadow-md);
491
+ font-size: 13px;
492
+ font-weight: 600;
493
+ color: var(--stor-ink);
494
+ transform: var(--endless-home-transform, translateX(-50%));
495
+ }
496
+ .home-jump:hover { background: var(--stor-surface-hover); }
497
+ .home-jump:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: 2px; }
498
+ </style>
@@ -0,0 +1,81 @@
1
+ import { SvelteMap } from 'svelte/reactivity';
2
+
3
+ /**
4
+ * Reads a batch of buckets, keys sorted, in one go (one SQL session). A key
5
+ * missing from the result stays unloaded and is asked for again later.
6
+ */
7
+ export type BucketLoader<T> = (keys: string[]) => Promise<Map<string, T>>;
8
+
9
+ /**
10
+ * Loaded buckets of an endless list, keyed like its axis. The list asks for
11
+ * the buckets that scroll into view. A refresh re-reads the buckets in view
12
+ * and forgets the rest, so they load again when they come back. Old data
13
+ * stays on screen until new data arrives, and a batch that a refresh or reset
14
+ * overtook is dropped.
15
+ */
16
+ export class BucketStore<T> {
17
+ readonly buckets = new SvelteMap<string, T>();
18
+ readonly #pending = new Set<string>();
19
+ readonly #load: BucketLoader<T>;
20
+ readonly #failure: string;
21
+ readonly #beforePublish = new Set<() => void>();
22
+ #generation = 0;
23
+
24
+ /** `failure` is the warning logged when a batch cannot be read. */
25
+ constructor(load: BucketLoader<T>, failure: string) {
26
+ this.#load = load;
27
+ this.#failure = failure;
28
+ }
29
+
30
+ get(key: string): T | undefined {
31
+ return this.buckets.get(key);
32
+ }
33
+
34
+ /** Lets a list capture its old DOM geometry before a loaded batch replaces it. */
35
+ beforePublish(listener: () => void): () => void {
36
+ this.#beforePublish.add(listener);
37
+ return () => { this.#beforePublish.delete(listener); };
38
+ }
39
+
40
+ /** Loads the given buckets that are neither loaded nor on their way. */
41
+ ensure(keys: string[]): void {
42
+ const missing = keys.filter((key) => !this.buckets.has(key) && !this.#pending.has(key));
43
+ if (missing.length > 0) void this.#read(missing, this.#generation);
44
+ }
45
+
46
+ /** Re-reads the buckets in view after a save or sync and drops the others. */
47
+ refresh(keysInView: string[]): void {
48
+ this.#generation += 1;
49
+ this.#pending.clear();
50
+ const keep = new Set(keysInView);
51
+ for (const key of [...this.buckets.keys()]) {
52
+ if (!keep.has(key)) this.buckets.delete(key);
53
+ }
54
+ if (keysInView.length > 0) void this.#read(keysInView, this.#generation);
55
+ }
56
+
57
+ /** Forgets everything, for example when the day changes. */
58
+ reset(): void {
59
+ this.#generation += 1;
60
+ this.#pending.clear();
61
+ this.buckets.clear();
62
+ }
63
+
64
+ async #read(keys: string[], generation: number): Promise<void> {
65
+ const sorted = [...keys].sort();
66
+ for (const key of sorted) this.#pending.add(key);
67
+ try {
68
+ const loaded = await this.#load(sorted);
69
+ if (generation !== this.#generation) return;
70
+ for (const listener of this.#beforePublish) listener();
71
+ for (const key of sorted) {
72
+ const value = loaded.get(key);
73
+ if (value !== undefined) this.buckets.set(key, value);
74
+ }
75
+ } catch (error) {
76
+ console.warn(this.#failure, error);
77
+ } finally {
78
+ if (generation === this.#generation) for (const key of sorted) this.#pending.delete(key);
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,49 @@
1
+ type MicrolighterModule = typeof import("microlighter");
2
+
3
+ let microlighter: Promise<MicrolighterModule> | null = null;
4
+ let highlightQueue = Promise.resolve();
5
+ let highlightScheduled = false;
6
+
7
+ export function formatJson(value: unknown): string {
8
+ const result = JSON.stringify(value, null, 2);
9
+ if (result === undefined) {
10
+ throw new TypeError("The supplied value cannot be represented as JSON.");
11
+ }
12
+ return result;
13
+ }
14
+
15
+ /**
16
+ * Highlight every mounted JSON viewer after Svelte has flushed its DOM.
17
+ * Microlighter uses a document-global CSS highlight registry, so calls are
18
+ * serialized and always scan all viewers together.
19
+ */
20
+ export function scheduleJsonHighlight(): void {
21
+ if (typeof window === "undefined" || typeof document === "undefined") {
22
+ return;
23
+ }
24
+ if (highlightScheduled) return;
25
+
26
+ highlightScheduled = true;
27
+ queueMicrotask(() => {
28
+ highlightScheduled = false;
29
+ highlightQueue = highlightQueue
30
+ .then(() => highlightJsonViewers())
31
+ .catch((error: unknown) => {
32
+ console.warn("JSON syntax highlighting is unavailable.", error);
33
+ });
34
+ });
35
+ }
36
+
37
+ async function highlightJsonViewers(): Promise<void> {
38
+ const css = globalThis.CSS as typeof globalThis.CSS & {
39
+ highlights?: unknown;
40
+ };
41
+ if (!css.highlights) return;
42
+
43
+ microlighter ??= import("microlighter");
44
+ const { highlightAll } = await microlighter;
45
+ await highlightAll({
46
+ root: document,
47
+ selector: '[data-json-viewer] pre > code.language-json',
48
+ });
49
+ }
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import { formatAmount, type AmountOptions } from '../../utils/number-format.js';
3
+
4
+ let {
5
+ value,
6
+ unit = 'Kč',
7
+ signed = false,
8
+ decimals = 'auto',
9
+ }: {
10
+ value: number;
11
+ unit?: string;
12
+ signed?: AmountOptions['signed'];
13
+ decimals?: AmountOptions['decimals'];
14
+ } = $props();
15
+ </script>
16
+
17
+ <span class="tabular-nums">{formatAmount(value, { signed, decimals })}</span>{' '}<span class="stor-unit">{unit}</span>
@@ -0,0 +1,40 @@
1
+ <script lang="ts">
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import type { Snippet } from "svelte";
4
+ import type { HTMLAttributes } from "svelte/elements";
5
+ import { cn } from "../../utils/cn.js";
6
+
7
+ const badgeVariants = cva(
8
+ "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "border-transparent bg-[var(--stor-surface-well)] text-[var(--stor-muted-text)]",
13
+ success: "border-transparent bg-[var(--stor-positive-soft)] text-[var(--stor-positive)]",
14
+ warning: "border-transparent bg-[var(--stor-warning-soft)] text-[var(--stor-warning)]",
15
+ danger: "border-transparent bg-[var(--stor-negative-soft)] text-[var(--stor-negative)]",
16
+ info: "border-[var(--stor-info-border)] bg-[var(--stor-info-soft)] text-[var(--stor-info)]",
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ variant: "default",
21
+ },
22
+ },
23
+ );
24
+
25
+ type BadgeProps = HTMLAttributes<HTMLSpanElement> &
26
+ VariantProps<typeof badgeVariants> & {
27
+ children?: Snippet;
28
+ };
29
+
30
+ let {
31
+ class: className = "",
32
+ variant = "default",
33
+ children,
34
+ ...restProps
35
+ }: BadgeProps = $props();
36
+ </script>
37
+
38
+ <span class={cn(badgeVariants({ variant }), className)} {...restProps}>
39
+ {@render children?.()}
40
+ </span>