Skip to content
This repository was archived by the owner on Sep 20, 2024. It is now read-only.

Commit 92985fe

Browse files
committed
fix: re-write focus lock with simpler promitives
1 parent 989eff9 commit 92985fe

File tree

17 files changed

+622
-107
lines changed

17 files changed

+622
-107
lines changed

packages/c-focus-lock/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./c-focus-lock"
22
export * from "./use-focus-lock"
3+
export * from "./use-focus-trap"
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {
2+
onUnmounted,
3+
onUpdated,
4+
ref,
5+
watchEffect,
6+
7+
// Types
8+
Ref,
9+
ComputedRef,
10+
} from "vue"
11+
12+
import {
13+
contains,
14+
Focus,
15+
focusElement,
16+
focusIn,
17+
FocusResult,
18+
Keys,
19+
} from "@chakra-ui/vue-utils"
20+
import { useWindowEvent } from "@chakra-ui/vue-composables"
21+
22+
export function useFocusTrap(
23+
containers: Ref<Set<HTMLElement>> | ComputedRef<Set<HTMLElement>>,
24+
enabled: Ref<boolean> | ComputedRef<boolean> = ref(true),
25+
options:
26+
| Ref<{ initialFocus?: HTMLElement | null }>
27+
| ComputedRef<{ initialFocus?: HTMLElement | null }> = ref({})
28+
) {
29+
let restoreElement = ref<HTMLElement | null>(
30+
typeof window !== "undefined"
31+
? (document.activeElement as HTMLElement)
32+
: null
33+
)
34+
let previousActiveElement = ref<HTMLElement | null>(null)
35+
36+
function handleFocus() {
37+
if (!enabled.value) return
38+
if (containers.value.size !== 1) return
39+
let { initialFocus } = options.value
40+
41+
let activeElement = document.activeElement as HTMLElement
42+
43+
if (initialFocus) {
44+
if (initialFocus === activeElement) {
45+
return // Initial focus ref is already the active element
46+
}
47+
} else if (contains(containers.value, activeElement)) {
48+
return // Already focused within Dialog
49+
}
50+
51+
restoreElement.value = activeElement
52+
53+
// Try to focus the initialFocus ref
54+
if (initialFocus) {
55+
focusElement(initialFocus)
56+
} else {
57+
let couldFocus = false
58+
for (let container of containers.value) {
59+
let result = focusIn(container, Focus.First)
60+
if (result === FocusResult.Success) {
61+
couldFocus = true
62+
break
63+
}
64+
}
65+
66+
if (!couldFocus)
67+
console.warn("There are no focusable elements inside the <FocusTrap />")
68+
}
69+
70+
previousActiveElement.value = document.activeElement as HTMLElement
71+
}
72+
73+
// Restore when `enabled` becomes false
74+
function restore() {
75+
focusElement(restoreElement.value)
76+
restoreElement.value = null
77+
previousActiveElement.value = null
78+
}
79+
80+
// Handle initial focus
81+
watchEffect(handleFocus)
82+
83+
onUpdated(() => {
84+
enabled.value ? handleFocus() : restore()
85+
})
86+
onUnmounted(restore)
87+
88+
// Handle Tab & Shift+Tab keyboard events
89+
useWindowEvent("keydown", (event) => {
90+
if (!enabled.value) return
91+
if (event.key !== Keys.Tab) return
92+
if (!document.activeElement) return
93+
if (containers.value.size !== 1) return
94+
95+
event.preventDefault()
96+
97+
for (let element of containers.value) {
98+
let result = focusIn(
99+
element,
100+
(event.shiftKey ? Focus.Previous : Focus.Next) | Focus.WrapAround
101+
)
102+
103+
if (result === FocusResult.Success) {
104+
previousActiveElement.value = document.activeElement as HTMLElement
105+
break
106+
}
107+
}
108+
})
109+
110+
// Prevent programmatically escaping
111+
useWindowEvent(
112+
"focus",
113+
(event) => {
114+
if (!enabled.value) return
115+
if (containers.value.size !== 1) return
116+
117+
let previous = previousActiveElement.value
118+
if (!previous) return
119+
120+
let toElement = event.target as HTMLElement | null
121+
122+
if (toElement && toElement instanceof HTMLElement) {
123+
if (!contains(containers.value, toElement)) {
124+
event.preventDefault()
125+
event.stopPropagation()
126+
focusElement(previous)
127+
} else {
128+
previousActiveElement.value = toElement
129+
focusElement(toElement)
130+
}
131+
} else {
132+
focusElement(previousActiveElement.value)
133+
}
134+
},
135+
true
136+
)
137+
}

packages/c-modal/examples/modal-simple.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
>Other button</c-button
1414
>
1515
<!-- eslint-disable-next-line -->
16-
<c-modal v-model="isOpen" :initial-focus-ref="() => $refs.initialFocus" :blockScrollOnMount="false" :final-focus-ref="() => $refs.finalFocus">
16+
<c-modal v-model="isOpen" :initial-focus-ref="() => $refs.initialFocus" :final-focus-ref="() => $refs.finalFocus">
1717
<c-modal-overlay />
1818
<c-modal-content>
1919
<c-modal-header>Modal header</c-modal-header>
@@ -38,7 +38,7 @@
3838
</template>
3939

4040
<script setup lang="ts">
41-
import { ref } from 'vue'
41+
import { ref } from "vue"
4242
const isOpen = ref(false)
4343
const finalFocus = ref()
4444
const initialFocus = ref()

packages/c-modal/src/c-drawer.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import {
77
unref,
88
withDirectives,
99
watch,
10-
ref,
11-
Ref,
1210
watchEffect,
1311
} from 'vue'
1412
import {
@@ -127,6 +125,7 @@ export const CDrawerContent: ComponentWithProps<
127125
dialogContainerProps: rawDialogContainerProps,
128126
dialogProps: rawDialogProps,
129127
modelValue,
128+
blockScrollOnMount
130129
} = unref(useModalContext())
131130
const transitionId = useId('drawer-transition')
132131

@@ -157,6 +156,27 @@ export const CDrawerContent: ComponentWithProps<
157156
...styles.value.dialog,
158157
}))
159158

159+
// Scroll lock
160+
watchEffect((onInvalidate: VoidFunction) => {
161+
if (!blockScrollOnMount.value) return
162+
if (modelValue.value !== true) return
163+
164+
let overflow = document.documentElement.style.overflow
165+
let paddingRight = document.documentElement.style.paddingRight
166+
167+
let scrollbarWidth =
168+
window.innerWidth - document.documentElement.clientWidth
169+
170+
document.documentElement.style.overflow = "hidden"
171+
document.documentElement.style.paddingRight = `${scrollbarWidth}px`
172+
173+
onInvalidate(() => {
174+
document.documentElement.style.overflow = overflow
175+
document.documentElement.style.paddingRight = paddingRight
176+
console.log("invalidating", document.documentElement.style.overflow)
177+
})
178+
})
179+
160180
/** Handles exit transition */
161181
const leave = (done: VoidFunction) => {
162182
const motions = useMotions()

packages/c-modal/src/c-modal.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
watch,
2323
unref,
2424
withDirectives,
25-
Component,
25+
watchEffect,
2626
onErrorCaptured,
2727
Ref,
2828
} from "vue"
@@ -46,7 +46,7 @@ import { useModal, UseModalOptions, UseModalReturn } from "./use-modal"
4646
import { DialogMotionPreset, dialogMotionPresets } from "./modal-transitions"
4747
import { Dict } from "@chakra-ui/utils"
4848
import { useId } from "@chakra-ui/vue-composables"
49-
import { CPortalProps } from "@chakra-ui/c-portal/dist/types/portal"
49+
import { CPortalProps } from "@chakra-ui/c-portal"
5050

5151
type ScrollBehavior = "inside" | "outside"
5252

@@ -163,15 +163,13 @@ interface CModalContext extends IUseModalOptions, UseModalReturn {
163163

164164
type CModalReactiveContext = ComputedRef<CModalContext>
165165

166-
const [
167-
ModalContextProvider,
168-
useModalContext,
169-
] = createContext<CModalReactiveContext>({
170-
strict: true,
171-
name: "ModalContext",
172-
errorMessage:
173-
"useModalContext: `context` is undefined. Seems you forgot to wrap modal components in `<CModal />`",
174-
})
166+
const [ModalContextProvider, useModalContext] =
167+
createContext<CModalReactiveContext>({
168+
strict: true,
169+
name: "ModalContext",
170+
errorMessage:
171+
"useModalContext: `context` is undefined. Seems you forgot to wrap modal components in `<CModal />`",
172+
})
175173

176174
export { ModalContextProvider, useModalContext }
177175

@@ -298,12 +296,8 @@ export const CModalContent: ComponentWithProps<
298296
inheritAttrs: false,
299297
emits: ["click", "mousedown", "keydown"],
300298
setup(_, { attrs, slots, emit }) {
301-
const {
302-
dialogContainerProps,
303-
dialogProps,
304-
modelValue,
305-
motionPreset,
306-
} = unref(useModalContext())
299+
const { dialogContainerProps, dialogProps, blockScrollOnMount, modelValue, motionPreset } =
300+
unref(useModalContext())
307301
const styles = useStyles()
308302
const transitionId = useId("modal-content")
309303

@@ -322,6 +316,27 @@ export const CModalContent: ComponentWithProps<
322316
}
323317
})
324318

319+
// Scroll lock
320+
watchEffect((onInvalidate: VoidFunction) => {
321+
if (!blockScrollOnMount.value) return
322+
if (modelValue.value !== true) return
323+
324+
let overflow = document.documentElement.style.overflow
325+
let paddingRight = document.documentElement.style.paddingRight
326+
327+
let scrollbarWidth =
328+
window.innerWidth - document.documentElement.clientWidth
329+
330+
document.documentElement.style.overflow = "hidden"
331+
document.documentElement.style.paddingRight = `${scrollbarWidth}px`
332+
333+
onInvalidate(() => {
334+
document.documentElement.style.overflow = overflow
335+
document.documentElement.style.paddingRight = paddingRight
336+
console.log("invalidating", document.documentElement.style.overflow)
337+
})
338+
})
339+
325340
const dialogContainerStyles = computed<SystemStyleObject>(() => ({
326341
display: "flex",
327342
width: "100vw",

0 commit comments

Comments
 (0)