uikit-react-public 0.30.1 → 0.32.4
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/components/Heading/Heading.stories.d.ts +5 -7
- package/dist/components/MenuNew/Menu.context.d.ts +9 -0
- package/dist/components/MenuNew/Menu.d.ts +2 -1
- package/dist/components/MenuNew/MenuHeading.d.ts +1 -1
- package/dist/components/MenuNew/MenuItemText.d.ts +2 -1
- package/dist/components/MenuNew/PrimaryMenuItem.d.ts +1 -1
- package/dist/components/Select/Select.types.d.ts +1 -1
- package/dist/components/Sidebar/Sidebar.d.ts +12 -0
- package/dist/components/Sidebar/Sidebar.stories.d.ts +38 -0
- package/dist/components/Sidebar/__tests__/Sidebar.test.d.ts +1 -0
- package/dist/components/Sidebar/index.d.ts +2 -0
- package/dist/components/Snackbar/Snackbar.d.ts +9 -5
- package/dist/components/Snackbar/Snackbar.stories.d.ts +3 -1
- package/dist/components/Snackbar/Snackbars.d.ts +19 -0
- package/dist/components/Snackbar/Snackbars.stories.d.ts +52 -0
- package/dist/components/Snackbar/__tests__/Snackbars.test.d.ts +1 -0
- package/dist/components/Snackbar/index.d.ts +2 -0
- package/dist/components/index.d.ts +4 -1
- package/dist/index.js +5979 -5448
- package/lib/components/Dropdown/Dropdown.stories.tsx +69 -74
- package/lib/components/Heading/Documentation.mdx +4 -14
- package/lib/components/Heading/Heading.stories.tsx +16 -30
- package/lib/components/MenuNew/Menu.context.tsx +18 -0
- package/lib/components/MenuNew/Menu.tsx +14 -9
- package/lib/components/MenuNew/MenuDivider.tsx +12 -1
- package/lib/components/MenuNew/MenuHeading.tsx +6 -0
- package/lib/components/MenuNew/MenuItemText.tsx +27 -3
- package/lib/components/MenuNew/MenuSection.tsx +11 -0
- package/lib/components/MenuNew/PrimaryMenuItem.tsx +71 -2
- package/lib/components/Select/Select.types.ts +1 -7
- package/lib/components/Select/__tests__/Select.test.tsx +112 -0
- package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +5 -0
- package/lib/components/Select/subcomponents/CustomSelect.tsx +75 -3
- package/lib/components/Select/subcomponents/FilterInput.tsx +1 -1
- package/lib/components/Sidebar/Sidebar.stories.tsx +94 -0
- package/lib/components/Sidebar/Sidebar.tsx +208 -0
- package/lib/components/Sidebar/__tests__/Sidebar.test.tsx +96 -0
- package/lib/components/Sidebar/__tests__/__snapshots__/Sidebar.test.tsx.snap +272 -0
- package/lib/components/Sidebar/index.ts +2 -0
- package/lib/components/Snackbar/Snackbar.stories.tsx +22 -0
- package/lib/components/Snackbar/Snackbar.tsx +168 -119
- package/lib/components/Snackbar/Snackbars.stories.tsx +141 -0
- package/lib/components/Snackbar/Snackbars.tsx +377 -0
- package/lib/components/Snackbar/__tests__/Snackbar.test.tsx +222 -10
- package/lib/components/Snackbar/__tests__/Snackbars.test.tsx +377 -0
- package/lib/components/Snackbar/__tests__/__snapshots__/Snackbar.test.tsx.snap +46 -25
- package/lib/components/Snackbar/index.ts +6 -0
- package/lib/components/index.ts +10 -1
- 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>
|
|
@@ -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 &&
|
|
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?.(
|
|
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={
|
|
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
|
+
});
|