uikit-react-public 0.30.1 → 0.32.3

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.
Files changed (49) hide show
  1. package/dist/components/Heading/Heading.stories.d.ts +5 -7
  2. package/dist/components/MenuNew/Menu.context.d.ts +9 -0
  3. package/dist/components/MenuNew/Menu.d.ts +2 -1
  4. package/dist/components/MenuNew/MenuHeading.d.ts +1 -1
  5. package/dist/components/MenuNew/MenuItemText.d.ts +2 -1
  6. package/dist/components/MenuNew/PrimaryMenuItem.d.ts +1 -1
  7. package/dist/components/Select/Select.types.d.ts +1 -1
  8. package/dist/components/Sidebar/Sidebar.d.ts +12 -0
  9. package/dist/components/Sidebar/Sidebar.stories.d.ts +38 -0
  10. package/dist/components/Sidebar/__tests__/Sidebar.test.d.ts +1 -0
  11. package/dist/components/Sidebar/index.d.ts +2 -0
  12. package/dist/components/Snackbar/Snackbar.d.ts +9 -5
  13. package/dist/components/Snackbar/Snackbar.stories.d.ts +2 -1
  14. package/dist/components/Snackbar/Snackbars.d.ts +19 -0
  15. package/dist/components/Snackbar/Snackbars.stories.d.ts +52 -0
  16. package/dist/components/Snackbar/__tests__/Snackbars.test.d.ts +1 -0
  17. package/dist/components/Snackbar/index.d.ts +2 -0
  18. package/dist/components/index.d.ts +4 -1
  19. package/dist/index.js +5740 -5217
  20. package/lib/components/Dropdown/Dropdown.stories.tsx +69 -74
  21. package/lib/components/Heading/Documentation.mdx +4 -14
  22. package/lib/components/Heading/Heading.stories.tsx +16 -30
  23. package/lib/components/MenuNew/Menu.context.tsx +18 -0
  24. package/lib/components/MenuNew/Menu.tsx +14 -9
  25. package/lib/components/MenuNew/MenuDivider.tsx +12 -1
  26. package/lib/components/MenuNew/MenuHeading.tsx +6 -0
  27. package/lib/components/MenuNew/MenuItemText.tsx +27 -3
  28. package/lib/components/MenuNew/MenuSection.tsx +11 -0
  29. package/lib/components/MenuNew/PrimaryMenuItem.tsx +71 -2
  30. package/lib/components/Select/Select.types.ts +1 -7
  31. package/lib/components/Select/__tests__/Select.test.tsx +112 -0
  32. package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +5 -0
  33. package/lib/components/Select/subcomponents/CustomSelect.tsx +75 -3
  34. package/lib/components/Select/subcomponents/FilterInput.tsx +1 -1
  35. package/lib/components/Sidebar/Sidebar.stories.tsx +94 -0
  36. package/lib/components/Sidebar/Sidebar.tsx +208 -0
  37. package/lib/components/Sidebar/__tests__/Sidebar.test.tsx +96 -0
  38. package/lib/components/Sidebar/__tests__/__snapshots__/Sidebar.test.tsx.snap +272 -0
  39. package/lib/components/Sidebar/index.ts +2 -0
  40. package/lib/components/Snackbar/Snackbar.stories.tsx +11 -0
  41. package/lib/components/Snackbar/Snackbar.tsx +162 -118
  42. package/lib/components/Snackbar/Snackbars.stories.tsx +130 -0
  43. package/lib/components/Snackbar/Snackbars.tsx +377 -0
  44. package/lib/components/Snackbar/__tests__/Snackbar.test.tsx +104 -0
  45. package/lib/components/Snackbar/__tests__/Snackbars.test.tsx +377 -0
  46. package/lib/components/Snackbar/__tests__/__snapshots__/Snackbar.test.tsx.snap +36 -19
  47. package/lib/components/Snackbar/index.ts +6 -0
  48. package/lib/components/index.ts +10 -1
  49. package/package.json +1 -1
@@ -518,6 +518,76 @@ describe('Select', () => {
518
518
  );
519
519
  });
520
520
 
521
+ test('selectionBehaviour=commit returns focus to the combobox on Enter (filterable)', async () => {
522
+ const user = userEvent.setup();
523
+
524
+ const ControlledSelect = () => {
525
+ const [value, setValue] = useState('');
526
+ return (
527
+ <Select
528
+ filterable
529
+ options={defaultOptions}
530
+ value={value}
531
+ selectionBehaviour='commit'
532
+ onValueChange={(next) => setValue(next as string)}
533
+ />
534
+ );
535
+ };
536
+
537
+ const result = render(
538
+ <ThemeContextProvider>
539
+ <ControlledSelect />
540
+ </ThemeContextProvider>
541
+ );
542
+
543
+ await user.click(result.getByTestId('ucl-uikit-select'));
544
+ // Filter input has focus while the panel is open
545
+ expect(result.getByRole('searchbox')).toHaveFocus();
546
+
547
+ await user.keyboard('{ArrowDown}');
548
+ await user.keyboard('{Enter}');
549
+
550
+ // Panel (and its filter input) has closed, and focus is back on the
551
+ // combobox rather than lost to the document body.
552
+ expect(
553
+ result.queryByTestId('ucl-uikit-select__panel')
554
+ ).not.toBeInTheDocument();
555
+ expect(result.getByRole('combobox')).toHaveFocus();
556
+ });
557
+
558
+ test('selectionBehaviour=commit announces the committed option via a live region', async () => {
559
+ const user = userEvent.setup();
560
+
561
+ const ControlledSelect = () => {
562
+ const [value, setValue] = useState('');
563
+ return (
564
+ <Select
565
+ filterable
566
+ options={defaultOptions}
567
+ value={value}
568
+ selectionBehaviour='commit'
569
+ onValueChange={(next) => setValue(next as string)}
570
+ />
571
+ );
572
+ };
573
+
574
+ const result = render(
575
+ <ThemeContextProvider>
576
+ <ControlledSelect />
577
+ </ThemeContextProvider>
578
+ );
579
+
580
+ const status = result.getByRole('status');
581
+ expect(status).toHaveTextContent('');
582
+
583
+ await user.click(result.getByTestId('ucl-uikit-select'));
584
+ await user.keyboard('{ArrowDown}');
585
+ await user.keyboard('{ArrowDown}');
586
+ await user.keyboard('{Enter}');
587
+
588
+ expect(status).toHaveTextContent('Option 2 selected');
589
+ });
590
+
521
591
  test('keyboard navigation does not get stuck on duplicate values', async () => {
522
592
  const user = userEvent.setup();
523
593
  const options = [
@@ -969,6 +1039,48 @@ describe('Select', () => {
969
1039
  ).not.toBeInTheDocument();
970
1040
  });
971
1041
 
1042
+ test('aria-activedescendant is absent immediately after opening with a preselected value', async () => {
1043
+ const user = userEvent.setup();
1044
+ const result = render(
1045
+ <ThemeContextProvider>
1046
+ <Select
1047
+ options={defaultOptions}
1048
+ value='2'
1049
+ onValueChange={() => {}}
1050
+ />
1051
+ </ThemeContextProvider>
1052
+ );
1053
+
1054
+ const combobox = result.getByRole('combobox');
1055
+ await user.click(combobox);
1056
+
1057
+ expect(combobox).not.toHaveAttribute('aria-activedescendant');
1058
+
1059
+ await user.keyboard('{ArrowDown}');
1060
+ expect(combobox).toHaveAttribute('aria-activedescendant');
1061
+ });
1062
+
1063
+ test('filterable: aria-activedescendant is absent immediately after opening with a preselected value', async () => {
1064
+ const user = userEvent.setup();
1065
+ const result = render(
1066
+ <ThemeContextProvider>
1067
+ <Select
1068
+ filterable
1069
+ options={defaultOptions}
1070
+ value='2'
1071
+ onValueChange={() => {}}
1072
+ />
1073
+ </ThemeContextProvider>
1074
+ );
1075
+
1076
+ await user.click(result.getByRole('combobox'));
1077
+ const filterInput = result.getByRole('searchbox');
1078
+ expect(filterInput).not.toHaveAttribute('aria-activedescendant');
1079
+
1080
+ await user.keyboard('{ArrowDown}');
1081
+ expect(filterInput).toHaveAttribute('aria-activedescendant');
1082
+ });
1083
+
972
1084
  test('disabled clearable select does not show clear button', () => {
973
1085
  const result = render(
974
1086
  <ThemeContextProvider>
@@ -35,5 +35,10 @@ exports[`Select > Snapshot: default 1`] = `
35
35
  />
36
36
  </svg>
37
37
  </div>
38
+ <span
39
+ aria-live="polite"
40
+ class="css-fj1rnv"
41
+ role="status"
42
+ />
38
43
  </div>
39
44
  `;
@@ -122,11 +122,15 @@ const CustomSelect = <T extends string | number>({
122
122
  null
123
123
  );
124
124
  const [overlaySize, setOverlaySize] = useState<OverlaySize | null>(null);
125
+ // Message announced via an aria-live region when a selection is committed,
126
+ // so screen reader users hear confirmation even after the panel/filter closes.
127
+ const [selectionAnnouncement, setSelectionAnnouncement] = useState('');
125
128
  const filterInputRef = useRef<HTMLInputElement | null>(null);
126
129
  const clearButtonRef = useRef<HTMLButtonElement | null>(null);
127
130
  const reactId = useId();
128
131
  const idBase = props.id ?? `${testId}-${reactId.replace(/[:]/g, '')}`;
129
132
  const listboxId = `${idBase}-listbox`;
133
+ const selectionStatusId = `${idBase}-selection-status`;
130
134
  const overlayViewportPadding = parseFloat(String(theme.margin.m32));
131
135
 
132
136
  // Returns a list of indexes of options that are currently visible based on the filter text
@@ -150,6 +154,12 @@ const CustomSelect = <T extends string | number>({
150
154
  if (!isOpen && filterText) setFilterText('');
151
155
  }, [isOpen, filterText]);
152
156
 
157
+ // Reset the live-region text when the panel opens so committing the same
158
+ // option again produces a changed value that screen readers will re-announce.
159
+ useEffect(() => {
160
+ if (isOpen && selectionAnnouncement) setSelectionAnnouncement('');
161
+ }, [isOpen, selectionAnnouncement]);
162
+
153
163
  useEffect(() => {
154
164
  const matchingIndexes = options.reduce<number[]>(
155
165
  (matches, option, index) => {
@@ -410,7 +420,9 @@ const CustomSelect = <T extends string | number>({
410
420
  ? visibleOptionIndexes[highlightedVisibleIndex]
411
421
  : null;
412
422
  const activeDescendantId =
413
- isOpen && highlightedOptionSourceIndex !== null
423
+ isOpen &&
424
+ effectiveActiveOptionIndex !== null &&
425
+ highlightedOptionSourceIndex !== null
414
426
  ? `${idBase}-option-${highlightedOptionSourceIndex}`
415
427
  : undefined;
416
428
 
@@ -457,12 +469,21 @@ const CustomSelect = <T extends string | number>({
457
469
  if (selectionBehaviour === 'commit' && visibleOptions.length > 0) {
458
470
  const currentOptionIndex = getCurrentOptionIndex();
459
471
  if (currentOptionIndex !== null) {
472
+ const committedOption = visibleOptions[currentOptionIndex];
460
473
  const currentSourceIndex = visibleOptionIndexes[currentOptionIndex];
461
474
  setSelectedOptionIndex(currentSourceIndex);
462
- onValueChange?.(visibleOptions[currentOptionIndex].value, event);
475
+ onValueChange?.(committedOption.value, event);
463
476
  setFilterText('');
477
+ // Announce the committed selection; the filter input (which carried the
478
+ // description) unmounts when the panel closes, so this live region is
479
+ // what confirms the choice to assistive tech.
480
+ setSelectionAnnouncement(`${committedOption.label} selected`);
464
481
  }
465
482
  closePanel();
483
+ // Return focus to the combobox so the screen reader stays on the control
484
+ // instead of falling back to the document/page title.
485
+ skipOpenOnFocusRef.current = true;
486
+ effectiveRef.current?.focus();
466
487
  return;
467
488
  }
468
489
 
@@ -581,8 +602,39 @@ const CustomSelect = <T extends string | number>({
581
602
  color: ${theme.color.text.secondary};
582
603
  `;
583
604
 
605
+ // Visually hidden but available to assistive technology
606
+ const visuallyHiddenStyle = css`
607
+ position: absolute;
608
+ width: 1px;
609
+ height: 1px;
610
+ padding: 0;
611
+ margin: -1px;
612
+ overflow: hidden;
613
+ clip: rect(0, 0, 0, 0);
614
+ white-space: nowrap;
615
+ border: 0;
616
+ `;
617
+
584
618
  const style = cx(NAME, baseStyle, disabled && disabledStyle, className);
585
619
 
620
+ // Forward the combobox's accessible name/description onto the filter input so
621
+ // assistive tech announces the field label (and current selection) while filtering.
622
+ const filterInputA11yProps = {
623
+ ...(props['aria-label'] !== undefined
624
+ ? { 'aria-label': props['aria-label'] }
625
+ : {}),
626
+ ...(props['aria-labelledby'] !== undefined
627
+ ? { 'aria-labelledby': props['aria-labelledby'] }
628
+ : {}),
629
+ 'aria-describedby':
630
+ [
631
+ props['aria-describedby'],
632
+ selectedOption ? selectionStatusId : undefined,
633
+ ]
634
+ .filter(Boolean)
635
+ .join(' ') || undefined,
636
+ };
637
+
586
638
  return (
587
639
  <div
588
640
  onClick={handleClick}
@@ -622,10 +674,26 @@ const CustomSelect = <T extends string | number>({
622
674
  ariaControls={listboxId}
623
675
  ariaExpanded={isOpen}
624
676
  ariaActiveDescendant={activeDescendantId}
677
+ {...filterInputA11yProps}
625
678
  {...filterInputProps}
626
679
  />
627
680
  )}
628
681
  </VisibleField>
682
+ {filterable && selectedOption && (
683
+ <span
684
+ id={selectionStatusId}
685
+ className={visuallyHiddenStyle}
686
+ >
687
+ {`${selectedOption.label} selected`}
688
+ </span>
689
+ )}
690
+ <span
691
+ role='status'
692
+ aria-live='polite'
693
+ className={visuallyHiddenStyle}
694
+ >
695
+ {selectionAnnouncement}
696
+ </span>
629
697
  {isOpen && (
630
698
  <Overlay
631
699
  reference={effectiveRef}
@@ -663,7 +731,11 @@ const CustomSelect = <T extends string | number>({
663
731
  onSelect={handleSelect}
664
732
  lineBreak={lineBreak}
665
733
  role='option'
666
- aria-selected={highlightedVisibleIndex === index}
734
+ aria-selected={
735
+ effectiveActiveOptionIndex !== null
736
+ ? highlightedVisibleIndex === index
737
+ : undefined
738
+ }
667
739
  aria-posinset={index + 1}
668
740
  aria-setsize={visibleOptions.length}
669
741
  {...option.optionProps}
@@ -75,13 +75,13 @@ const FilterInput = ({
75
75
  onBlur={onBlur}
76
76
  onClick={(e) => e.stopPropagation()}
77
77
  onKeyDown={(e) => handleOnKeyDown(e)}
78
- aria-label='Filter options'
79
78
  aria-autocomplete='list'
80
79
  role='searchbox'
81
80
  aria-haspopup='listbox'
82
81
  aria-controls={ariaControls}
83
82
  aria-expanded={ariaExpanded}
84
83
  aria-activedescendant={ariaActiveDescendant}
84
+ aria-label='Filter options'
85
85
  {...rest}
86
86
  />
87
87
  );
@@ -0,0 +1,94 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { css } from '@emotion/css';
3
+ import Sidebar from './Sidebar';
4
+ import Icon from '../Icon';
5
+ import Menu from '../MenuNew';
6
+
7
+ const meta = {
8
+ title: 'Components/Sidebar',
9
+ component: Sidebar,
10
+ parameters: {
11
+ layout: 'fullscreen',
12
+ },
13
+ argTypes: {
14
+ collapsed: { control: 'boolean' },
15
+ defaultCollapsed: { control: 'boolean' },
16
+ onCollapsedChange: { action: 'collapsed changed' },
17
+ testId: { control: { type: 'text' } },
18
+ className: { control: false },
19
+ children: { control: false },
20
+ },
21
+ args: {
22
+ defaultCollapsed: false,
23
+ },
24
+ tags: ['autodocs'],
25
+ } satisfies Meta<typeof Sidebar>;
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof meta>;
29
+
30
+ const pageStyle = css`
31
+ min-height: 100vh;
32
+ margin-left: 254px;
33
+ padding: 32px;
34
+ font-family: sans-serif;
35
+ background: #f5f7fa;
36
+ `;
37
+
38
+ const storyContent = (
39
+ <Menu>
40
+ <Menu.Section>
41
+ <Menu.Heading>Application</Menu.Heading>
42
+ <Menu.PrimaryItem
43
+ icon={<Icon.Home size={20} />}
44
+ active
45
+ >
46
+ Home
47
+ </Menu.PrimaryItem>
48
+ <Menu.PrimaryItem icon={<Icon.Search size={20} />}>
49
+ Search
50
+ </Menu.PrimaryItem>
51
+ <Menu.PrimaryItem icon={<Icon.Calendar size={20} />}>
52
+ Calendar
53
+ </Menu.PrimaryItem>
54
+ <Menu.PrimaryItem icon={<Icon.Settings size={20} />}>
55
+ Settings
56
+ </Menu.PrimaryItem>
57
+ </Menu.Section>
58
+ <Menu.Divider />
59
+ <Menu.Section>
60
+ <Menu.PrimaryItem icon={<Icon.HelpCircle size={20} />}>
61
+ Support
62
+ </Menu.PrimaryItem>
63
+ </Menu.Section>
64
+ </Menu>
65
+ );
66
+
67
+ export const Default: Story = {
68
+ render: (args) => (
69
+ <>
70
+ <Sidebar {...args}>{storyContent}</Sidebar>
71
+ <main className={pageStyle}>
72
+ <h1>Sidebar example</h1>
73
+ <p>
74
+ The sidebar stays fixed to the left and remains visible when
75
+ collapsed.
76
+ </p>
77
+ </main>
78
+ </>
79
+ ),
80
+ };
81
+
82
+ export const Collapsed: Story = {
83
+ args: {
84
+ defaultCollapsed: true,
85
+ },
86
+ render: (args) => (
87
+ <>
88
+ <Sidebar {...args}>{storyContent}</Sidebar>
89
+ <main className={pageStyle}>
90
+ <h1>Collapsed sidebar example</h1>
91
+ </main>
92
+ </>
93
+ ),
94
+ };
@@ -0,0 +1,208 @@
1
+ import {
2
+ Children,
3
+ HTMLAttributes,
4
+ ReactElement,
5
+ cloneElement,
6
+ memo,
7
+ useId,
8
+ useState,
9
+ } from 'react';
10
+ import { css, cx } from '@emotion/css';
11
+ import Icon from '../Icon';
12
+ import IconButton from '../IconButton';
13
+ import { useTheme } from '../../theme';
14
+ import {
15
+ DEFAULT_Z_INDEX as HEADER_Z_INDEX,
16
+ HEADER_MOBILE_HEIGHT_PX,
17
+ HEADER_TABLET_HEIGHT_PX,
18
+ } from '../HeaderNew';
19
+
20
+ export const NAME = 'ucl-uikit-sidebar';
21
+
22
+ export interface SidebarProps extends HTMLAttributes<HTMLElement> {
23
+ collapsed?: boolean;
24
+ defaultCollapsed?: boolean;
25
+ onCollapsedChange?: (collapsed: boolean) => void;
26
+ testId?: string;
27
+ collapseButtonAriaLabel?: string;
28
+ expandButtonAriaLabel?: string;
29
+ }
30
+
31
+ const Sidebar = ({
32
+ collapsed,
33
+ defaultCollapsed = false,
34
+ onCollapsedChange,
35
+ testId = NAME,
36
+ collapseButtonAriaLabel = 'Collapse sidebar',
37
+ expandButtonAriaLabel = 'Expand sidebar',
38
+ className,
39
+ children,
40
+ ...props
41
+ }: SidebarProps) => {
42
+ const [theme] = useTheme();
43
+ const sidebarId = useId();
44
+ const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
45
+ const isCollapsed = collapsed ?? internalCollapsed;
46
+
47
+ const handleToggle = () => {
48
+ const nextCollapsed = !isCollapsed;
49
+
50
+ if (collapsed === undefined) {
51
+ setInternalCollapsed(nextCollapsed);
52
+ }
53
+
54
+ onCollapsedChange?.(nextCollapsed);
55
+ };
56
+
57
+ const baseStyle = css`
58
+ position: fixed;
59
+ inset: ${HEADER_MOBILE_HEIGHT_PX}px auto 0 0;
60
+ z-index: ${HEADER_Z_INDEX - 1};
61
+ box-sizing: border-box;
62
+ width: min(254px, 100vw);
63
+ padding: ${theme.padding.p24} ${theme.padding.p12};
64
+ display: flex;
65
+ flex-direction: column;
66
+ overflow: hidden;
67
+ background-color: ${theme.colour.surface.primary};
68
+ border-right: ${theme.border.b1} solid ${theme.colour.border.subtle};
69
+ color: ${theme.colour.text.default};
70
+ font-family: ${theme.font.family.primary};
71
+ transition:
72
+ width 160ms ease,
73
+ padding 160ms ease;
74
+
75
+ @media screen and (min-width: ${theme.breakpoints.tablet}px) {
76
+ width: 254px;
77
+ }
78
+
79
+ @media screen and (min-width: ${theme.breakpoints.desktop}px) {
80
+ inset: ${HEADER_TABLET_HEIGHT_PX}px auto 0 0;
81
+ }
82
+ `;
83
+
84
+ const collapsedStyle = css`
85
+ width: 64px;
86
+ padding: ${theme.padding.p16} ${theme.padding.p8};
87
+
88
+ @media screen and (min-width: ${theme.breakpoints.tablet}px) {
89
+ width: 64px;
90
+ }
91
+ `;
92
+
93
+ const menuBaseStyle = css`
94
+ width: calc(100% + ${theme.padding.p24});
95
+ margin-left: -${theme.padding.p12};
96
+ box-sizing: border-box;
97
+ flex: 1 1 auto;
98
+ min-height: 0;
99
+ display: flex;
100
+ flex-direction: column;
101
+ align-items: stretch;
102
+ overflow-x: hidden;
103
+ overflow-y: auto;
104
+ background-color: transparent;
105
+ padding: 0 ${theme.padding.p12} !important;
106
+ `;
107
+
108
+ const menuCollapsedStyle = css`
109
+ width: calc(100% + ${theme.padding.p16});
110
+ margin-left: -${theme.padding.p8};
111
+ padding: 4px ${theme.padding.p8} 0 !important;
112
+ `;
113
+
114
+ const footerBaseStyle = css`
115
+ flex: 0 0 auto;
116
+ margin-top: auto;
117
+ padding-top: ${theme.padding.p16};
118
+ background-color: ${theme.colour.surface.primary};
119
+ display: flex;
120
+ justify-content: flex-end;
121
+ `;
122
+
123
+ const footerCollapsedStyle = css`
124
+ justify-content: center;
125
+ `;
126
+
127
+ const toggleStyle = css`
128
+ width: 32px;
129
+ height: 32px;
130
+ border-radius: 4px;
131
+ display: inline-flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ color: ${theme.colour.icon.default};
135
+
136
+ &:hover {
137
+ background-color: ${theme.colour.fill.brandSubtleHover};
138
+ color: ${theme.colour.icon.brandHover};
139
+ }
140
+ `;
141
+
142
+ const style = cx(NAME, baseStyle, isCollapsed && collapsedStyle, className);
143
+ const menuStyle = cx(menuBaseStyle, isCollapsed && menuCollapsedStyle);
144
+ const footerStyle = cx(footerBaseStyle, isCollapsed && footerCollapsedStyle);
145
+ const menu = cloneSidebarMenu(
146
+ Children.only(children) as ReactElement<SidebarChildProps>,
147
+ {
148
+ collapsed: isCollapsed,
149
+ className: menuStyle,
150
+ id: sidebarId,
151
+ }
152
+ );
153
+
154
+ return (
155
+ <aside
156
+ className={style}
157
+ data-testid={testId}
158
+ data-collapsed={isCollapsed}
159
+ {...props}
160
+ >
161
+ {menu}
162
+ <div className={footerStyle}>
163
+ <IconButton
164
+ type='button'
165
+ className={toggleStyle}
166
+ aria-controls={sidebarId}
167
+ aria-expanded={!isCollapsed}
168
+ aria-label={
169
+ isCollapsed ? expandButtonAriaLabel : collapseButtonAriaLabel
170
+ }
171
+ onClick={handleToggle}
172
+ >
173
+ {isCollapsed ? (
174
+ <Icon.ChevronsRight
175
+ size={20}
176
+ aria-hidden='true'
177
+ focusable='false'
178
+ />
179
+ ) : (
180
+ <Icon.ChevronsLeft
181
+ size={20}
182
+ aria-hidden='true'
183
+ focusable='false'
184
+ />
185
+ )}
186
+ </IconButton>
187
+ </div>
188
+ </aside>
189
+ );
190
+ };
191
+
192
+ interface SidebarChildProps {
193
+ collapsed?: boolean;
194
+ className?: string;
195
+ id?: string;
196
+ }
197
+
198
+ const cloneSidebarMenu = (
199
+ child: ReactElement<SidebarChildProps>,
200
+ props: Required<SidebarChildProps>
201
+ ) =>
202
+ cloneElement(child, {
203
+ id: props.id,
204
+ collapsed: props.collapsed,
205
+ className: cx(props.className, child.props.className),
206
+ });
207
+
208
+ export default memo(Sidebar);
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test, vitest } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import Sidebar from '../Sidebar';
5
+ import Icon from '../../Icon';
6
+ import Menu from '../../MenuNew';
7
+ import { ThemeContextProvider } from '../../../theme/useTheme';
8
+
9
+ const renderSidebar = (props = {}) =>
10
+ render(
11
+ <ThemeContextProvider>
12
+ <Sidebar {...props}>
13
+ <Menu>
14
+ <Menu.Section>
15
+ <Menu.Heading>Application</Menu.Heading>
16
+ <Menu.PrimaryItem
17
+ icon={<Icon.Home />}
18
+ active
19
+ >
20
+ Home
21
+ </Menu.PrimaryItem>
22
+ <Menu.PrimaryItem icon={<Icon.Search />}>Search</Menu.PrimaryItem>
23
+ </Menu.Section>
24
+ </Menu>
25
+ </Sidebar>
26
+ </ThemeContextProvider>
27
+ );
28
+
29
+ describe('Sidebar', () => {
30
+ test('snapshot: no props', () => {
31
+ const renderResult = renderSidebar();
32
+ expect(renderResult.container.firstChild).toMatchSnapshot();
33
+ });
34
+
35
+ test('snapshot: defaultCollapsed prop true', () => {
36
+ const renderResult = renderSidebar({ defaultCollapsed: true });
37
+ expect(renderResult.container.firstChild).toMatchSnapshot();
38
+ });
39
+
40
+ test('Can find by default testId', () => {
41
+ renderSidebar();
42
+ const sidebar = screen.getByTestId('ucl-uikit-sidebar');
43
+ expect(sidebar).toBeInTheDocument();
44
+ });
45
+
46
+ test('Can find by custom testId', () => {
47
+ renderSidebar({ testId: 'custom-sidebar' });
48
+ const sidebar = screen.getByTestId('custom-sidebar');
49
+ expect(sidebar).toBeInTheDocument();
50
+ });
51
+
52
+ test('Toggles collapsed state', async () => {
53
+ const user = userEvent.setup();
54
+ renderSidebar();
55
+
56
+ const sidebar = screen.getByTestId('ucl-uikit-sidebar');
57
+ const toggle = screen.getByRole('button', { name: 'Collapse sidebar' });
58
+
59
+ expect(sidebar).toHaveAttribute('data-collapsed', 'false');
60
+ await user.click(toggle);
61
+ expect(sidebar).toHaveAttribute('data-collapsed', 'true');
62
+ expect(
63
+ screen.getByRole('button', { name: 'Expand sidebar' })
64
+ ).toBeInTheDocument();
65
+ });
66
+
67
+ test('Calls onCollapsedChange', async () => {
68
+ const user = userEvent.setup();
69
+ const onCollapsedChange = vitest.fn();
70
+ renderSidebar({ onCollapsedChange });
71
+
72
+ await user.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
73
+ expect(onCollapsedChange).toHaveBeenCalledWith(true);
74
+ });
75
+
76
+ test('Supports controlled collapsed state', async () => {
77
+ const user = userEvent.setup();
78
+ const onCollapsedChange = vitest.fn();
79
+ renderSidebar({
80
+ collapsed: false,
81
+ onCollapsedChange,
82
+ });
83
+
84
+ const sidebar = screen.getByTestId('ucl-uikit-sidebar');
85
+ await user.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
86
+
87
+ expect(onCollapsedChange).toHaveBeenCalledWith(true);
88
+ expect(sidebar).toHaveAttribute('data-collapsed', 'false');
89
+ });
90
+
91
+ test('Marks the active item as the current page', () => {
92
+ renderSidebar();
93
+ const activeItem = screen.getByText('Home').closest('[aria-current]');
94
+ expect(activeItem).toHaveAttribute('aria-current', 'page');
95
+ });
96
+ });