feat: Skip to main content shortcut and `useDocumentTitle` (#30680)

Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
pull/31161/head
Douglas Fabris 2 years ago committed by GitHub
parent 0681c455fc
commit dd5fd6d2c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      .changeset/kind-beers-share.md
  2. 8
      apps/meteor/client/components/Page/PageHeader.tsx
  3. 2
      apps/meteor/client/sidebar/Item/Condensed.tsx
  4. 4
      apps/meteor/client/sidebar/Item/Extended.tsx
  5. 2
      apps/meteor/client/sidebar/Item/Medium.tsx
  6. 5
      apps/meteor/client/startup/unread.ts
  7. 2
      apps/meteor/client/views/directory/DirectoryPage.tsx
  8. 2
      apps/meteor/client/views/directory/tabs/channels/ChannelsTable/ChannelsTable.tsx
  9. 2
      apps/meteor/client/views/directory/tabs/teams/TeamsTable/TeamsTable.tsx
  10. 2
      apps/meteor/client/views/directory/tabs/users/UsersTable/UsersTable.tsx
  11. 12
      apps/meteor/client/views/home/cards/CustomContentCard.tsx
  12. 18
      apps/meteor/client/views/room/Header/RoomTitle.tsx
  13. 7
      apps/meteor/client/views/root/AppLayout.tsx
  14. 55
      apps/meteor/client/views/root/DocumentTitleWrapper.tsx
  15. 33
      apps/meteor/client/views/root/MainLayout/AccessibilityShortcut.tsx
  16. 7
      apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx
  17. 14
      apps/meteor/client/views/root/hooks/useUnreadMessages.ts
  18. 1
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  19. 12
      apps/meteor/tests/e2e/homepage.spec.ts
  20. 26
      packages/ui-client/src/hooks/useDocumentTitle.spec.ts
  21. 54
      packages/ui-client/src/hooks/useDocumentTitle.ts
  22. 1
      packages/ui-client/src/index.ts
  23. 2
      packages/web-ui-registration/src/GuestForm.tsx
  24. 3
      packages/web-ui-registration/src/LoginForm.tsx
  25. 5
      packages/web-ui-registration/src/RegisterSecretPageRouter.tsx
  26. 3
      packages/web-ui-registration/src/ResetPasswordForm.tsx

@ -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

@ -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<PageHeaderProps> = ({ children = undefined, title, onClickB
const t = useTranslation();
const [border] = useContext(PageContext);
const { isMobile } = useLayout();
const headerAutoFocus = useAutoFocus();
useDocumentTitle(typeof title === 'string' ? title : undefined);
return (
<Box
tabIndex={-1}
ref={headerAutoFocus}
is='header'
borderBlockEndWidth='default'
minHeight='x64'

@ -44,7 +44,7 @@ const Condensed: FC<CondensedProps> = ({ icon, title = '', avatar, actions, href
{badges && <Sidebar.Item.Badge>{badges}</Sidebar.Item.Badge>}
{menu && (
<Sidebar.Item.Menu {...handleMenuEvent}>
{menuVisibility ? menu() : <IconButton mini rcx-sidebar-item__menu icon='kebab' />}
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-item__menu icon='kebab' />}
</Sidebar.Item.Menu>
)}
</Sidebar.Item.Content>

@ -54,7 +54,7 @@ const Extended: VFC<ExtendedProps> = ({
};
return (
<Sidebar.Item aria-selected={selected} selected={selected} highlighted={unread} {...props} {...({ href } as any)} clickable={!!href}>
<Sidebar.Item selected={selected} highlighted={unread} {...props} {...({ href } as any)} clickable={!!href}>
{avatar && <Sidebar.Item.Avatar>{avatar}</Sidebar.Item.Avatar>}
<Sidebar.Item.Content>
<Sidebar.Item.Content>
@ -72,7 +72,7 @@ const Extended: VFC<ExtendedProps> = ({
<Sidebar.Item.Badge>{badges}</Sidebar.Item.Badge>
{menu && (
<Sidebar.Item.Menu {...handleMenuEvent}>
{menuVisibility ? menu() : <IconButton mini rcx-sidebar-item__menu icon='kebab' />}
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-item__menu icon='kebab' />}
</Sidebar.Item.Menu>
)}
</Sidebar.Item.Wrapper>

@ -42,7 +42,7 @@ const Medium: VFC<MediumProps> = ({ icon, title = '', avatar, actions, href, bad
{badges && <Sidebar.Item.Badge>{badges}</Sidebar.Item.Badge>}
{menu && (
<Sidebar.Item.Menu {...handleMenuEvent}>
{menuVisibility ? menu() : <IconButton mini rcx-sidebar-item__menu icon='kebab' />}
{menuVisibility ? menu() : <IconButton tabIndex={-1} aria-hidden mini rcx-sidebar-item__menu icon='kebab' />}
</Sidebar.Item.Menu>
)}
</Sidebar.Item.Content>

@ -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}`;
});
});

@ -56,8 +56,8 @@ const DirectoryPage = (): ReactElement => {
)}
</Tabs>
<Page.Content>
{tab === 'users' && <UsersTab />}
{tab === 'channels' && <ChannelsTab />}
{tab === 'users' && <UsersTab />}
{tab === 'teams' && <TeamsTab />}
{federationEnabled && tab === 'external' && <UsersTab workspace='external' />}
</Page.Content>

@ -96,7 +96,7 @@ const ChannelsTable = () => {
return (
<>
<FilterByText autoFocus placeholder={t('Search_Channels')} onChange={({ text }): void => setText(text)} />
<FilterByText placeholder={t('Search_Channels')} onChange={({ text }): void => setText(text)} />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>

@ -73,7 +73,7 @@ const TeamsTable = () => {
return (
<>
<FilterByText placeholder={t('Teams_Search_teams')} autoFocus onChange={({ text }): void => setText(text)} />
<FilterByText placeholder={t('Teams_Search_teams')} onChange={({ text }): void => setText(text)} />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>

@ -95,7 +95,7 @@ const UsersTable = ({ workspace = 'local' }): ReactElement => {
return (
<>
<FilterByText autoFocus placeholder={t('Search_Users')} onChange={({ text }): void => setText(text)} />
<FilterByText placeholder={t('Search_Users')} onChange={({ text }): void => setText(text)} />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>

@ -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<string>('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<boolean>('Layout_Home_Custom_Block_Visible');
const isCustomContentOnly = useSetting<boolean>('Layout_Custom_Body_Only');
const settingsRoute = useRoute('admin-settings');
@ -55,14 +55,12 @@ const CustomContentCard = (): ReactElement | null => {
return (
<Card data-qa-id='homepage-custom-card'>
<Box display='flex' mbe={12}>
<Tag role='status' aria-label={willNotShowCustomContent ? t('Not_Visible_To_Workspace') : t('Visible_To_Workspace')}>
<Tag>
<Icon mie={4} name={willNotShowCustomContent ? 'eye-off' : 'eye'} size='x12' />
{willNotShowCustomContent ? t('Not_Visible_To_Workspace') : t('Visible_To_Workspace')}
</Tag>
</Box>
<Box mb={8} role='status' aria-label={isCustomContentBodyEmpty ? t('Homepage_Custom_Content_Default_Message') : customContentBody}>
{isCustomContentBodyEmpty ? t('Homepage_Custom_Content_Default_Message') : <CustomHomepageContent />}
</Box>
<Box mb={8}>{isCustomContentBodyEmpty ? t('Homepage_Custom_Content_Default_Message') : <CustomHomepageContent />}</Box>
<CardFooterWrapper>
<CardFooter>
<Button onClick={() => settingsRoute.push({ group: 'Layout' })} title={t('Layout_Home_Page_Content')}>

@ -1,5 +1,5 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { HeaderTitle } from '@rocket.chat/ui-client';
import { HeaderTitle, useDocumentTitle } from '@rocket.chat/ui-client';
import type { ReactElement } from 'react';
import React from 'react';
@ -9,11 +9,15 @@ type RoomTitleProps = {
room: IRoom;
};
const RoomTitle = ({ room }: RoomTitleProps): ReactElement => (
<>
<HeaderIconWithRoom room={room} />
<HeaderTitle is='h1'>{room.name}</HeaderTitle>
</>
);
const RoomTitle = ({ room }: RoomTitleProps): ReactElement => {
useDocumentTitle(room.name, false);
return (
<>
<HeaderIconWithRoom room={room} />
<HeaderTitle is='h1'>{room.name}</HeaderTitle>
</>
);
};
export default RoomTitle;

@ -4,6 +4,7 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { useAnalytics } from '../../../app/analytics/client/loadScript';
import { useAnalyticsEventTracking } from '../../hooks/useAnalyticsEventTracking';
import { appLayout } from '../../lib/appLayout';
import DocumentTitleWrapper from './DocumentTitleWrapper';
import PageLoading from './PageLoading';
import { useEscapeKeyStroke } from './hooks/useEscapeKeyStroke';
import { useGoogleTagManager } from './hooks/useGoogleTagManager';
@ -26,7 +27,11 @@ const AppLayout = () => {
const layout = useSyncExternalStore(appLayout.subscribe, appLayout.getSnapshot);
return <Suspense fallback={<PageLoading />}>{layout}</Suspense>;
return (
<Suspense fallback={<PageLoading />}>
<DocumentTitleWrapper>{layout}</DocumentTitleWrapper>
</Suspense>
);
};
export default AppLayout;

@ -0,0 +1,55 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useDocumentTitle } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import type { FC } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useUnreadMessages } from './hooks/useUnreadMessages';
const useRouteTitleFocus = () => {
return useCallback((node: HTMLElement | null) => {
if (!node) {
return;
}
node.focus();
}, []);
};
const DocumentTitleWrapper: FC = ({ children }) => {
useDocumentTitle(useSetting<string>('Site_Name') || '', false);
const { title, key } = useDocumentTitle(useUnreadMessages(), false);
const refocusRef = useRouteTitleFocus();
useEffect(() => {
document.title = title;
}, [title]);
return (
<>
<Box
tabIndex={-1}
ref={refocusRef}
key={key}
className={css`
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
`}
>
{title}
</Box>
{children}
</>
);
};
export default DocumentTitleWrapper;

@ -0,0 +1,33 @@
import { css } from '@rocket.chat/css-in-js';
import { Button } from '@rocket.chat/fuselage';
import { useRouter, useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
const AccessibilityShortcut = () => {
const t = useTranslation();
const router = useRouter();
const currentRoutePath = router.getLocationPathname();
const customButtonClass = css`
position: absolute;
top: 2px;
left: 2px;
z-index: 99;
&:not(:focus) {
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
border: 0;
}
`;
return (
<Button className={customButtonClass} is='a' href={`${currentRoutePath}#main-content`} primary>
{t('Skip_to_main_content')}
</Button>
);
};
export default AccessibilityShortcut;

@ -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)}
>
<AccessibilityShortcut />
<PaletteStyleTag />
<SidebarPaletteStyleTag />
{!removeSidenav && <Sidebar />}
<main className={['rc-old', 'main-content', readReceiptsEnabled ? 'read-receipts-enabled' : undefined].filter(Boolean).join(' ')}>
<main
id='main-content'
className={['rc-old', 'main-content', readReceiptsEnabled ? 'read-receipts-enabled' : undefined].filter(Boolean).join(' ')}
>
{children}
</main>
</Box>

@ -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 });
})();
};

@ -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",

@ -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();
});
});
});

@ -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}`);
});

@ -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() };
};

@ -1,3 +1,4 @@
export * from './components';
export * from './hooks/useFeaturePreview';
export * from './hooks/useFeaturePreviewList';
export * from './hooks/useDocumentTitle';

@ -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 (
<Form>

@ -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);

@ -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<string>('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 <SecretRegisterInvalidForm />;
}

@ -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<HTMLElement>(null);
useDocumentTitle(t('registration.component.resetPassword'), false);
const {
register,
handleSubmit,

Loading…
Cancel
Save