Skip to content

Core 750: proper custom controls #2433

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 62 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
5ee7441
refactor toc with navigation tree
jomcarvajal Feb 19, 2025
93b6e82
remove redundant role
jomcarvajal Feb 19, 2025
7a8dbf7
Merge branch 'main' of github.com:openstax/rex-web into CORE-750-prop…
jomcarvajal Feb 19, 2025
9408066
replace quotemark
jomcarvajal Feb 19, 2025
c15001d
update snapshots
jomcarvajal Feb 19, 2025
3fed124
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 19, 2025
c0df3fe
fix coverage
jomcarvajal Feb 20, 2025
e2c2369
Merge branch 'CORE-750-proper-custom-controls' of github.com:openstax…
jomcarvajal Feb 20, 2025
055e657
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 20, 2025
2c93f3a
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
a0ff154
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
f8dd35b
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
5042a1a
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
b6c8c27
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
115431a
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
68964b8
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
4778e64
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
f0ab429
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
ef7117e
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Feb 21, 2025
993118a
refactor subtrees and anchors
jomcarvajal Feb 24, 2025
2cb6c02
refactor anchors and subtrees
jomcarvajal Feb 24, 2025
f1684f0
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 5, 2025
310fe5c
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 5, 2025
bb70adf
add keyboard support for tree navigation
jomcarvajal Mar 7, 2025
2bf7a10
Merge branch 'CORE-750-proper-custom-controls' of github.com:openstax…
jomcarvajal Mar 7, 2025
4624a5d
remove unknown old tag
jomcarvajal Mar 10, 2025
5af0f34
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 10, 2025
4ab5439
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 11, 2025
51e5eb0
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 11, 2025
79d826b
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 11, 2025
105e4bc
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 11, 2025
0c82088
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 11, 2025
cede341
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 11, 2025
f0926f2
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 11, 2025
5d90dd5
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 12, 2025
623f1b2
using stripIdVersion for compare ids in ol role
jomcarvajal Mar 18, 2025
03f9987
resolve conflicts
jomcarvajal Mar 18, 2025
ae906c9
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 24, 2025
5d87b47
CORE-750: run lint fix
jomcarvajal Mar 24, 2025
d4558fc
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Mar 26, 2025
6a4da81
fix prerenderspec.ts snap
jomcarvajal Mar 27, 2025
06a2c0f
Merge branch 'CORE-750-proper-custom-controls' of github.com:openstax…
jomcarvajal Mar 27, 2025
fcf3553
resolve merge conflicts
jomcarvajal Apr 1, 2025
95365be
improve conditional
jomcarvajal Apr 1, 2025
1bb6ffe
refactor keyboard support methods
jomcarvajal Apr 1, 2025
f3d977d
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 3, 2025
1ef9eea
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 4, 2025
97ca660
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 8, 2025
73030dc
resolve comments
jomcarvajal Apr 9, 2025
0df16b1
new querySelector to get all visible treeitems
jomcarvajal Apr 10, 2025
b349bdc
run linter
jomcarvajal Apr 10, 2025
1cbf5b5
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 15, 2025
7b446ee
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 15, 2025
2249123
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 21, 2025
6caf6c9
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 21, 2025
99e9458
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 28, 2025
55757f2
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 29, 2025
94294d6
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] Apr 30, 2025
17d6048
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] May 1, 2025
ec99cff
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] May 1, 2025
3f2105c
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] May 1, 2025
95f77b6
Merge branch 'main' into CORE-750-proper-custom-controls
staxly[bot] May 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions playwright/src/fixtures/toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
}

Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion pytest-selenium/regions/toc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
14 changes: 14 additions & 0 deletions src/app/components/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
`}
`;
6 changes: 3 additions & 3 deletions src/app/content/components/Assigned.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
Expand All @@ -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();
});
Expand Down
215 changes: 134 additions & 81 deletions src/app/content/components/ContentLink.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ describe('ContentLink', () => {
return event;
};

const onKeyDownMock = (event: React.KeyboardEvent<HTMLAnchorElement>, 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;
Expand All @@ -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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} />
<ConnectedContentLink book={book} page={page} onKeyDown={onKeyDownMock} />
</TestContainer>);

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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} queryParams={mockSearch} />
<ConnectedContentLink book={book} page={page} queryParams={mockSearch} onKeyDown={onKeyDownMock} />
</TestContainer>);

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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} queryParams={mockSearch} />
<ConnectedContentLink book={book} page={page} queryParams={mockSearch} onKeyDown={onKeyDownMock} />
</TestContainer>);

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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} queryParams={mockSearch} scrollTarget={scrollTarget} />
</TestContainer>);

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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} />
</TestContainer>);

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(<TestContainer store={store}>
<ConnectedContentLink
book={book}
page={page}
queryParams={mockSearch}
scrollTarget={scrollTarget}
onKeyDown={onKeyDownMock}
/>
</TestContainer>);

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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} onKeyDown={onKeyDownMock} />
</TestContainer>);

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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} onClick={clickSpy} />
<ConnectedContentLink book={book} page={page} onClick={clickSpy} onKeyDown={onKeyDownMock} />
</TestContainer>);

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

Expand All @@ -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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} onClick={clickSpy} />
</TestContainer>);

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(<TestContainer store={store}>
<ConnectedContentLink book={book} page={page} onClick={clickSpy} onKeyDown={onKeyDownMock} />
</TestContainer>);

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(<TestContainer store={store}>
Expand Down
Loading
Loading