Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Section): Add controlled mode support #6328

Merged
merged 5 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions packages/core/src/components/section/section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,31 @@ import { Icon } from "../icon/icon";
*/
export type SectionElevation = typeof Elevation.ZERO | typeof Elevation.ONE;

export interface SectionCollapseProps
extends Pick<CollapseProps, "className" | "isOpen" | "keepChildrenMounted" | "transitionDuration"> {
/**
* 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 element is clicked.
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
*/
onToggleCollapse?: () => void;
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
}

export interface SectionProps extends Props, Omit<HTMLDivProps, "title">, React.RefAttributes<HTMLDivElement> {
/**
* Whether this section's contents should be collapsible.
Expand All @@ -44,16 +69,9 @@ export interface SectionProps extends Props, Omit<HTMLDivProps, "title">, 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<CollapseProps, "className" | "keepChildrenMounted" | "transitionDuration"> & {
/**
* @default true
*/
defaultIsOpen?: boolean;
};
collapseProps?: SectionCollapseProps;

/**
* Whether this section should use compact styles.
Expand Down Expand Up @@ -113,9 +131,21 @@ export const Section: React.FC<SectionProps> = 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<boolean>(!(collapseProps?.defaultIsOpen ?? true));
const toggleIsCollapsed = React.useCallback(() => setIsCollapsed(!isCollapsed), [isCollapsed]);
const [isCollapsedUncontrolled, setIsCollapsed] = React.useState<boolean>(!(collapseProps?.defaultIsOpen ?? true));

const isCollapsed = isControlled ? !collapseProps?.isOpen : isCollapsedUncontrolled;

const toggleIsCollapsed = React.useCallback(() => {
if (isControlled) {
collapseProps?.onToggleCollapse?.();
} else {
setIsCollapsed(!isCollapsed);
}
}, [collapseProps, isCollapsed, isControlled]);

const isHeaderLeftContainerVisible = title != null || icon != null || subtitle != null;
const isHeaderRightContainerVisible = rightElement != null || collapsible;
Expand Down
94 changes: 61 additions & 33 deletions packages/core/test/section/sectionTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,6 +25,17 @@ import { Classes, H6, Section, SectionCard } from "../../src";
describe("<Section>", () => {
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);
Expand Down Expand Up @@ -62,40 +73,57 @@ describe("<Section>", () => {
assert.isTrue(wrapper.find(`.${Classes.SECTION_HEADER_SUB_TITLE}`).hostNodes().exists());
});

it("collapsible is open when defaultIsOpen={undefined}", () => {
const wrapper = mount(
<Section collapsible={true} collapseProps={{ defaultIsOpen: undefined }} title="Test">
<SectionCard>is open</SectionCard>
</Section>,
{
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(
<Section collapsible={true} collapseProps={{ defaultIsOpen: undefined }} title="Test">
<SectionCard>is open</SectionCard>
</Section>,
{ attachTo: containerElement },
);
assertIsOpen(wrapper);
});

it("collapsible is open when defaultIsOpen={true}", () => {
const wrapper = mount(
<Section collapsible={true} collapseProps={{ defaultIsOpen: true }} title="Test">
<SectionCard>is open</SectionCard>
</Section>,
{
attachTo: containerElement,
},
);
assert.isTrue(wrapper.find(`[data-icon="${IconNames.CHEVRON_UP}"]`).exists());
it("collapsible is open when defaultIsOpen={true}", () => {
const wrapper = mount(
<Section collapsible={true} collapseProps={{ defaultIsOpen: true }} title="Test">
<SectionCard>is open</SectionCard>
</Section>,
{ attachTo: containerElement },
);
assertIsOpen(wrapper);
});

it("collapsible is closed when defaultIsOpen={false}", () => {
const wrapper = mount(
<Section collapsible={true} collapseProps={{ defaultIsOpen: false }} title="Test">
<SectionCard>is closed</SectionCard>
</Section>,
{ attachTo: containerElement },
);
assertIsClosed(wrapper);
});
});

it("collapsible is closed when defaultIsOpen={false}", () => {
const wrapper = mount(
<Section collapsible={true} collapseProps={{ defaultIsOpen: false }} title="Test">
<SectionCard>is closed</SectionCard>
</Section>,
{
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(
<Section collapsible={true} collapseProps={{ isOpen: true }} title="Test">
<SectionCard>is open</SectionCard>
</Section>,
{ attachTo: containerElement },
);
assertIsOpen(wrapper);
});

it("collapsible is closed when isOpen={false}", () => {
const wrapper = mount(
<Section collapsible={true} collapseProps={{ isOpen: false }} title="Test">
<SectionCard>is closed</SectionCard>
</Section>,
{ attachTo: containerElement },
);
assertIsClosed(wrapper);
});
});
});
36 changes: 33 additions & 3 deletions packages/docs-app/src/examples/core-examples/sectionExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface SectionExampleState {
hasMultipleCards: boolean;
hasRightElement: boolean;
isCompact: boolean;
isControlled: boolean;
isOpen: boolean;
isPanelPadded: boolean;
}

Expand All @@ -63,6 +65,8 @@ export class SectionExample extends React.PureComponent<ExampleProps, SectionExa
hasMultipleCards: false,
hasRightElement: true,
isCompact: false,
isControlled: false,
isOpen: true,
isPanelPadded: true,
};

Expand Down Expand Up @@ -91,8 +95,26 @@ export class SectionExample extends React.PureComponent<ExampleProps, SectionExa
<Switch checked={collapsible} label="Collapsible" onChange={this.toggleCollapsible} />
{collapsible && (
<>
<H6>Collapse Props</H6>
<Switch checked={defaultIsOpen} label="Default is open" onChange={this.toggleDefaultIsOpen} />
<H5>Collapse Props</H5>
<Switch
checked={defaultIsOpen}
disabled={this.state.isControlled}
label="Default is open"
onChange={this.toggleDefaultIsOpen}
/>

<H6>Control</H6>
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
<Switch
checked={this.state.isControlled}
label="Is controlled"
onChange={this.toggleIsControlled}
/>
<Switch
checked={this.state.isOpen}
disabled={!this.state.isControlled}
label="Open"
onChange={this.toggleIsOpen}
/>
</>
)}
<Label>
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -141,6 +163,10 @@ export class SectionExample extends React.PureComponent<ExampleProps, SectionExa
</div>
);

const collapseProps = this.state.isControlled
? { isOpen: this.state.isOpen, onToggleCollapse: this.toggleIsOpen }
: { defaultIsOpen };

return (
<Example options={options} {...this.props}>
<Section
Expand All @@ -151,7 +177,7 @@ export class SectionExample extends React.PureComponent<ExampleProps, SectionExa
key={String(defaultIsOpen)}
collapsible={collapsible}
compact={isCompact}
collapseProps={{ defaultIsOpen }}
collapseProps={collapseProps}
elevation={elevation}
icon={hasIcon ? IconNames.BOOK : undefined}
rightElement={
Expand Down Expand Up @@ -190,6 +216,10 @@ export class SectionExample extends React.PureComponent<ExampleProps, SectionExa

private togglePanelIsPadded = () => 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) => {
Expand Down