Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 217 additions & 3 deletions packages/core/src/FocusScope/FocusScope.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { RenderResult } from '@testing-library/vue'
import type { VueWrapper } from '@vue/test-utils'
import userEvent from '@testing-library/user-event'
import { render, waitFor } from '@testing-library/vue'
import { fireEvent, render, waitFor } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { defineComponent, nextTick } from 'vue'
import { sleep } from '@/test'
import { FocusScope } from '.'
import Dialog from './story/shadowDom/_Dialog.vue'
import ShadowRootContainer from './story/shadowDom/ShadowRootContainer.vue'

const INNER_NAME_INPUT_LABEL = 'Name'
const INNER_EMAIL_INPUT_LABEL = 'Email'
Expand All @@ -21,7 +26,7 @@ const TestField = ({
`,
})

describe('focusScope', () => {
describe('focusScope (light DOM)', () => {
describe('given a default FocusScope', () => {
let rendered: RenderResult
let tabbableFirst: HTMLInputElement
Expand Down Expand Up @@ -134,3 +139,212 @@ describe('focusScope', () => {
})
})
})

describe('focusScope (shadow root)', () => {
function renderInShadowRoot(component: unknown) {
const host = document.createElement('div')
document.body.appendChild(host)
const shadow = host.attachShadow({ mode: 'open' })
const container = document.createElement('div')
shadow.appendChild(container)
const rendered = render(component, { container, baseElement: container })
return { rendered, host }
}

function getActiveElement(container: Element): Element | null {
const root = container.getRootNode()
if ((root as ShadowRoot).host)
return (root as ShadowRoot).activeElement
return null
}

it('keeps focus on input while adding or removing shadow elements during typing', async () => {
const { rendered, host } = renderInShadowRoot(defineComponent({
components: { FocusScope },
data: () => ({ value: '' }),
template: `
<FocusScope asChild loop trapped>
<div>
<label>
<span>${INNER_NAME_INPUT_LABEL}</span>
<input type="text" aria-label="${INNER_NAME_INPUT_LABEL}" v-model="value" />
</label>
<span v-if="value" data-testid="shadow-extra">extra</span>
</div>
</FocusScope>
`,
}))

try {
await nextTick()
const input = rendered.getByLabelText(INNER_NAME_INPUT_LABEL)
input.focus()
expect(getActiveElement(rendered.container)).toBe(input)
await userEvent.type(input, 'Foo')

await waitFor(() => expect(rendered.queryByTestId('shadow-extra')).not.toBeNull())
expect(getActiveElement(rendered.container)).toBe(input)
expect((getActiveElement(rendered.container) as HTMLInputElement).value).toBe('Foo')

await userEvent.keyboard('{Backspace>5}')
await waitFor(() => expect(rendered.queryByTestId('shadow-extra')).toBeNull())
expect(getActiveElement(rendered.container)).toBe(input)
}
finally {
rendered.unmount()
host?.remove()
}
})

type ShadowRootTestCase = {
description: string
testCase: 'shadowDomOnly' | 'mixedBodyAndShadowDom' | 'bodyOnly'
}

describe('shadow DOM focus loop test', () => {
const testSuite: ShadowRootTestCase[] = [
{
description: 'given a Dialog in the document body, with nested dismissable layers also in the document body',
testCase: 'bodyOnly',
},
{
description: 'given a Dialog inside a ShadowRoot, with nested dismissable layers also inside the ShadowRoot',
testCase: 'shadowDomOnly',
},
{
description: 'given a Dialog in the document body, with nested dismissable layers inside a ShadowRoot',
testCase: 'mixedBodyAndShadowDom',
},
]

testSuite.forEach(({ description, testCase }) => {
describe(description, () => {
let wrapper: VueWrapper<InstanceType<typeof ShadowRootContainer>>
let shadowHost: HTMLElement
let shadowRoot: ShadowRoot

function getDialogOverlay(): HTMLElement | null {
if (testCase === 'shadowDomOnly') {
return shadowRoot.querySelector('[data-testid="dialog-overlay"]')
}
else {
return document.body.querySelector('[data-testid="dialog-overlay"]')
}
}

function getDialogTrigger(): HTMLElement | null {
if (testCase === 'shadowDomOnly') {
return shadowRoot.querySelector('[data-testid="dialog-trigger"]')
}
else {
return document.body.querySelector('[data-testid="dialog-trigger"]')
}
}

function getDialogContent(): HTMLElement | null {
if (testCase === 'shadowDomOnly') {
return shadowRoot.querySelector('[data-testid="dialog-content"]')
}
else {
return document.body.querySelector('[data-testid="dialog-content"]')
}
}

function getQueryRoot(): Document | ShadowRoot {
if (testCase === 'bodyOnly') {
return document
}
else {
return shadowRoot
}
}

beforeEach(async () => {
document.body.innerHTML = ''
if (testCase === 'shadowDomOnly') {
wrapper = mount(ShadowRootContainer, { attachTo: document.body, props: { withDialog: true } })
await nextTick()
shadowHost = wrapper.find('#shadow-root-container').element as HTMLElement
shadowRoot = (shadowHost as unknown as { shadowRoot: ShadowRoot }).shadowRoot!
// Open the dialog
const trigger = getDialogTrigger() as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogOverlay = getDialogOverlay()
expect(dialogOverlay).toBeTruthy()
const dialogContent = getDialogContent()
expect(dialogContent).toBeTruthy()
}
else if (testCase === 'mixedBodyAndShadowDom') {
wrapper = mount(Dialog, { attachTo: document.body, props: { hasShadowRootInside: true } })
await nextTick()

// Open the dialog
const trigger = getDialogTrigger() as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogOverlay = getDialogOverlay()
expect(dialogOverlay).toBeTruthy()
const dialogContent = getDialogContent()
expect(dialogContent).toBeTruthy()

shadowHost = dialogContent?.querySelector('#shadow-root-container') as HTMLElement
shadowRoot = (shadowHost as unknown as { shadowRoot: ShadowRoot }).shadowRoot!
}
else {
wrapper = mount(Dialog, { attachTo: document.body })
await nextTick()

// Open the dialog
const trigger = getDialogTrigger() as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogOverlay = getDialogOverlay()
expect(dialogOverlay).toBeTruthy()
const dialogContent = getDialogContent()
expect(dialogContent).toBeTruthy()
}
})

afterEach(async () => {
await wrapper.unmount()
await nextTick()
})

if (testCase !== 'bodyOnly') {
it('shadowRoot should be defined', () => {
expect(shadowRoot).toBeDefined()
})
}

it('should loop focus within the FocusScope in the ShadowRoot', async () => {
const queryRoot = getQueryRoot()
const nameInput = queryRoot.querySelector(`input[name="name"]`) as HTMLElement
const emailInput = queryRoot.querySelector(`input[name="email"]`) as HTMLElement
const submitButton = queryRoot.querySelector('button[type="submit"]') as HTMLElement
const closeDialogButton = testCase === 'shadowDomOnly'
? shadowRoot.querySelector('[data-testid="dialog-close"]') as HTMLElement
: document.querySelector('[data-testid="dialog-close"]') as HTMLElement

// Focus the first input
nameInput.focus()
await waitFor(() => expect(queryRoot.activeElement).toBe(nameInput))

await userEvent.tab()
await waitFor(() => expect(queryRoot.activeElement).toBe(emailInput))
await userEvent.tab()
await waitFor(() => expect(queryRoot.activeElement).toBe(submitButton))
await userEvent.tab()
await waitFor(() => expect(testCase === 'shadowDomOnly' ? shadowRoot.activeElement : document.activeElement).toBe(closeDialogButton))
// Tab again should loop back to the first focusable element
await userEvent.tab()
await waitFor(() => expect(queryRoot.activeElement).toBe(nameInput))

// Reverse tab should go to the last focusable element
await userEvent.tab({ shift: true })
await waitFor(() => expect(testCase === 'shadowDomOnly' ? shadowRoot.activeElement : document.activeElement).toBe(closeDialogButton))
})
})
})
})
})
60 changes: 54 additions & 6 deletions packages/core/src/FocusScope/FocusScope.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,25 @@ const focusScope = reactive({
},
})

function getEventRoot(container: HTMLElement | null): Document | ShadowRoot {
const rootNode = container?.getRootNode()
if (rootNode instanceof ShadowRoot)
return rootNode
return document
}

watchEffect((cleanupFn) => {
if (!isClient)
return
const container = currentElement.value
const root = getEventRoot(container)
if (!props.trapped)
return

function handleFocusIn(event: FocusEvent) {
function handleFocusIn(event: Event) {
if (!(event instanceof FocusEvent))
return

if (focusScope.paused || !container)
return
const target = event.target as HTMLElement | null
Expand All @@ -84,7 +95,10 @@ watchEffect((cleanupFn) => {
else focus(lastFocusedElementRef.value, { select: true })
}

function handleFocusOut(event: FocusEvent) {
function handleFocusOut(event: Event) {
if (!(event instanceof FocusEvent))
return

if (focusScope.paused || !container)
return
const relatedTarget = event.relatedTarget as HTMLElement | null
Expand Down Expand Up @@ -122,15 +136,17 @@ watchEffect((cleanupFn) => {
focus(container)
}

document.addEventListener('focusin', handleFocusIn)
document.addEventListener('focusout', handleFocusOut)
root.addEventListener('focusin', handleFocusIn)
root.addEventListener('focusout', handleFocusOut)

const mutationObserver = new MutationObserver(handleMutations)
if (container)
mutationObserver.observe(container, { childList: true, subtree: true })

cleanupFn(() => {
document.removeEventListener('focusin', handleFocusIn)
document.removeEventListener('focusout', handleFocusOut)
root.removeEventListener('focusin', handleFocusIn)
root.removeEventListener('focusout', handleFocusOut)

mutationObserver.disconnect()
})
})
Expand Down Expand Up @@ -214,6 +230,38 @@ function handleKeyDown(event: KeyboardEvent) {
if (props.loop)
focus(last, { select: true })
}
else {
const focusedRoot = focusedElement.getRootNode()
const containerRoot = container.getRootNode()
const isInShadowDOM = focusedRoot instanceof ShadowRoot || containerRoot instanceof ShadowRoot

if (!isInShadowDOM)
return

event.preventDefault()
const allTabbable = getTabbableCandidates(container)

if (allTabbable.length === 0)
return

const currentIndex = allTabbable.indexOf(focusedElement)

if (currentIndex === -1) {
if (event.shiftKey)
focus(last, { select: true })
else
focus(first, { select: true })
return
}

const nextIndex = event.shiftKey
? currentIndex - 1
: currentIndex + 1
const nextElement = allTabbable[nextIndex]
if (nextElement) {
focus(nextElement, { select: true })
}
}
}
}
}
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script setup lang="ts">
import Dialog from './shadowDom/_Dialog.vue'
import ShadowRootContainer from './shadowDom/ShadowRootContainer.vue'
</script>

<template>
<Story
group="utilities"
title="FocusScope/ShadowRoot"
:layout="{ type: 'single' }"
>
<div class="text-center flex-col gap-8 flex p-2">
<div>
<span>Dialog in body and focusable elements in shadow root</span>
<Dialog :has-shadow-root-inside="true" />
</div>
<div>
<span>Dialog and focusable elements in shadow root</span>
<ShadowRootContainer with-dialog />
</div>
<div>
<span>Dialog and focusable elements in body</span>
<Dialog />
</div>
</div>
</Story>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<div class="flex gap-2">
<input
name="name"
placeholder="Name"
>
<input
name="email"
placeholder="Email"
>
<button type="submit">
Submit
</button>
</div>
</template>
Loading
Loading