Skip to content

Commit bd3bc6c

Browse files
authored
Merge pull request #713 from tailwindlabs/develop
Next release
2 parents d25f80a + 3f14839 commit bd3bc6c

39 files changed

+1288
-118
lines changed

CHANGELOG.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased - React]
99

10-
- Nothing yet!
10+
### Fixes
11+
12+
- Only add `type=button` to real buttons ([#709](https://github.com/tailwindlabs/headlessui/pull/709))
13+
- Fix `escape` bug not closing Dialog after clicking in Dialog ([#754](https://github.com/tailwindlabs/headlessui/pull/754))
14+
- Use `console.warn` instead of throwing an error when there are no focusable elements ([#775](https://github.com/tailwindlabs/headlessui/pull/775))
1115

1216
## [Unreleased - Vue]
1317

14-
- Nothing yet!
18+
### Fixes
19+
20+
- Only add `type=button` to real buttons ([#709](https://github.com/tailwindlabs/headlessui/pull/709))
21+
- Add Vue emit types ([#679](https://github.com/tailwindlabs/headlessui/pull/679), [#712](https://github.com/tailwindlabs/headlessui/pull/712))
22+
- Fix `escape` bug not closing Dialog after clicking in Dialog ([#754](https://github.com/tailwindlabs/headlessui/pull/754))
23+
- Use `console.warn` instead of throwing an error when there are no focusable elements ([#775](https://github.com/tailwindlabs/headlessui/pull/775))
1524

1625
## [@headlessui/react@v1.4.0] - 2021-07-29
1726

packages/@headlessui-react/src/components/dialog/dialog.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,90 @@ describe('Keyboard interactions', () => {
430430
assertDialog({ state: DialogState.InvisibleUnmounted })
431431
})
432432
)
433+
434+
it(
435+
'should be possible to close the dialog with Escape, when a field is focused',
436+
suppressConsoleLogs(async () => {
437+
function Example() {
438+
let [isOpen, setIsOpen] = useState(false)
439+
return (
440+
<>
441+
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
442+
Trigger
443+
</button>
444+
<Dialog open={isOpen} onClose={setIsOpen}>
445+
Contents
446+
<input id="name" />
447+
<TabSentinel />
448+
</Dialog>
449+
</>
450+
)
451+
}
452+
render(<Example />)
453+
454+
assertDialog({ state: DialogState.InvisibleUnmounted })
455+
456+
// Open dialog
457+
await click(document.getElementById('trigger'))
458+
459+
// Verify it is open
460+
assertDialog({
461+
state: DialogState.Visible,
462+
attributes: { id: 'headlessui-dialog-1' },
463+
})
464+
465+
// Close dialog
466+
await press(Keys.Escape)
467+
468+
// Verify it is close
469+
assertDialog({ state: DialogState.InvisibleUnmounted })
470+
})
471+
)
472+
473+
it(
474+
'should not be possible to close the dialog with Escape, when a field is focused but cancels the event',
475+
suppressConsoleLogs(async () => {
476+
function Example() {
477+
let [isOpen, setIsOpen] = useState(false)
478+
return (
479+
<>
480+
<button id="trigger" onClick={() => setIsOpen(v => !v)}>
481+
Trigger
482+
</button>
483+
<Dialog open={isOpen} onClose={setIsOpen}>
484+
Contents
485+
<input
486+
id="name"
487+
onKeyDown={event => {
488+
event.preventDefault()
489+
event.stopPropagation()
490+
}}
491+
/>
492+
<TabSentinel />
493+
</Dialog>
494+
</>
495+
)
496+
}
497+
render(<Example />)
498+
499+
assertDialog({ state: DialogState.InvisibleUnmounted })
500+
501+
// Open dialog
502+
await click(document.getElementById('trigger'))
503+
504+
// Verify it is open
505+
assertDialog({
506+
state: DialogState.Visible,
507+
attributes: { id: 'headlessui-dialog-1' },
508+
})
509+
510+
// Try to close the dialog
511+
await press(Keys.Escape)
512+
513+
// Verify it is still open
514+
assertDialog({ state: DialogState.Visible })
515+
})
516+
)
433517
})
434518
})
435519

packages/@headlessui-react/src/components/dialog/dialog.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@ import React, {
77
useMemo,
88
useReducer,
99
useRef,
10+
useState,
1011

1112
// Types
1213
ContextType,
1314
ElementType,
1415
MouseEvent as ReactMouseEvent,
15-
KeyboardEvent as ReactKeyboardEvent,
1616
MutableRefObject,
1717
Ref,
18-
useState,
1918
} from 'react'
2019

2120
import { Props } from '../../types'
@@ -217,6 +216,16 @@ let DialogRoot = forwardRefWithAs(function Dialog<
217216
close()
218217
})
219218

219+
// Handle `Escape` to close
220+
useWindowEvent('keydown', event => {
221+
if (event.key !== Keys.Escape) return
222+
if (dialogState !== DialogStates.Open) return
223+
if (hasNestedDialogs) return
224+
event.preventDefault()
225+
event.stopPropagation()
226+
close()
227+
})
228+
220229
// Scroll lock
221230
useEffect(() => {
222231
if (dialogState !== DialogStates.Open) return
@@ -282,16 +291,6 @@ let DialogRoot = forwardRefWithAs(function Dialog<
282291
onClick(event: ReactMouseEvent) {
283292
event.stopPropagation()
284293
},
285-
286-
// Handle `Escape` to close
287-
onKeyDown(event: ReactKeyboardEvent) {
288-
if (event.key !== Keys.Escape) return
289-
if (dialogState !== DialogStates.Open) return
290-
if (hasNestedDialogs) return
291-
event.preventDefault()
292-
event.stopPropagation()
293-
close()
294-
},
295294
}
296295
let passthroughProps = rest
297296

packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jest.mock('../../hooks/use-id')
2020
afterAll(() => jest.restoreAllMocks())
2121

2222
function nextFrame() {
23-
return new Promise(resolve => {
23+
return new Promise<void>(resolve => {
2424
requestAnimationFrame(() => {
2525
requestAnimationFrame(() => {
2626
resolve()
@@ -296,6 +296,66 @@ describe('Rendering', () => {
296296
assertDisclosurePanel({ state: DisclosureState.Visible })
297297
})
298298
)
299+
300+
describe('`type` attribute', () => {
301+
it('should set the `type` to "button" by default', async () => {
302+
render(
303+
<Disclosure>
304+
<Disclosure.Button>Trigger</Disclosure.Button>
305+
</Disclosure>
306+
)
307+
308+
expect(getDisclosureButton()).toHaveAttribute('type', 'button')
309+
})
310+
311+
it('should not set the `type` to "button" if it already contains a `type`', async () => {
312+
render(
313+
<Disclosure>
314+
<Disclosure.Button type="submit">Trigger</Disclosure.Button>
315+
</Disclosure>
316+
)
317+
318+
expect(getDisclosureButton()).toHaveAttribute('type', 'submit')
319+
})
320+
321+
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
322+
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
323+
<button ref={ref} {...props} />
324+
))
325+
326+
render(
327+
<Disclosure>
328+
<Disclosure.Button as={CustomButton}>Trigger</Disclosure.Button>
329+
</Disclosure>
330+
)
331+
332+
expect(getDisclosureButton()).toHaveAttribute('type', 'button')
333+
})
334+
335+
it('should not set the type if the "as" prop is not a "button"', async () => {
336+
render(
337+
<Disclosure>
338+
<Disclosure.Button as="div">Trigger</Disclosure.Button>
339+
</Disclosure>
340+
)
341+
342+
expect(getDisclosureButton()).not.toHaveAttribute('type')
343+
})
344+
345+
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
346+
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
347+
<div ref={ref} {...props} />
348+
))
349+
350+
render(
351+
<Disclosure>
352+
<Disclosure.Button as={CustomButton}>Trigger</Disclosure.Button>
353+
</Disclosure>
354+
)
355+
356+
expect(getDisclosureButton()).not.toHaveAttribute('type')
357+
})
358+
})
299359
})
300360

301361
describe('Disclosure.Panel', () => {

packages/@headlessui-react/src/components/disclosure/disclosure.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, {
77
useEffect,
88
useMemo,
99
useReducer,
10+
useRef,
1011

1112
// Types
1213
Dispatch,
@@ -26,6 +27,7 @@ import { useId } from '../../hooks/use-id'
2627
import { Keys } from '../keyboard'
2728
import { isDisabledReactIssue7711 } from '../../utils/bugs'
2829
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
30+
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2931

3032
enum DisclosureStates {
3133
Open,
@@ -226,7 +228,8 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
226228
ref: Ref<HTMLButtonElement>
227229
) {
228230
let [state, dispatch] = useDisclosureContext([Disclosure.name, Button.name].join('.'))
229-
let buttonRef = useSyncRefs(ref)
231+
let internalButtonRef = useRef<HTMLButtonElement | null>(null)
232+
let buttonRef = useSyncRefs(internalButtonRef, ref)
230233

231234
let panelContext = useDisclosurePanelContext()
232235
let isWithinPanel = panelContext === null ? false : panelContext === state.panelId
@@ -290,13 +293,14 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
290293
[state]
291294
)
292295

296+
let type = useResolveButtonType(props, internalButtonRef)
293297
let passthroughProps = props
294298
let propsWeControl = isWithinPanel
295-
? { type: 'button', onKeyDown: handleKeyDown, onClick: handleClick }
299+
? { ref: buttonRef, type, onKeyDown: handleKeyDown, onClick: handleClick }
296300
: {
297301
ref: buttonRef,
298302
id: state.buttonId,
299-
type: 'button',
303+
type,
300304
'aria-expanded': props.disabled
301305
? undefined
302306
: state.disclosureState === DisclosureStates.Open,

packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,19 @@ it('should focus the initialFocus element inside the FocusTrap even if another e
6262
assertActiveElement(document.getElementById('c'))
6363
})
6464

65-
it(
66-
'should error when there is no focusable element inside the FocusTrap',
67-
suppressConsoleLogs(() => {
68-
expect(() => {
69-
render(
70-
<FocusTrap>
71-
<span>Nothing to see here...</span>
72-
</FocusTrap>
73-
)
74-
}).toThrowErrorMatchingInlineSnapshot(
75-
`"There are no focusable elements inside the <FocusTrap />"`
65+
it('should warn when there is no focusable element inside the FocusTrap', () => {
66+
let spy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
67+
68+
function Example() {
69+
return (
70+
<FocusTrap>
71+
<span>Nothing to see here...</span>
72+
</FocusTrap>
7673
)
77-
})
78-
)
74+
}
75+
render(<Example />)
76+
expect(spy.mock.calls[0][0]).toBe('There are no focusable elements inside the <FocusTrap />')
77+
})
7978

8079
it(
8180
'should not be possible to programmatically escape the focus trap',

packages/@headlessui-react/src/components/listbox/listbox.test.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,66 @@ describe('Rendering', () => {
326326
assertListboxButtonLinkedWithListboxLabel()
327327
})
328328
)
329+
330+
describe('`type` attribute', () => {
331+
it('should set the `type` to "button" by default', async () => {
332+
render(
333+
<Listbox value={null} onChange={console.log}>
334+
<Listbox.Button>Trigger</Listbox.Button>
335+
</Listbox>
336+
)
337+
338+
expect(getListboxButton()).toHaveAttribute('type', 'button')
339+
})
340+
341+
it('should not set the `type` to "button" if it already contains a `type`', async () => {
342+
render(
343+
<Listbox value={null} onChange={console.log}>
344+
<Listbox.Button type="submit">Trigger</Listbox.Button>
345+
</Listbox>
346+
)
347+
348+
expect(getListboxButton()).toHaveAttribute('type', 'submit')
349+
})
350+
351+
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
352+
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
353+
<button ref={ref} {...props} />
354+
))
355+
356+
render(
357+
<Listbox value={null} onChange={console.log}>
358+
<Listbox.Button as={CustomButton}>Trigger</Listbox.Button>
359+
</Listbox>
360+
)
361+
362+
expect(getListboxButton()).toHaveAttribute('type', 'button')
363+
})
364+
365+
it('should not set the type if the "as" prop is not a "button"', async () => {
366+
render(
367+
<Listbox value={null} onChange={console.log}>
368+
<Listbox.Button as="div">Trigger</Listbox.Button>
369+
</Listbox>
370+
)
371+
372+
expect(getListboxButton()).not.toHaveAttribute('type')
373+
})
374+
375+
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
376+
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
377+
<div ref={ref} {...props} />
378+
))
379+
380+
render(
381+
<Listbox value={null} onChange={console.log}>
382+
<Listbox.Button as={CustomButton}>Trigger</Listbox.Button>
383+
</Listbox>
384+
)
385+
386+
expect(getListboxButton()).not.toHaveAttribute('type')
387+
})
388+
})
329389
})
330390

331391
describe('Listbox.Options', () => {

0 commit comments

Comments
 (0)