diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionChildItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionChildItem.tsx index c08e6cbf..dc34b5e4 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionChildItem.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionChildItem.tsx @@ -18,8 +18,17 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { useContext, useLayoutEffect, useRef } from 'react'; +import { useMatch, useResolvedPath } from 'react-router-dom'; +import { isDefined } from '~common/helpers/types'; + import { SidebarNavigationBaseItem } from './SidebarNavigationBaseItem'; +import { + SidebarNavigationAccordionContext, + type SidebarNavigationAccordionChildActiveHandler, +} from './SidebarNavigationAccordionContext'; + import { SidebarNavigationIconComponent, SidebarNavigationItemBaseProps, @@ -36,7 +45,54 @@ export interface SidebarNavigationAccordionChildItemProps extends SidebarNavigat export function SidebarNavigationAccordionChildItem( props: Readonly, ) { - return ; + const handleChildActive = useContext(SidebarNavigationAccordionContext); + + if (!isDefined(handleChildActive)) { + return ; + } + + return ( + + ); } SidebarNavigationAccordionChildItem.displayName = 'SidebarNavigationAccordionChildItem'; + +type SidebarNavigationAccordionChildItemWithAutoOpenProps = + SidebarNavigationAccordionChildItemProps & { + handleChildActive: SidebarNavigationAccordionChildActiveHandler; + }; + +function SidebarNavigationAccordionChildItemWithAutoOpen( + props: Readonly, +) { + const { handleChildActive, isActive, isMatchingFullPath = false, to, ...restProps } = props; + + const resolvedPath = useResolvedPath(to); + const routeMatch = useMatch({ end: isMatchingFullPath, path: resolvedPath.pathname }); + const resolvedIsActive = isDefined(isActive) ? isActive : isDefined(routeMatch); + const wasActiveRef = useRef(false); + + useLayoutEffect(() => { + if (resolvedIsActive && !wasActiveRef.current) { + handleChildActive(); + } + + wasActiveRef.current = resolvedIsActive; + }, [handleChildActive, resolvedIsActive]); + + return ( + + ); +} + +SidebarNavigationAccordionChildItemWithAutoOpen.displayName = + 'SidebarNavigationAccordionChildItemWithAutoOpen'; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionContext.ts b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionContext.ts new file mode 100644 index 00000000..62cc26dd --- /dev/null +++ b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionContext.ts @@ -0,0 +1,33 @@ +/* + * 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 { createContext } from 'react'; + +/** + * @internal + */ +export type SidebarNavigationAccordionChildActiveHandler = VoidFunction; + +/** + * @internal + */ +export const SidebarNavigationAccordionContext = createContext< + SidebarNavigationAccordionChildActiveHandler | undefined +>(undefined); diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx index e3a433f7..af4e7957 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx @@ -20,9 +20,7 @@ import styled from '@emotion/styled'; -import { ReactNode, Ref, useCallback, useEffect, useId, useRef, useState } from 'react'; - -import { isDefined } from '~common/helpers/types'; +import { ReactNode, Ref, useEffect, useId, useRef } from 'react'; import { TextNode } from '~types/utils'; import { cssVar } from '~utils/design-tokens'; import { IconChevronDown, IconChevronRight } from '../../icons'; @@ -34,8 +32,10 @@ import { SidebarNavigationItemLabel, } from './SidebarNavigationItemStyles'; +import { SidebarNavigationAccordionContext } from './SidebarNavigationAccordionContext'; import { SidebarNavigationIconComponent } from './SidebarNavigationTypes'; import { TOOLTIP_DELAY_IN_MS } from './utils'; +import { useSidebarNavigationAccordionState } from './useSidebarNavigationAccordionState'; interface SidebarNavigationAccordionItemCommonProps { /** @@ -53,7 +53,8 @@ interface SidebarNavigationAccordionItemCommonProps { 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. + * 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; @@ -67,15 +68,20 @@ interface SidebarNavigationAccordionItemCommonProps { */ label: TextNode; /** - * The onClose callback is called when the accordion is closed. + * Called when the accordion closes in uncontrolled mode. In controlled mode, called when the + * user requests closing it. */ onClose?: VoidFunction; /** - * The onOpen callback is called when the accordion is opened. + * Called when the accordion opens in uncontrolled mode, including automatic opening when a child + * becomes active on the first render or later. In controlled mode, called when the user + * requests opening it. */ onOpen?: VoidFunction; /** - * Called with the next open state when the user toggles the accordion. + * Called with the next open state when the accordion changes in uncontrolled mode, including + * automatic opening when a child becomes active on the first render or later. In controlled + * mode, called when the user requests a state change. */ onOpenChange?: (isOpen: boolean) => void; /** @@ -89,7 +95,8 @@ interface SidebarNavigationAccordionItemCommonProps { */ scrollLastChildIntoViewOnOpen?: boolean; /** - * Optional content to display on the right, before the chevron. Typically badges, item count and similar metadata. + * Optional content to display on the right, before the chevron. Typically badges, item count, + * and similar metadata. */ suffix?: ReactNode; } @@ -140,10 +147,17 @@ export function SidebarNavigationAccordionItem( ...htmlProps } = props; - const [internalOpen, setInternalOpen] = useState(isDefaultOpen); - const open = isDefined(isOpen) ? isOpen : internalOpen; const panelRef = useRef(null); + const { handleChildActive, handleToggle, open, shouldAutoOpenOnActiveChild } = + useSidebarNavigationAccordionState({ + isDefaultOpen, + isOpen, + onClose, + onOpen, + onOpenChange, + }); + useEffect(() => { if (open && scrollLastChildIntoViewOnOpen) { const lastChild = panelRef.current?.querySelector('li:last-child'); @@ -154,23 +168,7 @@ export function SidebarNavigationAccordionItem( const accordionId = `${useId()}sidebar-accordion`; const accordionPanelId = `${accordionId}-panel`; - const handleClick = useCallback(() => { - const nextOpen = !open; - - if (!isDefined(isOpen)) { - setInternalOpen(nextOpen); - } - - onOpenChange?.(nextOpen); - - if (nextOpen) { - onOpen?.(); - } else { - onClose?.(); - } - }, [isOpen, onClose, onOpen, onOpenChange, open]); - - return ( + const content = ( @@ -208,6 +206,16 @@ export function SidebarNavigationAccordionItem( ); + + if (!shouldAutoOpenOnActiveChild) { + return content; + } + + return ( + + {content} + + ); } SidebarNavigationAccordionItem.displayName = 'SidebarNavigationAccordionItem'; diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx index e55ad7c1..f6671ac3 100644 --- a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx @@ -19,16 +19,20 @@ */ import { matchers } from '@emotion/jest'; -import { screen } from '@testing-library/react'; -import { useState } from 'react'; -import { renderWithMemoryRouter } from '~common/helpers/test-utils'; -import { IconBranch, IconExpand, IconGitBranch } from '../../../icons'; +import { screen, waitFor } from '@testing-library/react'; +import { IconBranch, IconGitBranch } from '../../../icons'; import { SidebarNavigationAccordionChildItem } from '../SidebarNavigationAccordionChildItem'; import { - SidebarNavigationAccordionItem, - SidebarNavigationAccordionItemProps, -} from '../SidebarNavigationAccordionItem'; + checkAccordionAccessibility, + checkAccordionPanelVisibility, + getExplicitlyActiveSidebarNavigationAccordionChildren, + setupControlledSidebarNavigationAccordionItem, + setupSidebarNavigationAccordionItem, + setupSidebarNavigationAccordionItemWithExplicitActiveChild, + setupSidebarNavigationAccordionItemWithTransientInitialActiveChild, + setupSidebarNavigationAccordionItemWithRouter, +} from '../test-utils/SidebarNavigationAccordionItemTestUtils'; expect.extend(matchers); @@ -39,20 +43,32 @@ jest.mock('../utils', () => ({ it('should expand hidden elements when clicked', async () => { const onOpen = jest.fn(); const onClose = jest.fn(); - const { user } = setupSidebarNavigationAccordionItem({ onOpen, onClose }); + const onOpenChange = jest.fn(); + + const { user } = setupSidebarNavigationAccordionItem({ onClose, onOpen, onOpenChange }); const accordionButton = screen.getByRole('button', { name: 'Accordion Item' }); expect(accordionButton).toBeInTheDocument(); checkAccordionPanelVisibility(false); await user.click(accordionButton); - expect(onOpen).toHaveBeenCalled(); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenNthCalledWith(1, true); + checkAccordionPanelVisibility(true); expect(screen.getAllByRole('link')).toHaveLength(2); await user.click(accordionButton); + checkAccordionPanelVisibility(false); - expect(onClose).toHaveBeenCalled(); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledTimes(2); + expect(onOpenChange).toHaveBeenNthCalledWith(2, false); }); it('should render uncontrolled and closed by default', () => { @@ -62,6 +78,7 @@ it('should render uncontrolled and closed by default', () => { 'aria-expanded', 'false', ); + checkAccordionAccessibility(false); }); @@ -73,6 +90,58 @@ it('should render the accordion open when defaultOpen is true', () => { checkAccordionAccessibility(true); }); +it('should render the accordion open when a child route is active', () => { + setupSidebarNavigationAccordionItemWithRouter({}, ['/sub-item-1']); + + expect(screen.getAllByRole('link')).toHaveLength(2); + checkAccordionAccessibility(true); +}); + +it('should call open callbacks on initial auto-open from an active child route', async () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const onOpenChange = jest.fn(); + + setupSidebarNavigationAccordionItemWithRouter({ onClose, onOpen, onOpenChange }, ['/sub-item-1']); + + await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1)); + + expect(onClose).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true); + checkAccordionAccessibility(true); +}); + +it('should stay open when an initially active child becomes inactive', async () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const onOpenChange = jest.fn(); + + setupSidebarNavigationAccordionItemWithTransientInitialActiveChild({ + onClose, + onOpen, + onOpenChange, + }); + + await waitFor(() => { + checkAccordionAccessibility(true); + }); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true); +}); + +it('should render the accordion open when a child is explicitly active', () => { + setupSidebarNavigationAccordionItem({ + children: getExplicitlyActiveSidebarNavigationAccordionChildren(), + }); + + expect(screen.getAllByRole('link')).toHaveLength(2); + checkAccordionAccessibility(true); +}); + it.each([true, false])('should respect the controlled open state %s', (isOpen) => { setupSidebarNavigationAccordionItem({ isOpen }); @@ -80,9 +149,17 @@ it.each([true, false])('should respect the controlled open state %s', (isOpen) = 'aria-expanded', isOpen.toString(), ); + checkAccordionAccessibility(isOpen); }); +it('should not auto-open a controlled closed accordion when a child route is active', () => { + setupSidebarNavigationAccordionItemWithRouter({ isOpen: false }, ['/sub-item-1']); + + expect(screen.getByRole('link', { name: 'Sub Item 1' })).toHaveClass('active'); + checkAccordionAccessibility(false); +}); + it('should reflect controlled prop updates after mount', async () => { const { user } = setupControlledSidebarNavigationAccordionItem(); @@ -90,13 +167,16 @@ it('should reflect controlled prop updates after mount', async () => { 'aria-expanded', 'false', ); + checkAccordionAccessibility(false); await user.click(screen.getByRole('button', { name: 'Open accordion externally' })); + expect(screen.getByRole('button', { name: 'Accordion Item' })).toHaveAttribute( 'aria-expanded', 'true', ); + checkAccordionAccessibility(true); await user.click(screen.getByRole('button', { name: 'Close accordion externally' })); @@ -113,6 +193,127 @@ it('should call onOpenChange without changing a controlled state', async () => { checkAccordionAccessibility(false); }); +it('should call callbacks when a child route becomes active after navigation', async () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const onOpenChange = jest.fn(); + + const { user } = setupSidebarNavigationAccordionItemWithRouter({ onClose, onOpen, onOpenChange }); + + checkAccordionAccessibility(false); + + await user.click(screen.getByRole('button', { name: 'Navigate to first child route' })); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true); + checkAccordionAccessibility(true); +}); + +it('should stay open when an auto-opened child route becomes inactive', async () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const onOpenChange = jest.fn(); + + const { user } = setupSidebarNavigationAccordionItemWithRouter({ onClose, onOpen, onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Navigate to first child route' })); + + await user.click(screen.getByRole('button', { name: 'Navigate elsewhere' })); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true); + checkAccordionAccessibility(true); +}); + +it('should keep a manually opened accordion open after navigating elsewhere', async () => { + const { user } = setupSidebarNavigationAccordionItemWithRouter(); + + await user.click(screen.getByRole('button', { name: 'Accordion Item' })); + checkAccordionAccessibility(true); + + await user.click(screen.getByRole('button', { name: 'Navigate elsewhere' })); + + expect(screen.getAllByRole('link')).toHaveLength(2); + checkAccordionAccessibility(true); +}); + +it('should stay manually closed when an explicitly active child remains active during navigation', async () => { + const { user } = setupSidebarNavigationAccordionItemWithRouter({ + children: getExplicitlyActiveSidebarNavigationAccordionChildren(), + }); + + checkAccordionAccessibility(true); + + await user.click(screen.getByRole('button', { name: 'Accordion Item' })); + checkAccordionAccessibility(false); + + await user.click(screen.getByRole('button', { name: 'Navigate elsewhere' })); + + expect(screen.getByRole('link', { name: 'Sub Item 1' })).toHaveClass('active'); + checkAccordionAccessibility(false); +}); + +it('should stay manually closed while the same route stays active', async () => { + const { user } = setupSidebarNavigationAccordionItemWithRouter({}, ['/sub-item-1']); + + checkAccordionAccessibility(true); + + await user.click(screen.getByRole('button', { name: 'Accordion Item' })); + + checkAccordionAccessibility(false); + + expect(screen.getByRole('button', { name: 'Accordion Item' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); +}); + +it('should reopen when the same child route becomes active again after being manually closed', async () => { + const { user } = setupSidebarNavigationAccordionItemWithRouter({}, ['/sub-item-1']); + + checkAccordionAccessibility(true); + + await user.click(screen.getByRole('button', { name: 'Accordion Item' })); + checkAccordionAccessibility(false); + + await user.click(screen.getByRole('button', { name: 'Navigate elsewhere' })); + checkAccordionAccessibility(false); + + await user.click(screen.getByRole('button', { name: 'Navigate to first child route' })); + + checkAccordionAccessibility(true); + + expect(screen.getByRole('button', { name: 'Accordion Item' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); +}); + +it('should reopen when an explicitly active child becomes active again without navigation', async () => { + const { user } = setupSidebarNavigationAccordionItemWithExplicitActiveChild(); + + checkAccordionAccessibility(true); + + await user.click(screen.getByRole('button', { name: 'Accordion Item' })); + checkAccordionAccessibility(false); + + await user.click(screen.getByRole('button', { name: 'Toggle first child active state' })); + checkAccordionAccessibility(false); + + await user.click(screen.getByRole('button', { name: 'Toggle first child active state' })); + + checkAccordionAccessibility(true); + + expect(screen.getByRole('button', { name: 'Accordion Item' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); +}); + it("shouldn't have any a11y violation", async () => { const { container, user } = setupSidebarNavigationAccordionItem({ Icon: IconBranch }); await expect(container).toHaveNoA11yViolations(); @@ -219,66 +420,3 @@ describe('integration with SidebarNavigationAccordionChildItem', () => { expect(screen.getByRole('link', { name: 'Sub Item 2' })).toBeInTheDocument(); }); }); - -function checkAccordionPanelVisibility(isOpen: boolean) { - const region = screen.getByRole('region', { name: 'Accordion Item' }); - expect(region).toHaveAttribute('data-accordion-open', isOpen.toString()); -} - -function checkAccordionAccessibility(isOpen: boolean) { - const button = screen.getByRole('button', { name: 'Accordion Item' }); - const region = screen.getByRole('region', { name: 'Accordion Item' }); - - expect(button).toHaveAttribute('aria-expanded', isOpen.toString()); - expect(button).toHaveAttribute('aria-controls', region.id); - expect(region).toHaveAttribute('aria-labelledby', button.id); - checkAccordionPanelVisibility(isOpen); -} - -function setupControlledSidebarNavigationAccordionItem() { - function ControlledExample() { - const [isOpen, setIsOpen] = useState(false); - - return ( - <> - - -
    - - - Sub Item 1 - - -
- - ); - } - - return renderWithMemoryRouter(); -} - -function setupSidebarNavigationAccordionItem( - props: Partial = {}, -) { - return renderWithMemoryRouter( -
    - - {props.children ?? ( - <> - - Sub Item 1 - - - - Sub Item 2 - - - )} - -
, - ); -} diff --git a/src/components/layout/sidebar-navigation/index.ts b/src/components/layout/sidebar-navigation/index.ts index 04526214..0ea1b098 100644 --- a/src/components/layout/sidebar-navigation/index.ts +++ b/src/components/layout/sidebar-navigation/index.ts @@ -29,12 +29,12 @@ 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'; +export type { SidebarNavigationAccordionChildItemProps } from './SidebarNavigationAccordionChildItem'; const SidebarNavigationAccordionItemNamespace = Object.assign(SidebarNavigationAccordionItem, { /** @@ -58,6 +58,9 @@ export const SidebarNavigation = Object.assign(SidebarNavigationRoot, { * {@link SidebarNavigationAccordionItem | AccordionItem} provides expandable navigation sections * with collapsible sub-items. Ideal for organizing related navigation items. * + * In uncontrolled mode, the accordion opens automatically when one of its child items becomes + * active, including on the first render. Controlled accordions do not auto-open. + * * ```tsx * * diff --git a/src/components/layout/sidebar-navigation/test-utils/SidebarNavigationAccordionItemTestUtils.tsx b/src/components/layout/sidebar-navigation/test-utils/SidebarNavigationAccordionItemTestUtils.tsx new file mode 100644 index 00000000..1938ff55 --- /dev/null +++ b/src/components/layout/sidebar-navigation/test-utils/SidebarNavigationAccordionItemTestUtils.tsx @@ -0,0 +1,215 @@ +/* + * 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 '@testing-library/jest-dom'; +import { screen } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import { render, renderWithMemoryRouter } from '~common/helpers/test-utils'; +import { IconBranch, IconExpand } from '../../../icons'; +import { SidebarNavigationAccordionChildItem } from '../SidebarNavigationAccordionChildItem'; + +import { + SidebarNavigationAccordionItem, + SidebarNavigationAccordionItemProps, +} from '../SidebarNavigationAccordionItem'; + +type SetupResult = ReturnType; + +export function checkAccordionAccessibility(isOpen: boolean): void { + const button = screen.getByRole('button', { name: 'Accordion Item' }); + const region = screen.getByRole('region', { name: 'Accordion Item' }); + + expect(button).toHaveAttribute('aria-expanded', isOpen.toString()); + expect(button).toHaveAttribute('aria-controls', region.id); + expect(region).toHaveAttribute('aria-labelledby', button.id); + checkAccordionPanelVisibility(isOpen); +} + +export function checkAccordionPanelVisibility(isOpen: boolean): void { + const region = screen.getByRole('region', { name: 'Accordion Item' }); + expect(region).toHaveAttribute('data-accordion-open', isOpen.toString()); +} + +export function setupControlledSidebarNavigationAccordionItem(): SetupResult { + function ControlledExample() { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + +
    + + + Sub Item 1 + + +
+ + ); + } + + return renderWithMemoryRouter(); +} + +export function setupSidebarNavigationAccordionItem( + props: Partial = {}, +): SetupResult { + return renderWithMemoryRouter(createSidebarNavigationAccordionItem(props)); +} + +export function setupSidebarNavigationAccordionItemWithRouter( + props: Partial = {}, + initialEntries = ['/initial'], +): SetupResult { + return render( + + + } + path="*" + /> + + , + ); +} + +export function setupSidebarNavigationAccordionItemWithExplicitActiveChild(): SetupResult { + function SidebarNavigationAccordionItemExplicitActiveChildHarness() { + const [isFirstChildActive, setIsFirstChildActive] = useState(true); + + return ( + <> + + + {createSidebarNavigationAccordionItem({ + children: getExplicitlyActiveSidebarNavigationAccordionChildren({ isFirstChildActive }), + })} + + ); + } + + return renderWithMemoryRouter(); +} + +export function setupSidebarNavigationAccordionItemWithTransientInitialActiveChild( + props: Partial = {}, +): SetupResult { + function SidebarNavigationAccordionItemTransientInitialActiveChildHarness() { + const [isFirstChildActive, setIsFirstChildActive] = useState(true); + + useEffect(() => { + setIsFirstChildActive(false); + }, []); + + return createSidebarNavigationAccordionItem({ + ...props, + children: getExplicitlyActiveSidebarNavigationAccordionChildren({ isFirstChildActive }), + }); + } + + return renderWithMemoryRouter( + , + ); +} + +interface SidebarNavigationAccordionItemRouterHarnessProps { + accordionProps: Partial; +} + +function SidebarNavigationAccordionItemRouterHarness({ + accordionProps, +}: Readonly) { + const navigate = useNavigate(); + + return ( + <> + + + + + {createSidebarNavigationAccordionItem(accordionProps)} + + ); +} + +function createSidebarNavigationAccordionItem( + props: Partial = {}, +) { + return ( +
    + + {props.children ?? getDefaultSidebarNavigationAccordionChildren()} + +
+ ); +} + +interface ExplicitlyActiveSidebarNavigationAccordionChildrenProps { + isFirstChildActive?: boolean; +} + +export function getExplicitlyActiveSidebarNavigationAccordionChildren({ + isFirstChildActive = true, +}: Readonly = {}) { + return ( + <> + + Sub Item 1 + + + + Sub Item 2 + + + ); +} + +function getDefaultSidebarNavigationAccordionChildren() { + return ( + <> + + Sub Item 1 + + + + Sub Item 2 + + + ); +} diff --git a/src/components/layout/sidebar-navigation/useSidebarNavigationAccordionState.ts b/src/components/layout/sidebar-navigation/useSidebarNavigationAccordionState.ts new file mode 100644 index 00000000..64bfb30f --- /dev/null +++ b/src/components/layout/sidebar-navigation/useSidebarNavigationAccordionState.ts @@ -0,0 +1,100 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; + +import { isDefined } from '~common/helpers/types'; + +interface UseSidebarNavigationAccordionStateInput { + isDefaultOpen: boolean; + isOpen?: boolean; + onClose?: VoidFunction; + onOpen?: VoidFunction; + onOpenChange?: (isOpen: boolean) => void; +} + +interface UseSidebarNavigationAccordionStateOutput { + handleChildActive: VoidFunction; + handleToggle: VoidFunction; + open: boolean; + shouldAutoOpenOnActiveChild: boolean; +} + +/** + * @internal + * Accordion state machine for SidebarNavigation.AccordionItem. + * Owns uncontrolled open state, child-driven auto-open, and callback dispatch. + */ +export function useSidebarNavigationAccordionState( + props: Readonly, +): UseSidebarNavigationAccordionStateOutput { + const { isDefaultOpen, isOpen, onClose, onOpen, onOpenChange } = props; + + const [uncontrolledOpen, setUncontrolledOpen] = useState(isDefaultOpen); + const previousOpenRef = useRef(undefined); + const isControlled = isDefined(isOpen); + const shouldAutoOpenOnActiveChild = !isControlled; + const open = isDefined(isOpen) ? isOpen : uncontrolledOpen; + + // In uncontrolled mode, callbacks follow the visible state after the initial mount settles, so + // manual toggles and child-driven auto-open transitions share one path. + useEffect(() => { + const previousOpen = previousOpenRef.current; + previousOpenRef.current = open; + + if (isControlled || !isDefined(previousOpen) || previousOpen === open) { + return; + } + + onOpenChange?.(open); + + if (open) { + onOpen?.(); + } else { + onClose?.(); + } + }, [isControlled, onClose, onOpen, onOpenChange, open]); + + const handleChildActive = useCallback(() => { + if (!isControlled) { + setUncontrolledOpen(true); + } + }, [isControlled]); + + const handleToggle = useCallback(() => { + const nextOpen = !open; + + if (!isControlled) { + setUncontrolledOpen(nextOpen); + + return; + } + + onOpenChange?.(nextOpen); + + if (nextOpen) { + onOpen?.(); + } else { + onClose?.(); + } + }, [isControlled, onClose, onOpen, onOpenChange, open]); + + return { handleChildActive, handleToggle, open, shouldAutoOpenOnActiveChild }; +} diff --git a/stories/layout/sidebar-navigation/SidebarNavigationAccordionItem-stories.tsx b/stories/layout/sidebar-navigation/SidebarNavigationAccordionItem-stories.tsx index a761fa94..b1eff2a9 100644 --- a/stories/layout/sidebar-navigation/SidebarNavigationAccordionItem-stories.tsx +++ b/stories/layout/sidebar-navigation/SidebarNavigationAccordionItem-stories.tsx @@ -21,8 +21,10 @@ /* eslint-disable no-console */ import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ComponentProps } from 'react'; +import { useLocation } from 'react-router-dom'; import { useArgs } from 'storybook/preview-api'; -import { Badge, IconBranch, Layout, type SidebarNavigationAccordionItemProps } from '../../../src'; +import { Badge, IconBranch, Layout, Link } from '../../../src'; import { basicWrapperDecorator } from '../../helpers/BasicWrapper'; const baseAccordionChildren = ( @@ -54,7 +56,7 @@ function ControlledAccordionStory({ isOpen = false, onOpenChange, ...props -}: Readonly) { +}: Readonly>) { const [, updateArgs] = useArgs(); function handleOpenChange(nextIsOpen: boolean) { @@ -71,6 +73,30 @@ function ControlledAccordionStory({ ); } +function AccordionStoryWithExternalNavigation( + props: Readonly>, +) { + const { pathname } = useLocation(); + + return ( +
+
+ Go outside the accordion + + Go to Item 1 + + Go to Item 2 +
+ +
+ Current route: {pathname} +
+ + +
+ ); +} + const meta: Meta = { title: 'Echoes Patterns/Layout/SidebarNavigation/AccordionItem', component: Layout.SidebarNavigation.AccordionItem, @@ -128,6 +154,16 @@ export const withDefaultOpen: Story = { }, }; +export const autoOpensFromExternalNavigation: Story = { + args: { + Icon: IconBranch, + children: baseAccordionChildren, + isDefaultOpen: false, + label: 'Accordion', + }, + render: AccordionStoryWithExternalNavigation, +}; + export const withIcon: Story = { args: { Icon: IconBranch,