Skip to content

Commit

Permalink
Constrain tab navigation in toc (#2159)
Browse files Browse the repository at this point in the history
* Trap tab in open TOC

* Exclude hidden elements from focusable

* Constrain tab navigation

* Include Navbar and Book banner in tab cycle

* Uncomplicate things

* Trap tab in mobile menu

when mobile menu is open but TOC is not open

* update mobile menu test

---------

Co-authored-by: staxly[bot] <35789409+staxly[bot]@users.noreply.github.com>
  • Loading branch information
RoyEJohnson and staxly[bot] authored Feb 12, 2024
1 parent e011e9c commit df4df9f
Show file tree
Hide file tree
Showing 6 changed files with 299 additions and 128 deletions.
39 changes: 19 additions & 20 deletions src/app/content/components/TableOfContents/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import * as actions from '../../actions';
import { initialState } from '../../reducer';
import { formatBookData } from '../../utils';
import * as domUtils from '../../utils/domUtils';
import * as reactUtils from '../../../reactUtils';

const book = formatBookData(archiveBook, mockCmsBook);

describe('TableOfContents', () => {
let store: Store;
let Component: React.JSX.Element; // tslint:disable-line:variable-name

beforeEach(() => {
const state = {
Expand All @@ -27,29 +29,27 @@ describe('TableOfContents', () => {
},
} as any as AppState;
store = createTestStore(state);
Component =
<TestContainer store={store}>
<ConnectedTableOfContents />
</TestContainer>;
});

it('mounts and unmounts without a dom', () => {
const component = renderer.create(<TestContainer store={store}>
<ConnectedTableOfContents />
</TestContainer>);
const component = renderer.create(Component);
expect(() => component.unmount()).not.toThrow();
});

it('mounts and unmmounts with a dom', () => {
const {root} = renderToDom(<TestContainer store={store}>
<ConnectedTableOfContents />
</TestContainer>);
const {root} = renderToDom(Component);
expect(() => unmountComponentAtNode(root)).not.toThrow();
});

it('expands and scrolls to current chapter', () => {
const scrollSidebarSectionIntoView = jest.spyOn(domUtils, 'scrollSidebarSectionIntoView');
const expandCurrentChapter = jest.spyOn(domUtils, 'expandCurrentChapter');

renderer.create(<TestContainer store={store}>
<ConnectedTableOfContents />
</TestContainer>);
renderer.create(Component);

expect(expandCurrentChapter).not.toHaveBeenCalled();
expect(scrollSidebarSectionIntoView).toHaveBeenCalledTimes(1);
Expand All @@ -63,9 +63,14 @@ describe('TableOfContents', () => {
});

it('opens and closes', () => {
const component = renderer.create(<TestContainer store={store}>
<ConnectedTableOfContents />
</TestContainer>);
jest.spyOn(reactUtils, 'useMatchMobileQuery')
.mockReturnValue(true);
jest.spyOn(reactUtils, 'useMatchMobileMediumQuery')
.mockReturnValue(true);
const component = renderer.create(Component);

// To exercise ref code
renderToDom(Component);

expect(component.root.findByType(TableOfContents).props.isOpen).toBe(null);
renderer.act(() => {
Expand All @@ -81,9 +86,7 @@ describe('TableOfContents', () => {
it('resets toc on navigate', () => {
const dispatchSpy = jest.spyOn(store, 'dispatch');

const component = renderer.create(<TestContainer store={store}>
<ConnectedTableOfContents />
</TestContainer>);
const component = renderer.create(Component);

renderer.act(() => {
component.root.findAllByType('a')[0].props.onClick({preventDefault: () => null});
Expand All @@ -97,11 +100,7 @@ describe('TableOfContents', () => {
return expect(document).toBeTruthy();
}

const render = () => <TestContainer store={store}>
<ConnectedTableOfContents />
</TestContainer>;

const {node} = renderToDom(render());
const {node} = renderToDom(Component);
const spy = jest.spyOn(node.style, 'setProperty');

const event = document.createEvent('UIEvents');
Expand Down
229 changes: 158 additions & 71 deletions src/app/content/components/TableOfContents/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HTMLElement } from '@openstax/types/lib.dom';
import React, { Component } from 'react';
import { HTMLElement, NodeListOf, Element } from '@openstax/types/lib.dom';
import React, { Component, MutableRefObject } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { connect } from 'react-redux';
import { AppState, Dispatch } from '../../../types';
Expand All @@ -15,6 +15,7 @@ import { CloseToCAndMobileMenuButton, TOCBackButton, TOCCloseButton } from '../S
import { Header, HeaderText, SidebarPaneBody } from '../SidebarPane';
import { LeftArrow, TimesIcon } from '../Toolbar/styled';
import * as Styled from './styled';
import { createTrapTab, useMatchMobileQuery, useMatchMobileMediumQuery } from '../../../reactUtils';

interface SidebarProps {
onNavigate: () => void;
Expand All @@ -23,28 +24,169 @@ interface SidebarProps {
page?: Page;
}

function TabTrapper({
mRef,
isTocOpen,
}: {
mRef: MutableRefObject<HTMLElement>;
isTocOpen: boolean;
}) {
const isPhone = useMatchMobileMediumQuery();
const isMobile = useMatchMobileQuery();

React.useEffect(() => {
if (!mRef?.current) {
return;
}
const otherRegions =
document?.querySelectorAll(
'[data-testid="navbar"],[data-testid="bookbanner"]'
) as NodeListOf<Element>;
const containers = [
mRef.current,
...(isPhone
? []
: [mRef.current.previousElementSibling, ...Array.from(otherRegions)]),
];
const listener = createTrapTab(...(containers as HTMLElement[]));
if (isTocOpen && isMobile) {
document?.addEventListener('keydown', listener, true);
}

return () => document?.removeEventListener('keydown', listener, true);
}, [mRef, isTocOpen, isMobile, isPhone]);

return null;
}

// tslint:disable-next-line:variable-name
const SidebarBody = React.forwardRef<HTMLElement, React.ComponentProps<typeof SidebarPaneBody>>(
(props, ref) => <SidebarPaneBody
ref={ref}
data-testid='toc'
aria-label={useIntl().formatMessage({id: 'i18n:toc:title'})}
data-analytics-region='toc'
{...props}
/>
);
const SidebarBody = React.forwardRef<
HTMLElement,
React.ComponentProps<typeof SidebarPaneBody>
>((props, ref) => {

return (
<React.Fragment>
{typeof window !== 'undefined' && (
<TabTrapper
mRef={ref as MutableRefObject<HTMLElement>}
isTocOpen={props.isTocOpen}
/>
)}
<SidebarPaneBody
ref={ref}
data-testid='toc'
aria-label={useIntl().formatMessage({ id: 'i18n:toc:title' })}
data-analytics-region='toc'
{...props}
/>
</React.Fragment>
);
});

function TocHeader() {
return (
<Header data-testid='tocheader'>
<TOCBackButton><LeftArrow /></TOCBackButton>
<FormattedMessage id='i18n:toc:title'>
{(msg) => <HeaderText>{msg}</HeaderText>}
</FormattedMessage>
<CloseToCAndMobileMenuButton />
<TOCCloseButton><TimesIcon /></TOCCloseButton>
</Header>
);
}

function TocNode({
defaultOpen,
title,
children,
}: React.PropsWithChildren<{ defaultOpen: boolean; title: string }>) {
return (
<Styled.NavDetails {...(defaultOpen ? {defaultOpen} : {})}>
<Styled.Summary>
<Styled.SummaryWrapper>
<Styled.ExpandIcon/>
<Styled.CollapseIcon/>
<Styled.SummaryTitle dangerouslySetInnerHTML={{__html: title}}/>
</Styled.SummaryWrapper>
</Styled.Summary>
{children}
</Styled.NavDetails>
);
}

function TocSection({
book,
page,
section,
activeSection,
onNavigate,
}: {
book: Book | undefined;
page: Page | undefined;
section: ArchiveTree;
activeSection: React.RefObject<HTMLElement>;
onNavigate: () => void;
}) {
return (
<Styled.NavOl section={section}>
{linkContents(section).map((item) => {
const sectionType = getArchiveTreeSectionType(item);
const active = page && stripIdVersion(item.id) === page.id;

return isArchiveTree(item)
? <Styled.NavItem key={item.id} sectionType={sectionType}>
<TocNode defaultOpen={shouldBeOpen(page, item)} title={item.title}>
<TocSection
book={book} page={page} section={item} activeSection={activeSection}
onNavigate={onNavigate}
/>
</TocNode>
</Styled.NavItem>
: <Styled.NavItem
key={item.id}
sectionType={sectionType}
ref={active ? activeSection : null}
active={active}
>
<Styled.ContentLink
onClick={onNavigate}
book={book}
page={item}
dangerouslySetInnerHTML={{__html: item.title}}
/>
</Styled.NavItem>;
})}
</Styled.NavOl>
);
}

function shouldBeOpen(page: Page | undefined, node: ArchiveTree) {
return Boolean(page && archiveTreeContainsNode(node, page.id));
}

export class TableOfContents extends Component<SidebarProps> {
public sidebar = React.createRef<HTMLElement>();
public activeSection = React.createRef<HTMLElement>();

public render() {
const {isOpen, book} = this.props;
const { isOpen, book } = this.props;

return <SidebarBody isTocOpen={isOpen} ref={this.sidebar}>
{this.renderTocHeader()}
{book && this.renderToc(book)}
</SidebarBody>;
return (
<SidebarBody isTocOpen={isOpen} ref={this.sidebar}>
<TocHeader />
{book && (
<TocSection
book={book}
page={this.props.page}
section={book.tree}
activeSection={this.activeSection}
onNavigate={this.props.onNavigate}
/>
)}
</SidebarBody>
);
}

public componentDidMount() {
Expand Down Expand Up @@ -75,61 +217,6 @@ export class TableOfContents extends Component<SidebarProps> {
private scrollToSelectedPage() {
scrollSidebarSectionIntoView(this.sidebar.current, this.activeSection.current);
}

private renderChildren = (book: Book, section: ArchiveTree) =>
<Styled.NavOl section={section}>
{linkContents(section).map((item) => {
const sectionType = getArchiveTreeSectionType(item);
const active = this.props.page && stripIdVersion(item.id) === this.props.page.id;

return isArchiveTree(item)
? <Styled.NavItem key={item.id} sectionType={sectionType}>
{this.renderTocNode(book, item)}
</Styled.NavItem>
: <Styled.NavItem
key={item.id}
sectionType={sectionType}
ref={active ? this.activeSection : null}
active={active}
>
<Styled.ContentLink
onClick={this.props.onNavigate}
book={book}
page={item}
dangerouslySetInnerHTML={{__html: item.title}}
/>
</Styled.NavItem>;
})}
</Styled.NavOl>;

private renderTocNode = (book: Book, node: ArchiveTree) => <Styled.NavDetails
{...this.props.page && archiveTreeContainsNode(node, this.props.page.id)
? {defaultOpen: true}
: {}
}
>
<Styled.Summary>
<Styled.SummaryWrapper>
<Styled.ExpandIcon/>
<Styled.CollapseIcon/>
<Styled.SummaryTitle dangerouslySetInnerHTML={{__html: node.title}}/>
</Styled.SummaryWrapper>
</Styled.Summary>
{this.renderChildren(book, node)}
</Styled.NavDetails>;

private renderTocHeader = () => {
return <Header data-testid='tocheader'>
<TOCBackButton><LeftArrow /></TOCBackButton>
<FormattedMessage id='i18n:toc:title'>
{(msg) => <HeaderText>{msg}</HeaderText>}
</FormattedMessage>
<CloseToCAndMobileMenuButton />
<TOCCloseButton><TimesIcon /></TOCCloseButton>
</Header>;
};

private renderToc = (book: Book) => this.renderChildren(book, book.tree);
}

export default connect(
Expand Down
8 changes: 7 additions & 1 deletion src/app/content/components/Toolbar/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { receiveFeatureFlags } from '../../../featureFlags/actions';
import { MiddlewareAPI, Store } from '../../../types';
import { assertWindow } from '../../../utils';
import { closeMobileMenu } from '../../actions';
import { openToc } from '../../actions';
import { practiceQuestionsFeatureFlag } from '../../constants';
import { clearSearch, openSearchInSidebar } from '../../search/actions';
import * as searchSelectors from '../../search/selectors';
Expand Down Expand Up @@ -54,15 +55,20 @@ describe('toolbar', () => {

it('has a button that closes mobile menu', () => {
const dispatchSpy = jest.spyOn(store, 'dispatch');
const sidebar = assertWindow().document.createElement('div');
const component = renderer.create(<TestContainer store={store}>
<Toolbar />
</TestContainer>);
</TestContainer>, { createNodeMock: () => sidebar });

renderer.act(() => {
component.root.findByType(CloseToCAndMobileMenuButton).findByType(PlainButton).props.onClick();
});

expect(dispatchSpy).toHaveBeenCalledWith(closeMobileMenu());

// exercise teardown of useEffect
store.dispatch(openToc());
expect(selectors.tocOpen(store.getState())).toEqual(true);
});

describe('print button', () => {
Expand Down
Loading

0 comments on commit df4df9f

Please sign in to comment.