cursedbelt 5.89.0 → 5.91.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/dist/react/LazyToaster.d.ts +3 -0
- package/dist/react/LazyToaster.d.ts.map +1 -0
- package/dist/react/LazyToaster.js +21 -0
- package/dist/react/LazyToaster.js.map +1 -0
- package/dist/react/components/DataTable/DataTable.d.ts +14 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +18 -7
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/Drawer.d.ts +8 -1
- package/dist/react/components/Drawer.d.ts.map +1 -1
- package/dist/react/components/Drawer.js +2 -2
- package/dist/react/components/Drawer.js.map +1 -1
- package/dist/react/components/Page.d.ts +9 -1
- package/dist/react/components/Page.d.ts.map +1 -1
- package/dist/react/components/Page.js +3 -2
- package/dist/react/components/Page.js.map +1 -1
- package/dist/react/createApiHooks.d.ts +30 -0
- package/dist/react/createApiHooks.d.ts.map +1 -1
- package/dist/react/createApiHooks.js +31 -2
- package/dist/react/createApiHooks.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +9 -3
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/nav/RailPlaces.d.ts +6 -2
- package/dist/react/nav/RailPlaces.d.ts.map +1 -1
- package/dist/react/nav/RailPlaces.js +1 -1
- package/dist/react/nav/RailPlaces.js.map +1 -1
- package/dist/react/optimistic/index.d.ts +10 -0
- package/dist/react/optimistic/index.d.ts.map +1 -0
- package/dist/react/optimistic/index.js +22 -0
- package/dist/react/optimistic/index.js.map +1 -0
- package/dist/react/optimistic/optimistic.d.ts +159 -0
- package/dist/react/optimistic/optimistic.d.ts.map +1 -0
- package/dist/react/optimistic/optimistic.js +216 -0
- package/dist/react/optimistic/optimistic.js.map +1 -0
- package/dist/react/optimistic/revert.d.ts +47 -0
- package/dist/react/optimistic/revert.d.ts.map +1 -0
- package/dist/react/optimistic/revert.js +235 -0
- package/dist/react/optimistic/revert.js.map +1 -0
- package/dist/styles-areas/analytics.css +5 -5
- package/dist/styles-areas/data-table.css +5 -5
- package/dist/styles-utilities.css +10 -0
- package/package.json +1 -1
- package/scripts/styleAreas.ts +3 -0
- package/src/react/LazyToaster.spec.tsx +18 -0
- package/src/react/LazyToaster.tsx +26 -0
- package/src/react/components/DataTable/DataTable.tsx +37 -7
- package/src/react/components/DataTable/columnHeaders.spec.tsx +83 -0
- package/src/react/components/Drawer.tsx +13 -0
- package/src/react/components/Modal.spec.tsx +14 -0
- package/src/react/components/Page.spec.tsx +20 -0
- package/src/react/components/Page.tsx +35 -14
- package/src/react/createApiHooks.spec.tsx +77 -0
- package/src/react/createApiHooks.ts +58 -1
- package/src/react/file-tree/FileTree.spec.tsx +14 -0
- package/src/react/file-tree/FileTree.tsx +9 -3
- package/src/react/index.ts +2 -0
- package/src/react/nav/RailPlaces.spec.tsx +6 -0
- package/src/react/nav/RailPlaces.tsx +12 -2
- package/src/react/optimistic/index.ts +36 -0
- package/src/react/optimistic/optimistic.spec.tsx +225 -0
- package/src/react/optimistic/optimistic.ts +326 -0
- package/src/react/optimistic/queryTarget.spec.ts +64 -0
- package/src/react/optimistic/revert.spec.ts +114 -0
- package/src/react/optimistic/revert.ts +238 -0
- package/src/react/optimistic/staysOffTheEntry.spec.ts +38 -0
- package/src/styles-areas/analytics.css +5 -5
- package/src/styles-areas/data-table.css +5 -5
- package/src/styles-utilities.css +10 -0
package/scripts/styleAreas.ts
CHANGED
|
@@ -122,11 +122,14 @@ export const AREAS: readonly Area[] = [
|
|
|
122
122
|
'authSignal.ts',
|
|
123
123
|
'sessionAuth.ts',
|
|
124
124
|
'createApiHooks.ts',
|
|
125
|
+
// Draws nothing; `createApiHooks` imports it, and every app's changes go through it (STA-10).
|
|
126
|
+
'optimistic',
|
|
125
127
|
'usePagedList.ts',
|
|
126
128
|
'lazy',
|
|
127
129
|
'confirm.tsx',
|
|
128
130
|
'prompt.tsx',
|
|
129
131
|
'Toast.tsx',
|
|
132
|
+
'LazyToaster.tsx',
|
|
130
133
|
'colorScheme',
|
|
131
134
|
// The look store and its picker: the sibling of colorScheme, installed at boot beside it.
|
|
132
135
|
'look',
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { act, render } from '@testing-library/react';
|
|
3
|
+
import { LazyToaster } from './LazyToaster.js';
|
|
4
|
+
import { toast } from './Toast.js';
|
|
5
|
+
|
|
6
|
+
describe('LazyToaster', () => {
|
|
7
|
+
test('mounts the stack once its chunk lands, and a toast shows in it', async () => {
|
|
8
|
+
const { container } = render(<LazyToaster />);
|
|
9
|
+
await act(async () => {
|
|
10
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
11
|
+
});
|
|
12
|
+
await act(async () => {
|
|
13
|
+
toast('Saved');
|
|
14
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
15
|
+
});
|
|
16
|
+
expect(container.ownerDocument.body.textContent).toContain('Saved');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<LazyToaster />` — the toast stack, mounted without putting sonner on the entry chunk.
|
|
3
|
+
*
|
|
4
|
+
* Rollup colors a module into the chunk of its static importers, so an app that mounts `Toaster`
|
|
5
|
+
* from its root pays for the whole stack (~15 kB gzip, measured in family, forge task 2900) on
|
|
6
|
+
* first paint, however lazily its toasts are CALLED (`Button`, `optimistic()`). family solved it
|
|
7
|
+
* by hand with a lazy wrapper of its own; flix, roms and station mounted `Toaster` statically the
|
|
8
|
+
* day they first needed a toast (forge task 3583) and their entries grew by it. This is that
|
|
9
|
+
* wrapper, once: the stack's chunk loads after first paint, and a toast raised before it lands
|
|
10
|
+
* waits for it (sonner queues calls made before its Toaster mounts).
|
|
11
|
+
*
|
|
12
|
+
* Takes `Toaster`'s props. Mount it once, at the app's root, in place of `Toaster`.
|
|
13
|
+
*/
|
|
14
|
+
import { Suspense } from 'react';
|
|
15
|
+
import { lazyWithReload } from './lazy/staleChunk.js';
|
|
16
|
+
import type { ToasterProps } from './Toast.js';
|
|
17
|
+
|
|
18
|
+
const Stack = lazyWithReload(() => import('./Toast.js').then((m) => ({ default: m.Toaster })));
|
|
19
|
+
|
|
20
|
+
export function LazyToaster(props: ToasterProps) {
|
|
21
|
+
return (
|
|
22
|
+
<Suspense fallback={null}>
|
|
23
|
+
<Stack {...props} />
|
|
24
|
+
</Suspense>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -260,6 +260,19 @@ export interface DataTableProps<TRow extends Record<string, unknown>> {
|
|
|
260
260
|
* lists must never strand the reader without column context — STANDARDS §5c).
|
|
261
261
|
* Works inside any vertical scroll ancestor (`ScrollArea`). */
|
|
262
262
|
stickyHeader?: boolean;
|
|
263
|
+
/**
|
|
264
|
+
* Draw the column-header row. Default `true`.
|
|
265
|
+
*
|
|
266
|
+
* `false` for a list whose columns say what they are by their content — music's tracks, where a
|
|
267
|
+
* title and a length need no `TITLE` / `LENGTH` over them (owner, 2026-10-23: *"remove the row
|
|
268
|
+
* of table headers like "TITLE" because it will save space and users will already know they are
|
|
269
|
+
* seeing the title and length"*). The row is collapsed to nothing rather than removed: each `<th>`
|
|
270
|
+
* keeps its width (a `table-fixed` table takes its column tracks from it) and its name, read by a
|
|
271
|
+
* screen reader only, so a cell is still announced under its column. What the row carried that a
|
|
272
|
+
* reader acts on moves: the select-all box to its own line above the rows (the line the phone
|
|
273
|
+
* cards already draw), and sorting to the caller's own sort control — a hidden header sorts nothing.
|
|
274
|
+
*/
|
|
275
|
+
columnHeaders?: boolean;
|
|
263
276
|
/**
|
|
264
277
|
* Freeze the FIRST visible column so it stays on screen while the rest of the
|
|
265
278
|
* table scrolls sideways (owner, 2026-08-15, on auth's people matrix: *"if the
|
|
@@ -890,6 +903,12 @@ const HIDE_BELOW_CLASS = {
|
|
|
890
903
|
} as const;
|
|
891
904
|
|
|
892
905
|
/** `ColDef.priority`'s collapse — {@link HIDE_BELOW_CLASS}'s, unconditional, for the same reason. */
|
|
906
|
+
/**
|
|
907
|
+
* A header cell of a table whose header row is not drawn (`columnHeaders={false}`): no height, no
|
|
908
|
+
* rule under it, its width kept — `table-fixed` takes the column tracks from this row — and its
|
|
909
|
+
* content an sr-only name.
|
|
910
|
+
*/
|
|
911
|
+
const HIDDEN_HEADER_CELL = 'h-0 py-0! border-b-0! leading-none';
|
|
893
912
|
const COLLAPSED_CLASS = 'w-0! min-w-0! p-0! border-0 invisible text-[length:0] [&>*]:hidden';
|
|
894
913
|
const NOTHING_DROPPED: ReadonlySet<string> = new Set();
|
|
895
914
|
|
|
@@ -955,7 +974,8 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
955
974
|
hidePagination,
|
|
956
975
|
caption,
|
|
957
976
|
className,
|
|
958
|
-
stickyHeader,
|
|
977
|
+
stickyHeader: stickyHeaderProp,
|
|
978
|
+
columnHeaders = true,
|
|
959
979
|
stickyFirstColumn,
|
|
960
980
|
renderExpanded,
|
|
961
981
|
expandedRowId,
|
|
@@ -1006,6 +1026,8 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1006
1026
|
columnsPersistKey,
|
|
1007
1027
|
...passThrough
|
|
1008
1028
|
}: DataTableProps<TRow>) {
|
|
1029
|
+
// A header that is not drawn has nothing to pin (see `columnHeaders`).
|
|
1030
|
+
const stickyHeader = stickyHeaderProp && columnHeaders;
|
|
1009
1031
|
// Vertical cell padding for the wide grid tier — the one lever the density knob
|
|
1010
1032
|
// pulls (headers + body cells stay aligned by sharing it).
|
|
1011
1033
|
const cellPadY = density === 'compact' ? 'py-1.5' : 'py-2.5';
|
|
@@ -1889,11 +1911,12 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
1889
1911
|
beside them: a card grid already reflows, so a second breakpoint
|
|
1890
1912
|
ladder inside it would only fight the container query. ── */}
|
|
1891
1913
|
{/* Select-all for the tiers with no header row to carry it: the phone
|
|
1892
|
-
cards,
|
|
1893
|
-
|
|
1914
|
+
cards, the card view at every width, and every tier of a table whose header row
|
|
1915
|
+
is not drawn (`columnHeaders`). */}
|
|
1916
|
+
{selectionShown && (viewMode === 'card' || mobileCards || !columnHeaders) ? (
|
|
1894
1917
|
<div
|
|
1895
1918
|
data-cb-table-select-all=''
|
|
1896
|
-
className={cn('flex items-center px-2 py-1', viewMode !== 'card' && '@xl:hidden')}
|
|
1919
|
+
className={cn('flex items-center px-2 py-1', viewMode !== 'card' && columnHeaders && '@xl:hidden')}
|
|
1897
1920
|
>
|
|
1898
1921
|
{headerCheckbox('Select all')}
|
|
1899
1922
|
</div>
|
|
@@ -2124,7 +2147,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
2124
2147
|
});
|
|
2125
2148
|
return (
|
|
2126
2149
|
<>
|
|
2127
|
-
{header}
|
|
2150
|
+
{columnHeaders ? header : null}
|
|
2128
2151
|
<div
|
|
2129
2152
|
ref={activeTier === 'list' && narrowTiers ? (bodyRef as Ref<HTMLDivElement>) : undefined}
|
|
2130
2153
|
className='divide-y divide-border'
|
|
@@ -2213,6 +2236,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
2213
2236
|
// rides the <th> (not the <tr>) so it pins with stickyHeader.
|
|
2214
2237
|
'border-b border-border px-4 text-caption font-bold uppercase tracking-wider text-muted-foreground',
|
|
2215
2238
|
cellPadY,
|
|
2239
|
+
!columnHeaders && HIDDEN_HEADER_CELL,
|
|
2216
2240
|
align === 'right' ? 'text-right' : 'text-left',
|
|
2217
2241
|
meta?.numeric && 'tabular-nums',
|
|
2218
2242
|
// Under `fit` the column takes its CONTENT's width, and a truncating
|
|
@@ -2237,7 +2261,11 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
2237
2261
|
>
|
|
2238
2262
|
{h.isPlaceholder
|
|
2239
2263
|
? null
|
|
2240
|
-
:
|
|
2264
|
+
: columnHeaders
|
|
2265
|
+
? renderTemplate(h.column.columnDef.header, h.getContext())
|
|
2266
|
+
: // The name alone, for a screen reader: the header's sort button would be
|
|
2267
|
+
// a Tab stop nobody can see.
|
|
2268
|
+
<span className='sr-only'>{labelOf(h.column.id)}</span>}
|
|
2241
2269
|
</th>
|
|
2242
2270
|
);
|
|
2243
2271
|
};
|
|
@@ -2254,12 +2282,13 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
2254
2282
|
data-cb-selection-cell=''
|
|
2255
2283
|
className={cn(
|
|
2256
2284
|
'w-11 border-b border-border px-0 text-center',
|
|
2285
|
+
!columnHeaders && HIDDEN_HEADER_CELL,
|
|
2257
2286
|
stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm',
|
|
2258
2287
|
stickyFirstColumn && 'sticky left-0 bg-card',
|
|
2259
2288
|
stickyFirstColumn && (stickyHeader ? 'z-30' : 'z-20'),
|
|
2260
2289
|
)}
|
|
2261
2290
|
>
|
|
2262
|
-
{headerCheckbox()}
|
|
2291
|
+
{columnHeaders ? headerCheckbox() : <span className='sr-only'>Select</span>}
|
|
2263
2292
|
</th>
|
|
2264
2293
|
) : null}
|
|
2265
2294
|
{hg.headers.filter((h) => !isLeading(h)).map(renderHeader)}
|
|
@@ -2275,6 +2304,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
|
|
|
2275
2304
|
: kebabOnly
|
|
2276
2305
|
? 'w-11 border-b border-border px-0'
|
|
2277
2306
|
: 'w-20 border-b border-border px-2',
|
|
2307
|
+
!columnHeaders && HIDDEN_HEADER_CELL,
|
|
2278
2308
|
stickyHeader && 'sticky top-0 z-10 bg-card/95 backdrop-blur-sm',
|
|
2279
2309
|
)}
|
|
2280
2310
|
aria-label='Row actions'
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { DataTable, preloadDataTableParts } from './DataTable.js';
|
|
5
|
+
import type { ColDef } from './types.js';
|
|
6
|
+
|
|
7
|
+
// `columnHeaders={false}` — the owner, 2026-10-23, on music's tracks: *"remove the row of table
|
|
8
|
+
// headers like "TITLE" because it will save space"*. The row is collapsed, not deleted: its
|
|
9
|
+
// widths and its screen-reader names stay, and what a reader acts on in it moves.
|
|
10
|
+
beforeAll(preloadDataTableParts);
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
|
|
13
|
+
interface Song extends Record<string, unknown> {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
seconds: number;
|
|
17
|
+
}
|
|
18
|
+
const COLUMNS: ColDef<Song>[] = [
|
|
19
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true },
|
|
20
|
+
{ key: 'seconds', label: 'Length', type: 'number', sortable: true, width: 80 },
|
|
21
|
+
];
|
|
22
|
+
const SONGS: Song[] = [
|
|
23
|
+
{ id: 's1', title: 'Alpha', seconds: 10 },
|
|
24
|
+
{ id: 's2', title: 'Beta', seconds: 20 },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function Picking({ columnHeaders }: { columnHeaders: boolean }) {
|
|
28
|
+
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
|
29
|
+
return (
|
|
30
|
+
<DataTable
|
|
31
|
+
columnHeaders={columnHeaders}
|
|
32
|
+
columns={COLUMNS}
|
|
33
|
+
data={SONGS}
|
|
34
|
+
getRowId={(row) => row.id}
|
|
35
|
+
mobileCards={false}
|
|
36
|
+
onSelectionChange={setSelected}
|
|
37
|
+
selectedIds={selected}
|
|
38
|
+
stickyHeader
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('DataTable columnHeaders={false}', () => {
|
|
44
|
+
test('🔴 no column name is DRAWN, and no sort button stands in the row', () => {
|
|
45
|
+
const { container } = render(<Picking columnHeaders={false} />);
|
|
46
|
+
const ths = [...container.querySelectorAll('thead th')];
|
|
47
|
+
expect(ths.length).toBe(COLUMNS.length + 1);
|
|
48
|
+
for (const th of ths) {
|
|
49
|
+
// Every cell is a zero-height cell whose only content is an sr-only name.
|
|
50
|
+
expect(th.className).toContain('h-0');
|
|
51
|
+
expect(th.className).not.toContain('sticky');
|
|
52
|
+
for (const child of th.children) expect(child.className).toContain('sr-only');
|
|
53
|
+
}
|
|
54
|
+
expect(container.querySelector('thead button')).toBeNull();
|
|
55
|
+
expect(container.querySelector('thead [role="checkbox"]')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the names are still there for a screen reader, and the widths for the column tracks', () => {
|
|
59
|
+
const { container } = render(<Picking columnHeaders={false} />);
|
|
60
|
+
const names = [...container.querySelectorAll('thead th[scope="col"]')].map((th) => th.textContent);
|
|
61
|
+
expect(names).toEqual(['Select', 'Title', 'Length']);
|
|
62
|
+
const length = [...container.querySelectorAll('thead th')].find((th) => th.textContent === 'Length') as HTMLElement;
|
|
63
|
+
expect(length.style.width).toBe('80px');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('select-all moves to its own line above the rows, and still selects', () => {
|
|
67
|
+
const { container } = render(<Picking columnHeaders={false} />);
|
|
68
|
+
const line = container.querySelector('[data-cb-table-select-all]') as HTMLElement;
|
|
69
|
+
expect(line).not.toBeNull();
|
|
70
|
+
// Shown at every width, not only under the phone breakpoint.
|
|
71
|
+
expect(line.className).not.toContain('@xl:hidden');
|
|
72
|
+
fireEvent.click(line.querySelector('[role="checkbox"]') as HTMLElement);
|
|
73
|
+
expect(container.querySelectorAll('tbody [role="checkbox"][aria-checked="true"]').length).toBe(SONGS.length);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('the default draws the row exactly as before', () => {
|
|
77
|
+
const { container } = render(<Picking columnHeaders />);
|
|
78
|
+
expect(container.querySelector('thead button')).not.toBeNull();
|
|
79
|
+
expect(container.querySelector('thead [role="checkbox"]')).not.toBeNull();
|
|
80
|
+
expect(container.querySelector('thead th')?.className).toContain('sticky');
|
|
81
|
+
expect(container.querySelector('[data-cb-table-select-all]')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -33,6 +33,13 @@ export interface DrawerProps {
|
|
|
33
33
|
open: boolean;
|
|
34
34
|
onClose: () => void;
|
|
35
35
|
title?: ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* Icon controls on the title's line, before Close — a sidebar drawer's utilities (help, theme,
|
|
38
|
+
* account, collapse-all), where an app with no top bar keeps them on a phone (owner,
|
|
39
|
+
* 2026-10-23, music: *"Put all the other header buttons in the side menu"*). One line, so the
|
|
40
|
+
* drawer spends no second row on them.
|
|
41
|
+
*/
|
|
42
|
+
actions?: ReactNode;
|
|
36
43
|
/** Width on a left/right drawer, height on a bottom drawer. */
|
|
37
44
|
size?: DrawerSize;
|
|
38
45
|
side?: DrawerSide;
|
|
@@ -74,6 +81,7 @@ export function Drawer({
|
|
|
74
81
|
open,
|
|
75
82
|
onClose,
|
|
76
83
|
title,
|
|
84
|
+
actions,
|
|
77
85
|
size = 'md',
|
|
78
86
|
side = 'right',
|
|
79
87
|
isDirty,
|
|
@@ -149,6 +157,11 @@ export function Drawer({
|
|
|
149
157
|
) : (
|
|
150
158
|
<DialogTitle className='sr-only'>Panel</DialogTitle>
|
|
151
159
|
)}
|
|
160
|
+
{actions ? (
|
|
161
|
+
<div data-cb-drawer-actions='' className='flex shrink-0 items-center gap-0.5'>
|
|
162
|
+
{actions}
|
|
163
|
+
</div>
|
|
164
|
+
) : null}
|
|
152
165
|
<IconButton label='Close' onClick={guardedClose}>
|
|
153
166
|
<X />
|
|
154
167
|
</IconButton>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
|
|
3
3
|
import { confirm } from '../confirm.js';
|
|
4
|
+
import { Button } from './Button.js';
|
|
4
5
|
import { ConfirmDialog } from './ConfirmDialog.js';
|
|
5
6
|
import { Drawer } from './Drawer.js';
|
|
6
7
|
import { Input } from './Input.js';
|
|
@@ -336,6 +337,19 @@ describe('Drawer', () => {
|
|
|
336
337
|
expect(panel?.className).toContain('max-h-[var(--sheet-h-lg)]');
|
|
337
338
|
});
|
|
338
339
|
|
|
340
|
+
test('`actions` stand on the title line, before Close (a phone sidebar keeps its utilities there)', () => {
|
|
341
|
+
render(
|
|
342
|
+
<Drawer actions={<Button tooltip='Help'>Help</Button>} open onClose={() => {}} side='left' title='Music'>
|
|
343
|
+
x
|
|
344
|
+
</Drawer>,
|
|
345
|
+
);
|
|
346
|
+
const slot = document.querySelector('[data-cb-drawer-actions]') as HTMLElement;
|
|
347
|
+
expect(slot.textContent).toBe('Help');
|
|
348
|
+
const close = document.querySelector('[data-cb-drawer-handle] button[aria-label="Close"]') as HTMLElement;
|
|
349
|
+
expect(slot.compareDocumentPosition(close) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
350
|
+
expect(slot.parentElement).toBe(close.parentElement);
|
|
351
|
+
});
|
|
352
|
+
|
|
339
353
|
test('left side: slides in from the left', () => {
|
|
340
354
|
render(
|
|
341
355
|
<Drawer open onClose={() => {}} side='left' title='Palette'>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { Button } from './Button.js';
|
|
3
4
|
import { GuideRoute, GuideSection } from './Guide.js';
|
|
4
5
|
import { PageContainer, PageHeader, ReadableColumn } from './Page.js';
|
|
5
6
|
|
|
@@ -58,6 +59,25 @@ describe('PageHeader — NAV-11: one h1, a one-line subtitle', () => {
|
|
|
58
59
|
expect(container.querySelector('[data-cb-page-subtitle] button')).toBeNull();
|
|
59
60
|
});
|
|
60
61
|
|
|
62
|
+
test('`leading` stands before the title on its line, OUTSIDE the heading', () => {
|
|
63
|
+
// The phone's sidebar button in an app with no top bar (owner, 2026-10-23, music).
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<PageHeader leading={<Button tooltip='Open the sidebar'>Open the sidebar</Button>} title='Queue' />,
|
|
66
|
+
);
|
|
67
|
+
const h1 = container.querySelector('h1') as HTMLElement;
|
|
68
|
+
expect(h1.textContent).toBe('Queue');
|
|
69
|
+
const slot = container.querySelector('[data-cb-page-header-leading]') as HTMLElement;
|
|
70
|
+
expect(slot.textContent).toBe('Open the sidebar');
|
|
71
|
+
expect(slot.contains(h1)).toBe(false);
|
|
72
|
+
// Before the title, in document order.
|
|
73
|
+
expect(slot.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('no `leading`, no slot', () => {
|
|
77
|
+
const { container } = render(<PageHeader title='Queue' />);
|
|
78
|
+
expect(container.querySelector('[data-cb-page-header-leading]')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
61
81
|
test('🔴 a subtitle wider than its line gets an InfoHint carrying the whole text', () => {
|
|
62
82
|
const proto = HTMLElement.prototype;
|
|
63
83
|
const scroll = Object.getOwnPropertyDescriptor(proto, 'scrollWidth');
|
|
@@ -100,6 +100,14 @@ export function ReadableColumn({ className, ...p }: HTMLAttributes<HTMLDivElemen
|
|
|
100
100
|
|
|
101
101
|
export interface PageHeaderProps {
|
|
102
102
|
title: ReactNode;
|
|
103
|
+
/**
|
|
104
|
+
* One control BEFORE the title, on its line and outside the heading — the phone's sidebar button,
|
|
105
|
+
* where an app with no top bar keeps its way into the sidebar (owner, 2026-10-23, music: *"put the
|
|
106
|
+
* sidebar opening icon for small views to be to the left of the folder or section name … There
|
|
107
|
+
* will not be a top header row anymore"*; Linear's and Slack's phone layouts). Outside the `h1`,
|
|
108
|
+
* so the heading's name is the title alone.
|
|
109
|
+
*/
|
|
110
|
+
leading?: ReactNode;
|
|
103
111
|
/**
|
|
104
112
|
* Inline telemetry beside the title — metric pills (`Badge`/`StatusPill`, e.g.
|
|
105
113
|
* `19 apps` `1 running`), never a raw muted-text counter string that disappears
|
|
@@ -154,6 +162,7 @@ const SHORT_LANDSCAPE_TITLE = '[@media(orientation:landscape)_and_(max-height:50
|
|
|
154
162
|
*/
|
|
155
163
|
export function PageHeader({
|
|
156
164
|
title,
|
|
165
|
+
leading,
|
|
157
166
|
subtitle,
|
|
158
167
|
meta,
|
|
159
168
|
actions,
|
|
@@ -167,6 +176,23 @@ export function PageHeader({
|
|
|
167
176
|
}: PageHeaderProps) {
|
|
168
177
|
const Heading = level === 2 ? 'h2' : 'h1';
|
|
169
178
|
const inContainer = useContext(InPageContainer);
|
|
179
|
+
const titleBlock = (
|
|
180
|
+
<div className='min-w-0'>
|
|
181
|
+
<div className='flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1'>
|
|
182
|
+
<Heading
|
|
183
|
+
className={cn(
|
|
184
|
+
'font-bold tracking-tight text-foreground',
|
|
185
|
+
dense ? 'text-h3' : cn('text-h1', SHORT_LANDSCAPE_TITLE),
|
|
186
|
+
!titleOnMobile && 'hidden sm:block',
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{title}
|
|
190
|
+
</Heading>
|
|
191
|
+
{meta && <span className='flex flex-wrap items-center gap-1.5'>{meta}</span>}
|
|
192
|
+
</div>
|
|
193
|
+
{subtitle && <PageSubtitle dense={dense}>{subtitle}</PageSubtitle>}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
170
196
|
return (
|
|
171
197
|
<Split
|
|
172
198
|
{...rest}
|
|
@@ -184,21 +210,16 @@ export function PageHeader({
|
|
|
184
210
|
className,
|
|
185
211
|
)}
|
|
186
212
|
>
|
|
187
|
-
|
|
188
|
-
<div className='flex min-w-0
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
!titleOnMobile && 'hidden sm:block',
|
|
194
|
-
)}
|
|
195
|
-
>
|
|
196
|
-
{title}
|
|
197
|
-
</Heading>
|
|
198
|
-
{meta && <span className='flex flex-wrap items-center gap-1.5'>{meta}</span>}
|
|
213
|
+
{leading != null ? (
|
|
214
|
+
<div className='flex min-w-0 items-center gap-2'>
|
|
215
|
+
<span data-cb-page-header-leading='' className='flex shrink-0 items-center'>
|
|
216
|
+
{leading}
|
|
217
|
+
</span>
|
|
218
|
+
{titleBlock}
|
|
199
219
|
</div>
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
) : (
|
|
221
|
+
titleBlock
|
|
222
|
+
)}
|
|
202
223
|
{(actions || guide) && (
|
|
203
224
|
<div className='flex items-center gap-2'>
|
|
204
225
|
{guide ? <PageGuideButton>{guide}</PageGuideButton> : null}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, type Mock, spyOn, test } from 'bun:test';
|
|
2
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
+
import { act, renderHook } from '@testing-library/react';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { createApiHooks, OptimisticFailure } from './createApiHooks.js';
|
|
6
|
+
import { toast } from './Toast.js';
|
|
7
|
+
|
|
8
|
+
type Rom = { id: string; favorite: boolean };
|
|
9
|
+
|
|
10
|
+
let toastError: Mock<typeof toast.error>;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
toastError = spyOn(toast, 'error').mockImplementation(() => 0);
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => toastError.mockRestore());
|
|
15
|
+
|
|
16
|
+
const setup = (request: (rom: Rom) => Promise<void>) => {
|
|
17
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
18
|
+
queryClient.setQueryData<Rom[]>(['roms'], [{ id: 'r1', favorite: false }]);
|
|
19
|
+
const appToaster = { success: () => undefined, error: () => undefined };
|
|
20
|
+
const errorSpy = spyOn(appToaster, 'error');
|
|
21
|
+
const { useApiMutation } = createApiHooks({ useClient: () => null, useToaster: () => appToaster });
|
|
22
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
23
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
24
|
+
);
|
|
25
|
+
const { result } = renderHook(
|
|
26
|
+
() =>
|
|
27
|
+
useApiMutation<Rom, void>({
|
|
28
|
+
mutationFn: (_client, rom) => request(rom),
|
|
29
|
+
optimistic: {
|
|
30
|
+
label: (rom) => `favorite ${rom.id}`,
|
|
31
|
+
edits: (rom) => [
|
|
32
|
+
{
|
|
33
|
+
queryKey: ['roms'],
|
|
34
|
+
apply: (roms: Rom[]) => roms.map((r) => (r.id === rom.id ? { ...r, favorite: !r.favorite } : r)),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
{ wrapper },
|
|
40
|
+
);
|
|
41
|
+
return { queryClient, result, errorSpy };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('useApiMutation — optimistic', () => {
|
|
45
|
+
test('the cache changes before the request settles', async () => {
|
|
46
|
+
let finish!: () => void;
|
|
47
|
+
const { queryClient, result } = setup(() => new Promise<void>((resolve) => (finish = resolve)));
|
|
48
|
+
let done!: Promise<void>;
|
|
49
|
+
act(() => {
|
|
50
|
+
done = result.current.mutateAsync({ id: 'r1', favorite: false });
|
|
51
|
+
});
|
|
52
|
+
await act(async () => {
|
|
53
|
+
await Promise.resolve();
|
|
54
|
+
await Promise.resolve();
|
|
55
|
+
});
|
|
56
|
+
expect(queryClient.getQueryData<Rom[]>(['roms'])?.[0]?.favorite).toBe(true);
|
|
57
|
+
await act(async () => {
|
|
58
|
+
finish();
|
|
59
|
+
await done;
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a refusal reverts the cache, shows ONE Retry toast, and rejects with the cause', async () => {
|
|
64
|
+
const { queryClient, result, errorSpy } = setup(() => Promise.reject(new Error('nope')));
|
|
65
|
+
let caught: unknown;
|
|
66
|
+
await act(async () => {
|
|
67
|
+
await result.current.mutateAsync({ id: 'r1', favorite: false }).catch((e: unknown) => {
|
|
68
|
+
caught = e;
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
expect(queryClient.getQueryData<Rom[]>(['roms'])?.[0]?.favorite).toBe(false);
|
|
72
|
+
expect(caught).toBeInstanceOf(OptimisticFailure);
|
|
73
|
+
expect((caught as Error).cause).toBeInstanceOf(Error);
|
|
74
|
+
expect(toastError).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
useQueryClient,
|
|
7
7
|
} from '@tanstack/react-query';
|
|
8
8
|
import type { ReactNode } from 'react';
|
|
9
|
+
import { type OptimisticQueryClient, optimistic as runOptimistic, queryTarget } from './optimistic/optimistic.js';
|
|
10
|
+
import type { IdOf } from './optimistic/revert.js';
|
|
9
11
|
|
|
10
12
|
// IMPORTANT: this factory's PUBLIC surface is deliberately free of @tanstack's
|
|
11
13
|
// class-shaped types (UseQueryResult/UseQueryOptions/QueryClient). Those carry
|
|
@@ -121,6 +123,32 @@ export interface ApiMutationConfig<Client, TArgs, TData> {
|
|
|
121
123
|
onSuccess?: (data: TData, args: TArgs) => void;
|
|
122
124
|
/** Extra error handling, in addition to the automatic error toast. */
|
|
123
125
|
onError?: (error: unknown, args: TArgs) => void;
|
|
126
|
+
/**
|
|
127
|
+
* Show the change before the server answers (STA-10). Each edit is a cache entry and the
|
|
128
|
+
* forward change to its data; the rollback is derived, and a refusal reverts only what this
|
|
129
|
+
* call touched and toasts "Couldn't <label>" with Retry — in place of the plain error toast.
|
|
130
|
+
* Every edited key is refetched once the request settles.
|
|
131
|
+
*
|
|
132
|
+
* optimistic: {
|
|
133
|
+
* label: (rom) => `favorite “${rom.title}”`,
|
|
134
|
+
* edits: (rom) => [{ queryKey: ['roms'], apply: (roms: Rom[]) => roms.map(...) }],
|
|
135
|
+
* }
|
|
136
|
+
*/
|
|
137
|
+
optimistic?: ApiOptimistic<TArgs>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ApiOptimistic<TArgs> {
|
|
141
|
+
/** The verb phrase the failure toast completes: "Couldn't " + label. */
|
|
142
|
+
label: string | ((args: TArgs) => string);
|
|
143
|
+
/** The cache entries to change at once, each with its forward edit. */
|
|
144
|
+
edits: (args: TArgs) => ReadonlyArray<{
|
|
145
|
+
queryKey: QueryKey;
|
|
146
|
+
apply: (data: any) => any;
|
|
147
|
+
/** How the entry's array items are told apart when they carry neither `id` nor `key`. */
|
|
148
|
+
idOf?: IdOf;
|
|
149
|
+
}>;
|
|
150
|
+
/** Serialize requests that share a key — e.g. the record's id. */
|
|
151
|
+
key?: (args: TArgs) => string;
|
|
124
152
|
}
|
|
125
153
|
|
|
126
154
|
export interface ApiHooks<Client> {
|
|
@@ -130,6 +158,14 @@ export interface ApiHooks<Client> {
|
|
|
130
158
|
) => ApiMutationResult<TArgs, TData>;
|
|
131
159
|
}
|
|
132
160
|
|
|
161
|
+
/** The rejection an optimistic mutation's `mutateAsync` gives: the server's own error is `cause`. */
|
|
162
|
+
export class OptimisticFailure extends Error {
|
|
163
|
+
constructor(cause: unknown) {
|
|
164
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
165
|
+
this.name = 'OptimisticFailure';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
133
169
|
/**
|
|
134
170
|
* Build a pair of TanStack-Query hooks bound to your app's request client + toaster.
|
|
135
171
|
* Standardizes the data-fetching boilerplate every app reinvents: apply a fallback
|
|
@@ -182,12 +218,28 @@ export const createApiHooks = <Client>({
|
|
|
182
218
|
invalidate,
|
|
183
219
|
onSuccess,
|
|
184
220
|
onError,
|
|
221
|
+
optimistic,
|
|
185
222
|
}: ApiMutationConfig<Client, TArgs, TData>): ApiMutationResult<TArgs, TData> => {
|
|
186
223
|
const client = useClient();
|
|
187
224
|
const queryClient = useQueryClient();
|
|
188
225
|
const toaster = useToaster?.();
|
|
189
226
|
return useMutation<TData, unknown, TArgs>({
|
|
190
|
-
mutationFn:
|
|
227
|
+
mutationFn: optimistic
|
|
228
|
+
? async (args) => {
|
|
229
|
+
const outcome = await runOptimistic({
|
|
230
|
+
label: typeof optimistic.label === 'function' ? optimistic.label(args) : optimistic.label,
|
|
231
|
+
key: optimistic.key?.(args),
|
|
232
|
+
targets: optimistic.edits(args).map(({ queryKey, apply, idOf }) => ({
|
|
233
|
+
...queryTarget(queryClient as unknown as OptimisticQueryClient, queryKey),
|
|
234
|
+
apply,
|
|
235
|
+
...(idOf ? { idOf } : {}),
|
|
236
|
+
})),
|
|
237
|
+
request: () => mutationFn(client, args),
|
|
238
|
+
});
|
|
239
|
+
if (!outcome.ok) throw new OptimisticFailure(outcome.error);
|
|
240
|
+
return outcome.value;
|
|
241
|
+
}
|
|
242
|
+
: (args) => mutationFn(client, args),
|
|
191
243
|
onSuccess: (data, args) => {
|
|
192
244
|
if (successToast && toaster) {
|
|
193
245
|
toaster.success(
|
|
@@ -201,6 +253,11 @@ export const createApiHooks = <Client>({
|
|
|
201
253
|
onSuccess?.(data, args);
|
|
202
254
|
},
|
|
203
255
|
onError: (error, args) => {
|
|
256
|
+
// An optimistic call has already reverted and shown its own Retry toast.
|
|
257
|
+
if (error instanceof OptimisticFailure) {
|
|
258
|
+
onError?.(error.cause, args);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
204
261
|
toaster?.error(error);
|
|
205
262
|
onError?.(error, args);
|
|
206
263
|
},
|
|
@@ -678,6 +678,20 @@ describe('where you are', () => {
|
|
|
678
678
|
expect(scrolled).toEqual(['Dinos']);
|
|
679
679
|
});
|
|
680
680
|
|
|
681
|
+
test('🔴 a revealed row lands in the MIDDLE of the tree, as VS Code lands it — not on its last line', () => {
|
|
682
|
+
// Owner, 2026-10-23: `nearest` parked a row revealed from below at the bottom of the sidebar.
|
|
683
|
+
const asked: (ScrollIntoViewOptions | boolean | undefined)[] = [];
|
|
684
|
+
const view = tree({ selectedId: null });
|
|
685
|
+
for (const row of screen.getAllByRole('treeitem') as HTMLElement[]) {
|
|
686
|
+
row.scrollIntoView = (options?: ScrollIntoViewOptions | boolean) => asked.push(options);
|
|
687
|
+
row.getBoundingClientRect = () => ({ top: 900, bottom: 920 }) as DOMRect;
|
|
688
|
+
}
|
|
689
|
+
(view.container.querySelector('.cbft-scroll') as HTMLElement).getBoundingClientRect = () =>
|
|
690
|
+
({ top: 0, bottom: 400 }) as DOMRect;
|
|
691
|
+
view.rerender(<FileTree nodes={TREE} selectedId='dinos' onSelect={() => undefined} />);
|
|
692
|
+
expect(asked).toEqual([{ block: 'center', inline: 'nearest' }]);
|
|
693
|
+
});
|
|
694
|
+
|
|
681
695
|
test('a row already in view does not jump', () => {
|
|
682
696
|
const scrolled: string[] = [];
|
|
683
697
|
const view = tree({ selectedId: null });
|
|
@@ -1462,9 +1462,15 @@ export function FileTree({
|
|
|
1462
1462
|
*/
|
|
1463
1463
|
const frame = (scroll === 'host' ? hostScroller(scroller) : scroller).getBoundingClientRect();
|
|
1464
1464
|
if (box.top >= frame.top && box.bottom <= frame.bottom) return;
|
|
1465
|
-
// `
|
|
1466
|
-
//
|
|
1467
|
-
|
|
1465
|
+
// 🔴 `center`, as VS Code's explorer lands a revealed file: in the middle of the tree, with its
|
|
1466
|
+
// neighbors above and below it, wherever the scroll has room (the browser clamps it at either
|
|
1467
|
+
// end). It was `nearest`, the least movement, which parked a row revealed from below on the
|
|
1468
|
+
// tree's last line — owner, 2026-10-23, music's "Show in folders": *"have them show the
|
|
1469
|
+
// highlighted track in the middle of the screen when there is enough scroll space, instead of
|
|
1470
|
+
// letting them be at other places like at the bottom of the side menu. This is how vscode shows
|
|
1471
|
+
// files when you click between them in the file tree."* `nearest` sideways: no travel in a list
|
|
1472
|
+
// that does not scroll sideways.
|
|
1473
|
+
row.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
1468
1474
|
}, [selectedId, revealOpened, revealKey]);
|
|
1469
1475
|
|
|
1470
1476
|
// ── The drag ──────────────────────────────────────────────────────────────────────────
|