diff --git a/playwright/src/fixtures/toc.ts b/playwright/src/fixtures/toc.ts index 06e60a8584..f8c7d73c7f 100644 --- a/playwright/src/fixtures/toc.ts +++ b/playwright/src/fixtures/toc.ts @@ -13,7 +13,7 @@ class TOC { constructor(page: Page) { this.page = page this.pageLocator = this.page.locator('[data-type="page"]') - this.tocDropdownLocator = this.page.locator('details[class*="NavDetails"]') + this.tocDropdownLocator = this.page.locator('a[class*="NavCollapse"]') this.sectionNameLocator = this.page.locator('h1[class*="BookBanner"]') this.pageSlugLocator = this.page.locator('[data-type="page"] a') this.currentPageLocator = this.page.locator("[aria-label*='Current Page'] a") @@ -33,7 +33,7 @@ class TOC { async unitIntroCount() { // Total number of unit introduction pages in the book - const unitIntroPageLocator = this.page.locator('//li[@data-type="unit"]/details/ol[1]/li[1][@data-type="page"]') + const unitIntroPageLocator = this.page.locator('//li[@data-type="unit"]/ol[1]/li[1][@data-type="page"]') return await unitIntroPageLocator.count() } @@ -72,7 +72,7 @@ class TOC { await this.pageLocator.nth(pageNumber).click() } else { // expand the dropdowns in toc - await this.page.waitForSelector('details[class*="NavDetails"]') + await this.page.waitForSelector('a[class*="NavCollapse"]') const tocDropdownCounts = await this.tocDropdownLocator.count() let tocDropdownCount: number for (tocDropdownCount = 0; tocDropdownCount < tocDropdownCounts; tocDropdownCount++) { @@ -143,7 +143,7 @@ class TOC { // Return unit name of the current page const toc = this.page.locator('nav[data-testid=toc]') const unitLocator = toc - .locator('css=[data-type=unit] >> details', { + .locator('css=[data-type=unit] >> a', { has: this.page.locator(`[href="${await this.CurrentPageSlug()}"]`), }) .first() diff --git a/pytest-selenium/regions/toc.py b/pytest-selenium/regions/toc.py index a90fe8daa0..2f198b8f4c 100644 --- a/pytest-selenium/regions/toc.py +++ b/pytest-selenium/regions/toc.py @@ -21,7 +21,7 @@ class TableOfContents(Region): _section_link_locator = (By.CSS_SELECTOR, "ol li a") _active_section_locator = (By.CSS_SELECTOR, "[aria-label='Current Page']") - _chapter_link_selector = "li details" + _chapter_link_selector = "li a" @property def active_section(self): diff --git a/src/app/components/Details.tsx b/src/app/components/Details.tsx index 3f1b7e19ba..343a1ac7b1 100644 --- a/src/app/components/Details.tsx +++ b/src/app/components/Details.tsx @@ -50,3 +50,17 @@ export const Details = styled.details` } `} `; + +// Other components than ToC use Details, so we need to style them separately +// tslint:disable-next-line:variable-name +export const CollapseToggle = styled.a` + ${/* suppress errors from https://github.com/stylelint/stylelint/issues/3391 */ css` + &[open] > ${ExpandIcon} { + display: none; + } + + &:not([open]) > ${CollapseIcon} { + display: none; + } + `} +`; \ No newline at end of file diff --git a/src/app/content/components/Assigned.spec.tsx b/src/app/content/components/Assigned.spec.tsx index 51fdc08bdf..3e879f8b3d 100644 --- a/src/app/content/components/Assigned.spec.tsx +++ b/src/app/content/components/Assigned.spec.tsx @@ -154,7 +154,7 @@ describe('Assigned', () => { 'aria-label': 'Next Page', 'href': 'books/book-slug-1/pages/3-test-page-4', }) - .props.onClick({ preventDefault: jest.fn() }); + .props.onClick({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); await services.promiseCollector.calm(); }); @@ -209,7 +209,7 @@ describe('Assigned', () => { 'aria-label': 'Next Page', 'href': 'books/book-slug-1/pages/3-test-page-4', }) - .props.onClick({ preventDefault: jest.fn() }); + .props.onClick({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); await services.promiseCollector.calm(); }); @@ -223,7 +223,7 @@ describe('Assigned', () => { 'aria-label': 'Previous Page', 'href': 'books/book-slug-1/pages/test-page-1', }) - .props.onClick({ preventDefault: jest.fn() }); + .props.onClick({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); await services.promiseCollector.calm(); }); diff --git a/src/app/content/components/ContentLink.spec.tsx b/src/app/content/components/ContentLink.spec.tsx index 33d9c71d8e..326806ee7f 100644 --- a/src/app/content/components/ContentLink.spec.tsx +++ b/src/app/content/components/ContentLink.spec.tsx @@ -45,6 +45,21 @@ describe('ContentLink', () => { return event; }; + const onKeyDownMock = (event: React.KeyboardEvent, method: () => void) => { + event.preventDefault(); + method(); + }; + + const keyDown = async(component: renderer.ReactTestRenderer) => { + const event = { + preventDefault: jest.fn(), + }; + + await component.root.findByType('a').props.onKeyDown(event); + + return event; + }; + describe('without unsaved changes', () => { // tslint:disable-next-line:variable-name let ConnectedContentLink: React.ElementType; @@ -53,119 +68,151 @@ describe('ContentLink', () => { ConnectedContentLink = require('./ContentLink').default; }); - it('dispatches navigation action on click', async() => { + it.each` + method | description + ${click} | ${'using onClick'} + ${keyDown} | ${'using onKeyDown'} + `('dispatches navigation action on click %description', async({ method }) => { const component = renderer.create( - + ); - const event = await click(component); + const event = await method(component); expect(dispatch).toHaveBeenCalledWith(push({ - params: {book: {slug: BOOK_SLUG}, page: {slug: PAGE_SLUG}}, + params: { book: { slug: BOOK_SLUG }, page: { slug: PAGE_SLUG } }, route: content, - state: { }, + state: {}, })); expect(event.preventDefault).toHaveBeenCalled(); }); - it('dispatches navigation action with search if there is a search', async() => { + it.each` + method | description + ${click} | ${'using onClick'} + ${keyDown} | ${'using onKeyDown'} + `('dispatches navigation action with search if there is a search %description', async({ method }) => { store.dispatch(requestSearch('asdf')); store.dispatch(receiveBook(book)); const mockSearch = { query: 'asdf', }; const component = renderer.create( - + ); - const event = await click(component); + const event = await method(component); expect(dispatch).toHaveBeenCalledWith(push({ - params: {book: {slug: BOOK_SLUG}, page: {slug: PAGE_SLUG}}, + params: { book: { slug: BOOK_SLUG }, page: { slug: PAGE_SLUG } }, route: content, - state: { }, + state: {}, }, { search: 'query=asdf' })); expect(event.preventDefault).toHaveBeenCalled(); }); - it('search passed as prop overwrites search from the redux state', async() => { + it.each` + method | description + ${click} | ${'using onClick'} + ${keyDown} | ${'using onKeyDown'} + `('search passed as prop overwrites search from the redux state %description', async({ method }) => { store.dispatch(requestSearch('asdf')); store.dispatch(receiveBook(book)); const mockSearch = { query: 'search-from-direct-prop', }; const component = renderer.create( - + ); - const event = await click(component); + const event = await method(component); expect(dispatch).toHaveBeenCalledWith(push({ - params: {book: {slug: BOOK_SLUG}, page: {slug: PAGE_SLUG}}, + params: { book: { slug: BOOK_SLUG }, page: { slug: PAGE_SLUG } }, route: content, - state: { }, + state: {}, }, { search: `query=${mockSearch.query}` })); expect(event.preventDefault).toHaveBeenCalled(); }); - it('dispatches navigation action with scroll target data and search if scroll target is passed', async() => { - const scrollTarget: SearchScrollTarget = { type: 'search', index: 1, elementId: 'anchor' }; - store.dispatch(requestSearch('asdf')); - store.dispatch(receiveBook(book)); - const mockSearch = { - query: 'asdf', - }; - const component = renderer.create( - - ); - - dispatch.mockClear(); - - const event = await click(component); - - expect(dispatch).toHaveBeenCalledWith(push({ - params: {book: {slug: BOOK_SLUG}, page: {slug: PAGE_SLUG}}, - route: content, - state: { }, - }, { - hash: `#${scrollTarget.elementId}`, - search: queryString.stringify({ + it.each` + method | description + ${click} | ${'using onClick'} + ${keyDown} | ${'using onKeyDown'} + `('dispatches navigation action with scroll target data and search if scroll target is passed %description', + async({ method }) => { + const scrollTarget: SearchScrollTarget = { type: 'search', index: 1, elementId: 'anchor' }; + store.dispatch(requestSearch('asdf')); + store.dispatch(receiveBook(book)); + const mockSearch = { query: 'asdf', - target: JSON.stringify(omit('elementId', scrollTarget)), - }), - })); - expect(event.preventDefault).toHaveBeenCalled(); - }); - - it('dispatches navigation action without search when linking to a different book', async() => { - store.dispatch(requestSearch('asdf')); - store.dispatch(receiveBook({...book, id: 'differentid'})); - const component = renderer.create( - - ); - - const event = await click(component); - - expect(dispatch).toHaveBeenCalledWith(push({ - params: {book: {slug: BOOK_SLUG}, page: {slug: PAGE_SLUG}}, - route: content, - state: { }, - })); - expect(event.preventDefault).toHaveBeenCalled(); - }); - - it('calls onClick when passed', async() => { + }; + const component = renderer.create( + + ); + + dispatch.mockClear(); + + const event = await method(component); + + expect(dispatch).toHaveBeenCalledWith(push({ + params: { book: { slug: BOOK_SLUG }, page: { slug: PAGE_SLUG } }, + route: content, + state: {}, + }, { + hash: `#${scrollTarget.elementId}`, + search: queryString.stringify({ + query: 'asdf', + target: JSON.stringify(omit('elementId', scrollTarget)), + }), + })); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it.each` + method | description + ${click} | ${'using onClick'} + ${keyDown} | ${'using onKeyDown'} + `('dispatches navigation action without search when linking to a different book %description', + async({ method }) => { + store.dispatch(requestSearch('asdf')); + store.dispatch(receiveBook({ ...book, id: 'differentid' })); + const component = renderer.create( + + ); + + const event = await method(component); + + expect(dispatch).toHaveBeenCalledWith(push({ + params: { book: { slug: BOOK_SLUG }, page: { slug: PAGE_SLUG } }, + route: content, + state: {}, + })); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it.each` + method | description + ${click} | ${'using onClick method'} + ${keyDown} | ${'using onKeyDown method'} + `('calls onClick when passed %description', async({ method }) => { const clickSpy = jest.fn(); const component = renderer.create( - + ); - const event = await click(component); + const event = await method(component); expect(dispatch).toHaveBeenCalledWith(push({ - params: {book: {slug: BOOK_SLUG}, page: {slug: PAGE_SLUG}}, + params: { book: { slug: BOOK_SLUG }, page: { slug: PAGE_SLUG } }, route: content, - state: { }, + state: {}, })); expect(event.preventDefault).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled(); @@ -190,10 +237,11 @@ describe('ContentLink', () => { }); }); - describe('with unsaved changes' , () => { + describe('with unsaved changes', () => { // tslint:disable-next-line:variable-name let ConnectedContentLink: React.ElementType; const mockConfirmation = jest.fn() + .mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve(false), 300))) .mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve(false), 300))) .mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve(true), 300))); @@ -206,21 +254,26 @@ describe('ContentLink', () => { ConnectedContentLink = require('./ContentLink').default; }); - it('does not call onClick or dispatch if user decides not to discard changes' , async() => { - const clickSpy = jest.fn(); - store.dispatch(setAnnotationChangesPending(true)); - const component = renderer.create( - - ); - - const event = await click(component); - - expect(dispatch).not.toHaveBeenCalledWith(push(expect.anything())); - expect(event.preventDefault).toHaveBeenCalled(); - expect(clickSpy).not.toHaveBeenCalled(); - }); - - it('calls onClick and dispatch if user decides to discard changes' , async() => { + it.each` + method | description + ${click} | ${'using onClick method'} + ${keyDown} | ${'using onKeyDown method'} + `('does not call onClick or dispatch if user decides not to discard changes %description', + async({ method }) => { + const clickSpy = jest.fn(); + store.dispatch(setAnnotationChangesPending(true)); + const component = renderer.create( + + ); + + const event = await method(component); + + expect(dispatch).not.toHaveBeenCalledWith(push(expect.anything())); + expect(event.preventDefault).toHaveBeenCalled(); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('calls onClick and dispatch if user decides to discard changes', async() => { const clickSpy = jest.fn(); store.dispatch(setAnnotationChangesPending(true)); const component = renderer.create( diff --git a/src/app/content/components/ContentLink.tsx b/src/app/content/components/ContentLink.tsx index 4f1d60baff..942acee02b 100644 --- a/src/app/content/components/ContentLink.tsx +++ b/src/app/content/components/ContentLink.tsx @@ -28,6 +28,7 @@ interface Props { }; currentBook: Book | undefined; onClick?: () => void; // this one gets called before navigation + onKeyDown?: (event: React.KeyboardEvent, onSelect: () => void) => void; handleClick?: () => void; // this one gets called instead of navigation navigate: typeof push; currentPath: string; @@ -52,6 +53,7 @@ export const ContentLink = (props: React.PropsWithChildren) => { scrollTarget, navigate, onClick, + onKeyDown, handleClick, children, myForwardedRef, @@ -60,34 +62,37 @@ export const ContentLink = (props: React.PropsWithChildren) => { ...anchorProps } = props; - const {url, params} = getBookPageUrlAndParams(book, page); + const { url, params } = getBookPageUrlAndParams(book, page); const navigationMatch = createNavigationMatch(page, book, params); const relativeUrl = toRelativeUrl(currentPath, url); const bookUid = stripIdVersion(book.id); const options = currentBook && currentBook.id === bookUid - ? createNavigationOptions({...systemQueryParams}, + ? createNavigationOptions({ ...systemQueryParams }, scrollTarget) : undefined; const URL = options ? relativeUrl + navigationOptionsToString(options) : relativeUrl; const services = useServices(); + const onClickHandler = async() => { + if (hasUnsavedHighlight && !await showConfirmation(services)) { + return; + } + + if (onClick) { + onClick(); + } + }; + return { - if (isClickWithModifierKeys(e) || anchorProps.target === '_blank') { return; } e.preventDefault(); - if (hasUnsavedHighlight && !await showConfirmation(services)) { - return; - } - - if (onClick) { - onClick(); - } + await onClickHandler(); if (handleClick) { handleClick(); @@ -95,6 +100,10 @@ export const ContentLink = (props: React.PropsWithChildren) => { navigate(navigationMatch, options); } }} + onKeyDown={(e) => onKeyDown?.(e, async() => { + await onClickHandler(); + navigate(navigationMatch, options); + })} href={URL} {...anchorProps} >{children}; @@ -102,7 +111,7 @@ export const ContentLink = (props: React.PropsWithChildren) => { // tslint:disable-next-line:variable-name export const ConnectedContentLink = connect( - (state: AppState, ownProps: {queryParams?: OutputParams}) => ({ + (state: AppState, ownProps: { queryParams?: OutputParams }) => ({ currentBook: select.book(state), currentPath: selectNavigation.pathname(state), hasUnsavedHighlight: hasUnsavedHighlightSelector(state), diff --git a/src/app/content/components/TableOfContents/index.spec.tsx b/src/app/content/components/TableOfContents/index.spec.tsx index 3578e58e1d..2edbec7ff0 100644 --- a/src/app/content/components/TableOfContents/index.spec.tsx +++ b/src/app/content/components/TableOfContents/index.spec.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import ReactTestUtils from 'react-dom/test-utils'; + import { unmountComponentAtNode } from 'react-dom'; import { act as reactDomAct } from 'react-dom/test-utils'; import renderer from 'react-test-renderer'; @@ -42,7 +44,7 @@ describe('TableOfContents', () => { }); it('mounts and unmmounts with a dom', () => { - const {root} = renderToDom(Component); + const { root } = renderToDom(Component); expect(() => unmountComponentAtNode(root)).not.toThrow(); }); @@ -56,7 +58,7 @@ describe('TableOfContents', () => { expect(scrollSidebarSectionIntoView).toHaveBeenCalledTimes(1); renderer.act(() => { - store.dispatch(actions.receivePage({...shortPage, references: []})); + store.dispatch(actions.receivePage({ ...shortPage, references: [] })); }); expect(expandCurrentChapter).toHaveBeenCalled(); @@ -87,11 +89,11 @@ describe('TableOfContents', () => { jest.spyOn(reactUtils, 'useMatchMobileMediumQuery') .mockReturnValue(true); - const {root} = renderToDom( + const { root } = renderToDom( ); const sb = root.querySelector('[data-testid="toc"]')!; - const firstTocItem = sb.querySelector('ol > li a, old > li summary') as HTMLElement; + const firstTocItem = sb.querySelector('ol > li a') as HTMLElement; const focusSpy = jest.spyOn(firstTocItem as any, 'focus'); reactDomAct(() => { @@ -119,18 +121,135 @@ describe('TableOfContents', () => { const component = renderer.create(Component); renderer.act(() => { - component.root.findAllByType('a')[0].props.onClick({preventDefault: () => null}); + component.root.findAllByType('a')[0].props.onClick({ preventDefault: () => null }); + component.root.findAllByType('a')[1].props.onClick({ preventDefault: () => null }); }); expect(dispatchSpy).toHaveBeenCalledWith(actions.resetToc()); }); + it.each` + anchorNumber | description | isDispatchCalled + ${2} | ${'(TocSectionToggle)'} | ${false} + ${18} | ${'(ContentLink)'} | ${true} + `('toggles open state on Enter and Space key press $description', ({ anchorNumber, isDispatchCalled }) => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + const { root } = renderToDom(Component); + + const anchor = root.querySelectorAll('a[role="treeitem"]')[anchorNumber] as HTMLAnchorElement; + + // Trigger on Enter + ReactTestUtils.Simulate.keyDown(anchor, { key: 'Enter' }); + + // Trigger on Enter + ReactTestUtils.Simulate.keyDown(anchor, { key: ' ' }); + + /* + Trigger search and focus + Test with different values for coverage + */ + ReactTestUtils.Simulate.keyDown(anchor, { key: 'T' }); + ReactTestUtils.Simulate.keyDown(anchor, { key: 'p' }); + ReactTestUtils.Simulate.keyDown(anchor, { key: 'a' }); + + if (isDispatchCalled) { + expect(dispatchSpy).toHaveBeenCalled(); + } else { + expect(dispatchSpy).not.toHaveBeenCalled(); + } + + }); + + it.each` + anchorNumber | description + ${0} | ${'(ContentLink)'} + ${2} | ${'(TocSectionToggle)'} + `('open and closing using Arrow keys %description', ({ anchorNumber }) => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + const { root } = renderToDom(Component); + + const anchor = root.querySelectorAll('a[role="treeitem"]')[anchorNumber] as HTMLAnchorElement; + + // For ContentLink does nothing + ReactTestUtils.Simulate.keyDown(anchor, { key: 'ArrowRight' }); + + ReactTestUtils.Simulate.keyDown(anchor, { key: 'ArrowLeft' }); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it.each` + anchorNumber | description + ${0} | ${'(ContentLink)'} + ${2} | ${'(TocSectionToggle)'} + `('move using Arrow keys %description', ({ anchorNumber }) => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + const { root } = renderToDom(Component); + + const anchor = root.querySelectorAll('a[role="treeitem"]')[anchorNumber] as HTMLAnchorElement; + + // Move using left when group is closed + ReactTestUtils.Simulate.keyDown(anchor, { key: 'ArrowLeft' }); + + // Open group and then move using right (ContentLink does nothing) + ReactTestUtils.Simulate.keyDown(anchor, { key: 'ArrowRight' }); + ReactTestUtils.Simulate.keyDown(anchor, { key: 'ArrowRight' }); + + // Move using up and down + ReactTestUtils.Simulate.keyDown(anchor, { key: 'ArrowDown' }); + ReactTestUtils.Simulate.keyDown(anchor, { key: 'ArrowUp' }); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it.each` + anchorNumber | description + ${0} | ${'(ContentLink)'} + ${2} | ${'(TocSectionToggle)'} + `('move focus to start and end of treeitems %description', ({ anchorNumber }) => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + const { root } = renderToDom(Component); + + const anchor = root.querySelectorAll('a[role="treeitem"]')[anchorNumber] as HTMLAnchorElement; + + // Move focus to first treeitem + ReactTestUtils.Simulate.keyDown(anchor, { key: 'Home' }); + + // Move focus to last treeitem + ReactTestUtils.Simulate.keyDown(anchor, { key: 'End' }); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it.each` + anchorNumber | description | shiftKey + ${0} | ${'(ContentLink)'} | ${true} + ${0} | ${'(ContentLink)'} | ${false} + ${2} | ${'(TocSectionToggle)'} | ${true} + ${2} | ${'(TocSectionToggle)'} | ${false} + ${18} | ${'(TocSectionToggle)'} | ${false} + `('trigger tab navigation %description', ({ anchorNumber, shiftKey }) => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + const { root } = renderToDom(Component); + const anchor = root.querySelectorAll('a[role="treeitem"]')[anchorNumber] as HTMLAnchorElement; + + // Move focus to the first treeitem if shiftKey is false and to the last treeitem if is true + ReactTestUtils.Simulate.keyDown(anchor, { key: 'Tab', shiftKey }); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + it('resizes on scroll', () => { if (!document || !window) { expect(window).toBeTruthy(); return expect(document).toBeTruthy(); } - const {node} = renderToDom(Component); + const { node } = renderToDom(Component); const spy = jest.spyOn(node.style, 'setProperty'); const event = document.createEvent('UIEvents'); diff --git a/src/app/content/components/TableOfContents/index.tsx b/src/app/content/components/TableOfContents/index.tsx index 14b8626ee2..6f702263af 100644 --- a/src/app/content/components/TableOfContents/index.tsx +++ b/src/app/content/components/TableOfContents/index.tsx @@ -7,7 +7,7 @@ import { closeMobileMenu, resetToc } from '../../actions'; import { isArchiveTree } from '../../guards'; import { linkContents } from '../../search/utils'; import * as selectors from '../../selectors'; -import { ArchiveTree, Book, LinkedArchiveTreeSection, Page, State } from '../../types'; +import { ArchiveTree, Book, LinkedArchiveTree, LinkedArchiveTreeSection, Page, State } from '../../types'; import { archiveTreeContainsNode, getArchiveTreeSectionType, splitTitleParts } from '../../utils/archiveTreeUtils'; import { expandCurrentChapter, scrollSidebarSectionIntoView, setSidebarHeight } from '../../utils/domUtils'; import { stripIdVersion } from '../../utils/idUtils'; @@ -16,6 +16,7 @@ import { Header, HeaderText, SidebarPaneBody } from '../SidebarPane'; import { LeftArrow, TimesIcon } from '../Toolbar/styled'; import * as Styled from './styled'; import { createTrapTab, useMatchMobileQuery, useMatchMobileMediumQuery } from '../../../reactUtils'; +import { treeNavItemOnKeyDown, treeNavSubtreeOnKeyDown, KeyboardSupportProps } from './keyboardSupport'; interface SidebarProps { onNavigate: () => void; @@ -69,7 +70,7 @@ const SidebarBody = React.forwardRef< React.useEffect( () => { const firstItemInToc = mRef?.current?.querySelector( - 'ol > li a, old > li summary' + 'ol > li a' ) as HTMLElement; const el = mRef.current; const transitionListener = () => { @@ -117,22 +118,39 @@ function TocHeader() { ); } -function TocNode({ - defaultOpen, +function TocSectionToggle({ + id, + isOpen, title, - children, -}: React.PropsWithChildren<{ defaultOpen: boolean; title: string }>) { + treeId, + onClick, + onKeyDown, + visible, +}: { + id: string; + title: string, + isOpen: boolean, + treeId: string | undefined; + onClick?: (event: React.MouseEvent) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + visible: boolean; +}) { + return ( - - - - - - - - - {children} - + + + + + ); } @@ -150,51 +168,149 @@ function maybeAriaLabel(page: LinkedArchiveTreeSection) { return {}; } - return {'aria-label': `${titleText} - Chapter ${parentNum}`}; + return { 'aria-label': `${titleText} - Chapter ${parentNum}` }; +} + +function ArchiveTreeComponent({ + item, + book, + page, + activeSection, + treeId, + onNavigate, + onKeyDown, + visible, +}: { + item: LinkedArchiveTree; + book: Book | undefined; + page: Page | undefined; + activeSection: React.RefObject; + treeId: string | undefined; + onNavigate: () => void; + onKeyDown: (props: KeyboardSupportProps) => void; + visible: boolean; +}) { + const sectionType = getArchiveTreeSectionType(item); + + const [isOpen, setOpen] = React.useState(shouldBeOpen(page, item)); + + const toggleOpen = () => { + setOpen((prevState) => !prevState); + }; + + const onKeyDownSupport = (e: React.KeyboardEvent) => { + onKeyDown({ + event: e, + item, + isOpen, + treeId, + onSelect: toggleOpen, + }); + }; + + return ( + + + + + ); } function TocSection({ + id, book, page, section, activeSection, onNavigate, + open, + visible, + treeId, }: { + id?: string; book: Book | undefined; page: Page | undefined; section: ArchiveTree; activeSection: React.RefObject; onNavigate: () => void; + open: boolean; + visible: boolean; + treeId: string | undefined; }) { + + const linkedContents = linkContents(section); + return ( - - {linkContents(section).map((item) => { + + {linkedContents.map((item) => { const sectionType = getArchiveTreeSectionType(item); const active = page && stripIdVersion(item.id) === page.id; - return isArchiveTree(item) - ? - - - - - : - - ; + ) : ( + + , onSelect: () => void) => + treeNavItemOnKeyDown({ + event: e, + item, + treeId, + onSelect, + }) + } + book={book} + page={item} + dangerouslySetInnerHTML={{ __html: item.title }} + {...maybeAriaLabel(item)} + role='treeitem' + data-treeid={treeId} + data-visible={visible} + /> + + ); })} ); @@ -221,6 +337,9 @@ export class TableOfContents extends Component { section={book.tree} activeSection={this.activeSection} onNavigate={this.props.onNavigate} + open + visible + treeId={book.title} /> )} @@ -231,11 +350,11 @@ export class TableOfContents extends Component { this.scrollToSelectedPage(); const sidebar = this.sidebar.current; - if (!sidebar || typeof(window) === 'undefined') { + if (!sidebar || typeof (window) === 'undefined') { return; } - const {callback, deregister} = setSidebarHeight(sidebar, window); + const { callback, deregister } = setSidebarHeight(sidebar, window); callback(); this.deregister = deregister; } diff --git a/src/app/content/components/TableOfContents/keyboardSupport.tsx b/src/app/content/components/TableOfContents/keyboardSupport.tsx new file mode 100644 index 0000000000..61e90ad48e --- /dev/null +++ b/src/app/content/components/TableOfContents/keyboardSupport.tsx @@ -0,0 +1,179 @@ +import { HTMLElement } from '@openstax/types/lib.dom'; +import { LinkedArchiveTreeNode } from '../../types'; +import React from 'react'; +import { assertDocument } from '../../../utils'; +import { focusableItemQuery } from '../../../reactUtils'; + +export interface KeyboardSupportProps { + event: React.KeyboardEvent; + item: LinkedArchiveTreeNode; + isOpen?: boolean; + treeId?: string; + onSelect: () => void; +} + +const getTreeItems = (treeId: KeyboardSupportProps['treeId']) => { + return Array.from( + assertDocument() + .querySelectorAll(`a[role="treeitem"][data-visible="true"][data-treeid="${treeId}"]`) + ); +}; + +const focusNextTreeItem = (filteredTreeItems: HTMLElement[], currentItemIndex: number) => { + assertDocument().querySelector(`[id="${filteredTreeItems[currentItemIndex + 1]?.id}"]`)?.focus(); +}; + +const focusPrevTreeItem = (filteredTreeItems: HTMLElement[], currentItemIndex: number) => { + assertDocument().querySelector(`[id="${filteredTreeItems[currentItemIndex - 1]?.id}"]`)?.focus(); +}; + +const focusParent = (filteredTreeItems: HTMLElement[], currentItemIndex: number) => { + (assertDocument().querySelector( + `[id="${filteredTreeItems[currentItemIndex]?.id}"]`) + ?.parentElement?.parentElement + ?.previousElementSibling as HTMLElement) + ?.focus(); +}; + +const moveToFirstTreeItem = (filteredTreeItems: HTMLElement[]) => + assertDocument().querySelector(`[id="${filteredTreeItems[0]?.id}"]`); + +const moveToLastTreeItem = (filteredTreeItems: HTMLElement[]) => + assertDocument().querySelector(`[id="${filteredTreeItems[filteredTreeItems.length - 1]?.id}"]`); + +/** + * Handles focus out from Tree component. Tree navigates using arrow keys and tab is disabled inside it. + * When tab is pressed, this method looks to move focus to previous or next interactive element. + * @param filteredTreeItems + * @param shiftKey + */ +const focusOutsideOnTab = (filteredTreeItems: HTMLElement[], shiftKey: boolean) => { + const focusableElements = Array.from(assertDocument().querySelectorAll(focusableItemQuery)); + let index: number; + // Looks for the first tree interactive element when pressing tab + shift + if (shiftKey) { + index = focusableElements.indexOf(moveToFirstTreeItem(filteredTreeItems) as HTMLElement) - 1; + // Last interactive element if shift is not pressed + } else { + index = focusableElements.indexOf(moveToLastTreeItem(filteredTreeItems) as HTMLElement) + 1; + } + /** + * Then focus the prev or next interactive element + * outside Tree component depending if shiftKey is pressed or not. + */ + focusableElements[index]?.focus(); +}; + +const searchTreeItem = (filteredTreeItems: HTMLElement[], currentItemIndex: number, key: string) => { + /* + According Keyboard Support for Navigation Tree, the search starts with treeItems + next to current treeItem and if there is no result, it will look for previous treeItems + */ + const searchOptions = filteredTreeItems.slice(currentItemIndex + 1, filteredTreeItems.length) + .concat(filteredTreeItems.slice(0, currentItemIndex)); + const result = searchOptions.find((treeItem) => + treeItem.textContent?.trim().toLowerCase().startsWith(key.toLowerCase()) + ); + if (result) assertDocument().querySelector(`[id="${result?.id}"]`)?.focus(); +}; + +function getCurrentItemIndex(item: KeyboardSupportProps['item'], treeId: KeyboardSupportProps['treeId']) { + const filteredTreeItems = getTreeItems(treeId); + const currentItemIndex = filteredTreeItems.findIndex((treeitem) => + treeitem.id === item.id + ); + + return { filteredTreeItems, currentItemIndex }; +} + +export const treeNavSubtreeOnKeyDown = ({ + event, + item, + isOpen, + treeId, + onSelect, +}: KeyboardSupportProps) => { + event.preventDefault(); + + const { filteredTreeItems, currentItemIndex } = getCurrentItemIndex(item, treeId); + + switch (event.key) { + case 'Enter': + case ' ': + onSelect(); + break; + case 'ArrowDown': + focusNextTreeItem(filteredTreeItems, currentItemIndex); + break; + case 'ArrowUp': + focusPrevTreeItem(filteredTreeItems, currentItemIndex); + break; + case 'ArrowRight': + if (isOpen) { + focusNextTreeItem(filteredTreeItems, currentItemIndex); + } else { + onSelect(); + } + break; + case 'ArrowLeft': + if (isOpen) { + onSelect(); + } else { + focusParent(filteredTreeItems, currentItemIndex); + } + break; + case 'Home': + moveToFirstTreeItem(filteredTreeItems)?.focus(); + break; + case 'End': + moveToLastTreeItem(filteredTreeItems)?.focus(); + break; + case 'Tab': + focusOutsideOnTab(filteredTreeItems, event.shiftKey); + break; + default: + searchTreeItem(filteredTreeItems, currentItemIndex, event.key); + break; + } +}; + +export const treeNavItemOnKeyDown = ({ + event, + item, + treeId, + onSelect, +}: KeyboardSupportProps) => { + event.preventDefault(); + + const { filteredTreeItems, currentItemIndex } = getCurrentItemIndex(item, treeId); + + switch (event.key) { + case 'Enter': + case ' ': + onSelect(); + break; + case 'ArrowDown': + focusNextTreeItem(filteredTreeItems, currentItemIndex); + break; + case 'ArrowUp': + focusPrevTreeItem(filteredTreeItems, currentItemIndex); + break; + case 'ArrowLeft': + focusParent(filteredTreeItems, currentItemIndex); + break; + case 'Home': + moveToFirstTreeItem(filteredTreeItems)?.focus(); + break; + case 'End': + moveToLastTreeItem(filteredTreeItems)?.focus(); + break; + case 'Tab': + focusOutsideOnTab(filteredTreeItems, event.shiftKey); + break; + default: + searchTreeItem(filteredTreeItems, currentItemIndex, event.key); + break; + + } +}; + diff --git a/src/app/content/components/TableOfContents/styled/index.tsx b/src/app/content/components/TableOfContents/styled/index.tsx index e7220d0861..9be68010e2 100644 --- a/src/app/content/components/TableOfContents/styled/index.tsx +++ b/src/app/content/components/TableOfContents/styled/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import styled, { css } from 'styled-components/macro'; -import { Details } from '../../../../components/Details'; -import { iconSize, Summary as BaseSummary } from '../../../../components/Details'; +import { CollapseToggle } from '../../../../components/Details'; +import { iconSize } from '../../../../components/Details'; import { labelStyle } from '../../../../components/Typography'; import theme from '../../../../theme'; import { ArchiveTree } from '../../../types'; @@ -66,11 +66,12 @@ interface NavItemComponentProps { } // tslint:disable-next-line:variable-name export const NavItemComponent = React.forwardRef( - ({active, className, children, sectionType}, ref) =>
  • {children}
  • ); @@ -82,28 +83,19 @@ export const NavItem = styled(NavItemComponent)` ${theme.breakpoints.mobile(css` margin-top: 1.7rem; `)} -`; - -// tslint:disable-next-line:variable-name -export const Summary = styled(BaseSummary)` - :focus { - outline: none; - } - ${/* suppress errors from https://github.com/stylelint/stylelint/issues/3391 */ css` - :hover ${SummaryTitle}, - :focus ${SummaryTitle} { + a[role="treeitem"] { + :focus, + :hover { + outline: none; ${activeState} } - `} + } `; // tslint:disable-next-line:variable-name -export const SummaryWrapper = styled.div` - display: flex; -`; -const getNumberWidth = (contents: ArchiveTree['contents']) => contents.reduce((result, {title}) => { +const getNumberWidth = (contents: ArchiveTree['contents']) => contents.reduce((result, { title }) => { const num = splitTitleParts(title)[0]; if (!num) { @@ -121,15 +113,14 @@ const getNumberWidth = (contents: ArchiveTree['contents']) => contents.reduce((r }, 0); // tslint:disable-next-line:variable-name -export const NavOl = styled.ol<{section: ArchiveTree}>` +export const NavOl = styled.ol<{ section: ArchiveTree }>` margin: 0; padding: 0; ${(props) => { const numberWidth = getNumberWidth(props.section.contents); return css` - & > ${NavItem} > details > summary, - & > ${NavItem} > ${ContentLink} { + & > ${NavItem} > a { .os-number { width: ${numberWidth}rem; overflow: hidden; @@ -147,28 +138,65 @@ export const NavOl = styled.ol<{section: ArchiveTree}>` } } - & > ${NavItem} > details > ol { + & > ${NavItem} > ol { margin-left: ${numberWidth + dividerWidth}rem; } + + ${/* suppress errors from https://github.com/stylelint/stylelint/issues/3391 */ css` + &:not([open]) { + display: none; + + a[role='treeitem'] { + display: none; + } + + } + `} `; }} `; -interface DetailsComponentProps {defaultOpen: boolean; open: boolean; } -class DetailsComponent extends React.Component { - constructor(props: DetailsComponentProps) { - super(props); - this.state = {defaultOpen: props.defaultOpen}; - } +interface CollapseComponentProps { open: boolean; visible: boolean; treeId: string | undefined; } +class CollapseComponent extends React.Component { public render() { - const {open, defaultOpen: _, ...props} = this.props; - const {defaultOpen} = this.state; - - return
    ; + const { open, treeId, visible, ...props } = this.props; + + return ( + ); } } // tslint:disable-next-line:variable-name -export const NavDetails = styled(DetailsComponent)` +export const NavCollapse = styled(CollapseComponent)` + ${labelStyle} + display: flex; overflow: visible; + list-style: none; + cursor: pointer; + text-decoration: none; + + /* stylelint-disable no-descending-specificity */ + :focus, + :hover { + outline: none; + ${activeState} + } + + ::before { + display: none; + } + + ${/* suppress errors from https://github.com/stylelint/stylelint/issues/3391 */ css` + :hover ${SummaryTitle}, + :focus ${SummaryTitle} { + ${activeState} + } + `} `; diff --git a/src/app/content/components/__snapshots__/Content.spec.tsx.snap b/src/app/content/components/__snapshots__/Content.spec.tsx.snap index 063ef541a9..6eac7c1b9a 100644 --- a/src/app/content/components/__snapshots__/Content.spec.tsx.snap +++ b/src/app/content/components/__snapshots__/Content.spec.tsx.snap @@ -215,19 +215,19 @@ Array [ overflow: hidden; } -.c111 { +.c108 { height: 1em; } -.c113 { +.c110 { height: 1em; } -.c112 { +.c109 { height: 1em; } -.c94 { +.c91 { color: #424242; font-size: 1.6rem; line-height: 2.5rem; @@ -237,16 +237,16 @@ Array [ transition: opacity 0.2s; } -.c95 { +.c92 { color: #d5d5d5; display: grid; } -.c96 { +.c93 { background-color: #424242; } -.c97 { +.c94 { max-width: 131rem; -webkit-align-items: center; -webkit-box-align: center; @@ -268,45 +268,45 @@ Array [ overflow: visible; } -.c98 { +.c95 { grid-area: headline; margin: 0; } -.c99 { +.c96 { grid-area: mission; } -.c99 a { +.c96 a { color: #d5d5d5; font-weight: bold; text-underline-position: under; } -.c99 a:hover, -.c99 a:active, -.c99 a:focus { +.c96 a:hover, +.c96 a:active, +.c96 a:focus { color: inherit; } -.c103 { +.c100 { color: #d5d5d5; -webkit-text-decoration: none; text-decoration: none; } -.c103:hover, -.c103:active, -.c103:focus { +.c100:hover, +.c100:active, +.c100:focus { color: inherit; } -.c103:hover { +.c100:hover { -webkit-text-decoration: underline; text-decoration: underline; } -.c114 { +.c111 { color: #d5d5d5; display: inline-grid; grid-auto-flow: column; @@ -314,34 +314,34 @@ Array [ overflow: hidden; } -.c114:hover, -.c114:active, -.c114:focus { +.c111:hover, +.c111:active, +.c111:focus { color: inherit; } -.c100 { +.c97 { display: grid; grid-gap: 0.5rem; overflow: visible; grid-area: col1; } -.c104 { +.c101 { display: grid; grid-gap: 0.5rem; overflow: visible; grid-area: col2; } -.c105 { +.c102 { display: grid; grid-gap: 0.5rem; overflow: visible; grid-area: col3; } -.c101 { +.c98 { font-size: 1.8rem; font-weight: bold; -webkit-letter-spacing: -0.072rem; @@ -352,7 +352,7 @@ Array [ margin: 0; } -.c106 { +.c103 { font-size: 1.2rem; font-weight: normal; -webkit-letter-spacing: normal; @@ -363,7 +363,7 @@ Array [ background-color: #3b3b3b; } -.c107 { +.c104 { max-width: 131rem; -webkit-align-items: center; -webkit-box-align: center; @@ -385,28 +385,28 @@ Array [ overflow: visible; } -.c108 { +.c105 { display: grid; grid-gap: 1rem; overflow: visible; } -.c108 [data-html="copyright"] { +.c105 [data-html="copyright"] { overflow: visible; } -.c108 a { +.c105 a { color: #d5d5d5; overflow: visible; } -.c108 a:hover, -.c108 a:active, -.c108 a:focus { +.c105 a:hover, +.c105 a:active, +.c105 a:focus { color: inherit; } -.c108 sup { +.c105 sup { font-size: 66%; margin-left: 0.1rem; position: relative; @@ -414,7 +414,7 @@ Array [ vertical-align: top; } -.c109 { +.c106 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -430,7 +430,7 @@ Array [ overflow: visible; } -.c102 { +.c99 { margin: 0; padding: 0; display: -webkit-box; @@ -444,7 +444,7 @@ Array [ list-style: none; } -.c110 { +.c107 { color: #d5d5d5; font-size: 1.6rem; line-height: 2.5rem; @@ -464,13 +464,13 @@ Array [ width: 3rem; } -.c110:hover, -.c110:active, -.c110:focus { +.c107:hover, +.c107:active, +.c107:focus { color: inherit; } -.c115 { +.c112 { height: 4rem; -webkit-transform: translateY(0.2rem); -ms-transform: translateY(0.2rem); @@ -662,79 +662,79 @@ Array [ flex-direction: column; } -.c68 { +.c66 { height: 1.7rem; width: 1.7rem; } -.c70 { +.c64 { height: 1.7rem; width: 1.7rem; } -.c88 { +.c85 { list-style: none; cursor: pointer; } -.c88::before { +.c85::before { display: none; } -.c88::-moz-list-bullet { +.c85::-moz-list-bullet { list-style-type: none; } -.c88::-webkit-details-marker { +.c85::-webkit-details-marker { display: none; } -.c62[open] > summary .c67 { +.c61[open] > .c65 { display: none; } -.c62:not([open]) > summary .c69 { +.c61:not([open]) > .c63 { display: none; } -.c81 { +.c77 { outline: none; --content-text-scale: 1; } -.c81 .os-problem-container .token, -.c81 .os-solution-container .token { +.c77 .os-problem-container .token, +.c77 .os-solution-container .token { font-size-adjust: cap-height 1; vertical-align: middle; } -.c80 { +.c76 { overflow: visible; } -.c80:focus-visible { +.c76:focus-visible { outline: none; } -.c80 .highlight { +.c76 .highlight { position: relative; z-index: 1; } -.c80 .MathJax_Display .highlight, -.c80 .MathJax_Preview + .highlight { +.c76 .MathJax_Display .highlight, +.c76 .MathJax_Preview + .highlight { display: inline-block; } -.c80 .highlight.yellow { +.c76 .highlight.yellow { background-color: #ffff8a; } -.c80 .highlight.yellow.block { +.c76 .highlight.yellow.block { display: block; } -.c80 .highlight.yellow.block:after { +.c76 .highlight.yellow.block:after { position: absolute; z-index: -1; content: ""; @@ -746,7 +746,7 @@ Array [ background-color: #ffff8a; } -.c80 .highlight.yellow.block.first.has-note:before { +.c76 .highlight.yellow.block.first.has-note:before { position: absolute; top: -1rem; left: -1rem; @@ -758,7 +758,7 @@ Array [ border-bottom: 1.2em solid transparent; } -.c80 .highlight.yellow.first.text.has-note:after { +.c76 .highlight.yellow.first.text.has-note:after { position: absolute; top: 0; left: 0; @@ -773,15 +773,15 @@ Array [ transform: rotate(90deg); } -.c80 .highlight.green { +.c76 .highlight.green { background-color: #def99f; } -.c80 .highlight.green.block { +.c76 .highlight.green.block { display: block; } -.c80 .highlight.green.block:after { +.c76 .highlight.green.block:after { position: absolute; z-index: -1; content: ""; @@ -793,7 +793,7 @@ Array [ background-color: #def99f; } -.c80 .highlight.green.block.first.has-note:before { +.c76 .highlight.green.block.first.has-note:before { position: absolute; top: -1rem; left: -1rem; @@ -805,7 +805,7 @@ Array [ border-bottom: 1.2em solid transparent; } -.c80 .highlight.green.first.text.has-note:after { +.c76 .highlight.green.first.text.has-note:after { position: absolute; top: 0; left: 0; @@ -820,15 +820,15 @@ Array [ transform: rotate(90deg); } -.c80 .highlight.blue { +.c76 .highlight.blue { background-color: #c8f5ff; } -.c80 .highlight.blue.block { +.c76 .highlight.blue.block { display: block; } -.c80 .highlight.blue.block:after { +.c76 .highlight.blue.block:after { position: absolute; z-index: -1; content: ""; @@ -840,7 +840,7 @@ Array [ background-color: #c8f5ff; } -.c80 .highlight.blue.block.first.has-note:before { +.c76 .highlight.blue.block.first.has-note:before { position: absolute; top: -1rem; left: -1rem; @@ -852,7 +852,7 @@ Array [ border-bottom: 1.2em solid transparent; } -.c80 .highlight.blue.first.text.has-note:after { +.c76 .highlight.blue.first.text.has-note:after { position: absolute; top: 0; left: 0; @@ -867,15 +867,15 @@ Array [ transform: rotate(90deg); } -.c80 .highlight.purple { +.c76 .highlight.purple { background-color: #cbcfff; } -.c80 .highlight.purple.block { +.c76 .highlight.purple.block { display: block; } -.c80 .highlight.purple.block:after { +.c76 .highlight.purple.block:after { position: absolute; z-index: -1; content: ""; @@ -887,7 +887,7 @@ Array [ background-color: #cbcfff; } -.c80 .highlight.purple.block.first.has-note:before { +.c76 .highlight.purple.block.first.has-note:before { position: absolute; top: -1rem; left: -1rem; @@ -899,7 +899,7 @@ Array [ border-bottom: 1.2em solid transparent; } -.c80 .highlight.purple.first.text.has-note:after { +.c76 .highlight.purple.first.text.has-note:after { position: absolute; top: 0; left: 0; @@ -914,15 +914,15 @@ Array [ transform: rotate(90deg); } -.c80 .highlight.pink { +.c76 .highlight.pink { background-color: #ffc5e1; } -.c80 .highlight.pink.block { +.c76 .highlight.pink.block { display: block; } -.c80 .highlight.pink.block:after { +.c76 .highlight.pink.block:after { position: absolute; z-index: -1; content: ""; @@ -934,7 +934,7 @@ Array [ background-color: #ffc5e1; } -.c80 .highlight.pink.block.first.has-note:before { +.c76 .highlight.pink.block.first.has-note:before { position: absolute; top: -1rem; left: -1rem; @@ -946,7 +946,7 @@ Array [ border-bottom: 1.2em solid transparent; } -.c80 .highlight.pink.first.text.has-note:after { +.c76 .highlight.pink.first.text.has-note:after { position: absolute; top: 0; left: 0; @@ -961,12 +961,12 @@ Array [ transform: rotate(90deg); } -.c80 .os-figure, -.c80 .os-figure:last-child { +.c76 .os-figure, +.c76 .os-figure:last-child { margin-bottom: 5px; } -.c80 #main-content * { +.c76 #main-content * { overflow: initial; } @@ -983,21 +983,21 @@ Array [ grid-template-columns: 8rem auto auto; } -.c91 { +.c88 { margin-left: -0.3rem; } -.c92 { +.c89 { margin-left: -0.3rem; } -.c90 { +.c87 { font-weight: 500; list-style: none; } -.c90, -.c90 span { +.c87, +.c87 span { color: #424242; font-size: 1.6rem; line-height: 2.5rem; @@ -1007,33 +1007,33 @@ Array [ text-decoration: none; } -.c90 a, -.c90 span a { +.c87 a, +.c87 span a { color: #027EB5; cursor: pointer; -webkit-text-decoration: underline; text-decoration: underline; } -.c90 a:hover, -.c90 span a:hover { +.c87 a:hover, +.c87 span a:hover { color: #0064A0; } -.c90:hover, -.c90 span:hover, -.c90:focus, -.c90 span:focus { +.c87:hover, +.c87 span:hover, +.c87:focus, +.c87 span:focus { -webkit-text-decoration: underline; text-decoration: underline; color: #0064A0; } -.c93 blockquote { +.c90 blockquote { margin-left: 0; } -.c87 { +.c83 { color: #424242; font-size: 1.6rem; line-height: 2.5rem; @@ -1044,30 +1044,30 @@ Array [ padding-top: 1.8rem; } -.c87[open] > summary .c67 { +.c83[open] > summary .c65 { display: none; } -.c87:not([open]) > summary .c69 { +.c83:not([open]) > summary .c63 { display: none; } -.c87 a { +.c83 a { color: #027EB5; cursor: pointer; -webkit-text-decoration: underline; text-decoration: underline; } -.c87 a:hover { +.c83 a:hover { color: #0064A0; } -.c87 > .c89 { +.c83 > .c86 { margin-bottom: 1.8rem; } -.c87 li { +.c83 li { margin-bottom: 1rem; overflow: visible; } @@ -1234,7 +1234,7 @@ Array [ margin-top: -7rem; } -.c76 { +.c72 { grid-column: 1 / -1; grid-row: 1; justify-self: center; @@ -1248,7 +1248,7 @@ Array [ display: contents; } -.c78 { +.c74 { position: -webkit-sticky; position: sticky; overflow: visible; @@ -1365,50 +1365,50 @@ Array [ display: none; } -.c79:focus-within { +.c75:focus-within { overflow: visible; } -.c84 { +.c80 { height: 2.5rem; width: 2.5rem; } -.c86 { +.c82 { height: 2.5rem; width: 2.5rem; margin-top: 0.1rem; } -.c83 { +.c79 { color: #027EB5; cursor: pointer; -webkit-text-decoration: none; text-decoration: none; } -.c83:hover, -.c83:focus { +.c79:hover, +.c79:focus { -webkit-text-decoration: underline; text-decoration: underline; color: #0064A0; } -.c85 { +.c81 { color: #027EB5; cursor: pointer; -webkit-text-decoration: none; text-decoration: none; } -.c85:hover, -.c85:focus { +.c81:hover, +.c81:focus { -webkit-text-decoration: underline; text-decoration: underline; color: #0064A0; } -.c82 { +.c78 { color: #424242; font-size: 1.6rem; line-height: 2.5rem; @@ -1433,7 +1433,7 @@ Array [ border-bottom: solid 0.1rem #e5e5e5; } -.c82 a { +.c78 a { border: none; display: -webkit-box; display: -webkit-flex; @@ -1441,7 +1441,7 @@ Array [ display: flex; } -.c75 { +.c71 { position: absolute; width: 1px; height: 1px; @@ -1830,7 +1830,7 @@ Array [ text-align: left; } -.c72 { +.c68 { color: #424242; font-size: 1.4rem; line-height: 1.6rem; @@ -1846,7 +1846,7 @@ Array [ flex: 1; } -.c61 { +.c60 { color: #424242; font-size: 1.4rem; line-height: 1.6rem; @@ -1862,12 +1862,12 @@ Array [ text-decoration: none; } -li[aria-label="Current Page"] .c61 { +li[aria-label="Current Page"] .c60 { font-weight: 600; } -.c61:focus, -.c61:hover { +.c60:focus, +.c60:hover { outline: none; color: #000; -webkit-text-decoration: underline; @@ -1880,133 +1880,152 @@ li[aria-label="Current Page"] .c61 { margin: 1.2rem 0 0 0; } -.c65 { - list-style: none; - cursor: pointer; -} - -.c65::before { - display: none; -} - -.c65::-moz-list-bullet { - list-style-type: none; -} - -.c65::-webkit-details-marker { - display: none; -} - -.c65:focus { +.c59 a[role="treeitem"]:focus, +.c59 a[role="treeitem"]:hover { outline: none; -} - -.c65:hover .c71, -.c65:focus .c71 { color: #000; -webkit-text-decoration: underline; text-decoration: underline; } -.c66 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; -} - .c57 { margin: 0; padding: 0; } -.c57 > .c58 > details > summary .os-number, -.c57 > .c58 > .c60 .os-number { +.c57 > .c58 > a .os-number { width: 1.78125rem; overflow: hidden; } -.c57 > .c58 > details > summary .os-divider, -.c57 > .c58 > .c60 .os-divider { +.c57 > .c58 > a .os-divider { width: 0.8rem; text-align: center; overflow: hidden; } -.c57 > .c58 > details > summary .os-text, -.c57 > .c58 > .c60 .os-text { +.c57 > .c58 > a .os-text { -webkit-flex: 1; -ms-flex: 1; flex: 1; overflow: hidden; } -.c57 > .c58 > details > ol { +.c57 > .c58 > ol { margin-left: 2.58125rem; } -.c73 { +.c57:not([open]) { + display: none; +} + +.c57:not([open]) a[role='treeitem'] { + display: none; +} + +.c69 { margin: 0; padding: 0; } -.c73 > .c58 > details > summary .os-number, -.c73 > .c58 > .c60 .os-number { +.c69 > .c58 > a .os-number { width: 4.0078125rem; overflow: hidden; } -.c73 > .c58 > details > summary .os-divider, -.c73 > .c58 > .c60 .os-divider { +.c69 > .c58 > a .os-divider { width: 0.8rem; text-align: center; overflow: hidden; } -.c73 > .c58 > details > summary .os-text, -.c73 > .c58 > .c60 .os-text { +.c69 > .c58 > a .os-text { -webkit-flex: 1; -ms-flex: 1; flex: 1; overflow: hidden; } -.c73 > .c58 > details > ol { +.c69 > .c58 > ol { margin-left: 4.8078125rem; } -.c74 { +.c69:not([open]) { + display: none; +} + +.c69:not([open]) a[role='treeitem'] { + display: none; +} + +.c70 { margin: 0; padding: 0; } -.c74 > .c58 > details > summary .os-number, -.c74 > .c58 > .c60 .os-number { +.c70 > .c58 > a .os-number { width: 0rem; overflow: hidden; } -.c74 > .c58 > details > summary .os-divider, -.c74 > .c58 > .c60 .os-divider { +.c70 > .c58 > a .os-divider { width: 0.8rem; text-align: center; overflow: hidden; } -.c74 > .c58 > details > summary .os-text, -.c74 > .c58 > .c60 .os-text { +.c70 > .c58 > a .os-text { -webkit-flex: 1; -ms-flex: 1; flex: 1; overflow: hidden; } -.c74 > .c58 > details > ol { +.c70 > .c58 > ol { margin-left: 0.8rem; } -.c63 { +.c70:not([open]) { + display: none; +} + +.c70:not([open]) a[role='treeitem'] { + display: none; +} + +.c62 { + color: #424242; + font-size: 1.4rem; + line-height: 1.6rem; + font-weight: normal; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; overflow: visible; + list-style: none; + cursor: pointer; + -webkit-text-decoration: none; + text-decoration: none; +} + +.c62:focus, +.c62:hover { + outline: none; + color: #000; + -webkit-text-decoration: underline; + text-decoration: underline; +} + +.c62::before { + display: none; +} + +.c62:hover .c67, +.c62:focus .c67 { + color: #000; + -webkit-text-decoration: underline; + text-decoration: underline; } .c44 { @@ -2061,31 +2080,31 @@ li[aria-label="Current Page"] .c61 { } @media print { - .c94 { + .c91 { display: none; } } @media (min-width:60.1em) { - .c96 { + .c93 { padding: 7rem 0; } } @media (max-width:37.5em) { - .c96 { + .c93 { padding: 2rem 0; } } @media (max-width:60.1em) and (min-width:37.6em) { - .c96 { + .c93 { padding: 4rem 0; } } @media (min-width:37.6em) { - .c97 { + .c94 { -webkit-align-items: start; -webkit-box-align: start; -ms-flex-align: start; @@ -2096,19 +2115,19 @@ li[aria-label="Current Page"] .c61 { } @media (max-width:37.5em) { - .c97 { + .c94 { grid-template: "headline" "mission" "col1" "col2" "col3"; } } @media (min-width:60.1em) { - .c97 { + .c94 { grid-column-gap: 8rem; } } @media (min-width:37.6em) { - .c98 { + .c95 { font-size: 2.4rem; font-weight: bold; -webkit-letter-spacing: -0.096rem; @@ -2120,7 +2139,7 @@ li[aria-label="Current Page"] .c61 { } @media (max-width:37.5em) { - .c98 { + .c95 { font-size: 2rem; font-weight: bold; -webkit-letter-spacing: -0.08rem; @@ -2132,7 +2151,7 @@ li[aria-label="Current Page"] .c61 { } @media (min-width:37.6em) { - .c99 { + .c96 { font-size: 1.8rem; font-weight: normal; -webkit-letter-spacing: normal; @@ -2144,37 +2163,37 @@ li[aria-label="Current Page"] .c61 { } @media (max-width:37.5em) { - .c103 { + .c100 { line-height: 4.5rem; } } @media (max-width:37.5em) { - .c101 { + .c98 { line-height: 4.5rem; } } @media (min-width:37.6em) { - .c106 { + .c103 { padding: 2.5rem 0; } } @media (max-width:37.5em) { - .c106 { + .c103 { padding: 1.5rem; } } @media (min-width:37.6em) { - .c107 { + .c104 { grid-auto-flow: column; } } @media (max-width:37.5em) { - .c107 { + .c104 { padding: 0; } } @@ -2253,14 +2272,14 @@ li[aria-label="Current Page"] .c61 { } @media screen { - .c80 { + .c76 { max-width: 82.5rem; margin: 0 auto; } } @media screen { - .c80 { + .c76 { -webkit-flex: 1; -ms-flex: 1; flex: 1; @@ -2271,121 +2290,121 @@ li[aria-label="Current Page"] .c61 { width: 100%; } - .c80 #main-content { + .c76 #main-content { overflow: visible; width: 100%; } - .c80 #main-content > [data-type="page"], - .c80 #main-content > [data-type="composite-page"] { + .c76 #main-content > [data-type="page"], + .c76 #main-content > [data-type="composite-page"] { margin-top: 3.2rem; } } @media screen { - .c80 .highlight.yellow[aria-current] { + .c76 .highlight.yellow[aria-current] { background-color: #fed200; border-bottom: 0.2rem solid #8f7700; padding: 0.2rem 0 0; } - .c80 .highlight.yellow[aria-current].block:after { + .c76 .highlight.yellow[aria-current].block:after { background-color: #fed200; } - .c80 .highlight.yellow[aria-current].first.text.has-note:after { + .c76 .highlight.yellow[aria-current].first.text.has-note:after { display: none; } } @media screen { - .c80 .highlight.green[aria-current] { + .c76 .highlight.green[aria-current] { background-color: #92d101; border-bottom: 0.2rem solid #4e6f01; padding: 0.2rem 0 0; } - .c80 .highlight.green[aria-current].block:after { + .c76 .highlight.green[aria-current].block:after { background-color: #92d101; } - .c80 .highlight.green[aria-current].first.text.has-note:after { + .c76 .highlight.green[aria-current].first.text.has-note:after { display: none; } } @media screen { - .c80 .highlight.blue[aria-current] { + .c76 .highlight.blue[aria-current] { background-color: #00c3ed; border-bottom: 0.2rem solid #006880; padding: 0.2rem 0 0; } - .c80 .highlight.blue[aria-current].block:after { + .c76 .highlight.blue[aria-current].block:after { background-color: #00c3ed; } - .c80 .highlight.blue[aria-current].first.text.has-note:after { + .c76 .highlight.blue[aria-current].first.text.has-note:after { display: none; } } @media screen { - .c80 .highlight.purple[aria-current] { + .c76 .highlight.purple[aria-current] { background-color: #545ec8; border-bottom: 0.2rem solid #141a3e; padding: 0.2rem 0 0; color: #fff; } - .c80 .highlight.purple[aria-current].block:after { + .c76 .highlight.purple[aria-current].block:after { background-color: #545ec8; } - .c80 .highlight.purple[aria-current].first.text.has-note:after { + .c76 .highlight.purple[aria-current].first.text.has-note:after { display: none; } } @media screen { - .c80 .highlight.pink[aria-current] { + .c76 .highlight.pink[aria-current] { background-color: #de017e; border-bottom: 0.2rem solid #560131; padding: 0.2rem 0 0; color: #fff; } - .c80 .highlight.pink[aria-current].block:after { + .c76 .highlight.pink[aria-current].block:after { background-color: #de017e; } - .c80 .highlight.pink[aria-current].first.text.has-note:after { + .c76 .highlight.pink[aria-current].first.text.has-note:after { display: none; } } @media screen { - .c80 .search-highlight { + .c76 .search-highlight { font-weight: bold; } - .c80 .search-highlight, - .c80 .search-highlight .math { + .c76 .search-highlight, + .c76 .search-highlight .math { background-color: #ffea00; box-shadow: 0 0.2rem 0.3rem 0 rgb(0,0,0,0.41); } - .c80 .search-highlight[aria-current], - .c80 .search-highlight[aria-current] .math { + .c76 .search-highlight[aria-current], + .c76 .search-highlight[aria-current] .math { background-color: #ff9e4b; padding: 0.2rem 0; } - .c80 .search-highlight[aria-current] .search-highlight { + .c76 .search-highlight[aria-current] .search-highlight { background-color: unset; } - .c80 .search-highlight [data-for-screenreaders="true"]::before { + .c76 .search-highlight [data-for-screenreaders="true"]::before { content: attr(data-message); position: absolute; width: 1px; @@ -2407,38 +2426,38 @@ li[aria-label="Current Page"] .c61 { } @media screen { - .c90 { + .c87 { max-width: 82.5rem; margin: 0 auto; } } @media screen { - .c93 { + .c90 { max-width: 82.5rem; margin: 0 auto; } } @media screen and (max-width:75em) { - .c87 { + .c83 { padding: 0 1.6rem; } } @media screen and (max-width:75em) { - .c87 { + .c83 { min-height: 4rem; padding-top: 0.8rem; } - .c87 > .c64 { + .c83 > .c84 { margin-bottom: 0.8rem; } } @media print { - .c87 { + .c83 { display: none; } } @@ -2588,19 +2607,19 @@ li[aria-label="Current Page"] .c61 { } @media screen and (max-width:75em) { - .c76 { + .c72 { grid-column-start: 2; } } @media screen and (max-width:50em) { - .c76 { + .c72 { grid-column: 1 / -1; } } @media screen { - .c76 { + .c72 { background-color: #fff; padding-left: 37.5rem; -webkit-transition: padding-left 300ms ease-in-out; @@ -2609,21 +2628,21 @@ li[aria-label="Current Page"] .c61 { } @media screen and (max-width:90em) { - .c78 { + .c74 { max-width: calc(100vw - ((100vw - 128rem) / 2) - 8rem); left: calc(100vw - (100vw - ((100vw - 128rem) / 2) - 8rem)); } } @media screen and (max-width:80em) { - .c78 { + .c74 { max-width: calc(100vw - 8rem); left: 8rem; } } @media screen and (max-width:75em) { - .c78 { + .c74 { max-width: 100%; left: 0; z-index: 21; @@ -2632,7 +2651,7 @@ li[aria-label="Current Page"] .c61 { } @media screen { - .c77 { + .c73 { overflow: visible; -webkit-flex: 1; -ms-flex: 1; @@ -2720,7 +2739,7 @@ li[aria-label="Current Page"] .c61 { } @media screen { - .c79 { + .c75 { padding: 0 3.2rem; -webkit-flex: 1; -ms-flex: 1; @@ -2736,25 +2755,25 @@ li[aria-label="Current Page"] .c61 { } @media screen and (max-width:75em) { - .c83 { + .c79 { margin-left: -0.8rem; } } @media screen and (max-width:75em) { - .c85 { + .c81 { margin-right: -0.8rem; } } @media print { - .c82 { + .c78 { display: none; } } @media screen and (max-width:75em) { - .c82 { + .c78 { margin: 3.5rem auto 3rem auto; border: 0; } @@ -3679,608 +3698,723 @@ li[aria-label="Current Page"] .c61 {
    1. Test Page 1", } } + data-treeid="Test Book 1" + data-visible={true} href="test-page-1" + id="testbook1-testpage1-uuid@1.0" onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" />
    2. -
      - -
        -
      1. +
          -
          - -
          - - - 1 Test Chapter 1.1", - } - } - /> -
          -
          -
            -
          1. - Introduction to Science and the Realm of Physics, Physical Quantities, and Units", - } - } - href="1-introduction-to-science-and-the-realm-of-physics-physical-quantities-and-units" - onClick={[Function]} - /> -
          2. -
          -
          - -
        1. - 1.1 Physics: An Introduction", + Introduction to Science and the Realm of Physics, Physical Quantities, and Units", + } } + data-treeid="Test Book 1" + data-visible={false} + href="1-introduction-to-science-and-the-realm-of-physics-physical-quantities-and-units" + id="testbook1-testpage2-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
        2. +
        +
      2. +
      3. + 1.1 Physics: An Introduction", } - href="1-1-physics-an-introduction" - onClick={[Function]} - /> -
      4. -
      5. - 23.12 RLC Series AC Circuits", - } + } + data-treeid="Test Book 1" + data-visible={false} + href="1-1-physics-an-introduction" + id="testbook1-testpage11-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
      6. +
      7. + 23.12 RLC Series AC Circuits", } - href="23-12-rlc-series-ac-circuits" - onClick={[Function]} - /> -
      8. -
      -
      + } + data-treeid="Test Book 1" + data-visible={false} + href="23-12-rlc-series-ac-circuits" + id="testbook1-testpage8-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
    3. +
  • -
    - -
      + +
    1. - Test Page 3", - } + + + 2 Test Chapter 2", + } + } + /> + +
        +
      1. + Test Page 3", } - href="2-test-page-3" - onClick={[Function]} - /> -
      2. -
      -
    + } + data-treeid="Test Book 1" + data-visible={false} + href="2-test-page-3" + id="testbook1-testpage3-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
  • +
  • -
    - -
      + +
    1. - Test Page 4", - } + + + 3 Test Chapter 3", + } + } + /> + +
        +
      1. + Test Page 4", } - href="3-test-page-4" - onClick={[Function]} - /> -
      2. -
      -
    + } + data-treeid="Test Book 1" + data-visible={true} + href="3-test-page-4" + id="testbook1-testpage4-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
  • +
  • -
    - -
      + +
    1. - Test Page 5", - } + + + 4 Test Chapter 4", + } + } + /> + +
        +
      1. + Test Page 5", } - href="4-test-page-5" - onClick={[Function]} - /> -
      2. -
      -
    + } + data-treeid="Test Book 1" + data-visible={false} + href="4-test-page-5" + id="testbook1-testpage5-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
  • +
  • -
    - -
      + +
    1. - Test Page 6 with special characters in url", - } + + + 10 Test Chapter 5", + } + } + /> + +
        +
      1. + Test Page 6 with special characters in url", } - href="10-test-page-6-f%C3%ADsica" - onClick={[Function]} - /> -
      2. -
      -
    + } + data-treeid="Test Book 1" + data-visible={false} + href="10-test-page-6-f%C3%ADsica" + id="testbook1-testpage6-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
  • +
  • -
    - -
      + +
    1. - Test Page 7", - } + + + 12 Test Chapter 6", + } + } + /> + +
        +
      1. + Test Page 7", } - href="12-test-page-7" - onClick={[Function]} - /> -
      2. -
      -
    + } + data-treeid="Test Book 1" + data-visible={false} + href="12-test-page-7" + id="testbook1-testpage7-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
  • +
  • A | Atomic Masses", } } + data-treeid="Test Book 1" + data-visible={true} href="a-atomic-masses" + id="testbook1-testpage9-uuid@1.0" onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" />
  • D | Glossary of Key Symbols and Notation", } } + data-treeid="Test Book 1" + data-visible={true} href="d-glossary-of-key-symbols-and-notation" + id="testbook1-testpage10-uuid@1.0" onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" />
  • Test Page for Generic Styles", } } + data-treeid="Test Book 1" + data-visible={true} href="test-page-for-generic-styles" + id="testbook1-testpage12-uuid@1.0" onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" />
  • Search for key terms or text.
    this is a test page
    @@ -4293,19 +4427,20 @@ li[aria-label="Current Page"] .c61 { />
    + } + data-treeid="Test Book 1" + data-visible={false} + href="3-test-page-4" + id="testbook1-testpage4-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> + + + +
  • + + + + 4 Test Chapter 4", + } + } + /> + +
      +
    1. + Test Page 5", + } + } + data-treeid="Test Book 1" + data-visible={false} + href="4-test-page-5" + id="testbook1-testpage5-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
    2. +
    +
  • +
  • + + + + 10 Test Chapter 5", + } + } + /> + +
      +
    1. + Test Page 6 with special characters in url", + } + } + data-treeid="Test Book 1" + data-visible={false} + href="10-test-page-6-f%C3%ADsica" + id="testbook1-testpage6-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
    2. +
    +
  • +
  • + + + + 12 Test Chapter 6", + } + } + /> + +
      +
    1. + Test Page 7", + } + } + data-treeid="Test Book 1" + data-visible={false} + href="12-test-page-7" + id="testbook1-testpage7-uuid@1.0" + onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" + /> +
    2. +
  • A | Atomic Masses", } } + data-treeid="Test Book 1" + data-visible={true} href="a-atomic-masses" + id="testbook1-testpage9-uuid@1.0" onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" />
  • D | Glossary of Key Symbols and Notation", } } + data-treeid="Test Book 1" + data-visible={true} href="d-glossary-of-key-symbols-and-notation" + id="testbook1-testpage10-uuid@1.0" onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" />
  • Test Page for Generic Styles", } } + data-treeid="Test Book 1" + data-visible={true} href="test-page-for-generic-styles" + id="testbook1-testpage12-uuid@1.0" onClick={[Function]} + onKeyDown={[Function]} + role="treeitem" />
  • Search for key terms or text.