Skip to content

Commit 46a3a45

Browse files
committed
Persist sidebar width and other improvements
1 parent 7cceba9 commit 46a3a45

File tree

12 files changed

+158
-76
lines changed

12 files changed

+158
-76
lines changed

packages/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"ndarray-ops": "1.2.2",
6767
"react-error-boundary": "6.1.1",
6868
"react-icons": "5.4.0",
69-
"react-reflex": "4.2.7",
69+
"react-resizable-panels": "4.7.1",
7070
"three": "0.182.0",
7171
"zustand": "5.0.11"
7272
},

packages/app/src/App.module.css

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,18 +115,47 @@
115115
.sidebarArea {
116116
display: flex;
117117
flex-direction: column;
118-
scrollbar-width: thin;
119118
background-color: var(--primary-bg);
119+
overflow-x: hidden;
120+
overflow-y: auto;
121+
}
122+
123+
.root[data-sidebar-collapsed] .sidebarArea {
124+
display: none;
120125
}
121126

122127
.splitter {
123-
width: 5px !important;
124-
border-color: lightgray !important;
125-
transition: background-color 0.2s ease-in-out !important;
128+
position: relative;
129+
z-index: 1;
130+
width: 2px;
131+
background-color: #ddd;
132+
}
133+
134+
.splitter:focus {
135+
outline: none;
136+
}
137+
138+
.splitter::before {
139+
content: '';
140+
position: absolute;
141+
top: 0;
142+
bottom: 0;
143+
left: -2px;
144+
width: 6px;
145+
background-color: #ddd;
146+
transition: opacity 0.1s ease-in-out;
147+
cursor: ew-resize;
148+
opacity: 0;
149+
}
150+
151+
.splitter:not([data-separator='inactive'])::before,
152+
.splitter:hover::before /* splitter becomes "inactive" after resizing even if still hovered */ {
153+
opacity: 1;
154+
transition-delay: 0.3s; /* delay showing (but not hiding) */
126155
}
127156

128-
.splitter:hover {
129-
background-color: lightgray !important;
157+
.splitter:focus-visible::before {
158+
opacity: 1;
130159
}
131160

132161
.mainArea {

packages/app/src/App.tsx

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import '@h5web/lib'; // eslint-disable-line import/no-duplicates -- make sure lib styles come first in CSS bundle
22

33
import { KeepZoomProvider } from '@h5web/lib'; // eslint-disable-line import/no-duplicates
4-
import { useToggle } from '@react-hookz/web';
54
import { Suspense, useState } from 'react';
65
import { ErrorBoundary } from 'react-error-boundary';
7-
import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex';
6+
import {
7+
Group,
8+
Panel,
9+
Separator,
10+
useDefaultLayout,
11+
usePanelRef,
12+
} from 'react-resizable-panels';
813

914
import styles from './App.module.css';
1015
import BreadcrumbsBar from './breadcrumbs/BreadcrumbsBar';
@@ -18,6 +23,10 @@ import Sidebar from './Sidebar';
1823
import VisConfigProvider from './VisConfigProvider';
1924
import Visualizer from './visualizer/Visualizer';
2025

26+
const SIDEBAR_ID = 'h5w-sidebar';
27+
const MAIN_AREA_ID = 'h5w-main-area';
28+
const RESIZE_TARGET_MIN_SIZE = { coarse: 6, fine: 6 }; // match CSS width (.splitter::before)
29+
2130
interface Props {
2231
sidebarOpen?: boolean;
2332
initialPath?: string;
@@ -36,7 +45,6 @@ function App(props: Props) {
3645
} = props;
3746

3847
const [selectedPath, setSelectedPath] = useState<string>(initialPath);
39-
const [isSidebarOpen, toggleSidebarOpen] = useToggle(initialSidebarOpen);
4048
const [isInspecting, setInspecting] = useState(false);
4149

4250
const { valuesStore } = useDataContext();
@@ -45,6 +53,17 @@ function App(props: Props) {
4553
valuesStore.abortAll('entity changed', true);
4654
}
4755

56+
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
57+
id: 'h5web:layout',
58+
});
59+
60+
const sidebarPanelRef = usePanelRef();
61+
const [isSidebarOpen, setSidebarOpen] = useState(
62+
initialSidebarOpen
63+
? !defaultLayout || defaultLayout[SIDEBAR_ID] > 0
64+
: false,
65+
);
66+
4867
return (
4968
<ErrorBoundary
5069
FallbackComponent={ErrorFallback}
@@ -54,32 +73,49 @@ function App(props: Props) {
5473
}
5574
}}
5675
>
57-
<ReflexContainer
76+
<Group
5877
className={styles.root}
78+
resizeTargetMinimumSize={RESIZE_TARGET_MIN_SIZE}
79+
defaultLayout={initialSidebarOpen ? defaultLayout : undefined}
80+
onLayoutChanged={onLayoutChanged}
5981
data-fullscreen-root
6082
data-allow-dark-mode={disableDarkMode ? undefined : ''}
61-
orientation="vertical"
83+
data-sidebar-collapsed={!isSidebarOpen || undefined}
6284
>
63-
<ReflexElement
85+
<Panel
86+
id={SIDEBAR_ID}
6487
className={styles.sidebarArea}
65-
style={{ display: isSidebarOpen ? undefined : 'none' }}
66-
flex={25}
88+
panelRef={sidebarPanelRef}
89+
defaultSize={initialSidebarOpen ? '25%' : '0%'}
6790
minSize={150}
91+
collapsible
92+
onResize={({ inPixels: size }) => {
93+
const isNowOpen = size > 0;
94+
if (
95+
(isNowOpen && !isSidebarOpen) ||
96+
(!isNowOpen && isSidebarOpen)
97+
) {
98+
setSidebarOpen(isNowOpen);
99+
}
100+
}}
68101
>
69102
<Sidebar selectedPath={selectedPath} onSelect={onSelectPath} />
70-
</ReflexElement>
103+
</Panel>
71104

72-
<ReflexSplitter
73-
className={styles.splitter}
74-
style={{ display: isSidebarOpen ? undefined : 'none' }}
75-
/>
105+
<Separator className={styles.splitter} />
76106

77-
<ReflexElement className={styles.mainArea} flex={75} minSize={500}>
107+
<Panel id={MAIN_AREA_ID} className={styles.mainArea} minSize={500}>
78108
<BreadcrumbsBar
79109
path={selectedPath}
80110
isSidebarOpen={isSidebarOpen}
81111
isInspecting={isInspecting}
82-
onToggleSidebar={toggleSidebarOpen}
112+
onToggleSidebar={() => {
113+
if (isSidebarOpen) {
114+
sidebarPanelRef.current?.collapse();
115+
} else {
116+
sidebarPanelRef.current?.expand();
117+
}
118+
}}
83119
onChangeInspecting={setInspecting}
84120
onSelectPath={onSelectPath}
85121
getFeedbackURL={getFeedbackURL}
@@ -107,8 +143,8 @@ function App(props: Props) {
107143
</KeepZoomProvider>
108144
</DimMappingProvider>
109145
</VisConfigProvider>
110-
</ReflexElement>
111-
</ReflexContainer>
146+
</Panel>
147+
</Group>
112148
</ErrorBoundary>
113149
);
114150
}

packages/app/src/Sidebar.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.tabBar {
2+
flex: none;
23
display: flex;
34
height: var(--toolbar-height);
45
font-size: 1.25rem;

packages/app/src/Sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function Sidebar(props: Props) {
2828
}
2929

3030
return (
31-
<div>
31+
<>
3232
<div className={styles.tabBar}>
3333
<button
3434
className={styles.tab}
@@ -63,7 +63,7 @@ function Sidebar(props: Props) {
6363
getSearchablePaths={getSearchablePaths}
6464
/>
6565
)}
66-
</div>
66+
</>
6767
);
6868
}
6969

packages/app/src/__tests__/BreadcrumbsBar.test.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ test('toggle sidebar', async () => {
1313

1414
// Hide
1515
await toggleBtn.click();
16+
await expect
17+
.element(page.getByRole('treeitem', { name: 'source.h5' }))
18+
.not.toBeInTheDocument();
1619
expect(toggleBtn).toHaveAttribute('aria-pressed', 'false');
17-
expect(
18-
page.getByRole('treeitem', { name: 'source.h5' }),
19-
).not.toBeInTheDocument();
2020

2121
// Show
2222
await toggleBtn.click();
23+
await expect.element(getExplorerItem('source.h5')).toBeVisible();
2324
expect(toggleBtn).toHaveAttribute('aria-pressed', 'true');
24-
expect(getExplorerItem('source.h5')).toBeVisible();
2525
});
2626

2727
test('switch between "display" and "inspect" modes', async () => {
@@ -53,11 +53,13 @@ test('navigate with breadcrumbs', async () => {
5353
// Hide sidebar to show root crumb
5454
const toggleBtn = page.getByRole('button', { name: 'Toggle sidebar' });
5555
await toggleBtn.click();
56-
expect(
57-
page.getByRole('heading', {
58-
name: 'source.h5 / entities / empty_dataset',
59-
}),
60-
).toBeVisible();
56+
await expect
57+
.element(
58+
page.getByRole('heading', {
59+
name: 'source.h5 / entities / empty_dataset',
60+
}),
61+
)
62+
.toBeVisible();
6163

6264
// Select parent crumb
6365
await page.getByRole('button', { name: 'entities' }).click();

packages/app/src/__tests__/Explorer.test.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,55 @@ test('toggle sidebar', async () => {
2828
expect(sidebarBtn).toHaveAttribute('aria-pressed', 'true');
2929

3030
await sidebarBtn.click();
31-
expect(getExplorerItem('source.h5')).not.toBeInTheDocument();
31+
await expect.element(getExplorerItem('source.h5')).not.toBeInTheDocument();
3232
expect(sidebarBtn).toHaveAttribute('aria-pressed', 'false');
3333

3434
await sidebarBtn.click();
35-
expect(getExplorerItem('source.h5')).toBeVisible();
35+
await expect.element(getExplorerItem('source.h5')).toBeVisible();
3636
expect(sidebarBtn).toHaveAttribute('aria-pressed', 'true');
3737
});
3838

39+
test('resize and collapse/expand sidebar with keyboard', async () => {
40+
const { user } = await renderApp();
41+
42+
const splitter = page.getByRole('separator');
43+
expect(splitter).toHaveAttribute('aria-valuenow', '25');
44+
45+
// Resize sidebar to minimum width
46+
await user.type(splitter, '{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}');
47+
expect(splitter).toHaveAttribute('aria-valuenow', '7.817'); // min size (150px) / viewport (1920px)
48+
49+
// Collapse
50+
await user.type(splitter, '{ArrowLeft}');
51+
expect(splitter).toHaveAttribute('aria-valuenow', '0'); // collapsed
52+
await expect.element(getExplorerItem('source.h5')).not.toBeInTheDocument();
53+
54+
// Expand
55+
await user.type(splitter, '{ArrowRight}');
56+
expect(splitter).toHaveAttribute('aria-valuenow', '7.817');
57+
await expect.element(getExplorerItem('source.h5')).toBeVisible();
58+
});
59+
60+
test('remember sidebar width when toggling', async () => {
61+
const { user } = await renderApp();
62+
63+
const splitter = page.getByRole('separator');
64+
expect(splitter).toHaveAttribute('aria-valuenow', '25');
65+
66+
// Resize sidebar
67+
await user.type(splitter, '{ArrowRight}');
68+
expect(splitter).toHaveAttribute('aria-valuenow', '30');
69+
70+
// Collapse
71+
const sidebarBtn = page.getByRole('button', { name: 'Toggle sidebar' });
72+
await sidebarBtn.click();
73+
expect(splitter).toHaveAttribute('aria-valuenow', '0');
74+
75+
// Expand
76+
await sidebarBtn.click();
77+
expect(splitter).toHaveAttribute('aria-valuenow', '30');
78+
});
79+
3980
test('navigate groups in explorer', async () => {
4081
const { selectExplorerNode } = await renderApp();
4182

packages/app/src/explorer/Explorer.module.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@
2424
}
2525

2626
.btn:hover,
27-
.btn:focus {
27+
.btn:focus-visible {
2828
background-color: var(--primary-light);
2929
}
3030

31-
.btn:focus {
31+
.btn:focus-visible {
3232
outline: 1px solid var(--primary-dark);
3333
outline-offset: -1px;
3434
}

packages/app/src/global-styles.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
/* Global app styles for demo */
2-
@import 'react-reflex/styles.css';
32
@import '@h5web/lib/global-styles.css';

packages/app/src/styles.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,4 @@
22
Includes global app styles and distributed lib styles (so users don't have to import two stylesheets).
33
Output is later concatenated with local app styles. */
44

5-
import 'react-reflex/styles.css';
65
import '@h5web/lib/styles.css'; // distributed lib styles

0 commit comments

Comments
 (0)