diff --git a/i18n/keys.json b/i18n/keys.json index 89a56b710..917b590ea 100644 --- a/i18n/keys.json +++ b/i18n/keys.json @@ -58,6 +58,9 @@ "global_navigation.sidebar.dock": { "defaultMessage": "Dock sidebar" }, + "global_navigation.sidebar.open": { + "defaultMessage": "Open sidebar" + }, "global_navigation.sidebar.undock": { "defaultMessage": "Undock sidebar" }, diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 478b50b3c..273eaf5e6 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -19,13 +19,17 @@ */ import styled from '@emotion/styled'; -import { forwardRef, ReactNode, useEffect, useMemo, useState } from 'react'; -import { cssVar, designToken } from '~utils/design-tokens'; -import { LayoutContext } from './LayoutContext'; +import { type ReactNode, type Ref } from 'react'; +import { cssVar } from '~utils/design-tokens'; +import { LayoutSidebarContext } from './LayoutSidebarContext'; +import { SIDEBAR_INTERACTION_ZONE_ATTRIBUTE } from './LayoutSidebarInteraction'; import { GlobalGridArea } from './LayoutTypes'; +import { useLayoutSidebarState } from './useLayoutSidebarState'; export interface LayoutProps { + /** Optional CSS class name applied to the layout root element */ className?: string; + /** Main layout content */ children: ReactNode; /** * Whether the sidebar should be initially docked 🦆 or not, useful to init with user preferences. @@ -35,63 +39,52 @@ export interface LayoutProps { * Callback function called when the sidebar docked state changes, useful to save user preferences. */ onSidebarDockedChange?: (isDocked: boolean) => void; + /** React ref forwarded to the layout root element */ + ref?: Ref; } -export const Layout = forwardRef((props, ref) => { - const { children, isSidebarInitiallyDocked, onSidebarDockedChange, ...htmlProps } = props; - const mediaQueryList = useMemo( - () => - globalThis.matchMedia( - `(min-width: ${designToken('layout-sidebar-navigation-sizes-breakpoint-dockable')})`, - ), - [], - ); - - const [hasSidebar, setHasSidebar] = useState(false); - const [isSidebarDocked, setIsSidebarDocked] = useState( - () => isSidebarInitiallyDocked ?? mediaQueryList.matches, - ); - const [isSidebarDockable, setIsSidebarDockable] = useState(() => mediaQueryList.matches); - - const layoutContextValue = useMemo( - () => ({ - hasSidebar, - isSidebarDocked, - setHasSidebar, - setIsSidebarDocked, - }), - [hasSidebar, isSidebarDocked], - ); - - useEffect(() => { - const handleMediaQueryChange = ({ matches: canDockSidebar }: MediaQueryListEvent) => { - setIsSidebarDockable(canDockSidebar); - }; +export function Layout(props: Readonly) { + const { + children, + className, + isSidebarInitiallyDocked, + onSidebarDockedChange, + ref, + ...htmlProps + } = props; - mediaQueryList.addEventListener('change', handleMediaQueryChange); - - return () => { - mediaQueryList.removeEventListener('change', handleMediaQueryChange); - }; - }, [mediaQueryList]); - - useEffect(() => { - onSidebarDockedChange?.(isSidebarDocked); - }, [isSidebarDocked, onSidebarDockedChange]); + const sidebar = useLayoutSidebarState({ + isSidebarInitiallyDocked, + onSidebarDockedChange, + }); return ( - {children} + + {children} + + { + sidebar.handleInteractionZoneMouseLeave(event.relatedTarget); + }} + /> + ); -}); +} + Layout.displayName = 'Layout'; const Viewport = styled.div` @@ -102,6 +95,7 @@ const Viewport = styled.div` height: 100vh; width: 100vw; `; + Viewport.displayName = 'Viewport'; const MainGrid = styled.div` @@ -118,4 +112,26 @@ const MainGrid = styled.div` '${GlobalGridArea.globalNav} ${GlobalGridArea.globalNav}' '${GlobalGridArea.sidebar} ${GlobalGridArea.content}'; `; + MainGrid.displayName = 'MainGrid'; + +// Pointer-only zone that covers the full sidebar width above the floating sidebar +const LayoutSidebarTopInteractionZone = styled.div` + grid-column: 1; + grid-row: 1 / 3; + inset: 0; + pointer-events: none; + position: absolute; + + width: calc( + ${cssVar('layout-sidebar-navigation-sizes-width-open')} + ${cssVar('border-width-default')} + ); + + z-index: 0; + + [data-sidebar-docked='false'][data-sidebar-open='true'] & { + pointer-events: auto; + } +`; + +LayoutSidebarTopInteractionZone.displayName = 'LayoutSidebarTopInteractionZone'; diff --git a/src/components/layout/LayoutSidebarContext.ts b/src/components/layout/LayoutSidebarContext.ts new file mode 100644 index 000000000..c704f35ca --- /dev/null +++ b/src/components/layout/LayoutSidebarContext.ts @@ -0,0 +1,49 @@ +/* + * 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, Dispatch, SetStateAction } from 'react'; + +export interface LayoutSidebarContextShape { + close: VoidFunction; + handleInteractionZoneBlur: (relatedTarget: EventTarget | null) => void; + handleInteractionZoneMouseLeave: (relatedTarget: EventTarget | null) => void; + ignoreNextInteractionZoneBlur: VoidFunction; + isDockable: boolean; + isDocked: boolean; + isInLayout: boolean; + isOpen: boolean; + open: VoidFunction; + setIsDocked: Dispatch>; + setIsInLayout: Dispatch>; +} + +export const LayoutSidebarContext = createContext({ + close: () => {}, + handleInteractionZoneBlur: () => {}, + handleInteractionZoneMouseLeave: () => {}, + ignoreNextInteractionZoneBlur: () => {}, + isDockable: false, + isDocked: false, + isInLayout: false, + isOpen: false, + open: () => {}, + setIsDocked: () => {}, + setIsInLayout: () => {}, +}); diff --git a/src/components/layout/LayoutContext.ts b/src/components/layout/LayoutSidebarInteraction.ts similarity index 63% rename from src/components/layout/LayoutContext.ts rename to src/components/layout/LayoutSidebarInteraction.ts index 1463bc27d..12e843d61 100644 --- a/src/components/layout/LayoutContext.ts +++ b/src/components/layout/LayoutSidebarInteraction.ts @@ -18,18 +18,15 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { createContext, Dispatch, SetStateAction } from 'react'; +import { isDefined } from '~common/helpers/types'; -export interface LayoutContextShape { - hasSidebar: boolean; - isSidebarDocked: boolean; - setHasSidebar: Dispatch>; - setIsSidebarDocked: Dispatch>; -} +// Elements marked with this attribute belong to the undocked sidebar interaction zone. +// Moving pointer or focus between them must not close the sidebar. +export const SIDEBAR_INTERACTION_ZONE_ATTRIBUTE = 'data-sidebar-interaction-zone'; -export const LayoutContext = createContext({ - hasSidebar: false, - isSidebarDocked: false, - setHasSidebar: () => {}, - setIsSidebarDocked: () => {}, -}); +export function isWithinSidebarInteractionZone(target: EventTarget | null) { + return ( + target instanceof Element && + isDefined(target.closest(`[${SIDEBAR_INTERACTION_ZONE_ATTRIBUTE}='true']`)) + ); +} diff --git a/src/components/layout/__tests__/Layout-test.tsx b/src/components/layout/__tests__/Layout-test.tsx index efd59be02..b48260e90 100644 --- a/src/components/layout/__tests__/Layout-test.tsx +++ b/src/components/layout/__tests__/Layout-test.tsx @@ -18,11 +18,15 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { act, screen } from '@testing-library/react'; +import { matchers } from '@emotion/jest'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { useContext, useEffect } from 'react'; +import { isDefined } from '~common/helpers/types'; import { Layout } from '..'; -import { render } from '../../../common/helpers/test-utils'; -import { LayoutContext } from '../LayoutContext'; +import { render, renderWithMemoryRouter } from '../../../common/helpers/test-utils'; +import { LayoutSidebarContext } from '../LayoutSidebarContext'; + +expect.extend(matchers); const mediaQueryListMock = { matches: true, @@ -30,14 +34,15 @@ const mediaQueryListMock = { removeEventListener: jest.fn(), }; -// Mock window.matchMedia globally -Object.defineProperty(window, 'matchMedia', { +// Mock matchMedia globally +Object.defineProperty(globalThis, 'matchMedia', { writable: true, value: jest.fn().mockReturnValue(mediaQueryListMock), }); beforeEach(() => { jest.clearAllMocks(); + mediaQueryListMock.matches = true; }); it('should render correctly', () => { @@ -71,14 +76,16 @@ describe('Layout Sidebar docking behavior', () => { it('should handle media query changes and update dockable state', () => { render( - + , ); // Initially should be docked (wide screen initializes isSidebarDocked to true) expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-docked', 'true'); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-is-dockable', 'true'); - expect(screen.getByText('isSidebarDocked:true')).toBeInTheDocument(); + expect(screen.getByText('isDocked:true')).toBeInTheDocument(); + expect(screen.getByText('isOpen:true')).toBeInTheDocument(); // Simulate media query change to narrow screen act(() => { @@ -88,71 +95,367 @@ describe('Layout Sidebar docking behavior', () => { // Context should be re-rendered, so we need to check current state expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-docked', 'false'); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-is-dockable', 'false'); // Context should remember last user choice - expect(screen.getByText('isSidebarDocked:true')).toBeInTheDocument(); + expect(screen.getByText('isDocked:true')).toBeInTheDocument(); + expect(screen.getByText('isOpen:false')).toBeInTheDocument(); + }); + + it('should absolutely position the top interaction zone so it does not size the sidebar column', () => { + render( + +
Test
+
, + ); + + const topInteractionZone = screen.getByTestId('sidebar-top-interaction-zone'); + + expect(topInteractionZone).toHaveStyleRule('position', 'absolute'); + expect(topInteractionZone).toHaveStyleRule('inset', '0'); }); }); -describe('LayoutContext', () => { - it('should update LayoutContext state when it changes', async () => { +describe('LayoutSidebarContext', () => { + it('should update LayoutSidebarContext state when it changes', async () => { const onSidebarDockedChange = jest.fn(); + const { user } = render( , ); - expect(screen.getByText('hasSidebar:false')).toBeInTheDocument(); + expect(screen.getByText('isInLayout:false')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Set Sidebar' })); - expect(screen.getByText('hasSidebar:true')).toBeInTheDocument(); - expect(screen.getByText('isSidebarDocked:true')).toBeInTheDocument(); + expect(screen.getByText('isInLayout:true')).toBeInTheDocument(); + expect(screen.getByText('isDocked:true')).toBeInTheDocument(); + expect(screen.getByText('isOpen:true')).toBeInTheDocument(); expect(onSidebarDockedChange).toHaveBeenLastCalledWith(true); await user.click(screen.getByRole('button', { name: 'Unset Docked' })); - expect(screen.getByText('isSidebarDocked:false')).toBeInTheDocument(); + expect(screen.getByText('isDocked:false')).toBeInTheDocument(); + expect(screen.getByText('isOpen:false')).toBeInTheDocument(); expect(onSidebarDockedChange).toHaveBeenLastCalledWith(false); }); - it('should respect isSidebarInitiallyDocked prop when its present', () => { + it('should respect isSidebarInitiallyDocked prop when it is provided', () => { render( - + , ); - expect(screen.getByText('hasSidebar:true')).toBeInTheDocument(); - expect(screen.getByText('isSidebarDocked:false')).toBeInTheDocument(); + expect(screen.getByText('isInLayout:true')).toBeInTheDocument(); + expect(screen.getByText('isDocked:false')).toBeInTheDocument(); + expect(screen.getByText('isOpen:false')).toBeInTheDocument(); + }); + + it('should open the non-dockable sidebar from the trigger button', async () => { + mediaQueryListMock.matches = false; + + const { user } = setupLayoutWithSidebar(); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + + await user.click(screen.getByRole('button', { name: 'Open sidebar' })); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + }); + + it('should close the non-dockable sidebar on focus exit after a pointer-open that does not focus the trigger', async () => { + mediaQueryListMock.matches = false; + + setupLayoutWithSidebar(); + + const trigger = screen.getByRole('button', { name: 'Open sidebar' }); + const homeLink = screen.getByRole('link', { name: 'Brand home' }); + const pageContentButton = screen.getByRole('button', { name: 'Page content action' }); + + fireEvent.click(trigger, { detail: 1 }); + + expect(trigger).not.toHaveFocus(); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + act(() => { + homeLink.focus(); + }); + + act(() => { + pageContentButton.focus(); + }); + + await waitFor(() => { + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + }); + }); + + it('should close the non-dockable sidebar on pointer exit after opening it from the trigger button', async () => { + mediaQueryListMock.matches = false; + + const { user } = setupLayoutWithSidebar(); + const trigger = screen.getByRole('button', { name: 'Open sidebar' }); + + await user.click(trigger); + + expect(trigger).not.toHaveFocus(); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + await user.unhover(trigger); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + }); + + it('should open and close the non-dockable sidebar on trigger hover', async () => { + mediaQueryListMock.matches = false; + + const { user } = setupLayoutWithSidebar(); + const trigger = screen.getByRole('button', { name: 'Open sidebar' }); + + await user.hover(trigger); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + await user.unhover(trigger); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + }); + + it('should keep the undocked sidebar open while focus remains inside', async () => { + const { user } = setupLayoutWithSidebar({ isSidebarInitiallyDocked: false }); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + + await user.tab(); + expect(screen.getByRole('button', { name: 'Dock sidebar' })).toHaveFocus(); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + const sidebarHeaderButton = getSidebarHeaderButton(); + + act(() => { + sidebarHeaderButton.focus(); + }); + + expect(sidebarHeaderButton).toHaveFocus(); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + act(() => { + screen.getByRole('button', { name: 'Page content action' }).focus(); + }); + + expect(screen.getByRole('button', { name: 'Page content action' })).toHaveFocus(); + + await waitFor(() => { + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + }); + }); + + it('should keep the non-dockable sidebar open when opening it with the keyboard', async () => { + mediaQueryListMock.matches = false; + + const { user } = setupLayoutWithSidebar(); + + await user.tab(); + + const trigger = screen.getByRole('button', { name: 'Open sidebar' }); + expect(trigger).toHaveFocus(); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + await user.keyboard('{Enter}'); + + expect(trigger).toHaveFocus(); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + }); + + it('should hide the sidebar when undocking it on dockable screens', async () => { + const { user } = setupLayoutWithSidebar(); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-docked', 'true'); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + await user.click(screen.getByRole('button', { name: 'Undock sidebar' })); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-docked', 'false'); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + }); + + it('should close the undocked sidebar on pointer exit after pointer undocking', async () => { + const { user } = setupLayoutWithSidebar(); + const trigger = screen.getByRole('button', { name: 'Undock sidebar' }); + + await user.click(trigger); + + expect(trigger).not.toHaveFocus(); + + await user.unhover(trigger); + + await user.hover(trigger); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + await user.unhover(trigger); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + }); + + it('should keep the undocked sidebar open while moving from the trigger to the home area', async () => { + const { user } = setupLayoutWithSidebar(); + const trigger = screen.getByRole('button', { name: 'Undock sidebar' }); + const homeLink = screen.getByRole('link', { name: 'Brand home' }); + + await user.click(trigger); + + fireEvent.mouseOver(trigger); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + fireEvent.mouseOut(trigger, { relatedTarget: homeLink }); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + fireEvent.mouseOut(homeLink, { relatedTarget: null }); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'false'); + }); + + it('should keep the undocked sidebar open while moving from the trigger through the top interaction zone', async () => { + const { user } = setupLayoutWithSidebar(); + const trigger = screen.getByRole('button', { name: 'Undock sidebar' }); + const topInteractionZone = getSidebarTopInteractionZone(); + const sidebarHeaderButton = getSidebarHeaderButton(); + + await user.click(trigger); + + fireEvent.mouseOver(trigger); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + fireEvent.mouseOut(trigger, { relatedTarget: topInteractionZone }); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + fireEvent.mouseOut(topInteractionZone, { relatedTarget: sidebarHeaderButton }); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + }); + + it('should keep the undocked sidebar open while moving slowly straight down from the trigger', async () => { + const { user } = setupLayoutWithSidebar(); + const trigger = screen.getByRole('button', { name: 'Undock sidebar' }); + const topInteractionZone = getSidebarTopInteractionZone(); + const sidebarHeaderButton = getSidebarHeaderButton(); + + await user.click(trigger); + + fireEvent.mouseOver(trigger); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + fireEvent.mouseOut(trigger, { relatedTarget: topInteractionZone }); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + + fireEvent.mouseOut(topInteractionZone, { relatedTarget: sidebarHeaderButton }); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + }); + + it('should keep the undocked sidebar open when undocking it with the keyboard', async () => { + const { user } = setupLayoutWithSidebar(); + + await user.tab(); + + const trigger = screen.getByRole('button', { name: 'Undock sidebar' }); + expect(trigger).toHaveFocus(); + + await user.keyboard('{Enter}'); + + expect(screen.getByRole('button', { name: 'Dock sidebar' })).toHaveFocus(); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-docked', 'false'); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); + }); + + it('should dock the sidebar when the trigger button is clicked on dockable screens', async () => { + const { user } = setupLayoutWithSidebar({ isSidebarInitiallyDocked: false }); + + await user.click(screen.getByRole('button', { name: 'Dock sidebar' })); + + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-docked', 'true'); + expect(screen.getByTestId('layout')).toHaveAttribute('data-sidebar-open', 'true'); }); }); -function TestContextConsumer({ hasSidebarDefault }: { hasSidebarDefault?: boolean }) { - const context = useContext(LayoutContext); +function TestContextConsumer({ + isSidebarInLayoutDefault, +}: Readonly<{ isSidebarInLayoutDefault?: boolean }>) { + const context = useContext(LayoutSidebarContext); useEffect(() => { - if (hasSidebarDefault !== undefined) { - context.setHasSidebar(hasSidebarDefault); + if (isDefined(isSidebarInLayoutDefault)) { + context.setIsInLayout(isSidebarInLayoutDefault); } - }, [context, hasSidebarDefault]); + }, [context, isSidebarInLayoutDefault]); return (
-
{`hasSidebar:${context.hasSidebar.toString()}`}
-
{`isSidebarDocked:${context.isSidebarDocked.toString()}`}
- - - -
); } + +function setupLayoutWithSidebar({ + isSidebarInitiallyDocked, +}: { + isSidebarInitiallyDocked?: boolean; +} = {}) { + return renderWithMemoryRouter( + + + + + Brand + + + + + + + + + + + + , + ); +} + +function getSidebarTopInteractionZone() { + const topInteractionZone = screen.getByTestId('sidebar-top-interaction-zone'); + + if (!(topInteractionZone instanceof HTMLElement)) { + throw new TypeError('Missing sidebar top interaction zone'); + } + + return topInteractionZone; +} + +function getSidebarHeaderButton() { + const sidebarHeader = screen.getByRole('button', { name: 'Sidebar header' }); + + if (!(sidebarHeader instanceof HTMLButtonElement)) { + throw new TypeError('Missing sidebar header button'); + } + + return sidebarHeader; +} diff --git a/src/components/layout/global-navigation/GlobalNavigation.tsx b/src/components/layout/global-navigation/GlobalNavigation.tsx index a12d03a77..d00b1edbb 100644 --- a/src/components/layout/global-navigation/GlobalNavigation.tsx +++ b/src/components/layout/global-navigation/GlobalNavigation.tsx @@ -19,18 +19,22 @@ */ import styled from '@emotion/styled'; -import { forwardRef } from 'react'; +import { type Ref } from 'react'; import { useIntl } from 'react-intl'; import { cssVar } from '~utils/design-tokens'; import { GlobalGridArea } from '../LayoutTypes'; export interface GlobalNavigationProps extends React.PropsWithChildren { - className?: string; + /** Optional ARIA label applied to the root navigation element */ ariaLabel?: string; + /** Optional CSS class name applied to the root navigation element */ + className?: string; + /** React ref forwarded to the root navigation element */ + ref?: Ref; } -export const GlobalNavigationRoot = forwardRef((props, ref) => { - const { children, ariaLabel, ...rest } = props; +export function GlobalNavigationRoot(props: Readonly) { + const { ariaLabel, children, ref, ...rest } = props; const intl = useIntl(); const defaultAriaLabel = intl.formatMessage({ @@ -44,7 +48,7 @@ export const GlobalNavigationRoot = forwardRef ); -}); +} GlobalNavigationRoot.displayName = 'GlobalNavigation'; @@ -62,8 +66,15 @@ const GlobalNavigationContainer = styled.nav` background-color: ${cssVar('color-surface-default')}; border-bottom: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; + pointer-events: none; + + & > * { + pointer-events: auto; + } + z-index: 1; // Ensure the global navigation is showing over the content `; + GlobalNavigationContainer.displayName = 'GlobalNavigationContainer'; export const GlobalNavigationSecondary = styled.div` @@ -72,5 +83,7 @@ export const GlobalNavigationSecondary = styled.div` height: 100%; gap: ${cssVar('dimension-space-100')}; + pointer-events: auto; `; + GlobalNavigationSecondary.displayName = 'GlobalNavigationSecondary'; diff --git a/src/components/layout/global-navigation/GlobalNavigationHome.tsx b/src/components/layout/global-navigation/GlobalNavigationHome.tsx index e277f8f07..6347bf55b 100644 --- a/src/components/layout/global-navigation/GlobalNavigationHome.tsx +++ b/src/components/layout/global-navigation/GlobalNavigationHome.tsx @@ -19,46 +19,52 @@ */ import styled from '@emotion/styled'; -import { forwardRef } from 'react'; +import { type ReactNode, type Ref } from 'react'; import { useIntl } from 'react-intl'; -import { LinkProps } from 'react-router-dom'; +import type { LinkProps } from 'react-router-dom'; import { LinkStandalone } from '../../links'; import { cssVar } from '~utils/design-tokens'; export interface GlobalNavigationHomeProps { - children: React.ReactNode; - className?: string; + /** Optional ARIA label applied to the home link */ ariaLabel?: string; - reloadDocument?: LinkProps['reloadDocument']; + /** Content rendered inside the home link */ + children: ReactNode; + /** Optional CSS class name applied to the root container */ + className?: string; + /** React ref forwarded to the root container */ + ref?: Ref; + /** Whether the home link should force a full page reload */ + reloadDocument?: NonNullable; + /** + * Target location for the home link. + * @defaultValue '/' + */ to?: LinkProps['to']; } -export const GlobalNavigationHome = forwardRef( - ( - { children, ariaLabel, reloadDocument, to = '/', ...rest }: Readonly, - ref, - ) => { - const intl = useIntl(); - - const defaultAriaLabel = intl.formatMessage({ - id: 'global_navigation.home_logo', - defaultMessage: 'Link to home page', - description: 'ARIA-label for the brand link to home page', - }); - - return ( - - - {children} - - - ); - }, -); +export function GlobalNavigationHome(props: Readonly) { + const { children, ariaLabel, ref, reloadDocument, to = '/', ...rest } = props; + const intl = useIntl(); + + const defaultAriaLabel = intl.formatMessage({ + id: 'global_navigation.home_logo', + defaultMessage: 'Link to home page', + description: 'ARIA-label for the brand link to home page', + }); + + return ( + + + {children} + + + ); +} GlobalNavigationHome.displayName = 'GlobalNavigationHome'; @@ -66,6 +72,7 @@ const HomeContainer = styled.div` padding-left: ${cssVar('dimension-space-150')}; padding-right: ${cssVar('dimension-space-300')}; `; + HomeContainer.displayName = 'HomeContainer'; const StyledLinkStandalone = styled(LinkStandalone)` @@ -84,6 +91,7 @@ const StyledLinkStandalone = styled(LinkStandalone)` background-color: ${cssVar('color-surface-active')}; } `; + StyledLinkStandalone.displayName = 'StyledLinkStandalone'; const LogoContainer = styled.div` @@ -97,4 +105,5 @@ const LogoContainer = styled.div` object-fit: contain; } `; + LogoContainer.displayName = 'LogoContainer'; diff --git a/src/components/layout/global-navigation/GlobalNavigationPrimary.tsx b/src/components/layout/global-navigation/GlobalNavigationPrimary.tsx index 22b703d38..6d84c56f0 100644 --- a/src/components/layout/global-navigation/GlobalNavigationPrimary.tsx +++ b/src/components/layout/global-navigation/GlobalNavigationPrimary.tsx @@ -19,65 +19,224 @@ */ import styled from '@emotion/styled'; -import { forwardRef, useContext } from 'react'; + +import { + Children, + type MouseEvent, + type PropsWithChildren, + type Ref, + useCallback, + useContext, +} from 'react'; + import { useIntl } from 'react-intl'; import { cssVar } from '~utils/design-tokens'; import { ButtonIcon } from '../../buttons'; import { IconDockToRight } from '../../icons'; -import { LayoutContext } from '../LayoutContext'; +import { LayoutSidebarContext } from '../LayoutSidebarContext'; + +import { SIDEBAR_INTERACTION_ZONE_ATTRIBUTE } from '../LayoutSidebarInteraction'; + +const GLOBAL_NAVIGATION_PRIMARY_GAP_TOKEN = 'dimension-space-150'; -export interface GlobalNavigationPrimaryProps extends React.PropsWithChildren { +export interface GlobalNavigationPrimaryProps extends PropsWithChildren { + /** Optional CSS class name applied to the root element */ className?: string; + /** React ref forwarded to the root element */ + ref?: Ref; +} + +/** + * Browsers dispatch keyboard activation through `click` with `detail === 0`, while + * pointer clicks report a positive `detail`. + */ +function isKeyboardTriggeredClickEvent(event: MouseEvent) { + return event.detail === 0; } -export const GlobalNavigationPrimary = forwardRef( - (props, ref) => { - const { children, ...htmlProps } = props; - const { isSidebarDocked, setIsSidebarDocked } = useContext(LayoutContext); - const intl = useIntl(); - - return ( - - ) { + return event.detail > 0; +} + +/** + * Pointer clicks can leave the trigger focused, so blur it after opening the + * floating sidebar to avoid an immediate re-close race with the blur handler. + */ +function shouldBlurSidebarTriggerAfterPointerClick(event: MouseEvent) { + return isPointerClickEvent(event) && globalThis.document.activeElement === event.currentTarget; +} + +export function GlobalNavigationPrimary(props: Readonly) { + const { children, className, ref, ...htmlProps } = props; + const [leadingContent, ...trailingContent] = Children.toArray(children); + const sidebar = useContext(LayoutSidebarContext); + + const intl = useIntl(); + + const handleOpenSidebarOnInteraction = useCallback(() => { + if (!sidebar.isDocked || !sidebar.isDockable) { + sidebar.open(); + } + }, [sidebar]); + + const handleSidebarButtonClick = useCallback( + (event: MouseEvent) => { + if (sidebar.isDockable) { + if (sidebar.isDocked) { + if (isKeyboardTriggeredClickEvent(event)) { + sidebar.open(); + } else { + sidebar.close(); + event.currentTarget.blur(); } - onClick={() => { - setIsSidebarDocked((isSidebarDocked) => !isSidebarDocked); + } + + sidebar.setIsDocked((isDocked) => !isDocked); + + return; + } + + sidebar.open(); + + if (shouldBlurSidebarTriggerAfterPointerClick(event)) { + // Pointer clicks blur the trigger before focus can move into the floating sidebar. + // Ignore that blur once so the sidebar stays open for the current interaction. + sidebar.ignoreNextInteractionZoneBlur(); + event.currentTarget.blur(); + } + }, + [sidebar], + ); + + let sidebarButtonAriaLabel; + let sidebarButtonTooltipContent; + + if (sidebar.isDockable) { + if (sidebar.isDocked) { + sidebarButtonAriaLabel = intl.formatMessage({ + id: 'global_navigation.sidebar.undock', + defaultMessage: 'Undock sidebar', + }); + } else { + sidebarButtonAriaLabel = intl.formatMessage({ + id: 'global_navigation.sidebar.dock', + defaultMessage: 'Dock sidebar', + }); + } + } else { + sidebarButtonAriaLabel = intl.formatMessage({ + id: 'global_navigation.sidebar.open', + defaultMessage: 'Open sidebar', + }); + + sidebarButtonTooltipContent = ''; + } + + return ( + + {sidebar.isInLayout && ( + { + sidebar.handleInteractionZoneBlur(event.relatedTarget); }} - variety="default-ghost" - /> - {children} - - ); - }, -); + onFocus={handleOpenSidebarOnInteraction} + onMouseLeave={(event) => { + sidebar.handleInteractionZoneMouseLeave(event.relatedTarget); + }}> + + + + + )} + + {sidebar.isInLayout && leadingContent && ( + { + sidebar.handleInteractionZoneBlur(event.relatedTarget); + }} + onMouseLeave={(event) => { + sidebar.handleInteractionZoneMouseLeave(event.relatedTarget); + }}> + {leadingContent} + + )} + + + {sidebar.isInLayout ? trailingContent : children} + + + ); +} + GlobalNavigationPrimary.displayName = 'GlobalNavigationPrimary'; const GlobalNavigationPrimaryContainer = styled.div` + position: relative; display: flex; align-items: center; height: 100%; - gap: ${cssVar('dimension-space-150')}; + gap: ${cssVar(GLOBAL_NAVIGATION_PRIMARY_GAP_TOKEN)}; + pointer-events: none; `; + GlobalNavigationPrimaryContainer.displayName = 'GlobalNavigationPrimaryContainer'; -const GlobalNavigationSidebarDockButton = styled(ButtonIcon)` - display: none; - color: ${cssVar('color-icon-subtle')}; +const GlobalNavigationPrimaryContent = styled.div` + display: flex; + align-items: center; + + gap: ${cssVar(GLOBAL_NAVIGATION_PRIMARY_GAP_TOKEN)}; + position: relative; + pointer-events: none; + z-index: 1; - [data-sidebar-is-dockable='true'][data-sidebar-exist='true'] & { - display: inline-flex; + & > * { + pointer-events: auto; } `; + +GlobalNavigationPrimaryContent.displayName = 'GlobalNavigationPrimaryContent'; + +const GlobalNavigationSidebarTriggerArea = styled.div` + align-items: center; + display: flex; + height: 100%; + pointer-events: auto; + position: relative; + z-index: 1; +`; + +GlobalNavigationSidebarTriggerArea.displayName = 'GlobalNavigationSidebarTriggerArea'; + +const GlobalNavigationSidebarTriggerHoverArea = styled.div` + display: flex; +`; + +GlobalNavigationSidebarTriggerHoverArea.displayName = 'GlobalNavigationSidebarTriggerHoverArea'; + +const GlobalNavigationSidebarLeadingZone = styled.div` + align-items: center; + display: flex; + height: 100%; + pointer-events: auto; + position: relative; + z-index: 2; +`; + +GlobalNavigationSidebarLeadingZone.displayName = 'GlobalNavigationSidebarLeadingZone'; + +const GlobalNavigationSidebarDockButton = styled(ButtonIcon)` + flex-shrink: 0; +`; + GlobalNavigationSidebarDockButton.displayName = 'GlobalNavigationSidebarDockButton'; diff --git a/src/components/layout/global-navigation/__tests__/GlobalNavigation-test.tsx b/src/components/layout/global-navigation/__tests__/GlobalNavigation-test.tsx index 7cf0dc42d..4e02bcec0 100644 --- a/src/components/layout/global-navigation/__tests__/GlobalNavigation-test.tsx +++ b/src/components/layout/global-navigation/__tests__/GlobalNavigation-test.tsx @@ -26,7 +26,7 @@ import { LogoSonarQubeCloud } from '../../../logos'; describe('GlobalNavigation', () => { it('should render children inside the GlobalNavigation', () => { - render( + setupGlobalNavigation(
Test
, @@ -36,11 +36,12 @@ describe('GlobalNavigation', () => { }); it('should render GlobalNavigation.Primary content and GlobalNavigation.Secondary content', () => { - render( + setupGlobalNavigation(
Left Content
+
Right Content
@@ -50,6 +51,22 @@ describe('GlobalNavigation', () => { expect(screen.getByText('Left Content')).toBeInTheDocument(); expect(screen.getByText('Right Content')).toBeInTheDocument(); }); + + it('should keep direct custom children clickable', async () => { + const onClick = jest.fn(); + + const { user } = setupGlobalNavigation( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Custom action' })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); }); describe('GlobalNavigation.Home', () => { @@ -71,6 +88,7 @@ describe('GlobalNavigation.Home', () => { const { user } = setupWithMemoryRouter( Home Page} path="/" /> + @@ -109,6 +127,7 @@ describe('GlobalNavigation.Home', () => { const { user } = setupWithMemoryRouter( Dashboard Page} path="/dashboard" /> + @@ -138,3 +157,7 @@ const setupWithMemoryRouter = (children: React.ReactNode) => { , ); }; + +const setupGlobalNavigation = (children: React.ReactElement) => { + return render(children); +}; diff --git a/src/components/layout/global-navigation/__tests__/GlobalNavigationPrimary-test.tsx b/src/components/layout/global-navigation/__tests__/GlobalNavigationPrimary-test.tsx index 5cdb87c70..37060f53d 100644 --- a/src/components/layout/global-navigation/__tests__/GlobalNavigationPrimary-test.tsx +++ b/src/components/layout/global-navigation/__tests__/GlobalNavigationPrimary-test.tsx @@ -18,38 +18,31 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { render } from '~common/helpers/test-utils'; -import { LayoutContext } from '../../LayoutContext'; +import { LayoutSidebarContext, type LayoutSidebarContextShape } from '../../LayoutSidebarContext'; import { GlobalNavigationPrimary } from '../GlobalNavigationPrimary'; it('should render correctly', () => { - render( - -
Test
-
, - ); + setupGlobalNavigationPrimary(); expect(screen.getByText('Test')).toBeInTheDocument(); }); it('should display the sidebar dock button when the sidebar is dockable', async () => { + const closeSidebar = jest.fn(); + const openSidebar = jest.fn(); const setIsSidebarDocked = jest.fn(); - const { user } = render( - -
- -
Test
-
-
-
, - ); + + const { user } = setupGlobalNavigationPrimary({ + close: closeSidebar, + isDockable: true, + isDocked: false, + isOpen: false, + isInLayout: true, + open: openSidebar, + setIsDocked: setIsSidebarDocked, + }); expect(screen.getByRole('button', { name: 'Dock sidebar' })).toBeVisible(); @@ -58,24 +51,93 @@ it('should display the sidebar dock button when the sidebar is dockable', async expect(setIsSidebarDocked).toHaveBeenCalled(); }); -it('should not have the sidebar dock button when the sidebar does not exist', () => { - render( - -
Test
-
, - ); +it('should display the sidebar open button when the sidebar is not dockable', async () => { + const openSidebar = jest.fn(); + + const { user } = setupGlobalNavigationPrimary({ + isDockable: false, + isDocked: false, + isOpen: false, + isInLayout: true, + open: openSidebar, + }); + + await user.click(screen.getByRole('button', { name: 'Open sidebar' })); + + expect(openSidebar).toHaveBeenCalled(); +}); + +it('should not display a tooltip for the sidebar open button when the sidebar is not dockable', async () => { + const { user } = setupGlobalNavigationPrimary({ + isDockable: false, + isDocked: false, + isOpen: false, + isInLayout: true, + }); + + await user.hover(screen.getByRole('button', { name: 'Open sidebar' })); + + expect(screen.queryByRole('tooltip', { name: 'Open sidebar' })).not.toBeInTheDocument(); +}); + +it('should open the undocked sidebar on trigger hover', async () => { + const openSidebar = jest.fn(); + + const { user } = setupGlobalNavigationPrimary({ + isDockable: true, + isDocked: false, + isOpen: false, + isInLayout: true, + open: openSidebar, + }); + + await user.hover(screen.getByRole('button', { name: 'Dock sidebar' })); + + expect(openSidebar).toHaveBeenCalled(); +}); + +it('should not open the undocked sidebar when hovering the trigger area outside the button', () => { + const openSidebar = jest.fn(); + + setupGlobalNavigationPrimary({ + isDockable: true, + isDocked: false, + isOpen: false, + isInLayout: true, + open: openSidebar, + }); + + fireEvent.mouseEnter(screen.getByTestId('global-navigation-sidebar-trigger-area')); + + expect(openSidebar).not.toHaveBeenCalled(); +}); + +it('should not render the sidebar trigger button when the sidebar does not exist', () => { + setupGlobalNavigationPrimary(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); -it('should not have the sidebar dock button when the sidebar exist but is not dockable', () => { - render( -
+function setupGlobalNavigationPrimary(contextOverrides: Partial = {}) { + const defaultLayoutSidebarContext: LayoutSidebarContextShape = { + close: jest.fn(), + handleInteractionZoneBlur: jest.fn(), + handleInteractionZoneMouseLeave: jest.fn(), + isDockable: true, + isDocked: false, + isOpen: false, + isInLayout: false, + open: jest.fn(), + setIsDocked: jest.fn(), + setIsInLayout: jest.fn(), + ignoreNextInteractionZoneBlur: jest.fn(), + }; + + return render( +
Test
-
, + , ); - - expect(screen.queryByRole('button')).not.toBeInTheDocument(); -}); +} diff --git a/src/components/layout/sidebar-navigation/SidebarNavigation.tsx b/src/components/layout/sidebar-navigation/SidebarNavigation.tsx index 1c54a5718..ef808abd9 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigation.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigation.tsx @@ -19,33 +19,44 @@ */ import styled from '@emotion/styled'; -import { forwardRef, PropsWithChildren, useContext, useEffect } from 'react'; + +import { type PropsWithChildren, type Ref, useContext, useLayoutEffect } from 'react'; + import { useIntl } from 'react-intl'; import { cssVar } from '~utils/design-tokens'; -import { LayoutContext } from '../LayoutContext'; +import { LayoutSidebarContext } from '../LayoutSidebarContext'; + +import { SIDEBAR_INTERACTION_ZONE_ATTRIBUTE } from '../LayoutSidebarInteraction'; + import { GlobalGridArea } from '../LayoutTypes'; +const SIDEBAR_NAVIGATION_ATTRIBUTE = 'data-sidebar-navigation'; + export interface SidebarNavigationProps { /** * Sidebar navigation Aria-label, defaults to "Secondary navigation" */ ariaLabel?: string; + /** Optional CSS class name applied to the root container */ + className?: string; + /** React ref forwarded to the root navigation element */ + ref?: Ref; } -export const SidebarNavigation = forwardRef< - HTMLDivElement, - PropsWithChildren ->((props, ref) => { - const { ariaLabel, children } = props; +export function SidebarNavigation(props: Readonly>) { + const { ariaLabel, children, className, ref, ...rest } = props; const intl = useIntl(); - const { setHasSidebar } = useContext(LayoutContext); + const sidebar = useContext(LayoutSidebarContext); + const { setIsInLayout } = sidebar; + + // Mark the sidebar as in-layout before paint to avoid a docked/closed mount flicker + useLayoutEffect(() => { + setIsInLayout(true); - useEffect(() => { - setHasSidebar(true); return () => { - setHasSidebar(false); + setIsInLayout(false); }; - }, [setHasSidebar]); + }, [setIsInLayout]); const defaultAriaLabel = intl.formatMessage({ id: 'sidebar_navigation.label', @@ -53,14 +64,40 @@ export const SidebarNavigation = forwardRef< description: 'ARIA-label for the sidebar navigation', }); + function handleOpenSidebarOnInteraction() { + if (!sidebar.isDocked || !sidebar.isDockable) { + sidebar.open(); + } + } + return ( - - + { + sidebar.handleInteractionZoneMouseLeave(event.relatedTarget); + }}> + { + sidebar.handleInteractionZoneBlur(event.relatedTarget); + }} + onFocus={handleOpenSidebarOnInteraction} + ref={ref}> {children} ); -}); +} SidebarNavigation.displayName = 'SidebarNavigation'; @@ -68,43 +105,59 @@ const SidebarNavigationContainer = styled.div` grid-area: ${GlobalGridArea.sidebar}; position: relative; - width: calc(var(--sidebar-navigation-container-width) + ${cssVar('border-width-default')}); + width: ${cssVar('dimension-width-0')}; z-index: 1; // Ensure the sidebar is showing over the content - --sidebar-navigation-container-width: ${cssVar('layout-sidebar-navigation-sizes-width-closed')}; + [data-sidebar-docked='true'] &, + [data-sidebar-is-dockable='false'][data-sidebar-open='true'] & { + width: calc( + ${cssVar('layout-sidebar-navigation-sizes-width-open')} + ${cssVar('border-width-default')} + ); + } + + transition: width 0.1s; - [data-sidebar-docked='true'] & { - --sidebar-navigation-container-width: ${cssVar('layout-sidebar-navigation-sizes-width-open')}; + [data-sidebar-docked='false'] & { + transition: none; } `; + SidebarNavigationContainer.displayName = 'SidebarNavigationContainer'; const SidebarNavigationWrapper = styled.nav` - position: absolute; - top: 0; + background-color: ${cssVar('color-surface-default')}; + border-right: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; bottom: 0; + box-sizing: content-box; display: flex; flex-direction: column; - box-sizing: content-box; + left: 0; + opacity: 0; overflow: hidden; - - border-right: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; - background-color: ${cssVar('color-surface-default')}; - - transition: width 0.1s; - - width: var(--sidebar-navigation-width); - - --sidebar-navigation-width: ${cssVar('layout-sidebar-navigation-sizes-width-open')}; - - // hover and focus-within pilots the open state of the sidebar - [data-sidebar-docked='false'] &:not(:hover, :focus-within) { - --sidebar-navigation-width: ${cssVar('layout-sidebar-navigation-sizes-width-closed')}; + pointer-events: none; + position: absolute; + top: 0; + transform: translateX(-100%); + visibility: hidden; + width: ${cssVar('layout-sidebar-navigation-sizes-width-open')}; + + transition: + opacity 0.1s, + transform 0.1s, + visibility 0s linear 0.1s; + + [data-sidebar-open='true'] & { + opacity: 1; + pointer-events: auto; + transform: translateX(0); + transition-delay: 0s; + visibility: visible; } - [data-sidebar-docked='false'] &:is(:hover, :focus-within) { + [data-sidebar-docked='false'][data-sidebar-open='true'] & { box-shadow: ${cssVar('box-shadow-x-large')}; } `; + SidebarNavigationWrapper.displayName = 'SidebarNavigationWrapper'; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx index 63a62936e..732faa7c0 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationAccordionItem.tsx @@ -207,23 +207,14 @@ const AccordionItemPanel = styled.section` padding-right: ${cssVar('dimension-space-100')}; border-left: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; - // The children SidebarNavigationItems rely on this css property to set their display value, falling - // back to flex if not inside an accordion + // The children SidebarNavigationItems rely on this css property to set their display value, + // falling back to flex if not inside an accordion --sidebar-navigation-accordion-children-display: flex; - --sidebar-navigation-accordion-children-visibility: visible; - // The rule hide the child SidebarNavigationItems when the accordion is closed + // Hide the child SidebarNavigationItems when the accordion is closed &[data-accordion-open='false'] { --sidebar-navigation-accordion-children-display: none; } - - // This rule hides the SidebarNavigationItems when the accordion is open but the sidebar is collapsed - // using visibility to make sure it still takes space to avoid layout shift when hovering the sidebar - [data-sidebar-docked='false'] nav:not(:hover, :focus-within) & { - --sidebar-navigation-accordion-children-visibility: hidden; - --sidebar-navigation-accordion-children-outline: ${cssVar('color-surface-default')} solid - ${cssVar('focus-border-width-default')}; - } `; AccordionItemPanel.displayName = 'AccordionItemPanel'; @@ -239,11 +230,6 @@ const AccordionItemsList = styled.ul` // Override the gap to avoid extra space that shows the left border when the accordion is closed gap: ${cssVar('dimension-space-0')}; } - - [data-sidebar-docked='false'] nav:not(:hover, :focus-within) & { - margin-left: calc(-1 * ${cssVar('dimension-space-300')}); - width: ${cssVar('dimension-width-400')}; - } `; AccordionItemsList.displayName = 'AccordionItemsList'; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationBaseItem.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationBaseItem.tsx index 3268a26b4..ffc24e31a 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationBaseItem.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationBaseItem.tsx @@ -104,18 +104,13 @@ 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. + // When the item is inside an accordion, the display value changes based on the accordion state + // Outside of accordions, it falls back to flex 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. + // Active items stay visible even when the parent accordion is closed display: flex; visibility: visible; @@ -141,8 +136,7 @@ const sidebarNavigationBaseItemIconStyles = css` `; const sidebarNavigationBaseItemHideWhenSidebarOpenStyles = css` - [data-sidebar-docked='true'] &, - [data-sidebar-docked='false'] nav:is(:hover, :focus-within) & { + [data-sidebar-open='true'] & { display: none; } `; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationBody.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationBody.tsx index 71223e735..bf152b312 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationBody.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationBody.tsx @@ -17,18 +17,25 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import styled from '@emotion/styled'; -import { PropsWithChildren, useRef } from 'react'; +import { type PropsWithChildren, useRef } from 'react'; + import { BottomShadowScroll, TopShadowScroll, useShadowScroll, } from '~common/helpers/useShadowScroll'; + import { cssVar } from '~utils/design-tokens'; -export function SidebarNavigationBody({ children }: PropsWithChildren<{}>) { +const SIDEBAR_NAVIGATION_BODY_SPACE_50 = cssVar('dimension-space-50'); +const SIDEBAR_NAVIGATION_BODY_SPACE_100 = cssVar('dimension-space-100'); + +export function SidebarNavigationBody({ children }: Readonly>) { const scrollableContainerRef = useRef(null); const resizableContentRef = useRef(null); + const { showBottomShadow, showTopShadow } = useShadowScroll( scrollableContainerRef, resizableContentRef, @@ -36,19 +43,22 @@ export function SidebarNavigationBody({ children }: PropsWithChildren<{}>) { return ( - {showTopShadow && } + {showTopShadow && } + {children} - {showBottomShadow && } + + {showBottomShadow && } ); } + SidebarNavigationBody.displayName = 'SidebarNavigationBody'; -// This first layer wrapper allows to hold the bottom scroll shadow in place. +// This first layer wrapper allows to hold the bottom scroll shadow in place const SidebarNavigationBodyScrollWrapper = styled.div` flex: 1; @@ -56,8 +66,9 @@ const SidebarNavigationBodyScrollWrapper = styled.div` display: flex; overflow-y: hidden; - padding: ${cssVar('dimension-space-50')} 0; + padding: ${SIDEBAR_NAVIGATION_BODY_SPACE_50} 0; `; + SidebarNavigationBodyScrollWrapper.displayName = 'SidebarNavigationBodyScrollWrapper'; // This second layer wrapper holds the scrollbar, it's monitored by the useBottomShadowScroll hook to @@ -67,11 +78,8 @@ const SidebarNavigationBodyScrollContainer = styled.div` overflow-x: hidden; overflow-y: auto; - - [data-sidebar-docked='false'] nav:not(:hover, :focus-within) & { - overflow-y: hidden; - } `; + SidebarNavigationBodyScrollContainer.displayName = 'SidebarNavigationBodyScrollContainer'; // This third layer wrapper contains the list of navigation items, its height can change when there @@ -82,22 +90,9 @@ const SidebarNavigationBodyInner = styled.ul` display: flex; flex-direction: column; - gap: ${cssVar('dimension-space-50')}; - - padding: ${cssVar('dimension-space-50')} ${cssVar('dimension-space-100')}; -`; -SidebarNavigationBodyInner.displayName = 'SidebarNavigationBodyInner'; + gap: ${SIDEBAR_NAVIGATION_BODY_SPACE_50}; -const SidebarNavigationBottomShadowScroll = styled(BottomShadowScroll)` - [data-sidebar-docked='false'] nav:not(:hover, :focus-within) & { - opacity: 0.5; - } + padding: ${SIDEBAR_NAVIGATION_BODY_SPACE_50} ${SIDEBAR_NAVIGATION_BODY_SPACE_100}; `; -SidebarNavigationBottomShadowScroll.displayName = 'SidebarNavigationBottomShadowScroll'; -const SidebarNavigationTopShadowScroll = styled(TopShadowScroll)` - [data-sidebar-docked='false'] nav:not(:hover, :focus-within) & { - opacity: 0.5; - } -`; -SidebarNavigationTopShadowScroll.displayName = 'SidebarNavigationTopShadowScroll'; +SidebarNavigationBodyInner.displayName = 'SidebarNavigationBodyInner'; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationFooterPromotionCard.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationFooterPromotionCard.tsx index b4e9129fc..8bb8d2d71 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationFooterPromotionCard.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationFooterPromotionCard.tsx @@ -19,7 +19,8 @@ */ import styled from '@emotion/styled'; -import { forwardRef, useMemo } from 'react'; +import { type Ref, useMemo } from 'react'; + import { PROMOTED_SECTION_STYLES, PromotedSectionMainStyles, @@ -27,6 +28,7 @@ import { PromotedSectionTextContainer, PromotedSectionVariety, } from '~common/components/PromotedSectionStyles'; + import { TextNode } from '~types/utils'; import { cssVar } from '~utils/design-tokens'; import { Heading, HeadingSize, Text } from '../../typography'; @@ -34,7 +36,6 @@ import { Heading, HeadingSize, Text } from '../../typography'; export interface SidebarNavigationFooterPromotionCard { /** * The actions at the bottom should be instances of Button or StandaloneLink in a fragment. - * They are wrapped in a ButtonGroup by this component. */ actions: React.ReactNode; @@ -53,16 +54,20 @@ export interface SidebarNavigationFooterPromotionCard { */ headerText: TextNode; + /** React ref forwarded to the root element */ + ref?: Ref; + /** * The main text for the section */ text: TextNode; } -export const SidebarNavigationFooterPromotionCard = forwardRef< - HTMLDivElement, - Readonly ->(({ actions, badge, className, headerText, text, ...otherProps }, ref) => { +export function SidebarNavigationFooterPromotionCard( + props: Readonly, +) { + const { actions, badge, className, headerText, ref, text, ...otherProps } = props; + return ( ); -}); +} SidebarNavigationFooterPromotionCard.displayName = 'SidebarNavigationFooterPromotionCard'; @@ -93,16 +98,6 @@ const StyledPromotedSectionMainStyles = styled(PromotedSectionMainStyles)` flex-direction: column; gap: ${cssVar('dimension-space-100')}; align-items: start; - opacity: 1; - /* step-end makes it appear at the very end, when the sidebar has reached its full size. - * This prevents showing it resizing - */ - transition: opacity 0.1s step-end; - - [data-sidebar-docked='false'] nav:not(:hover, :focus-within) & { - opacity: 0; - /* When closing, we want the opposite: it disappears immediately */ - transition: opacity 0s; - } `; + StyledPromotedSectionMainStyles.displayName = 'StyledPromotedSectionMainStyles'; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationGroup.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationGroup.tsx index 53dbb045a..e11b2a947 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationGroup.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationGroup.tsx @@ -19,14 +19,14 @@ */ import styled from '@emotion/styled'; -import { forwardRef, PropsWithChildren, useId } from 'react'; +import { type PropsWithChildren, type Ref, useId } from 'react'; import { TextNode } from '~types/utils'; import { cssVar } from '~utils/design-tokens'; -import { Divider } from '../../divider'; import { Text } from '../../typography'; import { UnstyledListItem } from './SidebarNavigationItemStyles'; export interface SidebarNavigationGroupProps { + /** Optional CSS class name applied to the root container */ className?: string; /** * The label of the SidebarNavigationGroup. @@ -34,13 +34,14 @@ export interface SidebarNavigationGroupProps { * The styling (font, color, weight, ...) is handled by this component. */ label: TextNode; + /** React ref forwarded to the root group element */ + ref?: Ref; } -export const SidebarNavigationGroup = forwardRef< - HTMLDivElement, - PropsWithChildren ->((props, ref) => { - const { children, className, label, ...radixProps } = props; +export function SidebarNavigationGroup( + props: Readonly>, +) { + const { children, className, label, ref, ...radixProps } = props; const id = `${useId()}-sidebar-nav-group`; @@ -53,17 +54,16 @@ export const SidebarNavigationGroup = forwardRef< role="group" {...radixProps}> - + {label} - - - + + {children} ); -}); +} SidebarNavigationGroup.displayName = 'SidebarNavigationGroup'; @@ -76,6 +76,7 @@ const SidebarNavigationGroupListItem = styled(UnstyledListItem)` margin-top: calc(-1 * ${cssVar('dimension-space-50')}); } `; + SidebarNavigationGroupListItem.displayName = 'SidebarNavigationGroupListItem'; const SidebarNavigationGroupContainer = styled.div` @@ -83,37 +84,25 @@ const SidebarNavigationGroupContainer = styled.div` flex-direction: column; gap: ${cssVar('dimension-space-50')}; `; + SidebarNavigationGroupContainer.displayName = 'SidebarNavigationGroupContainer'; -const SidebarNavigationGroupLabel = styled.label` +const SidebarNavigationGroupLabel = styled.div` display: flex; align-items: center; height: ${cssVar('dimension-height-800')}; padding: 0 ${cssVar('dimension-space-100')}; white-space: nowrap; `; -SidebarNavigationGroupLabel.displayName = 'SidebarNavigationGroupLabel'; -const SidebarNavigationGroupLabelText = styled(Text)` - [data-sidebar-docked='false'] nav:not(:hover, :focus-within) & { - display: none; - } -`; -SidebarNavigationGroupLabelText.displayName = 'SidebarNavigationGroupLabelText'; - -const SidebarNavigationGroupLabelDivider = styled(Divider)` - [data-sidebar-docked='true'] &, - [data-sidebar-docked='false'] nav:is(:hover, :focus-within) & { - display: none; - } -`; -SidebarNavigationGroupLabelDivider.displayName = 'SidebarNavigationGroupLabelDivider'; +SidebarNavigationGroupLabel.displayName = 'SidebarNavigationGroupLabel'; -export const SidebarNavigationGroupList = styled.ul` +const SidebarNavigationGroupList = styled.ul` all: unset; display: flex; flex-direction: column; gap: ${cssVar('dimension-space-50')}; `; + SidebarNavigationGroupList.displayName = 'SidebarNavigationGroupList'; diff --git a/src/components/layout/sidebar-navigation/SidebarNavigationHeader.tsx b/src/components/layout/sidebar-navigation/SidebarNavigationHeader.tsx index 58c20a0a1..eb6edbcd8 100644 --- a/src/components/layout/sidebar-navigation/SidebarNavigationHeader.tsx +++ b/src/components/layout/sidebar-navigation/SidebarNavigationHeader.tsx @@ -17,8 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import styled from '@emotion/styled'; -import { forwardRef, ReactNode, useRef } from 'react'; +import { type ReactNode, type Ref, useRef } from 'react'; import { truncate } from '~common/helpers/styles'; import { useIsOverflow } from '~common/helpers/useIsOverflow'; import { TextNode, TextNodeOptional } from '~types/utils'; @@ -32,65 +33,71 @@ export interface SidebarNavigationHeaderProps { * Image to display to the left of the header. */ avatar?: ReactNode; + /** Optional CSS class name applied to the header wrapper */ + className?: string; /** * Display a Icon on the right, indicating it triggers a dropdown. * To be set to `true` when used as a dropdown trigger. */ isInteractive?: boolean; + /** + * The main text to show in the header + */ + name: TextNode; /** * Text to display under the main text of the header. * Typically defines the type of entity being shown (organization, enterprise, project, ...) */ qualifier?: TextNodeOptional; - /** - * The main text to show in the header - */ - name: TextNode; + /** React ref forwarded to the root header element */ + ref?: Ref; } -export const SidebarNavigationHeader = forwardRef( - (props, ref) => { - const { avatar = null, isInteractive = false, qualifier, name, ...radixProps } = props; - - const labelRef = useRef(null); - const [isOverflow] = useIsOverflow(labelRef, [name]); - - return ( - - - - - {avatar && {avatar}} - - - {name} +export function SidebarNavigationHeader(props: Readonly) { + const { avatar, className, isInteractive = false, name, qualifier, ref, ...radixProps } = props; + + const labelRef = useRef(null); + const [isOverflow] = useIsOverflow(labelRef, [name]); + + return ( + + + } + {...radixProps} + type={isInteractive ? 'button' : undefined}> + + {avatar && {avatar}} + + + + {name} + + + {qualifier && ( + + {qualifier} - {qualifier && ( - - {qualifier} - - )} - - - {isInteractive && } - - - - ); - }, -); + )} + + + + {isInteractive && } + + + + ); +} + SidebarNavigationHeader.displayName = 'SidebarNavigationHeader'; const HeaderWrapper = styled.div` padding: ${cssVar('dimension-space-100')}; display: flex; border-bottom: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; - - [data-sidebar-docked='false'] nav:not(:hover, :focus-within) & { - padding-left: ${cssVar('dimension-space-50')}; - padding-right: ${cssVar('dimension-space-50')}; - } `; + HeaderWrapper.displayName = 'HeaderWrapper'; const HeaderContainer = styled.button` @@ -132,6 +139,7 @@ const HeaderContainer = styled.button` } } `; + HeaderContainer.displayName = 'HeaderContainer'; const MainContent = styled.div` @@ -140,6 +148,7 @@ const MainContent = styled.div` gap: ${cssVar('dimension-space-100')}; min-width: ${cssVar('dimension-width-400')}; `; + MainContent.displayName = 'MainContent'; const AvatarWrapper = styled.div` @@ -147,6 +156,7 @@ const AvatarWrapper = styled.div` width: ${cssVar('dimension-width-300')}; height: ${cssVar('dimension-width-300')}; `; + AvatarWrapper.displayName = 'AvatarWrapper'; const TextContent = styled.div` @@ -159,4 +169,5 @@ const TextContent = styled.div` ${truncate} } `; + TextContent.displayName = 'TextContent'; diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigation-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigation-test.tsx index 34c4fa3c5..ccf628c3e 100644 --- a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigation-test.tsx +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigation-test.tsx @@ -19,32 +19,110 @@ */ import { renderWithMemoryRouter } from '~common/helpers/test-utils'; -import { LayoutContext } from '../../LayoutContext'; +import { matchers } from '@emotion/jest'; +import { fireEvent, screen } from '@testing-library/react'; +import { LayoutSidebarContext, type LayoutSidebarContextShape } from '../../LayoutSidebarContext'; +import { cssVar } from '~utils/design-tokens'; import { SidebarNavigation } from '../SidebarNavigation'; +expect.extend(matchers); + it('should have no a11y issues', async () => { - const { container } = renderWithMemoryRouter(); + const { container } = setupSidebarNavigation({ + isDocked: true, + isOpen: true, + isInLayout: true, + }); await expect(container).toHaveNoA11yViolations(); }); it('should set the layout context correctly', () => { - const setHasSidebar = jest.fn(); - const { unmount } = renderWithMemoryRouter( - - - , - ); + const setIsInLayout = jest.fn(); - expect(setHasSidebar).toHaveBeenCalledWith(true); + const { unmount } = setupSidebarNavigation({ + isInLayout: true, + setIsInLayout, + }); + + expect(setIsInLayout).toHaveBeenCalledWith(true); unmount(); - expect(setHasSidebar).toHaveBeenCalledWith(false); + expect(setIsInLayout).toHaveBeenCalledWith(false); +}); + +it('should snap the undocked sidebar width open without a transition', () => { + setupSidebarNavigation(); + const sidebarNavigationContainer = screen.getByTestId('sidebar-navigation-container'); + + expect(sidebarNavigationContainer).toHaveStyleRule('transition', 'none', { + target: "[data-sidebar-docked='false']", + }); +}); + +it('should widen the layout column when the sidebar is docked or opened in non-dockable mode', () => { + setupSidebarNavigation(); + const sidebarNavigationContainer = screen.getByTestId('sidebar-navigation-container'); + + const dockedSidebarWidth = new RegExp( + `calc\\(\\s*${escapeRegExp(cssVar('layout-sidebar-navigation-sizes-width-open'))}\\s*\\+\\s*${escapeRegExp( + cssVar('border-width-default'), + )}\\s*\\)`, + ); + + expect(sidebarNavigationContainer).toHaveStyleRule('width', dockedSidebarWidth, { + target: "[data-sidebar-docked='true']", + }); + + expect(sidebarNavigationContainer).toHaveStyleRule('width', dockedSidebarWidth, { + target: "[data-sidebar-is-dockable='false'][data-sidebar-open='true']", + }); + + expect(sidebarNavigationContainer).not.toHaveStyleRule('width', dockedSidebarWidth, { + target: "[data-sidebar-is-dockable='true'][data-sidebar-open='true']", + }); +}); + +it('should not request opening the sidebar again when it is already docked and open', () => { + const openSidebar = jest.fn(); + + setupSidebarNavigation({ + isDockable: true, + isDocked: true, + isOpen: true, + isInLayout: true, + open: openSidebar, + }); + + fireEvent.mouseEnter(screen.getByTestId('sidebar-navigation-container')); + fireEvent.focus(screen.getByTestId('sidebar-navigation-wrapper')); + + expect(openSidebar).not.toHaveBeenCalled(); }); + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function setupSidebarNavigation(contextOverrides: Partial = {}) { + const defaultLayoutSidebarContext: LayoutSidebarContextShape = { + close: jest.fn(), + handleInteractionZoneBlur: jest.fn(), + handleInteractionZoneMouseLeave: jest.fn(), + isDockable: true, + isDocked: false, + isInLayout: false, + isOpen: false, + open: jest.fn(), + setIsDocked: jest.fn(), + setIsInLayout: jest.fn(), + ignoreNextInteractionZoneBlur: jest.fn(), + }; + + return renderWithMemoryRouter( + + + , + ); +} diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionChildItem-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionChildItem-test.tsx index f7753c4c3..0cce9ffc7 100644 --- a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionChildItem-test.tsx +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionChildItem-test.tsx @@ -133,7 +133,7 @@ describe('active state behavior', () => { }); describe('CSS custom properties for accordion integration', () => { - it('should use CSS custom properties for display, visibility and outline', () => { + it('should use a CSS custom property for accordion child display', () => { setupSidebarNavigationAccordionChildItem({ Icon: IconClock }); const link = screen.getByRole('link'); @@ -142,13 +142,6 @@ describe('CSS custom properties for accordion integration', () => { '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)'); }); }); diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx index 3e63e1456..bdde36028 100644 --- a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationAccordionItem-test.tsx @@ -114,7 +114,7 @@ it('should scroll the last child into view when opened with scrollLastChildIntoV }); describe('integration with SidebarNavigationAccordionChildItem', () => { - it('should set CSS custom properties and active class on children', () => { + it('should set accordion child display CSS custom property and active class on children', () => { setupSidebarNavigationAccordionItem({ children: ( <> @@ -146,28 +146,6 @@ describe('integration with SidebarNavigationAccordionChildItem', () => { 'display', 'var(--sidebar-navigation-accordion-children-display, flex)', ); - - // Check that visibility CSS custom property is set - expect(subItem1).toHaveStyleRule( - 'visibility', - 'var(--sidebar-navigation-accordion-children-visibility, visible)', - ); - - expect(subItem2).toHaveStyleRule( - 'visibility', - 'var(--sidebar-navigation-accordion-children-visibility, visible)', - ); - - // Check that outline CSS custom property is set - expect(subItem1).toHaveStyleRule( - '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', () => { diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationHeader-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationHeader-test.tsx index 7631ee7ab..77144be89 100644 --- a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationHeader-test.tsx +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationHeader-test.tsx @@ -36,6 +36,12 @@ it('should render correctly', async () => { await expect(container).toHaveNoA11yViolations(); }); +it('should render interactive headers as non-submitting buttons', () => { + render(); + + expect(screen.getByRole('button')).toHaveAttribute('type', 'button'); +}); + it('should render with an avatar and subtext', async () => { const { container } = render( } name="main text" qualifier="subtext" />, diff --git a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx index f870e5d84..53f4e5007 100644 --- a/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx +++ b/src/components/layout/sidebar-navigation/__tests__/SidebarNavigationItem-test.tsx @@ -120,25 +120,15 @@ describe('active state behavior', () => { }); describe('CSS custom properties for accordion integration', () => { - it('should use CSS custom properties for display, visibility and outline', () => { + it('should use a CSS custom property for accordion child display', () => { setupSidebarNavigationItem(); const link = screen.getByRole('link'); - // Check that display CSS custom property is used with fallback expect(link).toHaveStyleRule( 'display', 'var(--sidebar-navigation-accordion-children-display, flex)', ); - - // Check that visibility CSS custom property is used with fallback - expect(link).toHaveStyleRule( - 'visibility', - 'var(--sidebar-navigation-accordion-children-visibility, visible)', - ); - - // Check that outline CSS custom property is used (no fallback needed) - expect(link).toHaveStyleRule('outline', 'var(--sidebar-navigation-accordion-children-outline)'); }); }); diff --git a/src/components/layout/useLayoutSidebarState.ts b/src/components/layout/useLayoutSidebarState.ts new file mode 100644 index 000000000..3ad0efd93 --- /dev/null +++ b/src/components/layout/useLayoutSidebarState.ts @@ -0,0 +1,171 @@ +/* + * 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, useMemo, useRef, useState } from 'react'; +import { isDefined } from '~common/helpers/types'; +import { designToken } from '~utils/design-tokens'; +import type { LayoutSidebarContextShape } from './LayoutSidebarContext'; + +import { isWithinSidebarInteractionZone } from './LayoutSidebarInteraction'; + +interface UseLayoutSidebarStateInput { + isSidebarInitiallyDocked?: boolean; + onSidebarDockedChange?: (isDocked: boolean) => void; +} + +export function useLayoutSidebarState( + props: Readonly, +): LayoutSidebarContextShape { + const { isSidebarInitiallyDocked, onSidebarDockedChange } = props; + + const mediaQueryList = useMemo( + () => + globalThis.matchMedia( + `(min-width: ${designToken('layout-sidebar-navigation-sizes-breakpoint-dockable')})`, + ), + [], + ); + + const [isInLayout, setIsInLayout] = useState(false); + + const [isDocked, setIsDocked] = useState( + () => isSidebarInitiallyDocked ?? mediaQueryList.matches, + ); + + const [isDockable, setIsDockable] = useState(() => mediaQueryList.matches); + const [isUndockedOpen, setIsUndockedOpen] = useState(false); + const blurTimeoutRef = useRef | undefined>(undefined); + const shouldIgnoreNextInteractionZoneBlurRef = useRef(false); + + const isOpen = isInLayout && ((isDocked && isDockable) || isUndockedOpen); + + const open = useCallback(() => { + if (isInLayout) { + setIsUndockedOpen(true); + } + }, [isInLayout]); + + const close = useCallback(() => { + setIsUndockedOpen(false); + }, []); + + const handleInteractionZoneBlur = useCallback( + (relatedTarget: EventTarget | null) => { + if (isWithinSidebarInteractionZone(relatedTarget)) { + return; + } + + if (shouldIgnoreNextInteractionZoneBlurRef.current) { + shouldIgnoreNextInteractionZoneBlurRef.current = false; + + return; + } + + if (isDefined(blurTimeoutRef.current)) { + globalThis.clearTimeout(blurTimeoutRef.current); + } + + // Defer the close check until the browser updates activeElement after blur + blurTimeoutRef.current = globalThis.setTimeout(() => { + if (isWithinSidebarInteractionZone(globalThis.document.activeElement)) { + return; + } + + close(); + }, 0); + }, + [close], + ); + + const handleInteractionZoneMouseLeave = useCallback( + (relatedTarget: EventTarget | null) => { + if ( + isWithinSidebarInteractionZone(relatedTarget) || + isWithinSidebarInteractionZone(globalThis.document.activeElement) + ) { + return; + } + + close(); + }, + [close], + ); + + const ignoreNextInteractionZoneBlur = useCallback(() => { + shouldIgnoreNextInteractionZoneBlurRef.current = true; + }, []); + + useEffect(() => { + const handleMediaQueryChange = ({ matches: canDockSidebar }: MediaQueryListEvent) => { + setIsDockable(canDockSidebar); + }; + + mediaQueryList.addEventListener('change', handleMediaQueryChange); + + return () => { + mediaQueryList.removeEventListener('change', handleMediaQueryChange); + }; + }, [mediaQueryList]); + + useEffect(() => { + onSidebarDockedChange?.(isDocked); + }, [isDocked, onSidebarDockedChange]); + + useEffect(() => { + if (!isInLayout || (isDocked && isDockable)) { + setIsUndockedOpen(false); + } + }, [isDockable, isDocked, isInLayout]); + + useEffect(() => { + return () => { + if (isDefined(blurTimeoutRef.current)) { + globalThis.clearTimeout(blurTimeoutRef.current); + } + }; + }, []); + + return useMemo( + () => ({ + close, + handleInteractionZoneBlur, + handleInteractionZoneMouseLeave, + ignoreNextInteractionZoneBlur, + isDockable, + isDocked, + isInLayout, + isOpen, + open, + setIsDocked, + setIsInLayout, + }), + [ + close, + handleInteractionZoneBlur, + handleInteractionZoneMouseLeave, + ignoreNextInteractionZoneBlur, + isDockable, + isDocked, + isInLayout, + isOpen, + open, + ], + ); +} diff --git a/stories/layout/Layout-stories.tsx b/stories/layout/Layout-stories.tsx index 76ca6d694..e2a6e773a 100644 --- a/stories/layout/Layout-stories.tsx +++ b/stories/layout/Layout-stories.tsx @@ -22,19 +22,22 @@ import styled from '@emotion/styled'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { + Badge, BadgeSeverity, Button, cssVar, DropdownMenu, - IconBranch, - IconComment, + IconComputer, IconGear, - IconGitBranch, + IconKey, + IconPackageAlt, + IconPeople, IconProject, - IconPullrequest, IconQuestionMark, + IconReports, IconSearch, - IconSecurityFinding, + IconShield, + IconWebhook, Layout, LinkStandalone, LoadingSkeleton, @@ -212,8 +215,8 @@ export const Default: Story = { justifyContent: 'space-around', marginTop: '32px', }}> - {Array.from({ length: 20 }).map((_, i) => ( - + {colorBoxIds.map((id, index) => ( + ))} @@ -296,55 +299,83 @@ function SidebarNav() { justifyContent: 'center', borderRadius: cssVar('border-radius-400'), }}> - S + } isInteractive - name="My Project name" + name="Administration for a large enterprise instance" /> - - Overview - + + + General settings + + + } to="/alm-integrations"> + DevOps platform integrations + + + + Webhooks + + + + Background tasks + + + + + + Users + - - - Main branch + + Groups - - Amazing Pull Request that updates a lot of things + + Authentication - - Small PR + + Global permissions - - - Reports - + }> + + Management + + + + Applications + - - Measures - - + + Portfolio projects + + + + + System + + + + Marketplace + + + + Audit logs + - - Settings + + Support @@ -380,6 +411,14 @@ function getHeaderProps() { }; } +function NewBadge() { + return ( + + New + + ); +} + const Avatar = styled.div` width: 24px; height: 24px; @@ -392,6 +431,13 @@ const Links = styled.div` `; const items = Array.from({ length: 100 }).map((_, i) => i); +const colorBoxIds = Array.from({ length: 20 }, (_, index) => `color-box-${index.toString()}`); + +const COLOR_BOX_HUE_MULTIPLIER = 37; +const COLOR_BOX_HUE_RANGE = 360; +const COLOR_BOX_MIN_SIZE = 150; +const COLOR_BOX_SIZE_VARIATION = 300; +const COLOR_BOX_SIZE_MULTIPLIER = 47; const List = styled.ul` all: unset; @@ -463,22 +509,23 @@ function TransformBox() { ); } -function getRandomColor() { - return `hsl(${Math.random() * 360}, 100%, 75%)`; +function getColorBoxColor(colorIndex: number) { + return `hsl(${(colorIndex * COLOR_BOX_HUE_MULTIPLIER) % COLOR_BOX_HUE_RANGE}, 100%, 75%)`; } -function getRandomSize() { - return `${150 + Math.random() * 300}px`; +function getColorBoxSize(colorIndex: number) { + return `${COLOR_BOX_MIN_SIZE + ((colorIndex * COLOR_BOX_SIZE_MULTIPLIER) % COLOR_BOX_SIZE_VARIATION)}px`; } -function ColorBox() { - const color = getRandomColor(); +function ColorBox({ colorIndex }: Readonly<{ colorIndex: number }>) { + const color = getColorBoxColor(colorIndex); + const size = getColorBoxSize(colorIndex); return (
; -const items = Array.from({ length: 10 }).map((_, i) => i + 1); +const sharedStoryParameters = { + exclude: ['children'], +}; +const sidebarNavigationStoryNarrowViewportWidth = `calc(${cssVar('layout-sidebar-navigation-sizes-breakpoint-dockable')} - ${cssVar('dimension-width-5000')})`; -export const Full: Story = { - parameters: { - exclude: ['children'], - }, +export const Docked: Story = { + parameters: sharedStoryParameters, + render: (args) => ( + + + + ), +}; + +export const Undocked: Story = { + parameters: sharedStoryParameters, + render: (args) => ( + + + + ), +}; + +export const UndockableNarrowViewport: Story = { + name: 'Undockable Narrow Viewport', + parameters: sharedStoryParameters, render: (args) => ( - + + + + ), +}; + +function SidebarNavigationStoryLayout({ + children, + highlightNarrowViewport = false, + isSidebarInitiallyDocked, + isSidebarDockable = true, +}: Readonly< + PropsWithChildren<{ + highlightNarrowViewport?: boolean; + isSidebarInitiallyDocked: boolean; + isSidebarDockable?: boolean; + }> +>) { + useStoryMatchMediaOverride(isSidebarDockable); + + const layout = {children}; + + if (!highlightNarrowViewport) { + return layout; + } + + return ( + {layout} + ); +} + +function useStoryMatchMediaOverride(matches: boolean) { + const originalMatchMedia = useRef(globalThis.matchMedia); + + const storyMatchMedia = useMemo( + () => (query: string) => + ({ + addEventListener: () => undefined, + addListener: () => undefined, + dispatchEvent: () => true, + matches, + media: query, + onchange: null, + removeEventListener: () => undefined, + removeListener: () => undefined, + }) as MediaQueryList, + [matches], + ); + + if (!matches) { + globalThis.matchMedia = storyMatchMedia; + } + + useEffect(() => { + if (matches) { + return undefined; + } + + const originalMatchMediaValue = originalMatchMedia.current; + + return () => { + globalThis.matchMedia = originalMatchMediaValue; + }; + }, [matches]); +} + +function SidebarNavigationStoryContent( + args: Readonly>, +) { + return ( + <> @@ -91,104 +188,127 @@ export const Full: Story = { justifyContent: 'center', borderRadius: cssVar('border-radius-400'), }}> - S +
} isInteractive - name="Hello this is a bit long, I think!" + name="Administration for a large enterprise instance" /> - - blablablba - + + + General settings + - - - Thing 1 - + } + to="/alm-integrations"> + DevOps platform integrations + - - Amazing project 2Amazing project 2Amazing project 2Amazing project 2Amazing project 2 - + + Webhooks + - } - to="somwhereelse3"> - Blabla 3 - - + + Housekeeping + + + + Email + + + + Background tasks + + + + + + Users + + + + Groups + + + + Global permissions + + + + Authentication + + + + Encryption + + }> - } - to="/1"> - child 1 with a long name hahahah + + Management - - child 2 + + Links - - child 3 + + Applications + + + + Portfolios + + + + DevOps platform projects + + + + Badges - - {items.map((v) => { - return ( - - Menu Item {v.toString()} - - ); - })} - + + System + + + + Marketplace + + + + Audit logs + + + + Support + + + + + Announcements + - - - - asdf - - - - zxcv - - - - - - asdf - - - - zxcv - - + + Maintenance windows + + + } + to="/automation"> + Automation + @@ -196,36 +316,27 @@ export const Full: Story = { - + - Maybe later + Compare editions } badge={Beta} - headerText="My feature is available now" - text="Learn how you can improve your code base simply by cleaning your new code." + headerText="Explore advanced governance" + text="Try portfolio management, extra reporting, and broader administration features." /> - - - Child settings 1 + + + Documentation - - Child settings 2 + + Web API - - Child settings 3 + + Release notes @@ -239,33 +350,36 @@ export const Full: Story = {

- Your last choice for docking the sidebar is saved in the browser local storage. + Hover, focus, or click the top-left trigger to reveal the administration sidebar. - - ), -}; - -function LayoutWithSidebarStateSaved({ children }: PropsWithChildren) { - const isSidebarDocked = globalThis.localStorage.getItem('echoes-sidebar-docked'); + + ); +} +function SidebarNavigationStoryNarrowViewportFrame({ children }: Readonly) { return ( - { - globalThis.localStorage.setItem('echoes-sidebar-docked', isDocked.toString()); +
{children} - +
); } function NewSuffix() { return ( - New! + New ); } diff --git a/stories/layout/sidebar-navigation/SidebarNavigationAccordionItem-stories.tsx b/stories/layout/sidebar-navigation/SidebarNavigationAccordionItem-stories.tsx index 673e2facc..921ec4514 100644 --- a/stories/layout/sidebar-navigation/SidebarNavigationAccordionItem-stories.tsx +++ b/stories/layout/sidebar-navigation/SidebarNavigationAccordionItem-stories.tsx @@ -21,7 +21,7 @@ /* eslint-disable no-console */ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Badge, cssVar, IconBranch, Layout } from '../../../src'; +import { Badge, IconBranch, Layout } from '../../../src'; import { basicWrapperDecorator } from '../../helpers/BasicWrapper'; const baseAccordionChildren = ( @@ -36,21 +36,6 @@ const baseAccordionChildren = ( ); -const dockedSidebarAccordionChildren = ( - <> - - Icon hidden while sidebar is open - - - - Icon stays visible while sidebar is open - - -); - const accordionChildrenWithIcon = ( <> @@ -133,26 +118,6 @@ export const withIcon: Story = { }, }; -export const withDisableIconWhenSidebarOpen: Story = { - args: { - Icon: IconBranch, - isDefaultOpen: true, - label: 'Accordion', - }, - render: ({ isDefaultOpen = true, ...args }) => ( -
- - {dockedSidebarAccordionChildren} - -
- ), -}; - const fourNavItems = Array.from({ length: 4 }, (_, i) => ( Item {`${i + 1}`}