@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyphen/hyphen-components",
3
- "version": "7.3.0",
3
+ "version": "7.3.1",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "@hyphen"
@@ -1,6 +1,11 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import React from 'react';
3
- import { Sidebar, SidebarProvider, SidebarTrigger } from './Sidebar';
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 SIDEBAR_KEYBOARD_SHORTCUT = '[';
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
- interface SidebarContextProps {
46
- isMobile: boolean;
47
- sides: Record<SidebarSide, SidebarContextSideState>;
48
- }
49
-
50
- const SidebarContext = React.createContext<SidebarContextProps | null>(null);
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 context = React.useContext(SidebarContext);
186
- if (!context) {
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
- ...context.sides[side],
193
- isMobile: context.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
- // Keydown event handler for toggling sidebar
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
- if (event.key === SIDEBAR_KEYBOARD_SHORTCUT) {
245
- event.preventDefault();
246
- const sideToToggle = lastToggledSideRef.current;
247
- (sideToToggle === 'left' ? leftState : rightState).toggleSidebar();
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
- }, [leftState, rightState]);
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
- <SidebarContext.Provider value={contextValue}>
268
- <TooltipProvider delayDuration={0}>
269
- <div
270
- style={
271
- {
272
- '--sidebar-width': SIDEBAR_WIDTH,
273
- '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
274
- minBlockSize: '100svh',
275
- ...style,
276
- } as React.CSSProperties
277
- }
278
- className={classNames(
279
- 'display-flex w-100 background-color-secondary',
280
- className
281
- )}
282
- ref={ref}
283
- {...props}
284
- >
285
- {children}
286
- </div>
287
- </TooltipProvider>
288
- </SidebarContext.Provider>
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
- left:
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="Toggle Sidebar ["
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',