Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix broken escape key behaviour #754

Merged
merged 2 commits into from
Aug 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixes

- Only add `type=button` to real buttons ([#709](https://github.com/tailwindlabs/headlessui/pull/709))
- Fix `escape` bug not closing Dialog after clicking in Dialog ([#754](https://github.com/tailwindlabs/headlessui/pull/754))

## [Unreleased - Vue]

### Fixes

- Only add `type=button` to real buttons ([#709](https://github.com/tailwindlabs/headlessui/pull/709))
- Add Vue emit types ([#679](https://github.com/tailwindlabs/headlessui/pull/679), [#712](https://github.com/tailwindlabs/headlessui/pull/712))
- Fix `escape` bug not closing Dialog after clicking in Dialog ([#754](https://github.com/tailwindlabs/headlessui/pull/754))

## [@headlessui/[email protected]] - 2021-07-29

Expand Down
84 changes: 84 additions & 0 deletions packages/@headlessui-react/src/components/dialog/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,90 @@ describe('Keyboard interactions', () => {
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)

it(
'should be possible to close the dialog with Escape, when a field is focused',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<input id="name" />
<TabSentinel />
</Dialog>
</>
)
}
render(<Example />)

assertDialog({ state: DialogState.InvisibleUnmounted })

// Open dialog
await click(document.getElementById('trigger'))

// Verify it is open
assertDialog({
state: DialogState.Visible,
attributes: { id: 'headlessui-dialog-1' },
})

// Close dialog
await press(Keys.Escape)

// Verify it is close
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)

it(
'should not be possible to close the dialog with Escape, when a field is focused but cancels the event',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<input
id="name"
onKeyDown={event => {
event.preventDefault()
event.stopPropagation()
}}
/>
<TabSentinel />
</Dialog>
</>
)
}
render(<Example />)

assertDialog({ state: DialogState.InvisibleUnmounted })

// Open dialog
await click(document.getElementById('trigger'))

// Verify it is open
assertDialog({
state: DialogState.Visible,
attributes: { id: 'headlessui-dialog-1' },
})

// Try to close the dialog
await press(Keys.Escape)

// Verify it is still open
assertDialog({ state: DialogState.Visible })
})
)
})
})

Expand Down
23 changes: 11 additions & 12 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ import React, {
useMemo,
useReducer,
useRef,
useState,

// Types
ContextType,
ElementType,
MouseEvent as ReactMouseEvent,
KeyboardEvent as ReactKeyboardEvent,
MutableRefObject,
Ref,
useState,
} from 'react'

import { Props } from '../../types'
Expand Down Expand Up @@ -217,6 +216,16 @@ let DialogRoot = forwardRefWithAs(function Dialog<
close()
})

// Handle `Escape` to close
useWindowEvent('keydown', event => {
if (event.key !== Keys.Escape) return
if (dialogState !== DialogStates.Open) return
if (hasNestedDialogs) return
event.preventDefault()
event.stopPropagation()
close()
})

// Scroll lock
useEffect(() => {
if (dialogState !== DialogStates.Open) return
Expand Down Expand Up @@ -282,16 +291,6 @@ let DialogRoot = forwardRefWithAs(function Dialog<
onClick(event: ReactMouseEvent) {
event.stopPropagation()
},

// Handle `Escape` to close
onKeyDown(event: ReactKeyboardEvent) {
if (event.key !== Keys.Escape) return
if (dialogState !== DialogStates.Open) return
if (hasNestedDialogs) return
event.preventDefault()
event.stopPropagation()
close()
},
}
let passthroughProps = rest

Expand Down
105 changes: 105 additions & 0 deletions packages/@headlessui-vue/src/components/dialog/dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,111 @@ describe('Keyboard interactions', () => {
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)

it(
'should be possible to close the dialog with Escape, when a field is focused',
suppressConsoleLogs(async () => {
renderTemplate({
template: `
<div>
<button id="trigger" @click="toggleOpen">
Trigger
</button>
<Dialog :open="isOpen" @close="setIsOpen">
Contents
<input id="name" />
<TabSentinel />
</Dialog>
</div>
`,
setup() {
let isOpen = ref(false)
return {
isOpen,
setIsOpen(value: boolean) {
isOpen.value = value
},
toggleOpen() {
isOpen.value = !isOpen.value
},
}
},
})

assertDialog({ state: DialogState.InvisibleUnmounted })

// Open dialog
await click(document.getElementById('trigger'))

// Verify it is open
assertDialog({
state: DialogState.Visible,
attributes: { id: 'headlessui-dialog-1' },
})

// Close dialog
await press(Keys.Escape)

// Verify it is close
assertDialog({ state: DialogState.InvisibleUnmounted })
})
)

it(
'should not be possible to close the dialog with Escape, when a field is focused but cancels the event',
suppressConsoleLogs(async () => {
renderTemplate({
template: `
<div>
<button id="trigger" @click="toggleOpen">
Trigger
</button>
<Dialog :open="isOpen" @close="setIsOpen">
Contents
<input
id="name"
@keydown="cancel"
/>
<TabSentinel />
</Dialog>
</div>
`,
setup() {
let isOpen = ref(false)
return {
isOpen,
setIsOpen(value: boolean) {
isOpen.value = value
},
toggleOpen() {
isOpen.value = !isOpen.value
},
cancel(event: KeyboardEvent) {
event.preventDefault()
event.stopPropagation()
},
}
},
})

assertDialog({ state: DialogState.InvisibleUnmounted })

// Open dialog
await click(document.getElementById('trigger'))

// Verify it is open
assertDialog({
state: DialogState.Visible,
attributes: { id: 'headlessui-dialog-1' },
})

// Try to close the dialog
await press(Keys.Escape)

// Verify it is still open
assertDialog({ state: DialogState.Visible })
})
)
})
})

Expand Down
21 changes: 10 additions & 11 deletions packages/@headlessui-vue/src/components/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ export let Dialog = defineComponent({
'aria-labelledby': this.titleId,
'aria-describedby': this.describedby,
onClick: this.handleClick,
onKeydown: this.handleKeyDown,
}
let { open: _, initialFocus, ...passThroughProps } = this.$props

Expand Down Expand Up @@ -205,6 +204,16 @@ export let Dialog = defineComponent({
nextTick(() => target?.focus())
})

// Handle `Escape` to close
useWindowEvent('keydown', event => {
if (event.key !== Keys.Escape) return
if (dialogState.value !== DialogStates.Open) return
if (containers.value.size > 1) return // 1 is myself, otherwise other elements in the Stack
event.preventDefault()
event.stopPropagation()
api.close()
})

// Scroll lock
watchEffect(onInvalidate => {
if (dialogState.value !== DialogStates.Open) return
Expand Down Expand Up @@ -260,16 +269,6 @@ export let Dialog = defineComponent({
handleClick(event: MouseEvent) {
event.stopPropagation()
},

// Handle `Escape` to close
handleKeyDown(event: KeyboardEvent) {
if (event.key !== Keys.Escape) return
if (dialogState.value !== DialogStates.Open) return
if (containers.value.size > 1) return // 1 is myself, otherwise other elements in the Stack
event.preventDefault()
event.stopPropagation()
api.close()
},
}
},
})
Expand Down