@abuzareal/contour-ui 0.5.0 → 0.6.1
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 +16 -0
- package/COMBOBOX.md +87 -0
- package/COMPOSITION.md +4 -0
- package/CONTRACTS.md +27 -27
- package/DOCS.md +86 -0
- package/FORMS.md +4 -0
- package/FOUNDATION.md +35 -0
- package/GETTING-STARTED.md +67 -0
- package/HELPERS.md +33 -0
- package/HOOKS.md +67 -0
- package/MANUAL-QA.md +38 -0
- package/MIGRATION.md +44 -0
- package/MODELS.md +18 -0
- package/NEXTJS.md +3 -1
- package/OVERLAYS.md +4 -0
- package/PROP-REFERENCE.md +37 -0
- package/PUBLIC-API.md +174 -164
- package/README.md +9 -5
- package/RECIPES.md +43 -0
- package/REFERENCE.md +193 -0
- package/RELEASING.md +8 -2
- package/SIZE.md +9 -3
- package/STYLING.md +4 -0
- package/SUPPORT.md +36 -0
- package/THREE.md +40 -0
- package/dist/components/ui/Combobox.d.ts +325 -0
- package/dist/components/ui/Combobox.js +135 -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/hooks/useInitialHashScroll.js +5 -1
- package/dist/hooks/usePrintPreparation.js +12 -1
- package/dist/hooks/useScrollSpy.js +6 -3
- 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/docs/prop-metadata.json +2668 -0
- package/examples/onboarding/SettingsForm.jsx +30 -0
- package/examples/onboarding/SettingsForm.tsx +50 -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/reference/HookExamples.tsx +146 -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 +37 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/** The application maps its service failures; never display raw server exceptions. */
|
|
5
|
+
export class RecipeServiceError extends Error {
|
|
6
|
+
constructor(public readonly kind: "permission" | "service") {
|
|
7
|
+
super(kind);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export type Loader<Query, Data> = (
|
|
11
|
+
query: Query,
|
|
12
|
+
signal: AbortSignal,
|
|
13
|
+
) => Promise<Data>;
|
|
14
|
+
|
|
15
|
+
/** Application-owned fetching. The old snapshot stays labelled; obsolete work cannot commit. */
|
|
16
|
+
export function useRemoteResource<Query, Data>(
|
|
17
|
+
query: Query,
|
|
18
|
+
load: Loader<Query, Data>,
|
|
19
|
+
) {
|
|
20
|
+
const [attempt, setAttempt] = useState(0);
|
|
21
|
+
const sequence = useRef(0);
|
|
22
|
+
const [state, setState] = useState<{
|
|
23
|
+
requested: Query | null;
|
|
24
|
+
loader: Loader<Query, Data> | null;
|
|
25
|
+
attempt: number;
|
|
26
|
+
applied: Query | null;
|
|
27
|
+
data: Data | null;
|
|
28
|
+
error: "permission" | "service" | null;
|
|
29
|
+
}>({
|
|
30
|
+
requested: null,
|
|
31
|
+
loader: null,
|
|
32
|
+
attempt: -1,
|
|
33
|
+
applied: null,
|
|
34
|
+
data: null,
|
|
35
|
+
error: null,
|
|
36
|
+
});
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const revision = ++sequence.current;
|
|
40
|
+
void (async () => {
|
|
41
|
+
try {
|
|
42
|
+
const data = await load(query, controller.signal);
|
|
43
|
+
if (controller.signal.aborted || sequence.current !== revision) return;
|
|
44
|
+
setState({
|
|
45
|
+
requested: query,
|
|
46
|
+
loader: load,
|
|
47
|
+
attempt,
|
|
48
|
+
applied: query,
|
|
49
|
+
data,
|
|
50
|
+
error: null,
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (controller.signal.aborted || sequence.current !== revision) return;
|
|
54
|
+
const kind =
|
|
55
|
+
error instanceof RecipeServiceError ? error.kind : "service";
|
|
56
|
+
setState((previous) => ({
|
|
57
|
+
...previous,
|
|
58
|
+
requested: query,
|
|
59
|
+
loader: load,
|
|
60
|
+
attempt,
|
|
61
|
+
error: kind,
|
|
62
|
+
// A denied snapshot must not reappear while a later query is pending.
|
|
63
|
+
data: kind === "permission" ? null : previous.data,
|
|
64
|
+
applied: kind === "permission" ? null : previous.applied,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
return () => controller.abort();
|
|
69
|
+
}, [query, load, attempt]);
|
|
70
|
+
const current =
|
|
71
|
+
state.requested === query &&
|
|
72
|
+
state.loader === load &&
|
|
73
|
+
state.attempt === attempt;
|
|
74
|
+
return {
|
|
75
|
+
data: state.data,
|
|
76
|
+
appliedQuery: state.applied,
|
|
77
|
+
loading: !current,
|
|
78
|
+
error: current ? state.error : null,
|
|
79
|
+
retry: () => setAttempt((n) => n + 1),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
TextField,
|
|
5
|
+
ThemeScope,
|
|
6
|
+
ToastProvider,
|
|
7
|
+
useClipboardCopy,
|
|
8
|
+
useDismiss,
|
|
9
|
+
useIdleTrigger,
|
|
10
|
+
useMotionPreference,
|
|
11
|
+
useTheme,
|
|
12
|
+
useToast,
|
|
13
|
+
type Theme,
|
|
14
|
+
} from "@abuzareal/contour-ui";
|
|
15
|
+
export function ClipboardExample() {
|
|
16
|
+
const { copy, status } = useClipboardCopy(
|
|
17
|
+
"Public synthetic project reference",
|
|
18
|
+
);
|
|
19
|
+
return (
|
|
20
|
+
<section aria-label="Clipboard example">
|
|
21
|
+
<Button onClick={() => void copy()}>Copy reference</Button>
|
|
22
|
+
<p role="status">
|
|
23
|
+
{status === "error"
|
|
24
|
+
? "Copy failed. You can try again."
|
|
25
|
+
: status === "copied"
|
|
26
|
+
? "Copied."
|
|
27
|
+
: status === "copying"
|
|
28
|
+
? "Copying…"
|
|
29
|
+
: "Ready to copy."}
|
|
30
|
+
</p>
|
|
31
|
+
</section>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
export function MotionExample() {
|
|
35
|
+
const reduced = useMotionPreference();
|
|
36
|
+
const [paused, setPaused] = useState(false);
|
|
37
|
+
return (
|
|
38
|
+
<section aria-label="Motion preference">
|
|
39
|
+
<Button
|
|
40
|
+
aria-pressed={paused}
|
|
41
|
+
onClick={() => setPaused((value) => !value)}
|
|
42
|
+
>
|
|
43
|
+
Pause optional motion
|
|
44
|
+
</Button>
|
|
45
|
+
<p>Optional animation is {!reduced && !paused ? "allowed" : "paused"}.</p>
|
|
46
|
+
<p>
|
|
47
|
+
This example owns no animation loop; a scene must consume this combined
|
|
48
|
+
policy.
|
|
49
|
+
</p>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
export function DismissExample() {
|
|
54
|
+
const [open, setOpen] = useState(false);
|
|
55
|
+
const panel = useRef<HTMLDivElement>(null),
|
|
56
|
+
trigger = useRef<HTMLButtonElement>(null);
|
|
57
|
+
useDismiss(
|
|
58
|
+
panel,
|
|
59
|
+
open,
|
|
60
|
+
(reason) => {
|
|
61
|
+
setOpen(false);
|
|
62
|
+
if (reason === "escape") trigger.current?.focus();
|
|
63
|
+
},
|
|
64
|
+
{ branches: [trigger] },
|
|
65
|
+
);
|
|
66
|
+
return (
|
|
67
|
+
<section aria-label="Custom nonmodal dismissal">
|
|
68
|
+
<Button
|
|
69
|
+
ref={trigger}
|
|
70
|
+
aria-expanded={open}
|
|
71
|
+
aria-controls="example-help"
|
|
72
|
+
onClick={() => setOpen((value) => !value)}
|
|
73
|
+
>
|
|
74
|
+
Toggle custom help
|
|
75
|
+
</Button>
|
|
76
|
+
{open && (
|
|
77
|
+
<div
|
|
78
|
+
id="example-help"
|
|
79
|
+
ref={panel}
|
|
80
|
+
role="region"
|
|
81
|
+
aria-label="Custom help"
|
|
82
|
+
>
|
|
83
|
+
<p>This nonmodal example does not trap or move focus.</p>
|
|
84
|
+
<TextField label="Local note" />
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
export function IdleExample() {
|
|
91
|
+
const [enabled, setEnabled] = useState(false);
|
|
92
|
+
const ready = useIdleTrigger(enabled);
|
|
93
|
+
return (
|
|
94
|
+
<section aria-label="Idle signal">
|
|
95
|
+
<Button onClick={() => setEnabled(true)}>Request idle signal</Button>
|
|
96
|
+
<p role="status">
|
|
97
|
+
{ready
|
|
98
|
+
? "Idle signal received; no work was loaded."
|
|
99
|
+
: enabled
|
|
100
|
+
? "Waiting for idle signal…"
|
|
101
|
+
: "Not requested."}
|
|
102
|
+
</p>
|
|
103
|
+
</section>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
function ThemeAction() {
|
|
107
|
+
const { theme, toggle } = useTheme(false);
|
|
108
|
+
return (
|
|
109
|
+
<Button
|
|
110
|
+
aria-pressed={theme === "dark"}
|
|
111
|
+
onClick={(event) => toggle(event.currentTarget)}
|
|
112
|
+
>
|
|
113
|
+
Dark embedded theme
|
|
114
|
+
</Button>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
export function ScopedThemeExample() {
|
|
118
|
+
const [theme, setTheme] = useState<Theme>("light");
|
|
119
|
+
return (
|
|
120
|
+
<ThemeScope theme={theme} onThemeChange={setTheme}>
|
|
121
|
+
<ThemeAction />
|
|
122
|
+
</ThemeScope>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
function ToastAction() {
|
|
126
|
+
const toast = useToast();
|
|
127
|
+
return (
|
|
128
|
+
<Button
|
|
129
|
+
onClick={() =>
|
|
130
|
+
toast.show({
|
|
131
|
+
title: "Notification example",
|
|
132
|
+
message: "This local example sends and saves nothing.",
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
>
|
|
136
|
+
Show example notification
|
|
137
|
+
</Button>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
export function ToastExample() {
|
|
141
|
+
return (
|
|
142
|
+
<ToastProvider>
|
|
143
|
+
<ToastAction />
|
|
144
|
+
</ToastProvider>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Minimal component / overlay / form reproduction
|
|
2
|
+
|
|
3
|
+
Copy this folder alone into an empty directory. Run npm install --strict-peer-deps, npm run build and npm run preview. Versions are pinned in package.json; commit the generated lockfile. There are no workspace aliases or private catalogue dependencies. Use npm ci once your application lockfile exists.
|
|
4
|
+
|
|
5
|
+
Keep a failing interaction in src/main.tsx and remove unrelated UI. Include installed Contour/React/browser versions, expected/actual behavior, keyboard steps, theme, viewport and the smallest input data. Replace names and service URLs with synthetic data; never include credentials, customer records or private repository links. A public repository or sandbox can use this standalone folder.
|
|
6
|
+
|
|
7
|
+
[Report a Contour 0.6.1 Combobox issue](https://github.com/abuzareal/contour-ui/issues/new?template=bug_report.yml&title=Combobox%200.6.1%3A%20&component=Combobox&version=0.6.1)
|
|
8
|
+
|
|
9
|
+
Candidate versions need the package to be published before registry installation; the maintainer tests the same source against the packed candidate first.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
+
<meta name="theme-color" content="#f2f3ee" />
|
|
7
|
+
<!-- theme-bootstrap -->
|
|
8
|
+
<title>Contour starter</title>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"private": true,
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22.12.0"
|
|
7
|
+
},
|
|
8
|
+
"name": "contour-bug-reproduction",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "vite",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"build": "tsc --noEmit && vite build",
|
|
13
|
+
"preview": "vite preview"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@abuzareal/contour-ui": "0.6.1",
|
|
17
|
+
"react": "18.3.1",
|
|
18
|
+
"react-dom": "18.3.1"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/react": "18.3.31",
|
|
22
|
+
"@types/react-dom": "18.3.7",
|
|
23
|
+
"@types/node": "26.6.5",
|
|
24
|
+
"typescript": "5.9.3",
|
|
25
|
+
"vite": "8.3.2"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { Button, Combobox, Dialog, Popover } from "@abuzareal/contour-ui";
|
|
4
|
+
import "@abuzareal/contour-ui/scoped.css";
|
|
5
|
+
import "./style.css";
|
|
6
|
+
const options = [
|
|
7
|
+
{ value: "one", label: "One" },
|
|
8
|
+
{ value: "two", label: "Two", disabled: true },
|
|
9
|
+
];
|
|
10
|
+
function Reproduction() {
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
return (
|
|
13
|
+
<main className="contour-root starter">
|
|
14
|
+
<h1>Contour reproduction</h1>
|
|
15
|
+
<p>
|
|
16
|
+
Contour 0.6.0, React/React DOM 18.3.1. Replace only the smallest
|
|
17
|
+
relevant case. No real user data.
|
|
18
|
+
</p>
|
|
19
|
+
<Button onClick={() => setOpen(true)}>Open dialog</Button>
|
|
20
|
+
<Dialog open={open} onClose={() => setOpen(false)} title="Reproduction">
|
|
21
|
+
<Popover trigger="Open popover" title="Nested layer">
|
|
22
|
+
<form onSubmit={(event) => event.preventDefault()}>
|
|
23
|
+
<Combobox label="Choice" name="choice" options={options} required />
|
|
24
|
+
<Button type="reset">Reset</Button>
|
|
25
|
+
</form>
|
|
26
|
+
</Popover>
|
|
27
|
+
</Dialog>
|
|
28
|
+
</main>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const element = document.getElementById("root");
|
|
32
|
+
if (!element) throw new Error("Missing root");
|
|
33
|
+
createRoot(element).render(<Reproduction />);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
body {
|
|
2
|
+
margin: 0;
|
|
3
|
+
background: var(--contour-bg);
|
|
4
|
+
color: var(--contour-fg);
|
|
5
|
+
}
|
|
6
|
+
.starter {
|
|
7
|
+
max-width: 42rem;
|
|
8
|
+
margin: 0 auto;
|
|
9
|
+
padding: 2rem 1rem;
|
|
10
|
+
display: grid;
|
|
11
|
+
gap: 1.25rem;
|
|
12
|
+
}
|
|
13
|
+
.starter form {
|
|
14
|
+
display: grid;
|
|
15
|
+
gap: 1rem;
|
|
16
|
+
}
|
|
17
|
+
.starter output {
|
|
18
|
+
overflow-wrap: anywhere;
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
5
|
+
"strict": true,
|
|
6
|
+
"skipLibCheck": false,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"module": "ESNext",
|
|
10
|
+
"moduleResolution": "Bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"isolatedModules": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
17
|
+
"exclude": ["node_modules"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { defineConfig } from "vite";
|
|
3
|
+
import { themeBootstrapScript } from "@abuzareal/contour-ui/theme-bootstrap";
|
|
4
|
+
const hash = createHash("sha256").update(themeBootstrapScript).digest("base64");
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [
|
|
7
|
+
{
|
|
8
|
+
name: "contour-theme-bootstrap",
|
|
9
|
+
transformIndexHtml(html) {
|
|
10
|
+
// Hash the exact bytes inserted here; no inline handler or unsafe-eval allowance.
|
|
11
|
+
const policy = `default-src 'self'; script-src 'self' 'sha256-${hash}'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'`;
|
|
12
|
+
return html.replace(
|
|
13
|
+
"<!-- theme-bootstrap -->",
|
|
14
|
+
`<meta http-equiv="Content-Security-Policy" content="${policy}"><script>${themeBootstrapScript}</script>`,
|
|
15
|
+
);
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Contour + Next App Router
|
|
2
|
+
|
|
3
|
+
Copy this directory. Use Node 22.12 or newer, then:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install --strict-peer-deps
|
|
7
|
+
npm run dev
|
|
8
|
+
npm run build
|
|
9
|
+
npm start
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Direct versions: Contour 0.6.1, Next 16.4.0, React/React DOM 19.3.0, TypeScript 7.0.2; webpack explicitly selected. Commit the generated application lockfile and use npm ci afterward. There are no workspace aliases, optional Three/Lenis peers, fonts or required providers. To opt into branded fonts, import `@abuzareal/contour-ui/fonts-latin.css` in app/layout.tsx. Root CSS and route composition belong to the application.
|
|
13
|
+
|
|
14
|
+
layout.tsx and the page are server components. Demo.tsx owns client state, refs and handlers. The pure theme-bootstrap subpath is server-safe; before-paint script changes only the root theme attribute and theme-color. The root's suppressHydrationWarning is narrowly for this attribute, not a workaround for arbitrary mismatches.
|
|
15
|
+
|
|
16
|
+
proxy.ts generates a new nonce on every document request, forwards the CSP and nonce to Next and returns the CSP header. The dynamic layout reads that nonce so both bootstrap and framework scripts are authorized. This deliberately requires server rendering; static export/cached shared nonces are not supported. Inline styles remain allowed for library geometry. Production script policy has no unsafe-eval; development adds it for the bundler. Configure additional service origins explicitly. See [Next's CSP guide](https://nextjs.org/docs/app/guides/content-security-policy).
|
|
17
|
+
|
|
18
|
+
This folder establishes the named App Router/webpack pair only. Turbopack, Pages Router and Next Lenis are unverified. Optional lazy Three is covered separately by the library's optional fixture, not installed here. A release candidate uses an archive only in disposable validation copies; the exact registry installation requires the published release.
|