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:
+6
-2
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user