uikit-react-public 0.30.0 → 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 (57) hide show
  1. package/dist/components/Dialog/BaseDialog.d.ts +1 -1
  2. package/dist/components/Dialog/Dialog.d.ts +1 -1
  3. package/dist/components/Dialog/Dialog.stories.d.ts +1 -1
  4. package/dist/components/Heading/Heading.stories.d.ts +5 -7
  5. package/dist/components/MenuNew/Menu.context.d.ts +9 -0
  6. package/dist/components/MenuNew/Menu.d.ts +2 -1
  7. package/dist/components/MenuNew/MenuHeading.d.ts +1 -1
  8. package/dist/components/MenuNew/MenuItemText.d.ts +2 -1
  9. package/dist/components/MenuNew/PrimaryMenuItem.d.ts +1 -1
  10. package/dist/components/Select/Select.types.d.ts +1 -1
  11. package/dist/components/Sidebar/Sidebar.d.ts +12 -0
  12. package/dist/components/Sidebar/Sidebar.stories.d.ts +38 -0
  13. package/dist/components/Sidebar/__tests__/Sidebar.test.d.ts +1 -0
  14. package/dist/components/Sidebar/index.d.ts +2 -0
  15. package/dist/components/Snackbar/Snackbar.d.ts +9 -5
  16. package/dist/components/Snackbar/Snackbar.stories.d.ts +2 -1
  17. package/dist/components/Snackbar/Snackbars.d.ts +19 -0
  18. package/dist/components/Snackbar/Snackbars.stories.d.ts +52 -0
  19. package/dist/components/Snackbar/__tests__/Snackbars.test.d.ts +1 -0
  20. package/dist/components/Snackbar/index.d.ts +2 -0
  21. package/dist/components/index.d.ts +4 -1
  22. package/dist/index.js +5897 -5358
  23. package/dist/utils/announce.d.ts +2 -1
  24. package/lib/components/Dialog/BaseDialog.tsx +180 -161
  25. package/lib/components/Dialog/Dialog.tsx +15 -11
  26. package/lib/components/Dropdown/Dropdown.stories.tsx +69 -74
  27. package/lib/components/Heading/Documentation.mdx +4 -14
  28. package/lib/components/Heading/Heading.stories.tsx +16 -30
  29. package/lib/components/MenuNew/Menu.context.tsx +18 -0
  30. package/lib/components/MenuNew/Menu.tsx +14 -9
  31. package/lib/components/MenuNew/MenuDivider.tsx +12 -1
  32. package/lib/components/MenuNew/MenuHeading.tsx +6 -0
  33. package/lib/components/MenuNew/MenuItemText.tsx +27 -3
  34. package/lib/components/MenuNew/MenuSection.tsx +11 -0
  35. package/lib/components/MenuNew/PrimaryMenuItem.tsx +71 -2
  36. package/lib/components/Select/Select.types.ts +1 -7
  37. package/lib/components/Select/__tests__/Select.test.tsx +112 -0
  38. package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +5 -0
  39. package/lib/components/Select/subcomponents/CustomSelect.tsx +75 -3
  40. package/lib/components/Select/subcomponents/FilterInput.tsx +1 -1
  41. package/lib/components/Sidebar/Sidebar.stories.tsx +94 -0
  42. package/lib/components/Sidebar/Sidebar.tsx +208 -0
  43. package/lib/components/Sidebar/__tests__/Sidebar.test.tsx +96 -0
  44. package/lib/components/Sidebar/__tests__/__snapshots__/Sidebar.test.tsx.snap +272 -0
  45. package/lib/components/Sidebar/index.ts +2 -0
  46. package/lib/components/Snackbar/Snackbar.stories.tsx +11 -0
  47. package/lib/components/Snackbar/Snackbar.tsx +162 -118
  48. package/lib/components/Snackbar/Snackbars.stories.tsx +130 -0
  49. package/lib/components/Snackbar/Snackbars.tsx +377 -0
  50. package/lib/components/Snackbar/__tests__/Snackbar.test.tsx +104 -0
  51. package/lib/components/Snackbar/__tests__/Snackbars.test.tsx +377 -0
  52. package/lib/components/Snackbar/__tests__/__snapshots__/Snackbar.test.tsx.snap +36 -19
  53. package/lib/components/Snackbar/index.ts +6 -0
  54. package/lib/components/index.ts +10 -1
  55. package/lib/utils/__tests__/announce.test.ts +53 -0
  56. package/lib/utils/announce.ts +33 -10
  57. package/package.json +1 -1
@@ -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
+ });