@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,78 @@
1
+ <script lang="ts">
2
+ import type { HeaderContext } from "@tanstack/svelte-table";
3
+ import IconArrowDown from "~icons/mdi/arrow-down";
4
+ import IconArrowUp from "~icons/mdi/arrow-up";
5
+ import IconUnfoldMoreHorizontal from "~icons/mdi/unfold-more-horizontal";
6
+ import type { AppFeatures } from "./table.js";
7
+ import type { DataTableRow } from "./types.js";
8
+
9
+ let {
10
+ name,
11
+ subtitle,
12
+ align = "left",
13
+ context,
14
+ }: {
15
+ name: string;
16
+ subtitle?: string;
17
+ align?: "left" | "right";
18
+ context?: HeaderContext<AppFeatures, DataTableRow, unknown>;
19
+ } = $props();
20
+
21
+ const sortDirection = $derived(context?.column.getIsSorted() ?? false);
22
+ const canSort = $derived(context?.column.getCanSort() ?? false);
23
+ const toggleSorting = $derived(context?.column.getToggleSortingHandler());
24
+ const sortLabel = $derived(
25
+ !canSort
26
+ ? undefined
27
+ : sortDirection === "asc"
28
+ ? `Sort by ${name}, descending`
29
+ : sortDirection === "desc"
30
+ ? `Sort by ${name}, clear sorting`
31
+ : `Sort by ${name}, ascending`,
32
+ );
33
+ </script>
34
+
35
+ {#if canSort && toggleSorting}
36
+ <button
37
+ type="button"
38
+ class="group flex w-full min-w-0 flex-col {align === 'right' ? 'items-end' : 'items-start'} gap-0.5 rounded-md px-1 py-0.5 text-left outline-none transition hover:bg-[var(--stor-surface)] focus-visible:ring-2 focus-visible:ring-[var(--stor-selected)]"
39
+ aria-label={sortLabel}
40
+ aria-sort={sortDirection === "asc"
41
+ ? "ascending"
42
+ : sortDirection === "desc"
43
+ ? "descending"
44
+ : "none"}
45
+ onclick={(event) => toggleSorting(event)}
46
+ >
47
+ <span class="flex w-full min-w-0 items-center gap-1 {align === 'right' ? 'justify-end' : ''}">
48
+ <span class="truncate font-semibold text-[var(--stor-ink)]">{name}</span>
49
+ <span class="shrink-0 text-[var(--stor-muted-text)]" aria-hidden="true">
50
+ {#if sortDirection === "asc"}
51
+ <IconArrowUp class="h-3.5 w-3.5 text-[var(--stor-accent-strong)]" />
52
+ {:else if sortDirection === "desc"}
53
+ <IconArrowDown class="h-3.5 w-3.5 text-[var(--stor-accent-strong)]" />
54
+ {:else}
55
+ <IconUnfoldMoreHorizontal class="h-3.5 w-3.5 opacity-40 transition group-hover:opacity-100" />
56
+ {/if}
57
+ </span>
58
+ </span>
59
+ {#if subtitle}
60
+ <span
61
+ class="whitespace-nowrap text-[10.5px] font-normal text-[var(--stor-muted-text)]"
62
+ >
63
+ {subtitle}
64
+ </span>
65
+ {/if}
66
+ </button>
67
+ {:else}
68
+ <div class="font-semibold text-[var(--stor-ink)] {align === 'right' ? 'text-right' : ''}">
69
+ {name}
70
+ </div>
71
+ {#if subtitle}
72
+ <div
73
+ class="mt-0.5 whitespace-nowrap text-[10.5px] font-normal text-[var(--stor-muted-text)] {align === 'right' ? 'text-right' : ''}"
74
+ >
75
+ {subtitle}
76
+ </div>
77
+ {/if}
78
+ {/if}
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import Select from "../ui/Select.svelte";
3
+ import IconChevronDoubleLeft from "~icons/mdi/chevron-double-left";
4
+ import IconChevronDoubleRight from "~icons/mdi/chevron-double-right";
5
+ import IconChevronLeft from "~icons/mdi/chevron-left";
6
+ import IconChevronRight from "~icons/mdi/chevron-right";
7
+
8
+ let {
9
+ currentPage,
10
+ totalPages,
11
+ totalRows,
12
+ pageSize,
13
+ pageSizes,
14
+ showingStart,
15
+ showingEnd,
16
+ onSelectPage,
17
+ onSelectPageSize,
18
+ }: {
19
+ currentPage: number;
20
+ totalPages: number;
21
+ totalRows: number;
22
+ pageSize: number;
23
+ pageSizes: readonly number[];
24
+ showingStart: number;
25
+ showingEnd: number;
26
+ onSelectPage: (page: number) => void;
27
+ onSelectPageSize: (pageSize: number) => void;
28
+ } = $props();
29
+ </script>
30
+
31
+ <footer
32
+ class="flex flex-col gap-3 border-t border-[var(--stor-border)] bg-[var(--stor-surface-muted)]/60 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
33
+ >
34
+ <div class="flex flex-wrap items-center gap-3 text-xs text-[var(--stor-muted-text)]">
35
+ <span>
36
+ Showing {showingStart}–{showingEnd} of
37
+ {totalRows}
38
+ </span>
39
+ <label class="flex items-center gap-1.5">
40
+ <span>Rows</span>
41
+ <Select ariaLabel="Rows per page" value={String(pageSize)} options={pageSizes.map((size) => ({ value: String(size), label: String(size) }))} onValueChange={(value) => onSelectPageSize(Number(value))} class="w-20" />
42
+ </label>
43
+ </div>
44
+
45
+ <div class="flex items-center gap-1">
46
+ <button
47
+ type="button"
48
+ class="rounded-lg p-1.5 text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface)] hover:text-[var(--stor-ink)] disabled:opacity-30"
49
+ aria-label="First page"
50
+ title="First page"
51
+ disabled={currentPage === 1}
52
+ onclick={() => onSelectPage(1)}
53
+ >
54
+ <IconChevronDoubleLeft class="h-4 w-4" />
55
+ </button>
56
+ <button
57
+ type="button"
58
+ class="rounded-lg p-1.5 text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface)] hover:text-[var(--stor-ink)] disabled:opacity-30"
59
+ aria-label="Previous page"
60
+ title="Previous page"
61
+ disabled={currentPage === 1}
62
+ onclick={() => onSelectPage(currentPage - 1)}
63
+ >
64
+ <IconChevronLeft class="h-4 w-4" />
65
+ </button>
66
+ <span class="min-w-24 text-center text-xs font-medium text-[var(--stor-muted-text)]">
67
+ Page {currentPage} of
68
+ {totalPages}
69
+ </span>
70
+ <button
71
+ type="button"
72
+ class="rounded-lg p-1.5 text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface)] hover:text-[var(--stor-ink)] disabled:opacity-30"
73
+ aria-label="Next page"
74
+ title="Next page"
75
+ disabled={currentPage === totalPages}
76
+ onclick={() => onSelectPage(currentPage + 1)}
77
+ >
78
+ <IconChevronRight class="h-4 w-4" />
79
+ </button>
80
+ <button
81
+ type="button"
82
+ class="rounded-lg p-1.5 text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface)] hover:text-[var(--stor-ink)] disabled:opacity-30"
83
+ aria-label="Last page"
84
+ title="Last page"
85
+ disabled={currentPage === totalPages}
86
+ onclick={() => onSelectPage(totalPages)}
87
+ >
88
+ <IconChevronDoubleRight class="h-4 w-4" />
89
+ </button>
90
+ </div>
91
+ </footer>
@@ -0,0 +1,30 @@
1
+ import {
2
+ createTableHook,
3
+ rowPaginationFeature,
4
+ rowSortingFeature,
5
+ tableFeatures,
6
+ } from "@tanstack/svelte-table";
7
+
8
+ export interface DataTableColumnMeta {
9
+ subtitle?: string;
10
+ /** Classes for the column's header and cells, for example to hide it on a phone or align its numbers. */
11
+ className?: string;
12
+ }
13
+
14
+ const appFeatures = tableFeatures({
15
+ rowPaginationFeature,
16
+ rowSortingFeature,
17
+ columnMeta: {} as DataTableColumnMeta,
18
+ });
19
+
20
+ export type AppFeatures = typeof appFeatures;
21
+
22
+ export const {
23
+ createAppColumnHelper,
24
+ createAppTable,
25
+ useCellContext,
26
+ useHeaderContext,
27
+ useTableContext,
28
+ } = createTableHook({
29
+ features: appFeatures,
30
+ });
@@ -0,0 +1,63 @@
1
+ import type {
2
+ ColumnDef,
3
+ PaginationState,
4
+ SortingState,
5
+ } from "@tanstack/svelte-table";
6
+ import type { Snippet } from "svelte";
7
+ import type { AppFeatures } from "./table.js";
8
+
9
+ export type DataTableRow = Record<string, unknown>;
10
+
11
+ export type DataTableColumn = ColumnDef<AppFeatures, DataTableRow, unknown>;
12
+
13
+ export interface DataTablePagination {
14
+ pageIndex: number;
15
+ pageSize: number;
16
+ rowCount: number;
17
+ pageCount: number;
18
+ }
19
+
20
+ export interface DataTableSorting {
21
+ id: string | null;
22
+ desc: boolean;
23
+ }
24
+
25
+ export function toTablePaginationState(
26
+ input: Pick<DataTablePagination, "pageIndex" | "pageSize">,
27
+ ): PaginationState {
28
+ return {
29
+ pageIndex: Math.max(0, input.pageIndex),
30
+ pageSize: input.pageSize,
31
+ };
32
+ }
33
+
34
+ export function toTableSortingState(sorting: DataTableSorting): SortingState {
35
+ if (!sorting.id) return [];
36
+ return [{ id: sorting.id, desc: sorting.desc }];
37
+ }
38
+
39
+ export function fromTableSortingState(sorting: SortingState): DataTableSorting {
40
+ const first = sorting[0];
41
+ if (!first?.id) return { id: null, desc: false };
42
+ return { id: first.id, desc: first.desc === true };
43
+ }
44
+
45
+ export interface DataTableProps {
46
+ columns: DataTableColumn[];
47
+ data: DataTableRow[];
48
+ pagination: DataTablePagination;
49
+ sorting?: DataTableSorting;
50
+ onSortingChange?: (sorting: DataTableSorting) => void;
51
+ isLoading?: boolean;
52
+ emptyTitle?: string;
53
+ emptyDescription?: string;
54
+ emptyContent?: Snippet;
55
+ /** `quiet` drops the header band and the heavy rules, for tables that sit inside a panel. */
56
+ variant?: "default" | "quiet";
57
+ /** Names the table for assistive technology. */
58
+ label?: string;
59
+ /** Extra class per row, e.g. a selection highlight. */
60
+ rowClass?: (row: DataTableRow) => string;
61
+ testId?: string;
62
+ scrollTestId?: string;
63
+ }
@@ -0,0 +1,355 @@
1
+ <script lang="ts" generics="T">
2
+ import { flushSync, tick, untrack, type Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import type { EndlessAxis } from "../../utils/endless-axis.js";
5
+ import type { BucketStore } from "./bucket-store.svelte.js";
6
+ import IconArrowLeft from "~icons/mdi/arrow-left";
7
+ import IconArrowRight from "~icons/mdi/arrow-right";
8
+
9
+ /** How much of the day before and after stays in view. */
10
+ const PEEK = 12;
11
+ /** The space between two days. */
12
+ const GAP = 8;
13
+ /** Where `scrollend` is missing, this long without a scroll counts as resting. */
14
+ const QUIET = 150;
15
+
16
+ /**
17
+ * One day at a time, sideways: across moves between days, down reads one,
18
+ * and each day scrolls on its own. Every swipe ends on a day, and the days
19
+ * either side peek in. Days have a fixed width, so only the day in view and
20
+ * one either side are drawn and read.
21
+ *
22
+ * With `flow` the page scrolls the day instead (phones): the day it rests on
23
+ * is as tall as its content, and the days either side are drawn from the
24
+ * reading edge down, so a swipe from far down a day shows the next day's
25
+ * start. Landing on a day brings its start to the reading edge.
26
+ */
27
+ let {
28
+ axis,
29
+ store,
30
+ opening,
31
+ revision,
32
+ home,
33
+ flow = false,
34
+ edge,
35
+ shift = $bindable(0),
36
+ onVisibleChange,
37
+ onSettle,
38
+ item,
39
+ class: className = "",
40
+ ...attributes
41
+ }: Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
42
+ /** The keys listed, one per day. Its end may grow while the pager is open. */
43
+ axis: EndlessAxis;
44
+ store: BucketStore<T>;
45
+ /** The day the pager opens at, read once. */
46
+ opening: string;
47
+ /** Changes after a save or a sync; the days on screen then read again. */
48
+ revision?: number;
49
+ /** A day to return to. While it is off screen, a button floats at the bottom. */
50
+ home?: { key: string; label: string };
51
+ /** The page scrolls the days instead of each day scrolling itself. */
52
+ flow?: boolean;
53
+ /** With `flow`, the screen line a day's start lands on; the top of the page's scrolling area by default. */
54
+ edge?: () => number | undefined;
55
+ /** How far the list sits past the day in view, in days, from -0.5 to 0.5. */
56
+ shift?: number;
57
+ /** The day in view changed, while scrolling too. */
58
+ onVisibleChange?: (key: string) => void;
59
+ /** Scrolling came to rest with this day in view. */
60
+ onSettle?: (key: string) => void;
61
+ /** Renders a day; `data` is missing while it loads. */
62
+ item: Snippet<[key: string, data: T | undefined]>;
63
+ } = $props();
64
+
65
+ let scroller = $state<HTMLDivElement>();
66
+ let width = $state(0);
67
+ /** The scroll position, read once per frame. */
68
+ let offset = $state(0);
69
+ /** The day a scroll is on its way to; it is drawn before the scroll starts. */
70
+ let aim = $state<number | null>(null);
71
+ /** With `flow`, the day the page rests on; it alone gives the pager its height. */
72
+ let rested = $state(untrack(() => axis.indexOf(opening)));
73
+
74
+ const count = $derived(axis.count);
75
+ /** Where a day's edge settles, so the day before it peeks in. */
76
+ const inset = $derived(PEEK + GAP / 2);
77
+ const pitch = $derived(width > 0 ? Math.max(1, width - 2 * inset) : 0);
78
+
79
+ const clamp = (index: number) => Math.max(0, Math.min(count - 1, index));
80
+
81
+ const position = $derived(pitch > 0 ? offset / pitch : 0);
82
+ const visibleIndex = $derived(clamp(Math.round(position)));
83
+
84
+ /** The days drawn: the one in view and one either side. */
85
+ const range = $derived.by(() => {
86
+ if (pitch === 0) return [] as { index: number; key: string }[];
87
+ let first = clamp(Math.floor(position));
88
+ let last = clamp(Math.ceil(position));
89
+ if (aim !== null) {
90
+ first = Math.min(first, aim);
91
+ last = Math.max(last, aim);
92
+ }
93
+ first = clamp(first - 1);
94
+ last = clamp(last + 1);
95
+ const indexes = Array.from({ length: last - first + 1 }, (_, offset) => first + offset);
96
+ // The day the page rests on holds the pager's height, so it stays drawn through a far jump.
97
+ if (flow && (rested < first || rested > last)) indexes.push(rested);
98
+ return indexes.map((index) => ({ index, key: axis.keyAt(index) }));
99
+ });
100
+ const keys = $derived(range.map((day) => day.key));
101
+
102
+ $effect(() => {
103
+ void store.buckets.size;
104
+ store.ensure(keys);
105
+ });
106
+
107
+ $effect(() => {
108
+ shift = position - Math.round(position);
109
+ });
110
+
111
+ let reported = "";
112
+ $effect(() => {
113
+ if (pitch === 0) return;
114
+ const key = axis.keyAt(visibleIndex);
115
+ if (key === reported) return;
116
+ reported = key;
117
+ untrack(() => onVisibleChange?.(key));
118
+ });
119
+
120
+ /** Re-reads the days on screen after a save or a sync. */
121
+ export function refresh(): void {
122
+ store.refresh(keys);
123
+ }
124
+
125
+ let refreshed = untrack(() => revision);
126
+ $effect(() => {
127
+ const current = revision;
128
+ if (current === refreshed) return;
129
+ refreshed = current;
130
+ untrack(() => refresh());
131
+ });
132
+
133
+ // ---------------------------------------------------------------- scrolling
134
+
135
+ /** The day kept in view while the width changes. */
136
+ let anchor = untrack(() => axis.indexOf(opening));
137
+ let aligning = false;
138
+
139
+ /** With `flow`, how far the page had scrolled past the pager's top when a swipe began. */
140
+ let lift = $state(0);
141
+ let swiping = false;
142
+
143
+ function page(): HTMLElement | null {
144
+ return scroller?.closest<HTMLElement>("[data-stor-main]") ?? null;
145
+ }
146
+
147
+ /** Draws the days either side from the reading edge, before a swipe or a jump moves across. */
148
+ function beginSwipe(): void {
149
+ if (!flow || swiping || !scroller) return;
150
+ swiping = true;
151
+ const main = page();
152
+ const line = edge?.() ?? (main ? main.getBoundingClientRect().top + parseFloat(getComputedStyle(main).paddingTop) : 0);
153
+ lift = Math.max(0, Math.round(line - scroller.getBoundingClientRect().top));
154
+ }
155
+
156
+ /** The day landed on takes the pager's height, and the page brings its start to the reading edge. */
157
+ function endSwipe(): void {
158
+ if (!flow) return;
159
+ swiping = false;
160
+ const main = page();
161
+ // The new day was drawn `lift` lower: scroll back by as much before it moves up, so nothing jumps.
162
+ // Scrolling first, while the old day still holds the height, keeps a shorter day from clamping the page.
163
+ if (main && lift > 0 && rested !== visibleIndex) main.scrollTop -= lift;
164
+ flushSync(() => {
165
+ rested = visibleIndex;
166
+ lift = 0;
167
+ });
168
+ }
169
+
170
+ function reducedMotion(): boolean {
171
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
172
+ }
173
+
174
+ /** Scrolls to a day. */
175
+ export function scrollTo(key: string, behavior: ScrollBehavior = "auto"): void {
176
+ if (!scroller || pitch === 0) return;
177
+ const index = axis.indexOf(key);
178
+ anchor = index;
179
+ if (Math.abs(index * pitch - scroller.scrollLeft) < 1) return;
180
+ beginSwipe();
181
+ // A far day has no element yet, and a snapping scroll needs one to land on.
182
+ aim = index;
183
+ void tick().then(() => {
184
+ if (aim !== index || !scroller) return;
185
+ scroller.scrollTo({ left: index * pitch, behavior: reducedMotion() ? "auto" : behavior });
186
+ });
187
+ }
188
+
189
+ // Opening, and every change of width, puts the anchored day back in view.
190
+ $effect.pre(() => {
191
+ const step = pitch;
192
+ void flow;
193
+ if (!scroller || step === 0) return;
194
+ untrack(() => {
195
+ // Resizing and changing the scrolling layout can snap to a neighbour
196
+ // before the anchored day's new geometry has reached the DOM.
197
+ aligning = true;
198
+ cancelAnimationFrame(frame);
199
+ clearTimeout(quiet);
200
+ scroller.style.scrollSnapType = "none";
201
+ // Draw the days there first: a snapping scroll lands only on a day that exists.
202
+ const target = anchor * step;
203
+ offset = target;
204
+ rested = anchor;
205
+ void tick().then(() => {
206
+ if (!scroller || step !== pitch) return;
207
+ scroller.scrollLeft = target;
208
+ offset = scroller.scrollLeft;
209
+ scroller.style.scrollSnapType = "";
210
+ aligning = false;
211
+ });
212
+ });
213
+ });
214
+
215
+ let frame = 0;
216
+ let quiet: ReturnType<typeof setTimeout> | undefined;
217
+
218
+ function handleScroll(): void {
219
+ if (aligning) return;
220
+ beginSwipe();
221
+ cancelAnimationFrame(frame);
222
+ frame = requestAnimationFrame(() => {
223
+ if (!scroller) return;
224
+ offset = scroller.scrollLeft;
225
+ if (aim === null && pitch > 0) anchor = visibleIndex;
226
+ });
227
+ if (!("onscrollend" in window)) {
228
+ clearTimeout(quiet);
229
+ quiet = setTimeout(settle, QUIET);
230
+ }
231
+ }
232
+
233
+ function settle(): void {
234
+ if (aligning || !scroller || pitch === 0) return;
235
+ offset = scroller.scrollLeft;
236
+ aim = null;
237
+ anchor = visibleIndex;
238
+ endSwipe();
239
+ // A day that has left the screen starts at its top when it comes back.
240
+ for (const column of scroller.querySelectorAll<HTMLElement>("[data-pager-index]")) {
241
+ if (Number(column.dataset.pagerIndex) !== visibleIndex && column.scrollTop !== 0) column.scrollTop = 0;
242
+ }
243
+ onSettle?.(axis.keyAt(visibleIndex));
244
+ }
245
+
246
+ // Opening on a day counts as resting there, so the address follows a day the opening had to move.
247
+ let settledOnce = false;
248
+ $effect(() => {
249
+ if (pitch === 0 || settledOnce) return;
250
+ settledOnce = true;
251
+ untrack(() => onSettle?.(axis.keyAt(visibleIndex)));
252
+ });
253
+
254
+ const homeIndex = $derived(home ? axis.indexOf(home.key) : null);
255
+ const homeAway = $derived(
256
+ homeIndex === null || pitch === 0 ? null
257
+ : homeIndex < visibleIndex ? "left"
258
+ : homeIndex > visibleIndex ? "right"
259
+ : null,
260
+ );
261
+
262
+ $effect(() => () => {
263
+ cancelAnimationFrame(frame);
264
+ clearTimeout(quiet);
265
+ });
266
+ </script>
267
+
268
+ <div class="pager" class:flow>
269
+ <div
270
+ {...attributes}
271
+ bind:this={scroller}
272
+ bind:clientWidth={width}
273
+ class={["scroller", className]}
274
+ style:scroll-padding-inline="{inset}px"
275
+ onscroll={handleScroll}
276
+ onscrollend={settle}
277
+ >
278
+ <div class="rail" style:width="{2 * inset + count * pitch}px">
279
+ {#each range as day (day.key)}
280
+ <div
281
+ class="column"
282
+ class:rested={flow && day.index === rested}
283
+ style:left={flow && day.index === rested ? undefined : `${inset + day.index * pitch}px`}
284
+ style:margin-left={flow && day.index === rested ? `${inset + day.index * pitch}px` : undefined}
285
+ style:top={flow && day.index !== rested ? `${lift}px` : undefined}
286
+ style:width="{pitch}px"
287
+ style:padding-inline="{GAP / 2}px"
288
+ data-pager-index={day.index}
289
+ data-days-scroll
290
+ >
291
+ {@render item(day.key, store.get(day.key))}
292
+ </div>
293
+ {/each}
294
+ </div>
295
+ </div>
296
+ {#if home && homeAway}
297
+ <button type="button" class="home-jump" onclick={() => home && scrollTo(home.key, "smooth")}>
298
+ {#if homeAway === "left"}<IconArrowLeft class="h-4 w-4" />{home.label}{:else}{home.label}<IconArrowRight class="h-4 w-4" />{/if}
299
+ </button>
300
+ {/if}
301
+ </div>
302
+
303
+ <style>
304
+ .pager { position: relative; height: 100%; }
305
+ .scroller {
306
+ position: relative; height: 100%; overflow-x: auto; overflow-y: hidden;
307
+ overscroll-behavior-x: contain; scrollbar-width: none;
308
+ }
309
+ .scroller::-webkit-scrollbar { display: none; }
310
+ .scroller { scroll-snap-type: x mandatory; }
311
+ .rail { position: relative; height: 100%; }
312
+ .column {
313
+ position: absolute; top: 0; bottom: 0; overflow-x: hidden; overflow-y: auto;
314
+ overscroll-behavior-y: contain; scrollbar-width: none;
315
+ }
316
+ .column::-webkit-scrollbar { display: none; }
317
+ .column { scroll-snap-align: start; scroll-snap-stop: always; }
318
+ /* The page scrolls: nothing here has a height of its own but the day it rests on. */
319
+ .flow, .flow .scroller, .flow .rail { height: auto; }
320
+ .flow .column { bottom: auto; overflow: visible; overflow-x: clip; }
321
+ .flow .column.rested { position: relative; }
322
+ .home-jump {
323
+ position: absolute;
324
+ bottom: 1rem;
325
+ left: 50%;
326
+ z-index: 3;
327
+ display: inline-flex;
328
+ height: 2.25rem;
329
+ align-items: center;
330
+ gap: 0.25rem;
331
+ padding: 0 0.75rem;
332
+ border: 0;
333
+ border-radius: 999px;
334
+ background: var(--stor-surface-raised);
335
+ box-shadow: var(--stor-shadow-md);
336
+ font-size: 13px;
337
+ font-weight: 600;
338
+ color: var(--stor-ink);
339
+ transform: translateX(-50%);
340
+ }
341
+ /* Paged by the page, the way back floats at the screen's bottom-left, level with the page's action. */
342
+ .flow .home-jump {
343
+ position: fixed;
344
+ left: 12px;
345
+ bottom: calc(var(--stor-mobile-bottom-inset) + var(--stor-mobile-nav-height) + var(--stor-mobile-stack-gap));
346
+ z-index: 40;
347
+ height: var(--stor-mobile-action-height);
348
+ padding: 0 1rem;
349
+ font-size: 14px;
350
+ transform: none;
351
+ }
352
+ @media (min-width: 768px) { .flow .home-jump { left: 116px; } }
353
+ .home-jump:hover { background: var(--stor-surface-hover); }
354
+ .home-jump:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: 2px; }
355
+ </style>