cursedbelt 5.90.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/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/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/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/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/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/file-tree/FileTree.spec.tsx +14 -0
- package/src/react/file-tree/FileTree.tsx +9 -3
- package/src/react/nav/RailPlaces.spec.tsx +6 -0
- package/src/react/nav/RailPlaces.tsx +12 -2
- package/src/styles-areas/analytics.css +5 -5
- package/src/styles-areas/data-table.css +5 -5
- package/src/styles-utilities.css +10 -0
|
@@ -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}
|
|
@@ -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 ──────────────────────────────────────────────────────────────────────────
|
|
@@ -26,6 +26,12 @@ describe('RailPlaces — the top bar moved into the sidebar (owner, 2026-10-20)'
|
|
|
26
26
|
expect(getByRole('link', { name: 'Stats · 3' }).getAttribute('href')).toBe('#/stats');
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
test('no brand (a drawer titled with the app): the places alone, no brand line', () => {
|
|
30
|
+
const { container, getByRole } = render(<RailPlaces items={items} />);
|
|
31
|
+
expect(container.querySelector('[data-cb-rail-brand]')).toBeNull();
|
|
32
|
+
expect(getByRole('link', { name: 'Library' }).getAttribute('href')).toBe('#/');
|
|
33
|
+
});
|
|
34
|
+
|
|
29
35
|
test('the current place is marked three ways (NAV-6): aria-current, the raised fill, the 3px accent edge', () => {
|
|
30
36
|
const { getByRole } = render(<RailPlaces brand={brand} items={items} />);
|
|
31
37
|
const library = getByRole('link', { name: 'Library' });
|
|
@@ -6,8 +6,12 @@ import { NAV_BADGE_TONE } from './navShared.js';
|
|
|
6
6
|
import type { TopBarItem, TopBarProps } from './TopBar.js';
|
|
7
7
|
|
|
8
8
|
export interface RailPlacesProps {
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The app's mark and name, linking to its root — the same shape `TopBar` takes. Absent where the
|
|
11
|
+
* sidebar's container already names the app (a phone's drawer, titled with it, whose title line
|
|
12
|
+
* carries the utilities too — `Drawer`'s `actions`): then only the places are drawn.
|
|
13
|
+
*/
|
|
14
|
+
brand?: TopBarProps['brand'];
|
|
11
15
|
/** The app's places, in IA order — the same items a `TopBar` would have drawn. */
|
|
12
16
|
items: readonly TopBarItem[];
|
|
13
17
|
/**
|
|
@@ -67,6 +71,7 @@ export function RailPlaces({
|
|
|
67
71
|
data-cb-rail-places=''
|
|
68
72
|
className={cn('flex flex-none flex-col gap-1 border-b border-rule px-2 pt-2 pb-2', className)}
|
|
69
73
|
>
|
|
74
|
+
{brand ? (
|
|
70
75
|
<div className={cn('flex h-10 min-w-0 items-center gap-1', collapsed && 'justify-center')}>
|
|
71
76
|
<Brand
|
|
72
77
|
{...brand.linkProps}
|
|
@@ -96,6 +101,11 @@ export function RailPlaces({
|
|
|
96
101
|
</div>
|
|
97
102
|
) : null}
|
|
98
103
|
</div>
|
|
104
|
+
) : trailing && !collapsed ? (
|
|
105
|
+
<div data-cb-rail-utilities='' data-cb-nav-utility='' className='flex h-10 items-center justify-end gap-0.5'>
|
|
106
|
+
{trailing}
|
|
107
|
+
</div>
|
|
108
|
+
) : null}
|
|
99
109
|
{trailing && collapsed ? (
|
|
100
110
|
<div data-cb-rail-utilities='' data-cb-nav-utility='' className='flex flex-col items-center gap-0.5'>
|
|
101
111
|
{trailing}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
19
19
|
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
20
20
|
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
21
|
-
*
|
|
21
|
+
* 159 candidate(s).
|
|
22
22
|
*
|
|
23
23
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
24
24
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
@source inline("@max-6xl:text-[length:0] @max-6xl:w-0! @max-xl:contents @xl:basis-auto @xl:block @xl:hidden");
|
|
34
34
|
@source inline("[&>*]:hidden [&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 active:cursor-grabbing basis");
|
|
35
35
|
@source inline("basis-full bg-[var(--cb-frozen-bg)] bg-accent/15 bg-card/95 bg-gradient-to-t bg-muted/30");
|
|
36
|
-
@source inline("bg-muted/50 border-accent-solid border-accent/60 border-field-error border-solid
|
|
37
|
-
@source inline("collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
|
|
36
|
+
@source inline("bg-muted/50 border-accent-solid border-accent/60 border-b-0! border-field-error border-solid");
|
|
37
|
+
@source inline("border-t-2 col collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
|
|
38
38
|
@source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
|
|
39
39
|
@source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
|
|
40
40
|
@source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
|
|
41
|
-
@source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-105");
|
|
41
|
+
@source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-0 h-105");
|
|
42
42
|
@source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-muted/40");
|
|
43
43
|
@source inline("hover:border-field-ring hover:text-warning italic justify-self-end last:border-0 left-11");
|
|
44
44
|
@source inline("left-2.5 list-none max-h-64 max-h-88 max-sm:flex-1 max-sm:flex-wrap max-sm:min-w-0");
|
|
45
45
|
@source inline("max-sm:min-w-24 max-sm:min-w-32 max-sm:ml-13.5 max-sm:w-auto max-w-32 max-w-36 max-w-45 max-w-48");
|
|
46
46
|
@source inline("max-w-60 min-h-20 min-h-dvh min-w-0! min-w-20 min-w-24 min-w-4 min-w-52");
|
|
47
47
|
@source inline("min-w-[var(--anchor-width)] min-w-max mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5");
|
|
48
|
-
@source inline("pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9 pt-1 resize-none resize-y right-3");
|
|
48
|
+
@source inline("pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9 pt-1 py-0! resize-none resize-y right-3");
|
|
49
49
|
@source inline("ring-field-error/30 self self-stretch shadow-[1px_0_0_0_var(--color-border)] shrink-[2] size-7");
|
|
50
50
|
@source inline("slide-in-from-top-1 table-fixed text-[length:0] text-accent-solid-foreground");
|
|
51
51
|
@source inline("text-field-placeholder text-success text-xl to-transparent top-3 transition-[transform,opacity]");
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
19
19
|
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
20
20
|
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
21
|
-
*
|
|
21
|
+
* 154 candidate(s).
|
|
22
22
|
*
|
|
23
23
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
24
24
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
@source inline("@max-6xl:text-[length:0] @max-6xl:w-0! @max-xl:contents @xl:basis-auto @xl:block @xl:hidden");
|
|
34
34
|
@source inline("[&>*]:hidden [&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 active:cursor-grabbing basis-full");
|
|
35
35
|
@source inline("bg-[var(--cb-frozen-bg)] bg-accent/15 bg-card/95 bg-gradient-to-t bg-muted/30 bg-muted/50");
|
|
36
|
-
@source inline("border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col
|
|
37
|
-
@source inline("cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
|
|
36
|
+
@source inline("border-accent-solid border-accent/60 border-b-0! border-field-error border-solid border-t-2 col");
|
|
37
|
+
@source inline("collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
|
|
38
38
|
@source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
|
|
39
39
|
@source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
|
|
40
40
|
@source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
|
|
41
|
-
@source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-105");
|
|
41
|
+
@source inline("focus-within:ring-field-ring/30 from-card gap-x-6 h-0 h-105");
|
|
42
42
|
@source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-muted/40");
|
|
43
43
|
@source inline("hover:border-field-ring hover:text-warning justify-self-end last:border-0 left-11 left-2.5");
|
|
44
44
|
@source inline("list-none max-h-64 max-h-88 max-sm:flex-1 max-sm:flex-wrap max-sm:min-w-0 max-sm:min-w-24");
|
|
45
45
|
@source inline("max-sm:min-w-32 max-sm:ml-13.5 max-sm:w-auto max-w-32 max-w-36 max-w-45 max-w-48 max-w-60");
|
|
46
46
|
@source inline("min-h-20 min-h-dvh min-w-0! min-w-24 min-w-4 min-w-52 min-w-[var(--anchor-width)] min-w-max mr-2");
|
|
47
47
|
@source inline("mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9");
|
|
48
|
-
@source inline("pt-1 resize-none resize-y right-3 ring-field-error/30 self self-stretch");
|
|
48
|
+
@source inline("pt-1 py-0! resize-none resize-y right-3 ring-field-error/30 self self-stretch");
|
|
49
49
|
@source inline("shadow-[1px_0_0_0_var(--color-border)] size-7 slide-in-from-top-1 table-fixed text-[length:0]");
|
|
50
50
|
@source inline("text-accent-solid-foreground text-field-placeholder text-success text-xl to-transparent top-3");
|
|
51
51
|
@source inline("transition-[transform,opacity] w-0! w-11 w-12 w-20 w-32 w-36 w-40 w-88");
|
package/src/styles-utilities.css
CHANGED
|
@@ -656,6 +656,9 @@
|
|
|
656
656
|
width: 100%;
|
|
657
657
|
height: 100%;
|
|
658
658
|
}
|
|
659
|
+
.h-0 {
|
|
660
|
+
height: 0px;
|
|
661
|
+
}
|
|
659
662
|
.h-0\.5 {
|
|
660
663
|
height: calc(var(--spacing) * 0.5);
|
|
661
664
|
}
|
|
@@ -1726,6 +1729,10 @@
|
|
|
1726
1729
|
border-bottom-style: var(--tw-border-style);
|
|
1727
1730
|
border-bottom-width: 0px;
|
|
1728
1731
|
}
|
|
1732
|
+
.border-b-0\! {
|
|
1733
|
+
border-bottom-style: var(--tw-border-style) !important;
|
|
1734
|
+
border-bottom-width: 0px !important;
|
|
1735
|
+
}
|
|
1729
1736
|
.border-b-2 {
|
|
1730
1737
|
border-bottom-style: var(--tw-border-style);
|
|
1731
1738
|
border-bottom-width: 2px;
|
|
@@ -2639,6 +2646,9 @@
|
|
|
2639
2646
|
.py-0 {
|
|
2640
2647
|
padding-block: 0px;
|
|
2641
2648
|
}
|
|
2649
|
+
.py-0\! {
|
|
2650
|
+
padding-block: 0px !important;
|
|
2651
|
+
}
|
|
2642
2652
|
.py-0\.5 {
|
|
2643
2653
|
padding-block: calc(var(--spacing) * 0.5);
|
|
2644
2654
|
}
|