Skip to content

Commit f69e8cc

Browse files
ECHOES-1032 Align icons in sidebar collapsed mode (#599)
Co-authored-by: Grégoire Aubert <[email protected]>
1 parent 2b2539b commit f69e8cc

File tree

7 files changed

+109
-27
lines changed

7 files changed

+109
-27
lines changed

src/components/layout/sidebar-navigation/SidebarNavigation.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const SidebarNavigationWrapper = styled.nav`
9999
100100
--sidebar-navigation-width: ${cssVar('layout-sidebar-navigation-sizes-width-open')};
101101
102-
// hover and focus-within pilotes the open state of the sidebar
102+
// hover and focus-within pilots the open state of the sidebar
103103
[data-sidebar-docked='false'] &:not(:hover, :focus-within) {
104104
--sidebar-navigation-width: ${cssVar('layout-sidebar-navigation-sizes-width-closed')};
105105
}

src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -163,41 +163,48 @@ const AccordionItem = styled.button`
163163
AccordionItem.displayName = 'AccordionItem';
164164

165165
const AccordionItemPanel = styled.section`
166-
margin-left: ${cssVar('dimension-space-300')};
166+
margin-left: ${cssVar('dimension-space-200')};
167167
padding-left: ${cssVar('dimension-space-100')};
168168
padding-right: ${cssVar('dimension-space-100')};
169169
border-left: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')};
170170
171171
// The children SidebarNavigationItems rely on this css property to set their display value, falling
172172
// back to flex if not inside an accordion
173173
--sidebar-navigation-accordion-children-display: flex;
174-
--sidebar-navigation-accordion-children-gap: ${cssVar('dimension-space-50')};
174+
--sidebar-navigation-accordion-children-visibility: visible;
175175
176-
// The next two rules hide the child SidebarNavigationItems when the accordion is closed, and also
177-
// when the sidebar is neither docked nor open (hovered or focused).
176+
// The rule hide the child SidebarNavigationItems when the accordion is closed
178177
&[data-accordion-open='false'] {
179178
--sidebar-navigation-accordion-children-display: none;
180-
--sidebar-navigation-accordion-children-gap: ${cssVar('dimension-space-0')};
181179
}
182180
181+
// This rule hides the SidebarNavigationItems when the accordion is open but the sidebar is collapsed
182+
// using visibility to make sure it still takes space to avoid layout shift when hovering the sidebar
183183
[data-sidebar-docked='false'] nav:not(:hover, :focus-within) & {
184-
margin: 0;
185-
padding: 0;
186-
border-left: none;
187-
188-
--sidebar-navigation-accordion-children-display: none;
189-
--sidebar-navigation-accordion-children-gap: ${cssVar('dimension-space-0')};
184+
--sidebar-navigation-accordion-children-visibility: hidden;
185+
--sidebar-navigation-accordion-children-outline: ${cssVar('color-surface-default')} solid
186+
${cssVar('focus-border-width-default')};
190187
}
191188
`;
192189

193190
AccordionItemPanel.displayName = 'AccordionItemPanel';
194191

195-
export const AccordionItemsList = styled.ul`
192+
const AccordionItemsList = styled.ul`
196193
all: unset;
197194
198195
display: flex;
199196
flex-direction: column;
200-
gap: var(--sidebar-navigation-accordion-children-gap);
197+
gap: ${cssVar('dimension-space-50')};
198+
199+
[data-accordion-open='false'] & {
200+
// Override the gap to avoid extra space that shows the left border when the accordion is closed
201+
gap: ${cssVar('dimension-space-0')};
202+
}
203+
204+
[data-sidebar-docked='false'] nav:not(:hover, :focus-within) & {
205+
margin-left: calc(-1 * ${cssVar('dimension-space-300')});
206+
width: ${cssVar('dimension-width-400')};
207+
}
201208
`;
202209

203210
AccordionItemsList.displayName = 'AccordionItemsList';

src/components/layout/sidebar-navigation/SidebarNavigationBody.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ SidebarNavigationBodyInner.displayName = 'SidebarNavigationBodyInner';
8282

8383
const SidebarNavigationBottomShadowScroll = styled(BottomShadowScroll)`
8484
[data-sidebar-docked='false'] nav:not(:hover, :focus-within) & {
85-
display: none;
85+
opacity: 0.5;
8686
}
8787
`;
8888
SidebarNavigationBottomShadowScroll.displayName = 'SidebarNavigationBottomShadowScroll';

src/components/layout/sidebar-navigation/SidebarNavigationGroup.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import styled from '@emotion/styled';
2222
import { forwardRef, PropsWithChildren, useId } from 'react';
2323
import { TextNode } from '~types/utils';
2424
import { cssVar } from '~utils/design-tokens';
25+
import { Divider } from '../../divider';
2526
import { Text } from '../../typography';
2627
import { UnstyledListItem } from './SidebarNavigationItemStyles';
2728

@@ -52,9 +53,11 @@ export const SidebarNavigationGroup = forwardRef<
5253
role="group"
5354
{...radixProps}>
5455
<SidebarNavigationGroupLabel id={id}>
55-
<Text isSubtle size="small">
56+
<SidebarNavigationGroupLabelText isSubtle size="small">
5657
{label}
57-
</Text>
58+
</SidebarNavigationGroupLabelText>
59+
60+
<SidebarNavigationGroupLabelDivider />
5861
</SidebarNavigationGroupLabel>
5962
<SidebarNavigationGroupList>{children}</SidebarNavigationGroupList>
6063
</SidebarNavigationGroupContainer>
@@ -87,12 +90,24 @@ const SidebarNavigationGroupLabel = styled.label`
8790
align-items: center;
8891
height: ${cssVar('dimension-height-800')};
8992
padding: 0 ${cssVar('dimension-space-100')};
93+
white-space: nowrap;
94+
`;
95+
SidebarNavigationGroupLabel.displayName = 'SidebarNavigationGroupLabel';
9096

97+
const SidebarNavigationGroupLabelText = styled(Text)`
9198
[data-sidebar-docked='false'] nav:not(:hover, :focus-within) & {
9299
display: none;
93100
}
94101
`;
95-
SidebarNavigationGroupLabel.displayName = 'SidebarNavigationGroupLabel';
102+
SidebarNavigationGroupLabelText.displayName = 'SidebarNavigationGroupLabelText';
103+
104+
const SidebarNavigationGroupLabelDivider = styled(Divider)`
105+
[data-sidebar-docked='true'] &,
106+
[data-sidebar-docked='false'] nav:is(:hover, :focus-within) & {
107+
display: none;
108+
}
109+
`;
110+
SidebarNavigationGroupLabelDivider.displayName = 'SidebarNavigationGroupLabelDivider';
96111

97112
export const SidebarNavigationGroupList = styled.ul`
98113
all: unset;

src/components/layout/sidebar-navigation/SidebarNavigationItem.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,24 @@ SidebarNavigationItem.displayName = 'SidebarNavigationItem';
135135
const NavigationItem = styled(NavLinkBase)`
136136
${sidebarNavigationBaseItemStyles}
137137
138-
// When the item is inside an accordion, the display value changes based on the state of the accordion, hiding the items when the sidebar is closed
139-
// This css property is set by the SidebarNavigationAccordionItem component
140-
// Fallback to flex if not inside an accordion
138+
// When the item is inside an accordion, the display/visibility value changes based on the state
139+
// of the accordion, hiding the items when the sidebar is closed.
140+
// These css properties are set by the SidebarNavigationAccordionItem component
141+
// Fallback to flex/visible if not inside an accordion
141142
display: var(--sidebar-navigation-accordion-children-display, flex);
143+
visibility: var(--sidebar-navigation-accordion-children-visibility, visible);
144+
145+
// Outline provided by the accordion item when the sidebar is collapsed, it's only visible for the
146+
// active element, but we don't put it inside the active rule to make sure it doesn't have higher
147+
// specificity than the outline provided by the sidebarNavigationBaseItemStyles on focus.
148+
// Also it doesn't matter if it's present when not active since non active items are not visible anyway.
149+
outline: var(--sidebar-navigation-accordion-children-outline);
142150
143151
&:active,
144152
&.active {
145153
// Always display the item when active even if behind a closed accordion, this overrides the previously set display value from the css property
146154
display: flex;
155+
visibility: visible;
147156
148157
background-color: ${cssVar('sidebar-navigation-item-colors-background-active')};
149158
color: ${cssVar('color-text-accent')};

src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe('ellipsis behavior', () => {
8080
});
8181

8282
describe('integration with SidebarNavigationItem', () => {
83-
it('should have active class and rely on the css property passed set form the accordion', () => {
83+
it('should set CSS custom properties and active class on children', () => {
8484
setupSidebarNavigationAccordionItem({
8585
children: (
8686
<>
@@ -94,17 +94,42 @@ describe('integration with SidebarNavigationItem', () => {
9494
),
9595
});
9696

97-
expect(screen.getByRole('link', { name: 'Sub Item 1' })).toHaveStyleRule(
97+
const subItem1 = screen.getByRole('link', { name: 'Sub Item 1' });
98+
const subItem2 = screen.getByRole('link', { name: 'Sub Item 2' });
99+
100+
// Check active class
101+
expect(subItem1).toHaveClass('active');
102+
expect(subItem2).not.toHaveClass('active');
103+
104+
// Check that display CSS custom property is set
105+
expect(subItem1).toHaveStyleRule(
98106
'display',
99107
'var(--sidebar-navigation-accordion-children-display, flex)',
100108
);
101-
expect(screen.getByRole('link', { name: 'Sub Item 1' })).toHaveClass('active');
102-
103-
expect(screen.queryByRole('link', { name: 'Sub Item 2' })).toHaveStyleRule(
109+
expect(subItem2).toHaveStyleRule(
104110
'display',
105111
'var(--sidebar-navigation-accordion-children-display, flex)',
106112
);
107-
expect(screen.getByRole('link', { name: 'Sub Item 2' })).not.toHaveClass('active');
113+
114+
// Check that visibility CSS custom property is set
115+
expect(subItem1).toHaveStyleRule(
116+
'visibility',
117+
'var(--sidebar-navigation-accordion-children-visibility, visible)',
118+
);
119+
expect(subItem2).toHaveStyleRule(
120+
'visibility',
121+
'var(--sidebar-navigation-accordion-children-visibility, visible)',
122+
);
123+
124+
// Check that outline CSS custom property is set
125+
expect(subItem1).toHaveStyleRule(
126+
'outline',
127+
'var(--sidebar-navigation-accordion-children-outline)',
128+
);
129+
expect(subItem2).toHaveStyleRule(
130+
'outline',
131+
'var(--sidebar-navigation-accordion-children-outline)',
132+
);
108133
});
109134
});
110135

src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
*/
2020

21+
import { matchers } from '@emotion/jest';
2122
import { screen } from '@testing-library/react';
2223
import { renderWithMemoryRouter } from '~common/helpers/test-utils';
2324
import { IconBranch, IconClock } from '../../../icons';
2425
import { SidebarNavigationItem, SidebarNavigationItemProps } from '../SidebarNavigationItem';
2526

27+
expect.extend(matchers);
28+
2629
it('should handle onClick events', async () => {
2730
const handleClick = jest.fn();
2831
const { user } = setupSidebarNavigationItem({ onClick: handleClick });
@@ -91,6 +94,29 @@ describe('active state behavior', () => {
9194
});
9295
});
9396

97+
describe('CSS custom properties for accordion integration', () => {
98+
it('should use CSS custom properties for display, visibility and outline', () => {
99+
setupSidebarNavigationItem();
100+
101+
const link = screen.getByRole('link');
102+
103+
// Check that display CSS custom property is used with fallback
104+
expect(link).toHaveStyleRule(
105+
'display',
106+
'var(--sidebar-navigation-accordion-children-display, flex)',
107+
);
108+
109+
// Check that visibility CSS custom property is used with fallback
110+
expect(link).toHaveStyleRule(
111+
'visibility',
112+
'var(--sidebar-navigation-accordion-children-visibility, visible)',
113+
);
114+
115+
// Check that outline CSS custom property is used (no fallback needed)
116+
expect(link).toHaveStyleRule('outline', 'var(--sidebar-navigation-accordion-children-outline)');
117+
});
118+
});
119+
94120
it("shouldn't have any a11y violation", async () => {
95121
const { container } = setupSidebarNavigationItem({ Icon: IconBranch });
96122
await expect(container).toHaveNoA11yViolations();

0 commit comments

Comments
 (0)