@viniaraujo68/plinth 0.0.0-stage → 0.2.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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +361 -2
  3. package/dist/anchoring.d.ts +61 -0
  4. package/dist/anchoring.js +151 -0
  5. package/dist/async-action.svelte.d.ts +37 -0
  6. package/dist/async-action.svelte.js +64 -0
  7. package/dist/attachments/index.d.ts +1 -0
  8. package/dist/attachments/index.js +1 -0
  9. package/dist/attachments/tooltip.d.ts +37 -0
  10. package/dist/attachments/tooltip.js +166 -0
  11. package/dist/components/AsyncButton.svelte +49 -0
  12. package/dist/components/AsyncButton.svelte.d.ts +32 -0
  13. package/dist/components/Combobox.svelte +380 -0
  14. package/dist/components/Combobox.svelte.d.ts +74 -0
  15. package/dist/components/Copyable.svelte +116 -0
  16. package/dist/components/Copyable.svelte.d.ts +37 -0
  17. package/dist/components/DateRangePicker.svelte +266 -0
  18. package/dist/components/DateRangePicker.svelte.d.ts +66 -0
  19. package/dist/components/Dialog.svelte +102 -0
  20. package/dist/components/Dialog.svelte.d.ts +38 -0
  21. package/dist/components/Dropdown.svelte +204 -0
  22. package/dist/components/Dropdown.svelte.d.ts +49 -0
  23. package/dist/components/ErrorDisplay.svelte +59 -0
  24. package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
  25. package/dist/components/ErrorPage.svelte +119 -0
  26. package/dist/components/ErrorPage.svelte.d.ts +52 -0
  27. package/dist/components/JsonInput.svelte +106 -0
  28. package/dist/components/JsonInput.svelte.d.ts +40 -0
  29. package/dist/components/Listbox.svelte +257 -0
  30. package/dist/components/Listbox.svelte.d.ts +78 -0
  31. package/dist/components/LoadingButton.svelte +36 -0
  32. package/dist/components/LoadingButton.svelte.d.ts +22 -0
  33. package/dist/components/Logo.svelte +112 -0
  34. package/dist/components/Logo.svelte.d.ts +29 -0
  35. package/dist/components/Modal.svelte +187 -0
  36. package/dist/components/Modal.svelte.d.ts +62 -0
  37. package/dist/components/MultiSelect.svelte +256 -0
  38. package/dist/components/MultiSelect.svelte.d.ts +89 -0
  39. package/dist/components/OptionPanel.svelte +241 -0
  40. package/dist/components/OptionPanel.svelte.d.ts +51 -0
  41. package/dist/components/Paginator.svelte +205 -0
  42. package/dist/components/Paginator.svelte.d.ts +48 -0
  43. package/dist/components/RefreshButton.svelte +246 -0
  44. package/dist/components/RefreshButton.svelte.d.ts +46 -0
  45. package/dist/components/SegmentedControl.svelte +147 -0
  46. package/dist/components/SegmentedControl.svelte.d.ts +71 -0
  47. package/dist/components/Select.svelte +312 -0
  48. package/dist/components/Select.svelte.d.ts +68 -0
  49. package/dist/components/Skeleton.svelte +97 -0
  50. package/dist/components/Skeleton.svelte.d.ts +32 -0
  51. package/dist/components/Tooltip.svelte +60 -0
  52. package/dist/components/Tooltip.svelte.d.ts +58 -0
  53. package/dist/components/date-range.d.ts +101 -0
  54. package/dist/components/date-range.js +144 -0
  55. package/dist/components/index.d.ts +25 -0
  56. package/dist/components/index.js +37 -0
  57. package/dist/components/json-text.d.ts +22 -0
  58. package/dist/components/json-text.js +22 -0
  59. package/dist/components/pick.d.ts +18 -0
  60. package/dist/components/pick.js +18 -0
  61. package/dist/components/segmented.d.ts +7 -0
  62. package/dist/components/segmented.js +1 -0
  63. package/dist/components/select.d.ts +66 -0
  64. package/dist/components/select.js +90 -0
  65. package/dist/confirm/Confirmer.svelte +194 -0
  66. package/dist/confirm/Confirmer.svelte.d.ts +45 -0
  67. package/dist/confirm/challenge.d.ts +16 -0
  68. package/dist/confirm/challenge.js +16 -0
  69. package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
  70. package/dist/confirm/confirm-manager.svelte.js +149 -0
  71. package/dist/confirm/index.d.ts +3 -0
  72. package/dist/confirm/index.js +3 -0
  73. package/dist/error.d.ts +25 -0
  74. package/dist/error.js +39 -0
  75. package/dist/formatters.d.ts +95 -0
  76. package/dist/formatters.js +146 -0
  77. package/dist/http.d.ts +68 -0
  78. package/dist/http.js +138 -0
  79. package/dist/index.d.ts +10 -0
  80. package/dist/index.js +14 -0
  81. package/dist/routing.svelte.d.ts +169 -0
  82. package/dist/routing.svelte.js +208 -0
  83. package/dist/shell/AppShell.svelte +874 -0
  84. package/dist/shell/AppShell.svelte.d.ts +93 -0
  85. package/dist/shell/Breadcrumbs.svelte +78 -0
  86. package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
  87. package/dist/shell/crumbs.d.ts +23 -0
  88. package/dist/shell/crumbs.js +1 -0
  89. package/dist/shell/index.d.ts +3 -0
  90. package/dist/shell/index.js +6 -0
  91. package/dist/table/ColumnMenu.svelte +249 -0
  92. package/dist/table/ColumnMenu.svelte.d.ts +47 -0
  93. package/dist/table/DataTable.svelte +643 -0
  94. package/dist/table/DataTable.svelte.d.ts +153 -0
  95. package/dist/table/columns.d.ts +55 -0
  96. package/dist/table/columns.js +153 -0
  97. package/dist/table/index.d.ts +4 -0
  98. package/dist/table/index.js +8 -0
  99. package/dist/table/sort.d.ts +48 -0
  100. package/dist/table/sort.js +139 -0
  101. package/dist/table/types.d.ts +93 -0
  102. package/dist/table/types.js +1 -0
  103. package/dist/theme/ThemeController.svelte +54 -0
  104. package/dist/theme/ThemeController.svelte.d.ts +27 -0
  105. package/dist/theme/ThemeToggle.svelte +119 -0
  106. package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
  107. package/dist/theme/css-color.d.ts +23 -0
  108. package/dist/theme/css-color.js +31 -0
  109. package/dist/theme/index.d.ts +4 -0
  110. package/dist/theme/index.js +5 -0
  111. package/dist/theme/theme.css +410 -0
  112. package/dist/theme/theme.svelte.d.ts +54 -0
  113. package/dist/theme/theme.svelte.js +58 -0
  114. package/dist/toast/Toaster.svelte +257 -0
  115. package/dist/toast/Toaster.svelte.d.ts +35 -0
  116. package/dist/toast/index.d.ts +2 -0
  117. package/dist/toast/index.js +2 -0
  118. package/dist/toast/toast-manager.svelte.d.ts +99 -0
  119. package/dist/toast/toast-manager.svelte.js +176 -0
  120. package/dist/user/ScopedComponent.svelte +47 -0
  121. package/dist/user/ScopedComponent.svelte.d.ts +29 -0
  122. package/dist/user/anonymous.d.ts +21 -0
  123. package/dist/user/anonymous.js +26 -0
  124. package/dist/user/context.d.ts +55 -0
  125. package/dist/user/context.js +13 -0
  126. package/dist/user/index.d.ts +3 -0
  127. package/dist/user/index.js +6 -0
  128. package/package.json +133 -4
@@ -0,0 +1,241 @@
1
+ <script lang="ts">
2
+ import { flushSync, onDestroy, type Snippet } from "svelte";
3
+ import type { ClassValue } from "svelte/elements";
4
+ import { positionUnder, supportsAnchorPositioning } from "../anchoring.js";
5
+ import type { SelectOption } from "./select.js";
6
+
7
+ /** `margin-block: 0.25rem` below, in pixels: the gap the anchored path puts under the control. */
8
+ const GAP = 4;
9
+
10
+ interface Props {
11
+ /** Document-unique id of the popover. `Select` names it from the trigger's `popovertarget`. */
12
+ id: string;
13
+ /** The `anchor-name` the control declares, which is what the panel hangs itself under. */
14
+ anchorName: string;
15
+ /**
16
+ * The control itself. Read only where the browser has no anchor positioning and the panel has
17
+ * to be placed by script — measuring it is the one thing {@link Props.anchorName} cannot do.
18
+ */
19
+ anchor?: HTMLElement;
20
+ listboxId: string;
21
+ /** Id of the control the listbox borrows its accessible name from. */
22
+ labelledBy: string;
23
+ /** The rows to render — already filtered, in the order they are shown. */
24
+ options: readonly SelectOption[];
25
+ /** The selected value, marked with a ✓ and `aria-selected`. */
26
+ value: string | null;
27
+ /** Index into `options`, or `-1`. The control owns it: the arrows move it, Enter reads it. */
28
+ highlighted: number;
29
+ /** Row ids, so the control can point `aria-activedescendant` at the same element. */
30
+ optionId: (index: number) => string;
31
+ option?: Snippet<[SelectOption]>;
32
+ /** Shown in place of the list when `options` is empty. Nothing is rendered without one. */
33
+ emptyLabel?: string;
34
+ class?: ClassValue;
35
+ /**
36
+ * Open and close, reported from `beforetoggle` rather than from `toggle`: it is synchronous,
37
+ * so a control can settle its own state — the highlight, the text a `Combobox` puts back —
38
+ * in the same task the panel changes state, with no frame in between showing the other one.
39
+ */
40
+ onopenchange: (open: boolean) => void;
41
+ onhighlight: (value: string) => void;
42
+ onchoose: (option: SelectOption) => void;
43
+ }
44
+
45
+ let {
46
+ id,
47
+ anchorName,
48
+ anchor,
49
+ listboxId,
50
+ labelledBy,
51
+ options,
52
+ value,
53
+ highlighted,
54
+ optionId,
55
+ option,
56
+ emptyLabel,
57
+ class: className,
58
+ onopenchange,
59
+ onhighlight,
60
+ onchoose,
61
+ }: Props = $props();
62
+
63
+ let panel = $state<HTMLDivElement>();
64
+ let isMounted = $state(false);
65
+
66
+ let stopPositioning: (() => void) | undefined;
67
+
68
+ /* Without anchor positioning the placement rules at the bottom of this file are dropped and
69
+ the UA popover ones are all that is left -- which, with the `margin-block` below overriding
70
+ half of their `margin: auto`, pins the panel to the top of the screen at an intrinsic width.
71
+ Script places it instead, to the same geometry: under the control, as wide as it, a hair
72
+ below it, and above it when there is no room. */
73
+ const reposition = () => {
74
+ stopPositioning?.();
75
+ stopPositioning = undefined;
76
+ if (!panel || !anchor || supportsAnchorPositioning()) return;
77
+
78
+ stopPositioning = positionUnder(panel, anchor, { gap: GAP, matchWidth: true });
79
+ };
80
+
81
+ const releasePositioning = () => {
82
+ stopPositioning?.();
83
+ stopPositioning = undefined;
84
+ };
85
+
86
+ // A panel left open when its control is destroyed would otherwise leave the reposition
87
+ // listeners on the window, holding a detached element.
88
+ onDestroy(releasePositioning);
89
+
90
+ /** Shows the panel. For a control that is not a `popovertarget` invoker — a `Combobox` input. */
91
+ export function show() {
92
+ isMounted = true;
93
+ if (panel && !panel.matches(":popover-open")) panel.showPopover();
94
+ // Ahead of the `toggle` event, which arrives a task later: the panel is on screen from this
95
+ // line onwards, so anything but a synchronous placement is a painted frame in the wrong place.
96
+ reposition();
97
+ }
98
+
99
+ export function hide() {
100
+ panel?.hidePopover();
101
+ }
102
+
103
+ export function isShown() {
104
+ return panel?.matches(":popover-open") ?? false;
105
+ }
106
+
107
+ // The highlight is an attribute on the control, not a focus ring the browser scrolls to on its
108
+ // own, so keeping it on screen is ours to do. `nearest` is what makes it a nudge of the list
109
+ // rather than a jump of the page.
110
+ $effect(() => {
111
+ if (!isMounted || highlighted < 0) return;
112
+
113
+ document.getElementById(optionId(highlighted))?.scrollIntoView({
114
+ block: "nearest",
115
+ inline: "nearest",
116
+ });
117
+ });
118
+
119
+ /* A hidden popover stops rendering entirely, which freezes any exit transition mid-flight and
120
+ means its `transitionend` never arrives. Asking the element whether it is actually visible is
121
+ the only reading that holds for both an animated and an instant close. */
122
+ const unmountWhenHidden = () => {
123
+ if (panel && !panel.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
124
+ isMounted = false;
125
+ };
126
+ /* `transitionend` bubbles, so a row's own transition would reach the panel and ask whether to
127
+ unmount the list while it is plainly open. Only the panel's transitions can be its exit. */
128
+ const onTransitionSettled = (event: TransitionEvent) => {
129
+ if (event.target === event.currentTarget) unmountWhenHidden();
130
+ };
131
+
132
+ const onBeforeToggle = (event: ToggleEvent) => {
133
+ const open = event.newState === "open";
134
+ onopenchange(open);
135
+ if (!open) return;
136
+
137
+ isMounted = true;
138
+ // The browser shows the popover in this same task, so the rows have to exist before the
139
+ // handler returns -- otherwise the first painted frame is an empty panel.
140
+ flushSync();
141
+ };
142
+
143
+ const onToggle = (event: ToggleEvent) => {
144
+ if (event.newState === "open") {
145
+ isMounted = true;
146
+ reposition();
147
+ } else {
148
+ releasePositioning();
149
+ unmountWhenHidden();
150
+ }
151
+ };
152
+ </script>
153
+
154
+ <!--
155
+ @component
156
+ Internal. The top-layer list `Select` and `Combobox` both drop under their control: the popover
157
+ element, the rows, the highlight's scrolling and the mount bookkeeping. Neither the query nor the
158
+ selection lives here — the control owns both and hands down what to draw.
159
+
160
+ Not exported from the package: it is the shared half of two components, not a third one.
161
+ -->
162
+
163
+ <div
164
+ bind:this={panel}
165
+ popover
166
+ {id}
167
+ class={[
168
+ "flex flex-col overflow-hidden rounded-box border border-base-content/10 bg-base-100 shadow-lg",
169
+ className,
170
+ ]}
171
+ style:position-anchor={anchorName}
172
+ onbeforetoggle={onBeforeToggle}
173
+ ontoggle={onToggle}
174
+ ontransitionend={onTransitionSettled}
175
+ ontransitioncancel={onTransitionSettled}
176
+ >
177
+ {#if isMounted}
178
+ <div
179
+ id={listboxId}
180
+ role="listbox"
181
+ aria-labelledby={labelledBy}
182
+ class="max-h-64 overflow-y-auto overscroll-contain p-1"
183
+ >
184
+ {#each options as candidate, index (candidate.value)}
185
+ <button
186
+ type="button"
187
+ id={optionId(index)}
188
+ role="option"
189
+ tabindex="-1"
190
+ disabled={candidate.disabled}
191
+ aria-selected={candidate.value === value}
192
+ aria-disabled={candidate.disabled}
193
+ class={[
194
+ "flex min-h-11 w-full cursor-pointer items-center gap-2 rounded-field px-3 py-2 text-start text-sm",
195
+ // A tint of the foreground rather than a step of the surface: base-200 is lighter than
196
+ // base-100 in a light theme and darker in a dark one, so a surface step reads as
197
+ // "raised" in one and "recessed" in the other.
198
+ index === highlighted && "bg-base-content/10",
199
+ candidate.disabled && "cursor-not-allowed opacity-40",
200
+ ]}
201
+ onmousedown={(event) => event.preventDefault()}
202
+ onpointermove={() => onhighlight(candidate.value)}
203
+ onclick={() => onchoose(candidate)}
204
+ >
205
+ <span class="min-w-0 flex-1 truncate">
206
+ {#if option}{@render option(candidate)}{:else}{candidate.label}{/if}
207
+ </span>
208
+ <span class="flex-none text-xs" aria-hidden="true">
209
+ {candidate.value === value ? "✓" : ""}
210
+ </span>
211
+ </button>
212
+ {/each}
213
+
214
+ {#if options.length === 0 && emptyLabel !== undefined}
215
+ <p class="px-3 py-4 text-center text-sm text-base-content/60">{emptyLabel}</p>
216
+ {/if}
217
+ </div>
218
+ {/if}
219
+ </div>
220
+
221
+ <style>
222
+ [popover] {
223
+ position-area: bottom;
224
+ position-try: top;
225
+ /* The panel is the control's list, so it is the control's width -- an option that fits the
226
+ closed control fits the open one. */
227
+ width: anchor-size(width);
228
+ /* Overriding the UA's `margin: auto`, which would otherwise centre the panel in the whole
229
+ region below the anchor instead of hanging it just under the control. The inline margins
230
+ stay auto so the panel keeps sitting over the anchor. */
231
+ margin-block: 0.25rem;
232
+ }
233
+
234
+ /* A closed popover is hidden by a rule in the UA stylesheet, which every author rule outranks --
235
+ including any `display` a utility class on the panel might carry. Restating the rule here puts
236
+ it at a specificity a utility cannot reach, while leaving an exit transition possible for a
237
+ caller who asks for one with `transition-behavior: allow-discrete`. */
238
+ [popover]:not(:popover-open) {
239
+ display: none;
240
+ }
241
+ </style>
@@ -0,0 +1,51 @@
1
+ import { type Snippet } from "svelte";
2
+ import type { ClassValue } from "svelte/elements";
3
+ import type { SelectOption } from "./select.js";
4
+ interface Props {
5
+ /** Document-unique id of the popover. `Select` names it from the trigger's `popovertarget`. */
6
+ id: string;
7
+ /** The `anchor-name` the control declares, which is what the panel hangs itself under. */
8
+ anchorName: string;
9
+ /**
10
+ * The control itself. Read only where the browser has no anchor positioning and the panel has
11
+ * to be placed by script — measuring it is the one thing {@link Props.anchorName} cannot do.
12
+ */
13
+ anchor?: HTMLElement;
14
+ listboxId: string;
15
+ /** Id of the control the listbox borrows its accessible name from. */
16
+ labelledBy: string;
17
+ /** The rows to render — already filtered, in the order they are shown. */
18
+ options: readonly SelectOption[];
19
+ /** The selected value, marked with a ✓ and `aria-selected`. */
20
+ value: string | null;
21
+ /** Index into `options`, or `-1`. The control owns it: the arrows move it, Enter reads it. */
22
+ highlighted: number;
23
+ /** Row ids, so the control can point `aria-activedescendant` at the same element. */
24
+ optionId: (index: number) => string;
25
+ option?: Snippet<[SelectOption]>;
26
+ /** Shown in place of the list when `options` is empty. Nothing is rendered without one. */
27
+ emptyLabel?: string;
28
+ class?: ClassValue;
29
+ /**
30
+ * Open and close, reported from `beforetoggle` rather than from `toggle`: it is synchronous,
31
+ * so a control can settle its own state — the highlight, the text a `Combobox` puts back —
32
+ * in the same task the panel changes state, with no frame in between showing the other one.
33
+ */
34
+ onopenchange: (open: boolean) => void;
35
+ onhighlight: (value: string) => void;
36
+ onchoose: (option: SelectOption) => void;
37
+ }
38
+ /**
39
+ * Internal. The top-layer list `Select` and `Combobox` both drop under their control: the popover
40
+ * element, the rows, the highlight's scrolling and the mount bookkeeping. Neither the query nor the
41
+ * selection lives here — the control owns both and hands down what to draw.
42
+ *
43
+ * Not exported from the package: it is the shared half of two components, not a third one.
44
+ */
45
+ declare const OptionPanel: import("svelte").Component<Props, {
46
+ show: () => void;
47
+ hide: () => void;
48
+ isShown: () => boolean;
49
+ }, "">;
50
+ type OptionPanel = ReturnType<typeof OptionPanel>;
51
+ export default OptionPanel;
@@ -0,0 +1,205 @@
1
+ <script lang="ts">
2
+ import type { ClassValue } from "svelte/elements";
3
+
4
+ /** The server answered with a count: the last page is reachable and the summary is exact. */
5
+ interface CountedPages {
6
+ totalPages: number;
7
+ totalItems?: number;
8
+ hasNextPage?: never;
9
+ }
10
+
11
+ /** Keyset or "one row too many" pagination: only the existence of a next page is known. */
12
+ interface UncountedPages {
13
+ hasNextPage: boolean;
14
+ totalPages?: never;
15
+ totalItems?: never;
16
+ }
17
+
18
+ type Props = {
19
+ class?: ClassValue;
20
+ /** One-based. */
21
+ pageNumber: number;
22
+ pageSize?: number;
23
+ /** Sizes offered by the picker. */
24
+ pageSizes?: number[];
25
+ /**
26
+ * Fires when the user picks a different page. Never fires for a size change — that is a
27
+ * separate event with a separate payload ({@link Props.onPageSizeChange}).
28
+ */
29
+ onPageChange?: (page: number) => void;
30
+ /**
31
+ * Fires when the user picks a new page size. The consumer owns the state: both the refetch
32
+ * and the reset back to page 1, since a new size invalidates the old page arithmetic.
33
+ */
34
+ onPageSizeChange?: (size: number) => void;
35
+ } & (CountedPages | UncountedPages);
36
+
37
+ let {
38
+ class: cls,
39
+ pageNumber,
40
+ pageSize,
41
+ pageSizes = [10, 20, 50, 100],
42
+ onPageChange,
43
+ onPageSizeChange,
44
+ totalPages,
45
+ totalItems,
46
+ hasNextPage,
47
+ }: Props = $props();
48
+
49
+ const range = (start: number, end: number): number[] =>
50
+ Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => start + i);
51
+
52
+ const canGoNext = $derived(
53
+ hasNextPage ?? (totalPages !== undefined && totalPages > 0 && pageNumber < totalPages),
54
+ );
55
+
56
+ const [firstShown, lastShown] = $derived(
57
+ pageSize === undefined || totalItems === undefined
58
+ ? [undefined, undefined]
59
+ : [(pageNumber - 1) * pageSize + 1, Math.min(pageNumber * pageSize, totalItems)],
60
+ );
61
+
62
+ const setPage = (page: number) => {
63
+ const clamped = Math.max(1, totalPages === undefined ? page : Math.min(page, totalPages));
64
+
65
+ // Clicking the current page, or an arrow at a bound, is not a change — staying silent here is
66
+ // what keeps a consumer from refetching the page it is already showing.
67
+ if (clamped === pageNumber) return;
68
+
69
+ onPageChange?.(clamped);
70
+ };
71
+
72
+ // `null` is the ellipsis. Around the current page the window is always the same width, so the
73
+ // buttons under the cursor do not shift as the user walks through the pages.
74
+ const pages = $derived.by((): (number | null)[] => {
75
+ if (totalPages === undefined) {
76
+ const leading =
77
+ pageNumber <= 3 ? range(1, pageNumber) : [1, null, pageNumber - 1, pageNumber];
78
+ return hasNextPage ? [...leading, pageNumber + 1, null] : leading;
79
+ }
80
+
81
+ if (totalPages <= 5) return range(1, totalPages);
82
+ if (pageNumber < 4) return [...range(1, 5), null, totalPages];
83
+ if (pageNumber > totalPages - 4) return [1, null, ...range(totalPages - 4, totalPages)];
84
+
85
+ return [1, null, pageNumber - 1, pageNumber, pageNumber + 1, null, totalPages];
86
+ });
87
+ </script>
88
+
89
+ <!--
90
+ @component
91
+ Page selector for a list, in two flavours picked by which props are passed: `totalPages`
92
+ (optionally with `totalItems`, which turns on the size picker and the "showing x to y" summary),
93
+ or `hasNextPage` alone when the backend only knows whether one more page exists. The two are
94
+ mutually exclusive in the type, so a partially-wired paginator is a compile error.
95
+
96
+ It holds no state: `pageNumber` comes in, `onPageChange` goes out, and the consumer decides what
97
+ that means. Sizing is in `em`, so the whole strip scales with the font size around it.
98
+
99
+ ```svelte
100
+ <Paginator pageNumber={page} totalPages={data.pages} totalItems={data.count}
101
+ pageSize={size} onPageChange={(p) => (page = p)} onPageSizeChange={(s) => { size = s; page = 1; }} />
102
+ ```
103
+ -->
104
+
105
+ {#snippet chevron(direction: "left" | "right")}
106
+ <svg
107
+ class="size-[1em]"
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ stroke-width="2"
112
+ stroke-linecap="round"
113
+ stroke-linejoin="round"
114
+ aria-hidden="true"
115
+ >
116
+ <path d={direction === "left" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"} />
117
+ </svg>
118
+ {/snippet}
119
+
120
+ {#snippet pageButton(page: number)}
121
+ <button
122
+ type="button"
123
+ class="btn h-[1.75em] min-w-[1.75em] px-2 tabular-nums btn-primary aria-[current=false]:btn-ghost"
124
+ onclick={() => setPage(page)}
125
+ aria-current={page === pageNumber}
126
+ aria-label={`Page ${page}`}
127
+ >
128
+ {page}
129
+ </button>
130
+ {/snippet}
131
+
132
+ {#snippet pageSelector()}
133
+ <button
134
+ type="button"
135
+ class="btn size-[1.75em] btn-ghost px-1"
136
+ aria-label="Previous page"
137
+ disabled={pageNumber <= 1}
138
+ onclick={() => setPage(pageNumber - 1)}
139
+ >
140
+ {@render chevron("left")}
141
+ </button>
142
+
143
+ <!-- Keyed by position: the list is a pure projection of `pageNumber`, and the ellipsis entries
144
+ are indistinguishable from one another by value. -->
145
+ {#each pages as page, slot (slot)}
146
+ {#if page === null}
147
+ <span class="mx-2 inline-block text-center" aria-hidden="true">...</span>
148
+ {:else}
149
+ {@render pageButton(page)}
150
+ {/if}
151
+ {/each}
152
+
153
+ <button
154
+ type="button"
155
+ class="btn size-[1.75em] btn-ghost px-1"
156
+ aria-label="Next page"
157
+ disabled={!canGoNext}
158
+ onclick={() => setPage(pageNumber + 1)}
159
+ >
160
+ {@render chevron("right")}
161
+ </button>
162
+ {/snippet}
163
+
164
+ {#if totalItems === undefined || pageSize === undefined}
165
+ <nav class={["flex place-content-center gap-px", cls]} aria-label="Pagination">
166
+ {@render pageSelector()}
167
+ </nav>
168
+ {:else}
169
+ <nav class={["@container", cls]} aria-label="Pagination">
170
+ <div class="grid items-center justify-items-center gap-1 @min-[45rem]:grid-cols-3">
171
+ <!-- Narrow, the summary and the size picker fall below the buttons: the control the user
172
+ came for stays at the top of the block rather than being pushed off by chrome. -->
173
+ <div class="flex items-center gap-1 @max-[45rem]:order-last @min-[45rem]:mr-auto">
174
+ <span class="text-nowrap">Items per page:</span>
175
+ <select
176
+ class="select box-content w-[3ch] select-xs"
177
+ aria-label="Items per page"
178
+ value={pageSize}
179
+ onchange={({ currentTarget }) => onPageSizeChange?.(Number(currentTarget.value))}
180
+ >
181
+ {#each pageSizes as size (size)}
182
+ <option value={size}>{size}</option>
183
+ {/each}
184
+ </select>
185
+ </div>
186
+
187
+ <div class="text-center tabular-nums" data-testid="paginator-summary">
188
+ Showing {firstShown} to {lastShown} of {totalItems} items
189
+ </div>
190
+
191
+ <div class="flex items-center gap-px @max-[45rem]:order-first @min-[45rem]:ml-auto">
192
+ {@render pageSelector()}
193
+ </div>
194
+ </div>
195
+ </nav>
196
+ {/if}
197
+
198
+ <style>
199
+ /* The strip sizes itself in `em`, so every control has to inherit the surrounding font size
200
+ rather than fall back to the browser default for form elements. */
201
+ button,
202
+ span {
203
+ font-size: inherit;
204
+ }
205
+ </style>
@@ -0,0 +1,48 @@
1
+ import type { ClassValue } from "svelte/elements";
2
+ /** The server answered with a count: the last page is reachable and the summary is exact. */
3
+ interface CountedPages {
4
+ totalPages: number;
5
+ totalItems?: number;
6
+ hasNextPage?: never;
7
+ }
8
+ /** Keyset or "one row too many" pagination: only the existence of a next page is known. */
9
+ interface UncountedPages {
10
+ hasNextPage: boolean;
11
+ totalPages?: never;
12
+ totalItems?: never;
13
+ }
14
+ type Props = {
15
+ class?: ClassValue;
16
+ /** One-based. */
17
+ pageNumber: number;
18
+ pageSize?: number;
19
+ /** Sizes offered by the picker. */
20
+ pageSizes?: number[];
21
+ /**
22
+ * Fires when the user picks a different page. Never fires for a size change — that is a
23
+ * separate event with a separate payload ({@link Props.onPageSizeChange}).
24
+ */
25
+ onPageChange?: (page: number) => void;
26
+ /**
27
+ * Fires when the user picks a new page size. The consumer owns the state: both the refetch
28
+ * and the reset back to page 1, since a new size invalidates the old page arithmetic.
29
+ */
30
+ onPageSizeChange?: (size: number) => void;
31
+ } & (CountedPages | UncountedPages);
32
+ /**
33
+ * Page selector for a list, in two flavours picked by which props are passed: `totalPages`
34
+ * (optionally with `totalItems`, which turns on the size picker and the "showing x to y" summary),
35
+ * or `hasNextPage` alone when the backend only knows whether one more page exists. The two are
36
+ * mutually exclusive in the type, so a partially-wired paginator is a compile error.
37
+ *
38
+ * It holds no state: `pageNumber` comes in, `onPageChange` goes out, and the consumer decides what
39
+ * that means. Sizing is in `em`, so the whole strip scales with the font size around it.
40
+ *
41
+ * ```svelte
42
+ * <Paginator pageNumber={page} totalPages={data.pages} totalItems={data.count}
43
+ * pageSize={size} onPageChange={(p) => (page = p)} onPageSizeChange={(s) => { size = s; page = 1; }} />
44
+ * ```
45
+ */
46
+ declare const Paginator: import("svelte").Component<Props, {}, "">;
47
+ type Paginator = ReturnType<typeof Paginator>;
48
+ export default Paginator;