Add ability to use Disclosure.Button inside a Disclosure.Panel (#682)

* add ability to use `Disclosure.Button` inside a `Disclosure.Panel`

If you do it this way, then the `Disclosure.Button` will function as a
`close` button.

This will make it consistent with the `Popover.Button` inside the
`Popover.Panel` funcitonality.

* update changelog
This commit is contained in:
Robin Malfait
2021-07-13 19:29:29 +02:00
committed by GitHub
parent 9af04a0a7e
commit 10110a928f
5 changed files with 210 additions and 57 deletions
+2
View File
@@ -10,12 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
- Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
## [Unreleased - Vue]
### Added
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
- Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
## [@headlessui/react@v1.3.0] - 2021-06-21
@@ -9,6 +9,8 @@ import {
assertDisclosureButton,
getDisclosureButton,
getDisclosurePanel,
assertActiveElement,
getByText,
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
import { Transition } from '../transitions/transition'
@@ -619,4 +621,36 @@ describe('Mouse interactions', () => {
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
})
)
it(
'should be possible to close the Disclosure by clicking on a Disclosure.Button inside a Disclosure.Panel',
suppressConsoleLogs(async () => {
render(
<Disclosure>
<Disclosure.Button>Open</Disclosure.Button>
<Disclosure.Panel>
<Disclosure.Button>Close</Disclosure.Button>
</Disclosure.Panel>
</Disclosure>
)
// Open the disclosure
await click(getDisclosureButton())
let closeBtn = getByText('Close')
expect(closeBtn).not.toHaveAttribute('id')
expect(closeBtn).not.toHaveAttribute('aria-controls')
expect(closeBtn).not.toHaveAttribute('aria-expanded')
// The close button should close the disclosure
await click(closeBtn)
// Verify it is closed
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
// Verify we restored the Open button
assertActiveElement(getDisclosureButton())
})
)
})
@@ -100,6 +100,13 @@ function useDisclosureContext(component: string) {
return context
}
let DisclosurePanelContext = createContext<string | null>(null)
DisclosurePanelContext.displayName = 'DisclosurePanelContext'
function useDisclosurePanelContext() {
return useContext(DisclosurePanelContext)
}
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
@@ -176,18 +183,35 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let [state, dispatch] = useDisclosureContext([Disclosure.name, Button.name].join('.'))
let buttonRef = useSyncRefs(ref)
let panelContext = useDisclosurePanelContext()
let isWithinPanel = panelContext === null ? false : panelContext === state.panelId
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ToggleDisclosure })
break
if (isWithinPanel) {
if (state.disclosureState === DisclosureStates.Closed) return
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ToggleDisclosure })
document.getElementById(state.buttonId)?.focus()
break
}
} else {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.ToggleDisclosure })
break
}
}
},
[dispatch]
[dispatch, isWithinPanel, state.disclosureState]
)
let handleKeyUp = useCallback((event: ReactKeyboardEvent<HTMLButtonElement>) => {
@@ -205,9 +229,15 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return
if (props.disabled) return
dispatch({ type: ActionTypes.ToggleDisclosure })
if (isWithinPanel) {
dispatch({ type: ActionTypes.ToggleDisclosure })
document.getElementById(state.buttonId)?.focus()
} else {
dispatch({ type: ActionTypes.ToggleDisclosure })
}
},
[dispatch, props.disabled]
[dispatch, props.disabled, state.buttonId, isWithinPanel]
)
let slot = useMemo<ButtonRenderPropArg>(
@@ -216,16 +246,20 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
)
let passthroughProps = props
let propsWeControl = {
ref: buttonRef,
id: state.buttonId,
type: 'button',
'aria-expanded': props.disabled ? undefined : state.disclosureState === DisclosureStates.Open,
'aria-controls': state.linkedPanel ? state.panelId : undefined,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
}
let propsWeControl = isWithinPanel
? { type: 'button', onKeyDown: handleKeyDown, onClick: handleClick }
: {
ref: buttonRef,
id: state.buttonId,
type: 'button',
'aria-expanded': props.disabled
? undefined
: state.disclosureState === DisclosureStates.Open,
'aria-controls': state.linkedPanel ? state.panelId : undefined,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
}
return render({
props: { ...passthroughProps, ...propsWeControl },
@@ -285,14 +319,18 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
}
let passthroughProps = props
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible,
name: 'Disclosure.Panel',
})
return (
<DisclosurePanelContext.Provider value={state.panelId}>
{render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible,
name: 'Disclosure.Panel',
})}
</DisclosurePanelContext.Provider>
)
})
// ---
@@ -8,6 +8,8 @@ import {
assertDisclosureButton,
getDisclosureButton,
getDisclosurePanel,
getByText,
assertActiveElement,
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
import { html } from '../../test-utils/html'
@@ -715,4 +717,38 @@ describe('Mouse interactions', () => {
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
})
)
it(
'should be possible to close the Disclosure by clicking on a DisclosureButton inside a DisclosurePanel',
suppressConsoleLogs(async () => {
renderTemplate(
html`
<Disclosure>
<DisclosureButton>Open</DisclosureButton>
<DisclosurePanel>
<DisclosureButton>Close</DisclosureButton>
</DisclosurePanel>
</Disclosure>
`
)
// Open the disclosure
await click(getDisclosureButton())
let closeBtn = getByText('Close')
expect(closeBtn).not.toHaveAttribute('id')
expect(closeBtn).not.toHaveAttribute('aria-controls')
expect(closeBtn).not.toHaveAttribute('aria-expanded')
// The close button should close the disclosure
await click(closeBtn)
// Verify it is closed
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
// Verify we restored the Open button
assertActiveElement(getDisclosureButton())
})
)
})
@@ -16,7 +16,10 @@ enum DisclosureStates {
interface StateDefinition {
// State
disclosureState: Ref<DisclosureStates>
panelRef: Ref<HTMLElement | null>
panel: Ref<HTMLElement | null>
panelId: string
button: Ref<HTMLButtonElement | null>
buttonId: string
// State mutators
toggleDisclosure(): void
@@ -36,6 +39,11 @@ function useDisclosureContext(component: string) {
return context
}
let DisclosurePanelContext = Symbol('DisclosurePanelContext') as InjectionKey<string | null>
function useDisclosurePanelContext() {
return inject(DisclosurePanelContext, null)
}
// ---
export let Disclosure = defineComponent({
@@ -45,14 +53,21 @@ export let Disclosure = defineComponent({
defaultOpen: { type: [Boolean], default: false },
},
setup(props, { slots, attrs }) {
let buttonId = `headlessui-disclosure-button-${useId()}`
let panelId = `headlessui-disclosure-panel-${useId()}`
let disclosureState = ref<StateDefinition['disclosureState']['value']>(
props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed
)
let panelRef = ref<StateDefinition['panelRef']['value']>(null)
let panelRef = ref<StateDefinition['panel']['value']>(null)
let buttonRef = ref<StateDefinition['button']['value']>(null)
let api = {
buttonId,
panelId,
disclosureState,
panelRef,
panel: panelRef,
button: buttonRef,
toggleDisclosure() {
disclosureState.value = match(disclosureState.value, {
[DisclosureStates.Open]: DisclosureStates.Closed,
@@ -91,18 +106,25 @@ export let DisclosureButton = defineComponent({
let api = useDisclosureContext('DisclosureButton')
let slot = { open: api.disclosureState.value === DisclosureStates.Open }
let propsWeControl = {
id: this.id,
type: 'button',
'aria-expanded': this.$props.disabled
? undefined
: api.disclosureState.value === DisclosureStates.Open,
'aria-controls': this.ariaControls,
disabled: this.$props.disabled ? true : undefined,
onClick: this.handleClick,
onKeydown: this.handleKeyDown,
onKeyup: this.handleKeyUp,
}
let propsWeControl = this.isWithinPanel
? {
type: 'button',
onClick: this.handleClick,
onKeydown: this.handleKeyDown,
}
: {
id: this.id,
ref: 'el',
type: 'button',
'aria-expanded': this.$props.disabled
? undefined
: api.disclosureState.value === DisclosureStates.Open,
'aria-controls': dom(api.panel) ? api.panelId : undefined,
disabled: this.$props.disabled ? true : undefined,
onClick: this.handleClick,
onKeydown: this.handleKeyDown,
onKeyup: this.handleKeyUp,
}
return render({
props: { ...this.$props, ...propsWeControl },
@@ -114,26 +136,46 @@ export let DisclosureButton = defineComponent({
},
setup(props) {
let api = useDisclosureContext('DisclosureButton')
let buttonId = `headlessui-disclosure-button-${useId()}`
let ariaControls = computed(() => dom(api.panelRef)?.id ?? undefined)
let panelContext = useDisclosurePanelContext()
let isWithinPanel = panelContext === null ? false : panelContext === api.panelId
return {
id: buttonId,
ariaControls,
isWithinPanel,
id: api.buttonId,
el: isWithinPanel ? undefined : api.button,
handleClick() {
if (props.disabled) return
api.toggleDisclosure()
if (isWithinPanel) {
api.toggleDisclosure()
dom(api.button)?.focus()
} else {
api.toggleDisclosure()
}
},
handleKeyDown(event: KeyboardEvent) {
if (props.disabled) return
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
api.toggleDisclosure()
break
if (isWithinPanel) {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
api.toggleDisclosure()
dom(api.button)?.focus()
break
}
} else {
switch (event.key) {
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
api.toggleDisclosure()
break
}
}
},
handleKeyUp(event: KeyboardEvent) {
@@ -177,7 +219,8 @@ export let DisclosurePanel = defineComponent({
},
setup() {
let api = useDisclosureContext('DisclosurePanel')
let panelId = `headlessui-disclosure-panel-${useId()}`
provide(DisclosurePanelContext, api.panelId)
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
@@ -188,6 +231,6 @@ export let DisclosurePanel = defineComponent({
return api.disclosureState.value === DisclosureStates.Open
})
return { id: panelId, el: api.panelRef, visible }
return { id: api.panelId, el: api.panel, visible }
},
})