cursedbelt 5.46.2 → 5.48.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/charts/IntervalTimeline.d.ts.map +1 -1
- package/dist/react/charts/IntervalTimeline.js +2 -2
- package/dist/react/charts/IntervalTimeline.js.map +1 -1
- package/dist/react/chat/DateMarker.d.ts +4 -0
- package/dist/react/chat/DateMarker.d.ts.map +1 -1
- package/dist/react/chat/DateMarker.js +15 -2
- package/dist/react/chat/DateMarker.js.map +1 -1
- package/dist/react/components/Button.d.ts.map +1 -1
- package/dist/react/components/Button.js +5 -0
- package/dist/react/components/Button.js.map +1 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +1 -1
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
- package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
- package/dist/react/components/DataTable/SortHeader.js +22 -5
- package/dist/react/components/DataTable/SortHeader.js.map +1 -1
- package/dist/react/components/DataTable/types.d.ts +10 -0
- package/dist/react/components/DataTable/types.d.ts.map +1 -1
- package/dist/react/components/DataTable/types.js.map +1 -1
- package/dist/react/components/DropdownMenu.js +10 -4
- package/dist/react/components/DropdownMenu.js.map +1 -1
- package/dist/react/components/Field.d.ts +9 -1
- package/dist/react/components/Field.d.ts.map +1 -1
- package/dist/react/components/Field.js +3 -3
- package/dist/react/components/Field.js.map +1 -1
- package/dist/react/components/SecretInput.d.ts +4 -2
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +2 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/auth/AuthCard.d.ts.map +1 -1
- package/dist/react/components/auth/AuthCard.js +5 -1
- package/dist/react/components/auth/AuthCard.js.map +1 -1
- package/dist/react/components/auth/PasswordSetFields.d.ts.map +1 -1
- package/dist/react/components/auth/PasswordSetFields.js +2 -2
- package/dist/react/components/auth/PasswordSetFields.js.map +1 -1
- package/dist/react/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
- package/dist/react/components/auth/PasswordStrengthMeter.js +3 -1
- package/dist/react/components/auth/PasswordStrengthMeter.js.map +1 -1
- package/dist/react/components/auth/RegisterForm.js +1 -1
- package/dist/react/components/auth/RegisterForm.js.map +1 -1
- package/dist/react/components/auth/SignupForm.d.ts.map +1 -1
- package/dist/react/components/auth/SignupForm.js +4 -2
- package/dist/react/components/auth/SignupForm.js.map +1 -1
- package/dist/react/components/auth/TotpEntry.d.ts.map +1 -1
- package/dist/react/components/auth/TotpEntry.js +18 -1
- package/dist/react/components/auth/TotpEntry.js.map +1 -1
- package/dist/react/components/auth/fields.d.ts +3 -1
- package/dist/react/components/auth/fields.d.ts.map +1 -1
- package/dist/react/components/auth/fields.js +6 -2
- package/dist/react/components/auth/fields.js.map +1 -1
- package/dist/react/components/calendar/DayAgenda.d.ts.map +1 -1
- package/dist/react/components/calendar/DayAgenda.js +4 -2
- package/dist/react/components/calendar/DayAgenda.js.map +1 -1
- package/dist/react/components/calendar/EventChip.d.ts +5 -1
- package/dist/react/components/calendar/EventChip.d.ts.map +1 -1
- package/dist/react/components/calendar/EventChip.js +10 -3
- package/dist/react/components/calendar/EventChip.js.map +1 -1
- package/dist/react/components/calendar/MonthGrid.js +1 -1
- package/dist/react/components/calendar/MonthGrid.js.map +1 -1
- package/dist/react/components/calendar/WeekView.d.ts.map +1 -1
- package/dist/react/components/calendar/WeekView.js +2 -2
- package/dist/react/components/calendar/WeekView.js.map +1 -1
- package/dist/react/components/calendar/colorUtils.d.ts.map +1 -1
- package/dist/react/components/calendar/colorUtils.js +13 -11
- package/dist/react/components/calendar/colorUtils.js.map +1 -1
- package/dist/react/components/fieldErrorText.d.ts +12 -0
- package/dist/react/components/fieldErrorText.d.ts.map +1 -0
- package/dist/react/components/fieldErrorText.js +12 -0
- package/dist/react/components/fieldErrorText.js.map +1 -0
- package/dist/react/deep-link/resume.d.ts.map +1 -1
- package/dist/react/deep-link/resume.js +9 -2
- package/dist/react/deep-link/resume.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +3 -0
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +12 -1
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.d.ts +3 -1
- package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.js +51 -15
- package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
- package/dist/react/file-tree/folderView.d.ts +25 -4
- package/dist/react/file-tree/folderView.d.ts.map +1 -1
- package/dist/react/file-tree/folderView.js +49 -9
- package/dist/react/file-tree/folderView.js.map +1 -1
- package/dist/react/file-tree/index.d.ts +2 -1
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +2 -1
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/file-tree/useFolderView.d.ts +34 -4
- package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
- package/dist/react/file-tree/useFolderView.js +43 -11
- package/dist/react/file-tree/useFolderView.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +8 -1
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +33 -8
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +12 -1
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +30 -2
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/controlBar.d.ts +3 -2
- package/dist/react/media/controlBar.d.ts.map +1 -1
- package/dist/react/media/controlBar.js +16 -10
- package/dist/react/media/controlBar.js.map +1 -1
- package/dist/react/media/denseGallery.css +56 -18
- package/dist/react/media/videoPlayer.css +2 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +8 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/styles-areas/analytics.css +26 -25
- package/dist/styles-areas/calendar.css +4 -4
- package/dist/styles-areas/data-table.css +26 -25
- package/dist/styles-utilities.css +24 -9
- package/package.json +5 -1
- package/scripts/demoCoverage.spec.ts +33 -0
- package/scripts/demoCoverage.ts +124 -0
- package/src/react/charts/IntervalTimeline.tsx +6 -4
- package/src/react/chat/DateMarker.tsx +22 -6
- package/src/react/chat/MessageThread.spec.tsx +25 -1
- package/src/react/components/Button.spec.tsx +11 -0
- package/src/react/components/Button.tsx +5 -0
- package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
- package/src/react/components/DataTable/DataTable.tsx +1 -0
- package/src/react/components/DataTable/SortHeader.tsx +45 -5
- package/src/react/components/DataTable/types.ts +10 -0
- package/src/react/components/DropdownMenu.spec.tsx +26 -0
- package/src/react/components/DropdownMenu.tsx +10 -3
- package/src/react/components/Field.spec.tsx +24 -0
- package/src/react/components/Field.tsx +15 -5
- package/src/react/components/SecretInput.tsx +5 -1
- package/src/react/components/auth/AuthCard.spec.tsx +8 -0
- package/src/react/components/auth/AuthCard.tsx +4 -1
- package/src/react/components/auth/PasswordSetFields.tsx +7 -11
- package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
- package/src/react/components/auth/RegisterForm.tsx +6 -6
- package/src/react/components/auth/SignupForm.spec.tsx +17 -0
- package/src/react/components/auth/SignupForm.tsx +11 -10
- package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
- package/src/react/components/auth/TotpEntry.tsx +18 -1
- package/src/react/components/auth/fields.tsx +8 -1
- package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
- package/src/react/components/calendar/DayAgenda.tsx +5 -2
- package/src/react/components/calendar/EventChip.spec.tsx +12 -8
- package/src/react/components/calendar/EventChip.tsx +11 -6
- package/src/react/components/calendar/MonthGrid.tsx +1 -1
- package/src/react/components/calendar/WeekView.tsx +5 -3
- package/src/react/components/calendar/colorUtils.ts +13 -11
- package/src/react/components/fieldErrorText.tsx +17 -0
- package/src/react/deep-link/resume.spec.tsx +16 -0
- package/src/react/deep-link/resume.ts +9 -2
- package/src/react/file-tree/FileTree.tsx +14 -1
- package/src/react/file-tree/FolderViewMenus.tsx +57 -17
- package/src/react/file-tree/folderView.spec.ts +63 -1
- package/src/react/file-tree/folderView.ts +63 -12
- package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
- package/src/react/file-tree/index.ts +15 -1
- package/src/react/file-tree/useFolderView.ts +73 -12
- package/src/react/media/DenseGalleryShell.spec.tsx +9 -7
- package/src/react/media/DenseGalleryShell.tsx +53 -11
- package/src/react/media/VideoPlayer.tsx +78 -2
- package/src/react/media/controlBar.tsx +17 -10
- package/src/react/media/denseGallery.css +56 -18
- package/src/react/media/videoPlayer.css +2 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +4 -4
- package/src/react/media-gallery/MediaGallery.tsx +12 -0
- package/src/styles-areas/analytics.css +26 -25
- package/src/styles-areas/calendar.css +4 -4
- package/src/styles-areas/data-table.css +26 -25
- package/src/styles-utilities.css +24 -9
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
|
2
2
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { useState } from 'react';
|
|
3
4
|
import type { FileTreeNode } from 'cursedbelt-core/file-tree/file-tree-model';
|
|
4
5
|
import { onPreferenceWrite } from '../media-gallery/preferenceBus.js';
|
|
5
6
|
import { toast } from '../Toast.js';
|
|
6
7
|
import { FileTree, type FileTreeProps, preloadFileTreePanels } from './FileTree.js';
|
|
7
|
-
import { useFolderView } from './useFolderView.js';
|
|
8
|
+
import { type UseFolderViewOptions, useFolderView } from './useFolderView.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Hidden folders and folder tags, through the gestures the owner named: right-click a folder,
|
|
@@ -27,8 +28,8 @@ const TREE: FileTreeNode[] = [
|
|
|
27
28
|
{ id: 'readme', name: 'readme.md', kind: 'file' },
|
|
28
29
|
];
|
|
29
30
|
|
|
30
|
-
function Harness(props: Partial<FileTreeProps>) {
|
|
31
|
-
const folderView = useFolderView({ key: KEY });
|
|
31
|
+
function Harness({ options, ...props }: Partial<FileTreeProps> & { options?: Partial<UseFolderViewOptions> }) {
|
|
32
|
+
const folderView = useFolderView({ key: KEY, ...options });
|
|
32
33
|
return (
|
|
33
34
|
<div>
|
|
34
35
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stands in for an app's header bar, whose right-click is exactly what is under test. */}
|
|
@@ -66,10 +67,10 @@ beforeEach(() => globalThis.localStorage.clear());
|
|
|
66
67
|
afterEach(cleanup);
|
|
67
68
|
|
|
68
69
|
describe('hiding a folder', () => {
|
|
69
|
-
test('🔴 right-click a folder → Hide
|
|
70
|
+
test('🔴 right-click a folder → Hide everywhere → it and its contents leave the tree', () => {
|
|
70
71
|
render(<Harness />);
|
|
71
72
|
fireEvent.contextMenu(row('Work'), { clientX: 10, clientY: 10 });
|
|
72
|
-
fireEvent.click(item('Hide
|
|
73
|
+
fireEvent.click(item('Hide everywhere'));
|
|
73
74
|
expect(screen.queryByText('Work')).toBeNull();
|
|
74
75
|
expect(screen.queryByText('Dynatrace')).toBeNull();
|
|
75
76
|
expect(screen.getByText('Home')).toBeDefined();
|
|
@@ -87,17 +88,17 @@ describe('hiding a folder', () => {
|
|
|
87
88
|
try {
|
|
88
89
|
render(<Harness />);
|
|
89
90
|
fireEvent.contextMenu(row('Work'));
|
|
90
|
-
fireEvent.click(item('Hide
|
|
91
|
+
fireEvent.click(item('Hide everywhere'));
|
|
91
92
|
expect(said.map((s) => s.message)).toEqual(['Hid “Work”, on every device']);
|
|
92
93
|
expect(screen.queryByText('Work')).toBeNull();
|
|
93
94
|
act(() => said[0]?.undo());
|
|
94
95
|
expect(screen.getByText('Work')).toBeDefined();
|
|
95
96
|
|
|
96
97
|
fireEvent.contextMenu(row('Work'));
|
|
97
|
-
fireEvent.click(item('Hide
|
|
98
|
+
fireEvent.click(item('Hide everywhere'));
|
|
98
99
|
fireEvent.keyDown(row('Home'), { key: '.', code: 'Period', shiftKey: true, metaKey: true });
|
|
99
100
|
fireEvent.contextMenu(row('Work'));
|
|
100
|
-
fireEvent.click(item('Unhide
|
|
101
|
+
fireEvent.click(item('Unhide everywhere'));
|
|
101
102
|
expect(said.at(-1)?.message).toBe('“Work” is back in the tree, on every device');
|
|
102
103
|
expect(row('Work').getAttribute('data-hidden')).toBeNull();
|
|
103
104
|
act(() => said.at(-1)?.undo());
|
|
@@ -110,7 +111,7 @@ describe('hiding a folder', () => {
|
|
|
110
111
|
test('🔴 "Show hidden folders" draws them back — the hidden one italic-marked, its contents faded-marked', () => {
|
|
111
112
|
render(<Harness />);
|
|
112
113
|
fireEvent.contextMenu(row('Work'));
|
|
113
|
-
fireEvent.click(item('Hide
|
|
114
|
+
fireEvent.click(item('Hide everywhere'));
|
|
114
115
|
fireEvent.contextMenu(blank(), { clientX: 5, clientY: 300 });
|
|
115
116
|
fireEvent.click(item(/Show hidden folders \(1\)/));
|
|
116
117
|
expect(row('Work').getAttribute('data-hidden')).toBe('self');
|
|
@@ -121,14 +122,14 @@ describe('hiding a folder', () => {
|
|
|
121
122
|
// Still hideable — the owner's "showing hidden folders still keeps them hideable": its own
|
|
122
123
|
// menu now offers the way back.
|
|
123
124
|
fireEvent.contextMenu(row('Work'));
|
|
124
|
-
fireEvent.click(item('Unhide
|
|
125
|
+
fireEvent.click(item('Unhide everywhere'));
|
|
125
126
|
expect(row('Work').getAttribute('data-hidden')).toBeNull();
|
|
126
127
|
});
|
|
127
128
|
|
|
128
129
|
test('the strip says hidden folders are showing, and its button leaves them out again', () => {
|
|
129
130
|
render(<Harness />);
|
|
130
131
|
fireEvent.contextMenu(row('Work'));
|
|
131
|
-
fireEvent.click(item('Hide
|
|
132
|
+
fireEvent.click(item('Hide everywhere'));
|
|
132
133
|
fireEvent.contextMenu(blank());
|
|
133
134
|
fireEvent.click(item(/Show hidden folders/));
|
|
134
135
|
expect(screen.getByText('Showing hidden')).toBeDefined();
|
|
@@ -140,7 +141,7 @@ describe('hiding a folder', () => {
|
|
|
140
141
|
test('⇧⌘. on a row toggles hidden folders — Finder\'s chord', () => {
|
|
141
142
|
render(<Harness />);
|
|
142
143
|
fireEvent.contextMenu(row('Work'));
|
|
143
|
-
fireEvent.click(item('Hide
|
|
144
|
+
fireEvent.click(item('Hide everywhere'));
|
|
144
145
|
fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
|
|
145
146
|
expect(row('Work').getAttribute('data-hidden')).toBe('self');
|
|
146
147
|
fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
|
|
@@ -223,7 +224,7 @@ describe('🔴 what reaches other devices, and what does not', () => {
|
|
|
223
224
|
try {
|
|
224
225
|
render(<Harness />);
|
|
225
226
|
fireEvent.contextMenu(row('Work'));
|
|
226
|
-
fireEvent.click(item('Hide
|
|
227
|
+
fireEvent.click(item('Hide everywhere'));
|
|
227
228
|
expect(heard).toEqual([KEY]);
|
|
228
229
|
fireEvent.contextMenu(blank());
|
|
229
230
|
fireEvent.click(item(/Show hidden folders/));
|
|
@@ -235,7 +236,100 @@ describe('🔴 what reaches other devices, and what does not', () => {
|
|
|
235
236
|
});
|
|
236
237
|
});
|
|
237
238
|
|
|
239
|
+
describe('🔴 hidden on THIS device only — desk\'s two lists, in the belt', () => {
|
|
240
|
+
test('Hide on this device leaves the tree, is never announced, and is stored on this device', () => {
|
|
241
|
+
const heard: string[] = [];
|
|
242
|
+
const stop = onPreferenceWrite((key) => heard.push(key));
|
|
243
|
+
try {
|
|
244
|
+
render(<Harness />);
|
|
245
|
+
fireEvent.contextMenu(row('Work'));
|
|
246
|
+
fireEvent.click(item('Hide on this device'));
|
|
247
|
+
expect(screen.queryByText('Work')).toBeNull();
|
|
248
|
+
expect(heard).toEqual([]);
|
|
249
|
+
expect(globalThis.localStorage.getItem(KEY)).toBeNull();
|
|
250
|
+
expect(globalThis.localStorage.getItem(`${KEY}:device`)).toContain('"hidden":["work"]');
|
|
251
|
+
} finally {
|
|
252
|
+
stop();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('under Show hidden, the menu says WHICH list a folder is in, and unhides from that one', () => {
|
|
257
|
+
render(<Harness />);
|
|
258
|
+
fireEvent.contextMenu(row('Work'));
|
|
259
|
+
fireEvent.click(item('Hide on this device'));
|
|
260
|
+
fireEvent.contextMenu(row('Home'));
|
|
261
|
+
fireEvent.click(item('Hide everywhere'));
|
|
262
|
+
fireEvent.keyDown(row('readme.md'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
|
|
263
|
+
|
|
264
|
+
fireEvent.contextMenu(row('Work'));
|
|
265
|
+
expect(() => item('Unhide everywhere')).toThrow();
|
|
266
|
+
fireEvent.click(item('Unhide on this device'));
|
|
267
|
+
expect(row('Work').getAttribute('data-hidden')).toBeNull();
|
|
268
|
+
|
|
269
|
+
fireEvent.contextMenu(row('Home'));
|
|
270
|
+
expect(() => item('Unhide on this device')).toThrow();
|
|
271
|
+
fireEvent.click(item('Unhide everywhere'));
|
|
272
|
+
expect(row('Home').getAttribute('data-hidden')).toBeNull();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('🔴 the marks kept by the app — `store` and `device: \'memory\'`', () => {
|
|
277
|
+
test('with a store, a hide goes to the store and NOTHING touches localStorage or the bus', () => {
|
|
278
|
+
const written: (string | null)[] = [];
|
|
279
|
+
const heard: string[] = [];
|
|
280
|
+
const stop = onPreferenceWrite((key) => heard.push(key));
|
|
281
|
+
try {
|
|
282
|
+
function Kept() {
|
|
283
|
+
const [marks, setMarks] = useState<string | null>(null);
|
|
284
|
+
return (
|
|
285
|
+
<Harness
|
|
286
|
+
options={{
|
|
287
|
+
store: {
|
|
288
|
+
marks,
|
|
289
|
+
write: (next) => {
|
|
290
|
+
written.push(next);
|
|
291
|
+
setMarks(next);
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
device: 'memory',
|
|
295
|
+
}}
|
|
296
|
+
/>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
render(<Kept />);
|
|
300
|
+
fireEvent.contextMenu(row('Work'));
|
|
301
|
+
// No device half kept → one hide, which means everywhere.
|
|
302
|
+
expect(() => item('Hide on this device')).toThrow();
|
|
303
|
+
fireEvent.click(item('Hide folder'));
|
|
304
|
+
expect(screen.queryByText('Work')).toBeNull();
|
|
305
|
+
expect(written).toHaveLength(1);
|
|
306
|
+
expect(written[0]).toContain('"hidden":["work"]');
|
|
307
|
+
fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
|
|
308
|
+
expect(row('Work').getAttribute('data-hidden')).toBe('self');
|
|
309
|
+
expect(globalThis.localStorage.length).toBe(0);
|
|
310
|
+
expect(heard).toEqual([]);
|
|
311
|
+
} finally {
|
|
312
|
+
stop();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('the store is the truth: a value it hands in is what the tree draws', () => {
|
|
317
|
+
render(<Harness options={{ store: { marks: '{"hidden":["home"],"tags":{}}', write: () => undefined } }} />);
|
|
318
|
+
expect(screen.queryByText('Home')).toBeNull();
|
|
319
|
+
expect(screen.getByText('Work')).toBeDefined();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
238
323
|
describe('a row menu of the app\'s own', () => {
|
|
324
|
+
test('🔴 with onRowMenu and no rowMenu, right-click opens the APP\'s menu at the pointer', () => {
|
|
325
|
+
const opened: { id: string; x: number; y: number }[] = [];
|
|
326
|
+
render(<Harness onRowMenu={(node, at) => opened.push({ id: node.id, ...at })} />);
|
|
327
|
+
expect(fireEvent.contextMenu(row('readme.md'), { clientX: 40, clientY: 50 })).toBe(false);
|
|
328
|
+
expect(opened).toEqual([{ id: 'readme', x: 40, y: 50 }]);
|
|
329
|
+
// The tree drew no menu of its own over the app's.
|
|
330
|
+
expect(items()).toHaveLength(0);
|
|
331
|
+
});
|
|
332
|
+
|
|
239
333
|
test('rowMenu items open on right-click, with Hide and Tags… after them on a folder', () => {
|
|
240
334
|
let renamed = '';
|
|
241
335
|
render(
|
|
@@ -246,7 +340,7 @@ describe('a row menu of the app\'s own', () => {
|
|
|
246
340
|
fireEvent.contextMenu(row('Home'));
|
|
247
341
|
const labels = items().map((el) => el.textContent);
|
|
248
342
|
expect(labels[0]).toBe('Rename');
|
|
249
|
-
expect(labels.some((l) => l?.startsWith('Hide
|
|
343
|
+
expect(labels.some((l) => l?.startsWith('Hide everywhere'))).toBe(true);
|
|
250
344
|
fireEvent.click(item('Rename'));
|
|
251
345
|
expect(renamed).toBe('home');
|
|
252
346
|
});
|
|
@@ -264,7 +358,8 @@ describe('a row menu of the app\'s own', () => {
|
|
|
264
358
|
fireEvent.contextMenu(row('Home'));
|
|
265
359
|
expect(items().map((el) => el.querySelector('.truncate')?.textContent)).toEqual([
|
|
266
360
|
'Rename',
|
|
267
|
-
'Hide
|
|
361
|
+
'Hide on this device',
|
|
362
|
+
'Hide everywhere',
|
|
268
363
|
'Tags…',
|
|
269
364
|
'Delete folder',
|
|
270
365
|
]);
|
|
@@ -274,7 +369,7 @@ describe('a row menu of the app\'s own', () => {
|
|
|
274
369
|
render(<Harness />);
|
|
275
370
|
act(() => row('Home').focus());
|
|
276
371
|
fireEvent.keyDown(row('Home'), { key: 'F10', shiftKey: true });
|
|
277
|
-
expect(item('Hide
|
|
372
|
+
expect(item('Hide everywhere')).toBeDefined();
|
|
278
373
|
});
|
|
279
374
|
|
|
280
375
|
test('a tree with neither rowMenu nor folderView leaves right-click to the browser', () => {
|
|
@@ -65,4 +65,18 @@ export {
|
|
|
65
65
|
folderViewMenuItems,
|
|
66
66
|
withFolderItems,
|
|
67
67
|
} from './FolderViewMenus.js';
|
|
68
|
-
export {
|
|
68
|
+
export {
|
|
69
|
+
type FolderMarks,
|
|
70
|
+
type FolderViewLocal,
|
|
71
|
+
type HiddenScope,
|
|
72
|
+
parseFolderMarks,
|
|
73
|
+
remapFolderMarks,
|
|
74
|
+
serializeFolderMarks,
|
|
75
|
+
} from './folderView.js';
|
|
76
|
+
export {
|
|
77
|
+
type FolderMarksStore,
|
|
78
|
+
type FolderView,
|
|
79
|
+
type HideScope,
|
|
80
|
+
type UseFolderViewOptions,
|
|
81
|
+
useFolderView,
|
|
82
|
+
} from './useFolderView.js';
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
* announced — *"Hiding or showing by tag can be stored per machine, so viewing the 'home'
|
|
18
18
|
* tag at home doesn't change my work computer."*
|
|
19
19
|
*
|
|
20
|
+
* An app that already keeps its own settings record hands the marks over instead — `store` — and
|
|
21
|
+
* then nothing goes to `localStorage` or the bus: desk carries them in its global settings blob
|
|
22
|
+
* beside the rest of its synced state, and vault seals them inside the vault, because its folder
|
|
23
|
+
* ids are folder NAMES and must never reach a disk in plaintext. `device: 'memory'` keeps the
|
|
24
|
+
* device half in page memory for the same reason; with it, "Hide on this device" is not offered,
|
|
25
|
+
* since a device list forgotten on reload would be a hide that silently undoes itself.
|
|
26
|
+
*
|
|
20
27
|
* And because the tree's header belongs to the APP (collections' `FilterRail` section, desk's
|
|
21
28
|
* explorer bar), the view menu has to be openable from outside the tree: the hook holds which
|
|
22
29
|
* menu is open, and the tree draws it. `onContextMenu` is the one handler an app puts on its
|
|
@@ -27,6 +34,8 @@ import { useRememberedPreference } from '../media-gallery/rememberedPreference.j
|
|
|
27
34
|
import {
|
|
28
35
|
type FolderMarks,
|
|
29
36
|
type FolderViewLocal,
|
|
37
|
+
type HiddenScope,
|
|
38
|
+
hiddenScope,
|
|
30
39
|
normalizeTag,
|
|
31
40
|
parseFolderMarks,
|
|
32
41
|
parseFolderViewLocal,
|
|
@@ -50,14 +59,23 @@ export type FolderViewPanel =
|
|
|
50
59
|
| { kind: 'view'; at: FolderViewPoint }
|
|
51
60
|
| { kind: 'tags'; at: FolderViewPoint | null; ids: readonly string[] };
|
|
52
61
|
|
|
62
|
+
/** Which list a hide writes to, or an unhide takes from. */
|
|
63
|
+
export type HideScope = 'everywhere' | 'device';
|
|
64
|
+
|
|
53
65
|
export interface FolderView {
|
|
54
66
|
marks: FolderMarks;
|
|
55
67
|
local: FolderViewLocal;
|
|
56
68
|
/** Every tag in use, most-used first. */
|
|
57
69
|
tags: readonly TagCount[];
|
|
70
|
+
/** Whether "Hide on this device" is offered — false when the device half is not kept. */
|
|
71
|
+
deviceHiding: boolean;
|
|
72
|
+
/** Which of the two lists hide this folder, if any. */
|
|
73
|
+
hiddenIn: (id: string) => HiddenScope | null;
|
|
58
74
|
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
/** Hide these folders — everywhere unless `scope: 'device'`. */
|
|
76
|
+
hide: (ids: readonly string[], options?: { scope?: HideScope }) => void;
|
|
77
|
+
/** Unhide these folders from the one list named, or from both when none is. */
|
|
78
|
+
unhide: (ids: readonly string[], options?: { scope?: HideScope }) => void;
|
|
61
79
|
/** Add or take away one tag on each folder named. */
|
|
62
80
|
toggleTag: (ids: readonly string[], tag: string) => void;
|
|
63
81
|
/** Replace one folder's tags. */
|
|
@@ -88,11 +106,39 @@ export interface UseFolderViewOptions {
|
|
|
88
106
|
* origin must not hide each other's folders.
|
|
89
107
|
*/
|
|
90
108
|
key: string;
|
|
109
|
+
/**
|
|
110
|
+
* The app keeps the marks itself — its own synced settings, or a sealed record. `marks` is
|
|
111
|
+
* what it holds now (`null` for none) and `write` is called with the next value to keep.
|
|
112
|
+
* Given a store, the marks never touch `localStorage` or the preference bus.
|
|
113
|
+
*/
|
|
114
|
+
store?: FolderMarksStore;
|
|
115
|
+
/**
|
|
116
|
+
* Where this device's view (show hidden, the tag filter, the device-only hidden list) lives:
|
|
117
|
+
* `'local'` (the default) is `localStorage` under `${key}:device`, never announced;
|
|
118
|
+
* `'memory'` is this page only, for an app whose folder ids may not be written to disk.
|
|
119
|
+
*/
|
|
120
|
+
device?: 'local' | 'memory';
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
123
|
+
/** The marks, kept by the app — see {@link UseFolderViewOptions.store}. */
|
|
124
|
+
export interface FolderMarksStore {
|
|
125
|
+
/** The stored form (`serializeFolderMarks`), or `null` for no marks. */
|
|
126
|
+
marks: string | null;
|
|
127
|
+
write: (next: string | null) => void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function useFolderView({ key, store, device = 'local' }: UseFolderViewOptions): FolderView {
|
|
131
|
+
// Hooks run unconditionally; an undefined key is the remembered preference that keeps nothing.
|
|
132
|
+
const [rememberedMarks, rememberMarks] = useRememberedPreference(store ? undefined : key);
|
|
133
|
+
const [rememberedLocal, rememberLocal] = useRememberedPreference(
|
|
134
|
+
device === 'local' ? `${key}:device` : undefined,
|
|
135
|
+
{ announce: false },
|
|
136
|
+
);
|
|
137
|
+
const [memoryLocal, setMemoryLocal] = useState<string | null>(null);
|
|
138
|
+
const rawMarks = store ? store.marks : rememberedMarks;
|
|
139
|
+
const writeMarks = store ? store.write : rememberMarks;
|
|
140
|
+
const rawLocal = device === 'local' ? rememberedLocal : memoryLocal;
|
|
141
|
+
const writeLocal = device === 'local' ? rememberLocal : setMemoryLocal;
|
|
96
142
|
const marks = useMemo(() => parseFolderMarks(rawMarks), [rawMarks]);
|
|
97
143
|
const local = useMemo(() => parseFolderViewLocal(rawLocal), [rawLocal]);
|
|
98
144
|
const tags = useMemo(() => tagCounts(marks), [marks]);
|
|
@@ -114,13 +160,20 @@ export function useFolderView({ key }: UseFolderViewOptions): FolderView {
|
|
|
114
160
|
},
|
|
115
161
|
[writeMarks],
|
|
116
162
|
);
|
|
163
|
+
// The same rule for this device's view: an Undo of "Hide on this device" must see the hide.
|
|
164
|
+
const latestLocal = useRef(local);
|
|
165
|
+
latestLocal.current = local;
|
|
117
166
|
const saveLocal = useCallback(
|
|
118
167
|
(next: FolderViewLocal) => {
|
|
119
|
-
|
|
168
|
+
if (next === latestLocal.current) return;
|
|
169
|
+
latestLocal.current = next;
|
|
170
|
+
const empty =
|
|
171
|
+
!next.showHidden && next.only.length === 0 && next.hide.length === 0 && next.hidden.length === 0;
|
|
120
172
|
writeLocal(empty ? null : JSON.stringify(next));
|
|
121
173
|
},
|
|
122
174
|
[writeLocal],
|
|
123
175
|
);
|
|
176
|
+
const deviceHiding = device === 'local';
|
|
124
177
|
|
|
125
178
|
const onContextMenu = useCallback((event: { clientX: number; clientY: number; preventDefault: () => void }) => {
|
|
126
179
|
event.preventDefault();
|
|
@@ -132,8 +185,16 @@ export function useFolderView({ key }: UseFolderViewOptions): FolderView {
|
|
|
132
185
|
marks,
|
|
133
186
|
local,
|
|
134
187
|
tags,
|
|
135
|
-
|
|
136
|
-
|
|
188
|
+
deviceHiding,
|
|
189
|
+
hiddenIn: (id) => hiddenScope(marks, local, id),
|
|
190
|
+
hide: (ids, options) => {
|
|
191
|
+
if (options?.scope === 'device' && deviceHiding) saveLocal(setHidden(latestLocal.current, ids, true));
|
|
192
|
+
else saveMarks(setHidden(latest.current, ids, true));
|
|
193
|
+
},
|
|
194
|
+
unhide: (ids, options) => {
|
|
195
|
+
if (options?.scope !== 'device') saveMarks(setHidden(latest.current, ids, false));
|
|
196
|
+
if (options?.scope !== 'everywhere') saveLocal(setHidden(latestLocal.current, ids, false));
|
|
197
|
+
},
|
|
137
198
|
toggleTag: (ids, raw) => {
|
|
138
199
|
const tag = normalizeTag(raw);
|
|
139
200
|
if (!tag || ids.length === 0) return;
|
|
@@ -148,15 +209,15 @@ export function useFolderView({ key }: UseFolderViewOptions): FolderView {
|
|
|
148
209
|
saveMarks(next);
|
|
149
210
|
},
|
|
150
211
|
setTags: (id, list) => saveMarks(setFolderTags(latest.current, id, list)),
|
|
151
|
-
setShowHidden: (show) => saveLocal({ ...
|
|
152
|
-
filterTag: (tag, mode) => saveLocal(setTagFilter(
|
|
153
|
-
clearTagFilter: () => saveLocal({ ...
|
|
212
|
+
setShowHidden: (show) => saveLocal({ ...latestLocal.current, showHidden: show }),
|
|
213
|
+
filterTag: (tag, mode) => saveLocal(setTagFilter(latestLocal.current, tag, mode)),
|
|
214
|
+
clearTagFilter: () => saveLocal({ ...latestLocal.current, only: [], hide: [] }),
|
|
154
215
|
panel,
|
|
155
216
|
openViewMenu: (at) => setPanel({ kind: 'view', at }),
|
|
156
217
|
openTagEditor: (ids, at) => setPanel({ kind: 'tags', at: at ?? null, ids }),
|
|
157
218
|
closePanel: () => setPanel(null),
|
|
158
219
|
onContextMenu,
|
|
159
220
|
}),
|
|
160
|
-
[marks, local, tags, panel, saveMarks, saveLocal, onContextMenu],
|
|
221
|
+
[marks, local, tags, deviceHiding, panel, saveMarks, saveLocal, onContextMenu],
|
|
161
222
|
);
|
|
162
223
|
}
|
|
@@ -16,7 +16,7 @@ describe('DenseGalleryDetail fullscreen toggle', () => {
|
|
|
16
16
|
/>,
|
|
17
17
|
);
|
|
18
18
|
expect(container.querySelector('.cbgd-watch')?.className).not.toContain('cbgd-watch-full');
|
|
19
|
-
expect(getByRole('button', { name:
|
|
19
|
+
expect(getByRole('button', { name: 'Full window' })).toBeDefined();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
test('clicking the toggle adds cbgd-watch-full and flips the button label', () => {
|
|
@@ -29,12 +29,14 @@ describe('DenseGalleryDetail fullscreen toggle', () => {
|
|
|
29
29
|
renderTile={() => null}
|
|
30
30
|
/>,
|
|
31
31
|
);
|
|
32
|
-
const toggle = getByRole('button', { name:
|
|
32
|
+
const toggle = getByRole('button', { name: 'Full window' });
|
|
33
33
|
fireEvent.click(toggle);
|
|
34
34
|
expect(container.querySelector('.cbgd-watch')?.className).toContain('cbgd-watch-full');
|
|
35
|
-
expect(
|
|
35
|
+
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// The floating way out — the titlebar's toggle is below the fold once the picture fills
|
|
38
|
+
// the window.
|
|
39
|
+
fireEvent.click(getByRole('button', { name: 'Leave full window' }));
|
|
38
40
|
expect(container.querySelector('.cbgd-watch')?.className).not.toContain('cbgd-watch-full');
|
|
39
41
|
});
|
|
40
42
|
});
|
|
@@ -149,7 +151,7 @@ describe('leaving full-stage by double-clicking the picture', () => {
|
|
|
149
151
|
|
|
150
152
|
test('a double-click on the frame leaves full-stage', () => {
|
|
151
153
|
const { container, getByRole } = detail();
|
|
152
|
-
fireEvent.click(getByRole('button', { name:
|
|
154
|
+
fireEvent.click(getByRole('button', { name: 'Full window' }));
|
|
153
155
|
const frame = container.querySelector('.cbgd-watch-player-box');
|
|
154
156
|
expect(frame).not.toBeNull();
|
|
155
157
|
fireEvent.doubleClick(frame as Element);
|
|
@@ -186,7 +188,7 @@ describe('leaving full-stage by double-clicking the picture', () => {
|
|
|
186
188
|
// `MediaGallery` passes `fullscreenOnDoubleClick={!fullStage}`, so while the shell is full
|
|
187
189
|
// there is exactly one claimant on the gesture and it is this one.
|
|
188
190
|
const { container, getByRole } = detail({ stageOwnsDoubleClick: true });
|
|
189
|
-
fireEvent.click(getByRole('button', { name:
|
|
191
|
+
fireEvent.click(getByRole('button', { name: 'Full window' }));
|
|
190
192
|
expect(container.querySelector('.cbgd-watch')?.className).toContain('cbgd-watch-full');
|
|
191
193
|
fireEvent.doubleClick(container.querySelector('.cbgd-watch-player-box') as Element);
|
|
192
194
|
expect(container.querySelector('.cbgd-watch')?.className).not.toContain('cbgd-watch-full');
|
|
@@ -232,6 +234,6 @@ describe('leaving full-stage by double-clicking the picture', () => {
|
|
|
232
234
|
/>,
|
|
233
235
|
);
|
|
234
236
|
expect(container.querySelector('.cbgd-watch')?.className).not.toContain('cbgd-watch-full');
|
|
235
|
-
expect(getByRole('button', { name:
|
|
237
|
+
expect(getByRole('button', { name: 'Full window' })).toBeDefined();
|
|
236
238
|
});
|
|
237
239
|
});
|
|
@@ -434,6 +434,13 @@ export interface DenseGalleryDetailProps {
|
|
|
434
434
|
* player is told to stand down there. See `leaveFullStage`.
|
|
435
435
|
*/
|
|
436
436
|
stageOwnsDoubleClick?: boolean;
|
|
437
|
+
/**
|
|
438
|
+
* Does the STAGE draw its own full-window toggle? `true` for a Vidstack video, whose control
|
|
439
|
+
* bar carries one beside fullscreen (`VideoPlayer`'s `fillWindowSlots`). Otherwise the shell
|
|
440
|
+
* floats its own way out over the corner of the window while `fullStage` is on, because the
|
|
441
|
+
* titlebar's toggle is below the fold whenever the picture fills the window's height.
|
|
442
|
+
*/
|
|
443
|
+
stageOffersFullStageToggle?: boolean;
|
|
437
444
|
}
|
|
438
445
|
|
|
439
446
|
/**
|
|
@@ -467,6 +474,7 @@ export function DenseGalleryDetail({
|
|
|
467
474
|
fullStage: fullStageProp,
|
|
468
475
|
onFullStageChange,
|
|
469
476
|
stageOwnsDoubleClick = false,
|
|
477
|
+
stageOffersFullStageToggle = false,
|
|
470
478
|
}: DenseGalleryDetailProps) {
|
|
471
479
|
// The watch view is ONE scroll container; the related grid windows against it so the
|
|
472
480
|
// stage scrolls off in normal flow rather than the grid owning a nested inner scroller.
|
|
@@ -565,10 +573,23 @@ export function DenseGalleryDetail({
|
|
|
565
573
|
apply();
|
|
566
574
|
return () => observer.disconnect();
|
|
567
575
|
}, [watchEl]);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
576
|
+
/*
|
|
577
|
+
* 🔴 Full-stage means FULL WINDOW — the browser window, not the plugin and not the screen.
|
|
578
|
+
*
|
|
579
|
+
* Owner, 2026-10-02: *"I often want the video as big as it can be to fill the browser
|
|
580
|
+
* window, while still keeping the aspect ratio and every part of the content visible … I
|
|
581
|
+
* still want to be able to scroll down to see other media files … This mode should also
|
|
582
|
+
* hide the top rows above the video … and hide the sidebar area too, but still allow
|
|
583
|
+
* scrolling."* Until then this filled only the gallery's own container — the app's
|
|
584
|
+
* toolstrip, its sidebar and its activity column still took their share of the window —
|
|
585
|
+
* and hid the related grid outright, so there was nothing to scroll to.
|
|
586
|
+
*
|
|
587
|
+
* So the detail view lifts itself over the WHOLE window (`.cbgd-watch-full`, `position:
|
|
588
|
+
* fixed`), the frame is exactly one window tall, and the titlebar and the related grid
|
|
589
|
+
* follow it in the same scroll. It is not browser fullscreen, which takes the physical
|
|
590
|
+
* screen; that stays the player's own button. Lifting with CSS rather than a portal is the
|
|
591
|
+
* point: a portal remounts the stage, and the video would start over.
|
|
592
|
+
*/
|
|
572
593
|
const [ownFullStage, setOwnFullStage] = useState(false);
|
|
573
594
|
// Controlled when the consumer passes it, uncontrolled otherwise — the ordinary React
|
|
574
595
|
// shape, so a consumer that never heard of the prop is byte-for-byte unchanged.
|
|
@@ -580,6 +601,16 @@ export function DenseGalleryDetail({
|
|
|
580
601
|
},
|
|
581
602
|
[onFullStageChange],
|
|
582
603
|
);
|
|
604
|
+
// Esc leaves full window, as it leaves every other mode that covers the page. Not while
|
|
605
|
+
// something else claimed the key first — an open menu or dialog closes and stays at that.
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
if (!fullStage) return;
|
|
608
|
+
const onKey = (event: KeyboardEvent) => {
|
|
609
|
+
if (event.key === 'Escape' && !event.defaultPrevented) setFullStage(false);
|
|
610
|
+
};
|
|
611
|
+
window.addEventListener('keydown', onKey);
|
|
612
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
613
|
+
}, [fullStage, setFullStage]);
|
|
583
614
|
|
|
584
615
|
/*
|
|
585
616
|
* 🔴 Double-click the picture to ENTER and LEAVE the shell's fullscreen.
|
|
@@ -752,8 +783,8 @@ export function DenseGalleryDetail({
|
|
|
752
783
|
{actions}
|
|
753
784
|
<Tooltip content={
|
|
754
785
|
fullStage
|
|
755
|
-
? '
|
|
756
|
-
: 'Fill the
|
|
786
|
+
? 'Leave full window (Esc)'
|
|
787
|
+
: 'Fill the window — the picture as big as the window, the files below'
|
|
757
788
|
}>
|
|
758
789
|
<button
|
|
759
790
|
// guardrails-ignore no-raw-action-button: same self-contained `.cbgd-`
|
|
@@ -762,11 +793,10 @@ export function DenseGalleryDetail({
|
|
|
762
793
|
type='button'
|
|
763
794
|
className='cbgd-order-btn is-icon'
|
|
764
795
|
aria-pressed={fullStage}
|
|
765
|
-
// 🔴
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
aria-label={fullStage ? 'Shrink' : 'Fullscreen'}
|
|
796
|
+
// 🔴 One NAME, and the state in `aria-pressed` — the toggle-button pattern.
|
|
797
|
+
// It was `Fullscreen`/`Shrink` until 2026-10-02, and `Fullscreen` stopped being
|
|
798
|
+
// true the day this began filling the WINDOW rather than the screen.
|
|
799
|
+
aria-label='Full window'
|
|
770
800
|
onClick={() => setFullStage(!fullStage)}
|
|
771
801
|
>
|
|
772
802
|
{fullStage ? <Minimize2 aria-hidden /> : <Maximize2 aria-hidden />}
|
|
@@ -782,6 +812,18 @@ export function DenseGalleryDetail({
|
|
|
782
812
|
) : null}
|
|
783
813
|
</div>
|
|
784
814
|
|
|
815
|
+
{fullStage && !stageOffersFullStageToggle ? (
|
|
816
|
+
<span className='cbgd-full-exit'>
|
|
817
|
+
<GalleryActionButton
|
|
818
|
+
iconOnly
|
|
819
|
+
tooltip='Leave full window (Esc)'
|
|
820
|
+
aria-label='Leave full window'
|
|
821
|
+
onClick={() => setFullStage(false)}
|
|
822
|
+
>
|
|
823
|
+
<Minimize2 aria-hidden />
|
|
824
|
+
</GalleryActionButton>
|
|
825
|
+
</span>
|
|
826
|
+
) : null}
|
|
785
827
|
<div className='cbgd-related'>
|
|
786
828
|
{relatedLabel ? <div className='cbgd-section-head'>{relatedLabel}</div> : null}
|
|
787
829
|
{relatedBody ?? (
|