diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 813d61b29e..f96c0f13af 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087))
- Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090))
+- Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093))
## [1.7.5] - 2022-12-08
diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
index cc6d15874f..1f3efc93b1 100644
--- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
+++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx
@@ -218,7 +218,6 @@ describe('Rendering', () => {
})
it('should be possible to use a different render strategy for the Dialog', async () => {
- let focusCounter = jest.fn()
function Example() {
let [isOpen, setIsOpen] = useState(false)
@@ -228,7 +227,7 @@ describe('Rendering', () => {
Trigger
>
)
@@ -239,17 +238,14 @@ describe('Rendering', () => {
await nextFrame()
assertDialog({ state: DialogState.InvisibleHidden })
- expect(focusCounter).toHaveBeenCalledTimes(0)
// Let's open the Dialog, to see if it is not hidden anymore
await click(document.getElementById('trigger'))
- expect(focusCounter).toHaveBeenCalledTimes(1)
assertDialog({ state: DialogState.Visible })
// Let's close the Dialog
await press(Keys.Escape)
- expect(focusCounter).toHaveBeenCalledTimes(1)
assertDialog({ state: DialogState.InvisibleHidden })
})
diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx
index 23a5a4e777..7f43d5ccd9 100644
--- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx
+++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useRef, FocusEvent } from 'react'
+import React, { useState, useRef } from 'react'
import { render, screen } from '@testing-library/react'
import { FocusTrap } from './focus-trap'
@@ -6,6 +6,13 @@ import { assertActiveElement } from '../../test-utils/accessibility-assertions'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
import { click, press, shift, Keys } from '../../test-utils/interactions'
+beforeAll(() => {
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
+ jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
+})
+
+afterAll(() => jest.restoreAllMocks())
+
function nextFrame() {
return new Promise((resolve) => {
requestAnimationFrame(() => {
@@ -365,76 +372,134 @@ it('should be possible skip disabled elements within the focus trap', async () =
assertActiveElement(document.getElementById('item-a'))
})
-it('should try to focus all focusable items (and fail)', async () => {
- let spy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
- let focusHandler = jest.fn()
- function handleFocus(e: FocusEvent) {
- let target = e.target as HTMLElement
- focusHandler(target.id)
- screen.getByText('After')?.focus()
- }
+it(
+ 'should not be possible to programmatically escape the focus trap',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ return (
+ <>
+
- render(
- <>
-
-
-
-
-
-
-
-
- >
- )
+
+
+
+
+
+ >
+ )
+ }
- await nextFrame()
+ render()
- expect(focusHandler.mock.calls).toEqual([['item-a'], ['item-b'], ['item-c'], ['item-d']])
- expect(spy).toHaveBeenCalledWith('There are no focusable elements inside the ')
- spy.mockReset()
-})
+ await nextFrame()
-it('should end up at the last focusable element', async () => {
- let spy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
+ let [a, b, c, d] = Array.from(document.querySelectorAll('input'))
- let focusHandler = jest.fn()
- function handleFocus(e: FocusEvent) {
- let target = e.target as HTMLElement
- focusHandler(target.id)
- screen.getByText('After')?.focus()
- }
+ // Ensure that input-b is the active element
+ assertActiveElement(b)
- render(
- <>
-
-
-
-
-
-
-
-
- >
- )
+ // Tab to the next item
+ await press(Keys.Tab)
- await nextFrame()
+ // Ensure that input-c is the active element
+ assertActiveElement(c)
- expect(focusHandler.mock.calls).toEqual([['item-a'], ['item-b'], ['item-c']])
- assertActiveElement(screen.getByText('Item D'))
- expect(spy).not.toHaveBeenCalled()
- spy.mockReset()
-})
+ // Try to move focus
+ a?.focus()
+
+ // Ensure that input-c is still the active element
+ assertActiveElement(c)
+
+ // Click on an element within the FocusTrap
+ await click(b)
+
+ // Ensure that input-b is the active element
+ assertActiveElement(b)
+
+ // Try to move focus again
+ a?.focus()
+
+ // Ensure that input-b is still the active element
+ assertActiveElement(b)
+
+ // Focus on an element within the FocusTrap
+ d?.focus()
+
+ // Ensure that input-d is the active element
+ assertActiveElement(d)
+
+ // Try to move focus again
+ a?.focus()
+
+ // Ensure that input-d is still the active element
+ assertActiveElement(d)
+ })
+)
+
+it(
+ 'should not be possible to escape the FocusTrap due to strange tabIndex usage',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ render()
+
+ await nextFrame()
+
+ let [_a, _b, c, d] = Array.from(document.querySelectorAll('input'))
+
+ // First item in the FocusTrap should be the active one
+ assertActiveElement(c)
+
+ // Tab to the next item
+ await press(Keys.Tab)
+
+ // Ensure that input-d is the active element
+ assertActiveElement(d)
+
+ // Tab to the next item
+ await press(Keys.Tab)
+
+ // Ensure that input-c is the active element
+ assertActiveElement(c)
+
+ // Tab to the next item
+ await press(Keys.Tab)
+
+ // Ensure that input-d is the active element
+ assertActiveElement(d)
+
+ // Let's go the other way
+
+ // Tab to the previous item
+ await press(shift(Keys.Tab))
+
+ // Ensure that input-c is the active element
+ assertActiveElement(c)
+
+ // Tab to the previous item
+ await press(shift(Keys.Tab))
+
+ // Ensure that input-d is the active element
+ assertActiveElement(d)
+
+ // Tab to the previous item
+ await press(shift(Keys.Tab))
+
+ // Ensure that input-c is the active element
+ assertActiveElement(c)
+ })
+)
diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx
index 2db6dc6679..a0a60339f8 100644
--- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx
+++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx
@@ -6,6 +6,7 @@ import React, {
ElementType,
MutableRefObject,
Ref,
+ FocusEvent as ReactFocusEvent,
} from 'react'
import { Props } from '../../types'
@@ -22,6 +23,7 @@ import { useOwnerDocument } from '../../hooks/use-owner'
import { useEventListener } from '../../hooks/use-event-listener'
import { microTask } from '../../utils/micro-task'
import { useWatch } from '../../hooks/use-watch'
+import { useDisposables } from '../../hooks/use-disposables'
let DEFAULT_FOCUS_TRAP_TAG = 'div' as const
@@ -75,27 +77,69 @@ export let FocusTrap = Object.assign(
)
let direction = useTabDirection()
- let handleFocus = useEvent(() => {
+ let handleFocus = useEvent((e: ReactFocusEvent) => {
let el = container.current as HTMLElement
if (!el) return
// TODO: Cleanup once we are using real browser tests
- if (process.env.NODE_ENV === 'test') {
- microTask(() => {
- match(direction.current, {
- [TabDirection.Forwards]: () => focusIn(el, Focus.First),
- [TabDirection.Backwards]: () => focusIn(el, Focus.Last),
- })
- })
- } else {
+ let wrapper = process.env.NODE_ENV === 'test' ? microTask : (cb: Function) => cb()
+ wrapper(() => {
match(direction.current, {
- [TabDirection.Forwards]: () => focusIn(el, Focus.First),
- [TabDirection.Backwards]: () => focusIn(el, Focus.Last),
+ [TabDirection.Forwards]: () =>
+ focusIn(el, Focus.First, { skipElements: [e.relatedTarget as HTMLElement] }),
+ [TabDirection.Backwards]: () =>
+ focusIn(el, Focus.Last, { skipElements: [e.relatedTarget as HTMLElement] }),
})
- }
+ })
})
- let ourProps = { ref: focusTrapRef }
+ let d = useDisposables()
+ let recentlyUsedTabKey = useRef(false)
+ let ourProps = {
+ ref: focusTrapRef,
+ onKeyDown(e: KeyboardEvent) {
+ if (e.key == 'Tab') {
+ recentlyUsedTabKey.current = true
+ d.requestAnimationFrame(() => {
+ recentlyUsedTabKey.current = false
+ })
+ }
+ },
+ onBlur(e: ReactFocusEvent) {
+ let allContainers = new Set(containers?.current)
+ allContainers.add(container)
+
+ let relatedTarget = e.relatedTarget as HTMLElement | null
+ if (!relatedTarget) return
+
+ // Known guards, leave them alone!
+ if (relatedTarget.dataset.headlessuiFocusGuard === 'true') {
+ return
+ }
+
+ // Blur is triggered due to focus on relatedTarget, and the relatedTarget is not inside any
+ // of the dialog containers. In other words, let's move focus back in!
+ if (!contains(allContainers, relatedTarget)) {
+ // Was the blur invoke via the keyboard? Redirect to the next in line.
+ if (recentlyUsedTabKey.current) {
+ focusIn(
+ container.current as HTMLElement,
+ match(direction.current, {
+ [TabDirection.Forwards]: () => Focus.Next,
+ [TabDirection.Backwards]: () => Focus.Previous,
+ }) | Focus.WrapAround,
+ { relativeTo: e.target as HTMLElement }
+ )
+ }
+
+ // It was invoke via something else (e.g.: click, programmatically, ...). Redirect to the
+ // previous active item in the FocusTrap
+ else if (e.target instanceof HTMLElement) {
+ focusElement(e.target)
+ }
+ }
+ },
+ }
return (
<>
@@ -103,6 +147,7 @@ export let FocusTrap = Object.assign(
@@ -117,6 +162,7 @@ export let FocusTrap = Object.assign(
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
index 17f74bb61b..8b393c97e1 100644
--- a/packages/@headlessui-react/src/components/popover/popover.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -792,7 +792,7 @@ let Panel = forwardRefWithAs(function Panel focusIn(el, Focus.Last),
})
diff --git a/packages/@headlessui-react/src/utils/focus-management.ts b/packages/@headlessui-react/src/utils/focus-management.ts
index 0800fbde3e..ab8971ae38 100644
--- a/packages/@headlessui-react/src/utils/focus-management.ts
+++ b/packages/@headlessui-react/src/utils/focus-management.ts
@@ -66,7 +66,11 @@ enum Direction {
export function getFocusableElements(container: HTMLElement | null = document.body) {
if (container == null) return []
- return Array.from(container.querySelectorAll(focusableSelector))
+ return Array.from(container.querySelectorAll(focusableSelector)).sort(
+ // We want to move `tabIndex={0}` to the end of the list, this is what the browser does as well.
+ (a, z) =>
+ Math.sign((a.tabIndex || Number.MAX_SAFE_INTEGER) - (z.tabIndex || Number.MAX_SAFE_INTEGER))
+ )
}
export enum FocusableMode {
@@ -143,14 +147,17 @@ export function sortByDomNode(
}
export function focusFrom(current: HTMLElement | null, focus: Focus) {
- return focusIn(getFocusableElements(), focus, true, current)
+ return focusIn(getFocusableElements(), focus, { relativeTo: current })
}
export function focusIn(
container: HTMLElement | HTMLElement[],
focus: Focus,
- sorted = true,
- active: HTMLElement | null = null
+ {
+ sorted = true,
+ relativeTo = null,
+ skipElements = [],
+ }: Partial<{ sorted: boolean; relativeTo: HTMLElement | null; skipElements: HTMLElement[] }> = {}
) {
let ownerDocument = Array.isArray(container)
? container.length > 0
@@ -163,7 +170,12 @@ export function focusIn(
? sortByDomNode(container)
: container
: getFocusableElements(container)
- active = active ?? (ownerDocument.activeElement as HTMLElement)
+
+ if (skipElements.length > 0) {
+ elements = elements.filter((x) => !skipElements.includes(x))
+ }
+
+ relativeTo = relativeTo ?? (ownerDocument.activeElement as HTMLElement)
let direction = (() => {
if (focus & (Focus.First | Focus.Next)) return Direction.Next
@@ -174,8 +186,8 @@ export function focusIn(
let startIndex = (() => {
if (focus & Focus.First) return 0
- if (focus & Focus.Previous) return Math.max(0, elements.indexOf(active)) - 1
- if (focus & Focus.Next) return Math.max(0, elements.indexOf(active)) + 1
+ if (focus & Focus.Previous) return Math.max(0, elements.indexOf(relativeTo)) - 1
+ if (focus & Focus.Next) return Math.max(0, elements.indexOf(relativeTo)) + 1
if (focus & Focus.Last) return elements.length - 1
throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last')
diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md
index 52c1c24616..558ddf10fb 100644
--- a/packages/@headlessui-vue/CHANGELOG.md
+++ b/packages/@headlessui-vue/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087))
- Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090))
+- Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093))
## [1.7.5] - 2022-12-08
diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts
index 22358c55b0..c54ff3f796 100644
--- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts
+++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts
@@ -312,20 +312,18 @@ describe('Rendering', () => {
})
it('should be possible to use a different render strategy for the Dialog', async () => {
- let focusCounter = jest.fn()
renderTemplate({
template: `
`,
setup() {
let isOpen = ref(false)
return {
- focusCounter,
isOpen,
setIsOpen(value: boolean) {
isOpen.value = value
@@ -337,19 +335,15 @@ describe('Rendering', () => {
await nextFrame()
assertDialog({ state: DialogState.InvisibleHidden })
- expect(focusCounter).toHaveBeenCalledTimes(0)
// Let's open the Dialog, to see if it is not hidden anymore
await click(document.getElementById('trigger'))
- expect(focusCounter).toHaveBeenCalledTimes(1)
assertDialog({ state: DialogState.Visible })
// Let's close the Dialog
await press(Keys.Escape)
- expect(focusCounter).toHaveBeenCalledTimes(1)
-
assertDialog({ state: DialogState.InvisibleHidden })
})
diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts
index 8ae1d202a1..c7996c7ee3 100644
--- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts
+++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts
@@ -387,73 +387,68 @@ it('should be possible skip disabled elements within the focus trap', async () =
assertActiveElement(document.getElementById('item-a'))
})
-it('should try to focus all focusable items in order (and fail)', async () => {
- let spy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
- let focusHandler = jest.fn()
+it(
+ 'should not be possible to escape the FocusTrap due to strange tabIndex usage',
+ suppressConsoleLogs(async () => {
+ renderTemplate(
+ html`
+
+ `
+ )
- await nextFrame()
+ await nextFrame()
- expect(focusHandler.mock.calls).toEqual([['item-a'], ['item-b'], ['item-c'], ['item-d']])
- expect(spy).toHaveBeenCalledWith('There are no focusable elements inside the ')
- spy.mockReset()
-})
+ let [_a, _b, c, d] = Array.from(document.querySelectorAll('input'))
-it('should end up at the last focusable element', async () => {
- let spy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
- let focusHandler = jest.fn()
+ // First item in the FocusTrap should be the active one
+ assertActiveElement(c)
- renderTemplate({
- template: html`
-
-
-
-
-
-
-
-
-
-
- `,
- setup() {
- return {
- handleFocus(e: Event) {
- let target = e.target as HTMLElement
- focusHandler(target.id)
- getByText('After')?.focus()
- },
- }
- },
- })
+ // Tab to the next item
+ await press(Keys.Tab)
- await nextFrame()
+ // Ensure that input-d is the active element
+ assertActiveElement(d)
- expect(focusHandler.mock.calls).toEqual([['item-a'], ['item-b'], ['item-c']])
- assertActiveElement(getByText('Item D'))
- expect(spy).not.toHaveBeenCalled()
- spy.mockReset()
-})
+ // Tab to the next item
+ await press(Keys.Tab)
+
+ // Ensure that input-c is the active element
+ assertActiveElement(c)
+
+ // Tab to the next item
+ await press(Keys.Tab)
+
+ // Ensure that input-d is the active element
+ assertActiveElement(d)
+
+ // Let's go the other way
+
+ // Tab to the previous item
+ await press(shift(Keys.Tab))
+
+ // Ensure that input-c is the active element
+ assertActiveElement(c)
+
+ // Tab to the previous item
+ await press(shift(Keys.Tab))
+
+ // Ensure that input-d is the active element
+ assertActiveElement(d)
+
+ // Tab to the previous item
+ await press(shift(Keys.Tab))
+
+ // Ensure that input-c is the active element
+ assertActiveElement(c)
+ })
+)
diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts
index 8b6b421481..faa6501426 100644
--- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts
+++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts
@@ -81,29 +81,70 @@ export let FocusTrap = Object.assign(
)
let direction = useTabDirection()
- function handleFocus() {
+ function handleFocus(e: FocusEvent) {
let el = dom(container) as HTMLElement
if (!el) return
// TODO: Cleanup once we are using real browser tests
- if (process.env.NODE_ENV === 'test') {
- microTask(() => {
- match(direction.value, {
- [TabDirection.Forwards]: () => focusIn(el, Focus.First),
- [TabDirection.Backwards]: () => focusIn(el, Focus.Last),
- })
- })
- } else {
+ let wrapper = process.env.NODE_ENV === 'test' ? microTask : (cb: Function) => cb()
+ wrapper(() => {
match(direction.value, {
- [TabDirection.Forwards]: () => focusIn(el, Focus.First),
- [TabDirection.Backwards]: () => focusIn(el, Focus.Last),
+ [TabDirection.Forwards]: () =>
+ focusIn(el, Focus.First, { skipElements: [e.relatedTarget as HTMLElement] }),
+ [TabDirection.Backwards]: () =>
+ focusIn(el, Focus.Last, { skipElements: [e.relatedTarget as HTMLElement] }),
+ })
+ })
+ }
+
+ let recentlyUsedTabKey = ref(false)
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Tab') {
+ recentlyUsedTabKey.value = true
+ requestAnimationFrame(() => {
+ recentlyUsedTabKey.value = false
})
}
}
+ function handleBlur(e: FocusEvent) {
+ let allContainers = new Set(props.containers?.value)
+ allContainers.add(container)
+
+ let relatedTarget = e.relatedTarget as HTMLElement | null
+ if (!relatedTarget) return
+
+ // Known guards, leave them alone!
+ if (relatedTarget.dataset.headlessuiFocusGuard === 'true') {
+ return
+ }
+
+ // Blur is triggered due to focus on relatedTarget, and the relatedTarget is not inside any
+ // of the dialog containers. In other words, let's move focus back in!
+ if (!contains(allContainers, relatedTarget)) {
+ // Was the blur invoke via the keyboard? Redirect to the next in line.
+ if (recentlyUsedTabKey.value) {
+ focusIn(
+ dom(container) as HTMLElement,
+ match(direction.value, {
+ [TabDirection.Forwards]: () => Focus.Next,
+ [TabDirection.Backwards]: () => Focus.Previous,
+ }) | Focus.WrapAround,
+ { relativeTo: e.target as HTMLElement }
+ )
+ }
+
+ // It was invoke via something else (e.g.: click, programmatically, ...). Redirect to the
+ // previous active item in the FocusTrap
+ else if (e.target instanceof HTMLElement) {
+ focusElement(e.target)
+ }
+ }
+ }
+
return () => {
let slot = {}
- let ourProps = { ref: container }
+ let ourProps = { ref: container, onKeydown: handleKeyDown, onFocusout: handleBlur }
let { features, initialFocus, containers: _containers, ...theirProps } = props
return h(Fragment, [
@@ -111,6 +152,7 @@ export let FocusTrap = Object.assign(
h(Hidden, {
as: 'button',
type: 'button',
+ 'data-headlessui-focus-guard': true,
onFocus: handleFocus,
features: HiddenFeatures.Focusable,
}),
@@ -126,6 +168,7 @@ export let FocusTrap = Object.assign(
h(Hidden, {
as: 'button',
type: 'button',
+ 'data-headlessui-focus-guard': true,
onFocus: handleFocus,
features: HiddenFeatures.Focusable,
}),
diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts
index b5bb8ca3b4..f767ec89bc 100644
--- a/packages/@headlessui-vue/src/components/popover/popover.ts
+++ b/packages/@headlessui-vue/src/components/popover/popover.ts
@@ -632,7 +632,7 @@ export let PopoverPanel = defineComponent({
}
}
- focusIn(combined, Focus.First, false)
+ focusIn(combined, Focus.First, { sorted: false })
},
[TabDirection.Backwards]: () => focusIn(el, Focus.Previous),
})
diff --git a/packages/@headlessui-vue/src/utils/focus-management.ts b/packages/@headlessui-vue/src/utils/focus-management.ts
index c8abd5836d..9ec2cd5f72 100644
--- a/packages/@headlessui-vue/src/utils/focus-management.ts
+++ b/packages/@headlessui-vue/src/utils/focus-management.ts
@@ -59,7 +59,11 @@ enum Direction {
export function getFocusableElements(container: HTMLElement | null = document.body) {
if (container == null) return []
- return Array.from(container.querySelectorAll(focusableSelector))
+ return Array.from(container.querySelectorAll(focusableSelector)).sort(
+ // We want to move `:tabindex="0"` to the end of the list, this is what the browser does as well.
+ (a, z) =>
+ Math.sign((a.tabIndex || Number.MAX_SAFE_INTEGER) - (z.tabIndex || Number.MAX_SAFE_INTEGER))
+ )
}
export enum FocusableMode {
@@ -136,14 +140,17 @@ export function sortByDomNode(
}
export function focusFrom(current: HTMLElement | null, focus: Focus) {
- return focusIn(getFocusableElements(), focus, true, current)
+ return focusIn(getFocusableElements(), focus, { relativeTo: current })
}
export function focusIn(
container: HTMLElement | HTMLElement[],
focus: Focus,
- sorted = true,
- active: HTMLElement | null = null
+ {
+ sorted = true,
+ relativeTo = null,
+ skipElements = [],
+ }: Partial<{ sorted: boolean; relativeTo: HTMLElement | null; skipElements: HTMLElement[] }> = {}
) {
let ownerDocument =
(Array.isArray(container)
@@ -157,7 +164,12 @@ export function focusIn(
? sortByDomNode(container)
: container
: getFocusableElements(container)
- active = active ?? (ownerDocument.activeElement as HTMLElement)
+
+ if (skipElements.length > 0) {
+ elements = elements.filter((x) => !skipElements.includes(x))
+ }
+
+ relativeTo = relativeTo ?? (ownerDocument.activeElement as HTMLElement)
let direction = (() => {
if (focus & (Focus.First | Focus.Next)) return Direction.Next
@@ -168,8 +180,8 @@ export function focusIn(
let startIndex = (() => {
if (focus & Focus.First) return 0
- if (focus & Focus.Previous) return Math.max(0, elements.indexOf(active)) - 1
- if (focus & Focus.Next) return Math.max(0, elements.indexOf(active)) + 1
+ if (focus & Focus.Previous) return Math.max(0, elements.indexOf(relativeTo)) - 1
+ if (focus & Focus.Next) return Math.max(0, elements.indexOf(relativeTo)) + 1
if (focus & Focus.Last) return elements.length - 1
throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last')