Skip to content

Commit

Permalink
feat: undo/redo, action enhancements (#128)
Browse files Browse the repository at this point in the history
* wip

* undo/redo history

* wip

* stash, clean up removed files

* wip

* cleanup imports

* wip, implicit negate

* cleanup tests

* chore: enableHistory

* chore: invariant, imports

* chore: add toast poc, cleanup components

* chore: cleanup

* wip, getting closer

* cleanup, tests

* fix: type

* shouldRejectKeystrokes util tweaks

* chore: cleanup

* customIgnoredElements -> ignoreWhenFocused

* fix: index check

* fix: cleanup not working

* fix: scrollbar style

* chore: naming

* chore: update readme

* chore: update docs
  • Loading branch information
timc1 authored Nov 25, 2021
1 parent 8bc20e0 commit e9bd63f
Show file tree
Hide file tree
Showing 38 changed files with 993 additions and 549 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
lib
lib
.DS_Store
25 changes: 10 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

kbar is a simple plug-n-play React component to add a fast, portable, and extensible <kbd>command</kbd> + <kbd>k</kbd> interface to your site.

![demo](https://user-images.githubusercontent.com/12195101/134022553-af4a29e9-0a3d-40f1-9254-3bd9673f3401.gif)
![demo](https://user-images.githubusercontent.com/12195101/143491194-1d3ad5d6-24ac-4e6e-8867-65f643ac2d24.gif)

## Background

Expand All @@ -15,10 +15,13 @@ abstraction to add a fast and extensible <kbd>command</kbd> + <kbd>k</kbd> menu

- Built in animations and fully customizable components
- Keyboard navigation support; e.g. <kbd>control</kbd> + <kbd>n</kbd> or <kbd>control</kbd> + <kbd>p</kbd> for the navigation wizards
- Keyboard shortcuts support for registering keystrokes to specific actions; e.g. hit <kbd>t</kbd> for Twitter
- Keyboard shortcuts support for registering keystrokes to specific actions; e.g. hit <kbd>t</kbd>
for Twitter, hit <kbd>?</kbd> to immediate bring up documentation search
- Nested actions enable creation of rich navigation experiences; e.g. hit backspace to navigate to
the previous action
- Performance as a first class priority; tens of thousands of actions? No problem.
- History management; easily add undo and redo to each action
- Built in screen reader support
- A simple data structure which enables anyone to easily build their own custom components

### Usage
Expand Down Expand Up @@ -104,8 +107,9 @@ At this point hitting <kbd>cmd</kbd>+<kbd>k</kbd> will animate in a search input

kbar provides a few utilities to render a performant list of search results.

- `useMatches` at its core returns a list of results based on the current search query, grouped by `action.section`
- `KBarResults` handles virtualizing your results
- `useMatches` at its core returns a flattened list of results and group name based on the current
search query; i.e. `["Section name", Action, Action, "Another section name", Action, Action]`
- `KBarResults` renders a performant virtualized list of these results

Combine the two utilities to create a powerful search interface:

Expand All @@ -124,20 +128,11 @@ import {
// ...

function RenderResults() {
const groups = useMatches();
const flattened = React.useMemo(
() =>
groups.reduce((acc, curr) => {
acc.push(curr.name);
acc.push(...curr.actions);
return acc;
}, []),
[groups]
);
const { results } = useMatches();

return (
<KBarResults
items={flattened.filter((i) => i !== NO_GROUP)}
items={results}
onRender={({ item, active }) =>
typeof item === "string" ? (
<div>{item}</div>
Expand Down
179 changes: 83 additions & 96 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import "./index.scss";
import { Toaster } from "react-hot-toast";
import * as React from "react";
import { Switch, Route, useHistory, Redirect } from "react-router-dom";
import Layout from "./Layout";
import Home from "./Home";
import Docs from "./Docs";
import RegisterDocActions from "./Docs/RegisterDocActions";
import useDocsActions from "./hooks/useDocsActions";
import { useAnalytics } from "./utils";
import Blog from "./Blog";
import { ActionImpl } from "../../src/action";

import {
ActionId,
KBarAnimator,
KBarProvider,
KBarPortal,
useDeepMatches,
KBarPositioner,
KBarSearch,
KBarResults,
createAction,
useMatches,
ActionImpl,
} from "../../src";
import useThemeActions from "./hooks/useThemeActions";

const searchStyle = {
padding: "12px 16px",
Expand Down Expand Up @@ -52,93 +54,58 @@ const groupNameStyle = {
const App = () => {
useAnalytics();
const history = useHistory();
const initialActions = [
{
id: "homeAction",
name: "Home",
shortcut: ["h"],
keywords: "back",
section: "Navigation",
perform: () => history.push("/"),
icon: <HomeIcon />,
subtitle: "Subtitles can help add more context.",
},
{
id: "docsAction",
name: "Docs",
shortcut: ["g", "d"],
keywords: "help",
section: "Navigation",
perform: () => history.push("/docs"),
},
{
id: "contactAction",
name: "Contact",
shortcut: ["c"],
keywords: "email hello",
section: "Navigation",
perform: () => window.open("mailto:[email protected]", "_blank"),
},
{
id: "twitterAction",
name: "Twitter",
shortcut: ["t"],
keywords: "social contact dm",
section: "Navigation",
perform: () => window.open("https://twitter.com/timcchang", "_blank"),
},
createAction({
name: "Github",
shortcut: ["g", "h"],
keywords: "sourcecode",
section: "Navigation",
perform: () => window.open("https://github.com/timc1/kbar", "_blank"),
}),
];

return (
<KBarProvider
options={{
callbacks: {
onOpen: () => console.log("open"),
onClose: () => console.log("close"),
onQueryChange: (query) => console.log("changed", query),
onSelectAction: (action) => console.log("executed", action),
},
enableHistory: true,
}}
actions={[
{
id: "homeAction",
name: "Home",
shortcut: ["h"],
keywords: "back",
section: "Navigation",
perform: () => history.push("/"),
icon: <HomeIcon />,
subtitle: "Subtitles can help add more context.",
},
{
id: "docsAction",
name: "Docs",
shortcut: ["g", "d"],
keywords: "help",
section: "Navigation",
perform: () => history.push("/docs"),
},
{
id: "contactAction",
name: "Contact",
shortcut: ["c"],
keywords: "email hello",
section: "Navigation",
perform: () => window.open("mailto:[email protected]", "_blank"),
},
{
id: "twitterAction",
name: "Twitter",
shortcut: ["t"],
keywords: "social contact dm",
section: "Navigation",
perform: () => window.open("https://twitter.com/timcchang", "_blank"),
},
createAction({
name: "Github",
shortcut: ["g", "h"],
keywords: "sourcecode",
section: "Navigation",
perform: () => window.open("https://github.com/timc1/kbar", "_blank"),
}),
{
id: "theme",
name: "Change theme…",
keywords: "interface color dark light",
section: "Preferences",
},
{
id: "darkTheme",
name: "Dark",
keywords: "dark theme",
section: "",
perform: () =>
document.documentElement.setAttribute("data-theme-dark", ""),
parent: "theme",
},
{
id: "lightTheme",
name: "Light",
keywords: "light theme",
section: "",
perform: () =>
document.documentElement.removeAttribute("data-theme-dark"),
parent: "theme",
},
]}
actions={initialActions}
>
<RegisterDocActions />
<KBarPortal>
<KBarPositioner>
<KBarAnimator style={animatorStyle}>
<KBarSearch style={searchStyle} />
<RenderResults />
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
<CommandBar />
<Layout>
<Switch>
<Route path="/docs" exact>
Expand All @@ -155,12 +122,32 @@ const App = () => {
</Route>
</Switch>
</Layout>
<Toaster
toastOptions={{
position: "bottom-right",
}}
/>
</KBarProvider>
);
};

function CommandBar() {
useDocsActions();
useThemeActions();
return (
<KBarPortal>
<KBarPositioner>
<KBarAnimator style={animatorStyle}>
<KBarSearch style={searchStyle} />
<RenderResults />
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
);
}

function RenderResults() {
const { results, rootActionId } = useDeepMatches();
const { results, rootActionId } = useMatches();

return (
<KBarResults
Expand Down Expand Up @@ -194,16 +181,16 @@ const ResultItem = React.forwardRef(
ref: React.Ref<HTMLDivElement>
) => {
const ancestors = React.useMemo(() => {
return (function collect(action: ActionImpl, ancestors = []) {
if (action.parent && action.parent.id !== currentRootActionId) {
ancestors.push(action.parent);
if (action.parent.parent) {
collect(action.parent.parent, ancestors);
}
}
return ancestors;
})(action);
}, [action, currentRootActionId]);
if (!currentRootActionId) return action.ancestors;
const index = action.ancestors.findIndex(
(ancestor) => ancestor.id === currentRootActionId
);
// +1 removes the currentRootAction; e.g.
// if we are on the "Set theme" parent action,
// the UI should not display "Set theme… > Dark"
// but rather just "Dark"
return action.ancestors.slice(index + 1);
}, [action.ancestors, currentRootActionId]);

return (
<div
Expand Down
4 changes: 2 additions & 2 deletions example/src/Blog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ export default function Blog() {
perform: () => alert(i),
})
),
greatgrandchild,
child,
parent,
child,
grandchild,
greatgrandchild,
]);

React.useEffect(() => {
Expand Down
7 changes: 6 additions & 1 deletion example/src/Code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ interface Props {

export default function Code(props: Props) {
return (
<Highlight {...defaultProps} code={props.code} language="tsx" theme={theme}>
<Highlight
{...defaultProps}
code={props.code.trim()}
language="tsx"
theme={theme}
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={classnames(className, styles.pre)} style={style}>
{tokens.map((line, i) => (
Expand Down
5 changes: 5 additions & 0 deletions example/src/Docs/APIReference.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export default function APIReference() {
Only re renders the component when return value deeply changes. All kbar
components are built using this hook.
</p>
<Heading name="HistoryImpl" />
<p>
An internal history implementation which maintains a simple in memory
list of actions that contain an undoable, negatable action.
</p>
</div>
);
}
Expand Down
47 changes: 21 additions & 26 deletions example/src/Docs/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,35 @@ export default function Actions() {
<h1>Actions</h1>
<p>
When a user searches for something in kbar, the result is a list of
actions. These actions are represented by a simple object data
structure.
<code>ActionImpl</code>s. ActionImpls are a more complex, powerful
representation of the <code>action</code> object literal that the user
defines.
</p>
<p>
The way users register actions is by passing a list of action objects to{" "}
<code>KBarProvider</code> or through <code>useRegisterActions</code>.
The way users register actions is by first passing a list of default
action objects to <code>KBarProvider</code>, and subsequently using{" "}
<code>useRegisterActions</code> to dynamic register actions.
</p>
<p>The object looks like this:</p>
<Code
code={`interface BaseAction {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
perform?: () => void;
section?: string;
parent?: ActionId | null;
}`}
code={`
type Action = {
id: ActionId;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
icon?: string | React.ReactElement | React.ReactNode;
subtitle?: string;
perform?: (currentActionImpl: ActionImpl) => any;
parent?: ActionId;
};`}
/>
<p>
kbar manages an internal state of action objects. What we do is take the
list of actions provided by the user and transform them under the hood
into our own representation of these objects, <code>ActionImpl</code>.
kbar manages an internal state of action objects. We take the list of
actions provided by the user and transform them under the hood into our
own representation of these objects, <code>ActionImpl</code>.
</p>
<p>
An <code>ActionImpl</code> is an internal implementation of the{" "}
<code>Action</code> interface:
</p>
<Code
code={`type Action = Omit<BaseAction, "parent"> &{
parent?: ActionImpl;
children?: ActionImpl;
}`}
/>
<p>
You don't need to know too much of the specifics of{" "}
<code>ActionImpl</code> – we transform what the user passes to us to add
Expand Down
Loading

1 comment on commit e9bd63f

@vercel
Copy link

@vercel vercel bot commented on e9bd63f Nov 25, 2021

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

Please sign in to comment.