Skip to content

Commit 10110a9

Browse files
authored
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
1 parent 9af04a0 commit 10110a9

File tree

5 files changed

+210
-57
lines changed

5 files changed

+210
-57
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
13+
- Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
1314

1415
## [Unreleased - Vue]
1516

1617
### Added
1718

1819
- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
20+
- Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
1921

2022
## [@headlessui/react@v1.3.0] - 2021-06-21
2123

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
assertDisclosureButton,
1010
getDisclosureButton,
1111
getDisclosurePanel,
12+
assertActiveElement,
13+
getByText,
1214
} from '../../test-utils/accessibility-assertions'
1315
import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
1416
import { Transition } from '../transitions/transition'
@@ -619,4 +621,36 @@ describe('Mouse interactions', () => {
619621
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
620622
})
621623
)
624+
625+
it(
626+
'should be possible to close the Disclosure by clicking on a Disclosure.Button inside a Disclosure.Panel',
627+
suppressConsoleLogs(async () => {
628+
render(
629+
<Disclosure>
630+
<Disclosure.Button>Open</Disclosure.Button>
631+
<Disclosure.Panel>
632+
<Disclosure.Button>Close</Disclosure.Button>
633+
</Disclosure.Panel>
634+
</Disclosure>
635+
)
636+
637+
// Open the disclosure
638+
await click(getDisclosureButton())
639+
640+
let closeBtn = getByText('Close')
641+
642+
expect(closeBtn).not.toHaveAttribute('id')
643+
expect(closeBtn).not.toHaveAttribute('aria-controls')
644+
expect(closeBtn).not.toHaveAttribute('aria-expanded')
645+
646+
// The close button should close the disclosure
647+
await click(closeBtn)
648+
649+
// Verify it is closed
650+
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
651+
652+
// Verify we restored the Open button
653+
assertActiveElement(getDisclosureButton())
654+
})
655+
)
622656
})

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

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ function useDisclosureContext(component: string) {
100100
return context
101101
}
102102

103+
let DisclosurePanelContext = createContext<string | null>(null)
104+
DisclosurePanelContext.displayName = 'DisclosurePanelContext'
105+
106+
function useDisclosurePanelContext() {
107+
return useContext(DisclosurePanelContext)
108+
}
109+
103110
function stateReducer(state: StateDefinition, action: Actions) {
104111
return match(action.type, reducers, state, action)
105112
}
@@ -176,18 +183,35 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
176183
let [state, dispatch] = useDisclosureContext([Disclosure.name, Button.name].join('.'))
177184
let buttonRef = useSyncRefs(ref)
178185

186+
let panelContext = useDisclosurePanelContext()
187+
let isWithinPanel = panelContext === null ? false : panelContext === state.panelId
188+
179189
let handleKeyDown = useCallback(
180190
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
181-
switch (event.key) {
182-
case Keys.Space:
183-
case Keys.Enter:
184-
event.preventDefault()
185-
event.stopPropagation()
186-
dispatch({ type: ActionTypes.ToggleDisclosure })
187-
break
191+
if (isWithinPanel) {
192+
if (state.disclosureState === DisclosureStates.Closed) return
193+
194+
switch (event.key) {
195+
case Keys.Space:
196+
case Keys.Enter:
197+
event.preventDefault()
198+
event.stopPropagation()
199+
dispatch({ type: ActionTypes.ToggleDisclosure })
200+
document.getElementById(state.buttonId)?.focus()
201+
break
202+
}
203+
} else {
204+
switch (event.key) {
205+
case Keys.Space:
206+
case Keys.Enter:
207+
event.preventDefault()
208+
event.stopPropagation()
209+
dispatch({ type: ActionTypes.ToggleDisclosure })
210+
break
211+
}
188212
}
189213
},
190-
[dispatch]
214+
[dispatch, isWithinPanel, state.disclosureState]
191215
)
192216

193217
let handleKeyUp = useCallback((event: ReactKeyboardEvent<HTMLButtonElement>) => {
@@ -205,9 +229,15 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
205229
(event: ReactMouseEvent) => {
206230
if (isDisabledReactIssue7711(event.currentTarget)) return
207231
if (props.disabled) return
208-
dispatch({ type: ActionTypes.ToggleDisclosure })
232+
233+
if (isWithinPanel) {
234+
dispatch({ type: ActionTypes.ToggleDisclosure })
235+
document.getElementById(state.buttonId)?.focus()
236+
} else {
237+
dispatch({ type: ActionTypes.ToggleDisclosure })
238+
}
209239
},
210-
[dispatch, props.disabled]
240+
[dispatch, props.disabled, state.buttonId, isWithinPanel]
211241
)
212242

213243
let slot = useMemo<ButtonRenderPropArg>(
@@ -216,16 +246,20 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
216246
)
217247

218248
let passthroughProps = props
219-
let propsWeControl = {
220-
ref: buttonRef,
221-
id: state.buttonId,
222-
type: 'button',
223-
'aria-expanded': props.disabled ? undefined : state.disclosureState === DisclosureStates.Open,
224-
'aria-controls': state.linkedPanel ? state.panelId : undefined,
225-
onKeyDown: handleKeyDown,
226-
onKeyUp: handleKeyUp,
227-
onClick: handleClick,
228-
}
249+
let propsWeControl = isWithinPanel
250+
? { type: 'button', onKeyDown: handleKeyDown, onClick: handleClick }
251+
: {
252+
ref: buttonRef,
253+
id: state.buttonId,
254+
type: 'button',
255+
'aria-expanded': props.disabled
256+
? undefined
257+
: state.disclosureState === DisclosureStates.Open,
258+
'aria-controls': state.linkedPanel ? state.panelId : undefined,
259+
onKeyDown: handleKeyDown,
260+
onKeyUp: handleKeyUp,
261+
onClick: handleClick,
262+
}
229263

230264
return render({
231265
props: { ...passthroughProps, ...propsWeControl },
@@ -285,14 +319,18 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
285319
}
286320
let passthroughProps = props
287321

288-
return render({
289-
props: { ...passthroughProps, ...propsWeControl },
290-
slot,
291-
defaultTag: DEFAULT_PANEL_TAG,
292-
features: PanelRenderFeatures,
293-
visible,
294-
name: 'Disclosure.Panel',
295-
})
322+
return (
323+
<DisclosurePanelContext.Provider value={state.panelId}>
324+
{render({
325+
props: { ...passthroughProps, ...propsWeControl },
326+
slot,
327+
defaultTag: DEFAULT_PANEL_TAG,
328+
features: PanelRenderFeatures,
329+
visible,
330+
name: 'Disclosure.Panel',
331+
})}
332+
</DisclosurePanelContext.Provider>
333+
)
296334
})
297335

298336
// ---

packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
assertDisclosureButton,
99
getDisclosureButton,
1010
getDisclosurePanel,
11+
getByText,
12+
assertActiveElement,
1113
} from '../../test-utils/accessibility-assertions'
1214
import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
1315
import { html } from '../../test-utils/html'
@@ -715,4 +717,38 @@ describe('Mouse interactions', () => {
715717
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
716718
})
717719
)
720+
721+
it(
722+
'should be possible to close the Disclosure by clicking on a DisclosureButton inside a DisclosurePanel',
723+
suppressConsoleLogs(async () => {
724+
renderTemplate(
725+
html`
726+
<Disclosure>
727+
<DisclosureButton>Open</DisclosureButton>
728+
<DisclosurePanel>
729+
<DisclosureButton>Close</DisclosureButton>
730+
</DisclosurePanel>
731+
</Disclosure>
732+
`
733+
)
734+
735+
// Open the disclosure
736+
await click(getDisclosureButton())
737+
738+
let closeBtn = getByText('Close')
739+
740+
expect(closeBtn).not.toHaveAttribute('id')
741+
expect(closeBtn).not.toHaveAttribute('aria-controls')
742+
expect(closeBtn).not.toHaveAttribute('aria-expanded')
743+
744+
// The close button should close the disclosure
745+
await click(closeBtn)
746+
747+
// Verify it is closed
748+
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
749+
750+
// Verify we restored the Open button
751+
assertActiveElement(getDisclosureButton())
752+
})
753+
)
718754
})

0 commit comments

Comments
 (0)