-
Notifications
You must be signed in to change notification settings - Fork 350
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
Transition mobile-keypad to use AphroditeCSSTransitionGroup (vendored from webapp) #768
Changes from all commits
d7393d4
4ba3e7b
10ea77f
4ac0147
1238d07
327e5ff
fea93be
970fd3b
7a1f586
c86f75f
dfeabe0
f330f2f
086d1e5
6cc4f6d
ebf299b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@khanacademy/math-input": minor | ||
--- | ||
|
||
Change mobile-keypad to use AphroditeCSSTransitionGroup to animate mounting/unmounting the keypad when active (prevents DOM elements from being in the DOM unless the keypad is actually open). |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
/** | ||
* Aphrodite doesn't play well with CSSTransition from react-transition-group, | ||
* which assumes that you have CSS classes and it can combine them arbitrarily. | ||
* | ||
* There are also some issue with react-transition-group that make it difficult | ||
* to work. Even if the CSS classes are defined ahead of time it makes no | ||
* guarantee that the start style will be applied by the browser before the | ||
* active style is applied. This can cause the first time a transition runs to | ||
* fail. | ||
* | ||
* AphroditeCSSTransitionGroup provides a wrapper around TransitionGroup to | ||
* address these issues. | ||
* | ||
* There are three types of transitions: | ||
* - appear: the time the child is added to the render tree | ||
* - enter: whenever the child is added to the render tree after "appear". If | ||
* no "appear" transition is specified then the "enter" transition will also | ||
* be used for the first time the child is added to the render tree. | ||
* - leave: whenever the child is removed from the render tree | ||
* | ||
* Each transition type has two states: | ||
* - base: e.g. css(enter) | ||
* - active: e.g. css(enter, enterActive) | ||
* | ||
* If "done" styles are not provided, the "active" style will remain on the | ||
* component after the animation has completed. | ||
* | ||
* Usage: TBD | ||
* | ||
* Limitations: | ||
* - This component only supports a single child whereas TransitionGroup supports | ||
* multiple children. | ||
* - We ignore inline styles that are provided as part of AnimationStyles. | ||
* | ||
* TODOs: | ||
* - (FEI-3211): Change the API for AphroditeCSSTransitionGroup so that it makes | ||
* bad states impossible. | ||
*/ | ||
import * as React from "react"; | ||
import {TransitionGroup} from "react-transition-group"; | ||
|
||
import TransitionChild from "./transition-child"; | ||
|
||
import type {AnimationStyles} from "./types"; | ||
|
||
type Props = { | ||
// If a function is provided, that function will be called to retrieve the | ||
// current set of animation styles to be used when animating the children. | ||
transitionStyle: AnimationStyles | (() => AnimationStyles); | ||
transitionAppearTimeout?: number; | ||
transitionEnterTimeout?: number; | ||
transitionLeaveTimeout?: number; | ||
children?: React.ReactNode; | ||
}; | ||
|
||
class AphroditeCSSTransitionGroup extends React.Component<Props> { | ||
render(): React.ReactNode { | ||
const {children} = this.props; | ||
return ( | ||
// `component={null}` prevents wrapping each child with a <div> | ||
// which can muck with certain layouts. | ||
<TransitionGroup component={null}> | ||
{React.Children.map(children, (child) => ( | ||
<TransitionChild | ||
transitionStyles={this.props.transitionStyle} | ||
appearTimeout={this.props.transitionAppearTimeout} | ||
enterTimeout={this.props.transitionEnterTimeout} | ||
leaveTimeout={this.props.transitionLeaveTimeout} | ||
> | ||
{child} | ||
</TransitionChild> | ||
))} | ||
</TransitionGroup> | ||
); | ||
} | ||
} | ||
|
||
export default AphroditeCSSTransitionGroup; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
import {withActionScheduler} from "@khanacademy/wonder-blocks-timing"; | ||
import * as React from "react"; | ||
import ReactDOM from "react-dom"; | ||
|
||
import {processStyleType} from "./util"; | ||
|
||
import type {AnimationStyles} from "./types"; | ||
import type {WithActionSchedulerProps} from "@khanacademy/wonder-blocks-timing"; | ||
|
||
type ChildProps = { | ||
transitionStyles: AnimationStyles | (() => AnimationStyles); | ||
appearTimeout?: number; // default appearTimeout to be the same as enterTimeout | ||
enterTimeout?: number; | ||
leaveTimeout?: number; | ||
children: React.ReactNode; | ||
in?: boolean; // provided by TransitionGroup | ||
} & WithActionSchedulerProps; | ||
|
||
type ChildState = { | ||
// Keeps track of whether we should render our children or not. | ||
status: "mounted" | "unmounted"; | ||
}; | ||
|
||
class TransitionChild extends React.Component<ChildProps, ChildState> { | ||
// Each 2-tuple in the queue represents two classnames: one to remove and | ||
// one to add (in that order). | ||
classNameQueue: Array<[string, string]>; | ||
// We keep track of all of the current applied classes so that we can remove | ||
// them before a new transition starts in the case of the current transition | ||
// being interrupted. | ||
appliedClassNames: Set<string>; | ||
_isMounted = false; | ||
|
||
// The use of getDerivedStateFromProps here is to avoid an extra call to | ||
// setState if the component re-enters. This can happen if TransitionGroup | ||
// sets `in` from `false` to `true`. | ||
// eslint-disable-next-line no-restricted-syntax | ||
static getDerivedStateFromProps( | ||
{in: nextIn}: ChildProps, | ||
prevState: ChildState, | ||
): Partial<ChildState> | null { | ||
if (nextIn && prevState.status === "unmounted") { | ||
return {status: "mounted"}; | ||
} | ||
Check warning on line 44 in packages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx Codecov / codecov/patchpackages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx#L43-L44
|
||
return null; | ||
} | ||
|
||
constructor(props: ChildProps) { | ||
super(props); | ||
|
||
this._isMounted = false; | ||
this.classNameQueue = []; | ||
this.appliedClassNames = new Set(); | ||
|
||
this.state = { | ||
status: "mounted", | ||
}; | ||
} | ||
|
||
componentDidMount() { | ||
this._isMounted = true; | ||
|
||
if (typeof this.props.appearTimeout === "number") { | ||
this.transition("appear", this.props.appearTimeout); | ||
Check warning on line 64 in packages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx Codecov / codecov/patchpackages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx#L64
|
||
} else { | ||
this.transition("enter", this.props.enterTimeout); | ||
} | ||
} | ||
|
||
componentDidUpdate(oldProps: ChildProps, oldState: ChildState) { | ||
if (oldProps.in && !this.props.in) { | ||
this.transition("leave", this.props.leaveTimeout); | ||
} else if (!oldProps.in && this.props.in) { | ||
this.transition("enter", this.props.enterTimeout); | ||
} | ||
Check warning on line 75 in packages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx Codecov / codecov/patchpackages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx#L74-L75
|
||
|
||
if (oldState.status !== "mounted" && this.state.status === "mounted") { | ||
// Remove the node from the DOM | ||
// eslint-disable-next-line react/no-did-update-set-state | ||
this.setState({status: "unmounted"}); | ||
} | ||
Check warning on line 81 in packages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx Codecov / codecov/patchpackages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx#L78-L81
|
||
} | ||
|
||
// NOTE: This will only get called when the parent TransitionGroup becomes | ||
// unmounted. This is because that component clones all of its children and | ||
// keeps them around so that they can be animated when leaving and also so | ||
// that the can be animated when re-rentering if that occurs. | ||
componentWillUnmount() { | ||
this._isMounted = false; | ||
this.props.schedule.clearAll(); | ||
} | ||
|
||
removeAllClasses(node: Element) { | ||
for (const className of this.appliedClassNames) { | ||
this.removeClass(node, className); | ||
} | ||
} | ||
|
||
addClass = (elem: Element, className: string): void => { | ||
if (className) { | ||
elem.classList.add(className); | ||
this.appliedClassNames.add(className); | ||
} | ||
}; | ||
|
||
removeClass = (elem: Element, className: string): void => { | ||
if (className) { | ||
elem.classList.remove(className); | ||
this.appliedClassNames.delete(className); | ||
} | ||
}; | ||
|
||
transition( | ||
animationType: "appear" | "enter" | "leave", | ||
duration?: number | null, | ||
) { | ||
const node = ReactDOM.findDOMNode(this); | ||
|
||
if (!(node instanceof Element)) { | ||
return; | ||
} | ||
Check warning on line 121 in packages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx Codecov / codecov/patchpackages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx#L120-L121
|
||
|
||
// Remove any classes from previous transitions. | ||
this.removeAllClasses(node); | ||
|
||
// A previous transition may still be in progress so clear its timers. | ||
this.props.schedule.clearAll(); | ||
|
||
const transitionStyles = | ||
typeof this.props.transitionStyles === "function" | ||
? this.props.transitionStyles() | ||
Check warning on line 131 in packages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx Codecov / codecov/patchpackages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx#L131
|
||
: this.props.transitionStyles; | ||
|
||
const {className} = processStyleType(transitionStyles[animationType]); | ||
const {className: activeClassName} = processStyleType([ | ||
transitionStyles[animationType], | ||
transitionStyles[animationType + "Active"], | ||
]); | ||
|
||
// Put the node in the starting position. | ||
this.addClass(node, className); | ||
|
||
// Queue the component to show the "active" style. | ||
this.queueClass(className, activeClassName); | ||
|
||
// Unmount the children after the 'leave' transition has completed. | ||
if (animationType === "leave") { | ||
this.props.schedule.timeout(() => { | ||
if (this._isMounted) { | ||
this.setState({status: "unmounted"}); | ||
} | ||
Check warning on line 151 in packages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx Codecov / codecov/patchpackages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx#L149-L151
|
||
}, duration || 0); | ||
} | ||
} | ||
|
||
queueClass(removeClassName: string, addClassName: string) { | ||
this.classNameQueue.push([removeClassName, addClassName]); | ||
this.props.schedule.animationFrame(this.flushClassNameQueue); | ||
} | ||
|
||
flushClassNameQueue = () => { | ||
if (this._isMounted) { | ||
const node = ReactDOM.findDOMNode(this); | ||
if (node instanceof Element) { | ||
this.classNameQueue.forEach( | ||
([removeClassName, addClassName]: [any, any]) => { | ||
// Remove the old class before adding a new class just | ||
// in case the new class is the same as the old one. | ||
this.removeClass(node, removeClassName); | ||
this.addClass(node, addClassName); | ||
}, | ||
); | ||
} | ||
} | ||
|
||
// Remove all items in the Array. | ||
this.classNameQueue.length = 0; | ||
}; | ||
|
||
render(): React.ReactNode { | ||
const {status} = this.state; | ||
|
||
if (status === "unmounted") { | ||
return null; | ||
} | ||
Check warning on line 185 in packages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx Codecov / codecov/patchpackages/math-input/src/components/aphrodite-css-transition-group/transition-child.tsx#L184-L185
|
||
|
||
return this.props.children; | ||
} | ||
} | ||
|
||
export default withActionScheduler(TransitionChild); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import type {StyleType} from "@khanacademy/wonder-blocks-core"; | ||
import type {CSSProperties} from "aphrodite"; | ||
|
||
export type AnimationStyles = { | ||
enter?: StyleType; | ||
enterActive?: StyleType; | ||
leave?: StyleType; | ||
leaveActive?: StyleType; | ||
appear?: StyleType; | ||
appearActive?: StyleType; | ||
}; | ||
|
||
export type InAnimationStyles = { | ||
enter?: CSSProperties; | ||
enterActive?: CSSProperties; | ||
leave?: CSSProperties; | ||
leaveActive?: CSSProperties; | ||
appear?: CSSProperties; | ||
appearActive?: CSSProperties; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
info: WB and other Khan Academy packages that don't live in this repo are always added as Dev and Peer devs. The hosting app will make the final determination of which versions we actually use. This avoids Perseus causing multiple packages of WB to be needed/used in the host app.