@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyphen/hyphen-components",
3
- "version": "7.3.0",
3
+ "version": "7.3.2",
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
  });
@@ -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 SIDEBAR_KEYBOARD_SHORTCUT = '[';
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
- interface SidebarContextProps {
46
- isMobile: boolean;
47
- sides: Record<SidebarSide, SidebarContextSideState>;
48
- }
49
-
50
- const SidebarContext = React.createContext<SidebarContextProps | null>(null);
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 context = React.useContext(SidebarContext);
186
- if (!context) {
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
- ...context.sides[side],
193
- isMobile: context.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
- // Keydown event handler for toggling sidebar
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
- if (event.key === SIDEBAR_KEYBOARD_SHORTCUT) {
245
- event.preventDefault();
246
- const sideToToggle = lastToggledSideRef.current;
247
- (sideToToggle === 'left' ? leftState : rightState).toggleSidebar();
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
- }, [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
- );
330
+ }, []);
265
331
 
266
332
  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>
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
- width: 'var(--sidebar-width)',
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
- side === 'right' && collapsible === 'offcanvas'
354
- ? { overflowX: 'hidden' }
355
- : undefined
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
- 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,
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="Toggle Sidebar ["
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' ? '-14px' : undefined,
854
+ left: side === 'right' ? '-18px' : undefined,
795
855
  width: '10px',
796
856
  }}
857
+ type="button"
797
858
  {...props}
798
859
  >
799
860
  <Box