Skip to content

Commit

Permalink
fix(modal): trapInEl moved to app context (#4165)
Browse files Browse the repository at this point in the history
* fix(modal): trapInEl moved to app context

* chore(modal): improve focusTrap story

* fix(modal): make app global reactive

* chore: document useAppGlobal
  • Loading branch information
m0ksem committed Mar 6, 2024
1 parent 34da4e4 commit c88c7a9
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 19 deletions.
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

0 comments on commit c88c7a9

Please sign in to comment.