@abuzareal/contour-ui 0.5.0 → 0.6.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 (67) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/COMBOBOX.md +87 -0
  3. package/CONTRACTS.md +17 -17
  4. package/DOCS.md +80 -0
  5. package/FOUNDATION.md +35 -0
  6. package/GETTING-STARTED.md +63 -0
  7. package/NEXTJS.md +3 -1
  8. package/PUBLIC-API.md +174 -164
  9. package/README.md +7 -5
  10. package/RECIPES.md +43 -0
  11. package/RELEASING.md +2 -2
  12. package/SIZE.md +9 -3
  13. package/dist/components/ui/Combobox.d.ts +325 -0
  14. package/dist/components/ui/Combobox.js +120 -0
  15. package/dist/components/ui/Combobox.types.d.ts +46 -0
  16. package/dist/components/ui/Combobox.types.js +1 -0
  17. package/dist/components/ui/DataTable.js +5 -4
  18. package/dist/components/ui/Field.js +1 -1
  19. package/dist/components/ui/FloatingPortal.js +2 -40
  20. package/dist/components/ui/FloatingScope.d.ts +9 -0
  21. package/dist/components/ui/FloatingScope.js +43 -0
  22. package/dist/components/ui/Table.d.ts +20 -0
  23. package/dist/components/ui/Table.js +9 -0
  24. package/dist/hooks/useComboboxAnchor.d.ts +17 -0
  25. package/dist/hooks/useComboboxAnchor.js +61 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +2 -0
  28. package/dist/kit/data-display.css +6 -0
  29. package/dist/kit/forms.css +34 -0
  30. package/dist/lib/componentClasses.js +1 -1
  31. package/dist/scoped/kit/data-display.css +6 -0
  32. package/dist/scoped/kit/forms.css +34 -0
  33. package/examples/optional/LazyScene.tsx +78 -0
  34. package/examples/optional/OptionalSmoothScroll.tsx +25 -0
  35. package/examples/optional/SmoothScrollOwner.tsx +7 -0
  36. package/examples/optional/optional.css +25 -0
  37. package/examples/recipes/AsyncProjectPicker.tsx +82 -0
  38. package/examples/recipes/DeleteProjectConfirmation.tsx +97 -0
  39. package/examples/recipes/InputCompositions.tsx +49 -0
  40. package/examples/recipes/RemoteRecordsTable.tsx +256 -0
  41. package/examples/recipes/useRemoteResource.ts +81 -0
  42. package/examples/reproduction/README.md +9 -0
  43. package/examples/reproduction/index.html +14 -0
  44. package/examples/reproduction/package.json +27 -0
  45. package/examples/reproduction/src/main.tsx +33 -0
  46. package/examples/reproduction/src/style.css +19 -0
  47. package/examples/reproduction/tsconfig.json +18 -0
  48. package/examples/reproduction/vite.config.ts +19 -0
  49. package/examples/starters/next/README.md +18 -0
  50. package/examples/starters/next/app/Demo.tsx +65 -0
  51. package/examples/starters/next/app/layout.tsx +25 -0
  52. package/examples/starters/next/app/next-page/page.tsx +9 -0
  53. package/examples/starters/next/app/page.tsx +11 -0
  54. package/examples/starters/next/app/style.css +19 -0
  55. package/examples/starters/next/next-env.d.ts +2 -0
  56. package/examples/starters/next/next.config.mjs +1 -0
  57. package/examples/starters/next/package.json +27 -0
  58. package/examples/starters/next/proxy.ts +16 -0
  59. package/examples/starters/next/tsconfig.json +30 -0
  60. package/examples/starters/vite/README.md +16 -0
  61. package/examples/starters/vite/index.html +14 -0
  62. package/examples/starters/vite/package.json +27 -0
  63. package/examples/starters/vite/src/main.tsx +81 -0
  64. package/examples/starters/vite/src/style.css +19 -0
  65. package/examples/starters/vite/tsconfig.json +18 -0
  66. package/examples/starters/vite/vite.config.ts +19 -0
  67. package/package.json +19 -4
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { lazy, Suspense, useState } from "react";
3
+ import {
4
+ Button,
5
+ SceneBoundary,
6
+ useMotionPreference,
7
+ } from "@abuzareal/contour-ui";
8
+ import "./optional.css";
9
+ const Sculpture = lazy(() =>
10
+ import("@abuzareal/contour-ui/three").then((module) => ({
11
+ default: module.Sculpture,
12
+ })),
13
+ );
14
+ export function LazyScene() {
15
+ const [loaded, setLoaded] = useState(false),
16
+ [paused, setPaused] = useState(false);
17
+ const reducedMotion = useMotionPreference();
18
+ return (
19
+ <figure>
20
+ <div className="recipe-scene-visual">
21
+ <div
22
+ className="recipe-scene-fallback"
23
+ role="img"
24
+ aria-label="Three interlocking sculptural links"
25
+ >
26
+ <svg aria-hidden="true" viewBox="0 0 300 180">
27
+ <g fill="none" stroke="currentColor" strokeWidth="16">
28
+ <ellipse
29
+ cx="95"
30
+ cy="78"
31
+ rx="52"
32
+ ry="29"
33
+ transform="rotate(-25 95 78)"
34
+ />
35
+ <ellipse
36
+ cx="165"
37
+ cy="95"
38
+ rx="52"
39
+ ry="29"
40
+ transform="rotate(25 165 95)"
41
+ />
42
+ <ellipse
43
+ cx="215"
44
+ cy="62"
45
+ rx="40"
46
+ ry="25"
47
+ transform="rotate(-35 215 62)"
48
+ />
49
+ </g>
50
+ </svg>
51
+ </div>
52
+ {loaded && (
53
+ <div className="recipe-scene-layer" aria-hidden="true">
54
+ <SceneBoundary>
55
+ <Suspense fallback={null}>
56
+ <Sculpture motionEnabled={!paused && !reducedMotion} />
57
+ </Suspense>
58
+ </SceneBoundary>
59
+ </div>
60
+ )}
61
+ </div>
62
+ <figcaption>
63
+ Three linked forms. The static study remains available if 3D cannot
64
+ load.
65
+ </figcaption>
66
+ <Button onClick={() => setLoaded(true)} disabled={loaded}>
67
+ Load 3D study
68
+ </Button>
69
+ <button
70
+ type="button"
71
+ aria-pressed={paused}
72
+ onClick={() => setPaused((value) => !value)}
73
+ >
74
+ Pause motion
75
+ </button>
76
+ </figure>
77
+ );
78
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { lazy, Suspense, useState } from "react";
3
+ import { useMotionPreference } from "@abuzareal/contour-ui";
4
+ const SmoothScrollOwner = lazy(() => import("./SmoothScrollOwner.js"));
5
+ /** Mount at most one owner for the page; ordinary native scrolling works before opt-in. */
6
+ export function OptionalSmoothScroll() {
7
+ const [enabled, setEnabled] = useState(false);
8
+ const reducedMotion = useMotionPreference();
9
+ return (
10
+ <>
11
+ <button
12
+ type="button"
13
+ aria-pressed={enabled}
14
+ onClick={() => setEnabled((value) => !value)}
15
+ >
16
+ Smooth scrolling
17
+ </button>
18
+ {enabled && !reducedMotion && (
19
+ <Suspense fallback={null}>
20
+ <SmoothScrollOwner enabled />
21
+ </Suspense>
22
+ )}
23
+ </>
24
+ );
25
+ }
@@ -0,0 +1,7 @@
1
+ "use client";
2
+ import { useSmoothScroll } from "@abuzareal/contour-ui/scroll";
3
+ import "lenis/dist/lenis.css";
4
+ export default function SmoothScrollOwner({ enabled }: { enabled: boolean }) {
5
+ useSmoothScroll(enabled);
6
+ return null;
7
+ }
@@ -0,0 +1,25 @@
1
+ .recipe-scene-visual {
2
+ position: relative;
3
+ block-size: 18rem;
4
+ overflow: hidden;
5
+ }
6
+ .recipe-scene-fallback,
7
+ .recipe-scene-layer {
8
+ position: absolute;
9
+ inset: 0;
10
+ }
11
+ .recipe-scene-fallback {
12
+ display: grid;
13
+ place-items: center;
14
+ }
15
+ .recipe-scene-fallback svg {
16
+ inline-size: min(100%, 24rem);
17
+ block-size: 100%;
18
+ }
19
+ .recipe-scene-layer .contour-sculpture-canvas {
20
+ position: absolute;
21
+ inset: 0;
22
+ }
23
+ .recipe-scene-visual:has([data-state="ready"]) .recipe-scene-fallback {
24
+ visibility: hidden;
25
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { Combobox, type ComboboxOption } from "@abuzareal/contour-ui";
4
+ import { useRemoteResource, type Loader } from "./useRemoteResource.js";
5
+
6
+ export type ProjectSearch = Loader<string, readonly ComboboxOption[]>;
7
+ export type AsyncProjectPickerProps = {
8
+ /** Stable function: validate response IDs/labels before resolving. */
9
+ searchProjects: ProjectSearch;
10
+ initialProject?: ComboboxOption;
11
+ onProjectChange?: (project: ComboboxOption | null) => void;
12
+ };
13
+
14
+ /** Copy this into the application; fetching and selected-record retention are not library features. */
15
+ export function AsyncProjectPicker({
16
+ searchProjects,
17
+ initialProject,
18
+ onProjectChange,
19
+ }: AsyncProjectPickerProps) {
20
+ const [query, setQuery] = useState(initialProject?.label ?? "");
21
+ const [selected, setSelected] = useState<ComboboxOption | null>(
22
+ initialProject ?? null,
23
+ );
24
+ const search = useCallback(
25
+ async (text: string, signal: AbortSignal) => {
26
+ const matches = await searchProjects(text, signal);
27
+ if (!signal.aborted)
28
+ setSelected((previous) =>
29
+ previous
30
+ ? (matches.find((option) => option.value === previous.value) ??
31
+ previous)
32
+ : null,
33
+ );
34
+ return matches;
35
+ },
36
+ [searchProjects],
37
+ );
38
+ const result = useRemoteResource(query, search);
39
+ // Never display the previous query's rows as if they matched this query.
40
+ const matches = useMemo(
41
+ () =>
42
+ !result.loading && !result.error && result.appliedQuery === query
43
+ ? (result.data ?? [])
44
+ : [],
45
+ [result.loading, result.error, result.appliedQuery, result.data, query],
46
+ );
47
+ // Bounded retention: keep the current result set and one selected record, not an unbounded cache.
48
+ const options = useMemo(
49
+ () =>
50
+ selected && !matches.some((option) => option.value === selected.value)
51
+ ? [...matches, selected]
52
+ : matches,
53
+ [matches, selected],
54
+ );
55
+ return (
56
+ <Combobox
57
+ label="Project"
58
+ name="project"
59
+ required
60
+ options={options}
61
+ filteredOptions={matches}
62
+ value={selected?.value ?? null}
63
+ onValueChange={(value) => {
64
+ const next = options.find((option) => option.value === value) ?? null;
65
+ setSelected(next);
66
+ onProjectChange?.(next);
67
+ }}
68
+ inputValue={query}
69
+ onInputValueChange={setQuery}
70
+ loading={result.loading}
71
+ searchError={
72
+ result.error === "permission"
73
+ ? "You do not have permission to search projects."
74
+ : result.error
75
+ ? "Projects could not be loaded. Try again."
76
+ : undefined
77
+ }
78
+ onRetry={result.retry}
79
+ hint="Choose a project from the results. Typed text is not a submitted project."
80
+ />
81
+ );
82
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Button, Dialog } from "@abuzareal/contour-ui";
4
+
5
+ export type DeleteProjectConfirmationProps = {
6
+ project: { id: string; name: string };
7
+ /** Resolve only after server confirmation. Authorization/idempotency belong on the server. */
8
+ deleteProject: (id: string, signal: AbortSignal) => Promise<void>;
9
+ onDeleted?: (id: string) => void;
10
+ };
11
+
12
+ /** Native Dialog composition; no AlertDialog export or simulated successful deletion. */
13
+ export function DeleteProjectConfirmation(
14
+ props: DeleteProjectConfirmationProps,
15
+ ) {
16
+ // Target identity owns the whole interaction; changing projects cannot reuse a pending confirmation.
17
+ return <ProjectDeletion key={props.project.id} {...props} />;
18
+ }
19
+
20
+ function ProjectDeletion({
21
+ project,
22
+ deleteProject,
23
+ onDeleted,
24
+ }: DeleteProjectConfirmationProps) {
25
+ const [open, setOpen] = useState(false),
26
+ [pending, setPending] = useState(false),
27
+ [error, setError] = useState("");
28
+ const cancel = useRef<HTMLButtonElement>(null);
29
+ const active = useRef<AbortController | null>(null);
30
+ // A changed target or unmount invalidates the old operation, even if its transport ignores abort.
31
+ useEffect(() => {
32
+ return () => {
33
+ active.current?.abort();
34
+ active.current = null;
35
+ };
36
+ }, [project.id]);
37
+ const dismiss = () => {
38
+ active.current?.abort();
39
+ active.current = null;
40
+ setPending(false);
41
+ setOpen(false);
42
+ setError("");
43
+ };
44
+ const remove = async () => {
45
+ if (active.current) return;
46
+ const controller = new AbortController();
47
+ active.current = controller;
48
+ setPending(true);
49
+ setError("");
50
+ try {
51
+ await deleteProject(project.id, controller.signal);
52
+ } catch {
53
+ if (controller.signal.aborted || active.current !== controller) return;
54
+ active.current = null;
55
+ setPending(false);
56
+ setError(
57
+ "The deletion could not be confirmed. Check the project before retrying.",
58
+ );
59
+ return;
60
+ }
61
+ if (controller.signal.aborted || active.current !== controller) return;
62
+ active.current = null;
63
+ setPending(false);
64
+ setOpen(false);
65
+ onDeleted?.(project.id);
66
+ };
67
+ return (
68
+ <>
69
+ <Button onClick={() => setOpen(true)}>Delete {project.name}</Button>
70
+ <Dialog
71
+ title={`Delete ${project.name}?`}
72
+ open={open}
73
+ onClose={dismiss}
74
+ initialFocus={cancel}
75
+ description="Deleting this project permanently removes its saved settings. This action cannot be undone."
76
+ closeButtonProps={{ "aria-label": "Cancel deletion" }}
77
+ footer={
78
+ <>
79
+ <Button ref={cancel} variant="secondary" onClick={dismiss}>
80
+ Keep project
81
+ </Button>
82
+ <Button disabled={pending} onClick={() => void remove()}>
83
+ {pending ? "Deleting…" : "Permanently delete project"}
84
+ </Button>
85
+ </>
86
+ }
87
+ >
88
+ <p role="status">
89
+ {pending
90
+ ? "Waiting for server confirmation. Closing stops waiting; the server may still complete the deletion."
91
+ : "No action is taken until you confirm."}
92
+ </p>
93
+ {error && <p role="alert">{error}</p>}
94
+ </Dialog>
95
+ </>
96
+ );
97
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import { useRef, useState } from "react";
3
+ import { TextField } from "@abuzareal/contour-ui";
4
+
5
+ /** Native number behavior is sufficient for a bounded integer quantity, not locale-aware money. */
6
+ export function QuantityField() {
7
+ return (
8
+ <TextField
9
+ leadingIcon={<span aria-hidden="true">#</span>}
10
+ label="Seats"
11
+ name="seats"
12
+ type="number"
13
+ min={1}
14
+ max={100}
15
+ step={1}
16
+ defaultValue={1}
17
+ trailing={<span aria-hidden="true">seats</span>}
18
+ hint="Whole number, 1–100 seats."
19
+ />
20
+ );
21
+ }
22
+
23
+ /** Keep the same native input mounted so form identity and password-manager hooks survive toggling. */
24
+ export function PasswordField() {
25
+ const [visible, setVisible] = useState(false);
26
+ const input = useRef<HTMLInputElement>(null);
27
+ return (
28
+ <TextField
29
+ ref={input}
30
+ label="Password"
31
+ name="password"
32
+ type={visible ? "text" : "password"}
33
+ autoComplete="current-password"
34
+ trailing={
35
+ <button
36
+ type="button"
37
+ aria-label="Show password"
38
+ aria-pressed={visible}
39
+ onClick={() => {
40
+ setVisible((previous) => !previous);
41
+ input.current?.focus();
42
+ }}
43
+ >
44
+ {visible ? "Hide" : "Show"}
45
+ </button>
46
+ }
47
+ />
48
+ );
49
+ }
@@ -0,0 +1,256 @@
1
+ "use client";
2
+ import { useCreateAtom, useSelector } from "@tanstack/react-store";
3
+ import {
4
+ createColumnHelper,
5
+ functionalUpdate,
6
+ rowPaginationFeature,
7
+ rowSortingFeature,
8
+ tableFeatures,
9
+ useTable,
10
+ type PaginationState,
11
+ type SortingState,
12
+ } from "@tanstack/react-table";
13
+ import { Button, Table, TextField } from "@abuzareal/contour-ui";
14
+ import { useRemoteResource, type Loader } from "./useRemoteResource.js";
15
+
16
+ export type RecordRow = { id: string; name: string; owner: string };
17
+ export type RecordsQuery = {
18
+ filter: string;
19
+ sorting: SortingState;
20
+ pagination: PaginationState;
21
+ };
22
+ export type RecordsPage = {
23
+ rows: readonly RecordRow[];
24
+ total: number;
25
+ unfilteredTotal: number;
26
+ };
27
+ export type LoadRecords = Loader<RecordsQuery, RecordsPage>;
28
+ const features = tableFeatures({ rowSortingFeature, rowPaginationFeature });
29
+ const column = createColumnHelper<typeof features, RecordRow>();
30
+ const columns = column.columns([
31
+ column.accessor("name", { header: "Name" }),
32
+ column.accessor("owner", { header: "Owner" }),
33
+ ]);
34
+
35
+ /** Validate parsed JSON at the application's service boundary before giving it to this recipe. */
36
+ export function parseRecordsPage(
37
+ value: unknown,
38
+ pageSize: number,
39
+ ): RecordsPage {
40
+ if (
41
+ !value ||
42
+ typeof value !== "object" ||
43
+ !("rows" in value) ||
44
+ !Array.isArray(value.rows) ||
45
+ !("total" in value) ||
46
+ !("unfilteredTotal" in value)
47
+ )
48
+ throw new Error("Invalid records response");
49
+ const { rows, total, unfilteredTotal } = value;
50
+ if (
51
+ typeof total !== "number" ||
52
+ !Number.isSafeInteger(total) ||
53
+ total < 0 ||
54
+ typeof unfilteredTotal !== "number" ||
55
+ !Number.isSafeInteger(unfilteredTotal) ||
56
+ unfilteredTotal < total ||
57
+ rows.length > pageSize ||
58
+ rows.length > total
59
+ )
60
+ throw new Error("Invalid records count");
61
+ const ids = new Set<string>();
62
+ const parsed: RecordRow[] = rows.map((row: unknown) => {
63
+ if (
64
+ !row ||
65
+ typeof row !== "object" ||
66
+ !("id" in row) ||
67
+ typeof row.id !== "string" ||
68
+ !row.id ||
69
+ ids.has(row.id) ||
70
+ !("name" in row) ||
71
+ typeof row.name !== "string" ||
72
+ !("owner" in row) ||
73
+ typeof row.owner !== "string"
74
+ )
75
+ throw new Error("Invalid record");
76
+ ids.add(row.id);
77
+ return { id: row.id, name: row.name, owner: row.owner };
78
+ });
79
+ return { rows: parsed, total, unfilteredTotal };
80
+ }
81
+
82
+ /** The service performs sorting/filtering/pagination. This is not local DataTable sorting. */
83
+ export function RemoteRecordsTable({
84
+ loadRecords,
85
+ }: {
86
+ loadRecords: LoadRecords;
87
+ }) {
88
+ const queryAtom = useCreateAtom<RecordsQuery>({
89
+ filter: "",
90
+ sorting: [],
91
+ pagination: { pageIndex: 0, pageSize: 10 },
92
+ });
93
+ const query = useSelector(queryAtom);
94
+ const result = useRemoteResource(query, loadRecords);
95
+ const page = result.data;
96
+ const applied = result.appliedQuery;
97
+ const table = useTable({
98
+ features,
99
+ columns,
100
+ data: page?.rows ?? [],
101
+ getRowId: (row) => row.id,
102
+ manualSorting: true,
103
+ manualPagination: true,
104
+ rowCount: page?.total ?? 0,
105
+ state: { sorting: query.sorting, pagination: query.pagination },
106
+ onSortingChange: (update) =>
107
+ queryAtom.set((previous) => ({
108
+ ...previous,
109
+ sorting: functionalUpdate(update, previous.sorting),
110
+ pagination: { ...previous.pagination, pageIndex: 0 },
111
+ })),
112
+ onPaginationChange: (update) =>
113
+ queryAtom.set((previous) => ({
114
+ ...previous,
115
+ pagination: functionalUpdate(update, previous.pagination),
116
+ })),
117
+ });
118
+ const showingPrevious = !!applied && applied !== query;
119
+ const canPage =
120
+ !!page && !result.loading && !result.error && !showingPrevious;
121
+ return (
122
+ <section aria-label="Remote records">
123
+ <TextField
124
+ label="Filter records"
125
+ value={query.filter}
126
+ onChange={(event) =>
127
+ queryAtom.set((previous) => ({
128
+ ...previous,
129
+ filter: event.target.value,
130
+ pagination: { ...previous.pagination, pageIndex: 0 },
131
+ }))
132
+ }
133
+ />
134
+ <p role="status">
135
+ {result.loading
136
+ ? page
137
+ ? "Loading requested records; previous results remain below."
138
+ : "Loading records…"
139
+ : result.error
140
+ ? "Requested records are unavailable."
141
+ : `${page?.total ?? 0} records found.`}
142
+ </p>
143
+ {result.error && (
144
+ <div role="alert">
145
+ <p>
146
+ {result.error === "permission"
147
+ ? "You do not have permission to view these records."
148
+ : "Records could not be loaded. Previous results have not been replaced."}
149
+ </p>
150
+ <Button onClick={result.retry}>Retry records</Button>
151
+ </div>
152
+ )}
153
+ {page && applied && result.error !== "permission" && (
154
+ <>
155
+ <p>
156
+ Displayed results: page {applied.pagination.pageIndex + 1}, filter{" "}
157
+ {applied.filter ? `“${applied.filter}”` : "none"}, sort{" "}
158
+ {applied.sorting
159
+ .map(
160
+ (sort) =>
161
+ `${sort.id} ${sort.desc ? "descending" : "ascending"}`,
162
+ )
163
+ .join(", ") || "service default"}
164
+ .
165
+ {showingPrevious
166
+ ? " These are previous results, not the requested result set."
167
+ : ""}
168
+ </p>
169
+ <Table
170
+ caption="Records returned by the service"
171
+ aria-busy={result.loading || undefined}
172
+ >
173
+ <thead>
174
+ <tr>
175
+ {table.getHeaderGroups()[0]?.headers.map((header) => {
176
+ const sort = applied.sorting.find(
177
+ (sort) => sort.id === header.column.id,
178
+ );
179
+ return (
180
+ <th
181
+ scope="col"
182
+ key={header.id}
183
+ aria-sort={
184
+ sort ? (sort.desc ? "descending" : "ascending") : "none"
185
+ }
186
+ >
187
+ <button
188
+ type="button"
189
+ onClick={header.column.getToggleSortingHandler()}
190
+ >
191
+ <table.FlexRender header={header} />
192
+ </button>
193
+ </th>
194
+ );
195
+ })}
196
+ </tr>
197
+ </thead>
198
+ <tbody>
199
+ {table.getRowModel().rows.map((row) => (
200
+ <tr key={row.id}>
201
+ {row.getAllCells().map((cell) => (
202
+ <td key={cell.id}>
203
+ <table.FlexRender cell={cell} />
204
+ </td>
205
+ ))}
206
+ </tr>
207
+ ))}
208
+ </tbody>
209
+ </Table>
210
+ {!page.rows.length && (
211
+ <p>
212
+ {page.unfilteredTotal === 0
213
+ ? "No records exist yet."
214
+ : page.total === 0
215
+ ? "No records match this filter."
216
+ : "This page no longer exists. Return to the first page."}
217
+ </p>
218
+ )}
219
+ <Button
220
+ disabled={!canPage || query.pagination.pageIndex === 0}
221
+ onClick={() => table.previousPage()}
222
+ >
223
+ Previous page
224
+ </Button>
225
+ <Button
226
+ disabled={
227
+ !canPage ||
228
+ (query.pagination.pageIndex + 1) * query.pagination.pageSize >=
229
+ page.total
230
+ }
231
+ onClick={() => table.nextPage()}
232
+ >
233
+ Next page
234
+ </Button>
235
+ {page.total > 0 && !page.rows.length && (
236
+ <Button
237
+ onClick={() =>
238
+ queryAtom.set((previous) => ({
239
+ ...previous,
240
+ pagination: { ...previous.pagination, pageIndex: 0 },
241
+ }))
242
+ }
243
+ >
244
+ First page
245
+ </Button>
246
+ )}
247
+ </>
248
+ )}
249
+ <p>
250
+ This recipe has no selection, bulk actions, virtualization or adjustable
251
+ density. Add those application contracts before enabling their
252
+ table-engine features.
253
+ </p>
254
+ </section>
255
+ );
256
+ }