cursedbelt 3.0.2 β 4.0.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/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.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/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +111 -31
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- package/src/verifyGraph.spec.ts +6 -0
|
@@ -639,6 +639,199 @@ describe('where you are', () => {
|
|
|
639
639
|
});
|
|
640
640
|
});
|
|
641
641
|
|
|
642
|
+
/**
|
|
643
|
+
* π΄ `expandOnReveal` β limit 1 of the reveal, opted into. See `FileTree.tsx`'s section on it.
|
|
644
|
+
*
|
|
645
|
+
* Owner, `ideas/collections-09-14.md`: *"clicking a file in media gallery content window should
|
|
646
|
+
* show that file in the file tree by like vscode does when you select a tab with a file in it."*
|
|
647
|
+
* He named the application whose `explorer.autoReveal` defaults to `true`, so the tree does it β
|
|
648
|
+
* and the half of the old refusal that was never taste (a CONTROLLED `collapsed` the consumer
|
|
649
|
+
* persists must not be rewritten behind its back) is what these tests hold down.
|
|
650
|
+
*/
|
|
651
|
+
describe('the reveal that opens the folder', () => {
|
|
652
|
+
/** The whole tree with `simpsons/screencaps` shut, reporting what it is asked to store. */
|
|
653
|
+
const withShut = (
|
|
654
|
+
props: Partial<React.ComponentProps<typeof FileTree>>,
|
|
655
|
+
shut: readonly string[],
|
|
656
|
+
onChange: (next: string[]) => void,
|
|
657
|
+
) => (
|
|
658
|
+
<FileTree
|
|
659
|
+
nodes={TREE}
|
|
660
|
+
selectedId={null}
|
|
661
|
+
onSelect={() => undefined}
|
|
662
|
+
collapsed={shut}
|
|
663
|
+
onCollapsedChange={onChange}
|
|
664
|
+
{...props}
|
|
665
|
+
/>
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
test('π΄ a shut folder on the way to the selection OPENS when the prop is wired', () => {
|
|
669
|
+
const shut: readonly string[] = ['simpsons/screencaps'];
|
|
670
|
+
const view = render(withShut({ expandOnReveal: true }, shut, () => undefined));
|
|
671
|
+
expect(screen.queryByText('s04')).toBeNull();
|
|
672
|
+
|
|
673
|
+
view.rerender(
|
|
674
|
+
withShut(
|
|
675
|
+
{ expandOnReveal: true, selectedId: 'simpsons/screencaps/s04' },
|
|
676
|
+
shut,
|
|
677
|
+
() => undefined,
|
|
678
|
+
),
|
|
679
|
+
);
|
|
680
|
+
expect(screen.getByText('s04')).toBeDefined();
|
|
681
|
+
// Nothing is left marked as "shut and holding it" β the row itself is the answer now.
|
|
682
|
+
expect(view.container.querySelectorAll('[data-holds-open]')).toHaveLength(0);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test('β¦and it stays SHUT without the prop β every existing consumer is unmoved', () => {
|
|
686
|
+
const shut: readonly string[] = ['simpsons/screencaps'];
|
|
687
|
+
const view = render(withShut({}, shut, () => undefined));
|
|
688
|
+
view.rerender(withShut({ selectedId: 'simpsons/screencaps/s04' }, shut, () => undefined));
|
|
689
|
+
expect(screen.queryByText('s04')).toBeNull();
|
|
690
|
+
expect(view.container.querySelectorAll('[data-holds-open]')).toHaveLength(1);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test('π΄ the open is NEVER written back β the persisted collapse is the one it was', () => {
|
|
694
|
+
// The real objection limit 1 recorded, and a correctness issue rather than a taste one:
|
|
695
|
+
// `collapsed` is controlled and the consumer writes it to storage, so a reveal that called
|
|
696
|
+
// `onCollapsedChange` would undo a collapse the person chose on every later mount. The
|
|
697
|
+
// expansion lives in an overlay of the tree's own instead, and this is what proves it.
|
|
698
|
+
let stored: readonly string[] = ['simpsons', 'simpsons/screencaps'];
|
|
699
|
+
const seen: string[][] = [];
|
|
700
|
+
const record = (next: string[]) => {
|
|
701
|
+
stored = next;
|
|
702
|
+
seen.push(next);
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const view = render(withShut({ expandOnReveal: true }, stored, record));
|
|
706
|
+
view.rerender(
|
|
707
|
+
withShut({ expandOnReveal: true, selectedId: 'simpsons/screencaps/s04' }, stored, record),
|
|
708
|
+
);
|
|
709
|
+
// Both shut ancestors opened β `screencaps` was not even drawn before this, so the folder
|
|
710
|
+
// the reveal needs is found from the tree rather than from a `data-holds-open` marker.
|
|
711
|
+
expect(screen.getByText('s04')).toBeDefined();
|
|
712
|
+
expect(seen).toEqual([]);
|
|
713
|
+
expect(stored).toEqual(['simpsons', 'simpsons/screencaps']);
|
|
714
|
+
|
|
715
|
+
// And the proof that matters: mounted again with the value the consumer still holds, the
|
|
716
|
+
// folders are shut, exactly as they were left.
|
|
717
|
+
cleanup();
|
|
718
|
+
render(withShut({ expandOnReveal: true, selectedId: null }, stored, record));
|
|
719
|
+
expect(screen.queryByText('screencaps')).toBeNull();
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test('π΄ a folder shut BY HAND afterwards stays shut β the reveal fires once per selection', () => {
|
|
723
|
+
// What `explorer.autoReveal` does, and the only thing that leaves the twisty working: an
|
|
724
|
+
// expansion that re-asserted itself would make the control do nothing at all while the
|
|
725
|
+
// same file is open.
|
|
726
|
+
const seen: string[][] = [];
|
|
727
|
+
const stored: readonly string[] = ['simpsons/screencaps'];
|
|
728
|
+
const props = { expandOnReveal: true, selectedId: 'simpsons/screencaps/s04' };
|
|
729
|
+
const view = render(withShut({ expandOnReveal: true }, stored, (n) => seen.push(n)));
|
|
730
|
+
view.rerender(withShut(props, stored, (n) => seen.push(n)));
|
|
731
|
+
expect(screen.getByText('s04')).toBeDefined();
|
|
732
|
+
|
|
733
|
+
const row = screen.getByText('screencaps').closest('[role="treeitem"]') as HTMLElement;
|
|
734
|
+
fireEvent.click(row.querySelector('.cbft-twisty') as HTMLElement);
|
|
735
|
+
expect(screen.queryByText('s04')).toBeNull();
|
|
736
|
+
// It was already shut in the value the consumer holds, so there is nothing to write there
|
|
737
|
+
// either β shutting it only dropped the overlay.
|
|
738
|
+
expect(seen).toEqual([]);
|
|
739
|
+
|
|
740
|
+
// Still shut on the next render of the same selection.
|
|
741
|
+
view.rerender(withShut(props, stored, (n) => seen.push(n)));
|
|
742
|
+
expect(screen.queryByText('s04')).toBeNull();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test('π΄ it SAYS which folders it opened β the half a lazily-fetched tree needs', () => {
|
|
746
|
+
// `apps/collections` asks the server for a folder's files only for the folders it believes
|
|
747
|
+
// are open, so an expansion it cannot see draws the folder open and empty with the file
|
|
748
|
+
// still missing. The overlay is silent; `onRevealExpand` is what stops it being invisible.
|
|
749
|
+
const told: string[][] = [];
|
|
750
|
+
const stored: readonly string[] = ['simpsons', 'simpsons/screencaps'];
|
|
751
|
+
const seen: string[][] = [];
|
|
752
|
+
const view = render(
|
|
753
|
+
<FileTree
|
|
754
|
+
nodes={TREE}
|
|
755
|
+
selectedId={null}
|
|
756
|
+
onSelect={() => undefined}
|
|
757
|
+
collapsed={stored}
|
|
758
|
+
onCollapsedChange={(next) => seen.push(next)}
|
|
759
|
+
expandOnReveal
|
|
760
|
+
onRevealExpand={(ids) => told.push([...ids])}
|
|
761
|
+
/>,
|
|
762
|
+
);
|
|
763
|
+
view.rerender(
|
|
764
|
+
<FileTree
|
|
765
|
+
nodes={TREE}
|
|
766
|
+
selectedId='simpsons/screencaps/s04'
|
|
767
|
+
onSelect={() => undefined}
|
|
768
|
+
collapsed={stored}
|
|
769
|
+
onCollapsedChange={(next) => seen.push(next)}
|
|
770
|
+
expandOnReveal
|
|
771
|
+
onRevealExpand={(ids) => told.push([...ids])}
|
|
772
|
+
/>,
|
|
773
|
+
);
|
|
774
|
+
// Both folders on the way, once, in root-first order β and still nothing to persist.
|
|
775
|
+
expect(told).toEqual([['simpsons', 'simpsons/screencaps']]);
|
|
776
|
+
expect(seen).toEqual([]);
|
|
777
|
+
|
|
778
|
+
// A selection inside a folder that is already open has nothing to say.
|
|
779
|
+
view.rerender(
|
|
780
|
+
<FileTree
|
|
781
|
+
nodes={TREE}
|
|
782
|
+
selectedId='simpsons/screencaps'
|
|
783
|
+
onSelect={() => undefined}
|
|
784
|
+
collapsed={stored}
|
|
785
|
+
onCollapsedChange={(next) => seen.push(next)}
|
|
786
|
+
expandOnReveal
|
|
787
|
+
onRevealExpand={(ids) => told.push([...ids])}
|
|
788
|
+
/>,
|
|
789
|
+
);
|
|
790
|
+
expect(told).toHaveLength(1);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test('π΄ the row it opened is the row it scrolls to', () => {
|
|
794
|
+
// Two passes, and this is the second: the row does not exist in the DOM until the folders
|
|
795
|
+
// above it are open, so an expansion that scrolled in the same pass would scroll to
|
|
796
|
+
// nothing. happy-dom computes no layout β the frame is stubbed and every row's rect is
|
|
797
|
+
// zeros, which puts all of them outside it.
|
|
798
|
+
//
|
|
799
|
+
// π΄ The stub goes on `HTMLElement.prototype`, not on `Element.prototype`, and the reason
|
|
800
|
+
// is a leak rather than a preference: `MediaGallery.spec.tsx` assigns
|
|
801
|
+
// `HTMLElement.prototype.scrollIntoView` and never puts it back, and `bun test` runs every
|
|
802
|
+
// spec in ONE process. The derived prototype shadows the base one, so a stub on `Element`
|
|
803
|
+
// is never reached once that file has run β this test passed alone and failed in the suite
|
|
804
|
+
// for exactly that reason. The row it needs is not in the DOM yet, which is why this one
|
|
805
|
+
// cannot stub per instance the way the two reveal tests above do.
|
|
806
|
+
const scrolled: string[] = [];
|
|
807
|
+
const proto = HTMLElement.prototype as unknown as { scrollIntoView?: () => void };
|
|
808
|
+
const prior = proto.scrollIntoView;
|
|
809
|
+
proto.scrollIntoView = function record(this: Element) {
|
|
810
|
+
scrolled.push((this.textContent ?? '').trim());
|
|
811
|
+
};
|
|
812
|
+
try {
|
|
813
|
+
const stored: readonly string[] = ['simpsons/screencaps'];
|
|
814
|
+
const view = render(withShut({ expandOnReveal: true }, stored, () => undefined));
|
|
815
|
+
// Both rects are stubbed, so the leaked `HTMLElement.prototype.getBoundingClientRect`
|
|
816
|
+
// from that same file (a 800Γ600 box at the origin) cannot decide this either way: the
|
|
817
|
+
// frame starts at 100, and every candidate row's top is above it.
|
|
818
|
+
(view.container.querySelector('.cbft-scroll') as HTMLElement).getBoundingClientRect = () =>
|
|
819
|
+
({ top: 100, bottom: 400 }) as DOMRect;
|
|
820
|
+
view.rerender(
|
|
821
|
+
withShut(
|
|
822
|
+
{ expandOnReveal: true, selectedId: 'simpsons/screencaps/s04' },
|
|
823
|
+
stored,
|
|
824
|
+
() => undefined,
|
|
825
|
+
),
|
|
826
|
+
);
|
|
827
|
+
expect(scrolled.at(-1)).toContain('s04');
|
|
828
|
+
} finally {
|
|
829
|
+
if (prior) proto.scrollIntoView = prior;
|
|
830
|
+
else delete proto.scrollIntoView;
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
|
|
642
835
|
describe('the drag, beyond the pointer', () => {
|
|
643
836
|
/** Start a real gesture over the blank space: press, then travel past `DRAG_THRESHOLD`. */
|
|
644
837
|
const lift = (row: HTMLElement) => {
|
|
@@ -807,6 +1000,599 @@ describe('the keyboard, beyond walking', () => {
|
|
|
807
1000
|
});
|
|
808
1001
|
});
|
|
809
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* π΄ MULTI-SELECT β owner, 2026-09-14, `ideas/file-tree.md` item 2.
|
|
1005
|
+
*
|
|
1006
|
+
* *"If I click on a file in the sidebar file tree with the folders then hold shift and click
|
|
1007
|
+
* another item in the folder, I want it to select all the files from the first file to the one
|
|
1008
|
+
* I clicked next while holding shift. Then with all of those selected I want to be able to
|
|
1009
|
+
* click and hold on any of them to drag and drop them to another folder or to the root outside
|
|
1010
|
+
* of any folder. Another key shortcut functionality I need is selecting multiple files by
|
|
1011
|
+
* holding the command key on my mac and clicking each file I want to select then dragging them
|
|
1012
|
+
* to another folder. For both of these new selection functionalities I also want to be able to
|
|
1013
|
+
* hold command and click delete to delete all the selected files. I also want to be able to cut
|
|
1014
|
+
* and paste to move them with command + x and command + v, and copy them with command + c and
|
|
1015
|
+
* command + v."*
|
|
1016
|
+
*
|
|
1017
|
+
* The arithmetic β which rows a range covers, whether a drop is legal for N ids β is
|
|
1018
|
+
* `fileTreeSelection.spec.ts`, without a DOM. What is here is the DOM half: which gesture
|
|
1019
|
+
* reports what, and the compatibility rule that holds the whole design together β
|
|
1020
|
+
* **a consumer that wired none of the plural props sees exactly what it saw before.**
|
|
1021
|
+
*/
|
|
1022
|
+
describe('selecting more than one row', () => {
|
|
1023
|
+
const idsOf = (name: string) =>
|
|
1024
|
+
(screen.getByText(name).closest('[role="treeitem"]') as HTMLElement) ?? null;
|
|
1025
|
+
|
|
1026
|
+
test('π΄ shift-click selects the range between the anchor and the click', () => {
|
|
1027
|
+
const reported: string[][] = [];
|
|
1028
|
+
const picked: string[] = [];
|
|
1029
|
+
tree({
|
|
1030
|
+
onSelect: (n) => picked.push(n.id),
|
|
1031
|
+
onSelectionChange: (ids) => reported.push(ids),
|
|
1032
|
+
});
|
|
1033
|
+
// A plain click sets the anchor β and still selects, because the ordinary act is
|
|
1034
|
+
// unchanged by any of this.
|
|
1035
|
+
fireEvent.click(idsOf('screencaps'));
|
|
1036
|
+
expect(picked).toEqual(['simpsons/screencaps']);
|
|
1037
|
+
expect(reported).toEqual([['simpsons/screencaps']]);
|
|
1038
|
+
|
|
1039
|
+
fireEvent.click(idsOf('Travels'), { shiftKey: true });
|
|
1040
|
+
expect(reported.at(-1)).toEqual([
|
|
1041
|
+
'simpsons/screencaps',
|
|
1042
|
+
'simpsons/screencaps/s04',
|
|
1043
|
+
'dinos',
|
|
1044
|
+
'travels',
|
|
1045
|
+
]);
|
|
1046
|
+
// π΄ β¦and it did NOT also navigate. Extending a selection is not opening anything, and in
|
|
1047
|
+
// a consumer that shows the selected row in the main pane it would be.
|
|
1048
|
+
expect(picked).toEqual(['simpsons/screencaps']);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test('π΄ the anchor does not move, so a second shift-click re-covers from the same start', () => {
|
|
1052
|
+
// The behaviour every file explorer has: shift-click down four rows, realise you meant
|
|
1053
|
+
// two, shift-click the second β and get two, not six.
|
|
1054
|
+
const reported: string[][] = [];
|
|
1055
|
+
tree({ onSelectionChange: (ids) => reported.push(ids) });
|
|
1056
|
+
fireEvent.click(idsOf('Simpsons'));
|
|
1057
|
+
fireEvent.click(idsOf('Travels'), { shiftKey: true });
|
|
1058
|
+
expect(reported.at(-1)).toHaveLength(5);
|
|
1059
|
+
fireEvent.click(idsOf('screencaps'), { shiftKey: true });
|
|
1060
|
+
expect(reported.at(-1)).toEqual(['simpsons', 'simpsons/screencaps']);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
test('π΄ β-click adds one, and β-clicking it again takes it out', () => {
|
|
1064
|
+
const reported: string[][] = [];
|
|
1065
|
+
const picked: string[] = [];
|
|
1066
|
+
const view = tree({
|
|
1067
|
+
selectedIds: ['travels'],
|
|
1068
|
+
onSelect: (n) => picked.push(n.id),
|
|
1069
|
+
onSelectionChange: (ids) => reported.push(ids),
|
|
1070
|
+
});
|
|
1071
|
+
fireEvent.click(idsOf('Dinos'), { metaKey: true });
|
|
1072
|
+
expect(reported.at(-1)).toEqual(['travels', 'dinos']);
|
|
1073
|
+
expect(picked).toEqual([]);
|
|
1074
|
+
|
|
1075
|
+
view.rerender(
|
|
1076
|
+
<FileTree
|
|
1077
|
+
nodes={TREE}
|
|
1078
|
+
selectedId={null}
|
|
1079
|
+
selectedIds={['travels', 'dinos']}
|
|
1080
|
+
onSelect={(n) => picked.push(n.id)}
|
|
1081
|
+
onSelectionChange={(ids) => reported.push(ids)}
|
|
1082
|
+
/>,
|
|
1083
|
+
);
|
|
1084
|
+
fireEvent.click(idsOf('Travels'), { metaKey: true });
|
|
1085
|
+
expect(reported.at(-1)).toEqual(['dinos']);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test('every selected row is drawn selected, and the tree says it takes more than one', () => {
|
|
1089
|
+
const { container } = tree({
|
|
1090
|
+
selectedIds: ['dinos', 'travels'],
|
|
1091
|
+
onSelectionChange: () => undefined,
|
|
1092
|
+
});
|
|
1093
|
+
const marked = [...container.querySelectorAll('[data-selected]')].map((r) =>
|
|
1094
|
+
(r.textContent ?? '').trim(),
|
|
1095
|
+
);
|
|
1096
|
+
expect(marked).toEqual(['Dinos', 'Travels']);
|
|
1097
|
+
expect(
|
|
1098
|
+
(container.querySelector('[role="tree"]') as HTMLElement).getAttribute(
|
|
1099
|
+
'aria-multiselectable',
|
|
1100
|
+
),
|
|
1101
|
+
).toBe('true');
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
test('π΄ a consumer that wired none of it is untouched β one selected row, no promise of more', () => {
|
|
1105
|
+
// The whole reason `selectedId` did not move. `apps/collections` and `apps/music` both
|
|
1106
|
+
// read it as "what the main pane is showing", and a tree that silently became
|
|
1107
|
+
// multi-select would have broken that on the day this published.
|
|
1108
|
+
const picked: string[] = [];
|
|
1109
|
+
const { container } = tree({ selectedId: 'dinos', onSelect: (n) => picked.push(n.id) });
|
|
1110
|
+
expect(container.querySelectorAll('[data-selected]')).toHaveLength(1);
|
|
1111
|
+
expect(
|
|
1112
|
+
(container.querySelector('[role="tree"]') as HTMLElement).getAttribute(
|
|
1113
|
+
'aria-multiselectable',
|
|
1114
|
+
),
|
|
1115
|
+
).toBeNull();
|
|
1116
|
+
// Shift and β are not this tree's keys unless the consumer can receive a selection.
|
|
1117
|
+
fireEvent.click(idsOf('Travels'), { shiftKey: true });
|
|
1118
|
+
fireEvent.click(idsOf('Simpsons'), { metaKey: true });
|
|
1119
|
+
expect(picked).toEqual(['travels', 'simpsons']);
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
describe('dragging a selection', () => {
|
|
1124
|
+
/** As in `the drag, beyond the pointer` β happy-dom computes no layout, so say where. */
|
|
1125
|
+
const layOut = (underPointer: string | null) => {
|
|
1126
|
+
for (const row of screen.getAllByRole('treeitem') as HTMLElement[]) {
|
|
1127
|
+
const hit = (row.textContent ?? '').trim() === underPointer;
|
|
1128
|
+
row.getBoundingClientRect = () =>
|
|
1129
|
+
({ top: hit ? 80 : 500, bottom: hit ? 100 : 520, left: 0, right: 200, height: 20 }) as DOMRect;
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
/** Press `from`, travel past `DRAG_THRESHOLD` onto whatever `layOut` put under the path. */
|
|
1134
|
+
const dragTo = (from: HTMLElement, over: string | null) => {
|
|
1135
|
+
fireEvent.pointerDown(from, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
|
|
1136
|
+
layOut(over);
|
|
1137
|
+
fireEvent.pointerMove(from, { clientX: 10, clientY: 90, pointerId: 1 });
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* The ROW with this name β not the drag chip, which carries the same name and is in the
|
|
1142
|
+
* document from the first `pointermove` onward.
|
|
1143
|
+
*/
|
|
1144
|
+
const rowNamed = (name: string) =>
|
|
1145
|
+
screen
|
|
1146
|
+
.getAllByText(name)
|
|
1147
|
+
.map((el) => el.closest('[role="treeitem"]'))
|
|
1148
|
+
.find(Boolean) as HTMLElement;
|
|
1149
|
+
|
|
1150
|
+
test('π΄ pressing a row that is part of a selection carries the whole selection', () => {
|
|
1151
|
+
// *"with all of those selected I want to be able to click and hold on any of them to drag
|
|
1152
|
+
// and drop them to another folder"*.
|
|
1153
|
+
const moved: Array<[readonly string[], unknown]> = [];
|
|
1154
|
+
const { container } = tree({
|
|
1155
|
+
selectedIds: ['dinos', 'travels'],
|
|
1156
|
+
onSelectionChange: () => undefined,
|
|
1157
|
+
onMoveMany: (ids, drop) => moved.push([ids, drop]),
|
|
1158
|
+
});
|
|
1159
|
+
dragTo(rowNamed('Travels'), 'Simpsons');
|
|
1160
|
+
// The chip counts rather than naming β twenty names is not something a chip under a
|
|
1161
|
+
// moving cursor can be read as.
|
|
1162
|
+
expect(container.querySelector('.cbft-chip-name')?.textContent).toBe('2 items');
|
|
1163
|
+
expect(container.querySelector('.cbft-chip-where')?.textContent).toBe('Into Simpsons');
|
|
1164
|
+
fireEvent.pointerUp(rowNamed('Travels'), { pointerId: 1 });
|
|
1165
|
+
expect(moved).toEqual([[['dinos', 'travels'], { kind: 'into', parentId: 'simpsons' }]]);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
test('every carried row is drawn lifted, contents included', () => {
|
|
1169
|
+
const { container } = tree({
|
|
1170
|
+
selectedIds: ['simpsons', 'travels'],
|
|
1171
|
+
onSelectionChange: () => undefined,
|
|
1172
|
+
onMoveMany: () => undefined,
|
|
1173
|
+
});
|
|
1174
|
+
dragTo(rowNamed('Travels'), 'Dinos');
|
|
1175
|
+
const lifted = [...container.querySelectorAll('[data-dragged]')].map((r) =>
|
|
1176
|
+
(r.textContent ?? '').trim(),
|
|
1177
|
+
);
|
|
1178
|
+
// `Simpsons` takes `screencaps` and `s04` with it β the union of what each row carries.
|
|
1179
|
+
expect(lifted).toEqual(['Simpsons', 'screencaps', 's04', 'Travels']);
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
test('π΄ one illegal destination refuses the whole drop, rather than half-landing it', () => {
|
|
1183
|
+
const moved: unknown[] = [];
|
|
1184
|
+
const { container } = tree({
|
|
1185
|
+
selectedIds: ['dinos', 'simpsons'],
|
|
1186
|
+
onSelectionChange: () => undefined,
|
|
1187
|
+
onMoveMany: (ids, drop) => moved.push([ids, drop]),
|
|
1188
|
+
});
|
|
1189
|
+
// `screencaps` is inside `Simpsons`, which is one of the two rows in the air.
|
|
1190
|
+
dragTo(rowNamed('Dinos'), 'screencaps');
|
|
1191
|
+
expect((container.querySelector('.cbft-chip') as HTMLElement).getAttribute('data-refused')).toBe(
|
|
1192
|
+
'true',
|
|
1193
|
+
);
|
|
1194
|
+
expect(container.querySelector('.cbft-chip-where')?.textContent).toBe(
|
|
1195
|
+
'A folder cannot go inside one of its own folders',
|
|
1196
|
+
);
|
|
1197
|
+
fireEvent.pointerUp(rowNamed('Dinos'), { pointerId: 1 });
|
|
1198
|
+
expect(moved).toEqual([]);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test('pressing a row OUTSIDE the selection carries only that row', () => {
|
|
1202
|
+
// Finder's rule, and the only one that lets you move a single row out of a group you have
|
|
1203
|
+
// built without dismantling it first.
|
|
1204
|
+
const many: unknown[] = [];
|
|
1205
|
+
const one: Array<[string, unknown]> = [];
|
|
1206
|
+
tree({
|
|
1207
|
+
selectedIds: ['dinos', 'travels'],
|
|
1208
|
+
onSelectionChange: () => undefined,
|
|
1209
|
+
onMove: (id, drop) => one.push([id, drop]),
|
|
1210
|
+
onMoveMany: (ids) => many.push(ids),
|
|
1211
|
+
});
|
|
1212
|
+
dragTo(rowNamed('screencaps'), 'Dinos');
|
|
1213
|
+
fireEvent.pointerUp(rowNamed('screencaps'), { pointerId: 1 });
|
|
1214
|
+
expect(many).toEqual([]);
|
|
1215
|
+
expect(one).toEqual([['simpsons/screencaps', { kind: 'into', parentId: 'dinos' }]]);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
test('π΄ without `onMoveMany` the drag is the one row it started on, exactly as before', () => {
|
|
1219
|
+
// The rule that keeps every existing consumer still: N > 1 goes through the plural verb,
|
|
1220
|
+
// and without it the gesture never invents a batch nobody can receive.
|
|
1221
|
+
const one: Array<[string, unknown]> = [];
|
|
1222
|
+
tree({
|
|
1223
|
+
selectedIds: ['dinos', 'travels'],
|
|
1224
|
+
onSelectionChange: () => undefined,
|
|
1225
|
+
onMove: (id, drop) => one.push([id, drop]),
|
|
1226
|
+
});
|
|
1227
|
+
dragTo(rowNamed('Travels'), 'Simpsons');
|
|
1228
|
+
fireEvent.pointerUp(rowNamed('Travels'), { pointerId: 1 });
|
|
1229
|
+
expect(one).toEqual([['travels', { kind: 'into', parentId: 'simpsons' }]]);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test('π΄ one undraggable row in the set stops the gesture β all or nothing', () => {
|
|
1233
|
+
// A drag that moved four of five rows and said nothing is the failure the plural drop rule
|
|
1234
|
+
// exists to prevent, and it is no better for arriving at the start of the gesture.
|
|
1235
|
+
// `apps/collections` marks its albums `draggable: false`.
|
|
1236
|
+
const moved: unknown[] = [];
|
|
1237
|
+
const nodes: FileTreeNode[] = [
|
|
1238
|
+
{ id: 'album', name: 'Album', kind: 'folder', draggable: false, children: [] },
|
|
1239
|
+
folder('dinos', 'Dinos'),
|
|
1240
|
+
folder('travels', 'Travels'),
|
|
1241
|
+
];
|
|
1242
|
+
const { container } = render(
|
|
1243
|
+
<FileTree
|
|
1244
|
+
nodes={nodes}
|
|
1245
|
+
selectedId={null}
|
|
1246
|
+
selectedIds={['dinos', 'album']}
|
|
1247
|
+
onSelect={() => undefined}
|
|
1248
|
+
onSelectionChange={() => undefined}
|
|
1249
|
+
onMoveMany={(ids) => moved.push(ids)}
|
|
1250
|
+
/>,
|
|
1251
|
+
);
|
|
1252
|
+
dragTo(screen.getByText('Dinos').closest('[role="treeitem"]') as HTMLElement, 'Travels');
|
|
1253
|
+
expect(container.querySelector('.cbft-chip')).toBeNull();
|
|
1254
|
+
expect(moved).toEqual([]);
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
describe('deleting a selection', () => {
|
|
1259
|
+
const focusRow = (name: string) => {
|
|
1260
|
+
const row = screen.getByText(name).closest('[role="treeitem"]') as HTMLElement;
|
|
1261
|
+
row.focus();
|
|
1262
|
+
return row;
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
test('π΄ ββ« on a selection deletes all of it', () => {
|
|
1266
|
+
// *"I also want to be able to hold command and click delete to delete all the selected
|
|
1267
|
+
// files."* It arrives as Backspace WITH metaKey.
|
|
1268
|
+
const many: string[][] = [];
|
|
1269
|
+
const one: string[] = [];
|
|
1270
|
+
tree({
|
|
1271
|
+
selectedIds: ['dinos', 'travels'],
|
|
1272
|
+
onSelectionChange: () => undefined,
|
|
1273
|
+
onDelete: (n) => one.push(n.id),
|
|
1274
|
+
onDeleteMany: (nodes) => many.push(nodes.map((n) => n.id)),
|
|
1275
|
+
});
|
|
1276
|
+
fireEvent.keyDown(focusRow('Dinos'), { key: 'Backspace', metaKey: true });
|
|
1277
|
+
expect(many).toEqual([['dinos', 'travels']]);
|
|
1278
|
+
// Never both β a row deleted twice is a confirmation dialog asked twice.
|
|
1279
|
+
expect(one).toEqual([]);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test('a folder and a file inside it ask for ONE deletion, not two', () => {
|
|
1283
|
+
const many: string[][] = [];
|
|
1284
|
+
tree({
|
|
1285
|
+
selectedIds: ['simpsons', 'simpsons/screencaps/s04'],
|
|
1286
|
+
onSelectionChange: () => undefined,
|
|
1287
|
+
onDeleteMany: (nodes) => many.push(nodes.map((n) => n.id)),
|
|
1288
|
+
});
|
|
1289
|
+
fireEvent.keyDown(focusRow('Simpsons'), { key: 'Delete' });
|
|
1290
|
+
expect(many).toEqual([['simpsons']]);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test('π΄ without `onDeleteMany` it is the focused row alone β todayβs behaviour, kept', () => {
|
|
1294
|
+
const one: string[] = [];
|
|
1295
|
+
tree({
|
|
1296
|
+
selectedIds: ['dinos', 'travels'],
|
|
1297
|
+
onSelectionChange: () => undefined,
|
|
1298
|
+
onDelete: (n) => one.push(n.id),
|
|
1299
|
+
});
|
|
1300
|
+
fireEvent.keyDown(focusRow('Dinos'), { key: 'Backspace', metaKey: true });
|
|
1301
|
+
expect(one).toEqual(['dinos']);
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
test('a focused row OUTSIDE the selection is the only thing the key means', () => {
|
|
1305
|
+
const many: unknown[] = [];
|
|
1306
|
+
const one: string[] = [];
|
|
1307
|
+
tree({
|
|
1308
|
+
selectedIds: ['dinos', 'travels'],
|
|
1309
|
+
onSelectionChange: () => undefined,
|
|
1310
|
+
onDelete: (n) => one.push(n.id),
|
|
1311
|
+
onDeleteMany: (nodes) => many.push(nodes.map((n) => n.id)),
|
|
1312
|
+
});
|
|
1313
|
+
fireEvent.keyDown(focusRow('screencaps'), { key: 'Backspace', metaKey: true });
|
|
1314
|
+
expect(many).toEqual([]);
|
|
1315
|
+
expect(one).toEqual(['simpsons/screencaps']);
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* βX / βC / βV β *"cut and paste to move them with command + x and command + v, and copy them
|
|
1321
|
+
* with command + c and command + v. Copying should allow copying in another folder or the same
|
|
1322
|
+
* folder. These are similar to how finder works except cutting and pasting will be easy with
|
|
1323
|
+
* just command + x."*
|
|
1324
|
+
*/
|
|
1325
|
+
describe('the clipboard', () => {
|
|
1326
|
+
const focusRow = (name: string) => {
|
|
1327
|
+
const row = screen.getByText(name).closest('[role="treeitem"]') as HTMLElement;
|
|
1328
|
+
row.focus();
|
|
1329
|
+
return row;
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
test('π΄ βX marks the rows, and βV moves them into the focused row', () => {
|
|
1333
|
+
const moved: Array<[readonly string[], unknown]> = [];
|
|
1334
|
+
const { container } = tree({
|
|
1335
|
+
selectedIds: ['dinos', 'travels'],
|
|
1336
|
+
onSelectionChange: () => undefined,
|
|
1337
|
+
onMoveMany: (ids, drop) => moved.push([ids, drop]),
|
|
1338
|
+
});
|
|
1339
|
+
fireEvent.keyDown(focusRow('Dinos'), { key: 'x', metaKey: true });
|
|
1340
|
+
// A cut with no mark is a keystroke that appears to have done nothing β so the next thing
|
|
1341
|
+
// a person does is press it again somewhere else, and then βV moves the wrong rows.
|
|
1342
|
+
expect([...container.querySelectorAll('[data-cut]')].map((r) => (r.textContent ?? '').trim())).toEqual(
|
|
1343
|
+
['Dinos', 'Travels'],
|
|
1344
|
+
);
|
|
1345
|
+
fireEvent.keyDown(focusRow('Simpsons'), { key: 'v', metaKey: true });
|
|
1346
|
+
expect(moved).toEqual([[['dinos', 'travels'], { kind: 'into', parentId: 'simpsons' }]]);
|
|
1347
|
+
// Consumed: the rows have moved, and a second paste would move them again from wherever
|
|
1348
|
+
// they now are.
|
|
1349
|
+
expect(container.querySelectorAll('[data-cut]')).toHaveLength(0);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test('π΄ a paste the drag would refuse is refused, in the same words', () => {
|
|
1353
|
+
// One opinion about what is legal. A keyboard route that reaches a destination the pointer
|
|
1354
|
+
// refuses is the second, weaker opinion nobody looks at.
|
|
1355
|
+
const moved: unknown[] = [];
|
|
1356
|
+
const { container } = tree({ onMove: (id) => moved.push(id) });
|
|
1357
|
+
fireEvent.keyDown(focusRow('Simpsons'), { key: 'x', metaKey: true });
|
|
1358
|
+
fireEvent.keyDown(focusRow('s04'), { key: 'v', metaKey: true });
|
|
1359
|
+
expect(moved).toEqual([]);
|
|
1360
|
+
expect(container.querySelector('.cbft-sr')?.textContent).toBe(
|
|
1361
|
+
'A folder cannot go inside one of its own folders',
|
|
1362
|
+
);
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
test('Escape puts a cut down, so the mark is never stuck on', () => {
|
|
1366
|
+
const { container } = tree({ onMove: () => undefined });
|
|
1367
|
+
fireEvent.keyDown(focusRow('Dinos'), { key: 'x', metaKey: true });
|
|
1368
|
+
expect(container.querySelectorAll('[data-cut]')).toHaveLength(1);
|
|
1369
|
+
fireEvent.keyDown(focusRow('Dinos'), { key: 'Escape' });
|
|
1370
|
+
expect(container.querySelectorAll('[data-cut]')).toHaveLength(0);
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
test('π΄ βC then βV copies, and the clipboard SURVIVES the paste', () => {
|
|
1374
|
+
// Finder pastes the same copy again, and *"copying in another folder or the same folder"*
|
|
1375
|
+
// is two pastes of one βC.
|
|
1376
|
+
const copied: Array<[readonly string[], unknown]> = [];
|
|
1377
|
+
tree({
|
|
1378
|
+
selectedIds: ['dinos', 'travels'],
|
|
1379
|
+
onSelectionChange: () => undefined,
|
|
1380
|
+
onCopy: (ids, drop) => copied.push([ids, drop]),
|
|
1381
|
+
});
|
|
1382
|
+
fireEvent.keyDown(focusRow('Dinos'), { key: 'c', metaKey: true });
|
|
1383
|
+
fireEvent.keyDown(focusRow('Simpsons'), { key: 'v', metaKey: true });
|
|
1384
|
+
fireEvent.keyDown(focusRow('screencaps'), { key: 'v', metaKey: true });
|
|
1385
|
+
expect(copied).toEqual([
|
|
1386
|
+
[['dinos', 'travels'], { kind: 'into', parentId: 'simpsons' }],
|
|
1387
|
+
[['dinos', 'travels'], { kind: 'into', parentId: 'simpsons/screencaps' }],
|
|
1388
|
+
]);
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
test('π΄ `canCopy` vetoes the take, not the paste β apps/music has nothing to duplicate', () => {
|
|
1392
|
+
// A folder there is derived from the `folder` column of the tracks in it, so copying one
|
|
1393
|
+
// would mean duplicating audio the owner has exactly one copy of. A refusal after βV
|
|
1394
|
+
// would be a refusal with nothing left to say it about.
|
|
1395
|
+
const copied: unknown[] = [];
|
|
1396
|
+
const { container } = tree({
|
|
1397
|
+
onCopy: (ids) => copied.push(ids),
|
|
1398
|
+
canCopy: (node) => (node.kind === 'folder' ? 'A folder here is derived, not a copy' : null),
|
|
1399
|
+
});
|
|
1400
|
+
fireEvent.keyDown(focusRow('Dinos'), { key: 'c', metaKey: true });
|
|
1401
|
+
expect(container.querySelector('.cbft-sr')?.textContent).toBe(
|
|
1402
|
+
'A folder here is derived, not a copy',
|
|
1403
|
+
);
|
|
1404
|
+
expect(container.querySelectorAll('[data-cut]')).toHaveLength(0);
|
|
1405
|
+
fireEvent.keyDown(focusRow('Simpsons'), { key: 'v', metaKey: true });
|
|
1406
|
+
expect(copied).toEqual([]);
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
test('π΄ a key with no verb behind it is not this treeβs key at all', () => {
|
|
1410
|
+
// No `onCopy` and βC is not a chord this component has β the event falls through to the
|
|
1411
|
+
// browser rather than being swallowed by a control that would do nothing with it.
|
|
1412
|
+
const cut = tree({ onCopy: undefined });
|
|
1413
|
+
const row = focusRow('Dinos');
|
|
1414
|
+
const copyEvent = new KeyboardEvent('keydown', {
|
|
1415
|
+
key: 'c',
|
|
1416
|
+
metaKey: true,
|
|
1417
|
+
bubbles: true,
|
|
1418
|
+
cancelable: true,
|
|
1419
|
+
});
|
|
1420
|
+
row.dispatchEvent(copyEvent);
|
|
1421
|
+
expect(copyEvent.defaultPrevented).toBe(false);
|
|
1422
|
+
// β¦and with no move verb either, neither is βX.
|
|
1423
|
+
const cutEvent = new KeyboardEvent('keydown', {
|
|
1424
|
+
key: 'x',
|
|
1425
|
+
metaKey: true,
|
|
1426
|
+
bubbles: true,
|
|
1427
|
+
cancelable: true,
|
|
1428
|
+
});
|
|
1429
|
+
row.dispatchEvent(cutEvent);
|
|
1430
|
+
expect(cutEvent.defaultPrevented).toBe(false);
|
|
1431
|
+
expect(cut.container.querySelectorAll('[data-cut]')).toHaveLength(0);
|
|
1432
|
+
});
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* ββN β *"allow make dir from file where dir wraps file location and gets name of file"*.
|
|
1437
|
+
*
|
|
1438
|
+
* Its own fixture, with real FILES in it: `TREE` above is all folders, and an extension is
|
|
1439
|
+
* half of what this gesture is about.
|
|
1440
|
+
*/
|
|
1441
|
+
describe('New Folder with Selection', () => {
|
|
1442
|
+
const file = (id: string, name: string): FileTreeNode => ({ id, name, kind: 'file' });
|
|
1443
|
+
/**
|
|
1444
|
+
* Trips/
|
|
1445
|
+
* beach.jpg
|
|
1446
|
+
* alps.png
|
|
1447
|
+
* .gitignore
|
|
1448
|
+
*/
|
|
1449
|
+
const WRAP_TREE: FileTreeNode[] = [
|
|
1450
|
+
folder('trips', 'Trips', [file('trips/beach', 'beach.jpg'), file('trips/alps', 'alps.png')]),
|
|
1451
|
+
file('dotfile', '.gitignore'),
|
|
1452
|
+
];
|
|
1453
|
+
|
|
1454
|
+
const wrapTree = (props: Partial<React.ComponentProps<typeof FileTree>> = {}) =>
|
|
1455
|
+
render(
|
|
1456
|
+
<FileTree nodes={WRAP_TREE} selectedId={null} onSelect={() => undefined} {...props} />,
|
|
1457
|
+
);
|
|
1458
|
+
|
|
1459
|
+
const focusRow = (name: string) => {
|
|
1460
|
+
const row = screen.getByText(name).closest('[role="treeitem"]') as HTMLElement;
|
|
1461
|
+
row.focus();
|
|
1462
|
+
return row;
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
test('π΄ ββN asks for a folder WHERE THE FILE IS, around the row it was pressed on', () => {
|
|
1466
|
+
const asked: Array<[string[], string | null]> = [];
|
|
1467
|
+
wrapTree({ onWrapStart: (nodes, parentId) => asked.push([nodes.map((n) => n.id), parentId]) });
|
|
1468
|
+
fireEvent.keyDown(focusRow('beach.jpg'), { key: 'n', ctrlKey: true, metaKey: true });
|
|
1469
|
+
// Not the root, not inside the selection: the folder is made in `beach.jpg`'s own parent.
|
|
1470
|
+
expect(asked).toEqual([[['trips/beach'], 'trips']]);
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
test('π΄ the draft opens PRE-FILLED with the name minus its extension, and selected', () => {
|
|
1474
|
+
wrapTree({
|
|
1475
|
+
draft: { parentId: 'trips', wrap: ['trips/beach'] },
|
|
1476
|
+
onDraftCommit: () => undefined,
|
|
1477
|
+
});
|
|
1478
|
+
const field = screen.getByLabelText('Name') as HTMLInputElement;
|
|
1479
|
+
expect(field.value).toBe('beach');
|
|
1480
|
+
// Selected, so one keystroke replaces the suggestion instead of appending to it.
|
|
1481
|
+
fireEvent.focus(field);
|
|
1482
|
+
expect([field.selectionStart, field.selectionEnd]).toEqual([0, 'beach'.length]);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
test('π΄ committing makes the folder AND re-files the ids in ONE call', () => {
|
|
1486
|
+
// Two round trips is how a folder ends up made, empty, and named after a file still
|
|
1487
|
+
// sitting outside it β so there is exactly one callback and it carries both.
|
|
1488
|
+
const made: Array<[string, readonly string[] | undefined]> = [];
|
|
1489
|
+
wrapTree({
|
|
1490
|
+
draft: { parentId: 'trips', wrap: ['trips/beach'] },
|
|
1491
|
+
onDraftCommit: (name, wrap) => made.push([name, wrap]),
|
|
1492
|
+
});
|
|
1493
|
+
// Enter on the SUGGESTION, unchanged β which is the person agreeing with it, not
|
|
1494
|
+
// cancelling. An unchanged RENAME still cancels; see the rename spec above.
|
|
1495
|
+
fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Enter' });
|
|
1496
|
+
expect(made).toEqual([['beach', ['trips/beach']]]);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
test('π΄ a multi-wrap is named after the FIRST IN TREE ORDER, whatever order it was built', () => {
|
|
1500
|
+
const made: Array<[string, readonly string[] | undefined]> = [];
|
|
1501
|
+
wrapTree({
|
|
1502
|
+
// Clicked bottom-up, which is an ordinary way to build a selection.
|
|
1503
|
+
draft: { parentId: 'trips', wrap: ['trips/alps', 'trips/beach'] },
|
|
1504
|
+
onDraftCommit: (name, wrap) => made.push([name, wrap]),
|
|
1505
|
+
});
|
|
1506
|
+
const field = screen.getByLabelText('Name') as HTMLInputElement;
|
|
1507
|
+
expect(field.value).toBe('beach');
|
|
1508
|
+
fireEvent.keyDown(field, { key: 'Enter' });
|
|
1509
|
+
expect(made).toEqual([['beach', ['trips/beach', 'trips/alps']]]);
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
test('a name that is all extension keeps the whole string', () => {
|
|
1513
|
+
wrapTree({ draft: { parentId: null, wrap: ['dotfile'] }, onDraftCommit: () => undefined });
|
|
1514
|
+
expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('.gitignore');
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
test('typing over the suggestion is what is committed, and Escape still cancels', () => {
|
|
1518
|
+
const made: string[] = [];
|
|
1519
|
+
let cancels = 0;
|
|
1520
|
+
wrapTree({
|
|
1521
|
+
draft: { parentId: 'trips', wrap: ['trips/beach'] },
|
|
1522
|
+
onDraftCommit: (name) => made.push(name),
|
|
1523
|
+
onDraftCancel: () => {
|
|
1524
|
+
cancels += 1;
|
|
1525
|
+
},
|
|
1526
|
+
});
|
|
1527
|
+
const field = screen.getByLabelText('Name') as HTMLInputElement;
|
|
1528
|
+
fireEvent.change(field, { target: { value: 'Beach day' } });
|
|
1529
|
+
fireEvent.keyDown(field, { key: 'Enter' });
|
|
1530
|
+
expect(made).toEqual(['Beach day']);
|
|
1531
|
+
fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Escape' });
|
|
1532
|
+
expect(cancels).toBe(1);
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
test('π΄ `canWrap` refuses BEFORE the draft opens β a tree of derived folders makes none', () => {
|
|
1536
|
+
// The refusal has to arrive while there is still something to say it about. After a name
|
|
1537
|
+
// has been typed it is a refusal aimed at somebody who has already done the work.
|
|
1538
|
+
const asked: unknown[] = [];
|
|
1539
|
+
const { container } = wrapTree({
|
|
1540
|
+
onWrapStart: (nodes) => asked.push(nodes.map((n) => n.id)),
|
|
1541
|
+
canWrap: () => 'Folders here are derived from the tracks',
|
|
1542
|
+
});
|
|
1543
|
+
fireEvent.keyDown(focusRow('beach.jpg'), { key: 'n', ctrlKey: true, metaKey: true });
|
|
1544
|
+
expect(asked).toEqual([]);
|
|
1545
|
+
expect(container.querySelector('.cbft-sr')?.textContent).toBe(
|
|
1546
|
+
'Folders here are derived from the tracks',
|
|
1547
|
+
);
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
test('a row that cannot be dragged cannot be wrapped either β wrapping MOVES it', () => {
|
|
1551
|
+
const asked: unknown[] = [];
|
|
1552
|
+
const { container } = render(
|
|
1553
|
+
<FileTree
|
|
1554
|
+
nodes={[{ id: 'album', name: 'Simpsons', kind: 'folder', draggable: false }]}
|
|
1555
|
+
selectedId={null}
|
|
1556
|
+
onSelect={() => undefined}
|
|
1557
|
+
onWrapStart={(nodes) => asked.push(nodes.map((n) => n.id))}
|
|
1558
|
+
/>,
|
|
1559
|
+
);
|
|
1560
|
+
fireEvent.keyDown(focusRow('Simpsons'), { key: 'n', ctrlKey: true, metaKey: true });
|
|
1561
|
+
expect(asked).toEqual([]);
|
|
1562
|
+
// Said out loud: a chord that silently does nothing reads as a broken control.
|
|
1563
|
+
expect(container.querySelector('.cbft-sr')?.textContent).toBe('Simpsons cannot be moved');
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
test('π΄ a consumer wiring none of it sees no new control and no new argument', () => {
|
|
1567
|
+
// The compatibility rule this component has held since multi-select: a gesture nobody
|
|
1568
|
+
// wired is not a gesture this tree has.
|
|
1569
|
+
const commit = jest.fn();
|
|
1570
|
+
const plain = tree({ draft: { parentId: 'simpsons' }, onDraftCommit: commit });
|
|
1571
|
+
const row = screen.getByText('Dinos').closest('[role="treeitem"]') as HTMLElement;
|
|
1572
|
+
row.focus();
|
|
1573
|
+
const chord = new KeyboardEvent('keydown', {
|
|
1574
|
+
key: 'n',
|
|
1575
|
+
ctrlKey: true,
|
|
1576
|
+
metaKey: true,
|
|
1577
|
+
bubbles: true,
|
|
1578
|
+
cancelable: true,
|
|
1579
|
+
});
|
|
1580
|
+
row.dispatchEvent(chord);
|
|
1581
|
+
// Not swallowed: with no `onWrapStart` this is not a chord this component has, and the
|
|
1582
|
+
// event belongs to whatever the app around it bound.
|
|
1583
|
+
expect(chord.defaultPrevented).toBe(false);
|
|
1584
|
+
expect(plain.container.querySelector('.cbft-row-draft')).not.toBeNull();
|
|
1585
|
+
|
|
1586
|
+
const field = screen.getByLabelText('Name') as HTMLInputElement;
|
|
1587
|
+
// An ordinary draft still opens EMPTY β there is nothing for it to be named after.
|
|
1588
|
+
expect(field.value).toBe('');
|
|
1589
|
+
fireEvent.change(field, { target: { value: 'posters' } });
|
|
1590
|
+
fireEvent.keyDown(field, { key: 'Enter' });
|
|
1591
|
+
// One argument, exactly as before `wrap` existed.
|
|
1592
|
+
expect(commit.mock.calls).toEqual([['posters']]);
|
|
1593
|
+
});
|
|
1594
|
+
});
|
|
1595
|
+
|
|
810
1596
|
describe('what a screen reader is told about the level', () => {
|
|
811
1597
|
test('a row says its position AND the size of its level', () => {
|
|
812
1598
|
tree();
|
|
@@ -818,3 +1604,226 @@ describe('what a screen reader is told about the level', () => {
|
|
|
818
1604
|
expect(nested.getAttribute('aria-setsize')).toBe('1');
|
|
819
1605
|
});
|
|
820
1606
|
});
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* ββ Pinned rows βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
1610
|
+
*
|
|
1611
|
+
* Owner, 2026-09-13: *"need β¦ ability to pin open files where they stay at the top of the file
|
|
1612
|
+
* tree in a new section above the folders. Pinned will mean I want to keep them there until I
|
|
1613
|
+
* unpin them β¦ They still will exist in the folder they were stored in and unpinning takes it
|
|
1614
|
+
* back to normal. Pinning or unpinning one place doesn't change pinning in the other place."*
|
|
1615
|
+
*
|
|
1616
|
+
* The two requirements a quick implementation loses are the two this block spends most of its
|
|
1617
|
+
* assertions on: the row is drawn TWICE (a section, not a sort order), and the state is the
|
|
1618
|
+
* consumer's, which is the whole of "one place doesn't change the other".
|
|
1619
|
+
*/
|
|
1620
|
+
describe('pinned rows', () => {
|
|
1621
|
+
const pinnedTree = () => screen.getByRole('tree', { name: 'Pinned' });
|
|
1622
|
+
const fileTree = () => screen.getByRole('tree', { name: 'Files' });
|
|
1623
|
+
const rowsOf = (list: HTMLElement) => [...list.querySelectorAll('.cbft-row')] as HTMLElement[];
|
|
1624
|
+
const named = (list: HTMLElement) =>
|
|
1625
|
+
rowsOf(list).map((row) => row.querySelector('.cbft-name')?.textContent ?? '');
|
|
1626
|
+
|
|
1627
|
+
test('π΄ a pinned row is drawn in a section ABOVE the folders AND still where it lives', () => {
|
|
1628
|
+
// *"in a new section above the folders"* and *"they still will exist in the folder they
|
|
1629
|
+
// were stored in"* β the second clause is what makes this a section rather than a sort.
|
|
1630
|
+
const { container } = tree({ pinnedIds: ['dinos'] });
|
|
1631
|
+
|
|
1632
|
+
const lists = [...container.querySelectorAll('[role="tree"]')] as HTMLElement[];
|
|
1633
|
+
expect(lists).toHaveLength(2);
|
|
1634
|
+
expect(lists[0]?.getAttribute('aria-label')).toBe('Pinned');
|
|
1635
|
+
expect(lists[1]?.getAttribute('aria-label')).toBe('Files');
|
|
1636
|
+
|
|
1637
|
+
// The very first row in the component, above every folder.
|
|
1638
|
+
expect(named(pinnedTree())).toEqual(['Dinos']);
|
|
1639
|
+
expect((rowsOf(container as unknown as HTMLElement)[0] as HTMLElement).dataset.pinnedCopy).toBe(
|
|
1640
|
+
'true',
|
|
1641
|
+
);
|
|
1642
|
+
// β¦and the tree below is the tree it always was, `Dinos` in its own place among them.
|
|
1643
|
+
expect(named(fileTree())).toEqual(['Simpsons', 'screencaps', 's04', 'Dinos', 'Travels']);
|
|
1644
|
+
// Two rows, one node: the copy is addressed separately and the identity is shared.
|
|
1645
|
+
expect(screen.getAllByText('Dinos')).toHaveLength(2);
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
test('π΄ a pin reaches a row inside a SHUT folder β which is what it is for', () => {
|
|
1649
|
+
// The pinned section is derived from the TREE, not from the rows on screen. A pin whose
|
|
1650
|
+
// folder is closed is the ordinary case (that is why somebody pinned it), so building the
|
|
1651
|
+
// section off `rows` would make the feature work only where it is not needed.
|
|
1652
|
+
tree({ pinnedIds: ['simpsons/screencaps/s04'], collapsed: ['simpsons'] });
|
|
1653
|
+
expect(named(pinnedTree())).toEqual(['s04']);
|
|
1654
|
+
expect(named(fileTree())).toEqual(['Simpsons', 'Dinos', 'Travels']);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
test('a pinned copy is drawn FLAT β no depth, no twisty, level 1', () => {
|
|
1658
|
+
// It is a list of shortcuts, not a second hierarchy beside the first one.
|
|
1659
|
+
tree({ pinnedIds: ['simpsons'] });
|
|
1660
|
+
const copy = rowsOf(pinnedTree())[0] as HTMLElement;
|
|
1661
|
+
expect(copy.getAttribute('aria-level')).toBe('1');
|
|
1662
|
+
expect(copy.style.getPropertyValue('--cbft-depth')).toBe('0');
|
|
1663
|
+
expect(copy.querySelector('.cbft-twisty')).toBeNull();
|
|
1664
|
+
// The real row keeps both β a pin changes nothing about the row in the folder.
|
|
1665
|
+
const real = rowsOf(fileTree())[0] as HTMLElement;
|
|
1666
|
+
expect(real.getAttribute('aria-level')).toBe('1');
|
|
1667
|
+
expect(real.querySelector('.cbft-twisty')).not.toBeNull();
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
test('π΄ the aria counts of the tree are UNCHANGED by a pin', () => {
|
|
1671
|
+
// The trap: `aria-setsize` is computed from the rows on screen, on the assumption that
|
|
1672
|
+
// every sibling of a visible row is itself visible. Folding pins into that row list makes
|
|
1673
|
+
// the counts wrong for EVERY row in the tree, not only for the pinned ones β which is why
|
|
1674
|
+
// the section is its own `role='tree'` with counts of its own.
|
|
1675
|
+
tree({ pinnedIds: ['dinos', 'travels'] });
|
|
1676
|
+
const inTree = (name: string) =>
|
|
1677
|
+
rowsOf(fileTree()).find((row) => row.textContent?.includes(name)) as HTMLElement;
|
|
1678
|
+
expect(inTree('Dinos').getAttribute('aria-posinset')).toBe('2');
|
|
1679
|
+
expect(inTree('Dinos').getAttribute('aria-setsize')).toBe('3');
|
|
1680
|
+
expect(inTree('screencaps').getAttribute('aria-posinset')).toBe('1');
|
|
1681
|
+
expect(inTree('screencaps').getAttribute('aria-setsize')).toBe('1');
|
|
1682
|
+
// And the section counts ITSELF: two pins, positions one and two.
|
|
1683
|
+
const pins = rowsOf(pinnedTree());
|
|
1684
|
+
expect(pins.map((row) => row.getAttribute('aria-posinset'))).toEqual(['1', '2']);
|
|
1685
|
+
expect(pins.map((row) => row.getAttribute('aria-setsize'))).toEqual(['2', '2']);
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test('the pin control reports the whole next list, and appends rather than resorts', () => {
|
|
1689
|
+
const asked: string[][] = [];
|
|
1690
|
+
tree({ pinnedIds: ['travels'], onPinnedChange: (next) => asked.push(next) });
|
|
1691
|
+
fireEvent.click(screen.getByRole('button', { name: 'Pin Dinos' }));
|
|
1692
|
+
// Appended: the section keeps the order it was built in rather than being resorted under
|
|
1693
|
+
// somebody who arranged it.
|
|
1694
|
+
expect(asked).toEqual([['travels', 'dinos']]);
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
test('π΄ unpinning removes ONLY the top copy, and leaves nothing behind', () => {
|
|
1698
|
+
// *"unpinning takes it back to normal"* β the row in the folder is the row it always was,
|
|
1699
|
+
// byte for byte the tree a consumer that never pinned anything sees.
|
|
1700
|
+
const asked: string[][] = [];
|
|
1701
|
+
const pinned = tree({ pinnedIds: ['dinos'], onPinnedChange: (next) => asked.push(next) });
|
|
1702
|
+
// The control on the TOP copy β two rows carry an "Unpin Dinos" button, and pressing
|
|
1703
|
+
// either is the same act on the same node.
|
|
1704
|
+
const [top] = screen.getAllByRole('button', { name: 'Unpin Dinos' });
|
|
1705
|
+
fireEvent.click(top as HTMLElement);
|
|
1706
|
+
expect(asked).toEqual([[]]);
|
|
1707
|
+
|
|
1708
|
+
pinned.rerender(
|
|
1709
|
+
<FileTree
|
|
1710
|
+
nodes={TREE}
|
|
1711
|
+
selectedId={null}
|
|
1712
|
+
onSelect={() => undefined}
|
|
1713
|
+
pinnedIds={[]}
|
|
1714
|
+
onPinnedChange={(next) => asked.push(next)}
|
|
1715
|
+
/>,
|
|
1716
|
+
);
|
|
1717
|
+
expect(screen.queryByRole('tree', { name: 'Pinned' })).toBeNull();
|
|
1718
|
+
expect(screen.getAllByText('Dinos')).toHaveLength(1);
|
|
1719
|
+
expect(named(fileTree())).toEqual(['Simpsons', 'screencaps', 's04', 'Dinos', 'Travels']);
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
test('π΄ a pinned FOLDER still takes a drop, and it lands in the REAL folder', () => {
|
|
1723
|
+
/*
|
|
1724
|
+
* happy-dom computes no layout, so the POINTER drag cannot be exercised here at all (see
|
|
1725
|
+
* this file's header) β `rowAt` hit-tests against rects that are all zeros. The external
|
|
1726
|
+
* drag is the one drop path that is real DOM and needs no layout, and it answers the same
|
|
1727
|
+
* question: the pinned copy is a live drop target, and what it reports is the node itself
|
|
1728
|
+
* rather than some shadow of it. The pointer half resolves through the very same
|
|
1729
|
+
* `FileTreeRow`, which carries the row's real `parentId`.
|
|
1730
|
+
*/
|
|
1731
|
+
const drops: (string | null)[] = [];
|
|
1732
|
+
tree({ pinnedIds: ['dinos'], onDropExternal: (node) => drops.push(node?.id ?? null) });
|
|
1733
|
+
const copy = rowsOf(pinnedTree())[0] as HTMLElement;
|
|
1734
|
+
fireEvent.dragOver(copy);
|
|
1735
|
+
expect(copy.getAttribute('data-into')).toBe('true');
|
|
1736
|
+
// Both copies light up, because there is one folder and the drag is over it.
|
|
1737
|
+
const real = rowsOf(fileTree()).find((row) => row.textContent?.includes('Dinos')) as HTMLElement;
|
|
1738
|
+
expect(real.getAttribute('data-into')).toBe('true');
|
|
1739
|
+
fireEvent.drop(copy);
|
|
1740
|
+
expect(drops).toEqual(['dinos']);
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
test('ββT pins the focused row, and pins the row in the folder just the same', () => {
|
|
1744
|
+
// Finder's own Add to Sidebar chord, anchored on Ctrl so βT works on the Windows and
|
|
1745
|
+
// Linux hosts this tree also runs on β the rule ββN already follows.
|
|
1746
|
+
const asked: string[][] = [];
|
|
1747
|
+
tree({ onPinnedChange: (next) => asked.push(next) });
|
|
1748
|
+
const row = screen.getByText('Travels').closest('[role="treeitem"]') as HTMLElement;
|
|
1749
|
+
row.focus();
|
|
1750
|
+
fireEvent.keyDown(row, { key: 't', ctrlKey: true, metaKey: true });
|
|
1751
|
+
expect(asked).toEqual([['travels']]);
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
test('π΄ exactly ONE copy draws the rename field', () => {
|
|
1755
|
+
// `renamingId` names a node, and a pinned node is two rows β the naive test opens two
|
|
1756
|
+
// autofocused inputs sharing one accessible name. The tree's copy wins while it exists.
|
|
1757
|
+
const { container } = tree({
|
|
1758
|
+
pinnedIds: ['dinos'],
|
|
1759
|
+
renamingId: 'dinos',
|
|
1760
|
+
onRename: () => undefined,
|
|
1761
|
+
});
|
|
1762
|
+
expect(container.querySelectorAll('.cbft-name-input')).toHaveLength(1);
|
|
1763
|
+
expect(rowsOf(fileTree()).some((row) => row.querySelector('.cbft-name-input'))).toBe(true);
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
test('π΄ β¦and the PINNED copy draws it when the real row is inside a shut folder', () => {
|
|
1767
|
+
// Otherwise a rename started from the section opens nowhere at all, which is the "control
|
|
1768
|
+
// that silently does nothing" this component refuses everywhere else.
|
|
1769
|
+
const { container } = tree({
|
|
1770
|
+
pinnedIds: ['simpsons/screencaps/s04'],
|
|
1771
|
+
collapsed: ['simpsons'],
|
|
1772
|
+
renamingId: 'simpsons/screencaps/s04',
|
|
1773
|
+
onRename: () => undefined,
|
|
1774
|
+
});
|
|
1775
|
+
expect(container.querySelectorAll('.cbft-name-input')).toHaveLength(1);
|
|
1776
|
+
expect(rowsOf(pinnedTree()).some((row) => row.querySelector('.cbft-name-input'))).toBe(true);
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
test('a pin the tree no longer holds is simply not drawn', () => {
|
|
1780
|
+
// A selection outlives a refresh and so does a pin: the server re-derives the tree between
|
|
1781
|
+
// a pin and a render, and a ghost row nothing can be done with is worse than an absent one.
|
|
1782
|
+
tree({ pinnedIds: ['dinos', 'gone-yesterday'] });
|
|
1783
|
+
expect(named(pinnedTree())).toEqual(['Dinos']);
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
test('the section takes the consumerβs own word for it', () => {
|
|
1787
|
+
// The mechanism is general and the vocabulary is not β the same reason `accessCopy` exists.
|
|
1788
|
+
tree({ pinnedIds: ['dinos'], pinnedLabel: 'Favourites' });
|
|
1789
|
+
expect(screen.getByRole('tree', { name: 'Favourites' })).toBeDefined();
|
|
1790
|
+
expect(screen.getByText('Favourites')).toBeDefined();
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
test('π΄ a consumer wiring none of it sees byte-for-byte its current tree', () => {
|
|
1794
|
+
/*
|
|
1795
|
+
* The compatibility assertion this component has held since multi-select, and the one
|
|
1796
|
+
* `tasks/Completed/119-*.md` established for this file: a gesture nobody wired is not a
|
|
1797
|
+
* gesture this tree has. An empty `pinnedIds` and a label are both inert β no section, no
|
|
1798
|
+
* control, no chord, and not one attribute of difference in the markup.
|
|
1799
|
+
*/
|
|
1800
|
+
const plain = render(
|
|
1801
|
+
<FileTree nodes={TREE} selectedId={null} onSelect={() => undefined} />,
|
|
1802
|
+
);
|
|
1803
|
+
const withPins = render(
|
|
1804
|
+
<FileTree
|
|
1805
|
+
nodes={TREE}
|
|
1806
|
+
selectedId={null}
|
|
1807
|
+
onSelect={() => undefined}
|
|
1808
|
+
pinnedIds={[]}
|
|
1809
|
+
pinnedLabel='Favourites'
|
|
1810
|
+
/>,
|
|
1811
|
+
);
|
|
1812
|
+
expect(withPins.container.innerHTML).toBe(plain.container.innerHTML);
|
|
1813
|
+
expect(plain.container.querySelector('.cbft-pin')).toBeNull();
|
|
1814
|
+
|
|
1815
|
+
// And ββT is not swallowed: with no `onPinnedChange` this is not a chord this component
|
|
1816
|
+
// has, and the event belongs to whatever the app around it bound.
|
|
1817
|
+
const row = plain.container.querySelector('[role="treeitem"]') as HTMLElement;
|
|
1818
|
+
row.focus();
|
|
1819
|
+
const chord = new KeyboardEvent('keydown', {
|
|
1820
|
+
key: 't',
|
|
1821
|
+
ctrlKey: true,
|
|
1822
|
+
metaKey: true,
|
|
1823
|
+
bubbles: true,
|
|
1824
|
+
cancelable: true,
|
|
1825
|
+
});
|
|
1826
|
+
row.dispatchEvent(chord);
|
|
1827
|
+
expect(chord.defaultPrevented).toBe(false);
|
|
1828
|
+
});
|
|
1829
|
+
});
|