mirror of
https://github.com/element-plus/element-plus.git
synced 2025-02-23 11:59:34 +08:00
feat(directives): v-trap-focus (#221)
* feat(directives): [WIP] v-trap-focus #218 * feat(directives): new directive `v-trap-focus` * feat(directives): - Address PR comments * fix: fix typo
This commit is contained in:
parent
ab1a1546e0
commit
0d811ad714
157
packages/directives/__tests__/trap-focus.spec.ts
Normal file
157
packages/directives/__tests__/trap-focus.spec.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import * as Aria from '@element-plus/utils/aria'
|
||||
|
||||
const isVisibleMock = jest
|
||||
.spyOn(Aria, 'isVisible')
|
||||
.mockImplementation(() => true)
|
||||
|
||||
import TrapFocus, {
|
||||
ITrapFocusElement,
|
||||
FOCUSABLE_CHILDREN,
|
||||
TRAP_FOCUS_HANDLER,
|
||||
} from '../trap-focus'
|
||||
|
||||
let wrapper
|
||||
const _mount = (template: string) =>
|
||||
mount(
|
||||
{
|
||||
template,
|
||||
props: {},
|
||||
},
|
||||
{
|
||||
global: {
|
||||
directives: { TrapFocus },
|
||||
},
|
||||
attachTo: document.body,
|
||||
},
|
||||
)
|
||||
|
||||
afterAll(() => {
|
||||
isVisibleMock.mockRestore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
describe('v-trap-focus', () => {
|
||||
test('should fetch all focusable element', () => {
|
||||
wrapper = _mount(`
|
||||
<div v-trap-focus>
|
||||
<button />
|
||||
</div>
|
||||
`)
|
||||
expect(
|
||||
(wrapper.element as ITrapFocusElement)[FOCUSABLE_CHILDREN].length,
|
||||
).toBe(1)
|
||||
expect(
|
||||
(wrapper.element as ITrapFocusElement)[TRAP_FOCUS_HANDLER].length,
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
test('should not fetch disabled element', () => {
|
||||
wrapper = _mount(`
|
||||
<div v-trap-focus>
|
||||
<button />
|
||||
<button disabled />
|
||||
<a href="test" />
|
||||
<a />
|
||||
<input />
|
||||
<input disabled />
|
||||
<input type="hidden" />
|
||||
<input type="file" />
|
||||
<div tabindex="-1" />
|
||||
<select />
|
||||
<select disabled />
|
||||
<textarea />
|
||||
<textarea disabled />
|
||||
</div>
|
||||
`)
|
||||
expect(
|
||||
(wrapper.element as ITrapFocusElement)[FOCUSABLE_CHILDREN].length,
|
||||
).toBe(5)
|
||||
})
|
||||
|
||||
test('should trap keyboard.tab event', async () => {
|
||||
wrapper = _mount(`
|
||||
<div v-trap-focus>
|
||||
<button class="button-1" />
|
||||
<button class="button-2" />
|
||||
<button class="button-3" />
|
||||
</div>
|
||||
`)
|
||||
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
await wrapper.find('.button-1').trigger('keydown', {
|
||||
code: 'Tab',
|
||||
shiftKey: true,
|
||||
})
|
||||
|
||||
expect(document.activeElement).toBe(wrapper.find('.button-3').element)
|
||||
await wrapper.find('.button-3').trigger('keydown', {
|
||||
code: 'Tab',
|
||||
})
|
||||
|
||||
expect(document.activeElement).toBe(wrapper.find('.button-1').element)
|
||||
// the current active element is .button-1
|
||||
await wrapper.find('.button-1').trigger('keydown', {
|
||||
code: 'Tab',
|
||||
})
|
||||
|
||||
expect(document.activeElement).toBe(wrapper.find('.button-2').element)
|
||||
|
||||
// now the active element became .button-2, this time we navigate back
|
||||
await wrapper.find('.button-2').trigger('keydown', {
|
||||
code: 'Tab',
|
||||
shiftKey: true,
|
||||
})
|
||||
expect(document.activeElement).toBe(wrapper.find('.button-1').element)
|
||||
})
|
||||
|
||||
test('should prevent tab event when there is only one element', async () => {
|
||||
wrapper = _mount(`
|
||||
<div v-trap-focus>
|
||||
<button />
|
||||
</div>
|
||||
`)
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
await wrapper.find('button').trigger('keydown', {
|
||||
code: 'Tab',
|
||||
})
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
})
|
||||
|
||||
test('should update focusable list when children changes', async () => {
|
||||
wrapper = mount(
|
||||
{
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div v-trap-focus>
|
||||
<button />
|
||||
<button v-if="show" />
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
global: {
|
||||
directives: {
|
||||
TrapFocus,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const initialElements = wrapper.element[FOCUSABLE_CHILDREN]
|
||||
expect(initialElements.length).toBe(1)
|
||||
|
||||
await wrapper.setProps({
|
||||
show: true,
|
||||
})
|
||||
|
||||
expect(wrapper.element[FOCUSABLE_CHILDREN].length).toBe(2)
|
||||
})
|
||||
})
|
58
packages/directives/trap-focus/index.ts
Normal file
58
packages/directives/trap-focus/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { on, off } from '@element-plus/utils/dom'
|
||||
import { obtainAllFocusableElements, EVENT_CODE } from '@element-plus/utils/aria'
|
||||
|
||||
import type { ObjectDirective } from 'vue'
|
||||
|
||||
export const FOCUSABLE_CHILDREN = '_trap-focus-children'
|
||||
export const TRAP_FOCUS_HANDLER = '_trap-focus-handler'
|
||||
|
||||
export interface ITrapFocusElement extends HTMLElement {
|
||||
[FOCUSABLE_CHILDREN]: HTMLElement[]
|
||||
[TRAP_FOCUS_HANDLER]: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
const TrapFocus: ObjectDirective = {
|
||||
beforeMount(el: ITrapFocusElement) {
|
||||
el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el)
|
||||
|
||||
el[TRAP_FOCUS_HANDLER] = (e: KeyboardEvent) => {
|
||||
const focusableElement = el[FOCUSABLE_CHILDREN]
|
||||
if (focusableElement.length > 0 && e.code === EVENT_CODE.tab) {
|
||||
if (focusableElement.length === 1) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const goingBackward = e.shiftKey
|
||||
const isFirst = e.target === focusableElement[0]
|
||||
const isLast = e.target === focusableElement[focusableElement.length - 1]
|
||||
if (isFirst && goingBackward) {
|
||||
e.preventDefault()
|
||||
focusableElement[focusableElement.length - 1].focus()
|
||||
}
|
||||
if (isLast && !goingBackward) {
|
||||
e.preventDefault()
|
||||
focusableElement[0].focus()
|
||||
}
|
||||
|
||||
// the is critical since jsdom did not implement user actions, you can only mock it
|
||||
// DELETE ME: when testing env switches to puppeteer
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
|
||||
const index = focusableElement.findIndex(element => element === e.target)
|
||||
if (index !== -1) {
|
||||
focusableElement[goingBackward ? index - 1 : index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
on(document, 'keydown', el[TRAP_FOCUS_HANDLER])
|
||||
},
|
||||
updated(el: ITrapFocusElement) {
|
||||
el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el)
|
||||
},
|
||||
unmounted(el: ITrapFocusElement) {
|
||||
off(document, 'keydown', el[TRAP_FOCUS_HANDLER])
|
||||
},
|
||||
}
|
||||
|
||||
export default TrapFocus
|
@ -1,3 +1,4 @@
|
||||
// since event.keyCode will be deprecated at any time refer to: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
|
||||
export const eventKeys = {
|
||||
tab: 9,
|
||||
enter: 13,
|
||||
@ -11,6 +12,38 @@ export const eventKeys = {
|
||||
delete: 46,
|
||||
}
|
||||
|
||||
// TODO: refactor all event.keyCode to event.code
|
||||
export const EVENT_CODE = {
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
space: 'Space',
|
||||
left: 'ArrowLeft',
|
||||
right: 'ArrowRight',
|
||||
top: 'ArrowTop',
|
||||
down: 'ArrowDown',
|
||||
esc: 'Esc',
|
||||
delete: 'Delete',
|
||||
backspace: 'Backspace',
|
||||
}
|
||||
|
||||
const FOCUSABLE_ELEMENT_SELECTORS =`a[href],button:not([disabled]),button:not([hidden]),:not([tabindex="-1"]),input:not([disabled]),input:not([type="hidden"]),select:not([disabled]),textarea:not([disabled])`
|
||||
|
||||
/**
|
||||
* Determine if the testing element is visible on screen no matter if its on the viewport or not
|
||||
*/
|
||||
export const isVisible = (element: HTMLElement) => {
|
||||
if (process.env.NODE_ENV === 'test') return true
|
||||
const computed = getComputedStyle(element)
|
||||
// element.offsetParent won't work on fix positioned
|
||||
// WARNING: potential issue here, going to need some expert advices on this issue
|
||||
return computed.position === 'fixed' ? false : element.offsetParent !== null
|
||||
}
|
||||
|
||||
export const obtainAllFocusableElements = (element: HTMLElement): HTMLElement[] => {
|
||||
return Array.from(element.querySelectorAll(FOCUSABLE_ELEMENT_SELECTORS)).filter(isFocusable)
|
||||
.filter(isVisible) as HTMLElement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc Determine if target element is focusable
|
||||
* @param element {HTMLElement}
|
||||
@ -60,7 +93,7 @@ export const attemptFocus = (element: HTMLElement): boolean => {
|
||||
}
|
||||
Utils.IgnoreUtilFocusChanges = true
|
||||
// Remove the old try catch block since there will be no error to be thrown
|
||||
element.focus && element.focus()
|
||||
element.focus?.()
|
||||
Utils.IgnoreUtilFocusChanges = false
|
||||
return document.activeElement === element
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user