Skip to content

Commit 6658abf

Browse files
feat(components): [FocusGuard] implements
1 parent 505078e commit 6658abf

File tree

13 files changed

+769
-1
lines changed

13 files changed

+769
-1
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Enter the component you want most in the components, leave the emojis and follow
6767
| ----------------------------------------------------------------------------------------------------- | ------ |
6868
| [Collection](https://vue-primitives.netlify.app/?path=/story/utilities-rovingfocusgroup--basic) ||
6969
| DismissableLayer | 🚧 |
70-
| FocusScope | 🚧 |
70+
| [FocusScope](https://vue-primitives.netlify.app/?path=/story/utilities-focusscope--basic) | |
7171
| Menu | 🚧 |
7272
| Popper | 🚧 |
7373
| Portal | 🚧 |
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { onBeforeUnmount, onMounted } from 'vue'
2+
3+
/** Number of components which have requested interest to have focus guards */
4+
let count = 0
5+
6+
export function useFocusGuards() {
7+
onMounted(() => {
8+
const edgeGuards = document.querySelectorAll('[data-radix-focus-guard]')
9+
document.body.insertAdjacentElement('afterbegin', edgeGuards[0] ?? createFocusGuard())
10+
document.body.insertAdjacentElement('beforeend', edgeGuards[1] ?? createFocusGuard())
11+
count++
12+
})
13+
14+
onBeforeUnmount(() => {
15+
if (count === 1) {
16+
document.querySelectorAll('[data-radix-focus-guard]').forEach(node => node.remove())
17+
}
18+
count--
19+
})
20+
}
21+
22+
function createFocusGuard() {
23+
const element = document.createElement('span')
24+
element.setAttribute('data-radix-focus-guard', '')
25+
element.tabIndex = 0
26+
element.style.cssText = 'outline: none; opacity: 0; position: fixed; pointer-events: none'
27+
return element
28+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script setup lang="ts">
2+
import { useFocusGuards } from './FocusGuards.ts'
3+
4+
useFocusGuards()
5+
</script>
6+
7+
<template>
8+
<slot />
9+
</template>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { useFocusGuards } from './FocusGuards.ts'
2+
export { default as FocusGuards } from './FocusGuards.vue'
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import { FocusScope } from '../../focus-scope/index.ts'
4+
5+
const trapped = shallowRef(false)
6+
const hasDestroyButton = shallowRef(true)
7+
</script>
8+
9+
<template>
10+
<div>
11+
<div>
12+
<button
13+
type="button" @click="() => {
14+
trapped = true
15+
}"
16+
>
17+
Trap
18+
</button>{{ ' ' }}
19+
<input> <input>
20+
</div>
21+
22+
<FocusScope v-if="trapped" as-child :loop="trapped" :trapped="trapped">
23+
<form
24+
:style="{
25+
display: 'inline-flex',
26+
flexDirection: 'column',
27+
gap: '20px',
28+
padding: '20px',
29+
margin: '50px',
30+
maxWidth: '500px',
31+
border: '2px solid',
32+
}"
33+
>
34+
<input type="text" placeholder="First name">
35+
<input type="text" placeholder="Last name">
36+
<input type="number" placeholder="Age">
37+
<div v-if="hasDestroyButton">
38+
<button
39+
type="button" @click="() => {
40+
hasDestroyButton = false
41+
}"
42+
>
43+
Destroy me
44+
</button>
45+
</div>
46+
<button
47+
type="button" @click="() => {
48+
trapped = false
49+
}"
50+
>
51+
Close
52+
</button>
53+
</form>
54+
</FocusScope>
55+
<div>
56+
<input> <input>
57+
</div>
58+
</div>
59+
</template>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import CBasic from './Basic.vue'
2+
import CMultiple from './Multiple.vue'
3+
import CWithOptions from './WithOptions.vue'
4+
5+
export default { title: 'Utilities/FocusScope' }
6+
7+
export function Basic() {
8+
return CBasic
9+
}
10+
11+
export function Multiple() {
12+
return CMultiple
13+
}
14+
15+
export function WithOptions() {
16+
return CWithOptions
17+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import { FocusScope } from '../../focus-scope/index.ts'
4+
5+
const trapped1 = shallowRef(false)
6+
const trapped2 = shallowRef(false)
7+
</script>
8+
9+
<template>
10+
<div :style="{ display: 'inline-flex', flexDirection: 'column', gap: '10px' }">
11+
<div>
12+
<button
13+
type="button" @click="() => {
14+
trapped1 = true
15+
}"
16+
>
17+
Trap 1
18+
</button>
19+
</div>
20+
<FocusScope v-if="trapped1" as-child :loop="trapped1" :trapped="trapped1">
21+
<form
22+
:style="{
23+
display: 'inline-flex',
24+
flexDirection: 'column',
25+
gap: '20px',
26+
padding: '20px',
27+
maxWidth: '500px',
28+
border: '2px solid',
29+
}"
30+
>
31+
<h1>One</h1>
32+
<input type="text" placeholder="First name">
33+
<input type="text" placeholder="Last name">
34+
<input type="number" placeholder="Age">
35+
<button
36+
type="button" @click="() => {
37+
trapped1 = false
38+
}"
39+
>
40+
Close
41+
</button>
42+
</form>
43+
</FocusScope>
44+
45+
<div>
46+
<button
47+
type="button" @click="() => {
48+
trapped2 = true
49+
}"
50+
>
51+
Trap 2
52+
</button>
53+
</div>
54+
55+
<FocusScope v-if="trapped2" as-child :loop="trapped2" :trapped="trapped2">
56+
<form
57+
:style="{
58+
display: 'inline-flex',
59+
flexDirection: 'column',
60+
gap: '20px',
61+
padding: '20px',
62+
maxWidth: '500px',
63+
border: '2px solid',
64+
}"
65+
>
66+
<h1>Two</h1>
67+
<input type="text" placeholder="First name">
68+
<input type="text" placeholder="Last name">
69+
<input type="number" placeholder="Age">
70+
<button
71+
type="button" @click="() => {
72+
trapped2 = false
73+
}"
74+
>
75+
Close
76+
</button>
77+
</form>
78+
</FocusScope>
79+
<div>
80+
<input>
81+
</div>
82+
</div>
83+
</template>
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import { FocusScope } from '../../focus-scope/index.ts'
4+
5+
const open = shallowRef(false)
6+
const isEmptyForm = shallowRef(false)
7+
8+
const trapFocus = shallowRef(false)
9+
const focusOnMount = shallowRef<boolean | 'age'>(false)
10+
const focusOnUnmount = shallowRef<boolean | 'next'>(false)
11+
12+
const ageFieldRef = shallowRef<HTMLInputElement | null>(null)
13+
const nextButtonRef = shallowRef<HTMLButtonElement | null>(null)
14+
</script>
15+
16+
<template>
17+
<div :style="{ fontFamily: 'sans-serif', textAlign: 'center' }">
18+
<h1>FocusScope</h1>
19+
20+
<div :style="{ display: 'inline-block', textAlign: 'left', marginBottom: '20px' }">
21+
<label :style="{ display: 'block' }">
22+
<input
23+
type="checkbox"
24+
:checked="trapFocus"
25+
@change="(event: any) => {
26+
trapFocus = event.target.checked
27+
}"
28+
>{{ ' ' }}
29+
Trap focus?
30+
</label>
31+
<label :style="{ display: 'block' }">
32+
<input
33+
type="checkbox"
34+
:checked="focusOnMount !== false"
35+
@change="(event: any) => {
36+
focusOnMount = event.target.checked
37+
if (event.target.checked === false) {
38+
isEmptyForm = false
39+
}
40+
}"
41+
>
42+
{{ ' ' }}
43+
Focus on mount?
44+
</label>
45+
<label v-if="focusOnMount !== false && !isEmptyForm" :style="{ display: 'block', marginLeft: '20px' }">
46+
<input
47+
type="checkbox"
48+
:checked="focusOnMount !== true"
49+
@change="(event: any) => {
50+
focusOnMount = event.target.checked ? 'age' : true
51+
}"
52+
>
53+
{{ ' ' }}
54+
on "age" field?
55+
</label>
56+
<label v-if="focusOnMount !== false" :style="{ display: 'block', marginLeft: '20px' }">
57+
<input
58+
type="checkbox"
59+
:checked="isEmptyForm"
60+
@change="(event: any) => {
61+
isEmptyForm = event.target.checked
62+
focusOnMount = true
63+
}"
64+
>{{ ' ' }}
65+
empty form?
66+
</label>
67+
<label :style="{ display: 'block' }">
68+
<input
69+
type="checkbox"
70+
:checked="focusOnUnmount !== false"
71+
@change="(event: any) => {
72+
focusOnUnmount = event.target.checked
73+
}"
74+
>
75+
{{ ' ' }}
76+
Focus on unmount?
77+
</label>
78+
<label v-if="focusOnUnmount !== false" :style="{ display: 'block', marginLeft: '20px' }">
79+
<input
80+
type="checkbox"
81+
:checked="focusOnUnmount !== true"
82+
@change="(event: any) => {
83+
focusOnUnmount = event.target.checked ? 'next' : true
84+
}"
85+
>{{ ' ' }}
86+
on "next" button?
87+
</label>
88+
</div>
89+
90+
<div :style="{ marginBottom: 20 }">
91+
<button
92+
type="button"
93+
@click="() => {
94+
open = !open
95+
}"
96+
>
97+
{{ open ? 'Close' : 'Open' }} form in between buttons
98+
</button>
99+
</div>
100+
101+
<button type="button" :style="{ marginRight: '10px' }">
102+
previous
103+
</button>
104+
105+
<FocusScope
106+
v-if="open"
107+
key="form"
108+
as-child
109+
:loop="trapFocus"
110+
:trapped="trapFocus"
111+
@mount-auto-focus="(event) => {
112+
if (focusOnMount !== true) {
113+
event.preventDefault();
114+
if (focusOnMount === 'age')
115+
ageFieldRef?.focus();
116+
}
117+
}"
118+
@unmount-auto-focus="(event) => {
119+
if (focusOnUnmount !== true) {
120+
event.preventDefault();
121+
if (focusOnUnmount === 'next')
122+
nextButtonRef?.focus();
123+
}
124+
}"
125+
>
126+
<form
127+
:style="{
128+
display: 'inline-flex',
129+
flexDirection: 'column',
130+
gap: '20px',
131+
padding: '20px',
132+
margin: '50px',
133+
maxWidth: '500px',
134+
border: '2px solid',
135+
}"
136+
>
137+
<template v-if="!isEmptyForm">
138+
<input type="text" placeholder="First name">
139+
<input type="text" placeholder="Last name">
140+
<input ref="ageFieldRef" type="number" placeholder="Age">
141+
<button
142+
type="button" @click="() => {
143+
open = false
144+
}"
145+
>
146+
Close
147+
</button>
148+
</template>
149+
</form>
150+
</FocusScope>
151+
152+
<button ref="nextButtonRef" type="button" :style="{ marginLeft: '10px' }">
153+
next
154+
</button>
155+
</div>
156+
</template>

0 commit comments

Comments
 (0)