-
Notifications
You must be signed in to change notification settings - Fork 2.2k
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
rewrite HotkeysTarget and ContextMenuTarget as HOCs #1914
Changes from all commits
f6d4d06
570caba
9c3f256
69269ef
5111bcc
2283eca
b7814d6
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,13 @@ | ||
/* | ||
* Copyright 2017 Palantir Technologies, Inc. All rights reserved. | ||
* | ||
* Licensed under the terms of the LICENSE file distributed with this project. | ||
*/ | ||
|
||
/** | ||
* Generic interface defining constructor types, such as classes. This is used to type the class | ||
* itself in meta-programming situations such as decorators. | ||
*/ | ||
export interface IConstructor<T> { | ||
new (...args: any[]): T; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,97 +5,100 @@ | |
*/ | ||
|
||
import * as React from "react"; | ||
import { isFunction, safeInvoke } from "../../common/utils"; | ||
|
||
import { IConstructor } from "../../common/constructor"; | ||
import { HOTKEYS_WARN_DECORATOR_NEEDS_REACT_ELEMENT, HOTKEYS_WARN_DECORATOR_NO_METHOD } from "../../common/errors"; | ||
import { getDisplayName, isFunction, safeInvoke } from "../../common/utils"; | ||
import { IHotkeysProps } from "./hotkeys"; | ||
import { HotkeyScope, HotkeysEvents } from "./hotkeysEvents"; | ||
|
||
export interface IHotkeysTarget extends React.Component<any, any>, React.ComponentLifecycle<any, any> { | ||
/** @internal */ | ||
globalHotkeysEvents?: HotkeysEvents; | ||
|
||
/** @internal */ | ||
localHotkeysEvents?: HotkeysEvents; | ||
|
||
render(): React.ReactElement<any> | null | undefined; | ||
/** | ||
* Components decorated with the `HotkeysTarget` decorator must implement | ||
* this method, and it must return a `Hotkeys` React element. | ||
*/ | ||
renderHotkeys(): React.ReactElement<IHotkeysProps>; | ||
} | ||
|
||
export function HotkeysTarget<T extends { prototype: IHotkeysTarget }>(constructor: T) { | ||
const { | ||
componentWillMount, | ||
componentDidMount, | ||
componentWillUnmount, | ||
render, | ||
renderHotkeys, | ||
} = constructor.prototype; | ||
|
||
if (!isFunction(renderHotkeys)) { | ||
throw new Error(`@HotkeysTarget-decorated class must implement \`renderHotkeys\`. ${constructor}`); | ||
export function HotkeysTarget<T extends IConstructor<IHotkeysTarget>>(WrappedComponent: T) { | ||
if (!isFunction(WrappedComponent.prototype.renderHotkeys)) { | ||
console.warn(HOTKEYS_WARN_DECORATOR_NO_METHOD); | ||
} | ||
|
||
// tslint:disable no-invalid-this only-arrow-functions | ||
constructor.prototype.componentWillMount = function() { | ||
this.localHotkeysEvents = new HotkeysEvents(HotkeyScope.LOCAL); | ||
this.globalHotkeysEvents = new HotkeysEvents(HotkeyScope.GLOBAL); | ||
return class HotkeysTargetClass extends WrappedComponent { | ||
public static displayName = `HotkeysTarget(${getDisplayName(WrappedComponent)})`; | ||
|
||
if (componentWillMount != null) { | ||
componentWillMount.call(this); | ||
} | ||
}; | ||
/** @internal */ | ||
public globalHotkeysEvents?: HotkeysEvents; | ||
|
||
constructor.prototype.componentDidMount = function() { | ||
// attach global key event listeners | ||
document.addEventListener("keydown", this.globalHotkeysEvents.handleKeyDown); | ||
document.addEventListener("keyup", this.globalHotkeysEvents.handleKeyUp); | ||
/** @internal */ | ||
public localHotkeysEvents?: HotkeysEvents; | ||
|
||
if (componentDidMount != null) { | ||
componentDidMount.call(this); | ||
public componentWillMount() { | ||
if (super.componentWillMount != null) { | ||
super.componentWillMount(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mmm not a huge fan since you'll have to do There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm sure that's a good reason. maybe leave a code comment about "preserving |
||
} | ||
this.localHotkeysEvents = new HotkeysEvents(HotkeyScope.LOCAL); | ||
this.globalHotkeysEvents = new HotkeysEvents(HotkeyScope.GLOBAL); | ||
} | ||
}; | ||
|
||
constructor.prototype.componentWillUnmount = function() { | ||
// detach global key event listeners | ||
document.removeEventListener("keydown", this.globalHotkeysEvents.handleKeyDown); | ||
document.removeEventListener("keyup", this.globalHotkeysEvents.handleKeyUp); | ||
public componentDidMount() { | ||
if (super.componentDidMount != null) { | ||
super.componentDidMount(); | ||
} | ||
|
||
// attach global key event listeners | ||
document.addEventListener("keydown", this.globalHotkeysEvents.handleKeyDown); | ||
document.addEventListener("keyup", this.globalHotkeysEvents.handleKeyUp); | ||
} | ||
|
||
this.globalHotkeysEvents.clear(); | ||
this.localHotkeysEvents.clear(); | ||
public componentWillUnmount() { | ||
if (super.componentWillUnmount != null) { | ||
super.componentWillUnmount(); | ||
} | ||
document.removeEventListener("keydown", this.globalHotkeysEvents.handleKeyDown); | ||
document.removeEventListener("keyup", this.globalHotkeysEvents.handleKeyUp); | ||
|
||
if (componentWillUnmount != null) { | ||
componentWillUnmount.call(this); | ||
this.globalHotkeysEvents.clear(); | ||
this.localHotkeysEvents.clear(); | ||
} | ||
}; | ||
|
||
constructor.prototype.render = function() { | ||
const element = render.call(this) as JSX.Element; | ||
|
||
const hotkeys = renderHotkeys.call(this); | ||
this.localHotkeysEvents.setHotkeys(hotkeys.props); | ||
this.globalHotkeysEvents.setHotkeys(hotkeys.props); | ||
|
||
// attach local key event listeners | ||
if (element != null && this.localHotkeysEvents.count() > 0) { | ||
const tabIndex = hotkeys.props.tabIndex === undefined ? 0 : hotkeys.props.tabIndex; | ||
|
||
const existingKeyDown = element.props.onKeyDown as React.KeyboardEventHandler<HTMLElement>; | ||
const onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => { | ||
this.localHotkeysEvents.handleKeyDown(e.nativeEvent as KeyboardEvent); | ||
safeInvoke(existingKeyDown, e); | ||
}; | ||
|
||
const existingKeyUp = element.props.onKeyUp as React.KeyboardEventHandler<HTMLElement>; | ||
const onKeyUp = (e: React.KeyboardEvent<HTMLElement>) => { | ||
this.localHotkeysEvents.handleKeyUp(e.nativeEvent as KeyboardEvent); | ||
safeInvoke(existingKeyUp, e); | ||
}; | ||
return React.cloneElement(element, { tabIndex, onKeyDown, onKeyUp }); | ||
} else { | ||
public render() { | ||
const element = super.render() as JSX.Element; | ||
|
||
if (element == null) { | ||
// always return `element` in case caller is distinguishing between `null` and `undefined` | ||
return element; | ||
} | ||
|
||
if (!React.isValidElement<any>(element)) { | ||
console.warn(HOTKEYS_WARN_DECORATOR_NEEDS_REACT_ELEMENT); | ||
return element; | ||
} | ||
|
||
if (isFunction(this.renderHotkeys)) { | ||
const hotkeys = this.renderHotkeys(); | ||
this.localHotkeysEvents.setHotkeys(hotkeys.props); | ||
this.globalHotkeysEvents.setHotkeys(hotkeys.props); | ||
|
||
if (this.localHotkeysEvents.count() > 0) { | ||
const tabIndex = hotkeys.props.tabIndex === undefined ? 0 : hotkeys.props.tabIndex; | ||
|
||
const { keyDown: existingKeyDown, keyUp: existingKeyUp } = element.props; | ||
const onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => { | ||
this.localHotkeysEvents.handleKeyDown(e.nativeEvent as KeyboardEvent); | ||
safeInvoke(existingKeyDown, e); | ||
}; | ||
|
||
const onKeyUp = (e: React.KeyboardEvent<HTMLElement>) => { | ||
this.localHotkeysEvents.handleKeyUp(e.nativeEvent as KeyboardEvent); | ||
safeInvoke(existingKeyUp, e); | ||
}; | ||
return React.cloneElement(element, { tabIndex, onKeyDown, onKeyUp }); | ||
} | ||
} | ||
return element; | ||
} | ||
}; | ||
// tslint:enable | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -47,6 +47,23 @@ describe("Hotkeys", () => { | |
).to.throw(HOTKEYS_HOTKEY_CHILDREN, "undefined"); | ||
}); | ||
|
||
it("Decorator does not mutate the original class", () => { | ||
class TestComponent extends React.Component<{}, {}> { | ||
public render() { | ||
return <div />; | ||
} | ||
|
||
public renderHotkeys() { | ||
return <Hotkeys />; | ||
} | ||
} | ||
|
||
const TargettedTestComponent = HotkeysTarget(TestComponent); | ||
|
||
// it's not the same Component | ||
expect(TargettedTestComponent).to.not.equal(TestComponent); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah |
||
}); | ||
|
||
describe("Local/Global @HotkeysTarget", () => { | ||
let localKeyDownSpy: SinonSpy = null; | ||
let localKeyUpSpy: SinonSpy = null; | ||
|
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.
should we invoke
oldOnContextMenu
in this code path?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.
Not sure about the logic here; @giladgray ?
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.
I'm not sure either. could just change next condition to be
if (isFunction && !prevented)
.