Skip to content

Commit

Permalink
chore: add event to dismissable-layer
Browse files Browse the repository at this point in the history
  • Loading branch information
productdevbook committed Jan 19, 2024
1 parent a198ffc commit eb87939
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 107 deletions.
71 changes: 53 additions & 18 deletions packages/vue/src/dismissable-layer/DismissableLayer.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
<script lang="ts">
import { computed, defineOptions, nextTick, reactive, useAttrs, watchEffect } from 'vue'
import type { DismissableLayerBranchElement, DismissableLayerElement, FocusBlurCaptureEvent, FocusCaptureEvent, FocusOutsideEvent, PointerdownCaptureEvent, PointerdownOutsideEvent } from './props'
import {
computed,
defineOptions,
onBeforeUnmount,
onMounted,
reactive,
ref,
useAttrs,
watch,
watchEffect,
} from 'vue'
import type {
DismissableLayerBranchElement,
DismissableLayerElement,
FocusBlurCaptureEvent,
FocusCaptureEvent,
FocusOutsideEvent,
PointerdownCaptureEvent,
PointerdownOutsideEvent,
} from './props'
import { CONTEXT_UPDATE } from './props'
export const context = reactive({
layers: new Set<DismissableLayerElement>(),
layersWithOutsidePointerEventsDisabled: new Set<DismissableLayerElement>(),
branches: new Set<DismissableLayerBranchElement>(),
})
let originalBodyPointerEvents: string
</script>

<script setup lang="ts">
import { useComponentRef, useEscapeKeydown } from '@oku-ui/use-composable'
import type { PrimitiveProps } from '@oku-ui/primitive'
import { Primitive } from '@oku-ui/primitive'
import { composeEventHandlers } from '@oku-ui/utils'
import { useFocusOutside, usePointerdownOutside } from './util'
import { dispatchUpdate, useFocusOutside, usePointerdownOutside } from './util'
export interface DismissableLayerProps extends PrimitiveProps {
/**
* When `true`, hover/focus/click interactions will be disabled on elements outside
Expand Down Expand Up @@ -83,28 +104,29 @@ const isPointerEventsEnabled = computed(() => {
return index.value >= highestLayerWithOutsidePointerEventsDisabledIndex
})
const pointerdownOutside = usePointerdownOutside(async (event) => {
const focusOutside = useFocusOutside(async (event) => {
const target = event.target as HTMLElement
const isPointerdownOnBranch = [...context.branches].some(branch => branch.contains(target))
if (!isPointerEventsEnabled.value || isPointerdownOnBranch)
const isFocusInBranch = [...context.branches].some(branch => branch.contains(target))
if (isFocusInBranch)
return
emit('pointerdownOutside', event)
emit('focusOutside', event)
emit('interactOutside', event)
await nextTick()
if (!event.defaultPrevented)
emit('dismiss')
}, ownerDocument)
const focusOutside = useFocusOutside(async (event) => {
const pointerdownOutside = usePointerdownOutside(async (event) => {
const target = event.target as HTMLElement
const isFocusInBranch = [...context.branches].some(branch => branch.contains(target))
if (isFocusInBranch)
const isPointerdownOnBranch = [...context.branches].some(branch => branch.contains(target))
if (!isPointerEventsEnabled.value || isPointerdownOnBranch)
return
emit('focusOutside', event)
emit('pointerdownOutside', event)
emit('interactOutside', event)
await nextTick()
if (!event.defaultPrevented)
emit('dismiss')
}, ownerDocument)
Expand All @@ -122,9 +144,7 @@ useEscapeKeydown((event) => {
}
}, ownerDocument)
let originalBodyPointerEvents: string
watchEffect(async (onCleanup) => {
watch([componentRef, context], (_newValue, _oldValue, onCleanup) => {
if (!currentElement.value)
return
Expand All @@ -138,6 +158,7 @@ watchEffect(async (onCleanup) => {
}
context.layers.add(currentElement.value)
dispatchUpdate()
onCleanup(() => {
if (
Expand All @@ -163,9 +184,24 @@ watchEffect((onCleanup) => {
context.layers.delete(currentElement.value)
context.layersWithOutsidePointerEventsDisabled.delete(currentElement.value)
dispatchUpdate()
})
})
const force = ref({})
function handleUpdate() {
force.value = {}
}
onMounted(() => {
document.addEventListener(CONTEXT_UPDATE, handleUpdate)
})
onBeforeUnmount(() => {
document.removeEventListener(CONTEXT_UPDATE, handleUpdate)
})
const attrs = useAttrs()
defineExpose({
Expand All @@ -177,8 +213,7 @@ defineExpose({
<Primitive
:is="is"
ref="componentRef"
:as-child="asChild"
data-dismissable-layer=""
:as-child="props.asChild"
:style="{
pointerEvents: isBodyPointerEventsDisabled
? isPointerEventsEnabled
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/dismissable-layer/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const BRANCH_NAME = 'OkuDismissableLayerBranch'

export const POINTER_DOWN_OUTSIDE = 'dismissableLayer.pointerdownOutside'
export const FOCUS_OUTSIDE = 'dismissableLayer.focusOutside'
export const CONTEXT_UPDATE = 'dismissableLayer.contextUpdate'

export const DismissableLayerProvideKey = Symbol('DismissableLayerProvide')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const { componentRef } = useComponentRef<HTMLElement | null>()
>
<slot />

<button type="button" @click="open = false">
<button type="button" @click="open = !open">
{{ closeLabel }}
</button>

Expand Down
170 changes: 98 additions & 72 deletions packages/vue/src/dismissable-layer/util.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,92 @@
import type { Ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ref, watch } from 'vue'
import { dispatchDiscreteCustomEvent } from '@oku-ui/primitive'
import type { FocusOutsideEvent, PointerdownOutsideEvent } from './props'
import { FOCUS_OUTSIDE, POINTER_DOWN_OUTSIDE } from './props'
import { CONTEXT_UPDATE, FOCUS_OUTSIDE, POINTER_DOWN_OUTSIDE } from './props'

/**
* Listens for `pointerdown` outside a subtree. We use `pointerdown` rather than `pointerup`
* to mimic layer dismissing behaviour present in OS.
* Returns props to pass to the node we want to check for outside events.
*/

export function usePointerdownOutside(
onPointerDownOutside?: (event: PointerdownOutsideEvent) => void,
ownerDocument: Ref<Document> = ref(globalThis?.document),
) {
const isPointerInsideDOMTree = ref(false)

const handleClick = () => {
ownerDocument.value.removeEventListener('click', handleClick)
isPointerInsideDOMTree.value = false
}

const handlePointerDown = async (event: PointerEvent) => {
await nextTick()

if (event.target && !isPointerInsideDOMTree.value) {
const eventDetail = { originalEvent: event }

function handleAndDispatchPointerDownOutsideEvent() {
handleAndDispatchCustomEvent(
POINTER_DOWN_OUTSIDE,
onPointerDownOutside,
eventDetail,
{ discrete: true },
)
}

if (event.pointerType === 'touch') {
ownerDocument.value.removeEventListener('click', handleClick)
ownerDocument.value.addEventListener('click', handleClick, { once: true })
const isPointerInsideReactTreeRef = ref(false)
const handleClickRef = ref(() => { })

watch(ownerDocument, (newValue, _old, onClean) => {
const handlePointerDown = (event: PointerEvent) => {
if (event.target && !isPointerInsideReactTreeRef.value) {
const eventDetail = { originalEvent: event }

function handleAndDispatchPointerDownOutsideEvent() {
handleAndDispatchCustomEvent(
POINTER_DOWN_OUTSIDE,
onPointerDownOutside,
eventDetail,
{ discrete: true },
)
}

/**
* On touch devices, we need to wait for a click event because browsers implement
* a ~350ms delay between the time the user stops touching the display and when the
* browser executres events. We need to ensure we don't reactivate pointer-events within
* this timeframe otherwise the browser may execute events that should have been prevented.
*
* Additionally, this also lets us deal automatically with cancellations when a click event
* isn't raised because the page was considered scrolled/drag-scrolled, long-pressed, etc.
*
* This is why we also continuously remove the previous listener, because we cannot be
* certain that it was raised, and therefore cleaned-up.
*/
if (event.pointerType === 'touch') {
newValue.removeEventListener('click', handleClickRef.value)
handleClickRef.value = handleAndDispatchPointerDownOutsideEvent
newValue.addEventListener('click', handleClickRef.value, { once: true })
}
else {
handleAndDispatchPointerDownOutsideEvent()
}
}
else {
handleAndDispatchPointerDownOutsideEvent()
// We need to remove the event listener in case the outside click has been canceled.
// See: https://github.com/radix-ui/primitives/issues/2171
newValue.removeEventListener('click', handleClickRef.value)
}
isPointerInsideReactTreeRef.value = false
}
}

onMounted(() => {
ownerDocument.value.addEventListener('pointerdown', handlePointerDown)
})

onBeforeUnmount(() => {
ownerDocument.value.removeEventListener('pointerdown', handlePointerDown)
ownerDocument.value.removeEventListener('click', handleClick)
/**
* if this hook executes in a component that mounts via a `pointerdown` event, the event
* would bubble up to the document and trigger a `pointerDownOutside` event. We avoid
* this by delaying the event listener registration on the document.
* This is not React specific, but rather how the DOM works, ie:
* ```
* button.addEventListener('pointerdown', () => {
* console.log('I will log');
* document.addEventListener('pointerdown', () => {
* console.log('I will also log');
* })
* });
*/
const timerId = window.setTimeout(() => {
newValue.addEventListener('pointerdown', handlePointerDown)
}, 0)

onClean(() => {
window.clearTimeout(timerId)
newValue.removeEventListener('pointerdown', handlePointerDown)
newValue.removeEventListener('click', handleClickRef.value)
})
}, {
immediate: true,
})

return {
onPointerdownCapture: () => {
isPointerInsideDOMTree.value = true
},
onPointerdownCapture: () => isPointerInsideReactTreeRef.value = true,
}
}

Expand All @@ -69,44 +98,36 @@ export function useFocusOutside(
onFocusOutside?: (event: FocusOutsideEvent) => void,
ownerDocument: Ref<Document | null> = ref(globalThis?.document),
) {
const isFocusInsideTreeRef = ref<boolean>(false)

const handleFocus = async (event: FocusEvent) => {
await nextTick()

if (event.target && !isFocusInsideTreeRef.value) {
const eventDetail = { originalEvent: event }

handleAndDispatchCustomEvent(
FOCUS_OUTSIDE,
onFocusOutside,
eventDetail,
{ discrete: true },
)
const isFocusInsideReactTree = ref(false)

watch(ownerDocument, (newValue, _old, onClean) => {
const handleFocus = (event: FocusEvent) => {
if (event.target && !isFocusInsideReactTree.value) {
const eventDetail = { originalEvent: event }
handleAndDispatchCustomEvent(FOCUS_OUTSIDE, onFocusOutside, eventDetail, {
discrete: false,
})
}
}
}

const addFocusEventListener = () => {
if (ownerDocument.value)
ownerDocument.value.addEventListener('focusin', handleFocus)
}

const removeFocusEventListener = () => {
if (ownerDocument.value)
ownerDocument.value.removeEventListener('focusin', handleFocus)
}
if (newValue)
newValue.addEventListener('focusin', handleFocus)

onMounted(() => {
addFocusEventListener()
})

onBeforeUnmount(() => {
removeFocusEventListener()
onClean(() => {
if (newValue)
newValue.removeEventListener('focusin', handleFocus)
})
}, {
immediate: true,
})

return {
onFocusCapture: () => (isFocusInsideTreeRef.value = true),
onBlurCapture: () => (isFocusInsideTreeRef.value = false),
onFocusCapture: () => {
isFocusInsideReactTree.value = true
},
onBlurCapture: () => {
isFocusInsideReactTree.value = false
},
}
}

Expand All @@ -127,3 +148,8 @@ export function handleAndDispatchCustomEvent<E extends CustomEvent, OriginalEven
else
target.dispatchEvent(event)
}

export function dispatchUpdate() {
const event = new CustomEvent(CONTEXT_UPDATE)
document.dispatchEvent(event)
}
9 changes: 3 additions & 6 deletions packages/vue/src/primitive/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// same inspiration and resource https://github.com/chakra-ui/ark/blob/main/packages/vue/src/factory.tsx

import type { VNode } from 'vue'
import { Fragment, nextTick } from 'vue'
import { Fragment, nextTick, watch, watchSyncEffect } from 'vue'

Check failure on line 4 in packages/vue/src/primitive/utils.ts

View workflow job for this annotation

GitHub Actions / build-test (ubuntu-latest, 20)

'nextTick' is defined but never used

Check failure on line 4 in packages/vue/src/primitive/utils.ts

View workflow job for this annotation

GitHub Actions / build-test (ubuntu-latest, 20)

'watch' is defined but never used

Check failure on line 4 in packages/vue/src/primitive/utils.ts

View workflow job for this annotation

GitHub Actions / build-test (ubuntu-latest, 20)

'watchSyncEffect' is defined but never used

/**
* Checks whether a given VNode is a render-vialble element.
Expand Down Expand Up @@ -50,11 +50,8 @@ export function dispatchDiscreteCustomEvent<E extends CustomEvent>(
target: E['target'],
event: E,
) {
if (target) {
nextTick(() => {
target.dispatchEvent(event)
})
}
if (target)
target.dispatchEvent(event)
}

export const primitiveProps = {
Expand Down
Loading

0 comments on commit eb87939

Please sign in to comment.