Skip to content

Commit

Permalink
fix(#3577): introduce useTeleported composable (#3578)
Browse files Browse the repository at this point in the history
* fix(#3577): introduce useTeleported composable

* chore(docs): update modal demo

* fix(time-input): open on click

* chore(docs): change button in example to primary

* fix(dropdown): close on keyboard only focus outside
  • Loading branch information
m0ksem authored Jul 7, 2023
1 parent efeb164 commit b1bfae0
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,27 @@
Nested Modal
</h3>

<va-date-input prevent-overflow />
<va-date-input
v-model="date"
outline
/>

<p class="va-text-secondary opacity-50">
This example shows how overlapping modals work after you click save.
</p>
</div>

<div class="flex justify-end mt-2">
<div class="flex justify-end mt-2 gap-2">
<va-button
preset="secondary"
color="secondary"
class="mr-2"
@click="hide()"
>
Cancel
</va-button>
<va-button preset="primary" @click="setDefault">
Set default
</va-button>
<va-button
@click="showSecondModal = !showSecondModal"
>
Expand All @@ -51,7 +56,17 @@ export default {
return {
showFirstModal: false,
showSecondModal: false,
date: new Date(),
};
},
methods: {
setDefault() {
this.date = new Date();
this.$vaToast.init({
message: 'Date was set to default',
color: '#222222',
})
}
}
};
</script>
6 changes: 5 additions & 1 deletion packages/ui/src/components/va-dropdown/VaDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { DropdownOffsetProp } from './types'
import { useDropdown } from './hooks/useDropdown'
import { warn } from '../../utils/console'
import { useFocusOutside } from '../../composables/useFocusOutside'
import { useTeleported } from '../../composables/useTeleported'
export default defineComponent({
name: 'VaDropdown',
Expand Down Expand Up @@ -194,7 +195,7 @@ export default defineComponent({
if (props.closeOnFocusOutside && valueComputed.value) {
emitAndClose('focus-outside', props.closeOnFocusOutside)
}
})
}, { onlyKeyboard: true })
const anchorComputed = computed(() => {
return cursorAnchor.value || anchor.value
Expand All @@ -219,6 +220,7 @@ export default defineComponent({
return {
...useTranslation(),
...useTeleported(),
anchor,
anchorClass,
floating,
Expand All @@ -239,6 +241,7 @@ export default defineComponent({
ref: 'floating',
class: 'va-dropdown__content-wrapper',
style: this.floatingStyles,
...this.teleportedAttrs,
...this.floatingListeners,
})
Expand All @@ -250,6 +253,7 @@ export default defineComponent({
'aria-label': this.tp(this.$props.ariaLabel),
'aria-disabled': this.$props.disabled,
'aria-expanded': !!this.showFloating,
...this.teleportFromAttrs,
...this.$attrs,
})
Expand Down
14 changes: 13 additions & 1 deletion packages/ui/src/components/va-modal/VaModal.demo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@

<va-modal v-model="showBeforeHideModal" :message="message" :beforeClose="beforeClose" />
</VbCard>
<VbCard title="closeOutside">
<button @click="showModalCloseOutside = !showModalCloseOutside">
Show modal
</button>

<va-modal v-model="showModalCloseOutside">
<VaDateInput />
</va-modal>
</VbCard>
</VbDemo>
</template>

Expand All @@ -373,9 +382,10 @@ import { VaButton } from '../va-button'
import { VaCollapse } from '../va-collapse'
import { VaInput } from '../va-input'
import { VaDatePicker } from '../va-date-picker'
import { VaDateInput } from '../va-date-input'
export default {
components: { VaModal, VaButton, VaCollapse, VaInput, VaDatePicker },
components: { VaModal, VaButton, VaCollapse, VaInput, VaDatePicker, VaDateInput },
data () {
return {
showModalSizeSmall: false,
Expand Down Expand Up @@ -411,6 +421,8 @@ export default {
showModalFocusTrap1: false,
showModalFocusTrap2: false,
showBeforeHideModal: false,
showModalCloseOutside: false,
c: false,
message: this.$vb.lorem(),
longMessage: this.$vb.lorem(5000),
collapseValue: false,
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/components/va-modal/VaModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
:aria-labelledby="title"
:class="$props.anchorClass"
>
<div v-if="$slots.anchor" class="va-modal__anchor">
<div v-if="$slots.anchor" class="va-modal__anchor" v-bind="teleportFromAttrs">
<slot name="anchor" v-bind="slotBind" />
</div>

Expand All @@ -17,7 +17,7 @@
:isTransition="!$props.withoutTransitions"
appear
:duration="300"
v-bind="$attrs"
v-bind="{ ...$attrs, ...teleportedAttrs }"
@beforeEnter="onBeforeEnterTransition"
@afterEnter="onAfterEnterTransition"
@beforeLeave="onBeforeLeaveTransition"
Expand Down Expand Up @@ -140,6 +140,7 @@ import {
useTranslation,
useClickOutside,
useDocument,
useTeleported,
} from '../../composables'
import { VaButton } from '../va-button'
Expand Down Expand Up @@ -357,6 +358,7 @@ export default defineComponent({
computedOverlayStyles,
slotBind: { show, hide, toggle, cancel, ok },
...publicMethods,
...useTeleported(),
}
},
})
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/va-time-input/VaTimeInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
class="va-time-input__anchor"
:style="cursorStyleComputed"
v-bind="computedInputWrapperProps"
@click.stop="toggleDropdown"
>
<template #default>
<input
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ export * from './useReactiveComputed'
export * from './useIcon'
export * from './useGlobalConfig'
export * from './usePlacementAliases'
export * from './useTeleported'
18 changes: 13 additions & 5 deletions packages/ui/src/composables/useClickOutside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Ref, unref } from 'vue'

import { useCaptureEvent } from './useCaptureEvent'
import { extractHTMLElement } from './useHTMLElement'
import { findTeleportedFrom } from './useTeleported'

const checkIfElementChild = (parent: HTMLElement, child: HTMLElement | null | undefined): boolean => {
if (!child) { return false }
Expand All @@ -23,11 +24,18 @@ export const useClickOutside = (elements: MaybeArray<MaybeRef<HTMLElement | unde
return
}

const isClickInside = safeArray(elements)
.some((element) => {
const el = extractHTMLElement(unref(element))
return el && checkIfElementChild(el, clickTarget)
})
// Handle floating UI teleport
const teleportParent = findTeleportedFrom(clickTarget)

const isClickInside = safeArray(elements).some((element) => {
const el = extractHTMLElement(unref(element))

if (!el) { return false }

if (!teleportParent) { return checkIfElementChild(el, clickTarget) }

return checkIfElementChild(el, clickTarget) || checkIfElementChild(el, teleportParent)
})

if (!isClickInside) { cb(clickTarget) }
})
Expand Down
63 changes: 47 additions & 16 deletions packages/ui/src/composables/useFocusOutside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,65 @@ import { Ref, unref } from 'vue'

import { useCaptureEvent } from './useCaptureEvent'
import { extractHTMLElement } from './useHTMLElement'
import { useEvent } from './useEvent'

const checkIfElementChild = (parent: HTMLElement, child: HTMLElement | Window | null | undefined): boolean => {
if (!child) { return false }
if (child instanceof Window) { return false }
if (child.parentElement === parent) { return true }
const checkIfElementChild = (
parent: HTMLElement,
child: HTMLElement | Window | null | undefined,
): boolean => {
if (!child) {
return false
}
if (child instanceof Window) {
return false
}
if (child.parentElement === parent) {
return true
}

return parent.contains(child)
}

type MaybeRef<T> = T | Ref<T>
type MaybeArray<T> = T | T[]
type MaybeRef<T> = T | Ref<T>;
type MaybeArray<T> = T | T[];

const safeArray = <T>(a: MaybeArray<T>) => Array.isArray(a) ? a : [a]
const safeArray = <T>(a: MaybeArray<T>) => (Array.isArray(a) ? a : [a])

export const useFocusOutside = (
elements: MaybeArray<MaybeRef<HTMLElement | undefined>>,
cb: (el: HTMLElement) => void,
options: {
onlyKeyboard?: boolean;
} = {},
) => {
let previouslyClicked = false
if (options.onlyKeyboard) {
useEvent('mousedown', (e) => {
previouslyClicked = true
setTimeout(() => {
previouslyClicked = false
}, 200)
}, true)
}

useEvent('focus', (event) => {
if (options.onlyKeyboard && previouslyClicked) {
return
}

export const useFocusOutside = (elements: MaybeArray<MaybeRef<HTMLElement | undefined>>, cb: (el: HTMLElement) => void) => {
useCaptureEvent('focus', (event: MouseEvent) => {
const focusTarget = event.target as HTMLElement

if ((event.target as HTMLElement).shadowRoot) {
return
}

const isFocusInside = safeArray(elements)
.some((element) => {
const el = extractHTMLElement(unref(element))
return el && checkIfElementChild(el, focusTarget)
})
const isFocusInside = safeArray(elements).some((element) => {
const el = extractHTMLElement(unref(element))
return el && checkIfElementChild(el, focusTarget)
})

if (!isFocusInside) { cb(focusTarget) }
})
if (!isFocusInside) {
cb(focusTarget)
}
}, true)
}
37 changes: 37 additions & 0 deletions packages/ui/src/composables/useTeleported.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useCurrentComponentId } from './useCurrentComponentId'

export const TELEPORT_FROM_ATTR = 'data-va-teleported-from'
export const TELEPORT_ATTR = 'data-va-teleported'

export const findTeleportedFrom = (el: HTMLElement | undefined | null): HTMLElement | null => {
if (!el) { return null }

const teleportId = el.getAttribute(TELEPORT_ATTR)

if (teleportId === null) { return findTeleportedFrom(el.parentElement) }

return document.querySelector<HTMLElement>(`[${TELEPORT_FROM_ATTR}="${teleportId}"]`)
}

/**
* Used in components, which have something to do with Teleport.
* You need to add `teleportFromAttrs` to the root element of the component,
* and `teleportedAttrs` to the element, which is teleported.
*
* This way you can find the original element, which was teleported from.
*
* @notice it is used in `useClickOutside` to track from where teleported originated from.
*/
export const useTeleported = () => {
const componentId = useCurrentComponentId()

return {
teleportFromAttrs: {
[TELEPORT_FROM_ATTR]: componentId,
},
teleportedAttrs: {
[TELEPORT_ATTR]: componentId,
},
findTeleportedFrom,
}
}

0 comments on commit b1bfae0

Please sign in to comment.