diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f97e89af6..7ef38401e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx index 06f432bf1b..ccbaa1e812 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx @@ -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( + + Open + + Close + + + ) + + // 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()) + }) + ) }) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 0f2f28785b..3de8a85bb0 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -100,6 +100,13 @@ function useDisclosureContext(component: string) { return context } +let DisclosurePanelContext = createContext(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) => { - 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) => { @@ -205,9 +229,15 @@ let Button = forwardRefWithAs(function Button { 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( @@ -216,16 +246,20 @@ let Button = forwardRefWithAs(function Button + {render({ + props: { ...passthroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_PANEL_TAG, + features: PanelRenderFeatures, + visible, + name: 'Disclosure.Panel', + })} + + ) }) // --- diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts index e0fd49d1b4..29f5952a42 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts @@ -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` + + Open + + Close + + + ` + ) + + // 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()) + }) + ) }) diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts index 7d67deccb4..5b5a5ab702 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts @@ -16,7 +16,10 @@ enum DisclosureStates { interface StateDefinition { // State disclosureState: Ref - panelRef: Ref + panel: Ref + panelId: string + button: Ref + buttonId: string // State mutators toggleDisclosure(): void @@ -36,6 +39,11 @@ function useDisclosureContext(component: string) { return context } +let DisclosurePanelContext = Symbol('DisclosurePanelContext') as InjectionKey +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( props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed ) - let panelRef = ref(null) + let panelRef = ref(null) + let buttonRef = ref(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 } }, })