diff --git a/.changeset/kind-beers-share.md b/.changeset/kind-beers-share.md new file mode 100644 index 000000000000..b7871e568d10 --- /dev/null +++ b/.changeset/kind-beers-share.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/ui-client": minor +"@rocket.chat/web-ui-registration": minor +--- + +feat: Skip to main content shortcut and useDocumentTitle diff --git a/apps/meteor/client/components/Page/PageHeader.tsx b/apps/meteor/client/components/Page/PageHeader.tsx index 6725c2da6f5c..25d20381e52e 100644 --- a/apps/meteor/client/components/Page/PageHeader.tsx +++ b/apps/meteor/client/components/Page/PageHeader.tsx @@ -1,6 +1,5 @@ import { Box, IconButton } from '@rocket.chat/fuselage'; -import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; -import { HeaderToolbox } from '@rocket.chat/ui-client'; +import { HeaderToolbox, useDocumentTitle } from '@rocket.chat/ui-client'; import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; import type { FC, ComponentProps, ReactNode } from 'react'; import React, { useContext } from 'react'; @@ -18,12 +17,11 @@ const PageHeader: FC = ({ children = undefined, title, onClickB const t = useTranslation(); const [border] = useContext(PageContext); const { isMobile } = useLayout(); - const headerAutoFocus = useAutoFocus(); + + useDocumentTitle(typeof title === 'string' ? title : undefined); return ( = ({ icon, title = '', avatar, actions, href {badges && {badges}} {menu && ( - {menuVisibility ? menu() : } + {menuVisibility ? menu() : } )} diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index c997a48b5b4a..73493a4aee8f 100644 --- a/apps/meteor/client/sidebar/Item/Extended.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.tsx @@ -54,7 +54,7 @@ const Extended: VFC = ({ }; return ( - + {avatar && {avatar}} @@ -72,7 +72,7 @@ const Extended: VFC = ({ {badges} {menu && ( - {menuVisibility ? menu() : } + {menuVisibility ? menu() : } )} diff --git a/apps/meteor/client/sidebar/Item/Medium.tsx b/apps/meteor/client/sidebar/Item/Medium.tsx index 2c97b890988f..6feed3071ffc 100644 --- a/apps/meteor/client/sidebar/Item/Medium.tsx +++ b/apps/meteor/client/sidebar/Item/Medium.tsx @@ -42,7 +42,7 @@ const Medium: VFC = ({ icon, title = '', avatar, actions, href, bad {badges && {badges}} {menu && ( - {menuVisibility ? menu() : } + {menuVisibility ? menu() : } )} diff --git a/apps/meteor/client/startup/unread.ts b/apps/meteor/client/startup/unread.ts index 6c076e4ba107..d9c2a35efab5 100644 --- a/apps/meteor/client/startup/unread.ts +++ b/apps/meteor/client/startup/unread.ts @@ -5,7 +5,6 @@ import { Session } from 'meteor/session'; import { Tracker } from 'meteor/tracker'; import { ChatSubscription, ChatRoom } from '../../app/models/client'; -import { settings } from '../../app/settings/client'; import { getUserPreference } from '../../app/utils/client'; import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent'; @@ -78,13 +77,9 @@ Meteor.startup(() => { const updateFavicon = manageFavicon(); Tracker.autorun(() => { - const siteName = settings.get('Site_Name') ?? ''; - const unread = Session.get('unread'); fireGlobalEvent('unread-changed', unread); updateFavicon(unread); - - document.title = unread === '' ? siteName : `(${unread}) ${siteName}`; }); }); diff --git a/apps/meteor/client/views/directory/DirectoryPage.tsx b/apps/meteor/client/views/directory/DirectoryPage.tsx index 2bcd32a2a5e8..d260971dde2c 100644 --- a/apps/meteor/client/views/directory/DirectoryPage.tsx +++ b/apps/meteor/client/views/directory/DirectoryPage.tsx @@ -56,8 +56,8 @@ const DirectoryPage = (): ReactElement => { )} - {tab === 'users' && } {tab === 'channels' && } + {tab === 'users' && } {tab === 'teams' && } {federationEnabled && tab === 'external' && } diff --git a/apps/meteor/client/views/directory/tabs/channels/ChannelsTable/ChannelsTable.tsx b/apps/meteor/client/views/directory/tabs/channels/ChannelsTable/ChannelsTable.tsx index 0d2b9f8db289..8ec510eed76d 100644 --- a/apps/meteor/client/views/directory/tabs/channels/ChannelsTable/ChannelsTable.tsx +++ b/apps/meteor/client/views/directory/tabs/channels/ChannelsTable/ChannelsTable.tsx @@ -96,7 +96,7 @@ const ChannelsTable = () => { return ( <> - setText(text)} /> + setText(text)} /> {isLoading && ( {headers} diff --git a/apps/meteor/client/views/directory/tabs/teams/TeamsTable/TeamsTable.tsx b/apps/meteor/client/views/directory/tabs/teams/TeamsTable/TeamsTable.tsx index 160ce8e8cc59..f33b359e4b07 100644 --- a/apps/meteor/client/views/directory/tabs/teams/TeamsTable/TeamsTable.tsx +++ b/apps/meteor/client/views/directory/tabs/teams/TeamsTable/TeamsTable.tsx @@ -73,7 +73,7 @@ const TeamsTable = () => { return ( <> - setText(text)} /> + setText(text)} /> {isLoading && ( {headers} diff --git a/apps/meteor/client/views/directory/tabs/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/directory/tabs/users/UsersTable/UsersTable.tsx index c67d408aa2a3..5b69a59909ca 100644 --- a/apps/meteor/client/views/directory/tabs/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/directory/tabs/users/UsersTable/UsersTable.tsx @@ -95,7 +95,7 @@ const UsersTable = ({ workspace = 'local' }): ReactElement => { return ( <> - setText(text)} /> + setText(text)} /> {isLoading && ( {headers} diff --git a/apps/meteor/client/views/home/cards/CustomContentCard.tsx b/apps/meteor/client/views/home/cards/CustomContentCard.tsx index 44f8c6262406..7683f1e1c300 100644 --- a/apps/meteor/client/views/home/cards/CustomContentCard.tsx +++ b/apps/meteor/client/views/home/cards/CustomContentCard.tsx @@ -13,10 +13,10 @@ const CustomContentCard = (): ReactElement | null => { const { data } = useIsEnterprise(); const isAdmin = useRole('admin'); - const customContentBody = String(useSetting('Layout_Home_Body')); + const customContentBody = useSetting('Layout_Home_Body'); const isCustomContentBodyEmpty = customContentBody === ''; - const isCustomContentVisible = Boolean(useSetting('Layout_Home_Custom_Block_Visible')); - const isCustomContentOnly = Boolean(useSetting('Layout_Custom_Body_Only')); + const isCustomContentVisible = useSetting('Layout_Home_Custom_Block_Visible'); + const isCustomContentOnly = useSetting('Layout_Custom_Body_Only'); const settingsRoute = useRoute('admin-settings'); @@ -55,14 +55,12 @@ const CustomContentCard = (): ReactElement | null => { return ( - + {willNotShowCustomContent ? t('Not_Visible_To_Workspace') : t('Visible_To_Workspace')} - - {isCustomContentBodyEmpty ? t('Homepage_Custom_Content_Default_Message') : } - + {isCustomContentBodyEmpty ? t('Homepage_Custom_Content_Default_Message') : } + ); +}; + +export default AccessibilityShortcut; diff --git a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx index 5a5ea0998c67..1edd6966d065 100644 --- a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx +++ b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx @@ -6,6 +6,7 @@ import type { ReactElement, ReactNode } from 'react'; import React, { useEffect, useRef } from 'react'; import Sidebar from '../../../sidebar'; +import AccessibilityShortcut from './AccessibilityShortcut'; const LayoutWithSidebar = ({ children }: { children: ReactNode }): ReactElement => { const { isEmbedded: embeddedLayout } = useLayout(); @@ -46,10 +47,14 @@ const LayoutWithSidebar = ({ children }: { children: ReactNode }): ReactElement className={[embeddedLayout ? 'embedded-view' : undefined, 'menu-nav'].filter(Boolean).join(' ')} aria-hidden={Boolean(modal)} > + {!removeSidenav && } -
+
{children}
diff --git a/apps/meteor/client/views/root/hooks/useUnreadMessages.ts b/apps/meteor/client/views/root/hooks/useUnreadMessages.ts new file mode 100644 index 000000000000..0f2ee34b8c4c --- /dev/null +++ b/apps/meteor/client/views/root/hooks/useUnreadMessages.ts @@ -0,0 +1,14 @@ +import { useSession, useTranslation } from '@rocket.chat/ui-contexts'; + +export const useUnreadMessages = (): string | undefined => { + const t = useTranslation(); + const unreadMessages = useSession('unread'); + + return (() => { + if (unreadMessages === '') { + return undefined; + } + + return t('unread_messages_counter', { count: unreadMessages }); + })(); +}; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 6898eb26e4a1..33df4302103b 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -4775,6 +4775,7 @@ "Size": "Size", "Skin_tone": "Skin tone", "Skip": "Skip", + "Skip_to_main_content": "Skip to main content", "SLA_Policy": "SLA Policy", "SLA_Policies": "SLA Policies", "SLA_removed": "SLA removed", diff --git a/apps/meteor/tests/e2e/homepage.spec.ts b/apps/meteor/tests/e2e/homepage.spec.ts index 4f8f9d09a2f3..89aa905744df 100644 --- a/apps/meteor/tests/e2e/homepage.spec.ts +++ b/apps/meteor/tests/e2e/homepage.spec.ts @@ -50,7 +50,7 @@ test.describe.serial('homepage', () => { test('visibility and button functionality in custom body with empty custom content', async () => { await test.step('expect default value in custom body', async () => { await expect( - adminPage.locator('role=status[name="Admins may insert content html to be rendered in this white space."]'), + adminPage.locator('div >> text="Admins may insert content html to be rendered in this white space."'), ).toBeVisible(); }); @@ -60,7 +60,7 @@ test.describe.serial('homepage', () => { }); await test.step('expect visibility tag to show "not visible"', async () => { - await expect(adminPage.locator('role=status[name="Not visible to workspace"]')).toBeVisible(); + await expect(adminPage.locator('span >> text="Not visible to workspace"')).toBeVisible(); }); }); }); @@ -72,7 +72,7 @@ test.describe.serial('homepage', () => { test('visibility and button functionality in custom body with custom content', async () => { await test.step('expect custom body to be visible', async () => { - await expect(adminPage.locator('role=status[name="Hello admin"]')).toBeVisible(); + await expect(adminPage.locator('div >> text="Hello admin"')).toBeVisible(); }); await test.step('expect correct state for card buttons', async () => { @@ -101,7 +101,7 @@ test.describe.serial('homepage', () => { }); await test.step('expect visibility tag to show "visible to workspace"', async () => { - await expect(adminPage.locator('role=status[name="Visible to workspace"]')).toBeVisible(); + await expect(adminPage.locator('span >> text="Visible to workspace"')).toBeVisible(); }); }); }); @@ -188,7 +188,7 @@ test.describe.serial('homepage', () => { }); test('expect custom body to be visible', async () => { - await expect(regularUserPage.locator('role=status[name="Hello"]')).toBeVisible(); + await expect(regularUserPage.locator('div >> text="Hello"')).toBeVisible(); }); test.describe('enterprise edition', () => { @@ -208,7 +208,7 @@ test.describe.serial('homepage', () => { }); await test.step('expect custom body to be visible', async () => { - await expect(regularUserPage.locator('role=status[name="Hello"]')).toBeVisible(); + await expect(regularUserPage.locator('div >> text="Hello"')).toBeVisible(); }); }); }); diff --git a/packages/ui-client/src/hooks/useDocumentTitle.spec.ts b/packages/ui-client/src/hooks/useDocumentTitle.spec.ts new file mode 100644 index 000000000000..e5df07fb354c --- /dev/null +++ b/packages/ui-client/src/hooks/useDocumentTitle.spec.ts @@ -0,0 +1,26 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useDocumentTitle } from './useDocumentTitle'; + +const DEFAULT_TITLE = 'Default Title'; +const EXAMPLE_TITLE = 'Example Title'; + +it('should return the default title', () => { + const { result } = renderHook(() => useDocumentTitle(DEFAULT_TITLE)); + + expect(result.current.title).toBe(DEFAULT_TITLE); +}); + +it('should return the default title and empty key value if refocus param is false', () => { + const { result } = renderHook(() => useDocumentTitle(DEFAULT_TITLE, false)); + + expect(result.current.title).toBe(DEFAULT_TITLE); + expect(result.current.key).toBe(''); +}); + +it('should return the default title and the example title concatenated', () => { + renderHook(() => useDocumentTitle(DEFAULT_TITLE)); + const { result } = renderHook(() => useDocumentTitle(EXAMPLE_TITLE)); + + expect(result.current.title).toBe(`${EXAMPLE_TITLE} - ${DEFAULT_TITLE}`); +}); diff --git a/packages/ui-client/src/hooks/useDocumentTitle.ts b/packages/ui-client/src/hooks/useDocumentTitle.ts new file mode 100644 index 000000000000..5c5aca8b2296 --- /dev/null +++ b/packages/ui-client/src/hooks/useDocumentTitle.ts @@ -0,0 +1,54 @@ +import { Emitter } from '@rocket.chat/emitter'; +import { useCallback, useEffect } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +const ee = new Emitter<{ + change: void; +}>(); + +const titles = new Set<{ + title?: string; + refocus?: boolean; +}>(); + +const useReactiveDocumentTitle = (): string => + useSyncExternalStore( + useCallback((callback) => ee.on('change', callback), []), + (): string => + Array.from(titles) + .reverse() + .map(({ title }) => title) + .join(' - '), + ); + +const useReactiveDocumentTitleKey = (): string => + useSyncExternalStore( + useCallback((callback) => ee.on('change', callback), []), + (): string => + Array.from(titles) + .filter(({ refocus }) => refocus) + .map(({ title }) => title) + .join(' - '), + ); + +export const useDocumentTitle = (documentTitle?: string, refocus = true) => { + useEffect(() => { + const titleObj = { + title: documentTitle, + refocus, + }; + + if (titleObj.title) { + titles.add(titleObj); + } + + ee.emit('change'); + + return () => { + titles.delete(titleObj); + ee.emit('change'); + }; + }, [documentTitle, refocus]); + + return { title: useReactiveDocumentTitle(), key: useReactiveDocumentTitleKey() }; +}; diff --git a/packages/ui-client/src/index.ts b/packages/ui-client/src/index.ts index 0e4454d38dbf..c04e795f6ab5 100644 --- a/packages/ui-client/src/index.ts +++ b/packages/ui-client/src/index.ts @@ -1,3 +1,4 @@ export * from './components'; export * from './hooks/useFeaturePreview'; export * from './hooks/useFeaturePreviewList'; +export * from './hooks/useDocumentTitle'; diff --git a/packages/web-ui-registration/src/GuestForm.tsx b/packages/web-ui-registration/src/GuestForm.tsx index 59df56837d86..d6b5fe15f135 100644 --- a/packages/web-ui-registration/src/GuestForm.tsx +++ b/packages/web-ui-registration/src/GuestForm.tsx @@ -1,11 +1,13 @@ import { Button, ButtonGroup } from '@rocket.chat/fuselage'; import { Form } from '@rocket.chat/layout'; +import { useDocumentTitle } from '@rocket.chat/ui-client'; import { useTranslation } from 'react-i18next'; import type { DispatchLoginRouter } from './hooks/useLoginRouter'; const GuestForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRouter }) => { const { t } = useTranslation(); + useDocumentTitle(t('registration.component.login'), false); return (
diff --git a/packages/web-ui-registration/src/LoginForm.tsx b/packages/web-ui-registration/src/LoginForm.tsx index 35141c4646c7..1c6e0661104f 100644 --- a/packages/web-ui-registration/src/LoginForm.tsx +++ b/packages/web-ui-registration/src/LoginForm.tsx @@ -13,6 +13,7 @@ import { } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { Form, ActionLink } from '@rocket.chat/layout'; +import { useDocumentTitle } from '@rocket.chat/ui-client'; import { useLoginWithPassword, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import type { ReactElement } from 'react'; @@ -79,6 +80,8 @@ export const LoginForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRoute const usernameOrEmailPlaceholder = String(useSetting('Accounts_EmailOrUsernamePlaceholder')); const passwordPlaceholder = String(useSetting('Accounts_PasswordPlaceholder')); + useDocumentTitle(t('registration.component.login'), false); + const loginMutation = useMutation({ mutationFn: (formData: { username: string; password: string }) => { return login(formData.username, formData.password); diff --git a/packages/web-ui-registration/src/RegisterSecretPageRouter.tsx b/packages/web-ui-registration/src/RegisterSecretPageRouter.tsx index 889d7e53e7b9..08cf481061f9 100644 --- a/packages/web-ui-registration/src/RegisterSecretPageRouter.tsx +++ b/packages/web-ui-registration/src/RegisterSecretPageRouter.tsx @@ -1,5 +1,7 @@ +import { useDocumentTitle } from '@rocket.chat/ui-client'; import { useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import RegisterForm from './RegisterForm'; import RegisterFormDisabled from './RegisterFormDisabled'; @@ -16,12 +18,15 @@ export const RegisterSecretPageRouter = ({ setLoginRoute: DispatchLoginRouter; origin: 'register' | 'secret-register' | 'invite-register'; }): ReactElement => { + const { t } = useTranslation(); const registrationMode = useSetting('Accounts_RegistrationForm'); const isPublicRegistration = registrationMode === 'Public'; const isRegistrationAllowedForSecret = registrationMode === 'Secret URL'; const isRegistrationDisabled = registrationMode === 'Disabled' || (origin === 'register' && isRegistrationAllowedForSecret); + useDocumentTitle(t('registration.component.form.createAnAccount'), false); + if (origin === 'secret-register' && !isRegistrationAllowedForSecret) { return ; } diff --git a/packages/web-ui-registration/src/ResetPasswordForm.tsx b/packages/web-ui-registration/src/ResetPasswordForm.tsx index f395a093d4ec..ae771c4494f7 100644 --- a/packages/web-ui-registration/src/ResetPasswordForm.tsx +++ b/packages/web-ui-registration/src/ResetPasswordForm.tsx @@ -1,6 +1,7 @@ import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { Form, ActionLink } from '@rocket.chat/layout'; +import { useDocumentTitle } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import { useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; @@ -15,6 +16,8 @@ export const ResetPasswordForm = ({ setLoginRoute }: { setLoginRoute: DispatchLo const formLabelId = useUniqueId(); const forgotPasswordFormRef = useRef(null); + useDocumentTitle(t('registration.component.resetPassword'), false); + const { register, handleSubmit,