@hyphen/hyphen-components 7.3.0 → 7.3.2
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/hyphen-components.cjs.development.js +64 -36
- package/dist/hyphen-components.cjs.development.js.map +1 -1
- package/dist/hyphen-components.cjs.production.min.js +1 -1
- package/dist/hyphen-components.cjs.production.min.js.map +1 -1
- package/dist/hyphen-components.esm.js +64 -36
- package/dist/hyphen-components.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Sidebar/Sidebar.test.tsx +115 -1
- package/src/components/Sidebar/Sidebar.tsx +139 -78
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Sidebar,
|
|
5
|
+
SidebarProvider,
|
|
6
|
+
SidebarTrigger,
|
|
7
|
+
useSidebar,
|
|
8
|
+
} from './Sidebar';
|
|
4
9
|
|
|
5
10
|
jest.mock('../../hooks/useIsMobile/useIsMobile', () => ({
|
|
6
11
|
useIsMobile: () => false,
|
|
@@ -71,6 +76,54 @@ describe('Sidebar', () => {
|
|
|
71
76
|
expect(rightSidebar).toHaveAttribute('data-state', 'collapsed');
|
|
72
77
|
});
|
|
73
78
|
|
|
79
|
+
test('toggles left sidebar with keyboard shortcut', () => {
|
|
80
|
+
render(
|
|
81
|
+
<SidebarProvider>
|
|
82
|
+
<Sidebar side="left">
|
|
83
|
+
<div>Left</div>
|
|
84
|
+
</Sidebar>
|
|
85
|
+
<Sidebar side="right">
|
|
86
|
+
<div>Right</div>
|
|
87
|
+
</Sidebar>
|
|
88
|
+
<SidebarTrigger side="left" data-testid="left-trigger" />
|
|
89
|
+
<SidebarTrigger side="right" data-testid="right-trigger" />
|
|
90
|
+
</SidebarProvider>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const leftSidebar = document.querySelector(
|
|
94
|
+
'[data-side="left"]'
|
|
95
|
+
) as HTMLElement;
|
|
96
|
+
|
|
97
|
+
expect(leftSidebar).toHaveAttribute('data-state', 'expanded');
|
|
98
|
+
|
|
99
|
+
fireEvent.keyDown(window, { key: '[' });
|
|
100
|
+
expect(leftSidebar).toHaveAttribute('data-state', 'collapsed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('toggles right sidebar with keyboard shortcut', () => {
|
|
104
|
+
render(
|
|
105
|
+
<SidebarProvider>
|
|
106
|
+
<Sidebar side="left">
|
|
107
|
+
<div>Left</div>
|
|
108
|
+
</Sidebar>
|
|
109
|
+
<Sidebar side="right">
|
|
110
|
+
<div>Right</div>
|
|
111
|
+
</Sidebar>
|
|
112
|
+
<SidebarTrigger side="left" data-testid="left-trigger" />
|
|
113
|
+
<SidebarTrigger side="right" data-testid="right-trigger" />
|
|
114
|
+
</SidebarProvider>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const rightSidebar = document.querySelector(
|
|
118
|
+
'[data-side="right"]'
|
|
119
|
+
) as HTMLElement;
|
|
120
|
+
|
|
121
|
+
expect(rightSidebar).toHaveAttribute('data-state', 'expanded');
|
|
122
|
+
|
|
123
|
+
fireEvent.keyDown(window, { key: ']' });
|
|
124
|
+
expect(rightSidebar).toHaveAttribute('data-state', 'collapsed');
|
|
125
|
+
});
|
|
126
|
+
|
|
74
127
|
test('calls onOpenChange callback when sidebar state changes', () => {
|
|
75
128
|
const onOpenChange = jest.fn();
|
|
76
129
|
render(
|
|
@@ -84,4 +137,65 @@ describe('Sidebar', () => {
|
|
|
84
137
|
fireEvent.click(screen.getByTestId('left-trigger'));
|
|
85
138
|
expect(onOpenChange).toHaveBeenCalledWith(true, 'left');
|
|
86
139
|
});
|
|
140
|
+
|
|
141
|
+
test.each([
|
|
142
|
+
['input', <input aria-label="input-field" />],
|
|
143
|
+
['textarea', <textarea aria-label="textarea-field" />],
|
|
144
|
+
['select', <select aria-label="select-field" />],
|
|
145
|
+
['contenteditable', <div aria-label="editable-field" contentEditable />],
|
|
146
|
+
])('ignores keyboard shortcuts for %s elements', (label, field) => {
|
|
147
|
+
render(
|
|
148
|
+
<SidebarProvider>
|
|
149
|
+
<Sidebar side="left">
|
|
150
|
+
<div>Left</div>
|
|
151
|
+
</Sidebar>
|
|
152
|
+
{field}
|
|
153
|
+
</SidebarProvider>
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const leftSidebar = document.querySelector(
|
|
157
|
+
'[data-side="left"]'
|
|
158
|
+
) as HTMLElement;
|
|
159
|
+
|
|
160
|
+
expect(leftSidebar).toHaveAttribute('data-state', 'expanded');
|
|
161
|
+
|
|
162
|
+
const target = screen.getByLabelText(/field/) as HTMLElement;
|
|
163
|
+
if (label === 'contenteditable') {
|
|
164
|
+
Object.defineProperty(target, 'isContentEditable', {
|
|
165
|
+
configurable: true,
|
|
166
|
+
value: true,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
fireEvent.keyDown(target, { key: '[' });
|
|
170
|
+
expect(leftSidebar).toHaveAttribute('data-state', 'expanded');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('avoids re-rendering right consumers when left toggles', () => {
|
|
174
|
+
const onRender = jest.fn();
|
|
175
|
+
const RightConsumer = React.memo(
|
|
176
|
+
({ onRender: onRenderProp }: { onRender: jest.Mock }) => {
|
|
177
|
+
useSidebar('right');
|
|
178
|
+
onRenderProp();
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
render(
|
|
184
|
+
<SidebarProvider>
|
|
185
|
+
<Sidebar side="left" />
|
|
186
|
+
<Sidebar side="right" />
|
|
187
|
+
<RightConsumer onRender={onRender} />
|
|
188
|
+
<SidebarTrigger side="left" data-testid="left-trigger" />
|
|
189
|
+
<SidebarTrigger side="right" data-testid="right-trigger" />
|
|
190
|
+
</SidebarProvider>
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
expect(onRender).toHaveBeenCalledTimes(1);
|
|
194
|
+
|
|
195
|
+
fireEvent.click(screen.getByTestId('left-trigger'));
|
|
196
|
+
expect(onRender).toHaveBeenCalledTimes(1);
|
|
197
|
+
|
|
198
|
+
fireEvent.click(screen.getByTestId('right-trigger'));
|
|
199
|
+
expect(onRender).toHaveBeenCalledTimes(2);
|
|
200
|
+
});
|
|
87
201
|
});
|
|
@@ -22,8 +22,10 @@ import {
|
|
|
22
22
|
} from '../Tooltip/Tooltip';
|
|
23
23
|
|
|
24
24
|
const SIDEBAR_WIDTH = '16rem';
|
|
25
|
+
const SIDEBAR_RIGHT_WIDTH = '24rem';
|
|
25
26
|
const SIDEBAR_WIDTH_ICON = '44px';
|
|
26
|
-
const
|
|
27
|
+
const SIDEBAR_KEYBOARD_SHORTCUT_LEFT = '[';
|
|
28
|
+
const SIDEBAR_KEYBOARD_SHORTCUT_RIGHT = ']';
|
|
27
29
|
|
|
28
30
|
type SidebarSide = 'left' | 'right';
|
|
29
31
|
|
|
@@ -42,12 +44,13 @@ interface SidebarContextSideState {
|
|
|
42
44
|
toggleSidebar: () => void;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const SidebarIsMobileContext = React.createContext<boolean | null>(null);
|
|
48
|
+
const SidebarLeftContext = React.createContext<SidebarContextSideState | null>(
|
|
49
|
+
null
|
|
50
|
+
);
|
|
51
|
+
const SidebarRightContext = React.createContext<SidebarContextSideState | null>(
|
|
52
|
+
null
|
|
53
|
+
);
|
|
51
54
|
const SidebarSideContext = React.createContext<SidebarSide>('left');
|
|
52
55
|
|
|
53
56
|
const resolveSideValue = (
|
|
@@ -99,6 +102,38 @@ const resolveStorageKey = (
|
|
|
99
102
|
return side === 'left' ? 'sidebar_expanded' : 'sidebar_expanded_right';
|
|
100
103
|
};
|
|
101
104
|
|
|
105
|
+
const getSidebarWidth = (
|
|
106
|
+
state: SidebarContextSideState['state'],
|
|
107
|
+
collapsible: 'offcanvas' | 'icon' | 'none'
|
|
108
|
+
) =>
|
|
109
|
+
state === 'collapsed' && collapsible === 'icon'
|
|
110
|
+
? 'var(--sidebar-width-icon)'
|
|
111
|
+
: state === 'collapsed'
|
|
112
|
+
? '0'
|
|
113
|
+
: 'var(--sidebar-width)';
|
|
114
|
+
|
|
115
|
+
const getSidebarOffsetStyles = (
|
|
116
|
+
side: SidebarSide,
|
|
117
|
+
state: SidebarContextSideState['state'],
|
|
118
|
+
collapsible: 'offcanvas' | 'icon' | 'none'
|
|
119
|
+
) => {
|
|
120
|
+
const isVisible = state === 'expanded' || collapsible === 'icon';
|
|
121
|
+
return {
|
|
122
|
+
left:
|
|
123
|
+
side === 'left'
|
|
124
|
+
? isVisible
|
|
125
|
+
? '0'
|
|
126
|
+
: 'calc(var(--sidebar-width)*-1)'
|
|
127
|
+
: undefined,
|
|
128
|
+
right:
|
|
129
|
+
side === 'right'
|
|
130
|
+
? isVisible
|
|
131
|
+
? '0'
|
|
132
|
+
: 'calc(var(--sidebar-width)*-1)'
|
|
133
|
+
: undefined,
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
102
137
|
const useSidebarSideState = ({
|
|
103
138
|
side,
|
|
104
139
|
isMobile,
|
|
@@ -182,15 +217,21 @@ const useSidebarSideState = ({
|
|
|
182
217
|
};
|
|
183
218
|
|
|
184
219
|
function useSidebar(sideOverride?: SidebarSide) {
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
220
|
+
const isMobile = React.useContext(SidebarIsMobileContext);
|
|
221
|
+
if (typeof isMobile !== 'boolean') {
|
|
187
222
|
throw new Error('useSidebar must be used within a SidebarProvider.');
|
|
188
223
|
}
|
|
189
224
|
const contextSide = React.useContext(SidebarSideContext);
|
|
190
225
|
const side = sideOverride ?? contextSide;
|
|
226
|
+
const sideContext = React.useContext(
|
|
227
|
+
side === 'left' ? SidebarLeftContext : SidebarRightContext
|
|
228
|
+
);
|
|
229
|
+
if (!sideContext) {
|
|
230
|
+
throw new Error('useSidebar must be used within a SidebarProvider.');
|
|
231
|
+
}
|
|
191
232
|
return {
|
|
192
|
-
...
|
|
193
|
-
isMobile
|
|
233
|
+
...sideContext,
|
|
234
|
+
isMobile,
|
|
194
235
|
side,
|
|
195
236
|
};
|
|
196
237
|
}
|
|
@@ -219,6 +260,12 @@ const SidebarProvider = forwardRef<
|
|
|
219
260
|
) => {
|
|
220
261
|
const isMobile = useIsMobile();
|
|
221
262
|
const lastToggledSideRef = React.useRef<SidebarSide>('left');
|
|
263
|
+
const leftToggleRef = React.useRef<
|
|
264
|
+
SidebarContextSideState['toggleSidebar'] | null
|
|
265
|
+
>(null);
|
|
266
|
+
const rightToggleRef = React.useRef<
|
|
267
|
+
SidebarContextSideState['toggleSidebar'] | null
|
|
268
|
+
>(null);
|
|
222
269
|
const leftState = useSidebarSideState({
|
|
223
270
|
side: 'left',
|
|
224
271
|
isMobile,
|
|
@@ -238,54 +285,77 @@ const SidebarProvider = forwardRef<
|
|
|
238
285
|
lastToggledSideRef,
|
|
239
286
|
});
|
|
240
287
|
|
|
241
|
-
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
leftToggleRef.current = leftState.toggleSidebar;
|
|
290
|
+
}, [leftState.toggleSidebar]);
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
rightToggleRef.current = rightState.toggleSidebar;
|
|
294
|
+
}, [rightState.toggleSidebar]);
|
|
295
|
+
|
|
242
296
|
useEffect(() => {
|
|
243
297
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
(
|
|
298
|
+
const target = event.target as HTMLElement | null;
|
|
299
|
+
if (
|
|
300
|
+
target &&
|
|
301
|
+
(target.tagName === 'INPUT' ||
|
|
302
|
+
target.tagName === 'TEXTAREA' ||
|
|
303
|
+
target.tagName === 'SELECT' ||
|
|
304
|
+
target.isContentEditable)
|
|
305
|
+
) {
|
|
306
|
+
return;
|
|
248
307
|
}
|
|
308
|
+
const shortcutSide =
|
|
309
|
+
event.key === SIDEBAR_KEYBOARD_SHORTCUT_LEFT
|
|
310
|
+
? 'left'
|
|
311
|
+
: event.key === SIDEBAR_KEYBOARD_SHORTCUT_RIGHT
|
|
312
|
+
? 'right'
|
|
313
|
+
: null;
|
|
314
|
+
|
|
315
|
+
if (!shortcutSide) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
event.preventDefault();
|
|
320
|
+
|
|
321
|
+
const toggleSidebar =
|
|
322
|
+
shortcutSide === 'left'
|
|
323
|
+
? leftToggleRef.current
|
|
324
|
+
: rightToggleRef.current;
|
|
325
|
+
toggleSidebar?.();
|
|
249
326
|
};
|
|
250
327
|
|
|
251
328
|
window.addEventListener('keydown', handleKeyDown);
|
|
252
329
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
253
|
-
}, [
|
|
254
|
-
|
|
255
|
-
const contextValue = useMemo<SidebarContextProps>(
|
|
256
|
-
() => ({
|
|
257
|
-
isMobile,
|
|
258
|
-
sides: {
|
|
259
|
-
left: leftState,
|
|
260
|
-
right: rightState,
|
|
261
|
-
},
|
|
262
|
-
}),
|
|
263
|
-
[isMobile, leftState, rightState]
|
|
264
|
-
);
|
|
330
|
+
}, []);
|
|
265
331
|
|
|
266
332
|
return (
|
|
267
|
-
<
|
|
268
|
-
<
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
333
|
+
<SidebarIsMobileContext.Provider value={isMobile}>
|
|
334
|
+
<SidebarLeftContext.Provider value={leftState}>
|
|
335
|
+
<SidebarRightContext.Provider value={rightState}>
|
|
336
|
+
<TooltipProvider delayDuration={0}>
|
|
337
|
+
<div
|
|
338
|
+
style={
|
|
339
|
+
{
|
|
340
|
+
'--sidebar-width': SIDEBAR_WIDTH,
|
|
341
|
+
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
|
342
|
+
minBlockSize: '100svh',
|
|
343
|
+
...style,
|
|
344
|
+
} as React.CSSProperties
|
|
345
|
+
}
|
|
346
|
+
className={classNames(
|
|
347
|
+
'display-flex w-100 background-color-secondary overflow-hidden',
|
|
348
|
+
className
|
|
349
|
+
)}
|
|
350
|
+
ref={ref}
|
|
351
|
+
{...props}
|
|
352
|
+
>
|
|
353
|
+
{children}
|
|
354
|
+
</div>
|
|
355
|
+
</TooltipProvider>
|
|
356
|
+
</SidebarRightContext.Provider>
|
|
357
|
+
</SidebarLeftContext.Provider>
|
|
358
|
+
</SidebarIsMobileContext.Provider>
|
|
289
359
|
);
|
|
290
360
|
}
|
|
291
361
|
);
|
|
@@ -303,6 +373,7 @@ const Sidebar = React.forwardRef<
|
|
|
303
373
|
ref
|
|
304
374
|
) => {
|
|
305
375
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar(side);
|
|
376
|
+
const sidebarWidth = side === 'right' ? SIDEBAR_RIGHT_WIDTH : SIDEBAR_WIDTH;
|
|
306
377
|
|
|
307
378
|
if (isMobile) {
|
|
308
379
|
return (
|
|
@@ -328,9 +399,12 @@ const Sidebar = React.forwardRef<
|
|
|
328
399
|
'group display-flex h-100 font-size-xs flex-direction-column background-color-secondary font-color-base',
|
|
329
400
|
className
|
|
330
401
|
)}
|
|
331
|
-
style={
|
|
332
|
-
|
|
333
|
-
|
|
402
|
+
style={
|
|
403
|
+
{
|
|
404
|
+
'--sidebar-width': sidebarWidth,
|
|
405
|
+
width: 'var(--sidebar-width)',
|
|
406
|
+
} as React.CSSProperties
|
|
407
|
+
}
|
|
334
408
|
ref={ref}
|
|
335
409
|
{...props}
|
|
336
410
|
>
|
|
@@ -350,9 +424,9 @@ const Sidebar = React.forwardRef<
|
|
|
350
424
|
fontSize="sm"
|
|
351
425
|
position="relative"
|
|
352
426
|
style={
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
427
|
+
{
|
|
428
|
+
'--sidebar-width': sidebarWidth,
|
|
429
|
+
} as React.CSSProperties
|
|
356
430
|
}
|
|
357
431
|
data-state={state}
|
|
358
432
|
data-collapsible={collapsible}
|
|
@@ -368,12 +442,7 @@ const Sidebar = React.forwardRef<
|
|
|
368
442
|
transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
369
443
|
animationDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
370
444
|
transitionProperty: 'width',
|
|
371
|
-
width:
|
|
372
|
-
state === 'collapsed' && collapsible === 'icon'
|
|
373
|
-
? 'var(--sidebar-width-icon)'
|
|
374
|
-
: state === 'collapsed'
|
|
375
|
-
? '0'
|
|
376
|
-
: 'var(--sidebar-width)',
|
|
445
|
+
width: getSidebarWidth(state, collapsible),
|
|
377
446
|
height: '100svh',
|
|
378
447
|
}}
|
|
379
448
|
className={classNames('position-relative', className)}
|
|
@@ -384,20 +453,7 @@ const Sidebar = React.forwardRef<
|
|
|
384
453
|
className
|
|
385
454
|
)}
|
|
386
455
|
style={{
|
|
387
|
-
|
|
388
|
-
side === 'left' &&
|
|
389
|
-
(state === 'expanded' || collapsible === 'icon')
|
|
390
|
-
? '0'
|
|
391
|
-
: side === 'left'
|
|
392
|
-
? 'calc(var(--sidebar-width)*-1)'
|
|
393
|
-
: undefined,
|
|
394
|
-
right:
|
|
395
|
-
side === 'right' &&
|
|
396
|
-
(state === 'expanded' || collapsible === 'icon')
|
|
397
|
-
? '0'
|
|
398
|
-
: side === 'right'
|
|
399
|
-
? 'calc(var(--sidebar-width)*-1)'
|
|
400
|
-
: undefined,
|
|
456
|
+
...getSidebarOffsetStyles(side, state, collapsible),
|
|
401
457
|
top: '0',
|
|
402
458
|
bottom: '0',
|
|
403
459
|
zIndex: 'var(--size-z-index-drawer)',
|
|
@@ -759,6 +815,10 @@ const SidebarRail = React.forwardRef<
|
|
|
759
815
|
React.ComponentProps<'button'>
|
|
760
816
|
>(({ className, ...props }, ref) => {
|
|
761
817
|
const { open, toggleSidebar, side } = useSidebar();
|
|
818
|
+
const shortcutLabel =
|
|
819
|
+
side === 'left'
|
|
820
|
+
? SIDEBAR_KEYBOARD_SHORTCUT_LEFT
|
|
821
|
+
: SIDEBAR_KEYBOARD_SHORTCUT_RIGHT;
|
|
762
822
|
|
|
763
823
|
const caretIcon = open
|
|
764
824
|
? side === 'right'
|
|
@@ -775,7 +835,7 @@ const SidebarRail = React.forwardRef<
|
|
|
775
835
|
aria-label="Toggle Sidebar"
|
|
776
836
|
tabIndex={-1}
|
|
777
837
|
onClick={toggleSidebar}
|
|
778
|
-
title=
|
|
838
|
+
title={`Toggle Sidebar ${shortcutLabel}`}
|
|
779
839
|
className={classNames(
|
|
780
840
|
styles.rail,
|
|
781
841
|
'hover-show-child background-color-transparent display-flex p-top-5xl p-left-xl p-right-0 justify-content-center position-absolute',
|
|
@@ -791,9 +851,10 @@ const SidebarRail = React.forwardRef<
|
|
|
791
851
|
top: '20px',
|
|
792
852
|
bottom: '20px',
|
|
793
853
|
right: side === 'left' ? '-14px' : undefined,
|
|
794
|
-
left: side === 'right' ? '-
|
|
854
|
+
left: side === 'right' ? '-18px' : undefined,
|
|
795
855
|
width: '10px',
|
|
796
856
|
}}
|
|
857
|
+
type="button"
|
|
797
858
|
{...props}
|
|
798
859
|
>
|
|
799
860
|
<Box
|