Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f

### Bug fixes

- Fixed alignment of `Page` and `TopBar` so the search aligns to the page. ([#3610](https://github.com/Shopify/polaris-react/pull/3610))
- Removed extra bottom border on the `DataTable` and added curved edges to footers ([#3571](https://github.com/Shopify/polaris-react/pull/3571))
- **`Button`:** `loading` no longer sets the invalid `role="alert"` ([#3590](https://github.com/Shopify/polaris-react/pull/3590))
- Removed `tabIndex=-1` from `Popover` when `preventAutoFocus` is true ([#3595](https://github.com/Shopify/polaris-react/pull/3595))
- Fix `Filters` janky animation when opening and closing ([#3606](https://github.com/Shopify/polaris-react/pull/3606))
- Fixed `Modal` header border color ([#3616](https://github.com/Shopify/polaris-react/pull/3616))
- Fixed `TopBar` search clear button alignment on iOS ([#3618](https://github.com/Shopify/polaris-react/pull/3618))
- Added dependency list to useImperativeHandle in `Banner` ([#3478](https://github.com/Shopify/polaris-react/pull/3478))
- Internationalize `Badge` labels ([#3655](https://github.com/Shopify/polaris-react/pull/3655))
- Aligned the `::before` 'indicator' to edge of container for `ActionList` ([#3619](https://github.com/Shopify/polaris-react/pull/3619))
- Fixed `FocusManager` from tracking inactive items that prevented trap focusing([#3630](https://github.com/Shopify/polaris-react/pull/3630))
- Added escape keybind to `Tooltip` ([#3627](https://github.com/Shopify/polaris-react/pull/3627))
- Removed extra bottom border on the `DataTable` and added curved edges to footers ([#3571](https://github.com/Shopify/polaris-react/pull/3571))
Expand Down
9 changes: 0 additions & 9 deletions scripts/build-validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,6 @@ function validateEsNextBuild() {
assert.ok(jsContent.includes("import './Avatar.css';"));
assert.ok(jsContent.includes('"Avatar": "Polaris-Avatar_z763p"'));
assert.ok(jsContent.includes('"hidden": "Polaris-Avatar--hidden_riqie"'));

assert.ok(
fs
.readFileSync(
'./dist/esnext/components/Collapsible/Collapsible.tsx.esnext',
'utf-8',
)
.includes('class Collapsible'),
);
}

function validateSassPublicApi() {
Expand Down
27 changes: 12 additions & 15 deletions src/components/Collapsible/Collapsible.scss
Original file line number Diff line number Diff line change
@@ -1,33 +1,30 @@
@import '../../styles/common';

.Collapsible {
overflow: hidden;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
will-change: opacity, max-height;
}

.animating {
transition-property: opacity, max-height;
transition-duration: duration(slow);
height: 0;
overflow: hidden;
will-change: height;
transition-property: height;
transition-duration: duration(fast);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to use var(--p-duration-1-0-0) here instead?

Edit: nvm looks like we need to use tokens to match some JS below

transition-timing-function: easing(out);
}

.open {
opacity: 1;
}

.fullyOpen {
height: auto;
overflow: visible;
}

// Stop children from being focused when aria-hidden
// .Collapsible[aria-hidden='true'] {
// display: none;
// }

.expandOnPrint {
@include when-printing {
opacity: 1;
// stylelint-disable-next-line declaration-no-important
max-height: none !important;
height: auto !important;
overflow: visible;
}
}
221 changes: 63 additions & 158 deletions src/components/Collapsible/Collapsible.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import React, {
createContext,
createRef,
TransitionEvent,
Component,
ComponentClass,
} from 'react';
import React, {useState, useRef, useEffect} from 'react';

import {classNames} from '../../utilities/css';

Expand All @@ -30,165 +24,76 @@ export interface CollapsibleProps {
children?: React.ReactNode;
}

type AnimationState =
| 'idle'
| 'measuring'
| 'closingStart'
| 'closing'
| 'openingStart'
| 'opening';

interface State {
height?: number | null;
animationState: AnimationState;
open: boolean;
}

const ParentCollapsibleExpandingContext = createContext(false);

class CollapsibleInner extends Component<CollapsibleProps, State> {
static contextType = ParentCollapsibleExpandingContext;

static getDerivedStateFromProps(
{open: willOpen}: CollapsibleProps,
{open, animationState: prevAnimationState}: State,
) {
let nextAnimationState = prevAnimationState;
if (open !== willOpen) {
nextAnimationState = 'measuring';
}

return {
animationState: nextAnimationState,
open: willOpen,
};
}

context!: React.ContextType<typeof ParentCollapsibleExpandingContext>;

state: State = {
height: null,
animationState: 'idle',
// eslint-disable-next-line react/no-unused-state
open: this.props.open,
export function Collapsible({
id,
expandOnPrint,
open,
transition,
children,
}: CollapsibleProps) {
const [height, setHeight] = useState<number | null>(null);
const [isOpen, setIsOpen] = useState(open);
const collapisbleContainer = useRef<HTMLDivElement>(null);

const wrapperClassName = classNames(
styles.Collapsible,
expandOnPrint && styles.expandOnPrint,
isOpen && styles.open,
height && styles.animating,
);

const collapsibleStyles = {
...(transition && {
transitionDuration: `${transition.duration}`,
transitionTimingFunction: `${transition.timingFunction}`,
}),
...(typeof height === 'number' && {
height: `${height}px`,
overflow: 'hidden',
}),
};

private node = createRef<HTMLDivElement>();
private heightNode = createRef<HTMLDivElement>();

componentDidUpdate({open: wasOpen}: CollapsibleProps) {
const {animationState} = this.state;
const parentCollapsibleExpanding = this.context;

if (parentCollapsibleExpanding && animationState !== 'idle') {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
animationState: 'idle',
});
// When animation is complete clean up
const handleCompleteAnimation = () => {
setHeight(null);
setIsOpen(open);
};

// Measure the child height for open and close
useEffect(() => {
if (open === isOpen || !collapisbleContainer.current) {
return;
}

requestAnimationFrame(() => {
const heightNode = this.heightNode.current;
switch (animationState) {
case 'idle':
break;
case 'measuring':
this.setState({
animationState: wasOpen ? 'closingStart' : 'openingStart',
height: wasOpen && heightNode ? heightNode.scrollHeight : 0,
});
break;
case 'closingStart':
this.setState({
animationState: 'closing',
height: 0,
});
break;
case 'openingStart':
this.setState({
animationState: 'opening',
height: heightNode ? heightNode.scrollHeight : 0,
});
}
});
}

render() {
const {id, expandOnPrint, open, children, transition} = this.props;
const {animationState, height} = this.state;
const parentCollapsibleExpanding = this.context;
setHeight(collapisbleContainer.current.scrollHeight);
}, [open, isOpen]);

const animating = animationState !== 'idle';

const wrapperClassName = classNames(
styles.Collapsible,
open && styles.open,
animating && styles.animating,
!animating && open && styles.fullyOpen,
expandOnPrint && styles.expandOnPrint,
);

const displayHeight = collapsibleHeight(open, animationState, height);

const content = animating || open || expandOnPrint ? children : null;

const transitionProperties = transition
? {
transitionDuration: `${transition.duration}`,
transitionTimingFunction: `${transition.timingFunction}`,
}
: null;

return (
<ParentCollapsibleExpandingContext.Provider
value={
parentCollapsibleExpanding || (open && animationState !== 'idle')
}
>
<div
id={id}
aria-hidden={!open}
style={{
maxHeight: `${displayHeight}`,
...transitionProperties,
}}
className={wrapperClassName}
ref={this.node}
onTransitionEnd={this.handleTransitionEnd}
>
<div ref={this.heightNode}>{content}</div>
</div>
</ParentCollapsibleExpandingContext.Provider>
);
}

private handleTransitionEnd = (event: TransitionEvent) => {
const {target} = event;
if (target === this.node.current) {
this.setState({animationState: 'idle', height: null});
// If closing, set the height zero on the next render
useEffect(() => {
if (open || height === null || !collapisbleContainer.current) {
return;
}
};
}

function collapsibleHeight(
open: boolean,
animationState: AnimationState,
height?: number | null,
) {
if (animationState === 'idle' && open) {
return open ? 'none' : undefined;
}

if (animationState === 'measuring') {
return open ? undefined : 'none';
}
// If it is currently animating put it back to zero
if (height !== collapisbleContainer.current.scrollHeight) {
setHeight(0);
return;
}

return `${height || 0}px`;
getComputedStyle(collapisbleContainer.current).height;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed otherwise the rendering engine measures and applies the height then set the new height causing no transition. This causes the rendering engine to apply the 0 height on the next cycle causing the transition to run.

setHeight(0);
}, [height, open]);

return (
<div
id={id}
style={collapsibleStyles}
className={wrapperClassName}
onTransitionEnd={() => handleCompleteAnimation()}
ref={collapisbleContainer}
// aria-hidden={!open && !isOpen}
>
{children}
</div>
);
}

export const Collapsible = CollapsibleInner as ComponentClass<
CollapsibleProps
> &
typeof CollapsibleInner;
Loading