cursedbelt 5.90.0 → 5.92.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/activity-bar/activityBar.css +5 -0
- package/dist/react/components/Autocomplete.d.ts +11 -2
- package/dist/react/components/Autocomplete.d.ts.map +1 -1
- package/dist/react/components/Autocomplete.js +7 -6
- package/dist/react/components/Autocomplete.js.map +1 -1
- 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/FileDropZone.d.ts.map +1 -1
- package/dist/react/components/FileDropZone.js +12 -76
- package/dist/react/components/FileDropZone.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/components/TagsInput.d.ts +46 -4
- package/dist/react/components/TagsInput.d.ts.map +1 -1
- package/dist/react/components/TagsInput.js +92 -39
- package/dist/react/components/TagsInput.js.map +1 -1
- package/dist/react/components/TagsInputSuggesting.d.ts +8 -0
- package/dist/react/components/TagsInputSuggesting.d.ts.map +1 -0
- package/dist/react/components/TagsInputSuggesting.js +48 -0
- package/dist/react/components/TagsInputSuggesting.js.map +1 -0
- package/dist/react/components/dropTransfer.d.ts +53 -0
- package/dist/react/components/dropTransfer.d.ts.map +1 -0
- package/dist/react/components/dropTransfer.js +116 -0
- package/dist/react/components/dropTransfer.js.map +1 -0
- package/dist/react/components/suggestionList.d.ts +13 -0
- package/dist/react/components/suggestionList.d.ts.map +1 -0
- package/dist/react/components/suggestionList.js +19 -0
- package/dist/react/components/suggestionList.js.map +1 -0
- 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 +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +4 -4
- 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/optimistic.d.ts +11 -2
- package/dist/react/optimistic/optimistic.d.ts.map +1 -1
- package/dist/react/optimistic/optimistic.js +4 -2
- package/dist/react/optimistic/optimistic.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/analytics.css +14 -19
- package/dist/styles-areas/auth.css +3 -4
- package/dist/styles-areas/canvas-hud.css +3 -3
- package/dist/styles-areas/chat.css +10 -11
- package/dist/styles-areas/code-editor.css +2 -2
- package/dist/styles-areas/core.css +60 -54
- package/dist/styles-areas/data-table.css +14 -18
- package/dist/styles-areas/disk-usage.css +6 -7
- package/dist/styles-areas/emoji.css +2 -3
- package/dist/styles-areas/fields.css +15 -20
- package/dist/styles-areas/master-detail.css +3 -4
- package/dist/styles-areas/master-lock.css +1 -2
- package/dist/styles-areas/media-gallery.css +8 -10
- package/dist/styles-areas/rich-text.css +3 -3
- package/dist/styles-areas/stats.css +13 -17
- package/dist/styles-areas/wizard.css +6 -11
- package/dist/styles-utilities.css +10 -0
- package/package.json +7 -1
- package/scripts/guardrails-baseline.json +1 -1
- package/scripts/guardrailsEnforce.spec.ts +13 -0
- package/scripts/guardrailsEnforce.ts +5 -3
- package/scripts/styleAreas.ts +3 -0
- package/src/react/activity-bar/activityBar.css +5 -0
- package/src/react/components/Autocomplete.spec.tsx +55 -1
- package/src/react/components/Autocomplete.tsx +18 -43
- 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/FileDropZone.tsx +12 -83
- 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/components/TagsInput.spec.tsx +129 -14
- package/src/react/components/TagsInput.tsx +153 -85
- package/src/react/components/TagsInputSuggesting.tsx +94 -0
- package/src/react/components/dropTransfer.spec.ts +76 -0
- package/src/react/components/dropTransfer.ts +133 -0
- package/src/react/components/suggestionList.tsx +66 -0
- package/src/react/disk-usage/DiskUsage.spec.tsx +3 -0
- package/src/react/file-tree/FileTree.spec.tsx +14 -0
- package/src/react/file-tree/FileTree.tsx +9 -3
- package/src/react/index.ts +1 -0
- package/src/react/lib/floatingStyles.spec.ts +2 -1
- package/src/react/media-gallery/MediaMetaEditor.tsx +4 -4
- package/src/react/nav/RailPlaces.spec.tsx +6 -0
- package/src/react/nav/RailPlaces.tsx +12 -2
- package/src/react/optimistic/optimistic.spec.tsx +41 -0
- package/src/react/optimistic/optimistic.ts +13 -4
- package/src/styles-areas/analytics.css +14 -19
- package/src/styles-areas/auth.css +3 -4
- package/src/styles-areas/canvas-hud.css +3 -3
- package/src/styles-areas/chat.css +10 -11
- package/src/styles-areas/code-editor.css +2 -2
- package/src/styles-areas/core.css +60 -54
- package/src/styles-areas/data-table.css +14 -18
- package/src/styles-areas/disk-usage.css +6 -7
- package/src/styles-areas/emoji.css +2 -3
- package/src/styles-areas/fields.css +15 -20
- package/src/styles-areas/master-detail.css +3 -4
- package/src/styles-areas/master-lock.css +1 -2
- package/src/styles-areas/media-gallery.css +8 -10
- package/src/styles-areas/rich-text.css +3 -3
- package/src/styles-areas/stats.css +13 -17
- package/src/styles-areas/wizard.css +6 -11
- package/src/styles-utilities.css +10 -0
|
@@ -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>
|
|
@@ -2,6 +2,7 @@ import { Upload, X } from 'lucide-react';
|
|
|
2
2
|
import { type DragEvent, type ReactNode, useId, useRef, useState } from 'react';
|
|
3
3
|
import { cn } from '../lib/cn.js';
|
|
4
4
|
import { Button } from './Button.js';
|
|
5
|
+
import { isHiddenName, matchesAccept, walkDrop } from './dropTransfer.js';
|
|
5
6
|
import { Input } from './Input.js';
|
|
6
7
|
|
|
7
8
|
export interface FileDropZoneProps {
|
|
@@ -51,102 +52,30 @@ export interface FileDropZoneProps {
|
|
|
51
52
|
className?: string;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/** The slice of the File and Directory Entries API a drop needs — every browser ships it. */
|
|
55
|
-
interface DropEntry {
|
|
56
|
-
isFile: boolean;
|
|
57
|
-
isDirectory: boolean;
|
|
58
|
-
name: string;
|
|
59
|
-
file?: (ok: (file: File) => void, fail: (error: unknown) => void) => void;
|
|
60
|
-
createReader?: () => {
|
|
61
|
-
readEntries: (ok: (entries: DropEntry[]) => void, fail: (error: unknown) => void) => void;
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** What a folder can hold before walking it stops — a drop, not a disk scan. */
|
|
66
|
-
const MAX_FOLDER_FILES = 5000;
|
|
67
|
-
|
|
68
|
-
const isHidden = (name: string) => name.startsWith('.');
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Every file under `entry`, depth-first. A directory's reader answers in batches until empty.
|
|
72
|
-
*
|
|
73
|
-
* Each file carries its path from the dropped folder in `webkitRelativePath` (`Album/CD1/01.mp3`),
|
|
74
|
-
* exactly as a folder PICKER's files do natively — so an app reading provenance (music defaults
|
|
75
|
-
* its "where these came from" field to the top folder) gets the same answer from both gestures.
|
|
76
|
-
* A drop leaves it `''` otherwise, and that difference is what kept music on its own walk.
|
|
77
|
-
*/
|
|
78
|
-
async function filesUnder(entry: DropEntry, out: File[], prefix = ''): Promise<void> {
|
|
79
|
-
if (out.length >= MAX_FOLDER_FILES || isHidden(entry.name)) return;
|
|
80
|
-
if (entry.isFile && entry.file) {
|
|
81
|
-
const read = entry.file.bind(entry);
|
|
82
|
-
const file = await new Promise<File>((ok, fail) => read(ok, fail));
|
|
83
|
-
Object.defineProperty(file, 'webkitRelativePath', { value: `${prefix}${file.name}`, configurable: true });
|
|
84
|
-
out.push(file);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (!entry.isDirectory || !entry.createReader) return;
|
|
88
|
-
const reader = entry.createReader();
|
|
89
|
-
while (out.length < MAX_FOLDER_FILES) {
|
|
90
|
-
const batch = await new Promise<DropEntry[]>((ok, fail) => reader.readEntries(ok, fail));
|
|
91
|
-
if (batch.length === 0) return;
|
|
92
|
-
for (const child of batch) await filesUnder(child, out, `${prefix}${entry.name}/`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
55
|
/**
|
|
97
56
|
* A drop's files, with every dropped FOLDER replaced by the accepted files inside it — see the
|
|
98
57
|
* FOLDER section above. `null` when nothing in the drop is a folder, so the ordinary path stays
|
|
99
|
-
* exactly `dataTransfer.files`.
|
|
100
|
-
*
|
|
101
|
-
* 🔴 The entries are taken SYNCHRONOUSLY, before the first `await`: a browser empties the
|
|
102
|
-
* `DataTransfer` once the drop event returns.
|
|
58
|
+
* exactly `dataTransfer.files`. The walk itself is {@link walkDrop}, shared with every app's own
|
|
59
|
+
* drop target through {@link filesFromDataTransfer}.
|
|
103
60
|
*/
|
|
104
61
|
function folderAwareDrop(
|
|
105
62
|
transfer: DataTransfer,
|
|
106
63
|
accept: string | undefined,
|
|
107
64
|
): Promise<{ loose: File[]; fromFolders: File[]; emptyFolders: File[] }> | null {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
? (item.webkitGetAsEntry() as DropEntry | null)
|
|
112
|
-
: null,
|
|
113
|
-
);
|
|
114
|
-
if (!entries.some((entry) => entry?.isDirectory)) return null;
|
|
115
|
-
const looseFiles = [...transfer.files];
|
|
116
|
-
return (async () => {
|
|
117
|
-
const loose: File[] = [];
|
|
65
|
+
const walking = walkDrop(transfer);
|
|
66
|
+
if (!walking) return null;
|
|
67
|
+
return walking.then(({ loose, folders }) => {
|
|
118
68
|
const fromFolders: File[] = [];
|
|
119
69
|
const emptyFolders: File[] = [];
|
|
120
|
-
for (const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (file) loose.push(file);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
const inside: File[] = [];
|
|
127
|
-
try {
|
|
128
|
-
await filesUnder(entry, inside);
|
|
129
|
-
} catch {
|
|
130
|
-
// An unreadable folder yields what was read before the error — and, if that is nothing, a refusal below.
|
|
131
|
-
}
|
|
132
|
-
const kept = inside.filter((f) => matchesAccept(f, accept));
|
|
70
|
+
for (const folder of folders) {
|
|
71
|
+
// A folder that yields nothing acceptable is a refusal under its own name, never silence.
|
|
72
|
+
const kept = folder.files.filter((f) => matchesAccept(f, accept));
|
|
133
73
|
if (kept.length > 0) fromFolders.push(...kept);
|
|
134
|
-
else emptyFolders.push(new File([],
|
|
74
|
+
else emptyFolders.push(new File([], folder.name));
|
|
135
75
|
}
|
|
136
76
|
return { loose, fromFolders, emptyFolders };
|
|
137
|
-
})();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const matchesAccept = (file: File, accept?: string): boolean => {
|
|
141
|
-
if (!accept) return true;
|
|
142
|
-
return accept.split(',').some((raw) => {
|
|
143
|
-
const rule = raw.trim().toLowerCase();
|
|
144
|
-
if (!rule) return false;
|
|
145
|
-
if (rule.startsWith('.')) return file.name.toLowerCase().endsWith(rule);
|
|
146
|
-
if (rule.endsWith('/*')) return file.type.toLowerCase().startsWith(rule.slice(0, -1));
|
|
147
|
-
return file.type.toLowerCase() === rule;
|
|
148
77
|
});
|
|
149
|
-
}
|
|
78
|
+
}
|
|
150
79
|
|
|
151
80
|
/**
|
|
152
81
|
* A folder PICKER's files, judged the way a dropped folder's are: what `accept` turns away is
|
|
@@ -156,7 +85,7 @@ const matchesAccept = (file: File, accept?: string): boolean => {
|
|
|
156
85
|
*/
|
|
157
86
|
function folderPick(list: FileList | null, accept: string | undefined): { fromFolders: File[]; emptyFolders: File[] } {
|
|
158
87
|
const all = list ? [...list] : [];
|
|
159
|
-
const kept = all.filter((f) => !f.webkitRelativePath.split('/').some(
|
|
88
|
+
const kept = all.filter((f) => !f.webkitRelativePath.split('/').some(isHiddenName) && matchesAccept(f, accept));
|
|
160
89
|
if (kept.length > 0 || all.length === 0) return { fromFolders: kept, emptyFolders: [] };
|
|
161
90
|
const name = all[0]?.webkitRelativePath.split('/')[0] || 'That folder';
|
|
162
91
|
return { fromFolders: [], emptyFolders: [new File([], name)] };
|
|
@@ -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}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
|
|
3
5
|
import { useState } from 'react';
|
|
4
|
-
import { TagsInput } from './TagsInput.js';
|
|
6
|
+
import { preloadSuggestingTagsInput, TagsInput } from './TagsInput.js';
|
|
5
7
|
|
|
6
8
|
afterEach(cleanup);
|
|
7
9
|
|
|
@@ -80,17 +82,76 @@ describe('TagsInput', () => {
|
|
|
80
82
|
});
|
|
81
83
|
});
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* 🔴 Never a `<datalist>` (forge task 3602). The owner, 2026-10-23: *"the dropdown does not look
|
|
87
|
+
* styled … I was in dark mode and it popped up light"* — the browser draws a datalist outside the
|
|
88
|
+
* page. The list is the kit's own (Base UI Autocomplete, portaled), carrying the popover tokens.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* A BYTE contract as much as a behavioral one (forge task 3614): the suggesting field is Base UI's
|
|
92
|
+
* Autocomplete, ~38 kB gzip, and 5.92.0 rooted every TagsInput in it — station's FolderSizesView,
|
|
93
|
+
* whose rules fields suggest nothing, went 7.15 → 44.32 kB gzip. A field without `suggestions`
|
|
94
|
+
* must not carry that engine; one with them fetches it, and loses nothing typed meanwhile.
|
|
95
|
+
*
|
|
96
|
+
* 🔴 These run BEFORE the `suggestions` block preloads the engine, so the first render here is
|
|
97
|
+
* the plain field the hand-over starts from.
|
|
98
|
+
*/
|
|
99
|
+
describe('the suggesting field is loaded only when asked for', () => {
|
|
100
|
+
test('TagsInput.tsx imports no Base UI, and reaches the suggesting field dynamically', () => {
|
|
101
|
+
const src = readFileSync(join(import.meta.dir, 'TagsInput.tsx'), 'utf8');
|
|
102
|
+
expect(src).not.toMatch(/from\s*['"]@base-ui\//);
|
|
103
|
+
expect(src).not.toMatch(/from\s*['"]\.\/(TagsInputSuggesting|suggestionList)(\.js)?['"]/);
|
|
104
|
+
expect(src).toContain("import('./TagsInputSuggesting.js')");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a draft typed and the focus held before the engine arrives are still there after', async () => {
|
|
108
|
+
const { container } = render(
|
|
109
|
+
<TagsInput value={[]} onChange={() => undefined} suggestions={['banking', 'tax']} />,
|
|
110
|
+
);
|
|
111
|
+
const plain = container.querySelector('input') as HTMLInputElement;
|
|
112
|
+
expect(plain.getAttribute('role'), 'the engine was already loaded — this test must run first').toBeNull();
|
|
113
|
+
act(() => plain.focus());
|
|
114
|
+
fireEvent.change(plain, { target: { value: 'ba' } });
|
|
115
|
+
// Where the focus is in the commit that swaps the inputs, not some polls later: a mutation
|
|
116
|
+
// record is a microtask after that commit, before any passive effect (a later task), so a
|
|
117
|
+
// hand-over in `useEffect` reads <body> here every time, not only under a loaded gate.
|
|
118
|
+
let focusAtSwap: Element | null | undefined;
|
|
119
|
+
const swap = new MutationObserver(() => {
|
|
120
|
+
if (focusAtSwap === undefined && container.querySelector('input[role="combobox"]')) {
|
|
121
|
+
focusAtSwap = document.activeElement;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
swap.observe(container, { childList: true, subtree: true, attributes: true });
|
|
125
|
+
await waitFor(() => expect(container.querySelector('input')?.getAttribute('role')).toBe('combobox'));
|
|
126
|
+
swap.disconnect();
|
|
127
|
+
const suggesting = container.querySelector('input') as HTMLInputElement;
|
|
128
|
+
expect(suggesting.value).toBe('ba');
|
|
129
|
+
expect(focusAtSwap).toBe(suggesting);
|
|
130
|
+
expect(document.activeElement).toBe(suggesting);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
83
134
|
describe('suggestions', () => {
|
|
84
|
-
|
|
135
|
+
// Every case below draws the list, so the engine is in hand before the first render.
|
|
136
|
+
beforeAll(() => preloadSuggestingTagsInput());
|
|
137
|
+
|
|
138
|
+
const openList = (input: HTMLInputElement) =>
|
|
139
|
+
act(() => {
|
|
140
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
141
|
+
});
|
|
142
|
+
const offeredNow = () => [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent);
|
|
143
|
+
|
|
144
|
+
test('drawn in the page with the kit tokens, never a datalist — minus what is already committed', () => {
|
|
85
145
|
const { container } = render(
|
|
86
146
|
<TagsInput value={['work']} onChange={() => undefined} suggestions={['work', 'bank']} />,
|
|
87
147
|
);
|
|
88
148
|
const input = container.querySelector('input') as HTMLInputElement;
|
|
89
|
-
|
|
90
|
-
expect(list).
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
149
|
+
expect(document.querySelector('datalist')).toBeNull();
|
|
150
|
+
expect(input.getAttribute('list')).toBeNull();
|
|
151
|
+
openList(input);
|
|
152
|
+
expect(offeredNow()).toEqual(['bank']);
|
|
153
|
+
const popup = document.querySelector('[role="option"]')?.closest('.bg-popover');
|
|
154
|
+
expect(popup, 'the list does not carry the popover tokens').not.toBeNull();
|
|
94
155
|
});
|
|
95
156
|
|
|
96
157
|
test('🔴 suppression is case-insensitive — "Bank" committed hides the suggestion "bank"', () => {
|
|
@@ -100,16 +161,70 @@ describe('suggestions', () => {
|
|
|
100
161
|
const { container } = render(
|
|
101
162
|
<TagsInput value={['Bank']} onChange={() => undefined} suggestions={['bank', 'tax']} />,
|
|
102
163
|
);
|
|
103
|
-
|
|
104
|
-
|
|
164
|
+
openList(container.querySelector('input') as HTMLInputElement);
|
|
165
|
+
expect(offeredNow()).toEqual(['tax']);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('narrows to what is typed, in any case', () => {
|
|
169
|
+
const { container } = render(
|
|
170
|
+
<TagsInput value={[]} onChange={() => undefined} suggestions={['Banking', 'tax', 'bank']} />,
|
|
105
171
|
);
|
|
106
|
-
|
|
172
|
+
const input = container.querySelector('input') as HTMLInputElement;
|
|
173
|
+
fireEvent.change(input, { target: { value: 'BAN' } });
|
|
174
|
+
openList(input);
|
|
175
|
+
expect(offeredNow()).toEqual(['Banking', 'bank']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('picking a suggestion commits it as a tag — not the half-typed draft', () => {
|
|
179
|
+
function WithSuggestions() {
|
|
180
|
+
const [value, setValue] = useState<string[]>([]);
|
|
181
|
+
return (
|
|
182
|
+
<div>
|
|
183
|
+
<TagsInput value={value} onChange={setValue} suggestions={['banking', 'tax']} />
|
|
184
|
+
<output data-json>{JSON.stringify(value)}</output>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const { container } = render(<WithSuggestions />);
|
|
189
|
+
const input = container.querySelector('input') as HTMLInputElement;
|
|
190
|
+
fireEvent.change(input, { target: { value: 'ba' } });
|
|
191
|
+
openList(input);
|
|
192
|
+
act(() => {
|
|
193
|
+
fireEvent.click(document.querySelector('[role="option"]') as HTMLElement);
|
|
194
|
+
});
|
|
195
|
+
expect(json(container)).toEqual(['banking']);
|
|
196
|
+
expect(input.value).toBe('');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('Enter on a highlighted suggestion commits the suggestion', () => {
|
|
200
|
+
function WithSuggestions() {
|
|
201
|
+
const [value, setValue] = useState<string[]>([]);
|
|
202
|
+
return (
|
|
203
|
+
<div>
|
|
204
|
+
<TagsInput value={value} onChange={setValue} suggestions={['banking', 'tax']} />
|
|
205
|
+
<output data-json>{JSON.stringify(value)}</output>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const { container } = render(<WithSuggestions />);
|
|
210
|
+
const input = container.querySelector('input') as HTMLInputElement;
|
|
211
|
+
fireEvent.change(input, { target: { value: 'ba' } });
|
|
212
|
+
openList(input);
|
|
213
|
+
act(() => {
|
|
214
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
215
|
+
});
|
|
216
|
+
act(() => {
|
|
217
|
+
fireEvent.keyDown(input, { key: 'Enter' });
|
|
218
|
+
});
|
|
219
|
+
expect(json(container)).toEqual(['banking']);
|
|
107
220
|
});
|
|
108
221
|
|
|
109
|
-
test('no suggestions → no
|
|
222
|
+
test('no suggestions → no list and no dangling list attribute', () => {
|
|
110
223
|
const { container } = render(<TagsInput value={[]} onChange={() => undefined} />);
|
|
111
|
-
expect(
|
|
224
|
+
expect(document.querySelector('datalist')).toBeNull();
|
|
112
225
|
const input = container.querySelector('input') as HTMLInputElement;
|
|
113
226
|
expect(input.getAttribute('list')).toBeNull();
|
|
227
|
+
openList(input);
|
|
228
|
+
expect(offeredNow()).toEqual([]);
|
|
114
229
|
});
|
|
115
230
|
});
|