@hyphen/hyphen-components 9.0.0 → 9.1.1

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/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.0.0",
3
+ "version": "9.1.1",
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.4",
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.0.0"
60
+ "@hyphen/hyphen-design-tokens": "^9.1.1"
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,391 @@
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', () => {
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
+ });
300
+
301
+ test.each(['left', 'right'] as const)(
302
+ 'keeps the last dragged %s width when capture ends before pointerup',
303
+ (side) => {
304
+ render(<Example side={side} />);
305
+ const rail = screen.getByRole('button', {
306
+ name: `Resize or toggle ${side} sidebar`,
307
+ });
308
+ const endX = side === 'left' ? 700 : 300;
309
+ fireEvent.pointerDown(rail, { pointerId: 1, button: 0, clientX: 500 });
310
+ // Do not pause for a render between the last move and losing capture.
311
+ act(() => {
312
+ fireEvent.pointerMove(rail, { pointerId: 1, clientX: endX });
313
+ fireEvent.lostPointerCapture(rail, { pointerId: 1 });
314
+ });
315
+ expect(width(side)).toBe('584px');
316
+ expect(localStorage.getItem(`width-${side}`)).toBe('584');
317
+ expect(document.body.style.cursor).toBe('');
318
+ expect(document.body.style.userSelect).toBe('');
319
+ fireEvent.pointerUp(rail, { pointerId: 1, clientX: endX });
320
+ fireEvent.click(rail, { detail: 1 });
321
+ expect(width(side)).toBe('584px');
322
+ expect(document.querySelector(`[data-side="${side}"]`)).toHaveAttribute(
323
+ 'data-state',
324
+ 'expanded'
325
+ );
326
+ }
327
+ );
328
+
329
+ test('resizes without ResizeObserver and removes its window listener on unmount', () => {
330
+ Object.defineProperty(window, 'ResizeObserver', {
331
+ configurable: true,
332
+ writable: true,
333
+ value: undefined,
334
+ });
335
+ const removeListener = jest.spyOn(window, 'removeEventListener');
336
+ const { unmount } = render(<Example />);
337
+ const rail = screen.getByRole('button', { name: /Resize or toggle/ });
338
+ drag(rail, 500, 300);
339
+ expect(width()).toBe('584px');
340
+ act(() => {
341
+ containerWidth = 600;
342
+ window.dispatchEvent(new Event('resize'));
343
+ });
344
+ expect(width()).toBe('400px');
345
+ act(() => {
346
+ containerWidth = 1800;
347
+ window.dispatchEvent(new Event('resize'));
348
+ });
349
+ expect(width()).toBe('584px');
350
+ unmount();
351
+ expect(removeListener).toHaveBeenCalledWith('resize', expect.any(Function));
352
+ });
353
+
354
+ test.each(['left', 'right'] as const)(
355
+ 'the %s caret inherits the resize cursor only when expanded',
356
+ (side) => {
357
+ render(<Example side={side} collapsible="icon" />);
358
+ const rail = screen.getByRole('button', {
359
+ name: `Resize or toggle ${side} sidebar`,
360
+ });
361
+ const caret = rail.firstElementChild;
362
+ expect(rail).toHaveStyle({ cursor: 'col-resize' });
363
+ expect(caret).not.toHaveClass('cursor-w-resize');
364
+ expect(caret).not.toHaveClass('cursor-e-resize');
365
+ fireEvent.click(rail);
366
+ expect(caret).toHaveClass(
367
+ side === 'left' ? 'cursor-e-resize' : 'cursor-w-resize'
368
+ );
369
+ }
370
+ );
371
+
372
+ test.each(['release', 'cancel'])(
373
+ 'keyboard activation toggles after drag %s without a pointer click',
374
+ (ending) => {
375
+ render(<Example />);
376
+ const rail = screen.getByRole('button', { name: /Resize or toggle/ });
377
+ fireEvent.pointerDown(rail, { pointerId: 1, button: 0, clientX: 500 });
378
+ fireEvent.pointerMove(rail, { pointerId: 1, clientX: 300 });
379
+ if (ending === 'release') {
380
+ fireEvent.pointerUp(rail, { pointerId: 1, clientX: 300 });
381
+ } else {
382
+ fireEvent.pointerCancel(rail, { pointerId: 1 });
383
+ }
384
+ // Native button keyboard activation has detail 0 and no pointerdown.
385
+ fireEvent.click(rail, { detail: 0 });
386
+ expect(document.querySelector('[data-side="right"]')).toHaveAttribute(
387
+ 'data-state',
388
+ 'collapsed'
389
+ );
390
+ }
391
+ );
@@ -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(