Skip to content

Commit c111784

Browse files
authored
Only add type=button for real buttons (#709)
* add `{type:'button'}` only for buttons We will try and infer the type based on the passed in `props.as` prop or the default tag. However, when somebody uses `as={CustomComponent}` then we don't know what it will render. Therefore we have to pass it a ref and check if the final result is a button or not. If it is, and it doesn't have a `type` yet, then we can set the `type` correctly. * update changelog
1 parent d25f80a commit c111784

28 files changed

+1026
-53
lines changed

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ 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))
1113

1214
## [Unreleased - Vue]
1315

14-
- Nothing yet!
16+
### Fixes
17+
18+
- Only add `type=button` to real buttons ([#709](https://github.com/tailwindlabs/headlessui/pull/709))
1519

1620
## [@headlessui/react@v1.4.0] - 2021-07-29
1721

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/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', () => {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { isDisabledReactIssue7711 } from '../../utils/bugs'
3232
import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
3333
import { useWindowEvent } from '../../hooks/use-window-event'
3434
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
35+
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3536

3637
enum ListboxStates {
3738
Open,
@@ -370,7 +371,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
370371
let propsWeControl = {
371372
ref: buttonRef,
372373
id,
373-
type: 'button',
374+
type: useResolveButtonType(props, state.buttonRef),
374375
'aria-haspopup': true,
375376
'aria-controls': state.optionsRef.current?.id,
376377
'aria-expanded': state.disabled ? undefined : state.listboxState === ListboxStates.Open,

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,65 @@ describe('Rendering', () => {
184184
assertMenu({ state: MenuState.Visible })
185185
})
186186
)
187+
describe('`type` attribute', () => {
188+
it('should set the `type` to "button" by default', async () => {
189+
render(
190+
<Menu>
191+
<Menu.Button>Trigger</Menu.Button>
192+
</Menu>
193+
)
194+
195+
expect(getMenuButton()).toHaveAttribute('type', 'button')
196+
})
197+
198+
it('should not set the `type` to "button" if it already contains a `type`', async () => {
199+
render(
200+
<Menu>
201+
<Menu.Button type="submit">Trigger</Menu.Button>
202+
</Menu>
203+
)
204+
205+
expect(getMenuButton()).toHaveAttribute('type', 'submit')
206+
})
207+
208+
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
209+
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
210+
<button ref={ref} {...props} />
211+
))
212+
213+
render(
214+
<Menu>
215+
<Menu.Button as={CustomButton}>Trigger</Menu.Button>
216+
</Menu>
217+
)
218+
219+
expect(getMenuButton()).toHaveAttribute('type', 'button')
220+
})
221+
222+
it('should not set the type if the "as" prop is not a "button"', async () => {
223+
render(
224+
<Menu>
225+
<Menu.Button as="div">Trigger</Menu.Button>
226+
</Menu>
227+
)
228+
229+
expect(getMenuButton()).not.toHaveAttribute('type')
230+
})
231+
232+
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
233+
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
234+
<div ref={ref} {...props} />
235+
))
236+
237+
render(
238+
<Menu>
239+
<Menu.Button as={CustomButton}>Trigger</Menu.Button>
240+
</Menu>
241+
)
242+
243+
expect(getMenuButton()).not.toHaveAttribute('type')
244+
})
245+
})
187246
})
188247

189248
describe('Menu.Items', () => {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
3434
import { useWindowEvent } from '../../hooks/use-window-event'
3535
import { useTreeWalker } from '../../hooks/use-tree-walker'
3636
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
37+
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3738

3839
enum MenuStates {
3940
Open,
@@ -294,7 +295,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
294295
let propsWeControl = {
295296
ref: buttonRef,
296297
id,
297-
type: 'button',
298+
type: useResolveButtonType(props, state.buttonRef),
298299
'aria-haspopup': true,
299300
'aria-controls': state.itemsRef.current?.id,
300301
'aria-expanded': props.disabled ? undefined : state.menuState === MenuStates.Open,

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

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

2525
function nextFrame() {
26-
return new Promise(resolve => {
26+
return new Promise<void>(resolve => {
2727
requestAnimationFrame(() => {
2828
requestAnimationFrame(() => {
2929
resolve()
@@ -319,6 +319,66 @@ describe('Rendering', () => {
319319
assertPopoverPanel({ state: PopoverState.Visible })
320320
})
321321
)
322+
323+
describe('`type` attribute', () => {
324+
it('should set the `type` to "button" by default', async () => {
325+
render(
326+
<Popover>
327+
<Popover.Button>Trigger</Popover.Button>
328+
</Popover>
329+
)
330+
331+
expect(getPopoverButton()).toHaveAttribute('type', 'button')
332+
})
333+
334+
it('should not set the `type` to "button" if it already contains a `type`', async () => {
335+
render(
336+
<Popover>
337+
<Popover.Button type="submit">Trigger</Popover.Button>
338+
</Popover>
339+
)
340+
341+
expect(getPopoverButton()).toHaveAttribute('type', 'submit')
342+
})
343+
344+
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
345+
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
346+
<button ref={ref} {...props} />
347+
))
348+
349+
render(
350+
<Popover>
351+
<Popover.Button as={CustomButton}>Trigger</Popover.Button>
352+
</Popover>
353+
)
354+
355+
expect(getPopoverButton()).toHaveAttribute('type', 'button')
356+
})
357+
358+
it('should not set the type if the "as" prop is not a "button"', async () => {
359+
render(
360+
<Popover>
361+
<Popover.Button as="div">Trigger</Popover.Button>
362+
</Popover>
363+
)
364+
365+
expect(getPopoverButton()).not.toHaveAttribute('type')
366+
})
367+
368+
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
369+
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
370+
<div ref={ref} {...props} />
371+
))
372+
373+
render(
374+
<Popover>
375+
<Popover.Button as={CustomButton}>Trigger</Popover.Button>
376+
</Popover>
377+
)
378+
379+
expect(getPopoverButton()).not.toHaveAttribute('type')
380+
})
381+
})
322382
})
323383

324384
describe('Popover.Panel', () => {

0 commit comments

Comments
 (0)