@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.
- package/CHANGELOG.md +12 -0
- package/COMBOBOX.md +87 -0
- package/CONTRACTS.md +17 -17
- package/DOCS.md +80 -0
- package/FOUNDATION.md +35 -0
- package/GETTING-STARTED.md +63 -0
- package/NEXTJS.md +3 -1
- package/PUBLIC-API.md +174 -164
- package/README.md +7 -5
- package/RECIPES.md +43 -0
- package/RELEASING.md +2 -2
- package/SIZE.md +9 -3
- package/dist/components/ui/Combobox.d.ts +325 -0
- package/dist/components/ui/Combobox.js +120 -0
- package/dist/components/ui/Combobox.types.d.ts +46 -0
- package/dist/components/ui/Combobox.types.js +1 -0
- package/dist/components/ui/DataTable.js +5 -4
- package/dist/components/ui/Field.js +1 -1
- package/dist/components/ui/FloatingPortal.js +2 -40
- package/dist/components/ui/FloatingScope.d.ts +9 -0
- package/dist/components/ui/FloatingScope.js +43 -0
- package/dist/components/ui/Table.d.ts +20 -0
- package/dist/components/ui/Table.js +9 -0
- package/dist/hooks/useComboboxAnchor.d.ts +17 -0
- package/dist/hooks/useComboboxAnchor.js +61 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/kit/data-display.css +6 -0
- package/dist/kit/forms.css +34 -0
- package/dist/lib/componentClasses.js +1 -1
- package/dist/scoped/kit/data-display.css +6 -0
- package/dist/scoped/kit/forms.css +34 -0
- package/examples/optional/LazyScene.tsx +78 -0
- package/examples/optional/OptionalSmoothScroll.tsx +25 -0
- package/examples/optional/SmoothScrollOwner.tsx +7 -0
- package/examples/optional/optional.css +25 -0
- package/examples/recipes/AsyncProjectPicker.tsx +82 -0
- package/examples/recipes/DeleteProjectConfirmation.tsx +97 -0
- package/examples/recipes/InputCompositions.tsx +49 -0
- package/examples/recipes/RemoteRecordsTable.tsx +256 -0
- package/examples/recipes/useRemoteResource.ts +81 -0
- package/examples/reproduction/README.md +9 -0
- package/examples/reproduction/index.html +14 -0
- package/examples/reproduction/package.json +27 -0
- package/examples/reproduction/src/main.tsx +33 -0
- package/examples/reproduction/src/style.css +19 -0
- package/examples/reproduction/tsconfig.json +18 -0
- package/examples/reproduction/vite.config.ts +19 -0
- package/examples/starters/next/README.md +18 -0
- package/examples/starters/next/app/Demo.tsx +65 -0
- package/examples/starters/next/app/layout.tsx +25 -0
- package/examples/starters/next/app/next-page/page.tsx +9 -0
- package/examples/starters/next/app/page.tsx +11 -0
- package/examples/starters/next/app/style.css +19 -0
- package/examples/starters/next/next-env.d.ts +2 -0
- package/examples/starters/next/next.config.mjs +1 -0
- package/examples/starters/next/package.json +27 -0
- package/examples/starters/next/proxy.ts +16 -0
- package/examples/starters/next/tsconfig.json +30 -0
- package/examples/starters/vite/README.md +16 -0
- package/examples/starters/vite/index.html +14 -0
- package/examples/starters/vite/package.json +27 -0
- package/examples/starters/vite/src/main.tsx +81 -0
- package/examples/starters/vite/src/style.css +19 -0
- package/examples/starters/vite/tsconfig.json +18 -0
- package/examples/starters/vite/vite.config.ts +19 -0
- 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,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
|
+
}
|