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
This commit is contained in:
Robin Malfait
2021-08-02 13:57:58 +02:00
committed by GitHub
parent d25f80ae9d
commit c1117840fd
28 changed files with 1026 additions and 53 deletions
+6 -2
View File
@@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased - React]
- Nothing yet!
### Fixes
- Only add `type=button` to real buttons ([#709](https://github.com/tailwindlabs/headlessui/pull/709))
## [Unreleased - Vue]
- Nothing yet!
### Fixes
- Only add `type=button` to real buttons ([#709](https://github.com/tailwindlabs/headlessui/pull/709))
## [@headlessui/react@v1.4.0] - 2021-07-29
@@ -20,7 +20,7 @@ jest.mock('../../hooks/use-id')
afterAll(() => jest.restoreAllMocks())
function nextFrame() {
return new Promise(resolve => {
return new Promise<void>(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve()
@@ -296,6 +296,66 @@ describe('Rendering', () => {
assertDisclosurePanel({ state: DisclosureState.Visible })
})
)
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
render(
<Disclosure>
<Disclosure.Button>Trigger</Disclosure.Button>
</Disclosure>
)
expect(getDisclosureButton()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
render(
<Disclosure>
<Disclosure.Button type="submit">Trigger</Disclosure.Button>
</Disclosure>
)
expect(getDisclosureButton()).toHaveAttribute('type', 'submit')
})
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
<button ref={ref} {...props} />
))
render(
<Disclosure>
<Disclosure.Button as={CustomButton}>Trigger</Disclosure.Button>
</Disclosure>
)
expect(getDisclosureButton()).toHaveAttribute('type', 'button')
})
it('should not set the type if the "as" prop is not a "button"', async () => {
render(
<Disclosure>
<Disclosure.Button as="div">Trigger</Disclosure.Button>
</Disclosure>
)
expect(getDisclosureButton()).not.toHaveAttribute('type')
})
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
<div ref={ref} {...props} />
))
render(
<Disclosure>
<Disclosure.Button as={CustomButton}>Trigger</Disclosure.Button>
</Disclosure>
)
expect(getDisclosureButton()).not.toHaveAttribute('type')
})
})
})
describe('Disclosure.Panel', () => {
@@ -7,6 +7,7 @@ import React, {
useEffect,
useMemo,
useReducer,
useRef,
// Types
Dispatch,
@@ -26,6 +27,7 @@ import { useId } from '../../hooks/use-id'
import { Keys } from '../keyboard'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum DisclosureStates {
Open,
@@ -226,7 +228,8 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
ref: Ref<HTMLButtonElement>
) {
let [state, dispatch] = useDisclosureContext([Disclosure.name, Button.name].join('.'))
let buttonRef = useSyncRefs(ref)
let internalButtonRef = useRef<HTMLButtonElement | null>(null)
let buttonRef = useSyncRefs(internalButtonRef, ref)
let panelContext = useDisclosurePanelContext()
let isWithinPanel = panelContext === null ? false : panelContext === state.panelId
@@ -290,13 +293,14 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
[state]
)
let type = useResolveButtonType(props, internalButtonRef)
let passthroughProps = props
let propsWeControl = isWithinPanel
? { type: 'button', onKeyDown: handleKeyDown, onClick: handleClick }
? { ref: buttonRef, type, onKeyDown: handleKeyDown, onClick: handleClick }
: {
ref: buttonRef,
id: state.buttonId,
type: 'button',
type,
'aria-expanded': props.disabled
? undefined
: state.disclosureState === DisclosureStates.Open,
@@ -326,6 +326,66 @@ describe('Rendering', () => {
assertListboxButtonLinkedWithListboxLabel()
})
)
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
render(
<Listbox value={null} onChange={console.log}>
<Listbox.Button>Trigger</Listbox.Button>
</Listbox>
)
expect(getListboxButton()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
render(
<Listbox value={null} onChange={console.log}>
<Listbox.Button type="submit">Trigger</Listbox.Button>
</Listbox>
)
expect(getListboxButton()).toHaveAttribute('type', 'submit')
})
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
<button ref={ref} {...props} />
))
render(
<Listbox value={null} onChange={console.log}>
<Listbox.Button as={CustomButton}>Trigger</Listbox.Button>
</Listbox>
)
expect(getListboxButton()).toHaveAttribute('type', 'button')
})
it('should not set the type if the "as" prop is not a "button"', async () => {
render(
<Listbox value={null} onChange={console.log}>
<Listbox.Button as="div">Trigger</Listbox.Button>
</Listbox>
)
expect(getListboxButton()).not.toHaveAttribute('type')
})
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
<div ref={ref} {...props} />
))
render(
<Listbox value={null} onChange={console.log}>
<Listbox.Button as={CustomButton}>Trigger</Listbox.Button>
</Listbox>
)
expect(getListboxButton()).not.toHaveAttribute('type')
})
})
})
describe('Listbox.Options', () => {
@@ -32,6 +32,7 @@ import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum ListboxStates {
Open,
@@ -370,7 +371,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let propsWeControl = {
ref: buttonRef,
id,
type: 'button',
type: useResolveButtonType(props, state.buttonRef),
'aria-haspopup': true,
'aria-controls': state.optionsRef.current?.id,
'aria-expanded': state.disabled ? undefined : state.listboxState === ListboxStates.Open,
@@ -184,6 +184,65 @@ describe('Rendering', () => {
assertMenu({ state: MenuState.Visible })
})
)
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
render(
<Menu>
<Menu.Button>Trigger</Menu.Button>
</Menu>
)
expect(getMenuButton()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
render(
<Menu>
<Menu.Button type="submit">Trigger</Menu.Button>
</Menu>
)
expect(getMenuButton()).toHaveAttribute('type', 'submit')
})
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
<button ref={ref} {...props} />
))
render(
<Menu>
<Menu.Button as={CustomButton}>Trigger</Menu.Button>
</Menu>
)
expect(getMenuButton()).toHaveAttribute('type', 'button')
})
it('should not set the type if the "as" prop is not a "button"', async () => {
render(
<Menu>
<Menu.Button as="div">Trigger</Menu.Button>
</Menu>
)
expect(getMenuButton()).not.toHaveAttribute('type')
})
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
<div ref={ref} {...props} />
))
render(
<Menu>
<Menu.Button as={CustomButton}>Trigger</Menu.Button>
</Menu>
)
expect(getMenuButton()).not.toHaveAttribute('type')
})
})
})
describe('Menu.Items', () => {
@@ -34,6 +34,7 @@ import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum MenuStates {
Open,
@@ -294,7 +295,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let propsWeControl = {
ref: buttonRef,
id,
type: 'button',
type: useResolveButtonType(props, state.buttonRef),
'aria-haspopup': true,
'aria-controls': state.itemsRef.current?.id,
'aria-expanded': props.disabled ? undefined : state.menuState === MenuStates.Open,
@@ -23,7 +23,7 @@ jest.mock('../../hooks/use-id')
afterAll(() => jest.restoreAllMocks())
function nextFrame() {
return new Promise(resolve => {
return new Promise<void>(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve()
@@ -319,6 +319,66 @@ describe('Rendering', () => {
assertPopoverPanel({ state: PopoverState.Visible })
})
)
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
render(
<Popover>
<Popover.Button>Trigger</Popover.Button>
</Popover>
)
expect(getPopoverButton()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
render(
<Popover>
<Popover.Button type="submit">Trigger</Popover.Button>
</Popover>
)
expect(getPopoverButton()).toHaveAttribute('type', 'submit')
})
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
<button ref={ref} {...props} />
))
render(
<Popover>
<Popover.Button as={CustomButton}>Trigger</Popover.Button>
</Popover>
)
expect(getPopoverButton()).toHaveAttribute('type', 'button')
})
it('should not set the type if the "as" prop is not a "button"', async () => {
render(
<Popover>
<Popover.Button as="div">Trigger</Popover.Button>
</Popover>
)
expect(getPopoverButton()).not.toHaveAttribute('type')
})
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
<div ref={ref} {...props} />
))
render(
<Popover>
<Popover.Button as={CustomButton}>Trigger</Popover.Button>
</Popover>
)
expect(getPopoverButton()).not.toHaveAttribute('type')
})
})
})
describe('Popover.Panel', () => {
@@ -35,6 +35,7 @@ import {
} from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum PopoverStates {
Open,
@@ -309,6 +310,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
ref,
isWithinPanel ? null : button => dispatch({ type: ActionTypes.SetButton, button })
)
let withinPanelButtonRef = useSyncRefs(internalButtonRef, ref)
// TODO: Revisit when handling Tab/Shift+Tab when using Portal's
let activeElementRef = useRef<Element | null>(null)
@@ -468,17 +470,19 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
[state]
)
let type = useResolveButtonType(props, internalButtonRef)
let passthroughProps = props
let propsWeControl = isWithinPanel
? {
type: 'button',
ref: withinPanelButtonRef,
type,
onKeyDown: handleKeyDown,
onClick: handleClick,
}
: {
ref: buttonRef,
id: state.buttonId,
type: 'button',
type,
'aria-expanded': props.disabled ? undefined : state.popoverState === PopoverStates.Open,
'aria-controls': state.panel ? state.panelId : undefined,
onKeyDown: handleKeyDown,
@@ -58,6 +58,66 @@ describe('Rendering', () => {
)
assertSwitch({ state: SwitchState.Off, label: 'Enable notifications' })
})
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
render(
<Switch checked={false} onChange={console.log}>
Trigger
</Switch>
)
expect(getSwitch()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
render(
<Switch checked={false} onChange={console.log} type="submit">
Trigger
</Switch>
)
expect(getSwitch()).toHaveAttribute('type', 'submit')
})
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
<button ref={ref} {...props} />
))
render(
<Switch checked={false} onChange={console.log} as={CustomButton}>
Trigger
</Switch>
)
expect(getSwitch()).toHaveAttribute('type', 'button')
})
it('should not set the type if the "as" prop is not a "button"', async () => {
render(
<Switch checked={false} onChange={console.log} as="div">
Trigger
</Switch>
)
expect(getSwitch()).not.toHaveAttribute('type')
})
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
<div ref={ref} {...props} />
))
render(
<Switch checked={false} onChange={console.log} as={CustomButton}>
Trigger
</Switch>
)
expect(getSwitch()).not.toHaveAttribute('type')
})
})
})
describe('Render composition', () => {
@@ -10,6 +10,7 @@ import React, {
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
useRef,
} from 'react'
import { Props } from '../../types'
@@ -19,6 +20,8 @@ import { Keys } from '../keyboard'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { Label, useLabels } from '../label/label'
import { Description, useDescriptions } from '../description/description'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useSyncRefs } from '../../hooks/use-sync-refs'
interface StateDefinition {
switch: HTMLButtonElement | null
@@ -90,6 +93,11 @@ export function Switch<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
let { checked, onChange, ...passThroughProps } = props
let id = `headlessui-switch-${useId()}`
let groupContext = useContext(GroupContext)
let internalSwitchRef = useRef<HTMLButtonElement | null>(null)
let switchRef = useSyncRefs(
internalSwitchRef,
groupContext === null ? null : groupContext.setSwitch
)
let toggle = useCallback(() => onChange(!checked), [onChange, checked])
let handleClick = useCallback(
@@ -117,8 +125,9 @@ export function Switch<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
let slot = useMemo<SwitchRenderPropArg>(() => ({ checked }), [checked])
let propsWeControl = {
id,
ref: groupContext === null ? undefined : groupContext.setSwitch,
ref: switchRef,
role: 'switch',
type: useResolveButtonType(props, internalSwitchRef),
tabIndex: 0,
'aria-checked': checked,
'aria-labelledby': groupContext?.labelledby,
@@ -128,10 +137,6 @@ export function Switch<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
onKeyPress: handleKeyPress,
}
if (passThroughProps.as === 'button') {
Object.assign(propsWeControl, { type: 'button' })
}
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
@@ -416,6 +416,78 @@ describe('Rendering', () => {
assertActiveElement(getByText('Tab 1'))
})
})
describe(`'Tab'`, () => {
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
render(
<Tab.Group>
<Tab.List>
<Tab>Trigger</Tab>
</Tab.List>
</Tab.Group>
)
expect(getTabs()[0]).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
render(
<Tab.Group>
<Tab.List>
<Tab type="submit">Trigger</Tab>
</Tab.List>
</Tab.Group>
)
expect(getTabs()[0]).toHaveAttribute('type', 'submit')
})
it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
let CustomButton = React.forwardRef<HTMLButtonElement>((props, ref) => (
<button ref={ref} {...props} />
))
render(
<Tab.Group>
<Tab.List>
<Tab as={CustomButton}>Trigger</Tab>
</Tab.List>
</Tab.Group>
)
expect(getTabs()[0]).toHaveAttribute('type', 'button')
})
it('should not set the type if the "as" prop is not a "button"', async () => {
render(
<Tab.Group>
<Tab.List>
<Tab as="div">Trigger</Tab>
</Tab.List>
</Tab.Group>
)
expect(getTabs()[0]).not.toHaveAttribute('type')
})
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
<div ref={ref} {...props} />
))
render(
<Tab.Group>
<Tab.List>
<Tab as={CustomButton}>Trigger</Tab>
</Tab.List>
</Tab.Group>
)
expect(getTabs()[0]).not.toHaveAttribute('type')
})
})
})
})
describe('Keyboard interactions', () => {
@@ -24,6 +24,7 @@ import { Keys } from '../../components/keyboard'
import { focusIn, Focus } from '../../utils/focus-management'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
interface StateDefinition {
selectedIndex: number | null
@@ -332,8 +333,6 @@ export function Tab<TTag extends ElementType = typeof DEFAULT_TAB_TAG>(
change(myIndex)
}, [change, myIndex, internalTabRef])
let type = props?.type ?? (props.as || DEFAULT_TAB_TAG) === 'button' ? 'button' : undefined
let slot = useMemo(() => ({ selected }), [selected])
let propsWeControl = {
ref: tabRef,
@@ -342,7 +341,7 @@ export function Tab<TTag extends ElementType = typeof DEFAULT_TAB_TAG>(
onClick: handleSelection,
id,
role: 'tab',
type,
type: useResolveButtonType(props, internalTabRef),
'aria-controls': panels[myIndex]?.current?.id,
'aria-selected': selected,
tabIndex: selected ? 0 : -1,
@@ -0,0 +1,34 @@
import { useState, MutableRefObject } from 'react'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
function resolveType<TTag>(props: { type?: string; as?: TTag }) {
if (props.type) return props.type
let tag = props.as ?? 'button'
if (typeof tag === 'string' && tag.toLowerCase() === 'button') return 'button'
return undefined
}
export function useResolveButtonType<TTag>(
props: { type?: string; as?: TTag },
ref: MutableRefObject<HTMLElement | null>
) {
let [type, setType] = useState(() => resolveType(props))
useIsoMorphicEffect(() => {
setType(resolveType(props))
}, [props.type, props.as])
useIsoMorphicEffect(() => {
if (type) return
if (!ref.current) return
if (ref.current instanceof HTMLButtonElement && !ref.current.hasAttribute('type')) {
setType('button')
}
}, [type, ref])
return type
}
@@ -1,7 +1,7 @@
import { useRef, useEffect, useCallback } from 'react'
export function useSyncRefs<TType>(
...refs: (React.MutableRefObject<TType> | ((instance: TType) => void) | null)[]
...refs: (React.MutableRefObject<TType | null> | ((instance: TType) => void) | null)[]
) {
let cache = useRef(refs)
@@ -7,7 +7,7 @@ function assertNever(x: never): never {
// ---
export function getMenuButton(): HTMLElement | null {
return document.querySelector('button,[role="button"]')
return document.querySelector('button,[role="button"],[id^="headlessui-menu-button-"]')
}
export function getMenuButtons(): HTMLElement[] {
@@ -226,7 +226,7 @@ export function getListboxLabel(): HTMLElement | null {
}
export function getListboxButton(): HTMLElement | null {
return document.querySelector('button,[role="button"]')
return document.querySelector('button,[role="button"],[id^="headlessui-listbox-button-"]')
}
export function getListboxButtons(): HTMLElement[] {
@@ -1,4 +1,4 @@
import { defineComponent, nextTick, ref, watch } from 'vue'
import { defineComponent, nextTick, ref, watch, h } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { Disclosure, DisclosureButton, DisclosurePanel } from './disclosure'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
@@ -291,6 +291,98 @@ describe('Rendering', () => {
assertDisclosurePanel({ state: DisclosureState.Visible })
})
)
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
renderTemplate(
html`
<Disclosure>
<DisclosureButton>
Trigger
</DisclosureButton>
</Disclosure>
`
)
expect(getDisclosureButton()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
renderTemplate(
html`
<Disclosure>
<DisclosureButton type="submit">
Trigger
</DisclosureButton>
</Disclosure>
`
)
expect(getDisclosureButton()).toHaveAttribute('type', 'submit')
})
it(
'should set the `type` to "button" when using the `as` prop which resolves to a "button"',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Disclosure>
<DisclosureButton :as="CustomButton">
Trigger
</DisclosureButton>
</Disclosure>
`,
setup: () => ({
CustomButton: defineComponent({
setup: props => () => h('button', { ...props }),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getDisclosureButton()).toHaveAttribute('type', 'button')
})
)
it('should not set the type if the "as" prop is not a "button"', async () => {
renderTemplate(
html`
<Disclosure>
<DisclosureButton as="div">
Trigger
</DisclosureButton>
</Disclosure>
`
)
expect(getDisclosureButton()).not.toHaveAttribute('type')
})
it(
'should not set the `type` to "button" when using the `as` prop which resolves to a "div"',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Disclosure>
<DisclosureButton :as="CustomButton">
Trigger
</DisclosureButton>
</Disclosure>
`,
setup: () => ({
CustomButton: defineComponent({
setup: props => () => h('div', props),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getDisclosureButton()).not.toHaveAttribute('type')
})
)
})
})
describe('DisclosurePanel', () => {
@@ -1,5 +1,14 @@
// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#disclosure
import { defineComponent, ref, provide, inject, InjectionKey, Ref, computed } from 'vue'
import {
defineComponent,
ref,
provide,
inject,
InjectionKey,
Ref,
computed,
watchEffect,
} from 'vue'
import { Keys } from '../../keyboard'
import { match } from '../../utils/match'
@@ -7,6 +16,7 @@ import { render, Features } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { dom } from '../../utils/dom'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum DisclosureStates {
Open,
@@ -129,14 +139,15 @@ export let DisclosureButton = defineComponent({
let slot = { open: api.disclosureState.value === DisclosureStates.Open }
let propsWeControl = this.isWithinPanel
? {
type: 'button',
ref: 'el',
type: this.type,
onClick: this.handleClick,
onKeydown: this.handleKeyDown,
}
: {
id: this.id,
ref: 'el',
type: 'button',
type: this.type,
'aria-expanded': this.$props.disabled
? undefined
: api.disclosureState.value === DisclosureStates.Open,
@@ -155,16 +166,28 @@ export let DisclosureButton = defineComponent({
name: 'DisclosureButton',
})
},
setup(props) {
setup(props, { attrs }) {
let api = useDisclosureContext('DisclosureButton')
let panelContext = useDisclosurePanelContext()
let isWithinPanel = panelContext === null ? false : panelContext === api.panelId
let elementRef = ref(null)
if (!isWithinPanel) {
watchEffect(() => {
api.button.value = elementRef.value
})
}
return {
isWithinPanel,
id: api.buttonId,
el: isWithinPanel ? undefined : api.button,
el: elementRef,
type: useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
elementRef
),
handleClick() {
if (props.disabled) return
@@ -1,4 +1,4 @@
import { defineComponent, nextTick, ref, watch } from 'vue'
import { defineComponent, nextTick, ref, watch, h } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } from './listbox'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
@@ -358,6 +358,101 @@ describe('Rendering', () => {
assertListboxButtonLinkedWithListboxLabel()
})
)
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
renderTemplate({
template: html`
<Listbox v-model="value">
<ListboxButton>Trigger</ListboxButton>
</Listbox>
`,
setup: () => ({ value: ref(null) }),
})
expect(getListboxButton()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
renderTemplate({
template: html`
<Listbox v-model="value">
<ListboxButton type="submit">
Trigger
</ListboxButton>
</Listbox>
`,
setup: () => ({ value: ref(null) }),
})
expect(getListboxButton()).toHaveAttribute('type', 'submit')
})
it(
'should set the `type` to "button" when using the `as` prop which resolves to a "button"',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Listbox v-model="value">
<ListboxButton :as="CustomButton">
Trigger
</ListboxButton>
</Listbox>
`,
setup: () => ({
value: ref(null),
CustomButton: defineComponent({
setup: props => () => h('button', { ...props }),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getListboxButton()).toHaveAttribute('type', 'button')
})
)
it('should not set the type if the "as" prop is not a "button"', async () => {
renderTemplate({
template: html`
<Listbox v-model="value">
<ListboxButton as="div">
Trigger
</ListboxButton>
</Listbox>
`,
setup: () => ({ value: ref(null) }),
})
expect(getListboxButton()).not.toHaveAttribute('type')
})
it(
'should not set the `type` to "button" when using the `as` prop which resolves to a "div"',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Listbox v-model="value">
<ListboxButton :as="CustomButton">
Trigger
</ListboxButton>
</Listbox>
`,
setup: () => ({
value: ref(null),
CustomButton: defineComponent({
setup: props => () => h('div', props),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getListboxButton()).not.toHaveAttribute('type')
})
)
})
})
describe('ListboxOptions', () => {
@@ -23,6 +23,7 @@ import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed'
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum ListboxStates {
Open,
@@ -268,7 +269,7 @@ export let ListboxButton = defineComponent({
let propsWeControl = {
ref: 'el',
id: this.id,
type: 'button',
type: this.type,
'aria-haspopup': true,
'aria-controls': dom(api.optionsRef)?.id,
'aria-expanded': api.disabled.value
@@ -291,7 +292,7 @@ export let ListboxButton = defineComponent({
name: 'ListboxButton',
})
},
setup() {
setup(props, { attrs }) {
let api = useListboxContext('ListboxButton')
let id = `headlessui-listbox-button-${useId()}`
@@ -344,7 +345,17 @@ export let ListboxButton = defineComponent({
}
}
return { id, el: api.buttonRef, handleKeyDown, handleKeyUp, handleClick }
return {
id,
el: api.buttonRef,
type: useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
api.buttonRef
),
handleKeyDown,
handleKeyUp,
handleClick,
}
},
})
@@ -353,6 +353,96 @@ describe('Rendering', () => {
})
})
)
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
renderTemplate(
jsx`
<Menu>
<MenuButton>Trigger</MenuButton>
</Menu>
`
)
expect(getMenuButton()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
renderTemplate(
jsx`
<Menu>
<MenuButton type="submit">
Trigger
</MenuButton>
</Menu>
`
)
expect(getMenuButton()).toHaveAttribute('type', 'submit')
})
it(
'should set the `type` to "button" when using the `as` prop which resolves to a "button"',
suppressConsoleLogs(async () => {
renderTemplate({
template: jsx`
<Menu>
<MenuButton :as="CustomButton">
Trigger
</MenuButton>
</Menu>
`,
setup: () => ({
CustomButton: defineComponent({
setup: props => () => h('button', { ...props }),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getMenuButton()).toHaveAttribute('type', 'button')
})
)
it('should not set the type if the "as" prop is not a "button"', async () => {
renderTemplate(
jsx`
<Menu>
<MenuButton as="div">
Trigger
</MenuButton>
</Menu>
`
)
expect(getMenuButton()).not.toHaveAttribute('type')
})
it(
'should not set the `type` to "button" when using the `as` prop which resolves to a "div"',
suppressConsoleLogs(async () => {
renderTemplate({
template: jsx`
<Menu>
<MenuButton :as="CustomButton">
Trigger
</MenuButton>
</Menu>
`,
setup: () => ({
CustomButton: defineComponent({
setup: props => () => h('div', props),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getMenuButton()).not.toHaveAttribute('type')
})
)
})
})
describe('MenuItems', () => {
@@ -20,6 +20,7 @@ import { useWindowEvent } from '../../hooks/use-window-event'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum MenuStates {
Open,
@@ -183,7 +184,7 @@ export let MenuButton = defineComponent({
let propsWeControl = {
ref: 'el',
id: this.id,
type: 'button',
type: this.type,
'aria-haspopup': true,
'aria-controls': dom(api.itemsRef)?.id,
'aria-expanded': this.$props.disabled ? undefined : api.menuState.value === MenuStates.Open,
@@ -200,7 +201,7 @@ export let MenuButton = defineComponent({
name: 'MenuButton',
})
},
setup(props) {
setup(props, { attrs }) {
let api = useMenuContext('MenuButton')
let id = `headlessui-menu-button-${useId()}`
@@ -256,7 +257,17 @@ export let MenuButton = defineComponent({
}
}
return { id, el: api.buttonRef, handleKeyDown, handleKeyUp, handleClick }
return {
id,
el: api.buttonRef,
type: useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
api.buttonRef
),
handleKeyDown,
handleKeyUp,
handleClick,
}
},
})
@@ -1,4 +1,4 @@
import { defineComponent, nextTick, ref, watch } from 'vue'
import { defineComponent, nextTick, ref, watch, h } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { Popover, PopoverGroup, PopoverButton, PopoverPanel, PopoverOverlay } from './popover'
@@ -327,6 +327,96 @@ describe('Rendering', () => {
assertPopoverPanel({ state: PopoverState.Visible })
})
)
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
renderTemplate(
html`
<Popover>
<PopoverButton>Trigger</PopoverButton>
</Popover>
`
)
expect(getPopoverButton()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
renderTemplate(
html`
<Popover>
<PopoverButton type="submit">
Trigger
</PopoverButton>
</Popover>
`
)
expect(getPopoverButton()).toHaveAttribute('type', 'submit')
})
it(
'should set the `type` to "button" when using the `as` prop which resolves to a "button"',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Popover>
<PopoverButton :as="CustomButton">
Trigger
</PopoverButton>
</Popover>
`,
setup: () => ({
CustomButton: defineComponent({
setup: props => () => h('button', { ...props }),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getPopoverButton()).toHaveAttribute('type', 'button')
})
)
it('should not set the type if the "as" prop is not a "button"', async () => {
renderTemplate(
html`
<Popover>
<PopoverButton as="div">
Trigger
</PopoverButton>
</Popover>
`
)
expect(getPopoverButton()).not.toHaveAttribute('type')
})
it(
'should not set the `type` to "button" when using the `as` prop which resolves to a "div"',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Popover>
<PopoverButton :as="CustomButton">
Trigger
</PopoverButton>
</Popover>
`,
setup: () => ({
CustomButton: defineComponent({
setup: props => () => h('div', props),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getPopoverButton()).not.toHaveAttribute('type')
})
)
})
})
describe('PopoverPanel', () => {
@@ -27,6 +27,7 @@ import {
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum PopoverStates {
Open,
@@ -211,14 +212,15 @@ export let PopoverButton = defineComponent({
let slot = { open: api.popoverState.value === PopoverStates.Open }
let propsWeControl = this.isWithinPanel
? {
type: 'button',
ref: 'el',
type: this.type,
onKeydown: this.handleKeyDown,
onClick: this.handleClick,
}
: {
ref: 'el',
id: api.buttonId,
type: 'button',
type: this.type,
'aria-expanded': this.$props.disabled
? undefined
: api.popoverState.value === PopoverStates.Open,
@@ -237,7 +239,7 @@ export let PopoverButton = defineComponent({
name: 'PopoverButton',
})
},
setup(props) {
setup(props, { attrs }) {
let api = usePopoverContext('PopoverButton')
let groupContext = usePopoverGroupContext()
@@ -261,9 +263,21 @@ export let PopoverButton = defineComponent({
true
)
let elementRef = ref(null)
if (!isWithinPanel) {
watchEffect(() => {
api.button.value = elementRef.value
})
}
return {
isWithinPanel,
el: isWithinPanel ? null : api.button,
el: elementRef,
type: useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
elementRef
),
handleKeyDown(event: KeyboardEvent) {
if (isWithinPanel) {
if (api.popoverState.value === PopoverStates.Closed) return
@@ -373,7 +387,6 @@ export let PopoverButton = defineComponent({
api.togglePopover()
}
},
handleFocus() {},
}
},
})
@@ -1,4 +1,4 @@
import { defineComponent, ref, watch } from 'vue'
import { defineComponent, ref, watch, h } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { Switch, SwitchLabel, SwitchDescription, SwitchGroup } from './switch'
@@ -12,6 +12,7 @@ import {
} from '../../test-utils/accessibility-assertions'
import { press, click, Keys } from '../../test-utils/interactions'
import { html } from '../../test-utils/html'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
jest.mock('../../hooks/use-id')
@@ -101,6 +102,93 @@ describe('Rendering', () => {
assertSwitch({ state: SwitchState.Off, label: 'Enable notifications' })
})
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
renderTemplate({
template: html`
<Switch v-model="checked">
Trigger
</Switch>
`,
setup: () => ({ checked: ref(false) }),
})
expect(getSwitch()).toHaveAttribute('type', 'button')
})
it('should not set the `type` to "button" if it already contains a `type`', async () => {
renderTemplate({
template: html`
<Switch v-model="checked" type="submit">
Trigger
</Switch>
`,
setup: () => ({ checked: ref(false) }),
})
expect(getSwitch()).toHaveAttribute('type', 'submit')
})
it(
'should set the `type` to "button" when using the `as` prop which resolves to a "button"',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Switch v-model="checked" :as="CustomButton">
Trigger
</Switch>
`,
setup: () => ({
checked: ref(false),
CustomButton: defineComponent({
setup: props => () => h('button', { ...props }),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getSwitch()).toHaveAttribute('type', 'button')
})
)
it('should not set the type if the "as" prop is not a "button"', async () => {
renderTemplate({
template: html`
<Switch v-model="checked" as="div">
Trigger
</Switch>
`,
setup: () => ({ checked: ref(false) }),
})
expect(getSwitch()).not.toHaveAttribute('type')
})
it(
'should not set the `type` to "button" when using the `as` prop which resolves to a "div"',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Switch v-model="checked" :as="CustomButton">
Trigger
</Switch>
`,
setup: () => ({
checked: ref(false),
CustomButton: defineComponent({
setup: props => () => h('div', props),
}),
}),
})
await new Promise(requestAnimationFrame)
expect(getSwitch()).not.toHaveAttribute('type')
})
)
})
})
describe('Render composition', () => {
@@ -7,6 +7,7 @@ import {
// Types
InjectionKey,
Ref,
computed,
} from 'vue'
import { render } from '../../utils/render'
@@ -14,6 +15,7 @@ import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { Label, useLabels } from '../label/label'
import { Description, useDescriptions } from '../description/description'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
type StateDefinition = {
// State
@@ -63,13 +65,12 @@ export let Switch = defineComponent({
modelValue: { type: Boolean, default: false },
},
render() {
let api = inject(GroupContext, null)
let slot = { checked: this.$props.modelValue }
let propsWeControl = {
id: this.id,
ref: api === null ? undefined : api.switchRef,
ref: 'el',
role: 'switch',
type: this.type,
tabIndex: 0,
'aria-checked': this.$props.modelValue,
'aria-labelledby': this.labelledby,
@@ -79,10 +80,6 @@ export let Switch = defineComponent({
onKeypress: this.handleKeyPress,
}
if (this.$props.as === 'button') {
Object.assign(propsWeControl, { type: 'button' })
}
return render({
props: { ...this.$props, ...propsWeControl },
slot,
@@ -91,7 +88,7 @@ export let Switch = defineComponent({
name: 'Switch',
})
},
setup(props, { emit }) {
setup(props, { emit, attrs }) {
let api = inject(GroupContext, null)
let id = `headlessui-switch-${useId()}`
@@ -99,9 +96,16 @@ export let Switch = defineComponent({
emit('update:modelValue', !props.modelValue)
}
let internalSwitchRef = ref(null)
let switchRef = api === null ? internalSwitchRef : api.switchRef
return {
id,
el: api?.switchRef,
el: switchRef,
type: useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
switchRef
),
labelledby: api?.labelledby,
describedby: api?.describedby,
handleClick(event: MouseEvent) {
@@ -0,0 +1,33 @@
import { ref, onMounted, watchEffect, Ref } from 'vue'
import { dom } from '../utils/dom'
function resolveType(type: unknown, as: string | object) {
if (type) return type
let tag = as ?? 'button'
if (typeof tag === 'string' && tag.toLowerCase() === 'button') return 'button'
return undefined
}
export function useResolveButtonType(
data: Ref<{ as: string | object; type?: unknown }>,
refElement: Ref<HTMLElement | null>
) {
let type = ref(resolveType(data.value.type, data.value.as))
onMounted(() => {
type.value = resolveType(data.value.type, data.value.as)
})
watchEffect(() => {
if (type.value) return
if (!dom(refElement)) return
if (dom(refElement) instanceof HTMLButtonElement && !dom(refElement)?.hasAttribute('type')) {
type.value = 'button'
}
})
return type
}
@@ -7,7 +7,7 @@ function assertNever(x: never): never {
// ---
export function getMenuButton(): HTMLElement | null {
return document.querySelector('button,[role="button"]')
return document.querySelector('button,[role="button"],[id^="headlessui-menu-button-"]')
}
export function getMenuButtons(): HTMLElement[] {
@@ -226,7 +226,7 @@ export function getListboxLabel(): HTMLElement | null {
}
export function getListboxButton(): HTMLElement | null {
return document.querySelector('button,[role="button"]')
return document.querySelector('button,[role="button"],[id^="headlessui-listbox-button-"]')
}
export function getListboxButtons(): HTMLElement[] {