@hyphen/hyphen-components 7.3.0 → 7.3.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/hyphen-components.cjs.development.js +54 -30
- 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 +54 -30
- 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 +126 -71
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
|
});
|
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
|
|
24
24
|
const SIDEBAR_WIDTH = '16rem';
|
|
25
25
|
const SIDEBAR_WIDTH_ICON = '44px';
|
|
26
|
-
const
|
|
26
|
+
const SIDEBAR_KEYBOARD_SHORTCUT_LEFT = '[';
|
|
27
|
+
const SIDEBAR_KEYBOARD_SHORTCUT_RIGHT = ']';
|
|
27
28
|
|
|
28
29
|
type SidebarSide = 'left' | 'right';
|
|
29
30
|
|
|
@@ -42,12 +43,13 @@ interface SidebarContextSideState {
|
|
|
42
43
|
toggleSidebar: () => void;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
const SidebarIsMobileContext = React.createContext<boolean | null>(null);
|
|
47
|
+
const SidebarLeftContext = React.createContext<SidebarContextSideState | null>(
|
|
48
|
+
null
|
|
49
|
+
);
|
|
50
|
+
const SidebarRightContext = React.createContext<SidebarContextSideState | null>(
|
|
51
|
+
null
|
|
52
|
+
);
|
|
51
53
|
const SidebarSideContext = React.createContext<SidebarSide>('left');
|
|
52
54
|
|
|
53
55
|
const resolveSideValue = (
|
|
@@ -99,6 +101,38 @@ const resolveStorageKey = (
|
|
|
99
101
|
return side === 'left' ? 'sidebar_expanded' : 'sidebar_expanded_right';
|
|
100
102
|
};
|
|
101
103
|
|
|
104
|
+
const getSidebarWidth = (
|
|
105
|
+
state: SidebarContextSideState['state'],
|
|
106
|
+
collapsible: 'offcanvas' | 'icon' | 'none'
|
|
107
|
+
) =>
|
|
108
|
+
state === 'collapsed' && collapsible === 'icon'
|
|
109
|
+
? 'var(--sidebar-width-icon)'
|
|
110
|
+
: state === 'collapsed'
|
|
111
|
+
? '0'
|
|
112
|
+
: 'var(--sidebar-width)';
|
|
113
|
+
|
|
114
|
+
const getSidebarOffsetStyles = (
|
|
115
|
+
side: SidebarSide,
|
|
116
|
+
state: SidebarContextSideState['state'],
|
|
117
|
+
collapsible: 'offcanvas' | 'icon' | 'none'
|
|
118
|
+
) => {
|
|
119
|
+
const isVisible = state === 'expanded' || collapsible === 'icon';
|
|
120
|
+
return {
|
|
121
|
+
left:
|
|
122
|
+
side === 'left'
|
|
123
|
+
? isVisible
|
|
124
|
+
? '0'
|
|
125
|
+
: 'calc(var(--sidebar-width)*-1)'
|
|
126
|
+
: undefined,
|
|
127
|
+
right:
|
|
128
|
+
side === 'right'
|
|
129
|
+
? isVisible
|
|
130
|
+
? '0'
|
|
131
|
+
: 'calc(var(--sidebar-width)*-1)'
|
|
132
|
+
: undefined,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
102
136
|
const useSidebarSideState = ({
|
|
103
137
|
side,
|
|
104
138
|
isMobile,
|
|
@@ -182,15 +216,21 @@ const useSidebarSideState = ({
|
|
|
182
216
|
};
|
|
183
217
|
|
|
184
218
|
function useSidebar(sideOverride?: SidebarSide) {
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
219
|
+
const isMobile = React.useContext(SidebarIsMobileContext);
|
|
220
|
+
if (typeof isMobile !== 'boolean') {
|
|
187
221
|
throw new Error('useSidebar must be used within a SidebarProvider.');
|
|
188
222
|
}
|
|
189
223
|
const contextSide = React.useContext(SidebarSideContext);
|
|
190
224
|
const side = sideOverride ?? contextSide;
|
|
225
|
+
const sideContext = React.useContext(
|
|
226
|
+
side === 'left' ? SidebarLeftContext : SidebarRightContext
|
|
227
|
+
);
|
|
228
|
+
if (!sideContext) {
|
|
229
|
+
throw new Error('useSidebar must be used within a SidebarProvider.');
|
|
230
|
+
}
|
|
191
231
|
return {
|
|
192
|
-
...
|
|
193
|
-
isMobile
|
|
232
|
+
...sideContext,
|
|
233
|
+
isMobile,
|
|
194
234
|
side,
|
|
195
235
|
};
|
|
196
236
|
}
|
|
@@ -219,6 +259,12 @@ const SidebarProvider = forwardRef<
|
|
|
219
259
|
) => {
|
|
220
260
|
const isMobile = useIsMobile();
|
|
221
261
|
const lastToggledSideRef = React.useRef<SidebarSide>('left');
|
|
262
|
+
const leftToggleRef = React.useRef<
|
|
263
|
+
SidebarContextSideState['toggleSidebar'] | null
|
|
264
|
+
>(null);
|
|
265
|
+
const rightToggleRef = React.useRef<
|
|
266
|
+
SidebarContextSideState['toggleSidebar'] | null
|
|
267
|
+
>(null);
|
|
222
268
|
const leftState = useSidebarSideState({
|
|
223
269
|
side: 'left',
|
|
224
270
|
isMobile,
|
|
@@ -238,54 +284,77 @@ const SidebarProvider = forwardRef<
|
|
|
238
284
|
lastToggledSideRef,
|
|
239
285
|
});
|
|
240
286
|
|
|
241
|
-
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
leftToggleRef.current = leftState.toggleSidebar;
|
|
289
|
+
}, [leftState.toggleSidebar]);
|
|
290
|
+
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
rightToggleRef.current = rightState.toggleSidebar;
|
|
293
|
+
}, [rightState.toggleSidebar]);
|
|
294
|
+
|
|
242
295
|
useEffect(() => {
|
|
243
296
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
(
|
|
297
|
+
const target = event.target as HTMLElement | null;
|
|
298
|
+
if (
|
|
299
|
+
target &&
|
|
300
|
+
(target.tagName === 'INPUT' ||
|
|
301
|
+
target.tagName === 'TEXTAREA' ||
|
|
302
|
+
target.tagName === 'SELECT' ||
|
|
303
|
+
target.isContentEditable)
|
|
304
|
+
) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const shortcutSide =
|
|
308
|
+
event.key === SIDEBAR_KEYBOARD_SHORTCUT_LEFT
|
|
309
|
+
? 'left'
|
|
310
|
+
: event.key === SIDEBAR_KEYBOARD_SHORTCUT_RIGHT
|
|
311
|
+
? 'right'
|
|
312
|
+
: null;
|
|
313
|
+
|
|
314
|
+
if (!shortcutSide) {
|
|
315
|
+
return;
|
|
248
316
|
}
|
|
317
|
+
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
|
|
320
|
+
const toggleSidebar =
|
|
321
|
+
shortcutSide === 'left'
|
|
322
|
+
? leftToggleRef.current
|
|
323
|
+
: rightToggleRef.current;
|
|
324
|
+
toggleSidebar?.();
|
|
249
325
|
};
|
|
250
326
|
|
|
251
327
|
window.addEventListener('keydown', handleKeyDown);
|
|
252
328
|
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
|
-
);
|
|
329
|
+
}, []);
|
|
265
330
|
|
|
266
331
|
return (
|
|
267
|
-
<
|
|
268
|
-
<
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
332
|
+
<SidebarIsMobileContext.Provider value={isMobile}>
|
|
333
|
+
<SidebarLeftContext.Provider value={leftState}>
|
|
334
|
+
<SidebarRightContext.Provider value={rightState}>
|
|
335
|
+
<TooltipProvider delayDuration={0}>
|
|
336
|
+
<div
|
|
337
|
+
style={
|
|
338
|
+
{
|
|
339
|
+
'--sidebar-width': SIDEBAR_WIDTH,
|
|
340
|
+
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
|
341
|
+
minBlockSize: '100svh',
|
|
342
|
+
...style,
|
|
343
|
+
} as React.CSSProperties
|
|
344
|
+
}
|
|
345
|
+
className={classNames(
|
|
346
|
+
'display-flex w-100 background-color-secondary',
|
|
347
|
+
className
|
|
348
|
+
)}
|
|
349
|
+
ref={ref}
|
|
350
|
+
{...props}
|
|
351
|
+
>
|
|
352
|
+
{children}
|
|
353
|
+
</div>
|
|
354
|
+
</TooltipProvider>
|
|
355
|
+
</SidebarRightContext.Provider>
|
|
356
|
+
</SidebarLeftContext.Provider>
|
|
357
|
+
</SidebarIsMobileContext.Provider>
|
|
289
358
|
);
|
|
290
359
|
}
|
|
291
360
|
);
|
|
@@ -368,12 +437,7 @@ const Sidebar = React.forwardRef<
|
|
|
368
437
|
transitionDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
369
438
|
animationDuration: 'var(--sidebar-transition-duration, 200ms)',
|
|
370
439
|
transitionProperty: 'width',
|
|
371
|
-
width:
|
|
372
|
-
state === 'collapsed' && collapsible === 'icon'
|
|
373
|
-
? 'var(--sidebar-width-icon)'
|
|
374
|
-
: state === 'collapsed'
|
|
375
|
-
? '0'
|
|
376
|
-
: 'var(--sidebar-width)',
|
|
440
|
+
width: getSidebarWidth(state, collapsible),
|
|
377
441
|
height: '100svh',
|
|
378
442
|
}}
|
|
379
443
|
className={classNames('position-relative', className)}
|
|
@@ -384,20 +448,7 @@ const Sidebar = React.forwardRef<
|
|
|
384
448
|
className
|
|
385
449
|
)}
|
|
386
450
|
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,
|
|
451
|
+
...getSidebarOffsetStyles(side, state, collapsible),
|
|
401
452
|
top: '0',
|
|
402
453
|
bottom: '0',
|
|
403
454
|
zIndex: 'var(--size-z-index-drawer)',
|
|
@@ -759,6 +810,10 @@ const SidebarRail = React.forwardRef<
|
|
|
759
810
|
React.ComponentProps<'button'>
|
|
760
811
|
>(({ className, ...props }, ref) => {
|
|
761
812
|
const { open, toggleSidebar, side } = useSidebar();
|
|
813
|
+
const shortcutLabel =
|
|
814
|
+
side === 'left'
|
|
815
|
+
? SIDEBAR_KEYBOARD_SHORTCUT_LEFT
|
|
816
|
+
: SIDEBAR_KEYBOARD_SHORTCUT_RIGHT;
|
|
762
817
|
|
|
763
818
|
const caretIcon = open
|
|
764
819
|
? side === 'right'
|
|
@@ -775,7 +830,7 @@ const SidebarRail = React.forwardRef<
|
|
|
775
830
|
aria-label="Toggle Sidebar"
|
|
776
831
|
tabIndex={-1}
|
|
777
832
|
onClick={toggleSidebar}
|
|
778
|
-
title=
|
|
833
|
+
title={`Toggle Sidebar ${shortcutLabel}`}
|
|
779
834
|
className={classNames(
|
|
780
835
|
styles.rail,
|
|
781
836
|
'hover-show-child background-color-transparent display-flex p-top-5xl p-left-xl p-right-0 justify-content-center position-absolute',
|