@hyphen/hyphen-components 9.0.0 → 9.1.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/css/index.css +17 -0
- package/dist/css/index.css.map +1 -1
- package/dist/css/utilities.css +1 -1
- package/dist/css/utilities.css.map +1 -1
- package/dist/hyphen-components.cjs.development.js +355 -143
- package/dist/hyphen-components.cjs.development.js.map +1 -1
- package/dist/hyphen-components.cjs.production.min.js +2 -2
- package/dist/hyphen-components.cjs.production.min.js.map +1 -1
- package/dist/hyphen-components.esm.js +346 -134
- package/dist/hyphen-components.esm.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/package.json +4 -3
- package/src/components/Sidebar/Sidebar.mdx +18 -0
- package/src/components/Sidebar/Sidebar.module.scss +18 -0
- package/src/components/Sidebar/Sidebar.react17.test.tsx +35 -0
- package/src/components/Sidebar/Sidebar.resize.test.tsx +368 -0
- package/src/components/Sidebar/Sidebar.stories.tsx +36 -0
- package/src/components/Sidebar/Sidebar.test.tsx +46 -0
- package/src/components/Sidebar/Sidebar.tsx +242 -150
- package/src/components/Sidebar/useSidebarResize.ts +214 -0
package/dist/index.d.ts
CHANGED
|
@@ -2225,6 +2225,19 @@ interface ResponsiveProviderProps {
|
|
|
2225
2225
|
}
|
|
2226
2226
|
declare const ResponsiveProvider: React__default.FC<ResponsiveProviderProps>;
|
|
2227
2227
|
|
|
2228
|
+
interface SidebarResizeProps {
|
|
2229
|
+
/** Enable desktop resizing with SidebarRail. Disabled by default. */
|
|
2230
|
+
resizable?: boolean;
|
|
2231
|
+
/** Initial expanded width in pixels. Defaults to 256 left / 384 right. */
|
|
2232
|
+
defaultWidth?: number;
|
|
2233
|
+
/** Minimum expanded width in pixels. Defaults to 256. */
|
|
2234
|
+
minWidth?: number;
|
|
2235
|
+
/** Maximum expanded width in pixels, also capped at 2/3 of the provider. */
|
|
2236
|
+
maxWidth?: number;
|
|
2237
|
+
/** Optional localStorage key for the preferred expanded width. */
|
|
2238
|
+
widthStorageKey?: string;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2228
2241
|
declare const TooltipProvider: React.FC<TooltipPrimitive.TooltipProviderProps>;
|
|
2229
2242
|
declare const Tooltip: React.FC<TooltipPrimitive.TooltipProps>;
|
|
2230
2243
|
declare const TooltipTrigger: React.ForwardRefExoticComponent<TooltipPrimitive.TooltipTriggerProps & React.RefAttributes<HTMLButtonElement>>;
|
|
@@ -2268,7 +2281,7 @@ declare const SidebarProvider: React__default.ForwardRefExoticComponent<Omit<Rea
|
|
|
2268
2281
|
*/
|
|
2269
2282
|
onOpenChange?: (open: boolean, side?: SidebarSide) => void;
|
|
2270
2283
|
}, "ref"> & React__default.RefAttributes<HTMLDivElement>>;
|
|
2271
|
-
declare const Sidebar: React__default.ForwardRefExoticComponent<Omit<React__default.ClassAttributes<HTMLDivElement> & React__default.HTMLAttributes<HTMLDivElement> & {
|
|
2284
|
+
declare const Sidebar: React__default.ForwardRefExoticComponent<Omit<React__default.ClassAttributes<HTMLDivElement> & React__default.HTMLAttributes<HTMLDivElement> & SidebarResizeProps & {
|
|
2272
2285
|
/**
|
|
2273
2286
|
* Which edge of the viewport the sidebar is attached to.
|
|
2274
2287
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyphen/hyphen-components",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "@hyphen"
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"@radix-ui/react-aspect-ratio": "^1.1.9",
|
|
41
41
|
"@radix-ui/react-collapsible": "^1.1.13",
|
|
42
42
|
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
|
43
|
+
"@radix-ui/react-id": "^1.1.4",
|
|
43
44
|
"@radix-ui/react-popover": "^1.1.16",
|
|
44
45
|
"@radix-ui/react-slot": "^1.2.5",
|
|
45
46
|
"@radix-ui/react-toggle": "^1.1.11",
|
|
@@ -52,11 +53,11 @@
|
|
|
52
53
|
"react-focus-lock": "^2.13.2",
|
|
53
54
|
"react-modal": "^3.16.3",
|
|
54
55
|
"react-remove-scroll": "^2.7.2",
|
|
55
|
-
"react-router-dom": "^6.30.
|
|
56
|
+
"react-router-dom": "^6.30.6",
|
|
56
57
|
"react-select": "^5.10.0",
|
|
57
58
|
"react-select-event": "^5.5.1",
|
|
58
59
|
"uuid": "^11.1.1",
|
|
59
|
-
"@hyphen/hyphen-design-tokens": "^9.
|
|
60
|
+
"@hyphen/hyphen-design-tokens": "^9.1.0"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@babel/core": "^7.27.4",
|
|
@@ -206,3 +206,21 @@ The sidebar exposes CSS custom properties you can override on `SidebarProvider`
|
|
|
206
206
|
- `SidebarRail` is removed from the tab order (`tabIndex={-1}`) since `SidebarTrigger` and the keyboard shortcuts cover keyboard users; it exposes an `aria-label` and a `title` with the shortcut.
|
|
207
207
|
- `SidebarMenu` and `SidebarMenuSub` render semantic lists, and `asChild` lets menu items render real links for correct link semantics.
|
|
208
208
|
- On mobile the sidebar renders inside a [Drawer](/docs/components-drawer--docs), which traps focus and closes with <kbd>Esc</kbd>.
|
|
209
|
+
|
|
210
|
+
## Resizable sidebars
|
|
211
|
+
|
|
212
|
+
Opt in with `resizable` and include a `SidebarRail`. Either side can resize independently on desktop. Drag the rail to resize; click to toggle. Keyboard users can focus the rail, move it with Left/Right arrows (10px), use Home/End for limits, and Enter/Space to toggle. Mobile drawers keep their existing behavior.
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
<Sidebar side="right" resizable defaultWidth={384} minWidth={320} maxWidth={960}
|
|
216
|
+
widthStorageKey="agent-panel-width">
|
|
217
|
+
<SidebarContent>{/* content */}</SidebarContent>
|
|
218
|
+
<SidebarRail />
|
|
219
|
+
</Sidebar>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Widths are pixels. Defaults are 256px on the left, 384px on the right, a 256px minimum, and a 960px maximum. Rendered width is additionally capped at two-thirds of the `SidebarProvider` container; fitting the container takes precedence over the minimum. Temporary container constraints and collapse do not overwrite the preferred expanded width.
|
|
223
|
+
|
|
224
|
+
`widthStorageKey` is optional. Supply a distinct key per panel to restore its preferred width across reloads. Without a key, width lasts until the sidebar unmounts. Existing sidebars remain unchanged unless `resizable` is enabled.
|
|
225
|
+
|
|
226
|
+
<Canvas of={Stories.ResizableBothSides} />
|
|
@@ -26,3 +26,21 @@
|
|
|
26
26
|
) {
|
|
27
27
|
display: none;
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
.resizeDescription {
|
|
31
|
+
position: absolute;
|
|
32
|
+
width: 1px;
|
|
33
|
+
height: 1px;
|
|
34
|
+
padding: 0;
|
|
35
|
+
margin: -1px;
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
clip-path: inset(50%);
|
|
38
|
+
white-space: nowrap;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Widen the hit area without moving the existing visible rail.
|
|
42
|
+
.rail[data-resizable]::before {
|
|
43
|
+
content: '';
|
|
44
|
+
position: absolute;
|
|
45
|
+
inset: 0 -7px;
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { Sidebar, SidebarProvider, SidebarRail } from './Sidebar';
|
|
4
|
+
|
|
5
|
+
// React 16.8 and 17 are in the peer range but have no React.useId.
|
|
6
|
+
jest.mock('react', () => ({ ...jest.requireActual('react'), useId: undefined }));
|
|
7
|
+
jest.mock('../../hooks/useIsMobile/useIsMobile', () => ({
|
|
8
|
+
useIsMobile: () => false,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
|
13
|
+
observe: jest.fn(),
|
|
14
|
+
disconnect: jest.fn(),
|
|
15
|
+
}));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('SidebarRail renders without React.useId', () => {
|
|
19
|
+
render(
|
|
20
|
+
<SidebarProvider>
|
|
21
|
+
<Sidebar side="left">
|
|
22
|
+
<SidebarRail />
|
|
23
|
+
</Sidebar>
|
|
24
|
+
<Sidebar side="right" resizable>
|
|
25
|
+
<SidebarRail />
|
|
26
|
+
</Sidebar>
|
|
27
|
+
</SidebarProvider>
|
|
28
|
+
);
|
|
29
|
+
expect(
|
|
30
|
+
screen.getByRole('button', { name: 'Toggle Sidebar' })
|
|
31
|
+
).toBeInTheDocument();
|
|
32
|
+
expect(
|
|
33
|
+
screen.getByRole('button', { name: 'Resize or toggle right sidebar' })
|
|
34
|
+
).toHaveAccessibleDescription(/^Width \d+ pixels/);
|
|
35
|
+
});
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import {
|
|
4
|
+
Sidebar,
|
|
5
|
+
SidebarProvider,
|
|
6
|
+
SidebarRail,
|
|
7
|
+
SidebarTrigger,
|
|
8
|
+
} from './Sidebar';
|
|
9
|
+
import { useIsMobile } from '../../hooks/useIsMobile/useIsMobile';
|
|
10
|
+
|
|
11
|
+
jest.mock('../../hooks/useIsMobile/useIsMobile', () => ({
|
|
12
|
+
useIsMobile: jest.fn(() => false),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
let containerWidth = 1800;
|
|
16
|
+
let notifyResize: () => void;
|
|
17
|
+
const disconnect = jest.fn();
|
|
18
|
+
|
|
19
|
+
function Example({
|
|
20
|
+
side = 'right',
|
|
21
|
+
resizable = true,
|
|
22
|
+
collapsible = 'offcanvas',
|
|
23
|
+
}: {
|
|
24
|
+
side?: 'left' | 'right';
|
|
25
|
+
resizable?: boolean;
|
|
26
|
+
collapsible?: 'offcanvas' | 'icon' | 'none';
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<SidebarProvider>
|
|
30
|
+
<Sidebar
|
|
31
|
+
side={side}
|
|
32
|
+
resizable={resizable}
|
|
33
|
+
defaultWidth={384}
|
|
34
|
+
minWidth={320}
|
|
35
|
+
maxWidth={960}
|
|
36
|
+
widthStorageKey={`width-${side}`}
|
|
37
|
+
collapsible={collapsible}
|
|
38
|
+
>
|
|
39
|
+
<SidebarRail />
|
|
40
|
+
</Sidebar>
|
|
41
|
+
<SidebarTrigger side={side} />
|
|
42
|
+
</SidebarProvider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
Example.displayName = 'Example';
|
|
46
|
+
|
|
47
|
+
function width(side = 'right') {
|
|
48
|
+
return document
|
|
49
|
+
.querySelector<HTMLElement>(`[data-side="${side}"]`)!
|
|
50
|
+
.style.getPropertyValue('--sidebar-width');
|
|
51
|
+
}
|
|
52
|
+
function transitionDuration(side = 'right') {
|
|
53
|
+
return (
|
|
54
|
+
document.querySelector(`[data-side="${side}"]`)!
|
|
55
|
+
.firstElementChild as HTMLElement
|
|
56
|
+
).style.transitionDuration;
|
|
57
|
+
}
|
|
58
|
+
function drag(rail: HTMLElement, from: number, to: number) {
|
|
59
|
+
fireEvent.pointerDown(rail, { pointerId: 1, button: 0, clientX: from });
|
|
60
|
+
fireEvent.pointerMove(rail, { pointerId: 1, clientX: to });
|
|
61
|
+
fireEvent.pointerUp(rail, { pointerId: 1, clientX: to });
|
|
62
|
+
fireEvent.click(rail, { detail: 1 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
localStorage.clear();
|
|
67
|
+
containerWidth = 1800;
|
|
68
|
+
jest.mocked(useIsMobile).mockReturnValue(false);
|
|
69
|
+
jest
|
|
70
|
+
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
71
|
+
.mockImplementation(() => ({
|
|
72
|
+
width: containerWidth,
|
|
73
|
+
height: 900,
|
|
74
|
+
x: 0,
|
|
75
|
+
y: 0,
|
|
76
|
+
top: 0,
|
|
77
|
+
left: 0,
|
|
78
|
+
right: containerWidth,
|
|
79
|
+
bottom: 900,
|
|
80
|
+
toJSON: () => ({}),
|
|
81
|
+
}));
|
|
82
|
+
// jsdom has no PointerEvent or pointer capture implementation.
|
|
83
|
+
window.PointerEvent = MouseEvent as typeof PointerEvent;
|
|
84
|
+
HTMLElement.prototype.setPointerCapture = jest.fn();
|
|
85
|
+
HTMLElement.prototype.releasePointerCapture = jest.fn();
|
|
86
|
+
window.ResizeObserver = jest.fn().mockImplementation((callback) => {
|
|
87
|
+
notifyResize = callback;
|
|
88
|
+
return { observe: jest.fn(), disconnect };
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test.each(['left', 'right'] as const)(
|
|
93
|
+
'resizes %s without toggling and persists on release',
|
|
94
|
+
(side) => {
|
|
95
|
+
render(<Example side={side} />);
|
|
96
|
+
const rail = screen.getByRole('button', {
|
|
97
|
+
name: `Resize or toggle ${side} sidebar`,
|
|
98
|
+
});
|
|
99
|
+
drag(rail, 500, side === 'left' ? 700 : 300);
|
|
100
|
+
expect(width(side)).toBe('584px');
|
|
101
|
+
expect(document.querySelector(`[data-side="${side}"]`)).toHaveAttribute(
|
|
102
|
+
'data-state',
|
|
103
|
+
'expanded'
|
|
104
|
+
);
|
|
105
|
+
expect(localStorage.getItem(`width-${side}`)).toBe('584');
|
|
106
|
+
expect(document.body.style.userSelect).toBe('');
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
test('restores a saved width without animating to it', () => {
|
|
111
|
+
localStorage.setItem('width-right', '700');
|
|
112
|
+
const commits: { width: string; duration: string }[] = [];
|
|
113
|
+
render(
|
|
114
|
+
<React.Profiler
|
|
115
|
+
id="sidebar"
|
|
116
|
+
onRender={() =>
|
|
117
|
+
commits.push({ width: width(), duration: transitionDuration() })
|
|
118
|
+
}
|
|
119
|
+
>
|
|
120
|
+
<Example />
|
|
121
|
+
</React.Profiler>
|
|
122
|
+
);
|
|
123
|
+
const animatedWidthChanges = commits.filter(
|
|
124
|
+
(commit, i) =>
|
|
125
|
+
i > 0 &&
|
|
126
|
+
commit.width !== commits[i - 1].width &&
|
|
127
|
+
commit.duration !== '0ms'
|
|
128
|
+
);
|
|
129
|
+
expect(animatedWidthChanges).toEqual([]);
|
|
130
|
+
expect(width()).toBe('700px');
|
|
131
|
+
expect(transitionDuration()).toBe(
|
|
132
|
+
'var(--sidebar-transition-duration, 200ms)'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a click with movement under the threshold toggles and reopening preserves width', () => {
|
|
137
|
+
render(<Example />);
|
|
138
|
+
const rail = screen.getByRole('button', { name: /Resize or toggle/ });
|
|
139
|
+
drag(rail, 500, 400);
|
|
140
|
+
drag(rail, 500, 502);
|
|
141
|
+
expect(document.querySelector('[data-side="right"]')).toHaveAttribute(
|
|
142
|
+
'data-state',
|
|
143
|
+
'collapsed'
|
|
144
|
+
);
|
|
145
|
+
fireEvent.click(screen.getByRole('button', { name: 'Toggle right sidebar' }));
|
|
146
|
+
expect(width()).toBe('484px');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('keyboard resizing respects bounds and container changes preserve preferred size', () => {
|
|
150
|
+
localStorage.setItem('width-right', '900');
|
|
151
|
+
render(<Example />);
|
|
152
|
+
const rail = screen.getByRole('button', { name: /Resize or toggle/ });
|
|
153
|
+
expect(rail).toHaveAttribute('tabindex', '0');
|
|
154
|
+
expect(width()).toBe('900px');
|
|
155
|
+
fireEvent.keyDown(rail, { key: 'End' });
|
|
156
|
+
expect(width()).toBe('960px');
|
|
157
|
+
act(() => {
|
|
158
|
+
containerWidth = 900;
|
|
159
|
+
notifyResize();
|
|
160
|
+
});
|
|
161
|
+
expect(width()).toBe('600px');
|
|
162
|
+
expect(localStorage.getItem('width-right')).toBe('960');
|
|
163
|
+
act(() => {
|
|
164
|
+
containerWidth = 1800;
|
|
165
|
+
notifyResize();
|
|
166
|
+
});
|
|
167
|
+
expect(width()).toBe('960px');
|
|
168
|
+
fireEvent.keyDown(rail, { key: 'Home' });
|
|
169
|
+
fireEvent.keyDown(rail, { key: 'ArrowRight' });
|
|
170
|
+
expect(width()).toBe('320px');
|
|
171
|
+
fireEvent.keyDown(rail, { key: 'ArrowLeft' });
|
|
172
|
+
expect(width()).toBe('330px');
|
|
173
|
+
act(() => {
|
|
174
|
+
containerWidth = 300;
|
|
175
|
+
notifyResize();
|
|
176
|
+
});
|
|
177
|
+
expect(width()).toBe('200px');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])(
|
|
181
|
+
'leaves %s arrow and Home/End keys to the browser',
|
|
182
|
+
(modifier) => {
|
|
183
|
+
render(<Example />);
|
|
184
|
+
const rail = screen.getByRole('button', { name: /Resize or toggle/ });
|
|
185
|
+
for (const key of ['ArrowLeft', 'ArrowRight', 'Home', 'End']) {
|
|
186
|
+
expect(fireEvent.keyDown(rail, { key, [modifier]: true })).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
expect(width()).toBe('384px');
|
|
189
|
+
expect(localStorage.getItem('width-right')).toBeNull();
|
|
190
|
+
}
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
test('left and right widths are independent', () => {
|
|
194
|
+
render(
|
|
195
|
+
<SidebarProvider>
|
|
196
|
+
<Sidebar side="left" resizable>
|
|
197
|
+
<SidebarRail />
|
|
198
|
+
</Sidebar>
|
|
199
|
+
<Sidebar side="right" resizable>
|
|
200
|
+
<SidebarRail />
|
|
201
|
+
</Sidebar>
|
|
202
|
+
</SidebarProvider>
|
|
203
|
+
);
|
|
204
|
+
fireEvent.keyDown(
|
|
205
|
+
screen.getByRole('button', { name: /Resize or toggle left/ }),
|
|
206
|
+
{ key: 'ArrowRight' }
|
|
207
|
+
);
|
|
208
|
+
expect(width('left')).toBe('266px');
|
|
209
|
+
expect(width('right')).toBe('384px');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test.each(['nonsense', '-1', 'Infinity'])(
|
|
213
|
+
'ignores invalid stored width %s',
|
|
214
|
+
(saved) => {
|
|
215
|
+
localStorage.setItem('width-right', saved);
|
|
216
|
+
render(<Example />);
|
|
217
|
+
expect(width()).toBe('384px');
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
test('unavailable storage does not block resizing', () => {
|
|
222
|
+
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
223
|
+
throw new Error('blocked');
|
|
224
|
+
});
|
|
225
|
+
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
226
|
+
throw new Error('blocked');
|
|
227
|
+
});
|
|
228
|
+
render(<Example />);
|
|
229
|
+
fireEvent.keyDown(screen.getByRole('button', { name: /Resize or toggle/ }), {
|
|
230
|
+
key: 'ArrowLeft',
|
|
231
|
+
});
|
|
232
|
+
expect(width()).toBe('394px');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('cancel restores the starting width and unmount cleans up an active drag', () => {
|
|
236
|
+
const { unmount } = render(<Example />);
|
|
237
|
+
const rail = screen.getByRole('button', { name: /Resize or toggle/ });
|
|
238
|
+
fireEvent.pointerDown(rail, { button: 0, clientX: 500 });
|
|
239
|
+
fireEvent.pointerMove(rail, { clientX: 300 });
|
|
240
|
+
expect(width()).toBe('584px');
|
|
241
|
+
expect(document.body.style.userSelect).toBe('none');
|
|
242
|
+
fireEvent.pointerCancel(rail);
|
|
243
|
+
expect(width()).toBe('384px');
|
|
244
|
+
expect(localStorage.getItem('width-right')).toBeNull();
|
|
245
|
+
fireEvent.pointerDown(rail, { button: 0, clientX: 500 });
|
|
246
|
+
fireEvent.pointerMove(rail, { clientX: 300 });
|
|
247
|
+
unmount();
|
|
248
|
+
expect(document.body.style.userSelect).toBe('');
|
|
249
|
+
expect(disconnect).toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('non-resizable sidebars retain their original rail and width', () => {
|
|
253
|
+
render(<Example resizable={false} />);
|
|
254
|
+
expect(width()).toBe('24rem');
|
|
255
|
+
expect(
|
|
256
|
+
screen.getByRole('button', { name: 'Toggle Sidebar' })
|
|
257
|
+
).toHaveAttribute('tabindex', '-1');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('icon collapse retains width and disables resizing until reopened', () => {
|
|
261
|
+
render(<Example collapsible="icon" />);
|
|
262
|
+
drag(screen.getByRole('button', { name: /Resize or toggle/ }), 500, 400);
|
|
263
|
+
fireEvent.click(screen.getByRole('button', { name: /Resize or toggle/ }));
|
|
264
|
+
expect(
|
|
265
|
+
screen.queryByRole('button', { name: /Resize or toggle/ })
|
|
266
|
+
).not.toBeInTheDocument();
|
|
267
|
+
fireEvent.click(screen.getByRole('button', { name: 'Toggle right sidebar' }));
|
|
268
|
+
expect(width()).toBe('484px');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('non-collapsible sidebars can resize', () => {
|
|
272
|
+
render(<Example collapsible="none" />);
|
|
273
|
+
const rail = screen.getByRole('button', { name: /Resize or toggle/ });
|
|
274
|
+
fireEvent.keyDown(rail, { key: 'End' });
|
|
275
|
+
expect(
|
|
276
|
+
rail
|
|
277
|
+
.closest<HTMLElement>('[style*="--sidebar-width"]')!
|
|
278
|
+
.style.getPropertyValue('--sidebar-width')
|
|
279
|
+
).toBe('960px');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('mobile uses its drawer without resize controls', () => {
|
|
283
|
+
jest.mocked(useIsMobile).mockReturnValue(true);
|
|
284
|
+
render(<Example />);
|
|
285
|
+
fireEvent.click(screen.getByRole('button', { name: 'Toggle right sidebar' }));
|
|
286
|
+
expect(document.querySelector('[data-mobile="true"]')).toBeInTheDocument();
|
|
287
|
+
expect(
|
|
288
|
+
screen.queryByRole('button', { name: /Resize or toggle/ })
|
|
289
|
+
).not.toBeInTheDocument();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('pointer movement clamps at both bounds and lost capture cancels', () => {
|
|
293
|
+
render(<Example />);
|
|
294
|
+
const rail = screen.getByRole('button', { name: /Resize or toggle/ });
|
|
295
|
+
drag(rail, 500, -1000);
|
|
296
|
+
expect(width()).toBe('960px');
|
|
297
|
+
drag(rail, 500, 1500);
|
|
298
|
+
expect(width()).toBe('320px');
|
|
299
|
+
fireEvent.pointerDown(rail, { button: 0, clientX: 500 });
|
|
300
|
+
fireEvent.pointerMove(rail, { clientX: 400 });
|
|
301
|
+
fireEvent.lostPointerCapture(rail);
|
|
302
|
+
expect(width()).toBe('320px');
|
|
303
|
+
expect(document.body.style.cursor).toBe('');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('resizes without ResizeObserver and removes its window listener on unmount', () => {
|
|
307
|
+
Object.defineProperty(window, 'ResizeObserver', {
|
|
308
|
+
configurable: true,
|
|
309
|
+
writable: true,
|
|
310
|
+
value: undefined,
|
|
311
|
+
});
|
|
312
|
+
const removeListener = jest.spyOn(window, 'removeEventListener');
|
|
313
|
+
const { unmount } = render(<Example />);
|
|
314
|
+
const rail = screen.getByRole('button', { name: /Resize or toggle/ });
|
|
315
|
+
drag(rail, 500, 300);
|
|
316
|
+
expect(width()).toBe('584px');
|
|
317
|
+
act(() => {
|
|
318
|
+
containerWidth = 600;
|
|
319
|
+
window.dispatchEvent(new Event('resize'));
|
|
320
|
+
});
|
|
321
|
+
expect(width()).toBe('400px');
|
|
322
|
+
act(() => {
|
|
323
|
+
containerWidth = 1800;
|
|
324
|
+
window.dispatchEvent(new Event('resize'));
|
|
325
|
+
});
|
|
326
|
+
expect(width()).toBe('584px');
|
|
327
|
+
unmount();
|
|
328
|
+
expect(removeListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test.each(['left', 'right'] as const)(
|
|
332
|
+
'the %s caret inherits the resize cursor only when expanded',
|
|
333
|
+
(side) => {
|
|
334
|
+
render(<Example side={side} collapsible="icon" />);
|
|
335
|
+
const rail = screen.getByRole('button', {
|
|
336
|
+
name: `Resize or toggle ${side} sidebar`,
|
|
337
|
+
});
|
|
338
|
+
const caret = rail.firstElementChild;
|
|
339
|
+
expect(rail).toHaveStyle({ cursor: 'col-resize' });
|
|
340
|
+
expect(caret).not.toHaveClass('cursor-w-resize');
|
|
341
|
+
expect(caret).not.toHaveClass('cursor-e-resize');
|
|
342
|
+
fireEvent.click(rail);
|
|
343
|
+
expect(caret).toHaveClass(
|
|
344
|
+
side === 'left' ? 'cursor-e-resize' : 'cursor-w-resize'
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
test.each(['release', 'cancel'])(
|
|
350
|
+
'keyboard activation toggles after drag %s without a pointer click',
|
|
351
|
+
(ending) => {
|
|
352
|
+
render(<Example />);
|
|
353
|
+
const rail = screen.getByRole('button', { name: /Resize or toggle/ });
|
|
354
|
+
fireEvent.pointerDown(rail, { pointerId: 1, button: 0, clientX: 500 });
|
|
355
|
+
fireEvent.pointerMove(rail, { pointerId: 1, clientX: 300 });
|
|
356
|
+
if (ending === 'release') {
|
|
357
|
+
fireEvent.pointerUp(rail, { pointerId: 1, clientX: 300 });
|
|
358
|
+
} else {
|
|
359
|
+
fireEvent.pointerCancel(rail, { pointerId: 1 });
|
|
360
|
+
}
|
|
361
|
+
// Native button keyboard activation has detail 0 and no pointerdown.
|
|
362
|
+
fireEvent.click(rail, { detail: 0 });
|
|
363
|
+
expect(document.querySelector('[data-side="right"]')).toHaveAttribute(
|
|
364
|
+
'data-state',
|
|
365
|
+
'collapsed'
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
);
|
|
@@ -746,3 +746,39 @@ const OrgSwitcher = ({
|
|
|
746
746
|
</DropdownMenu>
|
|
747
747
|
);
|
|
748
748
|
};
|
|
749
|
+
|
|
750
|
+
export const ResizableBothSides = () => (
|
|
751
|
+
<SidebarProvider>
|
|
752
|
+
<Sidebar
|
|
753
|
+
side="left"
|
|
754
|
+
collapsible="icon"
|
|
755
|
+
resizable
|
|
756
|
+
widthStorageKey="storybook-sidebar-left-width"
|
|
757
|
+
>
|
|
758
|
+
<SidebarHeader>Resizable navigation</SidebarHeader>
|
|
759
|
+
<SidebarRail />
|
|
760
|
+
</Sidebar>
|
|
761
|
+
<SidebarInset>
|
|
762
|
+
<Card height="100" padding="2xl">
|
|
763
|
+
<SidebarTrigger />
|
|
764
|
+
<SidebarTrigger side="right" />
|
|
765
|
+
<p>
|
|
766
|
+
Drag either rail to resize, or click to collapse. Focus a rail and use
|
|
767
|
+
arrow keys to resize.
|
|
768
|
+
</p>
|
|
769
|
+
</Card>
|
|
770
|
+
</SidebarInset>
|
|
771
|
+
<Sidebar
|
|
772
|
+
side="right"
|
|
773
|
+
resizable
|
|
774
|
+
defaultWidth={384}
|
|
775
|
+
minWidth={320}
|
|
776
|
+
maxWidth={960}
|
|
777
|
+
widthStorageKey="storybook-sidebar-right-width"
|
|
778
|
+
>
|
|
779
|
+
<SidebarHeader>Resizable agent panel</SidebarHeader>
|
|
780
|
+
<SidebarRail />
|
|
781
|
+
</Sidebar>
|
|
782
|
+
</SidebarProvider>
|
|
783
|
+
);
|
|
784
|
+
ResizableBothSides.displayName = 'ResizableBothSides';
|
|
@@ -124,6 +124,52 @@ describe('Sidebar', () => {
|
|
|
124
124
|
expect(rightSidebar).toHaveAttribute('data-state', 'collapsed');
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
test.each([
|
|
128
|
+
['Cmd', { metaKey: true }],
|
|
129
|
+
['Ctrl', { ctrlKey: true }],
|
|
130
|
+
])('leaves %s+[ and ] to the browser', (_label, modifier) => {
|
|
131
|
+
render(
|
|
132
|
+
<SidebarProvider>
|
|
133
|
+
<Sidebar side="left">
|
|
134
|
+
<div>Left</div>
|
|
135
|
+
</Sidebar>
|
|
136
|
+
<Sidebar side="right">
|
|
137
|
+
<div>Right</div>
|
|
138
|
+
</Sidebar>
|
|
139
|
+
</SidebarProvider>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(fireEvent.keyDown(window, { key: '[', ...modifier })).toBe(true);
|
|
143
|
+
expect(fireEvent.keyDown(window, { key: ']', ...modifier })).toBe(true);
|
|
144
|
+
expect(document.querySelector('[data-side="left"]')).toHaveAttribute(
|
|
145
|
+
'data-state',
|
|
146
|
+
'expanded'
|
|
147
|
+
);
|
|
148
|
+
expect(document.querySelector('[data-side="right"]')).toHaveAttribute(
|
|
149
|
+
'data-state',
|
|
150
|
+
'expanded'
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test.each([
|
|
155
|
+
['AltGraph', { ctrlKey: true, altKey: true, modifierAltGraph: true }],
|
|
156
|
+
['Option', { altKey: true }],
|
|
157
|
+
])('toggles when [ is typed with %s', (_label, modifier) => {
|
|
158
|
+
render(
|
|
159
|
+
<SidebarProvider>
|
|
160
|
+
<Sidebar side="left">
|
|
161
|
+
<div>Left</div>
|
|
162
|
+
</Sidebar>
|
|
163
|
+
</SidebarProvider>
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
fireEvent.keyDown(window, { key: '[', ...modifier });
|
|
167
|
+
expect(document.querySelector('[data-side="left"]')).toHaveAttribute(
|
|
168
|
+
'data-state',
|
|
169
|
+
'collapsed'
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
127
173
|
test('calls onOpenChange callback when sidebar state changes', () => {
|
|
128
174
|
const onOpenChange = jest.fn();
|
|
129
175
|
render(
|