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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(modal): trapInEl moved to app context #4165

Merged
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
24 changes: 22 additions & 2 deletions packages/ui/.storybook/interaction-utils/userEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,25 @@ export const userEvent = {
await sleep(mergedOptions.delay)
},

clear: async (element: Element) => event.clear(element)
}
clear: async (element: Element) => event.clear(element),

focus: async (element: Element, options?: { delay?: number }) => {
if ('focus' in element) {
(element as HTMLElement).focus()
}

const defaultOptions = { delay: 0 }
const mergedOptions = { ...defaultOptions, ...options }

await sleep(mergedOptions.delay)
},

tab: async (options?: { delay?: number, shift?: boolean }) => {
const defaultOptions = { delay: 0 }
const mergedOptions = { ...defaultOptions, ...options }

event.tab(mergedOptions)
// waiting for DOM changes
await sleep(mergedOptions.delay)
}
}
44 changes: 37 additions & 7 deletions packages/ui/src/components/va-modal/VaModal.stories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import VaModal from './VaModal.vue'
import VaModalDemos from './VaModal.demo.vue'
import { StoryFn } from '@storybook/vue3'
import { expect } from '@storybook/jest'
import { userEvent } from '../../../.storybook/interaction-utils/userEvent'

export default {
title: 'VaModal',
Expand All @@ -27,16 +29,44 @@ export const CloseButton: StoryFn = () => ({
export const focusTrap: StoryFn = () => ({
components: { VaModal },

mounted () {
this.$refs.btn.focus()
data () {
return {
show: false,
}
},

template: `
<button @v-node:mount="$refs.btn.focus()" ref="btn">Focused button</button>
<VaModal :model-value="true">
<input style="outline: 2px solid grey" />
<input style="outline: 2px solid grey" />
<input style="outline: 2px solid grey" />
<button @click="show = true">Focused button</button>
<VaModal v-model="show">
<input data-testid="input1" style="outline: 2px solid grey" />
<input data-testid="input2" style="outline: 2px solid grey" />
<input data-testid="input3" style="outline: 2px solid grey" />
</VaModal>
`,
})

focusTrap.play = async ({ canvasElement, step }) => {
const showModalButton = canvasElement.querySelector('button')

await userEvent.click(showModalButton!)

const [input1, input2, input3] = Array.from(canvasElement.ownerDocument.querySelectorAll('input'))

await step('Focus in modal after opened', async () => {
const modal = canvasElement.ownerDocument.querySelector('.va-modal')

await userEvent.tab()

expect(modal?.contains(document.activeElement)).toBe(true)
})

await step('Item in input can be focused', async () => {
await userEvent.focus(input2)

expect(document.activeElement).toBe(input2)
})

// Notice we're not able to prevent programmatic focus from being out of focus trap
// so we don't test it here, because userEvent.tab() will focus the next focusable element
// programaticaly, not emulating user behavior
}
39 changes: 39 additions & 0 deletions packages/ui/src/composables/useAppGlobal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { WritableComputedRef, computed, getCurrentInstance, reactive } from 'vue'

const getGlobalObject = () => {
const vm = getCurrentInstance()

const app = vm?.appContext.app

const { globalProperties } = app!.config

if ('$vaGlobalVariable' in globalProperties) {
return globalProperties.$vaGlobalVariable
}

globalProperties.$vaGlobalVariable = reactive({})

return globalProperties.$vaGlobalVariable
}

/**
* This composable must be used to make global variables. This global is shared in app context, rather then
* in window context. This is useful to avoid global variables in window context in multiple app mode, ssr
* or cjs build can mess up global variables
*/
export const useAppGlobal = <T>(key: string, defaultValue: T): WritableComputedRef<T> => {
const globalObject = getGlobalObject()

if (key in globalObject) {
return globalObject[key]
}

globalObject[key] = defaultValue

return computed({
get: () => globalObject[key],
set: (value: T) => {
globalObject[key] = value
},
})
}
17 changes: 7 additions & 10 deletions packages/ui/src/composables/useTrapFocus.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { useAppGlobal } from './useAppGlobal'
import { useDocument } from './useDocument'
import { useWindow } from './useWindow'

const TAB_KEYCODE = 9
const FOCUSABLE_ELEMENTS_SELECTOR = ':where(a, button, input, textarea, select):not([disabled]), *[tabindex]'
let trapInEl: HTMLElement | null = null

export const useTrapFocus = () => {
const document = useDocument()
const window = useWindow()

const trapInEl = useAppGlobal<null | HTMLElement>('trapInEl', null)

let focusableElements: HTMLElement[] = []
let firstFocusableElement: HTMLElement | null = null
let lastFocusableElement: HTMLElement | null = null
let isFocusTrapped = false

const isFocusIn = (evt: Event) => {
return trapInEl?.contains(evt.target as Node) || false
return trapInEl.value?.contains(evt.target as Node) || false
}

const focusFirstElement = () => {
Expand All @@ -34,8 +34,6 @@ export const useTrapFocus = () => {
}

if (!isFocusIn(evt)) {
isFocusTrapped = true

evt.preventDefault()
isShiftPressed ? focusLastElement() : focusFirstElement()

Expand All @@ -55,18 +53,18 @@ export const useTrapFocus = () => {
}

const trapFocusIn = (el: HTMLElement) => {
trapInEl = el
trapInEl.value = el

freeFocus()
trapFocus()
}

const trapFocus = () => {
if (!trapInEl) {
if (!trapInEl.value) {
return
}

focusableElements = Array.from(trapInEl.querySelectorAll(FOCUSABLE_ELEMENTS_SELECTOR))
focusableElements = Array.from(trapInEl.value.querySelectorAll(FOCUSABLE_ELEMENTS_SELECTOR))
firstFocusableElement = focusableElements[0]
lastFocusableElement = focusableElements[focusableElements.length - 1]

Expand All @@ -76,7 +74,6 @@ export const useTrapFocus = () => {
focusableElements = []
firstFocusableElement = null
lastFocusableElement = null
isFocusTrapped = false

window.value?.removeEventListener('keydown', onKeydown)
}
Expand Down