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

Hotkeys v2 with hooks #4532

Merged
merged 10 commits into from
Feb 18, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add HotkeysProvider, HotkeysDialog2, fix examples
  • Loading branch information
adidahiya committed Feb 17, 2021
commit c702efb6501d862d6afb6e9cfa4a01dbce6b6aa7
4 changes: 4 additions & 0 deletions packages/core/src/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export const HOTKEYS_WARN_DECORATOR_NO_METHOD = ns + ` @HotkeysTarget-decorated
export const HOTKEYS_WARN_DECORATOR_NEEDS_REACT_ELEMENT =
ns + ` "@HotkeysTarget-decorated components must return a single JSX.Element or an empty render.`;

export const HOTKEYS_TARGET2_CHILDREN_LOCAL_HOTKEYS =
ns +
` <HotkeysTarget2> was configured with local hotkeys, but you did not use the generated event handlers to bind their event handlers. Try using a render function as the child of this component.`;

export const INPUT_WARN_LEFT_ELEMENT_LEFT_ICON_MUTEX =
ns + ` <InputGroup> leftElement and leftIcon prop are mutually exclusive, with leftElement taking priority.`;

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/hotkeys/hotkey.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class Hotkey extends AbstractPureComponent2<IHotkeyProps> {

protected validateProps(props: IHotkeyProps) {
if (props.global !== true && props.group == null) {
throw new Error("non-global <Hotkey>s must define a group");
console.error("non-global <Hotkey>s must define a group");
}
}
}
2 changes: 1 addition & 1 deletion packages/core/src/components/hotkeys/hotkeys-target2.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default class extends React.PureComponent {

public render() {
return (
<HotkeysTarget2 hotkeys={hotkeys}>
<HotkeysTarget2 hotkeys={this.hotkeys}>
{({ handleKeyDown, handleKeyUp }) => (
<div tabIndex={0} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp}>
Need help?
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/components/hotkeys/hotkeys.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ Deprecated: use [useHotkeys](#core/hooks/useHotkeys)
</h4>

This API is **deprecated since @blueprintjs/core v3.39.0** in favor of the new
`useHotkeys` hook available to React 16.8+ users. You should migrate
to this new API which will become the standard in Blueprint v4.
[`useHotkeys` hook](#core/hooks/useHotkeys) and
[HotkeysTarget2 component](#core/components/hokeys-target2) available to React 16.8+ users.
You should migrate to one of these new APIs, as they will become the standard in Blueprint v4.

</div>

Expand Down
34 changes: 33 additions & 1 deletion packages/core/src/components/hotkeys/hotkeysDialog2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,38 @@
* limitations under the License.
*/

import classNames from "classnames";
import React from "react";

export const HotkeysDialog2: React.FC = () => <div />;
import { Classes } from "../../common";
import { Dialog, IDialogProps } from "../dialog/dialog";
import { IHotkeyProps, Hotkey } from "./hotkey";
import { Hotkeys } from "./hotkeys";

export interface HotkeysDialog2Props extends IDialogProps {
/**
* This string displayed as the group name in the hotkeys dialog for all
* global hotkeys.
*/
globalGroupName?: string;

hotkeys: IHotkeyProps[];
}

export const HotkeysDialog2: React.FC<HotkeysDialog2Props> = ({ globalGroupName = "Global", hotkeys, ...props }) => {
return (
<Dialog {...props} className={classNames(Classes.HOTKEY_DIALOG, props.className)}>
<div className={Classes.DIALOG_BODY}>
<Hotkeys>
{hotkeys.map((hotkey, index) => (
<Hotkey
key={index}
{...hotkey}
group={hotkey.global === true && hotkey.group == null ? globalGroupName : hotkey.group}
/>
))}
</Hotkeys>
</div>
</Dialog>
);
};
1 change: 1 addition & 0 deletions packages/core/src/components/hotkeys/hotkeysTarget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface IHotkeysTargetComponent extends React.Component {
renderHotkeys: () => React.ReactElement<IHotkeysProps>;
}

/** @deprecated use `useHotkeys` hook or `<HotkeysTarget2>` component */
export function HotkeysTarget<T extends IConstructor<IHotkeysTargetComponent>>(WrappedComponent: T) {
if (!isFunction(WrappedComponent.prototype.renderHotkeys)) {
console.warn(HOTKEYS_WARN_DECORATOR_NO_METHOD);
Expand Down
29 changes: 24 additions & 5 deletions packages/core/src/components/hotkeys/hotkeysTarget2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
* limitations under the License.
*/

import React from "react";
import React, { useEffect } from "react";

import * as Errors from "../../common/errors";
import { isNodeEnv } from "../../common/utils";
import { useHotkeys } from "../../hooks/useHotkeys";
import { IHotkeyProps } from "./hotkey";

Expand All @@ -26,8 +28,11 @@ export interface HotkeysTarget2RenderProps {
}

export interface HotkeysTarget2Props {
/** Render prop which receives the same callback handlers generated by the `useHotkeys` hook. */
children: (props: HotkeysTarget2RenderProps) => JSX.Element;
/**
* Render prop which receives the same callback handlers generated by the `useHotkeys` hook.
* If your hotkey definitions are all global, you may supply an element instead.
*/
children: JSX.Element | ((props: HotkeysTarget2RenderProps) => JSX.Element);

/** Hotkey definitions. */
hotkeys: IHotkeyProps[];
Expand All @@ -37,7 +42,21 @@ export interface HotkeysTarget2Props {
* Utility component which allows consumers to use the new `useHotkeys` hook inside
* React component classes. The implementation simply passes through to the hook.
*/
export const HotkeysTarget2: React.FC<HotkeysTarget2Props> = ({ children, hotkeys }) => {
export const HotkeysTarget2 = ({ children, hotkeys }: HotkeysTarget2Props): JSX.Element => {
const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys);
return children({ handleKeyDown, handleKeyUp });

// run props validation
useEffect(() => {
if (!isNodeEnv("production")) {
if (typeof children !== "function" && hotkeys.some(h => !h.global)) {
console.error(Errors.HOTKEYS_TARGET2_CHILDREN_LOCAL_HOTKEYS);
}
}
}, [hotkeys]);

if (typeof children === "function") {
return children({ handleKeyDown, handleKeyUp });
} else {
return children;
}
};
92 changes: 92 additions & 0 deletions packages/core/src/context/hotkeysProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2021 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React, { createContext, useReducer, Dispatch, useCallback } from "react";

import { IHotkeyProps, HotkeysDialog2 } from "../components";
import { HotkeysDialog2Props } from "../components/hotkeys/hotkeysDialog2";

interface HotkeysContextState {
/** List of hotkeys accessible in the current scope, registered by currently mounted components, can be global or local. */
hotkeys: IHotkeyProps[];

/** Whether the global hotkeys dialog is open. */
isDialogOpen: boolean;
}

type HotkeysAction =
| { type: "ADD_HOTKEYS" | "REMOVE_HOTKEYS"; payload: IHotkeyProps[] }
| { type: "CLOSE_DIALOG" | "OPEN_DIALOG" };

const initialHotkeysState: HotkeysContextState = { hotkeys: [], isDialogOpen: false };

export const HotkeysContext = createContext<[HotkeysContextState, Dispatch<HotkeysAction>]>([
initialHotkeysState,
() => null,
]);

const hotkeysReducer = (state: HotkeysContextState, action: HotkeysAction) => {
switch (action.type) {
case "ADD_HOTKEYS":
return {
...state,
hotkeys: [...state.hotkeys, ...action.payload],
};
case "REMOVE_HOTKEYS":
return {
...state,
hotkeys: state.hotkeys.filter(key => action.payload.indexOf(key) === -1),
};
case "OPEN_DIALOG":
return { ...state, isDialogOpen: true };
case "CLOSE_DIALOG":
return { ...state, isDialogOpen: false };
default:
return state;
}
};

export interface HotkeysProviderProps {
/** The component subtree which will have access to this hotkeys context. */
children: React.ReactChild;

/** Optional props to customize the rendered hotkeys dialog. */
dialogProps?: Partial<Omit<HotkeysDialog2Props, "hotkeys">>;

/** If provided, this dialog render function will be used in place of the default implementation. */
renderDialog?: (state: HotkeysContextState, contextActions: { handleDialogClose: () => void }) => JSX.Element;
}

export const HotkeysProvider = ({ children, dialogProps, renderDialog }: HotkeysProviderProps) => {
const [state, dispatch] = useReducer(hotkeysReducer, initialHotkeysState);
const handleDialogClose = useCallback(() => dispatch({ type: "CLOSE_DIALOG" }), []);

const dialog = renderDialog?.(state, { handleDialogClose }) ?? (
<HotkeysDialog2
{...dialogProps}
isOpen={state.isDialogOpen}
hotkeys={state.hotkeys}
onClose={handleDialogClose}
/>
);

return (
<HotkeysContext.Provider value={[state, dispatch]}>
{children}
{dialog}
</HotkeysContext.Provider>
);
};
17 changes: 17 additions & 0 deletions packages/core/src/context/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export { HotkeysProvider, HotkeysProviderProps } from "./hotkeysProvider";
4 changes: 2 additions & 2 deletions packages/core/src/hooks/useHotkeys.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ Migrating from [HotkeysTarget](#core/components/hotkeys)?
</h4>

`useHotkeys` is a replacement for HotkeysTarget. You are encouraged to use this new API in your function
components, or the HotkeysTarget2 component in your component classes, as they will be come the standard
APIs in Blueprint v4. See the full
components, or the [HotkeysTarget2 component](#core/components/hotkeys-target2) in your component classes,
as they will be come the standard APIs in Blueprint v4. See the full
[migration guide](https://github.com/palantir/blueprint/wiki/useHotkeys-migration) on the wiki.

</div>
Expand Down
44 changes: 32 additions & 12 deletions packages/core/src/hooks/useHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,22 @@
* limitations under the License.
*/

import React, { useCallback, useEffect, useMemo } from "react";
import React, { useCallback, useContext, useEffect, useMemo } from "react";

import { IHotkeyProps } from "../components/hotkeys/hotkey";
import { comboMatches, getKeyCombo, IKeyCombo, parseKeyCombo } from "../components/hotkeys/hotkeyParser";
import { HotkeysEvents, HotkeyScope } from "../components/hotkeys/hotkeysEvents";

export function useHotkeys(keys: IHotkeyProps[]) {
const localHotkeysEvents = useMemo(() => new HotkeysEvents(HotkeyScope.LOCAL), []);
const globalHotkeysEvents = useMemo(() => new HotkeysEvents(HotkeyScope.GLOBAL), []);
import { HotkeysContext } from "../context/hotkeysProvider";

export interface UseHotkeysOptions {
/**
* The key combo which will trigger the hotkeys dialog to open.
*
* @default "?"
*/
showDialogKeyCombo?: string;
}

export function useHotkeys(keys: IHotkeyProps[], { showDialogKeyCombo = "?" }: UseHotkeysOptions = {}) {
const localKeys = useMemo(
() =>
keys
Expand All @@ -32,7 +38,7 @@ export function useHotkeys(keys: IHotkeyProps[]) {
combo: parseKeyCombo(k.combo),
props: k,
})),
keys,
[keys],
);
const globalKeys = useMemo(
() =>
Expand All @@ -42,9 +48,17 @@ export function useHotkeys(keys: IHotkeyProps[]) {
combo: parseKeyCombo(k.combo),
props: k,
})),
keys,
[keys],
);

// register keys with global context
const [, dispatch] = useContext(HotkeysContext);
useEffect(() => {
const payload = [...globalKeys.map(k => k.props), ...localKeys.map(k => k.props)];
dispatch({ type: "ADD_HOTKEYS", payload });
return () => dispatch({ type: "REMOVE_HOTKEYS", payload });
}, [keys]);

const invokeNamedCallbackIfComboRecognized = (
global: boolean,
combo: IKeyCombo,
Expand All @@ -69,7 +83,16 @@ export function useHotkeys(keys: IHotkeyProps[]) {
};

const handleGlobalKeyDown = useCallback(
(e: KeyboardEvent) => invokeNamedCallbackIfComboRecognized(true, getKeyCombo(e), "onKeyDown", e),
(e: KeyboardEvent) => {
// special case for global keydown: if '?' is pressed, open the hotkeys dialog
const combo = getKeyCombo(e);
const isTextInput = isTargetATextInput(e);
if (!isTextInput && comboMatches(parseKeyCombo(showDialogKeyCombo), combo)) {
dispatch({ type: "OPEN_DIALOG" });
} else {
invokeNamedCallbackIfComboRecognized(true, getKeyCombo(e), "onKeyDown", e);
}
},
[globalKeys],
);
const handleGlobalKeyUp = useCallback(
Expand All @@ -94,9 +117,6 @@ export function useHotkeys(keys: IHotkeyProps[]) {
return () => {
document.removeEventListener("keydown", handleGlobalKeyDown);
document.removeEventListener("keyup", handleGlobalKeyUp);

globalHotkeysEvents.clear();
localHotkeysEvents.clear();
};
}, []);

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@
export * from "./accessibility";
export * from "./common";
export * from "./components";
export * from "./context";
export * from "./hooks";
26 changes: 14 additions & 12 deletions packages/docs-app/src/components/blueprintDocs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { IHeadingNode, IPageData, isPageNode, ITsDocBase } from "@documentalist/
import classNames from "classnames";
import * as React from "react";

import { AnchorButton, Classes, setHotkeysDialogProps, Tag } from "@blueprintjs/core";
import { AnchorButton, Classes, HotkeysProvider, setHotkeysDialogProps, Tag } from "@blueprintjs/core";
import { IDocsCompleteData } from "@blueprintjs/docs-data";
import { Documentation, IDocumentationProps, INavMenuItemProps, NavMenuItem } from "@blueprintjs/docs-theme";

Expand Down Expand Up @@ -80,17 +80,19 @@ export class BlueprintDocs extends React.Component<IBlueprintDocsProps, { themeN
/>
);
return (
<Documentation
{...this.props}
className={this.state.themeName}
footer={footer}
header={header}
navigatorExclude={isNavSection}
onComponentUpdate={this.handleComponentUpdate}
renderNavMenuItem={this.renderNavMenuItem}
renderPageActions={this.renderPageActions}
renderViewSourceLinkText={this.renderViewSourceLinkText}
/>
<HotkeysProvider>
<Documentation
{...this.props}
className={this.state.themeName}
footer={footer}
header={header}
navigatorExclude={isNavSection}
onComponentUpdate={this.handleComponentUpdate}
renderNavMenuItem={this.renderNavMenuItem}
renderPageActions={this.renderPageActions}
renderViewSourceLinkText={this.renderViewSourceLinkText}
/>
</HotkeysProvider>
);
}

Expand Down
Loading