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:
@@ -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 }
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user