Skip to content
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

Add reactToKeys prop to Toc component and on_keydown handler to enable navigating ToC with keyboard #55

Merged
merged 4 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ repos:
args: [--ignore-words-list, falsy, --check-filenames]

- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.0.0-beta.1
rev: v9.0.0-beta.2
hooks:
- id: eslint
types: [file]
Expand Down
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@
"svelte": "^4.2.12"
},
"devDependencies": {
"@playwright/test": "1.42.0",
"@playwright/test": "1.42.1",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.2",
"@sveltejs/package": "^2.2.7",
"@sveltejs/kit": "^2.5.4",
"@sveltejs/package": "^2.3.0",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.1",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@vitest/coverage-v8": "^1.4.0",
"eslint": "^8.57.0",
"eslint-plugin-svelte": "^2.35.1",
"hastscript": "^9.0.0",
Expand All @@ -44,13 +44,13 @@
"prettier-plugin-svelte": "^3.2.2",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"svelte-check": "^3.6.6",
"svelte-check": "^3.6.8",
"svelte-preprocess": "^5.1.3",
"svelte-zoo": "^0.4.10",
"svelte2tsx": "^0.7.3",
"typescript": "5.3.3",
"vite": "^5.1.4",
"vitest": "^1.3.1"
"svelte2tsx": "^0.7.5",
"typescript": "5.4.3",
"vite": "^5.2.2",
"vitest": "^1.4.0"
},
"keywords": [
"svelte",
Expand Down
6 changes: 6 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ Full list of props and bindable variables for this component (all of them option

Which DOM node to use as the `MutationObserver` root node. This is usually the page's `<main>` tag or `<body>` element. All headings to list in the ToC should be children of this root node. Use the closest parent node containing all headings for efficiency, especially if you have a lot of elements on the page that are on a separate branch of the DOM tree from the headings you want to list.

1. ```ts
reactToKeys: string[] = [`ArrowDown`, `ArrowUp`, ` `, `Enter`, `Escape`, `Tab`]
```

Which keyboard events to listen for. The default set of keys closes the ToC on `Escape` and `Tab` out, navigates the ToC list with `ArrowDown`, `ArrowUp`, and scrolls to the active ToC item on `Space`, and `Enter`. Set `reactToKeys = false` or `[]` to disable keyboard support entirely. Remove individual keys from the array to disable specific behaviors.

1. ```ts
scrollBehavior: 'auto' | 'smooth' = `smooth`
```
Expand Down
48 changes: 47 additions & 1 deletion src/lib/Toc.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
export let nav: HTMLElement | undefined = undefined
export let open: boolean = false
export let openButtonLabel: string = `Open table of contents`
// prettier-ignore
export let reactToKeys: string[] = [`ArrowDown`, `ArrowUp`, ` `, `Enter`, `Escape`, `Tab`]
export let pageBody: string | HTMLElement = `body`
export let scrollBehavior: 'auto' | 'smooth' = `smooth`
export let title: string = `On this page`
Expand Down Expand Up @@ -94,11 +96,12 @@
}
}

// click and key handler for ToC items that scrolls to the heading
const li_click_key_handler =
(node: HTMLHeadingElement) => (event: MouseEvent | KeyboardEvent) => {
if (event instanceof KeyboardEvent && ![`Enter`, ` `].includes(event.key)) return
open = false
node.scrollIntoView({ behavior: scrollBehavior, block: `start` })
node.scrollIntoView?.({ behavior: scrollBehavior, block: `start` })

const id = getHeadingIds && getHeadingIds(node)
if (id) history.replaceState({}, ``, `#${id}`)
Expand All @@ -122,6 +125,44 @@
set_active_heading()
scroll_to_active_toc_item(`instant`)
}

// enable keyboard navigation
function on_keydown(event: KeyboardEvent) {
if (!reactToKeys || !reactToKeys.includes(event.key)) return

// `:hover`.at(-1) returns the most deeply nested hovered element
const hovered = [...document.querySelectorAll(`:hover`)].at(-1)
const toc_is_hovered = hovered && nav?.contains(hovered)

if (
// return early if ToC does not have focus
(event.key === `Tab` && !nav?.contains(document.activeElement)) ||
// ignore keyboard events when ToC is closed on mobile or when ToC is not currently hovered on desktop
(!desktop && !open) ||
(desktop && !toc_is_hovered)
)
return

event.preventDefault()

if (event.key === `Escape` && open) open = false
else if (event.key === `Tab` && !aside?.contains(document.activeElement)) open = false
else if (activeTocLi) {
if (event.key === `ArrowDown`) {
const next = activeTocLi.nextElementSibling
if (next) activeTocLi = next as HTMLLIElement
}
if (event.key === `ArrowUp`) {
const prev = activeTocLi.previousElementSibling
if (prev) activeTocLi = prev as HTMLLIElement
}
// update active heading
activeHeading = headings[tocItems.indexOf(activeTocLi)]
}
if (activeTocLi && [` `, `Enter`].includes(event.key)) {
activeHeading?.scrollIntoView({ behavior: `instant`, block: `start` })
}
}
</script>

<svelte:window
Expand All @@ -133,6 +174,11 @@
// smooth or otherwise (https://stackoverflow.com/a/63563437)
scroll_to_active_toc_item()
}}
on:resize={() => {
desktop = window_width > breakpoint
set_active_heading()
}}
on:keydown={on_keydown}
/>

<aside
Expand Down
67 changes: 65 additions & 2 deletions tests/unit/toc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ describe(`Toc`, () => {
expect(toc.blurParams).toEqual(blurParams)
})

test(`should expose nav and aside HTMLElements via export let`, async () => {
test(`should expose nav and aside HTMLElements as props for external binding`, async () => {
const toc = new Toc({
target: document.body,
props: { open: true },
Expand All @@ -218,7 +218,7 @@ describe(`Toc`, () => {
expect(toc.nav.tagName).toBe(`NAV`)
})

test(`open custom event fires whenever open changes`, async () => {
test(`custom event 'open' fires whenever Toc.open state changes`, async () => {
const toc = new Toc({ target: document.body })

const open_handler = vi.fn()
Expand Down Expand Up @@ -277,4 +277,67 @@ describe(`Toc`, () => {
expect(active_li).toBeTruthy()
expect(active_li.textContent?.trim()).toBe(`Heading 100`)
})

test.each([true, false])(
`arrow keys navigate the active ToC on mobile item when open=%s`,
// TODO also test on desktop when ToC is hovered, JSDOM doesn't seem to support hover
async (open) => {
document.body.innerHTML = `
<h2>Heading 1</h2>
<h2>Heading 2</h2>
<h2>Heading 3</h2>
<h2>Heading 4</h2>
`

const toc = new Toc({ target: document.body, props: { open } })
await tick()

const tocItems = document.querySelectorAll(`aside.toc > nav > ol > li`)
expect(tocItems.length).toBe(4)

if (open) {
// check initially active item before pressing arrow keys
expect(toc.activeTocLi).toBe(tocItems[3])

for (const [idx, key] of [
[2, `ArrowUp`],
[1, `ArrowUp`],
[2, `ArrowDown`],
[3, `ArrowDown`],
] as const) {
// simulate ArrowUp/Down keys
window.dispatchEvent(new KeyboardEvent(`keydown`, { key }))
expect(toc.activeTocLi).toBe(tocItems[idx])
}
} else {
// if ToC is closed, no item should be active and arrow keys should not navigate
expect(toc.activeTocLi).toBe(undefined)

// simulate pressing ArrowDown key
window.dispatchEvent(new KeyboardEvent(`keydown`, { key: `ArrowDown` }))
expect(toc.activeTocLi).toBe(undefined)
}
},
)

test.each([[[]], [[`Escape`]]])(
`Escape key closes ToC on mobile if reactToKeys=%s includes 'Escape'`,
async (reactToKeys) => {
// simulate mobile
window.innerWidth = 600

const toc = new Toc({
target: document.body,
props: { open: true, reactToKeys },
})
await tick()

expect(toc.open).toBe(true)

// simulate pressing Escape
window.dispatchEvent(new KeyboardEvent(`keydown`, { key: `Escape` }))

expect(toc.open).toBe(!reactToKeys.includes(`Escape`))
},
)
})