Next release (#431)
* Fixed typos (#350) * chore: Fix typo in render.ts (#347) * Better vue link (#353) * Better vue link * add better React link Co-authored-by: Robin Malfait <malfait.robin@gmail.com> * Enable NoScroll feature for the initial useFocusTrap hook (#356) * enable NoScroll feature for the initial useFocusTrap hook Once you are using Tab and Shift+Tab it does the scrolling. Fixes: #345 * update changelog * Revert "Enable NoScroll feature for the initial useFocusTrap hook (#356)" This reverts commit 19590b07624d7e3d751cbf11de869dfb0ea432ba. Solution is not 100% correct, so will revert for now! * Improve search (#385) * make search case insensitive for the listbox * make search case insensitive for the menu * update changelog * add `disabled` prop to RadioGroup and RadioGroup Option (#401) * add `disabled` prop to RadioGroup and RadioGroup Option Also did some general cleanup which in turn fixed an issue where the RadioGroup is unreachable when a value is used that doesn't exist in the list of options. Fixes: #378 * update changelog * Fix type of `RadioGroupOption` (#400) Match RadioGroupOption value types to match modelValue allowed types for RadioGroup * update changelog * fix typo's * chore(CI): update main workflow (#395) * chore(CI): update main workflow * Update main.yml * fix dialog event propagation (#422) * re-export the `screen` utility for quick debugging purposes * stop event propagation when clicking inside a Dialog Fixes: #414 * improve dialog escape (#430) * Make sure that `Escape` only closes the top most Dialog * update changelog * add defaultOpen prop to Disclosure component (#447) * add defaultOpen prop to Disclosure component * update changelog Co-authored-by: Shuvro Roy <shuvro.roy@northsouth.edu> Co-authored-by: Alex Nault <nault.alex@gmail.com> Co-authored-by: Eugene Kopich <github@web2033.com> Co-authored-by: Nathan Shoemark <n.shoemark@gmail.com> Co-authored-by: Michaël De Boey <info@michaeldeboey.be>
This commit is contained in:
@@ -9,13 +9,13 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node 12
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 12
|
||||
|
||||
# - name: Use cached node_modules
|
||||
# id: cache
|
||||
# uses: actions/cache@v1
|
||||
# uses: actions/cache@v2
|
||||
# with:
|
||||
# path: node_modules
|
||||
# key: nodeModules-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
+21
-2
@@ -7,11 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased - React]
|
||||
|
||||
- Nothing yet!
|
||||
### Fixes
|
||||
|
||||
- Improve search, make searching case insensitive ([#385](https://github.com/tailwindlabs/headlessui/pull/385))
|
||||
- Fix unreachable `RadioGroup` ([#401](https://github.com/tailwindlabs/headlessui/pull/401))
|
||||
- Fix closing nested `Dialog` components when pressing `Escape` ([#430](https://github.com/tailwindlabs/headlessui/pull/430))
|
||||
|
||||
### Added
|
||||
|
||||
- Add `disabled` prop to `RadioGroup` and `RadioGroup.Option` ([#401](https://github.com/tailwindlabs/headlessui/pull/401))
|
||||
- Add `defaultOpen` prop to the `Disclosure` component ([#447](https://github.com/tailwindlabs/headlessui/pull/447))
|
||||
|
||||
## [Unreleased - Vue]
|
||||
|
||||
- Nothing yet!
|
||||
### Fixes
|
||||
|
||||
- Improve search, make searching case insensitive ([#385](https://github.com/tailwindlabs/headlessui/pull/385))
|
||||
- Fix unreachable `RadioGroup` ([#401](https://github.com/tailwindlabs/headlessui/pull/401))
|
||||
- Fix `RadioGroupOption` value type ([#400](https://github.com/tailwindlabs/headlessui/pull/400))
|
||||
- Fix closing nested `Dialog` components when pressing `Escape` ([#430](https://github.com/tailwindlabs/headlessui/pull/430))
|
||||
|
||||
### Added
|
||||
|
||||
- Add `disabled` prop to `RadioGroup` and `RadioGroupOption` ([#401](https://github.com/tailwindlabs/headlessui/pull/401))
|
||||
- Add `defaultOpen` prop to the `Disclosure` component ([#447](https://github.com/tailwindlabs/headlessui/pull/447))
|
||||
|
||||
## [@headlessui/react@v1.0.0] - 2021-04-14
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ yarn add @headlessui/react
|
||||
|
||||
## Documentation
|
||||
|
||||
For full documentation, visit [headlessui.dev](https://headlessui.dev/react).
|
||||
For full documentation, visit [headlessui.dev](https://headlessui.dev/react/menu).
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ function Nested({ onClose, level = 0 }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={true} onClose={onClose} className="fixed z-10 inset-0 pointer-events-none">
|
||||
<Dialog open={true} onClose={onClose} className="fixed z-10 inset-0">
|
||||
{true && <Dialog.Overlay className="fixed inset-0 bg-gray-500 opacity-25" />}
|
||||
<div
|
||||
className="z-10 fixed left-12 top-24 bg-white w-96 p-4 pointer-events-auto"
|
||||
className="z-10 fixed left-12 top-24 bg-white w-96 p-4"
|
||||
style={{
|
||||
transform: `translate(calc(50px * ${level}), calc(50px * ${level}))`,
|
||||
}}
|
||||
|
||||
@@ -64,7 +64,7 @@ it('should be possible to use a DescriptionProvider and a single Description, an
|
||||
`)
|
||||
})
|
||||
|
||||
it('should be possible to use a DescriptionProvider and multiple Description ocmponents, and have them linked', async () => {
|
||||
it('should be possible to use a DescriptionProvider and multiple Description components, and have them linked', async () => {
|
||||
function Component(props: { children: ReactNode }) {
|
||||
let [describedby, DescriptionProvider] = useDescriptions()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getDialogOverlay,
|
||||
getByText,
|
||||
assertActiveElement,
|
||||
getDialogs,
|
||||
} from '../../test-utils/accessibility-assertions'
|
||||
import { click, press, Keys } from '../../test-utils/interactions'
|
||||
import { PropsOf } from '../../types'
|
||||
@@ -496,4 +497,152 @@ describe('Mouse interactions', () => {
|
||||
assertActiveElement(getByText('Hello'))
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should stop propagating click events when clicking on the Dialog.Overlay',
|
||||
suppressConsoleLogs(async () => {
|
||||
let wrapperFn = jest.fn()
|
||||
function Example() {
|
||||
let [isOpen, setIsOpen] = useState(true)
|
||||
return (
|
||||
<div onClick={wrapperFn}>
|
||||
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||
Contents
|
||||
<Dialog.Overlay />
|
||||
<TabSentinel />
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
render(<Example />)
|
||||
|
||||
// Verify it is open
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
// Verify that the wrapper function has not been called yet
|
||||
expect(wrapperFn).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Click the Dialog.Overlay to close the Dialog
|
||||
await click(getDialogOverlay())
|
||||
|
||||
// Verify it is closed
|
||||
assertDialog({ state: DialogState.InvisibleUnmounted })
|
||||
|
||||
// Verify that the wrapper function has not been called yet
|
||||
expect(wrapperFn).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should stop propagating click events when clicking on an element inside the Dialog',
|
||||
suppressConsoleLogs(async () => {
|
||||
let wrapperFn = jest.fn()
|
||||
function Example() {
|
||||
let [isOpen, setIsOpen] = useState(true)
|
||||
return (
|
||||
<div onClick={wrapperFn}>
|
||||
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||
Contents
|
||||
<button onClick={() => setIsOpen(false)}>Inside</button>
|
||||
<TabSentinel />
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
render(<Example />)
|
||||
|
||||
// Verify it is open
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
// Verify that the wrapper function has not been called yet
|
||||
expect(wrapperFn).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Click the button inside the the Dialog
|
||||
await click(getByText('Inside'))
|
||||
|
||||
// Verify it is closed
|
||||
assertDialog({ state: DialogState.InvisibleUnmounted })
|
||||
|
||||
// Verify that the wrapper function has not been called yet
|
||||
expect(wrapperFn).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
it('should be possible to open nested Dialog components and close them with `Escape`', async () => {
|
||||
function Nested({ onClose, level = 1 }: { onClose: (value: boolean) => void; level?: number }) {
|
||||
let [showChild, setShowChild] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
<div>
|
||||
<p>Level: {level}</p>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1}</button>
|
||||
</div>
|
||||
{showChild && <Nested onClose={setShowChild} level={level + 1} />}
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Example() {
|
||||
let [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open 1</button>
|
||||
{open && <Nested onClose={setOpen} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Example />)
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3
|
||||
await click(getByText('Open 3'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import React, {
|
||||
ContextType,
|
||||
ElementType,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MutableRefObject,
|
||||
Ref,
|
||||
} from 'react'
|
||||
@@ -92,7 +93,13 @@ let DEFAULT_DIALOG_TAG = 'div' as const
|
||||
interface DialogRenderPropArg {
|
||||
open: boolean
|
||||
}
|
||||
type DialogPropsWeControl = 'id' | 'role' | 'aria-modal' | 'aria-describedby' | 'aria-labelledby'
|
||||
type DialogPropsWeControl =
|
||||
| 'id'
|
||||
| 'role'
|
||||
| 'aria-modal'
|
||||
| 'aria-describedby'
|
||||
| 'aria-labelledby'
|
||||
| 'onClick'
|
||||
|
||||
let DialogRenderFeatures = Features.RenderStrategy | Features.Static
|
||||
|
||||
@@ -171,14 +178,6 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
close()
|
||||
})
|
||||
|
||||
// Handle `Escape` to close
|
||||
useWindowEvent('keydown', event => {
|
||||
if (event.key !== Keys.Escape) return
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
if (containers.current.size > 1) return // 1 is myself, otherwise other elements in the Stack
|
||||
close()
|
||||
})
|
||||
|
||||
// Scroll lock
|
||||
useEffect(() => {
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
@@ -190,6 +189,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
|
||||
document.documentElement.style.overflow = 'hidden'
|
||||
document.documentElement.style.paddingRight = `${scrollbarWidth}px`
|
||||
|
||||
return () => {
|
||||
document.documentElement.style.overflow = overflow
|
||||
document.documentElement.style.paddingRight = paddingRight
|
||||
@@ -243,6 +243,20 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
'aria-modal': dialogState === DialogStates.Open ? true : undefined,
|
||||
'aria-labelledby': state.titleId,
|
||||
'aria-describedby': describedby,
|
||||
onClick(event: ReactMouseEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
},
|
||||
|
||||
// Handle `Escape` to close
|
||||
onKeyDown(event: ReactKeyboardEvent) {
|
||||
if (event.key !== Keys.Escape) return
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
if (containers.current.size > 1) return // 1 is myself, otherwise other elements in the Stack
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
},
|
||||
}
|
||||
let passthroughProps = rest
|
||||
|
||||
@@ -302,6 +316,8 @@ let Overlay = forwardRefWithAs(function Overlay<
|
||||
let handleClick = useCallback(
|
||||
(event: ReactMouseEvent) => {
|
||||
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
},
|
||||
[close]
|
||||
|
||||
@@ -79,6 +79,29 @@ describe('Rendering', () => {
|
||||
assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
|
||||
})
|
||||
)
|
||||
|
||||
it('should be possible to render a Disclosure in an open state by default', async () => {
|
||||
render(
|
||||
<Disclosure defaultOpen>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button>Trigger</Disclosure.Button>
|
||||
<Disclosure.Panel>Panel is: {open ? 'open' : 'closed'}</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
)
|
||||
|
||||
assertDisclosureButton({
|
||||
state: DisclosureState.Visible,
|
||||
attributes: { id: 'headlessui-disclosure-button-1' },
|
||||
})
|
||||
assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
|
||||
|
||||
await click(getDisclosureButton())
|
||||
|
||||
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disclosure.Button', () => {
|
||||
|
||||
@@ -111,13 +111,16 @@ interface DisclosureRenderPropArg {
|
||||
}
|
||||
|
||||
export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
|
||||
props: Props<TTag, DisclosureRenderPropArg>
|
||||
props: Props<TTag, DisclosureRenderPropArg> & {
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
) {
|
||||
let { defaultOpen = false, ...passthroughProps } = props
|
||||
let buttonId = `headlessui-disclosure-button-${useId()}`
|
||||
let panelId = `headlessui-disclosure-panel-${useId()}`
|
||||
|
||||
let reducerBag = useReducer(stateReducer, {
|
||||
disclosureState: DisclosureStates.Closed,
|
||||
disclosureState: defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed,
|
||||
linkedPanel: false,
|
||||
buttonId,
|
||||
panelId,
|
||||
@@ -135,7 +138,7 @@ export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_
|
||||
return (
|
||||
<DisclosureContext.Provider value={reducerBag}>
|
||||
{render({
|
||||
props,
|
||||
props: passthroughProps,
|
||||
slot,
|
||||
defaultTag: DEFAULT_DISCLOSURE_TAG,
|
||||
name: 'Disclosure',
|
||||
|
||||
@@ -98,13 +98,13 @@ it(
|
||||
|
||||
let [a, b, c, d] = Array.from(document.querySelectorAll('input'))
|
||||
|
||||
// Ensure that input-b is the active elememt
|
||||
// Ensure that input-b is the active element
|
||||
assertActiveElement(b)
|
||||
|
||||
// Tab to the next item
|
||||
await press(Keys.Tab)
|
||||
|
||||
// Ensure that input-c is the active elememt
|
||||
// Ensure that input-c is the active element
|
||||
assertActiveElement(c)
|
||||
|
||||
// Try to move focus
|
||||
|
||||
@@ -64,7 +64,7 @@ it('should be possible to use a LabelProvider and a single Label, and have them
|
||||
`)
|
||||
})
|
||||
|
||||
it('should be possible to use a LabelProvider and multiple Label ocmponents, and have them linked', async () => {
|
||||
it('should be possible to use a LabelProvider and multiple Label components, and have them linked', async () => {
|
||||
function Component(props: { children: ReactNode }) {
|
||||
let [labelledby, LabelProvider] = useLabels()
|
||||
|
||||
|
||||
@@ -2764,6 +2764,39 @@ describe('Keyboard interactions', () => {
|
||||
assertActiveListboxOption(options[2])
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should be possible to search for a word (case insensitive)',
|
||||
suppressConsoleLogs(async () => {
|
||||
render(
|
||||
<Listbox value={undefined} onChange={console.log}>
|
||||
<Listbox.Button>Trigger</Listbox.Button>
|
||||
<Listbox.Options>
|
||||
<Listbox.Option value="alice">alice</Listbox.Option>
|
||||
<Listbox.Option value="bob">bob</Listbox.Option>
|
||||
<Listbox.Option value="charlie">charlie</Listbox.Option>
|
||||
</Listbox.Options>
|
||||
</Listbox>
|
||||
)
|
||||
|
||||
// Focus the button
|
||||
getListboxButton()?.focus()
|
||||
|
||||
// Open listbox
|
||||
await press(Keys.ArrowUp)
|
||||
|
||||
let options = getListboxOptions()
|
||||
|
||||
// We should be on the last option
|
||||
assertActiveListboxOption(options[2])
|
||||
|
||||
// Search for bob in a different casing
|
||||
await type(word('BO'))
|
||||
|
||||
// We should be on `bob`
|
||||
assertActiveListboxOption(options[1])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ let reducers: {
|
||||
if (state.disabled) return state
|
||||
if (state.listboxState === ListboxStates.Closed) return state
|
||||
|
||||
let searchQuery = state.searchQuery + action.value
|
||||
let searchQuery = state.searchQuery + action.value.toLowerCase()
|
||||
let match = state.options.findIndex(
|
||||
option =>
|
||||
!option.dataRef.current.disabled &&
|
||||
|
||||
@@ -2408,6 +2408,38 @@ describe('Keyboard interactions', () => {
|
||||
assertMenuLinkedWithMenuItem(items[2])
|
||||
})
|
||||
)
|
||||
it(
|
||||
'should be possible to search for a word (case insensitive)',
|
||||
suppressConsoleLogs(async () => {
|
||||
render(
|
||||
<Menu>
|
||||
<Menu.Button>Trigger</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.Item as="a">alice</Menu.Item>
|
||||
<Menu.Item as="a">bob</Menu.Item>
|
||||
<Menu.Item as="a">charlie</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
)
|
||||
|
||||
// Focus the button
|
||||
getMenuButton()?.focus()
|
||||
|
||||
// Open menu
|
||||
await press(Keys.ArrowUp)
|
||||
|
||||
let items = getMenuItems()
|
||||
|
||||
// We should be on the last item
|
||||
assertMenuLinkedWithMenuItem(items[2])
|
||||
|
||||
// Search for bob in a different casing
|
||||
await type(word('BO'))
|
||||
|
||||
// We should be on `bob`
|
||||
assertMenuLinkedWithMenuItem(items[1])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ let reducers: {
|
||||
return { ...state, searchQuery: '', activeItemIndex }
|
||||
},
|
||||
[ActionTypes.Search]: (state, action) => {
|
||||
let searchQuery = state.searchQuery + action.value
|
||||
let searchQuery = state.searchQuery + action.value.toLowerCase()
|
||||
let match = state.items.findIndex(
|
||||
item =>
|
||||
item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled
|
||||
|
||||
@@ -570,7 +570,7 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
|
||||
|
||||
// We will take-over the default tab behaviour so that we have a bit
|
||||
// control over what is focused next. It will behave exactly the same,
|
||||
// but it will also "fix" some issues based on wether you are using a
|
||||
// but it will also "fix" some issues based on whether you are using a
|
||||
// Portal or not.
|
||||
event.preventDefault()
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('Safe guards', () => {
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should be possible to render a RadioGroup, where the first element is tabbable', async () => {
|
||||
it('should be possible to render a RadioGroup, where the first element is tabbable (value is undefined)', async () => {
|
||||
render(
|
||||
<RadioGroup value={undefined} onChange={console.log}>
|
||||
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
|
||||
@@ -72,6 +72,23 @@ describe('Rendering', () => {
|
||||
assertNotFocusable(getByText('Dine in'))
|
||||
})
|
||||
|
||||
it('should be possible to render a RadioGroup, where the first element is tabbable (value is null)', async () => {
|
||||
render(
|
||||
<RadioGroup value={null} onChange={console.log}>
|
||||
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
|
||||
<RadioGroup.Option value="pickup">Pickup</RadioGroup.Option>
|
||||
<RadioGroup.Option value="home-delivery">Home delivery</RadioGroup.Option>
|
||||
<RadioGroup.Option value="dine-in">Dine in</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
)
|
||||
|
||||
expect(getRadioGroupOptions()).toHaveLength(3)
|
||||
|
||||
assertFocusable(getByText('Pickup'))
|
||||
assertNotFocusable(getByText('Home delivery'))
|
||||
assertNotFocusable(getByText('Dine in'))
|
||||
})
|
||||
|
||||
it('should be possible to render a RadioGroup with an active value', async () => {
|
||||
render(
|
||||
<RadioGroup value="home-delivery" onChange={console.log}>
|
||||
@@ -120,6 +137,121 @@ describe('Rendering', () => {
|
||||
await press(Keys.ArrowUp) // Up again
|
||||
assertActiveElement(getByText('Home delivery'))
|
||||
})
|
||||
|
||||
it('should be possible to disable a RadioGroup', async () => {
|
||||
let changeFn = jest.fn()
|
||||
|
||||
function Example() {
|
||||
let [disabled, setDisabled] = useState(true)
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setDisabled(v => !v)}>Toggle</button>
|
||||
<RadioGroup value={undefined} onChange={changeFn} disabled={disabled}>
|
||||
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
|
||||
<RadioGroup.Option value="pickup">Pickup</RadioGroup.Option>
|
||||
<RadioGroup.Option value="home-delivery">Home delivery</RadioGroup.Option>
|
||||
<RadioGroup.Option value="dine-in">Dine in</RadioGroup.Option>
|
||||
<RadioGroup.Option value="render-prop" data-value="render-prop">
|
||||
{JSON.stringify}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Example />)
|
||||
|
||||
// Try to click one a few options
|
||||
await click(getByText('Pickup'))
|
||||
await click(getByText('Dine in'))
|
||||
|
||||
// Verify that the RadioGroup.Option gets the disabled state
|
||||
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
checked: false,
|
||||
disabled: true,
|
||||
active: false,
|
||||
})
|
||||
)
|
||||
|
||||
// Make sure that the onChange handler never got called
|
||||
expect(changeFn).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Toggle the disabled state
|
||||
await click(getByText('Toggle'))
|
||||
|
||||
// Verify that the RadioGroup.Option gets the disabled state
|
||||
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
checked: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
})
|
||||
)
|
||||
|
||||
// Try to click one a few options
|
||||
await click(getByText('Pickup'))
|
||||
|
||||
// Make sure that the onChange handler got called
|
||||
expect(changeFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be possible to disable a RadioGroup.Option', async () => {
|
||||
let changeFn = jest.fn()
|
||||
|
||||
function Example() {
|
||||
let [disabled, setDisabled] = useState(true)
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setDisabled(v => !v)}>Toggle</button>
|
||||
<RadioGroup value={undefined} onChange={changeFn}>
|
||||
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
|
||||
<RadioGroup.Option value="pickup">Pickup</RadioGroup.Option>
|
||||
<RadioGroup.Option value="home-delivery">Home delivery</RadioGroup.Option>
|
||||
<RadioGroup.Option value="dine-in">Dine in</RadioGroup.Option>
|
||||
<RadioGroup.Option value="render-prop" disabled={disabled} data-value="render-prop">
|
||||
{JSON.stringify}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Example />)
|
||||
|
||||
// Try to click the disabled option
|
||||
await click(document.querySelector('[data-value="render-prop"]'))
|
||||
|
||||
// Verify that the RadioGroup.Option gets the disabled state
|
||||
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
checked: false,
|
||||
disabled: true,
|
||||
active: false,
|
||||
})
|
||||
)
|
||||
|
||||
// Make sure that the onChange handler never got called
|
||||
expect(changeFn).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Toggle the disabled state
|
||||
await click(getByText('Toggle'))
|
||||
|
||||
// Verify that the RadioGroup.Option gets the disabled state
|
||||
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
checked: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
})
|
||||
)
|
||||
|
||||
// Try to click one a few options
|
||||
await click(document.querySelector('[data-value="render-prop"]'))
|
||||
|
||||
// Make sure that the onChange handler got called
|
||||
expect(changeFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard interactions', () => {
|
||||
|
||||
@@ -7,10 +7,10 @@ import React, {
|
||||
useRef,
|
||||
|
||||
// Types
|
||||
Dispatch,
|
||||
ElementType,
|
||||
MutableRefObject,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
ContextType,
|
||||
} from 'react'
|
||||
|
||||
import { Props, Expand } from '../../types'
|
||||
@@ -28,11 +28,10 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
|
||||
interface Option {
|
||||
id: string
|
||||
element: MutableRefObject<HTMLElement | null>
|
||||
propsRef: MutableRefObject<{ value: unknown }>
|
||||
propsRef: MutableRefObject<{ value: unknown; disabled: boolean }>
|
||||
}
|
||||
|
||||
interface StateDefinition {
|
||||
propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
|
||||
options: Option[]
|
||||
}
|
||||
|
||||
@@ -69,7 +68,14 @@ let reducers: {
|
||||
},
|
||||
}
|
||||
|
||||
let RadioGroupContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
|
||||
let RadioGroupContext = createContext<{
|
||||
registerOption(option: Option): () => void
|
||||
change(value: unknown): boolean
|
||||
value: unknown
|
||||
firstOption?: Option
|
||||
containsCheckedOption: boolean
|
||||
disabled: boolean
|
||||
} | null>(null)
|
||||
RadioGroupContext.displayName = 'RadioGroupContext'
|
||||
|
||||
function useRadioGroupContext(component: string) {
|
||||
@@ -106,30 +112,40 @@ export function RadioGroup<
|
||||
disabled?: boolean
|
||||
}
|
||||
) {
|
||||
let { value, onChange, ...passThroughProps } = props
|
||||
let reducerBag = useReducer(stateReducer, {
|
||||
propsRef: { current: { value, onChange } },
|
||||
let { value, onChange, disabled = false, ...passThroughProps } = props
|
||||
let [{ options }, dispatch] = useReducer(stateReducer, {
|
||||
options: [],
|
||||
} as StateDefinition)
|
||||
let [{ propsRef, options }] = reducerBag
|
||||
let [labelledby, LabelProvider] = useLabels()
|
||||
let [describedby, DescriptionProvider] = useDescriptions()
|
||||
let id = `headlessui-radiogroup-${useId()}`
|
||||
let radioGroupRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
propsRef.current.value = value
|
||||
}, [value, propsRef])
|
||||
useIsoMorphicEffect(() => {
|
||||
propsRef.current.onChange = onChange
|
||||
}, [onChange, propsRef])
|
||||
let firstOption = useMemo(
|
||||
() =>
|
||||
options.find(option => {
|
||||
if (option.propsRef.current.disabled) return false
|
||||
return true
|
||||
}),
|
||||
[options]
|
||||
)
|
||||
let containsCheckedOption = useMemo(
|
||||
() => options.some(option => option.propsRef.current.value === value),
|
||||
[options, value]
|
||||
)
|
||||
|
||||
let triggerChange = useCallback(
|
||||
nextValue => {
|
||||
if (nextValue === value) return
|
||||
return onChange(nextValue)
|
||||
if (disabled) return false
|
||||
if (nextValue === value) return false
|
||||
let nextOption = options.find(option => option.propsRef.current.value === nextValue)?.propsRef
|
||||
.current
|
||||
if (nextOption?.disabled) return false
|
||||
|
||||
onChange(nextValue)
|
||||
return true
|
||||
},
|
||||
[onChange, value]
|
||||
[onChange, value, disabled, options]
|
||||
)
|
||||
|
||||
useTreeWalker({
|
||||
@@ -149,6 +165,10 @@ export function RadioGroup<
|
||||
let container = radioGroupRef.current
|
||||
if (!container) return
|
||||
|
||||
let all = options
|
||||
.filter(option => option.propsRef.current.disabled === false)
|
||||
.map(radio => radio.element.current) as HTMLElement[]
|
||||
|
||||
switch (event.key) {
|
||||
case Keys.ArrowLeft:
|
||||
case Keys.ArrowUp:
|
||||
@@ -156,10 +176,7 @@ export function RadioGroup<
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
let result = focusIn(
|
||||
options.map(radio => radio.element.current) as HTMLElement[],
|
||||
Focus.Previous | Focus.WrapAround
|
||||
)
|
||||
let result = focusIn(all, Focus.Previous | Focus.WrapAround)
|
||||
|
||||
if (result === FocusResult.Success) {
|
||||
let activeOption = options.find(
|
||||
@@ -176,10 +193,7 @@ export function RadioGroup<
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
let result = focusIn(
|
||||
options.map(option => option.element.current) as HTMLElement[],
|
||||
Focus.Next | Focus.WrapAround
|
||||
)
|
||||
let result = focusIn(all, Focus.Next | Focus.WrapAround)
|
||||
|
||||
if (result === FocusResult.Success) {
|
||||
let activeOption = options.find(
|
||||
@@ -206,6 +220,26 @@ export function RadioGroup<
|
||||
[radioGroupRef, options, triggerChange]
|
||||
)
|
||||
|
||||
let registerOption = useCallback(
|
||||
(option: Option) => {
|
||||
dispatch({ type: ActionTypes.RegisterOption, ...option })
|
||||
return () => dispatch({ type: ActionTypes.UnregisterOption, id: option.id })
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
let api = useMemo<ContextType<typeof RadioGroupContext>>(
|
||||
() => ({
|
||||
registerOption,
|
||||
firstOption,
|
||||
containsCheckedOption,
|
||||
change: triggerChange,
|
||||
disabled,
|
||||
value,
|
||||
}),
|
||||
[registerOption, firstOption, containsCheckedOption, triggerChange, disabled, value]
|
||||
)
|
||||
|
||||
let propsWeControl = {
|
||||
ref: radioGroupRef,
|
||||
id,
|
||||
@@ -218,7 +252,7 @@ export function RadioGroup<
|
||||
return (
|
||||
<DescriptionProvider name="RadioGroup.Description">
|
||||
<LabelProvider name="RadioGroup.Label">
|
||||
<RadioGroupContext.Provider value={reducerBag}>
|
||||
<RadioGroupContext.Provider value={api}>
|
||||
{render({
|
||||
props: { ...passThroughProps, ...propsWeControl },
|
||||
defaultTag: DEFAULT_RADIO_GROUP_TAG,
|
||||
@@ -241,6 +275,7 @@ let DEFAULT_OPTION_TAG = 'div' as const
|
||||
interface OptionRenderPropArg {
|
||||
checked: boolean
|
||||
active: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
type RadioPropsWeControl =
|
||||
| 'aria-checked'
|
||||
@@ -258,8 +293,9 @@ function Option<
|
||||
// But today is not that day..
|
||||
TType = Parameters<typeof RadioGroup>[0]['value']
|
||||
>(
|
||||
props: Props<TTag, OptionRenderPropArg, RadioPropsWeControl | 'value'> & {
|
||||
props: Props<TTag, OptionRenderPropArg, RadioPropsWeControl | 'value' | 'disabled'> & {
|
||||
value: TType
|
||||
disabled?: boolean
|
||||
}
|
||||
) {
|
||||
let optionRef = useRef<HTMLElement | null>(null)
|
||||
@@ -269,35 +305,46 @@ function Option<
|
||||
let [describedby, DescriptionProvider] = useDescriptions()
|
||||
let { addFlag, removeFlag, hasFlag } = useFlags(OptionState.Empty)
|
||||
|
||||
let { value, ...passThroughProps } = props
|
||||
let propsRef = useRef({ value })
|
||||
let { value, disabled = false, ...passThroughProps } = props
|
||||
let propsRef = useRef({ value, disabled })
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
propsRef.current.value = value
|
||||
}, [value, propsRef])
|
||||
|
||||
let [{ propsRef: radioGroupPropsRef, options }, dispatch] = useRadioGroupContext(
|
||||
[RadioGroup.name, Option.name].join('.')
|
||||
)
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
dispatch({ type: ActionTypes.RegisterOption, id, element: optionRef, propsRef })
|
||||
return () => dispatch({ type: ActionTypes.UnregisterOption, id })
|
||||
}, [id, dispatch, optionRef, props])
|
||||
propsRef.current.disabled = disabled
|
||||
}, [disabled, propsRef])
|
||||
|
||||
let {
|
||||
registerOption,
|
||||
disabled: radioGroupDisabled,
|
||||
change,
|
||||
firstOption,
|
||||
containsCheckedOption,
|
||||
value: radioGroupValue,
|
||||
} = useRadioGroupContext([RadioGroup.name, Option.name].join('.'))
|
||||
|
||||
useIsoMorphicEffect(() => registerOption({ id, element: optionRef, propsRef }), [
|
||||
id,
|
||||
registerOption,
|
||||
optionRef,
|
||||
props,
|
||||
])
|
||||
|
||||
let handleClick = useCallback(() => {
|
||||
if (radioGroupPropsRef.current.value === value) return
|
||||
if (!change(value)) return
|
||||
|
||||
addFlag(OptionState.Active)
|
||||
radioGroupPropsRef.current.onChange(value)
|
||||
optionRef.current?.focus()
|
||||
}, [addFlag, radioGroupPropsRef, value])
|
||||
}, [addFlag, change, value])
|
||||
|
||||
let handleFocus = useCallback(() => addFlag(OptionState.Active), [addFlag])
|
||||
let handleBlur = useCallback(() => removeFlag(OptionState.Active), [removeFlag])
|
||||
|
||||
let firstRadio = options?.[0]?.id === id
|
||||
let checked = radioGroupPropsRef.current.value === value
|
||||
let isFirstOption = firstOption?.id === id
|
||||
let isDisabled = radioGroupDisabled || disabled
|
||||
|
||||
let checked = radioGroupValue === value
|
||||
let propsWeControl = {
|
||||
ref: optionRef,
|
||||
id,
|
||||
@@ -305,14 +352,19 @@ function Option<
|
||||
'aria-checked': checked ? 'true' : 'false',
|
||||
'aria-labelledby': labelledby,
|
||||
'aria-describedby': describedby,
|
||||
tabIndex: checked ? 0 : radioGroupPropsRef.current.value === undefined && firstRadio ? 0 : -1,
|
||||
onClick: handleClick,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
tabIndex: (() => {
|
||||
if (isDisabled) return -1
|
||||
if (checked) return 0
|
||||
if (!containsCheckedOption && isFirstOption) return 0
|
||||
return -1
|
||||
})(),
|
||||
onClick: isDisabled ? undefined : handleClick,
|
||||
onFocus: isDisabled ? undefined : handleFocus,
|
||||
onBlur: isDisabled ? undefined : handleBlur,
|
||||
}
|
||||
let slot = useMemo<OptionRenderPropArg>(
|
||||
() => ({ checked, active: hasFlag(OptionState.Active) }),
|
||||
[checked, hasFlag]
|
||||
() => ({ checked, disabled: isDisabled, active: hasFlag(OptionState.Active) }),
|
||||
[checked, isDisabled, hasFlag]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -219,7 +219,7 @@ function TransitionChild<TTag extends ElementType = typeof DEFAULT_TRANSITION_CH
|
||||
|
||||
let nesting = useNesting(() => {
|
||||
// When all children have been unmounted we can only hide ourselves if and only if we are not
|
||||
// transitioning ourserlves. Otherwise we would unmount before the transitions are finished.
|
||||
// transitioning ourselves. Otherwise we would unmount before the transitions are finished.
|
||||
if (!isTransitioning.current) {
|
||||
setState(TreeStates.Hidden)
|
||||
unregister(id)
|
||||
|
||||
@@ -39,7 +39,7 @@ it('should be possible to transition', async () => {
|
||||
expect(snapshots[1].content).toEqual('<div class="enter enterFrom"></div>')
|
||||
|
||||
// NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
|
||||
// necessary to put the classes on the element and immediatley remove them.
|
||||
// necessary to put the classes on the element and immediately remove them.
|
||||
|
||||
// Cleanup phase
|
||||
expect(snapshots[2].content).toEqual('<div class=""></div>')
|
||||
|
||||
@@ -25,7 +25,7 @@ function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) {
|
||||
let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => {
|
||||
let [resolvedValue = 0] = value
|
||||
.split(',')
|
||||
// Remove falseys we can't work with
|
||||
// Remove falsy we can't work with
|
||||
.filter(Boolean)
|
||||
// Values are returned as `0.3s` or `75ms`
|
||||
.map(v => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
|
||||
|
||||
@@ -864,6 +864,10 @@ export function getDialog(): HTMLElement | null {
|
||||
return document.querySelector('[role="dialog"]')
|
||||
}
|
||||
|
||||
export function getDialogs(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll('[role="dialog"]'))
|
||||
}
|
||||
|
||||
export function getDialogTitle(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-title-"]')
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
|
||||
|
||||
// This is a little weird, but let me try and explain: There are a few scenario's
|
||||
// in chrome for example where a focused `<a>` tag does not get the default focus
|
||||
// styles and sometimes they do. This highly depends on wether you started by
|
||||
// styles and sometimes they do. This highly depends on whether you started by
|
||||
// clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
|
||||
// then the active element (document.activeElement) is this anchor, which is expected.
|
||||
// However in that case the default focus styles are not applied *unless* you
|
||||
|
||||
@@ -107,7 +107,7 @@ function _render<TTag extends ElementType, TSlot>(
|
||||
'static',
|
||||
])
|
||||
|
||||
// This allows us to use `<HeadlessUIComponent as={MyComopnent} refName="innerRef" />`
|
||||
// This allows us to use `<HeadlessUIComponent as={MyComponent} refName="innerRef" />`
|
||||
let refRelatedProps = props.ref !== undefined ? { [refName]: props.ref } : {}
|
||||
|
||||
let resolvedChildren = (typeof children === 'function' ? children(slot) : children) as
|
||||
|
||||
@@ -27,7 +27,7 @@ yarn add @headlessui/vue
|
||||
|
||||
## Documentation
|
||||
|
||||
For full documentation, visit [headlessui.dev](https://headlessui.dev/vue).
|
||||
For full documentation, visit [headlessui.dev](https://headlessui.dev/vue/menu).
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineComponent, ref, nextTick } from 'vue'
|
||||
import { defineComponent, ref, nextTick, h } from 'vue'
|
||||
import { render } from '../../test-utils/vue-testing-library'
|
||||
|
||||
import { Dialog, DialogOverlay, DialogTitle, DialogDescription } from './dialog'
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getDialogOverlay,
|
||||
getByText,
|
||||
assertActiveElement,
|
||||
getDialogs,
|
||||
} from '../../test-utils/accessibility-assertions'
|
||||
import { click, press, Keys } from '../../test-utils/interactions'
|
||||
import { html } from '../../test-utils/html'
|
||||
@@ -607,4 +608,191 @@ describe('Mouse interactions', () => {
|
||||
assertActiveElement(getByText('Hello'))
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should stop propagating click events when clicking on the Dialog.Overlay',
|
||||
suppressConsoleLogs(async () => {
|
||||
let wrapperFn = jest.fn()
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div @click="wrapperFn">
|
||||
<Dialog v-if="true" :open="isOpen" @close="setIsOpen">
|
||||
Contents
|
||||
<DialogOverlay />
|
||||
<TabSentinel />
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(true)
|
||||
return {
|
||||
isOpen,
|
||||
wrapperFn,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Verify it is open
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
// Verify that the wrapper function has not been called yet
|
||||
expect(wrapperFn).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Click the Dialog.Overlay to close the Dialog
|
||||
await click(getDialogOverlay())
|
||||
|
||||
// Verify it is closed
|
||||
assertDialog({ state: DialogState.InvisibleUnmounted })
|
||||
|
||||
// Verify that the wrapper function has not been called yet
|
||||
expect(wrapperFn).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should stop propagating click events when clicking on an element inside the Dialog',
|
||||
suppressConsoleLogs(async () => {
|
||||
let wrapperFn = jest.fn()
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div @click="wrapperFn">
|
||||
<Dialog v-if="true" :open="isOpen" @close="setIsOpen">
|
||||
Contents
|
||||
<button @click="setIsOpen(false)">Inside</button>
|
||||
<TabSentinel />
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(true)
|
||||
return {
|
||||
isOpen,
|
||||
wrapperFn,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Verify it is open
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
// Verify that the wrapper function has not been called yet
|
||||
expect(wrapperFn).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Click the button inside the the Dialog
|
||||
await click(getByText('Inside'))
|
||||
|
||||
// Verify it is closed
|
||||
assertDialog({ state: DialogState.InvisibleUnmounted })
|
||||
|
||||
// Verify that the wrapper function has not been called yet
|
||||
expect(wrapperFn).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
it('should be possible to open nested Dialog components and close them with `Escape`', async () => {
|
||||
let Nested = defineComponent({
|
||||
components: { Dialog },
|
||||
emits: ['close'],
|
||||
props: ['level'],
|
||||
render() {
|
||||
let level = this.$props.level ?? 1
|
||||
return h(Dialog, { open: true, onClose: this.onClose }, () => [
|
||||
h('div', [
|
||||
h('p', `Level: ${level}`),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
},
|
||||
`Open ${level + 1}`
|
||||
),
|
||||
]),
|
||||
this.showChild &&
|
||||
h(Nested, {
|
||||
onClose: () => {
|
||||
this.showChild = false
|
||||
},
|
||||
level: level + 1,
|
||||
}),
|
||||
])
|
||||
},
|
||||
setup(_props, { emit }) {
|
||||
let showChild = ref(false)
|
||||
|
||||
return {
|
||||
showChild,
|
||||
onClose() {
|
||||
emit('close', false)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
renderTemplate({
|
||||
components: { Nested },
|
||||
template: `
|
||||
<button @click="isOpen = true">Open 1</button>
|
||||
<Nested v-if="isOpen" @close="isOpen = false" />
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return { isOpen }
|
||||
},
|
||||
})
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3
|
||||
await click(getByText('Open 3'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,6 +85,8 @@ export let Dialog = defineComponent({
|
||||
'aria-modal': this.dialogState === DialogStates.Open ? true : undefined,
|
||||
'aria-labelledby': this.titleId,
|
||||
'aria-describedby': this.describedby,
|
||||
onClick: this.handleClick,
|
||||
onKeydown: this.handleKeyDown,
|
||||
}
|
||||
let { open, initialFocus, ...passThroughProps } = this.$props
|
||||
let slot = { open: this.dialogState === DialogStates.Open }
|
||||
@@ -183,14 +185,6 @@ export let Dialog = defineComponent({
|
||||
nextTick(() => target?.focus())
|
||||
})
|
||||
|
||||
// Handle `Escape` to close
|
||||
useWindowEvent('keydown', event => {
|
||||
if (event.key !== Keys.Escape) return
|
||||
if (dialogState.value !== DialogStates.Open) return
|
||||
if (containers.value.size > 1) return // 1 is myself, otherwise other elements in the Stack
|
||||
api.close()
|
||||
})
|
||||
|
||||
// Scroll lock
|
||||
watchEffect(onInvalidate => {
|
||||
if (dialogState.value !== DialogStates.Open) return
|
||||
@@ -241,6 +235,20 @@ export let Dialog = defineComponent({
|
||||
dialogState,
|
||||
titleId,
|
||||
describedby,
|
||||
handleClick(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
},
|
||||
|
||||
// Handle `Escape` to close
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== Keys.Escape) return
|
||||
if (dialogState.value !== DialogStates.Open) return
|
||||
if (containers.value.size > 1) return // 1 is myself, otherwise other elements in the Stack
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
api.close()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -276,7 +284,9 @@ export let DialogOverlay = defineComponent({
|
||||
|
||||
return {
|
||||
id,
|
||||
handleClick() {
|
||||
handleClick(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
api.close()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,6 +100,29 @@ describe('Rendering', () => {
|
||||
assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
|
||||
})
|
||||
)
|
||||
|
||||
it('should be possible to render a Disclosure in an open state by default', async () => {
|
||||
renderTemplate(
|
||||
html`
|
||||
<Disclosure v-slot="{ open }" defaultOpen>
|
||||
<DisclosureButton>Trigger</DisclosureButton>
|
||||
<DisclosurePanel>Panel is: {{open ? 'open' : 'closed'}}</DisclosurePanel>
|
||||
</Disclosure>
|
||||
`
|
||||
)
|
||||
|
||||
await new Promise<void>(nextTick)
|
||||
|
||||
assertDisclosureButton({
|
||||
state: DisclosureState.Visible,
|
||||
attributes: { id: 'headlessui-disclosure-button-1' },
|
||||
})
|
||||
assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
|
||||
|
||||
await click(getDisclosureButton())
|
||||
|
||||
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisclosureButton', () => {
|
||||
|
||||
@@ -41,11 +41,12 @@ export let Disclosure = defineComponent({
|
||||
name: 'Disclosure',
|
||||
props: {
|
||||
as: { type: [Object, String], default: 'template' },
|
||||
defaultOpen: { type: [Boolean], default: false },
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
let { ...passThroughProps } = props
|
||||
|
||||
let disclosureState = ref<StateDefinition['disclosureState']['value']>(DisclosureStates.Closed)
|
||||
let disclosureState = ref<StateDefinition['disclosureState']['value']>(
|
||||
props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed
|
||||
)
|
||||
let panelRef = ref<StateDefinition['panelRef']['value']>(null)
|
||||
|
||||
let api = {
|
||||
@@ -62,6 +63,7 @@ export let Disclosure = defineComponent({
|
||||
provide(DisclosureContext, api)
|
||||
|
||||
return () => {
|
||||
let { defaultOpen: _, ...passThroughProps } = props
|
||||
let slot = { open: disclosureState.value === DisclosureStates.Open }
|
||||
return render({ props: passThroughProps, slot, slots, attrs, name: 'Disclosure' })
|
||||
}
|
||||
|
||||
@@ -153,13 +153,13 @@ it(
|
||||
|
||||
let [a, b, c, d] = Array.from(document.querySelectorAll('input'))
|
||||
|
||||
// Ensure that input-b is the active elememt
|
||||
// Ensure that input-b is the active element
|
||||
assertActiveElement(b)
|
||||
|
||||
// Tab to the next item
|
||||
await press(Keys.Tab)
|
||||
|
||||
// Ensure that input-c is the active elememt
|
||||
// Ensure that input-c is the active element
|
||||
assertActiveElement(c)
|
||||
|
||||
// Try to move focus
|
||||
|
||||
@@ -2965,6 +2965,42 @@ describe('Keyboard interactions', () => {
|
||||
assertActiveListboxOption(options[2])
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should be possible to search for a word (case insensitive)',
|
||||
suppressConsoleLogs(async () => {
|
||||
renderTemplate({
|
||||
template: html`
|
||||
<Listbox v-model="value">
|
||||
<ListboxButton>Trigger</ListboxButton>
|
||||
<ListboxOptions>
|
||||
<ListboxOption value="alice">alice</ListboxOption>
|
||||
<ListboxOption value="bob">bob</ListboxOption>
|
||||
<ListboxOption value="charlie">charlie</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
`,
|
||||
setup: () => ({ value: ref(null) }),
|
||||
})
|
||||
|
||||
// Focus the button
|
||||
getListboxButton()?.focus()
|
||||
|
||||
// Open listbox
|
||||
await press(Keys.ArrowUp)
|
||||
|
||||
let options = getListboxOptions()
|
||||
|
||||
// We should be on the last option
|
||||
assertActiveListboxOption(options[2])
|
||||
|
||||
// Search for bob in a different casing
|
||||
await type(word('BO'))
|
||||
|
||||
// We should be on `bob`
|
||||
assertActiveListboxOption(options[1])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ export let Listbox = defineComponent({
|
||||
if (disabled) return
|
||||
if (listboxState.value === ListboxStates.Closed) return
|
||||
|
||||
searchQuery.value += value
|
||||
searchQuery.value += value.toLowerCase()
|
||||
|
||||
let match = options.value.findIndex(
|
||||
option =>
|
||||
|
||||
@@ -2454,6 +2454,36 @@ describe('Keyboard interactions', () => {
|
||||
// We should still be on the last item
|
||||
assertMenuLinkedWithMenuItem(items[2])
|
||||
})
|
||||
|
||||
it('should be possible to search for a word (case insensitive)', async () => {
|
||||
renderTemplate(jsx`
|
||||
<Menu>
|
||||
<MenuButton>Trigger</MenuButton>
|
||||
<MenuItems>
|
||||
<MenuItem as="a">alice</MenuItem>
|
||||
<MenuItem as="a">bob</MenuItem>
|
||||
<MenuItem as="a">charlie</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
`)
|
||||
|
||||
// Focus the button
|
||||
getMenuButton()?.focus()
|
||||
|
||||
// Open menu
|
||||
await press(Keys.ArrowUp)
|
||||
|
||||
let items = getMenuItems()
|
||||
|
||||
// We should be on the last item
|
||||
assertMenuLinkedWithMenuItem(items[2])
|
||||
|
||||
// Search for bob in a different casing
|
||||
await type(word('BO'))
|
||||
|
||||
// We should be on `bob`
|
||||
assertMenuLinkedWithMenuItem(items[1])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ export let Menu = defineComponent({
|
||||
activeItemIndex.value = nextActiveItemIndex
|
||||
},
|
||||
search(value: string) {
|
||||
searchQuery.value += value
|
||||
searchQuery.value += value.toLowerCase()
|
||||
|
||||
let match = items.value.findIndex(
|
||||
item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled
|
||||
|
||||
@@ -456,7 +456,7 @@ export let PopoverPanel = defineComponent({
|
||||
|
||||
// We will take-over the default tab behaviour so that we have a bit
|
||||
// control over what is focused next. It will behave exactly the same,
|
||||
// but it will also "fix" some issues based on wether you are using a
|
||||
// but it will also "fix" some issues based on whether you are using a
|
||||
// Portal or not.
|
||||
event.preventDefault()
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('Safe guards', () => {
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should be possible to render a RadioGroup, where the first element is tabbable', async () => {
|
||||
it('should be possible to render a RadioGroup, where the first element is tabbable (value is undefined)', async () => {
|
||||
renderTemplate({
|
||||
template: html`
|
||||
<RadioGroup v-model="deliveryMethod">
|
||||
@@ -113,6 +113,31 @@ describe('Rendering', () => {
|
||||
assertNotFocusable(getByText('Dine in'))
|
||||
})
|
||||
|
||||
it('should be possible to render a RadioGroup, where the first element is tabbable (value is null)', async () => {
|
||||
renderTemplate({
|
||||
template: html`
|
||||
<RadioGroup v-model="deliveryMethod">
|
||||
<RadioGroupLabel>Pizza Delivery</RadioGroupLabel>
|
||||
<RadioGroupOption value="pickup">Pickup</RadioGroupOption>
|
||||
<RadioGroupOption value="home-delivery">Home delivery</RadioGroupOption>
|
||||
<RadioGroupOption value="dine-in">Dine in</RadioGroupOption>
|
||||
</RadioGroup>
|
||||
`,
|
||||
setup() {
|
||||
let deliveryMethod = ref(null)
|
||||
return { deliveryMethod }
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise<void>(nextTick)
|
||||
|
||||
expect(getRadioGroupOptions()).toHaveLength(3)
|
||||
|
||||
assertFocusable(getByText('Pickup'))
|
||||
assertNotFocusable(getByText('Home delivery'))
|
||||
assertNotFocusable(getByText('Dine in'))
|
||||
})
|
||||
|
||||
it('should be possible to render a RadioGroup with an active value', async () => {
|
||||
renderTemplate({
|
||||
template: html`
|
||||
@@ -189,7 +214,7 @@ describe('Rendering', () => {
|
||||
await new Promise<void>(nextTick)
|
||||
|
||||
expect(document.querySelector('[id^="headlessui-radiogroup-option-"]')).toHaveTextContent(
|
||||
`Pickup - ${JSON.stringify({ checked: false, active: false })}`
|
||||
`Pickup - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -220,13 +245,13 @@ describe('Rendering', () => {
|
||||
document.querySelectorAll('[id^="headlessui-radiogroup-option-"]')
|
||||
)
|
||||
expect(pickup).toHaveTextContent(
|
||||
`Pickup - ${JSON.stringify({ checked: false, active: false })}`
|
||||
`Pickup - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
|
||||
)
|
||||
expect(homeDelivery).toHaveTextContent(
|
||||
`Home delivery - ${JSON.stringify({ checked: false, active: false })}`
|
||||
`Home delivery - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
|
||||
)
|
||||
expect(dineIn).toHaveTextContent(
|
||||
`Dine in - ${JSON.stringify({ checked: false, active: false })}`
|
||||
`Dine in - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
|
||||
)
|
||||
|
||||
await click(homeDelivery)
|
||||
@@ -235,13 +260,13 @@ describe('Rendering', () => {
|
||||
)
|
||||
|
||||
expect(pickup).toHaveTextContent(
|
||||
`Pickup - ${JSON.stringify({ checked: false, active: false })}`
|
||||
`Pickup - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
|
||||
)
|
||||
expect(homeDelivery).toHaveTextContent(
|
||||
`Home delivery - ${JSON.stringify({ checked: true, active: true })}`
|
||||
`Home delivery - ${JSON.stringify({ checked: true, disabled: false, active: true })}`
|
||||
)
|
||||
expect(dineIn).toHaveTextContent(
|
||||
`Dine in - ${JSON.stringify({ checked: false, active: false })}`
|
||||
`Dine in - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -293,6 +318,126 @@ describe('Rendering', () => {
|
||||
|
||||
expect(getByText('Pickup')).toHaveClass('abc')
|
||||
})
|
||||
|
||||
it('should be possible to disable a RadioGroup', async () => {
|
||||
let changeFn = jest.fn()
|
||||
renderTemplate({
|
||||
template: html`
|
||||
<button @click="disabled = !disabled">Toggle</button>
|
||||
<RadioGroup v-model="deliveryMethod" :disabled="disabled">
|
||||
<RadioGroupLabel>Pizza Delivery</RadioGroupLabel>
|
||||
<RadioGroupOption value="pickup">Pickup</RadioGroupOption>
|
||||
<RadioGroupOption value="home-delivery">Home delivery</RadioGroupOption>
|
||||
<RadioGroupOption value="dine-in">Dine in</RadioGroupOption>
|
||||
<RadioGroupOption value="render-prop" data-value="render-prop" v-slot="data">
|
||||
{{JSON.stringify(data)}}
|
||||
</RadioGroupOption>
|
||||
</RadioGroup>
|
||||
`,
|
||||
setup() {
|
||||
let deliveryMethod = ref(undefined)
|
||||
let disabled = ref(true)
|
||||
watch([deliveryMethod], () => changeFn(deliveryMethod.value))
|
||||
return { deliveryMethod, disabled }
|
||||
},
|
||||
})
|
||||
|
||||
// Try to click one a few options
|
||||
await click(getByText('Pickup'))
|
||||
await click(getByText('Dine in'))
|
||||
|
||||
// Verify that the RadioGroup.Option gets the disabled state
|
||||
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
checked: false,
|
||||
disabled: true,
|
||||
active: false,
|
||||
})
|
||||
)
|
||||
|
||||
// Make sure that the onChange handler never got called
|
||||
expect(changeFn).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Toggle the disabled state
|
||||
await click(getByText('Toggle'))
|
||||
|
||||
// Verify that the RadioGroup.Option gets the disabled state
|
||||
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
checked: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
})
|
||||
)
|
||||
|
||||
// Try to click one a few options
|
||||
await click(getByText('Pickup'))
|
||||
|
||||
// Make sure that the onChange handler got called
|
||||
expect(changeFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be possible to disable a RadioGroup.Option', async () => {
|
||||
let changeFn = jest.fn()
|
||||
renderTemplate({
|
||||
template: html`
|
||||
<button @click="disabled = !disabled">Toggle</button>
|
||||
<RadioGroup v-model="deliveryMethod">
|
||||
<RadioGroupLabel>Pizza Delivery</RadioGroupLabel>
|
||||
<RadioGroupOption value="pickup">Pickup</RadioGroupOption>
|
||||
<RadioGroupOption value="home-delivery">Home delivery</RadioGroupOption>
|
||||
<RadioGroupOption value="dine-in">Dine in</RadioGroupOption>
|
||||
<RadioGroupOption
|
||||
value="render-prop"
|
||||
:disabled="disabled"
|
||||
data-value="render-prop"
|
||||
v-slot="data"
|
||||
>
|
||||
{{JSON.stringify(data)}}
|
||||
</RadioGroupOption>
|
||||
</RadioGroup>
|
||||
`,
|
||||
setup() {
|
||||
let deliveryMethod = ref(undefined)
|
||||
let disabled = ref(true)
|
||||
watch([deliveryMethod], () => changeFn(deliveryMethod.value))
|
||||
return { deliveryMethod, disabled }
|
||||
},
|
||||
})
|
||||
|
||||
// Try to click the disabled option
|
||||
await click(document.querySelector('[data-value="render-prop"]'))
|
||||
|
||||
// Verify that the RadioGroup.Option gets the disabled state
|
||||
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
checked: false,
|
||||
disabled: true,
|
||||
active: false,
|
||||
})
|
||||
)
|
||||
|
||||
// Make sure that the onChange handler never got called
|
||||
expect(changeFn).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Toggle the disabled state
|
||||
await click(getByText('Toggle'))
|
||||
|
||||
// Verify that the RadioGroup.Option gets the disabled state
|
||||
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
checked: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
})
|
||||
)
|
||||
|
||||
// Try to click one a few options
|
||||
await click(document.querySelector('[data-value="render-prop"]'))
|
||||
|
||||
// Make sure that the onChange handler got called
|
||||
expect(changeFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard interactions', () => {
|
||||
|
||||
@@ -26,16 +26,19 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
|
||||
interface Option {
|
||||
id: string
|
||||
element: Ref<HTMLElement | null>
|
||||
propsRef: Ref<{ value: unknown }>
|
||||
propsRef: Ref<{ value: unknown; disabled: boolean }>
|
||||
}
|
||||
|
||||
interface StateDefinition {
|
||||
// State
|
||||
options: Ref<Option[]>
|
||||
value: Ref<unknown>
|
||||
disabled: Ref<boolean>
|
||||
firstOption: Ref<Option | undefined>
|
||||
containsCheckedOption: Ref<boolean>
|
||||
|
||||
// State mutators
|
||||
change(nextValue: unknown): void
|
||||
change(nextValue: unknown): boolean
|
||||
registerOption(action: Option): void
|
||||
unregisterOption(id: Option['id']): void
|
||||
}
|
||||
@@ -95,10 +98,25 @@ export let RadioGroup = defineComponent({
|
||||
let api = {
|
||||
options,
|
||||
value,
|
||||
disabled: computed(() => props.disabled),
|
||||
firstOption: computed(() =>
|
||||
options.value.find(option => {
|
||||
if (option.propsRef.disabled) return false
|
||||
return true
|
||||
})
|
||||
),
|
||||
containsCheckedOption: computed(() =>
|
||||
options.value.some(option => toRaw(option.propsRef.value) === toRaw(props.modelValue))
|
||||
),
|
||||
change(nextValue: unknown) {
|
||||
if (props.disabled) return
|
||||
if (value.value === nextValue) return
|
||||
if (props.disabled) return false
|
||||
if (value.value === nextValue) return false
|
||||
let nextOption = options.value.find(
|
||||
option => toRaw(option.propsRef.value) === toRaw(nextValue)
|
||||
)?.propsRef
|
||||
if (nextOption?.disabled) return false
|
||||
emit('update:modelValue', nextValue)
|
||||
return true
|
||||
},
|
||||
registerOption(action: UnwrapRef<Option>) {
|
||||
let orderMap = Array.from(
|
||||
@@ -137,6 +155,10 @@ export let RadioGroup = defineComponent({
|
||||
if (!radioGroupRef.value) return
|
||||
if (!radioGroupRef.value.contains(event.target as HTMLElement)) return
|
||||
|
||||
let all = options.value
|
||||
.filter(option => option.propsRef.disabled === false)
|
||||
.map(radio => radio.element) as HTMLElement[]
|
||||
|
||||
switch (event.key) {
|
||||
case Keys.ArrowLeft:
|
||||
case Keys.ArrowUp:
|
||||
@@ -144,10 +166,7 @@ export let RadioGroup = defineComponent({
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
let result = focusIn(
|
||||
options.value.map(radio => radio.element) as HTMLElement[],
|
||||
Focus.Previous | Focus.WrapAround
|
||||
)
|
||||
let result = focusIn(all, Focus.Previous | Focus.WrapAround)
|
||||
|
||||
if (result === FocusResult.Success) {
|
||||
let activeOption = options.value.find(
|
||||
@@ -164,10 +183,7 @@ export let RadioGroup = defineComponent({
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
let result = focusIn(
|
||||
options.value.map(option => option.element) as HTMLElement[],
|
||||
Focus.Next | Focus.WrapAround
|
||||
)
|
||||
let result = focusIn(all, Focus.Next | Focus.WrapAround)
|
||||
|
||||
if (result === FocusResult.Success) {
|
||||
let activeOption = options.value.find(
|
||||
@@ -215,7 +231,7 @@ export let RadioGroupOption = defineComponent({
|
||||
name: 'RadioGroupOption',
|
||||
props: {
|
||||
as: { type: [Object, String], default: 'div' },
|
||||
value: { type: [Object, String] },
|
||||
value: { type: [Object, String, Number, Boolean] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
class: { type: [String, Function], required: false },
|
||||
className: { type: [String, Function], required: false },
|
||||
@@ -228,11 +244,13 @@ export let RadioGroupOption = defineComponent({
|
||||
className = defaultClass,
|
||||
...passThroughProps
|
||||
} = this.$props
|
||||
let api = useRadioGroupContext('RadioGroupOption')
|
||||
|
||||
let firstRadio = api.options.value?.[0]?.id === this.id
|
||||
let slot = {
|
||||
checked: this.checked,
|
||||
disabled: this.disabled,
|
||||
active: Boolean(this.state & OptionState.Active),
|
||||
}
|
||||
|
||||
let slot = { checked: this.checked, active: Boolean(this.state & OptionState.Active) }
|
||||
let propsWeControl = {
|
||||
id: this.id,
|
||||
ref: 'el',
|
||||
@@ -241,10 +259,10 @@ export let RadioGroupOption = defineComponent({
|
||||
'aria-checked': this.checked ? 'true' : 'false',
|
||||
'aria-labelledby': this.labelledby,
|
||||
'aria-describedby': this.describedby,
|
||||
tabIndex: this.checked ? 0 : api.value.value === undefined && firstRadio ? 0 : -1,
|
||||
onClick: this.handleClick,
|
||||
onFocus: this.handleFocus,
|
||||
onBlur: this.handleBlur,
|
||||
tabIndex: this.tabIndex,
|
||||
onClick: this.disabled ? undefined : this.handleClick,
|
||||
onFocus: this.disabled ? undefined : this.handleFocus,
|
||||
onBlur: this.disabled ? undefined : this.handleBlur,
|
||||
}
|
||||
|
||||
return render({
|
||||
@@ -262,26 +280,34 @@ export let RadioGroupOption = defineComponent({
|
||||
let describedby = useDescriptions({ name: 'RadioGroupDescription' })
|
||||
|
||||
let optionRef = ref<HTMLElement | null>(null)
|
||||
let propsRef = computed(() => ({ value: props.value }))
|
||||
let propsRef = computed(() => ({ value: props.value, disabled: props.disabled }))
|
||||
let state = ref(OptionState.Empty)
|
||||
|
||||
onMounted(() => api.registerOption({ id, element: optionRef, propsRef }))
|
||||
onUnmounted(() => api.unregisterOption(id))
|
||||
|
||||
let isFirstOption = computed(() => api.firstOption.value?.id === id)
|
||||
let disabled = computed(() => api.disabled.value || props.disabled)
|
||||
let checked = computed(() => toRaw(api.value.value) === toRaw(props.value))
|
||||
|
||||
return {
|
||||
id,
|
||||
el: optionRef,
|
||||
labelledby,
|
||||
describedby,
|
||||
state,
|
||||
checked: computed(() => toRaw(api.value.value) === toRaw(props.value)),
|
||||
disabled,
|
||||
checked,
|
||||
tabIndex: computed(() => {
|
||||
if (disabled.value) return -1
|
||||
if (checked.value) return 0
|
||||
if (!api.containsCheckedOption.value && isFirstOption.value) return 0
|
||||
return -1
|
||||
}),
|
||||
handleClick() {
|
||||
let value = props.value
|
||||
if (api.value.value === value) return
|
||||
if (!api.change(props.value)) return
|
||||
|
||||
state.value |= OptionState.Active
|
||||
|
||||
api.change(value)
|
||||
optionRef.value?.focus()
|
||||
},
|
||||
handleFocus() {
|
||||
|
||||
@@ -179,7 +179,7 @@ export let TransitionChild = defineComponent({
|
||||
|
||||
let nesting = useNesting(() => {
|
||||
// When all children have been unmounted we can only hide ourselves if and only if we are not
|
||||
// transitioning ourserlves. Otherwise we would unmount before the transitions are finished.
|
||||
// transitioning ourselves. Otherwise we would unmount before the transitions are finished.
|
||||
if (!isTransitioning.value) {
|
||||
state.value = TreeStates.Hidden
|
||||
unregister(id)
|
||||
|
||||
@@ -39,7 +39,7 @@ it('should be possible to transition', async () => {
|
||||
expect(snapshots[1].content).toEqual('<div class="enter enterFrom"></div>')
|
||||
|
||||
// NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
|
||||
// necessary to put the classes on the element and immediatley remove them.
|
||||
// necessary to put the classes on the element and immediately remove them.
|
||||
|
||||
// Cleanup phase
|
||||
expect(snapshots[2].content).toEqual('<div class=""></div>')
|
||||
|
||||
@@ -864,6 +864,10 @@ export function getDialog(): HTMLElement | null {
|
||||
return document.querySelector('[role="dialog"]')
|
||||
}
|
||||
|
||||
export function getDialogs(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll('[role="dialog"]'))
|
||||
}
|
||||
|
||||
export function getDialogTitle(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-title-"]')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { logDOM, fireEvent } from '@testing-library/dom'
|
||||
import { logDOM, fireEvent, screen } from '@testing-library/dom'
|
||||
|
||||
let mountedWrappers = new Set()
|
||||
|
||||
@@ -38,6 +38,7 @@ export function render(TestComponent: any, options?: Parameters<typeof mount>[1]
|
||||
|
||||
function cleanup() {
|
||||
mountedWrappers.forEach(cleanupAtWrapper)
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
function cleanupAtWrapper(wrapper: any) {
|
||||
@@ -57,4 +58,4 @@ if (typeof afterEach === 'function') {
|
||||
afterEach(() => cleanup())
|
||||
}
|
||||
|
||||
export { fireEvent }
|
||||
export { fireEvent, screen }
|
||||
|
||||
@@ -144,7 +144,7 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
|
||||
|
||||
// This is a little weird, but let me try and explain: There are a few scenario's
|
||||
// in chrome for example where a focused `<a>` tag does not get the default focus
|
||||
// styles and sometimes they do. This highly depends on wether you started by
|
||||
// styles and sometimes they do. This highly depends on whether you started by
|
||||
// clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
|
||||
// then the active element (document.activeElement) is this anchor, which is expected.
|
||||
// However in that case the default focus styles are not applied *unless* you
|
||||
|
||||
Reference in New Issue
Block a user