-
Notifications
You must be signed in to change notification settings - Fork 13.9k
feat: add SidebarRail collapsed icon navigation rail #41712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
aa0e1b6
7357246
d434ecb
42f0336
8b2c490
e192de9
b82e8a8
cf27247
26f083b
420bd99
28807e0
de6274d
44d7e84
a51c3de
54ed8d2
8501ce0
5624fd6
7d95325
b7bf314
aef4f1e
7c2ecb6
eae7185
5f133ff
ff13dd3
65ae224
1e580e7
8ec58e0
1bd7506
3d3af6d
42de6dd
402d25f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { Divider } from '@rocket.chat/fuselage'; | ||
| import type { ComponentProps } from 'react'; | ||
|
|
||
| type HorizontalDividerProps = Omit<ComponentProps<typeof Divider>, 'vertical'>; | ||
|
|
||
| const HorizontalDivider = (props: HorizontalDividerProps) => <Divider {...props} vertical={false} />; | ||
|
|
||
| export default HorizontalDivider; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './HorizontalDivider'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { Box } from '@rocket.chat/fuselage'; | ||
| import { mockAppRoot } from '@rocket.chat/mock-providers'; | ||
| import { SessionContext } from '@rocket.chat/ui-contexts'; | ||
| import type { SessionContextValue } from '@rocket.chat/ui-contexts'; | ||
| import type { Meta, StoryObj } from '@storybook/react'; | ||
| import type { ReactNode } from 'react'; | ||
|
|
||
| import SidebarRail from './SidebarRail'; | ||
|
|
||
| const sessionMock = (state: Record<string, unknown>): SessionContextValue => ({ | ||
| query: (name) => [() => () => undefined, () => state[name]], | ||
| dispatch: () => undefined, | ||
| }); | ||
|
|
||
| const baseRoot = () => | ||
| mockAppRoot().withSetting('Layout_Show_Home_Button', true).withTranslations('en', 'core', { | ||
| Sidebar: 'Sidebar', | ||
| Home: 'Home', | ||
| Create_new: 'Create new', | ||
| Voice_Call: 'Voice Call', | ||
| Pages_and_actions: 'Pages and actions', | ||
| Workspace_and_user_preferences: 'Workspace and user preferences', | ||
| }); | ||
|
|
||
| export default { | ||
| title: 'Sidebar/SidebarRail', | ||
|
|
||
| component: SidebarRail, | ||
| parameters: { | ||
| layout: 'fullscreen', | ||
| }, | ||
| decorators: [ | ||
| (Story) => ( | ||
| <Box height='100vh' display='flex'> | ||
| <Story /> | ||
| </Box> | ||
| ), | ||
| ], | ||
| } satisfies Meta<typeof SidebarRail>; | ||
|
|
||
| type Story = StoryObj<typeof SidebarRail>; | ||
|
|
||
| export const Anonymous: Story = { | ||
| decorators: [baseRoot().buildStoryDecorator()], | ||
| }; | ||
|
|
||
| export const LoggedIn: Story = { | ||
| decorators: [baseRoot().withJohnDoe().buildStoryDecorator()], | ||
| }; | ||
|
|
||
| export const WithUnreadBadge: Story = { | ||
| decorators: [ | ||
| baseRoot() | ||
| .withJohnDoe() | ||
| .wrap((children: ReactNode) => <SessionContext.Provider value={sessionMock({ unread: 5 })}>{children}</SessionContext.Provider>) | ||
| .buildStoryDecorator(), | ||
| ], | ||
| }; | ||
|
|
||
| export const WithCreatePermissions: Story = { | ||
| decorators: [ | ||
| baseRoot().withJohnDoe().withPermission('create-c').withPermission('create-p').withPermission('create-d').buildStoryDecorator(), | ||
| ], | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { Box, NavBarGroup } from '@rocket.chat/fuselage'; | ||
| import { useUser } from '@rocket.chat/ui-contexts'; | ||
| import { memo } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| import SidebarRailCreateNew from './SidebarRailCreateNew'; | ||
| import SidebarRailDivider from './SidebarRailDivider'; | ||
| import SidebarRailLoginPage from './SidebarRailLoginPage'; | ||
| import SidebarRailPhone from './SidebarRailPhone'; | ||
| import SidebarRailSort from './SidebarRailSort'; | ||
| import NavBarItemDirectoryPage from '../../navbar/NavBarPagesGroup/NavBarItemDirectoryPage'; | ||
| import NavBarItemHomePage from '../../navbar/NavBarPagesGroup/NavBarItemHomePage'; | ||
| import NavBarItemMarketPlaceMenu from '../../navbar/NavBarPagesGroup/NavBarItemMarketPlaceMenu'; | ||
| import { NavBarItemAdministrationMenu, UserMenu } from '../../navbar/NavBarSettingsToolbar'; | ||
|
|
||
| const SidebarRail = () => { | ||
| const { t } = useTranslation(); | ||
| const user = useUser(); | ||
|
|
||
| return ( | ||
| <Box | ||
| is='nav' | ||
| aria-label={t('Sidebar_rail')} | ||
| className='rcx-sidebar-rail' | ||
| backgroundColor='surface-sidebar' | ||
| borderInlineEndWidth='default' | ||
| borderInlineEndStyle='solid' | ||
| borderInlineEndColor='stroke-light' | ||
| display='flex' | ||
| flexDirection='column' | ||
| alignItems='stretch' | ||
| width='x44' | ||
| height='full' | ||
| // secondarySidebar Feature Preview animates transitions between panels. | ||
| // This zIndex ensures the panels transition stays behind the SideRail | ||
| zIndex={10} | ||
| > | ||
| <Box flexGrow={1} minHeight={0} overflow='hidden auto' padding={8}> | ||
| <NavBarGroup vertical aria-label={t('Pages_and_actions')}> | ||
| <NavBarItemHomePage title={t('Home')} /> | ||
| <SidebarRailSort /> | ||
| <SidebarRailCreateNew /> | ||
| </NavBarGroup> | ||
| <SidebarRailDivider /> | ||
| <NavBarGroup vertical aria-label={t('Voice_Call')}> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: For screen-reader users, Directory and Marketplace are announced as members of the Voice Call group. Give the lower group an accurate label or split the non-voice items into their own labelled group. Prompt for AI agents |
||
| <SidebarRailPhone /> | ||
| <NavBarItemDirectoryPage title={t('Directory')} /> | ||
| <NavBarItemMarketPlaceMenu /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a user lacks both marketplace permissions, this unconditional item still renders as a disabled Marketplace icon. Apply the same permission gate used by Prompt for AI agents |
||
| </NavBarGroup> | ||
| </Box> | ||
| <Box padding={8}> | ||
| <NavBarGroup vertical aria-label={t('Workspace_and_user_preferences')}> | ||
| <NavBarItemAdministrationMenu /> | ||
| {user ? <UserMenu user={user} /> : <SidebarRailLoginPage />} | ||
| </NavBarGroup> | ||
| </Box> | ||
| </Box> | ||
| ); | ||
| }; | ||
|
|
||
| export default memo(SidebarRail); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { Box, Sidepanel } from '@rocket.chat/fuselage'; | ||
| import { FeaturePreview, FeaturePreviewOn, FeaturePreviewOff } from '@rocket.chat/ui-client'; | ||
| import { useLayout } from '@rocket.chat/ui-contexts'; | ||
| import { InlineMediaCallWidget } from '@rocket.chat/ui-voip'; | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| import SidebarPortal from '../../portals/SidebarPortal'; | ||
|
|
||
| const SidebarRailCallPanel = () => { | ||
| const { t } = useTranslation(); | ||
| const { isEmbedded: embeddedLayout, isMobile } = useLayout(); | ||
|
|
||
| return ( | ||
| <FeaturePreview feature='sidebarRail' disabled={embeddedLayout || isMobile}> | ||
| <FeaturePreviewOn> | ||
| <SidebarPortal> | ||
| <Sidepanel role='complementary' aria-label={t('Calls')}> | ||
| <Box padding={16}> | ||
| <InlineMediaCallWidget /> | ||
| </Box> | ||
| </Sidepanel> | ||
| </SidebarPortal> | ||
| </FeaturePreviewOn> | ||
| <FeaturePreviewOff>{null}</FeaturePreviewOff> | ||
| </FeaturePreview> | ||
| ); | ||
| }; | ||
|
|
||
| export default SidebarRailCallPanel; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { NavBarItem } from '@rocket.chat/fuselage'; | ||
| import { GenericMenu } from '@rocket.chat/ui-client'; | ||
| import type { HTMLAttributes } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| import { useCreateNewMenu } from '../../navbar/NavBarPagesGroup/hooks/useCreateNewMenu'; | ||
|
|
||
| type SidebarRailCreateNewProps = Omit<HTMLAttributes<HTMLElement>, 'is'>; | ||
|
|
||
| const SidebarRailCreateNew = (props: SidebarRailCreateNewProps) => { | ||
| const { t } = useTranslation(); | ||
|
|
||
| const sections = useCreateNewMenu(); | ||
|
|
||
| if (sections.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return <GenericMenu icon='pencil-box' sections={sections} title={t('Create_new')} is={NavBarItem} placement='right-start' {...props} />; | ||
| }; | ||
|
|
||
| export default SidebarRailCreateNew; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import type { ComponentProps } from 'react'; | ||
|
|
||
| import HorizontalDivider from '../../components/HorizontalDivider'; | ||
|
|
||
| type SidebarRailDividerProps = ComponentProps<typeof HorizontalDivider>; | ||
|
|
||
| const SidebarRailDivider = (props: SidebarRailDividerProps) => ( | ||
| <HorizontalDivider marginBlock={16} marginInline={4} borderBlockStartColor='stroke-light' {...props} /> | ||
| ); | ||
|
|
||
| export default SidebarRailDivider; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,15 @@ | ||||||
| import { Box, NavBar as NavBarComponent, NavBarSection } from '@rocket.chat/fuselage'; | ||||||
|
|
||||||
| import NavBarNavigation from '../../navbar/NavBarNavigation'; | ||||||
|
|
||||||
| const SidebarRailHeader = () => ( | ||||||
| <NavBarComponent aria-label='header' style={{ paddingInline: '0.5rem' }}> | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The Prompt for AI agents
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Hardcoding Prompt for AI agents |
||||||
| <NavBarSection> | ||||||
| <Box is='img' src='/images/logo/icon.svg' alt='Rocket.Chat' size='x28' /> | ||||||
| </NavBarSection> | ||||||
| <NavBarNavigation /> | ||||||
| <NavBarSection /> | ||||||
| </NavBarComponent> | ||||||
| ); | ||||||
|
|
||||||
| export default SidebarRailHeader; | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { NavBarItem } from '@rocket.chat/fuselage'; | ||
| import { useSessionDispatch } from '@rocket.chat/ui-contexts'; | ||
| import type { HTMLAttributes } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| type SidebarRailLoginPageProps = Omit<HTMLAttributes<HTMLElement>, 'is'>; | ||
|
|
||
| const SidebarRailLoginPage = (props: SidebarRailLoginPageProps) => { | ||
| const setForceLogin = useSessionDispatch('forceLogin'); | ||
| const { t } = useTranslation(); | ||
|
|
||
| return <NavBarItem {...props} icon='login' title={t('Login')} onClick={() => setForceLogin(true)} />; | ||
| }; | ||
|
|
||
| export default SidebarRailLoginPage; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { NavBarItem } from '@rocket.chat/fuselage'; | ||
| import { useStableCallback } from '@rocket.chat/fuselage-hooks'; | ||
| import { useCurrentRoutePath, useRouter } from '@rocket.chat/ui-contexts'; | ||
| import { useMediaCallAction } from '@rocket.chat/ui-voip'; | ||
| import type { HTMLAttributes } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| type SidebarRailPhoneProps = Omit<HTMLAttributes<HTMLElement>, 'is'>; | ||
|
|
||
| const SidebarRailPhone = (props: SidebarRailPhoneProps) => { | ||
| const { t } = useTranslation(); | ||
| const callAction = useMediaCallAction(); | ||
| const router = useRouter(); | ||
| const currentRoute = useCurrentRoutePath(); | ||
|
|
||
| const isActive = currentRoute?.includes('/call-history') ?? false; | ||
|
|
||
| const handleClick = useStableCallback(() => { | ||
| router.navigate('/call-history'); | ||
| }); | ||
|
|
||
| if (!callAction) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <NavBarItem | ||
| {...props} | ||
| title={t('Calls')} | ||
| icon='phone' | ||
| pressed={isActive} | ||
| aria-current={isActive ? 'page' : undefined} | ||
| onClick={handleClick} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| export default SidebarRailPhone; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { NavBarItem } from '@rocket.chat/fuselage'; | ||
| import { GenericMenu } from '@rocket.chat/ui-client'; | ||
| import type { HTMLAttributes } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| import { useSortMenu } from '../../navbar/NavBarPagesGroup/hooks/useSortMenu'; | ||
|
|
||
| type SidebarRailSortProps = Omit<HTMLAttributes<HTMLElement>, 'is'>; | ||
|
|
||
| const SidebarRailSort = (props: SidebarRailSortProps) => { | ||
| const { t } = useTranslation(); | ||
|
|
||
| const sections = useSortMenu(); | ||
|
|
||
| return ( | ||
| <GenericMenu | ||
| icon='sort' | ||
| sections={sections} | ||
| title={t('Display')} | ||
| selectionMode='multiple' | ||
| is={NavBarItem} | ||
| placement='right-start' | ||
| {...props} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| export default SidebarRailSort; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './SidebarRail'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,6 @@ | ||
| import { mockAppRoot } from '@rocket.chat/mock-providers'; | ||
| import { useCurrentRoutePath, useRouter } from '@rocket.chat/ui-contexts'; | ||
| import { render } from '@testing-library/react'; | ||
| import type { ReactNode } from 'react'; | ||
|
|
||
| import LayoutWithSidebar from './LayoutWithSidebar'; | ||
|
|
||
|
|
@@ -12,19 +11,17 @@ jest.mock('@rocket.chat/ui-contexts', () => ({ | |
| })); | ||
|
|
||
| jest.mock('../../../navbar', () => () => <div>NavBar</div>); | ||
| jest.mock('../../../sidebar', () => () => <div>Sidebar</div>); | ||
| jest.mock('../../navigation', () => () => <div>NavigationRegion</div>); | ||
| jest.mock('../../../sidebar/SidebarRail', () => () => <div>SidebarRail</div>); | ||
| jest.mock('../../../sidebar/SidebarRail/SidebarRailHeader', () => () => <div>SidebarRailHeader</div>); | ||
| jest.mock('./AccessibilityShortcut', () => () => <div>AccessibilityShortcut</div>); | ||
| jest.mock('../../navigation/providers/RoomsNavigationProvider', () => ({ | ||
| __esModule: true, | ||
| default: ({ children }: { children: ReactNode }) => <>{children}</>, | ||
| })); | ||
|
|
||
| jest.mock('../../navigation/providers/RoomsNavigationProvider', () => () => <div>Navigationprovider</div>); | ||
| jest.mock('../../navigation', () => () => <div>NavigationRegion</div>); | ||
| jest.mock('../../../sidebar', () => () => <div>Sidebar</div>); | ||
| jest.mock('@rocket.chat/ui-client', () => ({ | ||
| ...jest.requireActual('@rocket.chat/ui-client'), | ||
| FeaturePreview: ({ children }: { children: ReactNode }) => <>{children}</>, | ||
| FeaturePreviewOn: ({ children }: { children: ReactNode }) => <>{children}</>, | ||
| FeaturePreviewOff: ({ children }: { children: ReactNode }) => <>{children}</>, | ||
| FeaturePreview: ({ children }: any) => children, | ||
| FeaturePreviewOn: ({ children }: any) => children, | ||
| FeaturePreviewOff: () => null, | ||
|
Comment on lines
+22
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Make the
🤖 Prompt for AI Agents |
||
| })); | ||
|
|
||
| const mockedUseCurrentRoutePath = useCurrentRoutePath as jest.MockedFunction<typeof useCurrentRoutePath>; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The WithUnreadBadge story injects a SessionContext with
unread: 5, but no component rendered by SidebarRail reads theunreadsession (it is only consumed by SidebarToggler, which is not part of this rail). The story renders identically to LoggedIn and never shows a badge, so it is misleading. Remove the story, or point the mock at a value the rail actually renders, or document which planned badge it is for.Prompt for AI agents