Skip to content

Commit

Permalink
feat(useActivatedFocusTrap): set better fallback options fixes #2095 (
Browse files Browse the repository at this point in the history
  • Loading branch information
VividLemon authored Aug 9, 2024
1 parent 427d7c7 commit 873e716
Show file tree
Hide file tree
Showing 3 changed files with 80 additions and 10 deletions.
21 changes: 19 additions & 2 deletions packages/bootstrap-vue-next/src/components/BModal/BModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
v-bind="$attrs"
:style="computedZIndex"
>
<div class="modal-dialog" :class="modalDialogClasses" tabindex="0">
<div class="modal-dialog" :class="modalDialogClasses">
<div v-if="lazyShowing" class="modal-content" :class="props.contentClass">
<div v-if="!props.hideHeader" class="modal-header" :class="headerClasses">
<slot name="header" v-bind="sharedSlots">
Expand Down Expand Up @@ -94,6 +94,13 @@
<slot v-if="!props.hideBackdrop" name="backdrop">
<div class="modal-backdrop fade show" @click="hideFn('backdrop')" />
</slot>
<div
v-if="needsFallback"
ref="fallbackFocusElement"
:class="fallbackClassSelector"
tabindex="0"
style="width: 0; height: 0; overflow: hidden"
/>
</div>
</Transition>
</Teleport>
Expand Down Expand Up @@ -233,13 +240,23 @@ const computedId = useId(() => props.id, 'modal')
const modelValue = defineModel<boolean>({default: false})
const element = ref<HTMLElement | null>(null)
const fallbackFocusElement = ref<HTMLElement | null>(null)
const okButton = ref<HTMLElement | null>(null)
const cancelButton = ref<HTMLElement | null>(null)
const closeButton = ref<HTMLElement | null>(null)
const isActive = ref(false)
const lazyLoadCompleted = ref(false)
useActivatedFocusTrap({element, isActive, noTrap: () => props.noTrap})
const fallbackClassSelector = 'modal-fallback-focus'
const {needsFallback} = useActivatedFocusTrap({
element,
isActive,
noTrap: () => props.noTrap,
fallbackFocus: {
ref: fallbackFocusElement,
classSelector: fallbackClassSelector,
},
})
const fadeTransitionProps = useFadeTransition(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,20 @@
</template>
</slot>
</div>
<div
class="offcanvas-body"
tabindex="0"
:class="props.bodyClass"
v-bind="props.bodyAttrs"
>
<div class="offcanvas-body" :class="props.bodyClass" v-bind="props.bodyAttrs">
<slot v-bind="sharedSlots" />
</div>
<div v-if="hasFooterSlot" :class="props.footerClass">
<slot name="footer" v-bind="sharedSlots" />
</div>
</template>
<div
v-if="needsFallback"
ref="fallbackFocusElement"
:class="fallbackClassSelector"
tabindex="0"
style="width: 0; height: 0; overflow: hidden"
/>
</div>
</Transition>
<slot v-if="showBackdrop" name="backdrop">
Expand Down Expand Up @@ -164,6 +166,7 @@ const isOpenByBreakpoint = computed(
useSafeScrollLock(modelValue, () => props.bodyScrolling || isOpenByBreakpoint.value)
const element = ref<HTMLElement | null>(null)
const fallbackFocusElement = ref<HTMLElement | null>(null)
onKeyStroke(
'Escape',
Expand All @@ -179,10 +182,15 @@ const {focused} = useFocus(element, {
const isActive = ref(modelValue.value)
useActivatedFocusTrap({
const fallbackClassSelector = 'offcanvas-fallback-focus'
const {needsFallback} = useActivatedFocusTrap({
element,
isActive,
noTrap: () => props.noTrap || isOpenByBreakpoint.value,
fallbackFocus: {
classSelector: fallbackClassSelector,
ref: fallbackFocusElement,
},
})
const lazyLoadCompleted = ref(false)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,64 @@
import {type MaybeRefOrGetter, nextTick, readonly, type Ref, toRef, watch} from 'vue'
import {
type MaybeRefOrGetter,
nextTick,
onMounted,
readonly,
ref,
type Ref,
toRef,
watch,
} from 'vue'
import {useFocusTrap, type UseFocusTrapOptions} from '@vueuse/integrations/useFocusTrap'
import {useMutationObserver} from '@vueuse/core'

export const useActivatedFocusTrap = (
{
element,
isActive,
noTrap,
fallbackFocus,
}: {
element: Ref<HTMLElement | null>
isActive: MaybeRefOrGetter<boolean>
noTrap: MaybeRefOrGetter<boolean>
/**
* We need this in the case when there are no focusable elements in the trap. So elements that use this need to implement a fallback focus element.
*
* Use the `needsFallback` ref to check if you can v-if the element or not. So it's not included in the component tree when not needed.
*/
fallbackFocus: {
ref: Ref<HTMLElement | null>
/**
* The fallback focus element needs some specific selector to ensure it's not included when checking for focusable elements
*/
classSelector: string
}
},
focusTrapOpts: UseFocusTrapOptions = {
allowOutsideClick: true,
fallbackFocus: fallbackFocus.ref.value ?? undefined,
}
) => {
const resolvedIsActive = readonly(toRef(isActive))
const resolvedNoTrap = readonly(toRef(noTrap))

const checkNeedsFocus = () => {
const tabbableElements = element.value?.querySelectorAll(
`a, button, input, select, textarea, [tabindex]:not([tabindex="-1"]):not(.${fallbackFocus.classSelector})`
)
return !tabbableElements || tabbableElements.length === 0
}
const needsFallback = ref(checkNeedsFocus())
onMounted(() => {
useMutationObserver(
element,
() => {
needsFallback.value = checkNeedsFocus()
},
{childList: true, subtree: true}
)
})

const trap = useFocusTrap(element, focusTrapOpts)
watch(resolvedIsActive, async (newValue) => {
await nextTick()
Expand All @@ -33,4 +74,8 @@ export const useActivatedFocusTrap = (
trap.deactivate()
}
})

return {
needsFallback: readonly(needsFallback),
}
}

0 comments on commit 873e716

Please sign in to comment.