From 784d7cf449b290866652ea0d790a995c64ffdeea Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Thu, 2 Jul 2026 10:29:30 +0200 Subject: [PATCH 1/5] ECHOES-1408 Add accordion child sidebar navigation item --- .../SidebarNavigationAccordionChildItem.tsx | 42 +++++ .../SidebarNavigationAccordionItem.tsx | 61 ++++--- .../SidebarNavigationBaseItem.tsx | 148 ++++++++++++++++ .../SidebarNavigationItem.tsx | 163 ++---------------- .../SidebarNavigationTypes.ts | 83 +++++++++ .../layout/sidebar-navigation/index.ts | 34 +++- 6 files changed, 346 insertions(+), 185 deletions(-) create mode 100644 src/components/layout/sidebar-navigation/SidebarNavigationAccordionChildItem.tsx create mode 100644 src/components/layout/sidebar-navigation/SidebarNavigationBaseItem.tsx create mode 100644 src/components/layout/sidebar-navigation/SidebarNavigationTypes.ts diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionChildItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionChildItem.tsx new file mode 100644 index 000000000..c08e6cbfb --- /dev/null +++ b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionChildItem.tsx @@ -0,0 +1,42 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { SidebarNavigationBaseItem } from './SidebarNavigationBaseItem'; + +import { + SidebarNavigationIconComponent, + SidebarNavigationItemBaseProps, +} from './SidebarNavigationTypes'; + +export interface SidebarNavigationAccordionChildItemProps extends SidebarNavigationItemBaseProps { + /** + * The icon component to display at the start of the SidebarNavigationAccordionChildItem. + * Must be an Echoes Icon component. Omit it for icon-less accordion child rows. + */ + Icon?: SidebarNavigationIconComponent; +} + +export function SidebarNavigationAccordionChildItem( + props: Readonly, +) { + return ; +} + +SidebarNavigationAccordionChildItem.displayName = 'SidebarNavigationAccordionChildItem'; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx index 8290497f3..d8721d9b9 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx @@ -19,40 +19,48 @@ */ import styled from '@emotion/styled'; -import { - forwardRef, - ForwardRefExoticComponent, - ReactNode, - useCallback, - useEffect, - useId, - useRef, - useState, -} from 'react'; + +import { ReactNode, Ref, useCallback, useEffect, useId, useRef, useState } from 'react'; + import { TextNode } from '~types/utils'; import { cssVar } from '~utils/design-tokens'; -import { IconChevronDown, IconChevronRight, IconFilledProps } from '../../icons'; +import { IconChevronDown, IconChevronRight } from '../../icons'; import { Tooltip } from '../../tooltip'; import { sidebarNavigationBaseItemStyles, sidebarNavigationItemIconStyles, SidebarNavigationItemLabel, } from './SidebarNavigationItemStyles'; + +import { SidebarNavigationIconComponent } from './SidebarNavigationTypes'; import { TOOLTIP_DELAY_IN_MS } from './utils'; export interface SidebarNavigationAccordionItemProps { - ariaLabel?: string; /** - * List of SidebarNavigationItem that are displayed when the accordion is expanded. - * Should ideally be maximum 5 items. + * List of navigation child items displayed when the accordion is expanded. + * Prefer `SidebarNavigation.AccordionItem.Item` and keep the list to five items or fewer. */ children: ReactNode; + /** + * Optional CSS class name applied to the accordion button element. + */ className?: string; /** * Whether to disable the tooltip on the accordion item or not. * By default the tooltip is enabled, it should only be disabled if you don't expect the content to be ellipsed. + * @defaultValue false */ disableTooltip?: boolean; + /** + * The icon component to display at the start of the SidebarNavigationAccordionItem. + * Must be an Echoes Icon component. + */ + Icon: SidebarNavigationIconComponent; + /** + * Whether the accordion is open by default. + * @defaultValue false + */ + isDefaultOpen?: boolean; /** * The label for the SidebarNavigationAccordionItem. */ @@ -66,37 +74,33 @@ export interface SidebarNavigationAccordionItemProps { */ onOpen?: VoidFunction; /** - * Whether the accordion is open by default. Defaults to false. + * React ref forwarded to the root button element. */ - isDefaultOpen?: boolean; + ref?: Ref; /** * When true, scrolls the last child item into view when the accordion opens. * Useful when the accordion is near the bottom of a scrollable container. + * @defaultValue false */ scrollLastChildIntoViewOnOpen?: boolean; /** * Optional content to display on the right, before the chevron. Typically badges, item count and similar metadata. */ suffix?: ReactNode; - /** - * The icon component to display at the start of the SidebarNavigationAccordionItem. - * Must be an Echoes Icon component. - */ - Icon: ForwardRefExoticComponent>; } -export const SidebarNavigationAccordionItem = forwardRef< - HTMLButtonElement, - SidebarNavigationAccordionItemProps ->((props, ref) => { +export function SidebarNavigationAccordionItem( + props: Readonly, +) { const { children, - isDefaultOpen = false, disableTooltip = false, Icon, + isDefaultOpen = false, label, onClose, onOpen, + ref, scrollLastChildIntoViewOnOpen, suffix, ...htmlProps @@ -141,7 +145,8 @@ export const SidebarNavigationAccordionItem = forwardRef< aria-expanded={open} id={accordionId} onClick={handleClick} - ref={ref}> + ref={ref} + type="button"> {label} @@ -165,7 +170,7 @@ export const SidebarNavigationAccordionItem = forwardRef< ); -}); +} SidebarNavigationAccordionItem.displayName = 'SidebarNavigationAccordionItem'; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationBaseItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationBaseItem.tsx new file mode 100644 index 000000000..3268a26b4 --- /dev/null +++ b/src/components/layout/sidebar-navigation/SidebarNavigationBaseItem.tsx @@ -0,0 +1,148 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { MouseEvent, useCallback } from 'react'; + +import { NavLinkBase } from '~common/components/NavLinkBase'; +import { isDefined } from '~common/helpers/types'; +import { cssVar } from '~utils/design-tokens'; +import { Tooltip } from '../../tooltip'; + +import { + sidebarNavigationBaseItemStyles, + sidebarNavigationItemIconStyles, + SidebarNavigationItemLabel, + UnstyledListItem, +} from './SidebarNavigationItemStyles'; + +import { + SidebarNavigationIconComponent, + SidebarNavigationItemBaseProps, +} from './SidebarNavigationTypes'; + +import { TOOLTIP_DELAY_IN_MS } from './utils'; + +interface SidebarNavigationBaseItemInternalProps extends SidebarNavigationItemBaseProps { + Icon?: SidebarNavigationIconComponent; +} + +export function SidebarNavigationBaseItem(props: Readonly) { + const { + ariaLabel, + children, + disableIconWhenSidebarOpen = false, + disableTooltip = false, + Icon, + onClick, + ref, + suffix, + ...htmlProps + } = props; + + const handleClick = useCallback( + (event: MouseEvent) => { + event.currentTarget.blur(); + onClick?.(event); + }, + [onClick], + ); + + return ( + + + + {isDefined(Icon) && ( + + )} + + {children} + + {suffix} + + + + ); +} + +SidebarNavigationBaseItem.displayName = 'SidebarNavigationBaseItem'; + +const SidebarNavigationBaseItemLink = styled(NavLinkBase)` + ${sidebarNavigationBaseItemStyles} + + // When the item is inside an accordion, the display and visibility values change based on the + // accordion state. Outside of accordions, they fall back to flex and visible. + display: var(--sidebar-navigation-accordion-children-display, flex); + visibility: var(--sidebar-navigation-accordion-children-visibility, visible); + + // The accordion provides an outline in closed sidebar mode for the active child item. Keeping it + // outside the active selector avoids overriding the focus outline from the base item styles. + outline: var(--sidebar-navigation-accordion-children-outline); + + &:active, + &.active { + // Active items stay visible even when the parent accordion is closed. + display: flex; + visibility: visible; + + background-color: ${cssVar('sidebar-navigation-item-colors-background-active')}; + color: ${cssVar('color-text-accent')}; + font: ${cssVar('typography-text-default-semi-bold')}; + + &:hover { + background-color: ${cssVar('sidebar-navigation-item-colors-background-hover')}; + } + } +`; + +SidebarNavigationBaseItemLink.displayName = 'SidebarNavigationBaseItemLink'; + +const sidebarNavigationBaseItemIconStyles = css` + ${sidebarNavigationItemIconStyles} + + ${SidebarNavigationBaseItemLink}.active > &, + ${SidebarNavigationBaseItemLink}:active > & { + color: ${cssVar('color-icon-accent')}; + } +`; + +const sidebarNavigationBaseItemHideWhenSidebarOpenStyles = css` + [data-sidebar-docked='true'] &, + [data-sidebar-docked='false'] nav:is(:hover, :focus-within) & { + display: none; + } +`; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationItem.tsx index c5368acd8..618584f45 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationItem.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationItem.tsx @@ -17,165 +17,24 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; -import { - forwardRef, - ForwardRefExoticComponent, - MouseEvent, - MouseEventHandler, - ReactNode, - useCallback, -} from 'react'; -import { NavLinkBase, NavLinkBaseProps } from '~common/components/NavLinkBase'; -import { TextNode } from '~types/utils'; -import { cssVar } from '~utils/design-tokens'; -import { IconFilledProps } from '../../icons'; -import { Tooltip } from '../../tooltip'; + +import { SidebarNavigationBaseItem } from './SidebarNavigationBaseItem'; + import { - sidebarNavigationBaseItemStyles, - sidebarNavigationItemIconStyles, - SidebarNavigationItemLabel, - UnstyledListItem, -} from './SidebarNavigationItemStyles'; -import { TOOLTIP_DELAY_IN_MS } from './utils'; + SidebarNavigationIconComponent, + SidebarNavigationItemBaseProps, +} from './SidebarNavigationTypes'; -export interface SidebarNavigationItemProps extends Pick< - NavLinkBaseProps, - 'isMatchingFullPath' | 'enableOpenInNewTab' | 'to' -> { - ariaLabel?: string; - /** - * The label of the SidebarNavigationItem. - * It can be a string or a JSX.Element, in the case of a JSX.Element it should not be wrapped in - * a `` component, the SidebarNavigationItem already handles the typography styling for you. - */ - children: TextNode; - className?: string; - /** - * Whether to hide the Icon when the sidebar is open. - * The purpose is to have the icon appear only when the sidebar is not open, and for accordion child items only. - */ - disableIconWhenSidebarOpen?: boolean; - /** - * Whether to disable the tooltip on the item or not. - * By default the tooltip is enabled, it should only be disabled if you don't expect the content to be ellipsed. - */ - disableTooltip?: boolean; - /** - * Control whether the SidebarNavigationItem is active or not. - * If true, the item will have a different style to indicate it is active. - * If false it will override any default behavior and not indicate it is active. - * - * By default this behavior is handled by the underlying react-router's NavLink component, - * overriding this is only needed for complex scenarios. - */ - isActive?: boolean; - /** - * The onClick handler for the SidebarNavigationItem. - */ - onClick?: MouseEventHandler; - /** - * Optional content to display on the right. Typically badges and similar metadata. - */ - suffix?: ReactNode; +export interface SidebarNavigationItemProps extends SidebarNavigationItemBaseProps { /** * The icon component to display at the start of the SidebarNavigationItem. * Must be an Echoes Icon component. */ - Icon: ForwardRefExoticComponent>; + Icon: SidebarNavigationIconComponent; } -export const SidebarNavigationItem = forwardRef( - (props, ref) => { - const { - children, - disableIconWhenSidebarOpen = false, - disableTooltip = false, - Icon, - onClick, - suffix, - ...htmlProps - } = props; - - const handleClick = useCallback( - (event: MouseEvent) => { - event.currentTarget.blur(); - onClick?.(event); - }, - [onClick], - ); - - return ( - - - - - {children} - {suffix} - - - - ); - }, -); +export function SidebarNavigationItem(props: Readonly) { + return ; +} SidebarNavigationItem.displayName = 'SidebarNavigationItem'; - -const NavigationItem = styled(NavLinkBase)` - ${sidebarNavigationBaseItemStyles} - - // When the item is inside an accordion, the display/visibility value changes based on the state - // of the accordion, hiding the items when the sidebar is closed. - // These css properties are set by the SidebarNavigationAccordionItem component - // Fallback to flex/visible if not inside an accordion - display: var(--sidebar-navigation-accordion-children-display, flex); - visibility: var(--sidebar-navigation-accordion-children-visibility, visible); - - // Outline provided by the accordion item when the sidebar is collapsed, it's only visible for the - // active element, but we don't put it inside the active rule to make sure it doesn't have higher - // specificity than the outline provided by the sidebarNavigationBaseItemStyles on focus. - // Also it doesn't matter if it's present when not active since non active items are not visible anyway. - outline: var(--sidebar-navigation-accordion-children-outline); - - &:active, - &.active { - // Always display the item when active even if behind a closed accordion, this overrides the previously set display value from the css property - display: flex; - visibility: visible; - - background-color: ${cssVar('sidebar-navigation-item-colors-background-active')}; - color: ${cssVar('color-text-accent')}; - font: ${cssVar('typography-text-default-semi-bold')}; - - &:hover { - background-color: ${cssVar('sidebar-navigation-item-colors-background-hover')}; - } - } -`; -NavigationItem.displayName = 'NavigationItem'; - -const navigationItemIconStyles = css` - ${sidebarNavigationItemIconStyles} - - ${NavigationItem}.active > &, - ${NavigationItem}:active > & { - color: ${cssVar('color-icon-accent')}; - } -`; - -const hideWhenSidebarOpenStyles = css` - [data-sidebar-docked='true'] &, - [data-sidebar-docked='false'] nav:is(:hover, :focus-within) & { - display: none; - } -`; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationTypes.ts b/src/components/layout/sidebar-navigation/SidebarNavigationTypes.ts new file mode 100644 index 000000000..d9f4049db --- /dev/null +++ b/src/components/layout/sidebar-navigation/SidebarNavigationTypes.ts @@ -0,0 +1,83 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { ForwardRefExoticComponent, MouseEventHandler, ReactNode, Ref, RefAttributes } from 'react'; +import { NavLinkBaseProps } from '~common/components/NavLinkBase'; +import { TextNode } from '~types/utils'; +import { IconFilledProps } from '../../icons'; + +export type SidebarNavigationIconComponent = ForwardRefExoticComponent< + IconFilledProps & RefAttributes +>; + +export interface SidebarNavigationItemBaseProps extends Pick< + NavLinkBaseProps, + 'enableOpenInNewTab' | 'isMatchingFullPath' | 'to' +> { + /** + * ARIA label for the sidebar navigation item. + */ + ariaLabel?: string; + /** + * The label of the sidebar navigation item. + * It can be a string or a JSX.Element. In the case of a JSX.Element it should not be wrapped in + * a `` component, the sidebar navigation item already handles the typography styling for + * you. + */ + children: TextNode; + /** + * Optional CSS class name applied to the root link element. + */ + className?: string; + /** + * Whether to hide the icon while the sidebar is open. + * Useful for rows that should only show their icon while the sidebar is collapsed. + * @defaultValue false + */ + disableIconWhenSidebarOpen?: boolean; + /** + * Whether to disable the tooltip on the item or not. + * By default the tooltip is enabled, it should only be disabled if you don't expect the content + * to be ellipsed. + * @defaultValue false + */ + disableTooltip?: boolean; + /** + * Control whether the sidebar navigation item is active or not. + * If true, the item will have a different style to indicate it is active. + * If false it will override any default behavior and not indicate it is active. + * + * By default this behavior is handled by the underlying react-router's NavLink component, + * overriding this is only needed for complex scenarios. + */ + isActive?: boolean; + /** + * The onClick handler for the sidebar navigation item. + */ + onClick?: MouseEventHandler; + /** + * React ref forwarded to the root link element. + */ + ref?: Ref; + /** + * Optional content to display on the right. Typically badges and similar metadata. + */ + suffix?: ReactNode; +} diff --git a/src/components/layout/sidebar-navigation/index.ts b/src/components/layout/sidebar-navigation/index.ts index c1f57fec0..f6d9bb556 100644 --- a/src/components/layout/sidebar-navigation/index.ts +++ b/src/components/layout/sidebar-navigation/index.ts @@ -19,6 +19,7 @@ */ import { SidebarNavigation as SidebarNavigationRoot } from './SidebarNavigation'; +import { SidebarNavigationAccordionChildItem } from './SidebarNavigationAccordionChildItem'; import { SidebarNavigationAccordionItem } from './SidebarNavigationAccordionItem'; import { SidebarNavigationBody } from './SidebarNavigationBody'; import { SidebarNavigationFooterPromotionCard } from './SidebarNavigationFooterPromotionCard'; @@ -28,11 +29,30 @@ import { SidebarNavigationItem } from './SidebarNavigationItem'; import { SidebarNavigationFooter } from './SidebarNavigationItemStyles'; export { type SidebarNavigationProps } from './SidebarNavigation'; +export { type SidebarNavigationAccordionChildItemProps } from './SidebarNavigationAccordionChildItem'; export { type SidebarNavigationAccordionItemProps } from './SidebarNavigationAccordionItem'; export { type SidebarNavigationGroupProps } from './SidebarNavigationGroup'; export { type SidebarNavigationHeaderProps } from './SidebarNavigationHeader'; +export { type SidebarNavigationItemBaseProps } from './SidebarNavigationTypes'; export { type SidebarNavigationItemProps } from './SidebarNavigationItem'; +const SidebarNavigationAccordionItemNamespace = Object.assign(SidebarNavigationAccordionItem, { + /** + * {@link SidebarNavigationAccordionChildItem | Item} represents navigation rows inside an + * accordion section. Icons are optional so accordion child rows can be rendered with or without + * them. + * + * ```tsx + * + * + * General settings + * + * + * ``` + */ + Item: SidebarNavigationAccordionChildItem, +}); + export const SidebarNavigation = Object.assign(SidebarNavigationRoot, { /** * {@link SidebarNavigationAccordionItem | AccordionItem} provides expandable navigation sections @@ -40,13 +60,13 @@ export const SidebarNavigation = Object.assign(SidebarNavigationRoot, { * * ```tsx * - * + * * Security Hotspots - * + * * * ``` */ - AccordionItem: SidebarNavigationAccordionItem, + AccordionItem: SidebarNavigationAccordionItemNamespace, /** * {@link SidebarNavigationBody | Body} provides the main scrollable content area for navigation items. @@ -54,7 +74,9 @@ export const SidebarNavigation = Object.assign(SidebarNavigationRoot, { * * ```tsx * - * Dashboard + * + * Dashboard + * * * ``` */ @@ -129,7 +151,9 @@ export const SidebarNavigation = Object.assign(SidebarNavigationRoot, { /** * {@link SidebarNavigationItem | Item} represents individual navigation items with support for - * icons, active states, and router integration. Do not wrap children in Text components. + * required icons, active states, and router integration. Use + * {@link SidebarNavigationAccordionChildItem | AccordionItem.Item} for accordion child rows. + * Do not wrap children in Text components. * * ```tsx * From a1bfe539ec51714a206ee920129b351dee2901df Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Thu, 2 Jul 2026 10:29:40 +0200 Subject: [PATCH 2/5] ECHOES-1408 Honor aria labels in sidebar accordion items --- .../sidebar-navigation/SidebarNavigationAccordionItem.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx index d8721d9b9..63a62936e 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx @@ -26,6 +26,7 @@ import { TextNode } from '~types/utils'; import { cssVar } from '~utils/design-tokens'; import { IconChevronDown, IconChevronRight } from '../../icons'; import { Tooltip } from '../../tooltip'; + import { sidebarNavigationBaseItemStyles, sidebarNavigationItemIconStyles, @@ -36,6 +37,10 @@ import { SidebarNavigationIconComponent } from './SidebarNavigationTypes'; import { TOOLTIP_DELAY_IN_MS } from './utils'; export interface SidebarNavigationAccordionItemProps { + /** + * ARIA label for the SidebarNavigationAccordionItem button. + */ + ariaLabel?: string; /** * List of navigation child items displayed when the accordion is expanded. * Prefer `SidebarNavigation.AccordionItem.Item` and keep the list to five items or fewer. @@ -93,6 +98,7 @@ export function SidebarNavigationAccordionItem( props: Readonly, ) { const { + ariaLabel, children, disableTooltip = false, Icon, @@ -143,6 +149,7 @@ export function SidebarNavigationAccordionItem( {...htmlProps} aria-controls={accordionPanelId} aria-expanded={open} + aria-label={ariaLabel} id={accordionId} onClick={handleClick} ref={ref} From d03db630b79802a40f01fc0baffd9b1dcbb2ac0a Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Thu, 2 Jul 2026 10:29:43 +0200 Subject: [PATCH 3/5] ECHOES-1408 Update sidebar navigation stories for accordion child rows --- stories/layout/Layout-stories.tsx | 47 +++++++++++-- .../SidebarNavigation-stories.tsx | 68 +++++++++++++------ ...SidebarNavigationAccordionItem-stories.tsx | 52 +++++++++++--- 3 files changed, 132 insertions(+), 35 deletions(-) diff --git a/stories/layout/Layout-stories.tsx b/stories/layout/Layout-stories.tsx index 8cd784e03..76ca6d694 100644 --- a/stories/layout/Layout-stories.tsx +++ b/stories/layout/Layout-stories.tsx @@ -20,6 +20,7 @@ import styled from '@emotion/styled'; import type { Meta, StoryObj } from '@storybook/react-vite'; + import { BadgeSeverity, Button, @@ -41,6 +42,7 @@ import { Text, TextInput, } from '../../src'; + import { AsideSize } from '../../src/components/layout/LayoutTypes'; import { PageHeaderScrollBehavior } from '../../src/components/layout/header/common/HeaderTypes'; @@ -60,6 +62,7 @@ const meta: Meta = { {}} variety="danger"> Oups something is wrong! + This is a general notification! ), @@ -155,14 +158,19 @@ export const Default: Story = { render: (args) => ( {args.banner} + + {args.sidebar} + {args.contentHeader} + {args.aside} {args.pageHeader(args.pageHeaderScrollBehavior)} +

@@ -174,20 +182,27 @@ export const Default: Story = { deserunt mollit anim id est laborum.

+ + + + + + An action}> +
+ 2018-2025 SonarSource Sàrl. All rights reserved + Terms + Pricing + Privacy + Cookies + Terms @@ -238,13 +259,18 @@ function GlobalNav() { Home + Quality Profiles + Rules + + + } items={Settings} @@ -276,39 +302,46 @@ function SidebarNav() { isInteractive name="My Project name" /> + Overview + - Main branch - - + + Amazing Pull Request that updates a lot of things - - + + Small PR - + + Reports + Measures + Settings @@ -340,6 +373,7 @@ function getHeaderProps() { navigation: ( Nav Item 1 + Nav Item 2 ), @@ -439,6 +473,7 @@ function getRandomSize() { function ColorBox() { const color = getRandomColor(); + return (
Home + Quality Profiles + Rules + + blablablba + Thing 1 + Amazing project 2Amazing project 2Amazing project 2Amazing project 2Amazing project 2 + } @@ -111,24 +119,34 @@ export const Full: Story = { Blabla 3 + }> - } to="/1"> child 1 with a long name hahahah - - + + + child 2 - - + + + child 3 - + + {items.map((v) => { return ( @@ -141,40 +159,45 @@ export const Full: Story = { - asdf - - + + zxcv - + + - asdf - - + + zxcv - + + + Maybe later } @@ -182,28 +205,32 @@ export const Full: Story = { headerText="My feature is available now" text="Learn how you can improve your code base simply by cleaning your new code." /> + - Child settings 1 - - + + Child settings 2 - - + + Child settings 3 - + + @@ -223,6 +250,7 @@ export const Full: Story = { function LayoutWithSidebarStateSaved({ children }: PropsWithChildren) { const isSidebarDocked = globalThis.localStorage.getItem('echoes-sidebar-docked'); + return ( - + Item 1 - + - + Item 2 - + ); const dockedSidebarAccordionChildren = ( <> - + Icon hidden while sidebar is open - + - + Icon stays visible while sidebar is open - + + +); + +const accordionChildrenWithIcon = ( + <> + + Item 1 + + + + Item 2 + ); @@ -108,6 +124,15 @@ export const withDefaultOpen: Story = { }, }; +export const withIcon: Story = { + args: { + Icon: IconBranch, + children: accordionChildrenWithIcon, + isDefaultOpen: true, + label: 'Accordion', + }, +}; + export const withDisableIconWhenSidebarOpen: Story = { args: { Icon: IconBranch, @@ -134,6 +159,15 @@ const fourNavItems = Array.from({ length: 4 }, (_, i) => ( )); +const fourAccordionChildItems = Array.from({ length: 4 }, (_, i) => ( + + Item {`${i + 1}`} + +)); + export const scrollLastChildIntoView: Story = { decorators: [basicWrapperDecorator], args: { @@ -148,7 +182,7 @@ export const scrollLastChildIntoView: Story = { Icon={IconBranch} label="Accordion" scrollLastChildIntoViewOnOpen={scrollLastChildIntoViewOnOpen}> - {fourNavItems} + {fourAccordionChildItems}
From cc554d347b6ae38986f3b8df8f600df42f47f4c7 Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Thu, 2 Jul 2026 10:29:46 +0200 Subject: [PATCH 4/5] ECHOES-1408 Add tests for accordion child sidebar items --- ...debarNavigationAccordionChildItem-test.tsx | 176 ++++++++++++++++++ .../SidebarNavigationAccordionItem-test.tsx | 57 +++++- .../__tests__/SidebarNavigationItem-test.tsx | 32 +++- 3 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionChildItem-test.tsx diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionChildItem-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionChildItem-test.tsx new file mode 100644 index 000000000..f7753c4c3 --- /dev/null +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionChildItem-test.tsx @@ -0,0 +1,176 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { matchers } from '@emotion/jest'; +import { screen } from '@testing-library/react'; +import { createRef, type Ref } from 'react'; +import { renderWithMemoryRouter } from '~common/helpers/test-utils'; +import { IconBranch, IconClock } from '../../../icons'; + +import { + SidebarNavigationAccordionChildItem, + SidebarNavigationAccordionChildItemProps, +} from '../SidebarNavigationAccordionChildItem'; + +expect.extend(matchers); + +jest.mock('../utils', () => ({ + TOOLTIP_DELAY_IN_MS: 0, +})); + +it('should handle onClick events', async () => { + const handleClick = jest.fn(); + const { user } = setupSidebarNavigationAccordionChildItem({ onClick: handleClick }); + + await user.click(screen.getByRole('link')); + expect(handleClick).toHaveBeenCalledTimes(1); +}); + +it('should render without an icon', () => { + setupSidebarNavigationAccordionChildItem(); + + expect(screen.getByRole('link', { name: 'Test Item' })).toBeInTheDocument(); +}); + +it('should use ariaLabel as the accessible name', () => { + setupSidebarNavigationAccordionChildItem({ ariaLabel: 'Accordion child item label' }); + + expect(screen.getByRole('link', { name: 'Accordion child item label' })).toBeInTheDocument(); +}); + +it('should forward refs to the underlying link', () => { + const ref = createRef(); + setupSidebarNavigationAccordionChildItem({}, ref); + + expect(ref.current).toBe(screen.getByRole('link', { name: 'Test Item' })); +}); + +describe('ellipsis behavior', () => { + it('should show tooltip by default', async () => { + const { user } = setupSidebarNavigationAccordionChildItem(); + + await user.hover(screen.getByRole('link')); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Test Item'); + }); + + it('should not show tooltip when disableTooltip prop is true', async () => { + const { user } = setupSidebarNavigationAccordionChildItem({ disableTooltip: true }); + + await user.hover(screen.getByRole('link')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); +}); + +describe('navigation behavior', () => { + it('should navigate to the correct path', async () => { + const { user } = setupSidebarNavigationAccordionChildItem(); + + expect(screen.getByRole('link', { name: 'Test Item' })).toBeInTheDocument(); + + await user.click(screen.getByRole('link', { name: 'Test Item' })); + expect(screen.getByText('/second')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Test Item' })).not.toBeInTheDocument(); + }); + + it('should navigate to the correct path with the keyboard', async () => { + const { user } = setupSidebarNavigationAccordionChildItem(); + + await user.tab(); + expect(screen.getByRole('link', { name: 'Test Item' })).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(screen.getByText('/second')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Test Item' })).not.toBeInTheDocument(); + }); +}); + +describe('active state behavior', () => { + it('should apply active class when isActive is true', () => { + setupSidebarNavigationAccordionChildItem({ + isActive: true, + disableIconWhenSidebarOpen: true, + }); + + expect(screen.getByRole('link')).toHaveClass('active'); + }); + + it('should automatically apply active class for active link', () => { + setupSidebarNavigationAccordionChildItem({ to: '/initial' }); + + expect(screen.getByRole('link')).toHaveClass('active'); + }); + + it('should not have active class when isActive is false', () => { + setupSidebarNavigationAccordionChildItem({ isActive: false, to: '/initial' }); + + expect(screen.getByRole('link')).not.toHaveClass('active'); + }); + + it('should not have active class for non active link', () => { + setupSidebarNavigationAccordionChildItem({ to: '/second' }); + + expect(screen.getByRole('link')).not.toHaveClass('active'); + }); +}); + +describe('CSS custom properties for accordion integration', () => { + it('should use CSS custom properties for display, visibility and outline', () => { + setupSidebarNavigationAccordionChildItem({ Icon: IconClock }); + + const link = screen.getByRole('link'); + + expect(link).toHaveStyleRule( + 'display', + 'var(--sidebar-navigation-accordion-children-display, flex)', + ); + + expect(link).toHaveStyleRule( + 'visibility', + 'var(--sidebar-navigation-accordion-children-visibility, visible)', + ); + + expect(link).toHaveStyleRule('outline', 'var(--sidebar-navigation-accordion-children-outline)'); + }); +}); + +it("shouldn't have any a11y violation", async () => { + const { container } = setupSidebarNavigationAccordionChildItem({ Icon: IconBranch }); + await expect(container).toHaveNoA11yViolations(); +}); + +it("shouldn't have any a11y violation without an icon", async () => { + const { container } = setupSidebarNavigationAccordionChildItem(); + await expect(container).toHaveNoA11yViolations(); +}); + +function setupSidebarNavigationAccordionChildItem( + props: Partial = {}, + ref?: Ref, +) { + return renderWithMemoryRouter( +
    + + Test Item + +
, + ); +} diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx index b4065c7c9..3e63e1456 100644 --- a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx @@ -22,11 +22,12 @@ import { matchers } from '@emotion/jest'; import { screen } from '@testing-library/react'; import { renderWithMemoryRouter } from '~common/helpers/test-utils'; import { IconBranch, IconExpand, IconGitBranch } from '../../../icons'; +import { SidebarNavigationAccordionChildItem } from '../SidebarNavigationAccordionChildItem'; + import { SidebarNavigationAccordionItem, SidebarNavigationAccordionItemProps, } from '../SidebarNavigationAccordionItem'; -import { SidebarNavigationItem } from '../SidebarNavigationItem'; expect.extend(matchers); @@ -68,6 +69,18 @@ it("shouldn't have any a11y violation", async () => { await expect(container).toHaveNoA11yViolations(); }); +it('should use ariaLabel as the accessible name', () => { + setupSidebarNavigationAccordionItem({ ariaLabel: 'Accordion button label' }); + + expect(screen.getByRole('button', { name: 'Accordion button label' })).toBeInTheDocument(); +}); + +it('should render a button trigger that does not submit surrounding forms', () => { + setupSidebarNavigationAccordionItem(); + + expect(screen.getByRole('button', { name: 'Accordion Item' })).toHaveAttribute('type', 'button'); +}); + describe('ellipsis behavior', () => { it('should show tooltip by default', async () => { const { user } = setupSidebarNavigationAccordionItem(); @@ -100,17 +113,18 @@ it('should scroll the last child into view when opened with scrollLastChildIntoV globalThis.HTMLElement.prototype.scrollIntoView = () => {}; }); -describe('integration with SidebarNavigationItem', () => { +describe('integration with SidebarNavigationAccordionChildItem', () => { it('should set CSS custom properties and active class on children', () => { setupSidebarNavigationAccordionItem({ children: ( <> - + Sub Item 1 - - + + + Sub Item 2 - + ), }); @@ -127,6 +141,7 @@ describe('integration with SidebarNavigationItem', () => { 'display', 'var(--sidebar-navigation-accordion-children-display, flex)', ); + expect(subItem2).toHaveStyleRule( 'display', 'var(--sidebar-navigation-accordion-children-display, flex)', @@ -137,6 +152,7 @@ describe('integration with SidebarNavigationItem', () => { 'visibility', 'var(--sidebar-navigation-accordion-children-visibility, visible)', ); + expect(subItem2).toHaveStyleRule( 'visibility', 'var(--sidebar-navigation-accordion-children-visibility, visible)', @@ -147,11 +163,31 @@ describe('integration with SidebarNavigationItem', () => { 'outline', 'var(--sidebar-navigation-accordion-children-outline)', ); + expect(subItem2).toHaveStyleRule( 'outline', 'var(--sidebar-navigation-accordion-children-outline)', ); }); + + it('should render child items with and without icons', () => { + setupSidebarNavigationAccordionItem({ + children: ( + <> + + Sub Item 1 + + + + Sub Item 2 + + + ), + }); + + expect(screen.getByRole('link', { name: 'Sub Item 1' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Sub Item 2' })).toBeInTheDocument(); + }); }); function checkAccordionPanelVisibility(isOpen: boolean) { @@ -167,12 +203,13 @@ function setupSidebarNavigationAccordionItem( {props.children ?? ( <> - + Sub Item 1 - - + + + Sub Item 2 - + )} diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx index 8db41e025..f870e5d84 100644 --- a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx @@ -20,6 +20,7 @@ import { matchers } from '@emotion/jest'; import { screen } from '@testing-library/react'; +import { createRef, type Ref } from 'react'; import { renderWithMemoryRouter } from '~common/helpers/test-utils'; import { IconBranch, IconClock } from '../../../icons'; import { SidebarNavigationItem, SidebarNavigationItemProps } from '../SidebarNavigationItem'; @@ -38,6 +39,19 @@ it('should handle onClick events', async () => { expect(handleClick).toHaveBeenCalledTimes(1); }); +it('should use ariaLabel as the accessible name', () => { + setupSidebarNavigationItem({ ariaLabel: 'Sidebar item label' }); + + expect(screen.getByRole('link', { name: 'Sidebar item label' })).toBeInTheDocument(); +}); + +it('should forward refs to the underlying link', () => { + const ref = createRef(); + setupSidebarNavigationItem({}, ref); + + expect(ref.current).toBe(screen.getByRole('link', { name: 'Test Item' })); +}); + describe('ellipsis behavior', () => { it('should show tooltip by default', async () => { const { user } = setupSidebarNavigationItem(); @@ -66,6 +80,17 @@ describe('navigation behavior', () => { expect(screen.getByText('/second')).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Test Item' })).not.toBeInTheDocument(); }); + + it('should navigate to the correct path with the keyboard', async () => { + const { user } = setupSidebarNavigationItem(); + + await user.tab(); + expect(screen.getByRole('link', { name: 'Test Item' })).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(screen.getByText('/second')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Test Item' })).not.toBeInTheDocument(); + }); }); describe('active state behavior', () => { @@ -122,10 +147,13 @@ it("shouldn't have any a11y violation", async () => { await expect(container).toHaveNoA11yViolations(); }); -function setupSidebarNavigationItem(props: Partial = {}) { +function setupSidebarNavigationItem( + props: Partial = {}, + ref?: Ref, +) { return renderWithMemoryRouter(
    - + Test Item
, From d45a6c0f990896ddf8ce707a20181a5e9d503f3b Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Thu, 2 Jul 2026 10:29:48 +0200 Subject: [PATCH 5/5] ECHOES-1408 Re-export sidebar navigation child item types --- src/components/layout/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index 40a5ee865..932cefd40 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -22,6 +22,7 @@ import { Banner } from './banner'; import { GlobalNavigation } from './global-navigation'; import { ContentHeader, PageHeader } from './header'; import { Layout as LayoutRoot } from './Layout'; + import { AsideLeft, BannerContainer, @@ -30,9 +31,11 @@ import { PageFooter, PageGrid, } from './LayoutSlots'; + import { SidebarNavigation } from './sidebar-navigation'; export { BannerVariety, type BannerProps } from './banner'; + export type { GlobalNavigationAccountProps, GlobalNavigationActionProps, @@ -43,6 +46,7 @@ export type { GlobalNavigationPrimaryProps, GlobalNavigationProps, } from './global-navigation'; + export type { ContentHeaderProps, ContentHeaderTitleProps, @@ -50,13 +54,17 @@ export type { PageHeaderProps, PageHeaderTitleProps, } from './header'; + export type { LayoutProps } from './Layout'; export type { AsideProps, PageGridProps } from './LayoutSlots'; export { AsideSize, PageWidth } from './LayoutTypes'; + export type { + SidebarNavigationAccordionChildItemProps, SidebarNavigationAccordionItemProps, SidebarNavigationGroupProps, SidebarNavigationHeaderProps, + SidebarNavigationItemBaseProps, SidebarNavigationItemProps, SidebarNavigationProps, } from './sidebar-navigation'; @@ -354,6 +362,7 @@ export const Layout = Object.assign(LayoutRoot, { * - {@link SidebarNavigation.Group} - Grouped navigation sections * - {@link SidebarNavigation.Item} - Individual navigation items * - {@link SidebarNavigation.AccordionItem} - Expandable sections + * - {@link SidebarNavigation.AccordionItem.Item} - Navigation rows within accordion sections * - {@link SidebarNavigation.Footer} - Fixed footer section * * ```tsx