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.
- package/dist/components/Dialog/BaseDialog.d.ts +1 -1
- package/dist/components/Dialog/Dialog.d.ts +1 -1
- package/dist/components/Dialog/Dialog.stories.d.ts +1 -1
- 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 +2 -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 +5897 -5358
- package/dist/utils/announce.d.ts +2 -1
- package/lib/components/Dialog/BaseDialog.tsx +180 -161
- package/lib/components/Dialog/Dialog.tsx +15 -11
- 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 +11 -0
- package/lib/components/Snackbar/Snackbar.tsx +162 -118
- package/lib/components/Snackbar/Snackbars.stories.tsx +130 -0
- package/lib/components/Snackbar/Snackbars.tsx +377 -0
- package/lib/components/Snackbar/__tests__/Snackbar.test.tsx +104 -0
- package/lib/components/Snackbar/__tests__/Snackbars.test.tsx +377 -0
- package/lib/components/Snackbar/__tests__/__snapshots__/Snackbar.test.tsx.snap +36 -19
- package/lib/components/Snackbar/index.ts +6 -0
- package/lib/components/index.ts +10 -1
- package/lib/utils/__tests__/announce.test.ts +53 -0
- package/lib/utils/announce.ts +33 -10
- 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 &&
|
|
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
|
+
});
|