diff --git a/packages/core/src/components/section/section.tsx b/packages/core/src/components/section/section.tsx index a3c19106aa..62d0623f19 100644 --- a/packages/core/src/components/section/section.tsx +++ b/packages/core/src/components/section/section.tsx @@ -34,6 +34,31 @@ import { Icon } from "../icon/icon"; */ export type SectionElevation = typeof Elevation.ZERO | typeof Elevation.ONE; +export interface SectionCollapseProps + extends Pick { + /** + * Whether the component is initially open or closed. + * + * This prop has no effect if `collapsible={false}` or the component is in controlled mode, + * i.e. when `isOpen` is **not** `undefined`. + * + * @default true + */ + defaultIsOpen?: boolean; + + /** + * Whether the component is open or closed. + * + * Passing a boolean value to `isOpen` will enabled controlled mode for the component. + */ + isOpen?: boolean; + + /** + * Callback invoked in controlled mode when the collapse toggle element is clicked. + */ + onToggle?: () => void; +} + export interface SectionProps extends Props, Omit, React.RefAttributes { /** * Whether this section's contents should be collapsible. @@ -44,16 +69,9 @@ export interface SectionProps extends Props, Omit, React. /** * Subset of props to forward to the underlying {@link Collapse} component, with the addition of a - * `defaultIsOpen` option which sets the default open state of the component. - * - * This prop has no effect if `collapsible={false}`. + * `defaultIsOpen` option which sets the default open state of the component when in uncontrolled mode. */ - collapseProps?: Pick & { - /** - * @default true - */ - defaultIsOpen?: boolean; - }; + collapseProps?: SectionCollapseProps; /** * Whether this section should use compact styles. @@ -113,9 +131,21 @@ export const Section: React.FC = React.forwardRef((props, ref) => title, ...htmlProps } = props; + // Determine whether to use controlled or uncontrolled state. + const isControlled = collapseProps?.isOpen != null; + // The initial useState value is negated in order to conform to the `isCollapsed` expectation. - const [isCollapsed, setIsCollapsed] = React.useState(!(collapseProps?.defaultIsOpen ?? true)); - const toggleIsCollapsed = React.useCallback(() => setIsCollapsed(!isCollapsed), [isCollapsed]); + const [isCollapsedUncontrolled, setIsCollapsed] = React.useState(!(collapseProps?.defaultIsOpen ?? true)); + + const isCollapsed = isControlled ? !collapseProps?.isOpen : isCollapsedUncontrolled; + + const toggleIsCollapsed = React.useCallback(() => { + if (isControlled) { + collapseProps?.onToggle?.(); + } else { + setIsCollapsed(!isCollapsed); + } + }, [collapseProps, isCollapsed, isControlled]); const isHeaderLeftContainerVisible = title != null || icon != null || subtitle != null; const isHeaderRightContainerVisible = rightElement != null || collapsible; diff --git a/packages/core/test/section/sectionTests.tsx b/packages/core/test/section/sectionTests.tsx index 1e1f53dd36..2df80dbf87 100644 --- a/packages/core/test/section/sectionTests.tsx +++ b/packages/core/test/section/sectionTests.tsx @@ -15,7 +15,7 @@ */ import { assert } from "chai"; -import { mount } from "enzyme"; +import { mount, ReactWrapper } from "enzyme"; import * as React from "react"; import { IconNames } from "@blueprintjs/icons"; @@ -25,6 +25,17 @@ import { Classes, H6, Section, SectionCard } from "../../src"; describe("
", () => { let containerElement: HTMLElement | undefined; + const isOpenSelector = `[data-icon="${IconNames.CHEVRON_UP}"]`; + const isClosedSelector = `[data-icon="${IconNames.CHEVRON_DOWN}"]`; + + const assertIsOpen = (wrapper: ReactWrapper) => { + assert.isTrue(wrapper.find(isOpenSelector).exists()); + }; + + const assertIsClosed = (wrapper: ReactWrapper) => { + assert.isTrue(wrapper.find(isClosedSelector).exists()); + }; + beforeEach(() => { containerElement = document.createElement("div"); document.body.appendChild(containerElement); @@ -62,40 +73,57 @@ describe("
", () => { assert.isTrue(wrapper.find(`.${Classes.SECTION_HEADER_SUB_TITLE}`).hostNodes().exists()); }); - it("collapsible is open when defaultIsOpen={undefined}", () => { - const wrapper = mount( -
- is open -
, - { - attachTo: containerElement, - }, - ); - assert.isTrue(wrapper.find(`[data-icon="${IconNames.CHEVRON_UP}"]`).exists()); - }); + describe("uncontrolled collapse mode", () => { + it("collapsible is open when defaultIsOpen={undefined}", () => { + const wrapper = mount( +
+ is open +
, + { attachTo: containerElement }, + ); + assertIsOpen(wrapper); + }); - it("collapsible is open when defaultIsOpen={true}", () => { - const wrapper = mount( -
- is open -
, - { - attachTo: containerElement, - }, - ); - assert.isTrue(wrapper.find(`[data-icon="${IconNames.CHEVRON_UP}"]`).exists()); + it("collapsible is open when defaultIsOpen={true}", () => { + const wrapper = mount( +
+ is open +
, + { attachTo: containerElement }, + ); + assertIsOpen(wrapper); + }); + + it("collapsible is closed when defaultIsOpen={false}", () => { + const wrapper = mount( +
+ is closed +
, + { attachTo: containerElement }, + ); + assertIsClosed(wrapper); + }); }); - it("collapsible is closed when defaultIsOpen={false}", () => { - const wrapper = mount( -
- is closed -
, - { - attachTo: containerElement, - }, - ); - - assert.isTrue(wrapper.find(`[data-icon="${IconNames.CHEVRON_DOWN}"]`).exists()); + describe("controlled collapse mode", () => { + it("collapsible is open when isOpen={true}", () => { + const wrapper = mount( +
+ is open +
, + { attachTo: containerElement }, + ); + assertIsOpen(wrapper); + }); + + it("collapsible is closed when isOpen={false}", () => { + const wrapper = mount( +
+ is closed +
, + { attachTo: containerElement }, + ); + assertIsClosed(wrapper); + }); }); }); diff --git a/packages/docs-app/src/examples/core-examples/sectionExample.tsx b/packages/docs-app/src/examples/core-examples/sectionExample.tsx index 5c3a96811c..352762262d 100644 --- a/packages/docs-app/src/examples/core-examples/sectionExample.tsx +++ b/packages/docs-app/src/examples/core-examples/sectionExample.tsx @@ -23,7 +23,6 @@ import { EditableText, Elevation, H5, - H6, Label, Section, SectionCard, @@ -43,6 +42,8 @@ export interface SectionExampleState { hasMultipleCards: boolean; hasRightElement: boolean; isCompact: boolean; + isControlled: boolean; + isOpen: boolean; isPanelPadded: boolean; } @@ -63,6 +64,8 @@ export class SectionExample extends React.PureComponent - {collapsible && ( - <> -
Collapse Props
- - - )} +
Collapse Props
+ + + +
Children
); + const collapseProps = this.state.isControlled + ? { isOpen: this.state.isOpen, onToggle: this.toggleIsOpen } + : { defaultIsOpen }; + return (
this.setState({ isPanelPadded: !this.state.isPanelPadded }); + private toggleIsControlled = () => this.setState({ isControlled: !this.state.isControlled }); + + private toggleIsOpen = () => this.setState({ isOpen: !this.state.isOpen }); + private handleElevationChange = (elevation: SectionElevation) => this.setState({ elevation }); private handleEditContent = (event: React.MouseEvent) => {