Adjust outside click handling (#1667)
* Don’t close dialog if opened during mouse up event * Don’t close dialog if drag starts inside dialog and ends outside dialog * Handle closing of nested dialogs that are always mounted * Fix focus trap restoration in Vue * Update changelog
This commit is contained in:
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- Fixed SSR support on Deno ([#1671](https://github.com/tailwindlabs/headlessui/pull/1671))
|
||||
- Don’t close dialog when opened during mouse up event ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
|
||||
- Don’t close dialog when drag ends outside dialog ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
|
||||
- Fix outside clicks to close dialog when nested, unopened dialogs are present ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
|
||||
|
||||
## [1.6.6] - 2022-07-07
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
getDialogs,
|
||||
getDialogOverlays,
|
||||
} from '../../test-utils/accessibility-assertions'
|
||||
import { click, press, Keys } from '../../test-utils/interactions'
|
||||
import { click, mouseDrag, press, Keys } from '../../test-utils/interactions'
|
||||
import { PropsOf } from '../../types'
|
||||
import { Transition } from '../transitions/transition'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -1066,14 +1066,101 @@ describe('Mouse interactions', () => {
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should not close the dialog if opened during mouse up',
|
||||
suppressConsoleLogs(async () => {
|
||||
function Example() {
|
||||
let [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button id="trigger" onMouseUpCapture={() => setIsOpen((v) => !v)}>
|
||||
Trigger
|
||||
</button>
|
||||
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Panel>
|
||||
<button id="inside">Inside</button>
|
||||
<TabSentinel />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Example />)
|
||||
|
||||
await click(document.getElementById('trigger'))
|
||||
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
await click(document.getElementById('inside'))
|
||||
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should not close the dialog if click starts inside the dialog but ends outside',
|
||||
suppressConsoleLogs(async () => {
|
||||
function Example() {
|
||||
let [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
|
||||
Trigger
|
||||
</button>
|
||||
<div id="imoutside">this thing</div>
|
||||
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Panel>
|
||||
<button id="inside">Inside</button>
|
||||
<TabSentinel />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Example />)
|
||||
|
||||
// Open the dialog
|
||||
await click(document.getElementById('trigger'))
|
||||
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
// Start a click inside the dialog and end it outside
|
||||
await mouseDrag(document.getElementById('inside'), document.getElementById('imoutside'))
|
||||
|
||||
// It should not have hidden
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
await click(document.getElementById('imoutside'))
|
||||
|
||||
// It's gone
|
||||
assertDialog({ state: DialogState.InvisibleUnmounted })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
function Nested({ onClose, level = 1 }: { onClose: (value: boolean) => void; level?: number }) {
|
||||
type RenderStrategy = 'mounted' | 'always'
|
||||
|
||||
function Nested({
|
||||
onClose,
|
||||
open = true,
|
||||
level = 1,
|
||||
renderWhen = 'mounted',
|
||||
}: {
|
||||
onClose: (value: boolean) => void
|
||||
open?: boolean
|
||||
level?: number
|
||||
renderWhen?: RenderStrategy
|
||||
}) {
|
||||
let [showChild, setShowChild] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<Dialog.Overlay />
|
||||
|
||||
<div>
|
||||
@@ -1082,31 +1169,42 @@ describe('Nesting', () => {
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1} b</button>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1} c</button>
|
||||
</div>
|
||||
{showChild && <Nested onClose={setShowChild} level={level + 1} />}
|
||||
{renderWhen === 'always' ? (
|
||||
<Nested
|
||||
open={showChild}
|
||||
onClose={setShowChild}
|
||||
level={level + 1}
|
||||
renderWhen={renderWhen}
|
||||
/>
|
||||
) : (
|
||||
showChild && <Nested open={true} onClose={setShowChild} level={level + 1} />
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Example() {
|
||||
function Example({ renderWhen = 'mounted' }: { renderWhen: RenderStrategy }) {
|
||||
let [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open 1</button>
|
||||
{open && <Nested onClose={setOpen} />}
|
||||
{open && <Nested open={true} onClose={setOpen} renderWhen={renderWhen} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
it.each`
|
||||
strategy | action
|
||||
${'with `Escape`'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${() => click(document.body)}
|
||||
${'with `Click on Dialog.Overlay`'} | ${() => click(getDialogOverlays().pop()!)}
|
||||
strategy | when | action
|
||||
${'with `Escape`'} | ${'mounted'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${'mounted'} | ${() => click(document.body)}
|
||||
${'with `Click on Dialog.Overlay`'} | ${'mounted'} | ${() => click(getDialogOverlays().pop()!)}
|
||||
${'with `Escape`'} | ${'always'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${'always'} | ${() => click(document.body)}
|
||||
`(
|
||||
'should be possible to open nested Dialog components and close them $strategy',
|
||||
async ({ action }) => {
|
||||
render(<Example />)
|
||||
'should be possible to open nested Dialog components (visible when $when) and close them $strategy',
|
||||
async ({ when, action }) => {
|
||||
render(<Example renderWhen={when} />)
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
@@ -305,6 +305,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
return (
|
||||
<StackProvider
|
||||
type="Dialog"
|
||||
enabled={dialogState === DialogStates.Open}
|
||||
element={internalDialogRef}
|
||||
onUpdate={useEvent((message, type, element) => {
|
||||
if (type !== 'Dialog') return
|
||||
|
||||
@@ -90,9 +90,31 @@ export function useOutsideClick(
|
||||
return cb(event, target)
|
||||
}
|
||||
|
||||
let initialClickTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
useWindowEvent(
|
||||
'mousedown',
|
||||
(event) => {
|
||||
if (enabledRef.current) {
|
||||
initialClickTarget.current = event.target
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
useWindowEvent(
|
||||
'click',
|
||||
(event) => handleOutsideClick(event, (event) => event.target as HTMLElement),
|
||||
(event) => {
|
||||
if (!initialClickTarget.current) {
|
||||
return
|
||||
}
|
||||
|
||||
handleOutsideClick(event, () => {
|
||||
return initialClickTarget.current as HTMLElement
|
||||
})
|
||||
|
||||
initialClickTarget.current = null
|
||||
},
|
||||
|
||||
// We will use the `capture` phase so that layers in between with `event.stopPropagation()`
|
||||
// don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu`
|
||||
|
||||
@@ -32,11 +32,13 @@ export function StackProvider({
|
||||
onUpdate,
|
||||
type,
|
||||
element,
|
||||
enabled,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onUpdate?: OnUpdate
|
||||
type: string
|
||||
element: MutableRefObject<HTMLElement | null>
|
||||
enabled?: boolean
|
||||
}) {
|
||||
let parentUpdate = useStackContext()
|
||||
|
||||
@@ -49,9 +51,14 @@ export function StackProvider({
|
||||
})
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
notify(StackMessage.Add, type, element)
|
||||
return () => notify(StackMessage.Remove, type, element)
|
||||
}, [notify, type, element])
|
||||
let shouldNotify = enabled === undefined || enabled === true
|
||||
|
||||
shouldNotify && notify(StackMessage.Add, type, element)
|
||||
|
||||
return () => {
|
||||
shouldNotify && notify(StackMessage.Remove, type, element)
|
||||
}
|
||||
}, [notify, type, element, enabled])
|
||||
|
||||
return <StackContext.Provider value={notify}>{children}</StackContext.Provider>
|
||||
}
|
||||
|
||||
@@ -344,6 +344,74 @@ export async function mouseLeave(element: Document | Element | Window | null) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function mouseDrag(
|
||||
startingElement: Document | Element | Window | Node | null,
|
||||
endingElement: Document | Element | Window | Node | null
|
||||
) {
|
||||
let button = MouseButton.Left
|
||||
|
||||
try {
|
||||
if (startingElement === null) return expect(startingElement).not.toBe(null)
|
||||
if (endingElement === null) return expect(endingElement).not.toBe(null)
|
||||
if (startingElement instanceof HTMLButtonElement && startingElement.disabled) return
|
||||
|
||||
let options = { button }
|
||||
|
||||
// Cancel in pointerDown cancels mouseDown, mouseUp
|
||||
let cancelled = !fireEvent.pointerDown(startingElement, options)
|
||||
|
||||
if (!cancelled) {
|
||||
cancelled = !fireEvent.mouseDown(startingElement, options)
|
||||
}
|
||||
|
||||
// Ensure to trigger a `focus` event if the element is focusable, or within a focusable element
|
||||
if (!cancelled) {
|
||||
let next: HTMLElement | null = startingElement as HTMLElement | null
|
||||
while (next !== null) {
|
||||
if (next.matches(focusableSelector)) {
|
||||
next.focus()
|
||||
break
|
||||
}
|
||||
next = next.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent.pointerMove(startingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseMove(startingElement, options)
|
||||
}
|
||||
|
||||
fireEvent.pointerOut(startingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseOut(startingElement, options)
|
||||
}
|
||||
|
||||
// crosses over to the ending element
|
||||
|
||||
fireEvent.pointerOver(endingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseOver(endingElement, options)
|
||||
}
|
||||
|
||||
fireEvent.pointerMove(endingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseMove(endingElement, options)
|
||||
}
|
||||
|
||||
fireEvent.pointerUp(endingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseUp(endingElement, options)
|
||||
}
|
||||
|
||||
fireEvent.click(endingElement, options)
|
||||
|
||||
await new Promise(nextFrame)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) Error.captureStackTrace(err, click)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
function focusNext(event: Partial<KeyboardEvent>) {
|
||||
|
||||
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- Fixed SSR support on Deno ([#1671](https://github.com/tailwindlabs/headlessui/pull/1671))
|
||||
- Don’t close dialog when opened during mouse up event ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
|
||||
- Don’t close dialog when drag ends outside dialog ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
|
||||
- Fix outside clicks to close dialog when nested, unopened dialogs are present ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
|
||||
|
||||
## [1.6.7] - 2022-07-12
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
getDialogs,
|
||||
getDialogOverlays,
|
||||
} from '../../test-utils/accessibility-assertions'
|
||||
import { click, press, Keys } from '../../test-utils/interactions'
|
||||
import { click, mouseDrag, press, Keys } from '../../test-utils/interactions'
|
||||
import { html } from '../../test-utils/html'
|
||||
|
||||
// @ts-expect-error
|
||||
@@ -1444,13 +1444,106 @@ describe('Mouse interactions', () => {
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should not close the dialog if opened during mouse up',
|
||||
suppressConsoleLogs(async () => {
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger" @mouseup.capture="toggleOpen">
|
||||
Trigger
|
||||
</button>
|
||||
<Dialog :open="isOpen" @close="setIsOpen">
|
||||
<DialogBackdrop />
|
||||
<DialogPanel>
|
||||
<button id="inside">Inside</button>
|
||||
<TabSentinel />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await click(document.getElementById('trigger'))
|
||||
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
await click(document.getElementById('inside'))
|
||||
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should not close the dialog if click starts inside the dialog but ends outside',
|
||||
suppressConsoleLogs(async () => {
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger" @click="toggleOpen">
|
||||
Trigger
|
||||
</button>
|
||||
<div id="imoutside">this thing</div>
|
||||
<Dialog :open="isOpen" @close="setIsOpen">
|
||||
<DialogBackdrop />
|
||||
<DialogPanel>
|
||||
<button id="inside">Inside</button>
|
||||
<TabSentinel />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Open the dialog
|
||||
await click(document.getElementById('trigger'))
|
||||
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
// Start a click inside the dialog and end it outside
|
||||
await mouseDrag(document.getElementById('inside'), document.getElementById('imoutside'))
|
||||
|
||||
// It should not have hidden
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
await click(document.getElementById('imoutside'))
|
||||
|
||||
// It's gone
|
||||
assertDialog({ state: DialogState.InvisibleUnmounted })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
let Nested: ConcreteComponent = defineComponent({
|
||||
components: { Dialog, DialogOverlay },
|
||||
emits: ['close'],
|
||||
props: ['level'],
|
||||
props: ['open', 'level', 'renderWhen'],
|
||||
setup(props, { emit }) {
|
||||
let showChild = ref(false)
|
||||
function onClose() {
|
||||
@@ -1459,7 +1552,7 @@ describe('Nesting', () => {
|
||||
|
||||
return () => {
|
||||
let level = props.level ?? 1
|
||||
return h(Dialog, { open: true, onClose }, () => [
|
||||
return h(Dialog, { open: props.open ?? true, onClose }, () => [
|
||||
h(DialogOverlay),
|
||||
h('div', [
|
||||
h('p', `Level: ${level}`),
|
||||
@@ -1467,29 +1560,40 @@ describe('Nesting', () => {
|
||||
h('button', { onClick: () => (showChild.value = true) }, `Open ${level + 1} b`),
|
||||
h('button', { onClick: () => (showChild.value = true) }, `Open ${level + 1} c`),
|
||||
]),
|
||||
showChild.value &&
|
||||
h(Nested, {
|
||||
onClose: () => (showChild.value = false),
|
||||
level: level + 1,
|
||||
}),
|
||||
props.renderWhen === 'always'
|
||||
? h(Nested, {
|
||||
open: showChild.value,
|
||||
onClose: () => (showChild.value = false),
|
||||
level: level + 1,
|
||||
renderWhen: props.renderWhen,
|
||||
})
|
||||
: showChild.value &&
|
||||
h(Nested, {
|
||||
open: true,
|
||||
onClose: () => (showChild.value = false),
|
||||
level: level + 1,
|
||||
renderWhen: props.renderWhen,
|
||||
}),
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
it.each`
|
||||
strategy | action
|
||||
${'with `Escape`'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${() => click(document.body)}
|
||||
${'with `Click on Dialog.Overlay`'} | ${() => click(getDialogOverlays().pop()!)}
|
||||
strategy | when | action
|
||||
${'with `Escape`'} | ${'mounted'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${'mounted'} | ${() => click(document.body)}
|
||||
${'with `Click on Dialog.Overlay`'} | ${'mounted'} | ${() => click(getDialogOverlays().pop()!)}
|
||||
${'with `Escape`'} | ${'always'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${'always'} | ${() => click(document.body)}
|
||||
`(
|
||||
'should be possible to open nested Dialog components and close them $strategy',
|
||||
async ({ action }) => {
|
||||
async ({ when, action }) => {
|
||||
renderTemplate({
|
||||
components: { Nested },
|
||||
template: `
|
||||
<button @click="isOpen = true">Open 1</button>
|
||||
<Nested v-if="isOpen" @close="isOpen = false" />
|
||||
<Nested v-if="isOpen" @close="isOpen = false" renderWhen="${when}" />
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
|
||||
@@ -140,6 +140,7 @@ export let Dialog = defineComponent({
|
||||
)
|
||||
useStackProvider({
|
||||
type: 'Dialog',
|
||||
enabled: computed(() => dialogState.value === DialogStates.Open),
|
||||
element: internalDialogRef,
|
||||
onUpdate: (message, type, element) => {
|
||||
if (type !== 'Dialog') return
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PropType,
|
||||
Fragment,
|
||||
Ref,
|
||||
onUnmounted,
|
||||
} from 'vue'
|
||||
import { render } from '../../utils/render'
|
||||
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
|
||||
@@ -142,44 +143,38 @@ function useRestoreFocus(
|
||||
) {
|
||||
let restoreElement = ref<HTMLElement | null>(null)
|
||||
|
||||
// Deliberately not using a ref, we don't want to trigger re-renders.
|
||||
let mounted = { value: false }
|
||||
function captureFocus() {
|
||||
if (restoreElement.value) return
|
||||
restoreElement.value = ownerDocument.value?.activeElement as HTMLElement
|
||||
}
|
||||
|
||||
// Restore the focus to the previous element
|
||||
function restoreFocusIfNeeded() {
|
||||
if (!restoreElement.value) return
|
||||
focusElement(restoreElement.value)
|
||||
restoreElement.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Capture the currently focused element, before we try to move the focus inside the FocusTrap.
|
||||
watch(
|
||||
enabled,
|
||||
(newValue, prevValue) => {
|
||||
if (newValue === prevValue) return
|
||||
if (!enabled.value) return
|
||||
|
||||
mounted.value = true
|
||||
|
||||
if (!restoreElement.value) {
|
||||
restoreElement.value = ownerDocument.value?.activeElement as HTMLElement
|
||||
if (newValue) {
|
||||
// The FocusTrap has become enabled which means we're going to move the focus into the trap
|
||||
// We need to capture the current focus before we do that so we can restore it when done
|
||||
captureFocus()
|
||||
} else {
|
||||
restoreFocusIfNeeded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Restore the focus when we unmount the component.
|
||||
watch(
|
||||
enabled,
|
||||
(newValue, prevValue, onInvalidate) => {
|
||||
if (newValue === prevValue) return
|
||||
if (!enabled.value) return
|
||||
|
||||
onInvalidate(() => {
|
||||
if (mounted.value === false) return
|
||||
mounted.value = false
|
||||
|
||||
focusElement(restoreElement.value)
|
||||
restoreElement.value = null
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
// Restore the focus when we unmount the component
|
||||
onUnmounted(restoreFocusIfNeeded)
|
||||
}
|
||||
|
||||
function useInitialFocus(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useWindowEvent } from './use-window-event'
|
||||
import { computed, Ref, ComputedRef } from 'vue'
|
||||
import { computed, Ref, ComputedRef, ref } from 'vue'
|
||||
import { FocusableMode, isFocusableElement } from '../utils/focus-management'
|
||||
import { dom } from '../utils/dom'
|
||||
|
||||
@@ -76,9 +76,31 @@ export function useOutsideClick(
|
||||
return cb(event, target)
|
||||
}
|
||||
|
||||
let initialClickTarget = ref<EventTarget | null>(null)
|
||||
|
||||
useWindowEvent(
|
||||
'mousedown',
|
||||
(event) => {
|
||||
if (enabled.value) {
|
||||
initialClickTarget.value = event.target
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
useWindowEvent(
|
||||
'click',
|
||||
(event) => handleOutsideClick(event, (event) => event.target as HTMLElement),
|
||||
(event) => {
|
||||
if (!initialClickTarget.value) {
|
||||
return
|
||||
}
|
||||
|
||||
handleOutsideClick(event, () => {
|
||||
return initialClickTarget.value as HTMLElement
|
||||
})
|
||||
|
||||
initialClickTarget.value = null
|
||||
},
|
||||
|
||||
// We will use the `capture` phase so that layers in between with `event.stopPropagation()`
|
||||
// don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu`
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
// Types
|
||||
InjectionKey,
|
||||
Ref,
|
||||
watch,
|
||||
ref,
|
||||
onBeforeUnmount,
|
||||
} from 'vue'
|
||||
|
||||
type OnUpdate = (message: StackMessage, type: string, element: Ref<HTMLElement | null>) => void
|
||||
@@ -24,10 +27,12 @@ export function useStackContext() {
|
||||
|
||||
export function useStackProvider({
|
||||
type,
|
||||
enabled,
|
||||
element,
|
||||
onUpdate,
|
||||
}: {
|
||||
type: string
|
||||
enabled: Ref<boolean | undefined>
|
||||
element: Ref<HTMLElement | null>
|
||||
onUpdate?: OnUpdate
|
||||
}) {
|
||||
@@ -42,11 +47,23 @@ export function useStackProvider({
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
notify(StackMessage.Add, type, element)
|
||||
watch(
|
||||
enabled,
|
||||
(isEnabled, oldIsEnabled) => {
|
||||
if (isEnabled) {
|
||||
notify(StackMessage.Add, type, element)
|
||||
} else if (oldIsEnabled === true) {
|
||||
notify(StackMessage.Remove, type, element)
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
onUnmounted(() => {
|
||||
if (enabled.value) {
|
||||
notify(StackMessage.Remove, type, element)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
provide(StackContext, notify)
|
||||
|
||||
@@ -338,6 +338,74 @@ export async function mouseLeave(element: Document | Element | Window | null) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function mouseDrag(
|
||||
startingElement: Document | Element | Window | Node | null,
|
||||
endingElement: Document | Element | Window | Node | null
|
||||
) {
|
||||
let button = MouseButton.Left
|
||||
|
||||
try {
|
||||
if (startingElement === null) return expect(startingElement).not.toBe(null)
|
||||
if (endingElement === null) return expect(endingElement).not.toBe(null)
|
||||
if (startingElement instanceof HTMLButtonElement && startingElement.disabled) return
|
||||
|
||||
let options = { button }
|
||||
|
||||
// Cancel in pointerDown cancels mouseDown, mouseUp
|
||||
let cancelled = !fireEvent.pointerDown(startingElement, options)
|
||||
|
||||
if (!cancelled) {
|
||||
cancelled = !fireEvent.mouseDown(startingElement, options)
|
||||
}
|
||||
|
||||
// Ensure to trigger a `focus` event if the element is focusable, or within a focusable element
|
||||
if (!cancelled) {
|
||||
let next: HTMLElement | null = startingElement as HTMLElement | null
|
||||
while (next !== null) {
|
||||
if (next.matches(focusableSelector)) {
|
||||
next.focus()
|
||||
break
|
||||
}
|
||||
next = next.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent.pointerMove(startingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseMove(startingElement, options)
|
||||
}
|
||||
|
||||
fireEvent.pointerOut(startingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseOut(startingElement, options)
|
||||
}
|
||||
|
||||
// crosses over to the ending element
|
||||
|
||||
fireEvent.pointerOver(endingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseOver(endingElement, options)
|
||||
}
|
||||
|
||||
fireEvent.pointerMove(endingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseMove(endingElement, options)
|
||||
}
|
||||
|
||||
fireEvent.pointerUp(endingElement, options)
|
||||
if (!cancelled) {
|
||||
fireEvent.mouseUp(endingElement, options)
|
||||
}
|
||||
|
||||
fireEvent.click(endingElement, options)
|
||||
|
||||
await new Promise(nextFrame)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) Error.captureStackTrace(err, click)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
function focusNext(event: Partial<KeyboardEvent>) {
|
||||
|
||||
Reference in New Issue
Block a user