From c1de58a11f99bc296ea10e4c3d2c61728952682a Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Sep 2021 01:36:36 -0400 Subject: [PATCH] feat: rework styles (#35) * feat: rework components to better support styling * fix: cleanup buffer * feat: add bump animation on action transition * chore: cleanup * chore: rename content -> portal * fix: import * chore: update readme * chore: add gh link to demo * chore: lowercase * chore: cleanup * chore: add initial docs * chore: add docs to search * chore: keep default styles --- README.md | 63 ++++++++---- example/dist/index.html | 10 +- example/src/App.tsx | 123 +++++++++++------------ example/src/Docs.tsx | 5 - example/src/Docs/Actions.tsx | 52 ++++++++++ example/src/Docs/Overview.tsx | 19 ++++ example/src/Docs/State.tsx | 63 ++++++++++++ example/src/Docs/data.ts | 51 ++++++++++ example/src/Docs/index.tsx | 71 +++++++++++++ example/src/Docs/styles.module.scss | 68 +++++++++++++ example/src/Home.tsx | 13 ++- example/src/SearchDocsActions.tsx | 57 +++++++++++ package-lock.json | 85 +++++++++++++++- package.json | 1 + src/InternalEvents.tsx | 3 +- src/KBarAnimator.tsx | 147 +++++++++++++++++++++++++++ src/KBarContent.tsx | 149 ---------------------------- src/KBarPortal.tsx | 20 ++++ src/KBarPositioner.tsx | 24 +++++ src/KBarResults.tsx | 9 +- src/index.tsx | 13 ++- src/types.ts | 7 +- src/useKBar.tsx | 4 +- src/useRegisterActions.tsx | 5 +- src/useStore.tsx | 5 +- 25 files changed, 802 insertions(+), 265 deletions(-) delete mode 100644 example/src/Docs.tsx create mode 100644 example/src/Docs/Actions.tsx create mode 100644 example/src/Docs/Overview.tsx create mode 100644 example/src/Docs/State.tsx create mode 100644 example/src/Docs/data.ts create mode 100644 example/src/Docs/index.tsx create mode 100644 example/src/Docs/styles.module.scss create mode 100644 example/src/SearchDocsActions.tsx create mode 100644 src/KBarAnimator.tsx delete mode 100644 src/KBarContent.tsx create mode 100644 src/KBarPortal.tsx create mode 100644 src/KBarPositioner.tsx diff --git a/README.md b/README.md index 3927631..e8e3016 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,28 @@ ## kbar -kbar is a simple plug-n-play React component to add a fast, -portable, and extensible command+k interface to your site. +kbar is a simple plug-n-play React component to add a fast, portable, and extensible command+k interface to your site. -![demo](https://user-images.githubusercontent.com/12195101/132958919-7a525cab-e191-4642-ae9a-5f22a3ba7845.gif) +![demo](https://user-images.githubusercontent.com/12195101/134022553-af4a29e9-0a3d-40f1-9254-3bd9673f3401.gif) -## Background +### Background Command+k interfaces are used to create a web experience where any type of action users would be able to do via clicking can be done through a command menu. -With macOS's Spotlight and Linear's command+k experience in mind, kbar aims to be a simple abstraction to add a fast and extensible command+k menu to your site. +With macOS's Spotlight and Linear's command+k experience in mind, kbar aims to be a simple +abstraction to add a fast and extensible command+k menu to your site. ### Features -- Built in animations, fully customizable +- Built in animations and fully customizable components - Keyboard navigation support; e.g. ctrl n / ctrl p for the navigation wizards - Keyboard shortcuts support for registering keystrokes to specific actions; e.g. hit t for Twitter -- Navigate between nested actions with backspace -- A simple data structure which enables anyone to easily build their custom components +- Nested actions enable creation of rich navigation experiences; e.g. hit backspace to navigate to + the previous action +- A simple data structure which enables anyone to easily build their own custom components -Usage -Have a fully functioning command menu for your site in minutes. Let's start with a basic example. First, install kbar. +### Usage + +Have a fully functioning command menu for your site in minutes. First, install kbar. ``` npm install kbar @@ -41,7 +43,8 @@ return ( kbar is built on top of `actions`. Actions define what to execute when a user selects it. Actions can have children which are just other actions. -Let's create a few static actions. Static actions are actions with no external dependencies; they don't rely on a method from some other hook, for instance. We'll talk about dynamic actions later. +We'll create a few static actions first. Static actions are actions with no external dependencies. Our example below sets the `window.location.pathname`, which does not rely on any +external hook, for instance. ```tsx const actions = [ @@ -68,19 +71,43 @@ return ( ); ``` -kbar exposes a few components which handle animations, keyboard events, etc. You can compose them together like so: +kbar exposes a few components which handle animations, keyboard events, default styles, etc. You can use them together like so: ```tsx import { KBarProvider, KBarContent, KBarSearch } from "kbar"; - - - - + // Renders the content outside the root node + // Centers the content + // Handles the show/hide and height animations + // Search input + // Results renderer + + + ; ``` -Hit cmd+k and you should see a primitive command menu. kbar allows you to have full control over all -aspects of your command menu – refer to the docs to get an understanding of further capabilities. +Hit cmd+k (or ctrl+k) and you should see a primitive command menu. kbar allows you to have full control over all +aspects of your command menu – refer to the docs to get +an understanding of further capabilities. Excited to see what you build. + +### Contributing to kbar + +Contributions are welcome! + +#### New features + +Please [open a new issue](https://github.com/timc1/kbar/issues) so we can discuss prior to moving +forward. + +#### Bug fixes + +Please [open a new Pull Request](https://github.com/timc1/kbar/pulls) for the given bug fix. + +#### Nits and spelling mistakes + +Please [open a new issue](https://github.com/timc1/kbar/issues) for things like spelling mistakes +and README tweaks – we will group the issues together and tackle them as a group. Please do not +create a PR for it! diff --git a/example/dist/index.html b/example/dist/index.html index 1ca789b..fa1d9df 100644 --- a/example/dist/index.html +++ b/example/dist/index.html @@ -8,28 +8,28 @@ href="data:image/svg+xml,"> - KBar – command+k interface for your site - + kbar – command+k interface for your site + - + - +
- + \ No newline at end of file diff --git a/example/src/App.tsx b/example/src/App.tsx index 349bbf1..17f314b 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,15 +1,18 @@ import "./index.scss"; import * as React from "react"; -import { KBarContent } from "../../src/KBarContent"; +import { KBarAnimator } from "../../src/KBarAnimator"; import { KBarProvider } from "../../src/KBarContextProvider"; import KBarResults from "../../src/KBarResults"; +import KBarPortal from "../../src/KBarPortal"; +import KBarPositioner from "../../src/KBarPositioner"; import KBarSearch from "../../src/KBarSearch"; -import { Switch, Route, useHistory } from "react-router-dom"; +import { Switch, Route, useHistory, Redirect } from "react-router-dom"; import Layout from "./Layout"; import Home from "./Home"; import Docs from "./Docs"; +import SearchDocsActions from "./SearchDocsActions"; -const searchStyles = { +const searchStyle = { padding: "12px 16px", fontSize: "16px", width: "100%", @@ -20,19 +23,26 @@ const searchStyles = { color: "var(--foreground)", }; +const resultsStyle = { + maxHeight: 400, + overflow: "auto", +}; + +const animatorStyle = { + maxWidth: "500px", + width: "100%", + background: "var(--background)", + color: "var(--foreground)", + borderRadius: "8px", + overflow: "hidden", + boxShadow: "var(--shadow)", +}; + const App = () => { const history = useHistory(); return ( { { id: "docsAction", name: "Docs", - shortcut: ["d"], + shortcut: ["g", "d"], keywords: "help", section: "Navigation", perform: () => history.push("/docs"), @@ -65,24 +75,6 @@ const App = () => { section: "Navigation", perform: () => window.open("https://twitter.com/timcchang", "_blank"), }, - { - id: "docs1", - name: "Docs 1 (Coming soon)", - shortcut: [], - keywords: "Docs 1", - section: "", - perform: () => window.alert("nav -> Docs 1"), - parent: "searchBlogAction", - }, - { - id: "docs2", - name: "Docs 2 (Coming soon)", - shortcut: [], - keywords: "Docs 2", - section: "", - perform: () => window.alert("nav -> Docs 2"), - parent: "searchBlogAction", - }, { id: "theme", name: "Change theme…", @@ -116,37 +108,35 @@ const App = () => { animations: { enterMs: 200, exitMs: 100, - maxContentHeight: 400, }, }} > - - - ( - - )} - /> - + + + + + + ( + + )} + /> + + + - + + + + - + @@ -162,7 +152,7 @@ function Render({ action, handlers, state }) { React.useEffect(() => { if (active) { - // wait for the KBarContent to resize, _then_ scrollIntoView. + // wait for the KBarAnimator to resize, _then_ scrollIntoView. // https://medium.com/@owencm/one-weird-trick-to-performant-touch-response-animations-with-react-9fe4a0838116 window.requestAnimationFrame(() => window.requestAnimationFrame(() => { @@ -197,15 +187,20 @@ function Render({ action, handlers, state }) { > {action.name} {action.shortcut?.length ? ( - - {action.shortcut} - +
+ {action.shortcut.map((sc) => ( + + {sc} + + ))} +
) : null} ); diff --git a/example/src/Docs.tsx b/example/src/Docs.tsx deleted file mode 100644 index ec23965..0000000 --- a/example/src/Docs.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from "react"; - -export default function Docs() { - return
Docs (Coming soon)
; -} diff --git a/example/src/Docs/Actions.tsx b/example/src/Docs/Actions.tsx new file mode 100644 index 0000000..8fd5f67 --- /dev/null +++ b/example/src/Docs/Actions.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import Code from "../Code"; + +export default function Actions() { + return ( +
+

Actions

+

+ 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. +

+ void; + section?: string; + parent?: ActionId | null | undefined; + children?: ActionId[]; +}`} + /> +

kbar manages an internal state of action objects.

+

+ Actions can have nested actions, represented by children{" "} + above. With this, we can do things like building a folder-like + experience where toggling one action leads to displaying a "nested" list + of other actions. +

+

Static, global actions

+

+ kbar takes an initial list of actions when instantiated. This initial + list is considered a static/global list of actions. These actions exist + on each page of your site. +

+

Dynamic actions

+

+ While it is good to have a set of actions registered up front and + available globally, sometimes you will want to have actions available + only when on a specific page, or even when a specific component is + rendered. +

+

+ Actions can be registered at runtime using the{" "} + useRegisterActions hook. This dynamically adds and removes + actions based on where the hook lives. +

+
+ ); +} diff --git a/example/src/Docs/Overview.tsx b/example/src/Docs/Overview.tsx new file mode 100644 index 0000000..674d34a --- /dev/null +++ b/example/src/Docs/Overview.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +export default function Overview() { + return ( +
+

Overview

+

+ Command+k interfaces are used to create a web experience where any type + of action users would be able to do via clicking can be done through a + command menu. +

+

+ With macOS's Spotlight and Linear's command+k experience in mind, kbar + aims to be a simple abstraction to add a fast and extensible command+k + menu to your site. +

+
+ ); +} diff --git a/example/src/Docs/State.tsx b/example/src/Docs/State.tsx new file mode 100644 index 0000000..c94083d --- /dev/null +++ b/example/src/Docs/State.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import Code from "../Code"; + +export default function State() { + return ( +
+

Interfacing with state

+

+ While it is great that kbar exposes some primitive components; e.g.{" "} + KBarSearch, KBarResults, etc., what if you + wanted to build some custom components, perhaps a set of breadcrumbs + that display the current action and it's ancestor actions? +

+

useKBar

+

+ useKBar enables you to hook into the current state of kbar + and collect the values you need to build your custom UI. +

+ { + let actionAncestors = []; + const collectAncestors = (actionId) => { + const action = state.actions[actionId]; + if (!action.parent) { + return null; + } + actionWithAncestors.unshift(action); + const parent = state.actions[action.parent]; + collectAncestors(parent); + }; + + return { + actionAncestors + } + }) +} + + return ( +
    + {actionWithAncestors.map(action => ( +
  • + // ... +
  • + ))} +
+ );`} + /> +

+ Pass a callback to useKBar and retrieve only what you + collect. This pattern was introduced to me by my friend{" "} + + Prev + + . Reading any value from state enables you to create quite powerful + UIs – in fact, all of kbar's internal components are built using the + same pattern. +

+
+ ); +} diff --git a/example/src/Docs/data.ts b/example/src/Docs/data.ts new file mode 100644 index 0000000..bbbea8e --- /dev/null +++ b/example/src/Docs/data.ts @@ -0,0 +1,51 @@ +import Actions from "./Actions"; +import Overview from "./Overview"; +import State from "./State"; + +const data = { + introduction: { + name: "Introduction", + slug: "/docs", + children: { + overview: { + name: "Overview", + slug: "/docs/overview", + component: Overview, + }, + }, + }, + concepts: { + name: "Concepts", + slug: "/concepts", + children: { + overview: { + name: "Actions", + slug: "/docs/concepts/actions", + component: Actions, + }, + accessingState: { + name: "Interfacing with state", + slug: "/docs/concepts/state", + component: State, + }, + }, + }, + tutorials: { + name: "Tutorials", + slug: "/tutorials", + children: { + basic: { + name: "Basic tutorial", + slug: "/docs/tutorials/basic", + component: null, + }, + custom: { + name: "Custom styles", + slug: "/docs/tutorials/custom-styles", + component: null, + }, + }, + }, +}; + +export default data; diff --git a/example/src/Docs/index.tsx b/example/src/Docs/index.tsx new file mode 100644 index 0000000..6bbd6e6 --- /dev/null +++ b/example/src/Docs/index.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; +import { + Accordion, + AccordionButton, + AccordionPanel, + AccordionItem, +} from "@reach/accordion"; +import styles from "./styles.module.scss"; +import { Link, Switch, useLocation, Route } from "react-router-dom"; +import data from "./data"; +import { classnames } from "../utils"; + +export default function Docs() { + const location = useLocation(); + + const routes = React.useMemo(() => { + function generateRoute(tree) { + return Object.keys(tree).map((key) => { + const item = tree[key]; + if (item.children) { + return generateRoute(item.children); + } + return ; + }); + } + return generateRoute(data); + }, []); + + return ( +
+
+ + {Object.keys(data).map((key) => { + const section = data[key]; + return ( + +

+ {section.name} +

+ {Object.keys(section.children).length > 0 ? ( + +
    + {Object.keys(section.children).map((key) => { + const child = section.children[key]; + return ( +
  • + + {child.name} + +
  • + ); + })} +
+
+ ) : null} +
+ ); + })} +
+
+ {routes} +
+ ); +} diff --git a/example/src/Docs/styles.module.scss b/example/src/Docs/styles.module.scss new file mode 100644 index 0000000..3c637df --- /dev/null +++ b/example/src/Docs/styles.module.scss @@ -0,0 +1,68 @@ +.wrapper { + display: flex; + gap: 24px; + + h1 { + margin-top: 0; + } +} + +.toc { + max-width: 200px; + width: 100%; + flex-grow: 1; + flex-shrink: 0; + + ul { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 8px; + } + + h3 { + margin: 0 0 12px 0; + font-weight: 400; + } + + a { + text-decoration: none; + display: block; + } + + a.active { + font-weight: 600; + } + + a.comingSoon { + opacity: 0.5; + &::after { + content: " 🚧"; + } + } + + [data-reach-accordion-button] { + background: none; + border: none; + text-align: left; + width: 100%; + cursor: pointer; + padding: 0; + color: var(--foreground); + } + + [data-reach-accordion-item] { + margin-bottom: 12px; + } + + [data-reach-accordion-panel] { + padding-left: 12px; + } +} + +@media (max-width: 676px) { + .wrapper { + flex-direction: column; + } +} diff --git a/example/src/Home.tsx b/example/src/Home.tsx index 37a2cbc..1b403ce 100644 --- a/example/src/Home.tsx +++ b/example/src/Home.tsx @@ -1,5 +1,3 @@ -import Highlight, { defaultProps } from "prism-react-renderer"; -import vsLight from "prism-react-renderer/themes/vsLight"; import * as React from "react"; import { Link } from "react-router-dom"; import Code from "./Code"; @@ -8,7 +6,16 @@ export default function Home() { return ( <>

- kbar is a fully extensible command+k interface for your site. Try it out + + + kbar + + {" "} + is a fully extensible command+k interface for your site. Try it out – press cmd and k, or click the logo above.

Background

diff --git a/example/src/SearchDocsActions.tsx b/example/src/SearchDocsActions.tsx new file mode 100644 index 0000000..5c332c6 --- /dev/null +++ b/example/src/SearchDocsActions.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import { useHistory } from "react-router"; +import useRegisterActions from "../../src/useRegisterActions"; +import data from "./Docs/data"; + +const searchId = randomId(); + +export default function SearchDocsActions() { + const history = useHistory(); + + const searchActions = React.useMemo(() => { + let actions = []; + const collectDocs = (tree) => { + Object.keys(tree).forEach((key) => { + const curr = tree[key]; + if (curr.children) { + collectDocs(curr.children); + } + if (curr.component) { + actions.push({ + id: randomId(), + parent: searchId, + name: curr.name, + shortcut: [], + keywords: "", + perform: () => history.push(curr.slug), + }); + } + }); + return actions; + }; + return collectDocs(data); + }, []); + + const rootSearchAction = React.useMemo( + () => + searchActions.length + ? { + id: searchId, + name: "Search docs…", + shortcut: [], + keywords: "find", + section: "", + children: searchActions.map((action) => action.id), + } + : null, + [searchActions] + ); + + useRegisterActions([rootSearchAction, ...searchActions].filter(Boolean)); + + return null; +} + +function randomId() { + return Math.random().toString(36).substring(2, 9); +} diff --git a/package-lock.json b/package-lock.json index 57b5edb..ec2ce40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kbar", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kbar", - "version": "0.1.0-beta.1", + "version": "0.1.0-beta.3", "license": "MIT", "dependencies": { "@reach/portal": "^0.16.0", @@ -14,6 +14,7 @@ "match-sorter": "^6.3.0" }, "devDependencies": { + "@reach/accordion": "^0.16.1", "@types/react": "^17.0.19", "@types/react-dom": "^17.0.9", "@types/react-router-dom": "^5.1.8", @@ -92,6 +93,52 @@ "node": ">= 8" } }, + "node_modules/@reach/accordion": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@reach/accordion/-/accordion-0.16.1.tgz", + "integrity": "sha512-gv0Trq3cfM92h8xZ9RBr5MGyulR6EgfLUKYrf+s9XUJjcIi3sMc611tbwlByigYVSt5gE/TLK0mKHLiXOrT3Sg==", + "dev": true, + "dependencies": { + "@reach/auto-id": "0.16.0", + "@reach/descendants": "0.16.1", + "@reach/utils": "0.16.0", + "prop-types": "^15.7.2", + "tiny-warning": "^1.0.3", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "react": "^16.8.0 || 17.x", + "react-dom": "^16.8.0 || 17.x" + } + }, + "node_modules/@reach/auto-id": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@reach/auto-id/-/auto-id-0.16.0.tgz", + "integrity": "sha512-5ssbeP5bCkM39uVsfQCwBBL+KT8YColdnMN5/Eto6Rj7929ql95R3HZUOkKIvj7mgPtEb60BLQxd1P3o6cjbmg==", + "dev": true, + "dependencies": { + "@reach/utils": "0.16.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "react": "^16.8.0 || 17.x", + "react-dom": "^16.8.0 || 17.x" + } + }, + "node_modules/@reach/descendants": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@reach/descendants/-/descendants-0.16.1.tgz", + "integrity": "sha512-3WZgRnD9O4EORKE31rrduJDiPFNMOjUkATx0zl192ZxMq3qITe4tUj70pS5IbJl/+v9zk78JwyQLvA1pL7XAPA==", + "dev": true, + "dependencies": { + "@reach/utils": "0.16.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "react": "^16.8.0 || 17.x", + "react-dom": "^16.8.0 || 17.x" + } + }, "node_modules/@reach/portal": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@reach/portal/-/portal-0.16.0.tgz", @@ -4666,6 +4713,40 @@ "fastq": "^1.6.0" } }, + "@reach/accordion": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@reach/accordion/-/accordion-0.16.1.tgz", + "integrity": "sha512-gv0Trq3cfM92h8xZ9RBr5MGyulR6EgfLUKYrf+s9XUJjcIi3sMc611tbwlByigYVSt5gE/TLK0mKHLiXOrT3Sg==", + "dev": true, + "requires": { + "@reach/auto-id": "0.16.0", + "@reach/descendants": "0.16.1", + "@reach/utils": "0.16.0", + "prop-types": "^15.7.2", + "tiny-warning": "^1.0.3", + "tslib": "^2.3.0" + } + }, + "@reach/auto-id": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@reach/auto-id/-/auto-id-0.16.0.tgz", + "integrity": "sha512-5ssbeP5bCkM39uVsfQCwBBL+KT8YColdnMN5/Eto6Rj7929ql95R3HZUOkKIvj7mgPtEb60BLQxd1P3o6cjbmg==", + "dev": true, + "requires": { + "@reach/utils": "0.16.0", + "tslib": "^2.3.0" + } + }, + "@reach/descendants": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@reach/descendants/-/descendants-0.16.1.tgz", + "integrity": "sha512-3WZgRnD9O4EORKE31rrduJDiPFNMOjUkATx0zl192ZxMq3qITe4tUj70pS5IbJl/+v9zk78JwyQLvA1pL7XAPA==", + "dev": true, + "requires": { + "@reach/utils": "0.16.0", + "tslib": "^2.3.0" + } + }, "@reach/portal": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@reach/portal/-/portal-0.16.0.tgz", diff --git a/package.json b/package.json index 051a64c..71ed195 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "webpack-dev-server --config ./example/webpack.config.cjs --hot" }, "devDependencies": { + "@reach/accordion": "^0.16.1", "@types/react": "^17.0.19", "@types/react-dom": "^17.0.9", "@types/react-router-dom": "^5.1.8", diff --git a/src/InternalEvents.tsx b/src/InternalEvents.tsx index 88503e3..dec0ae9 100644 --- a/src/InternalEvents.tsx +++ b/src/InternalEvents.tsx @@ -134,7 +134,7 @@ function useShortcuts() { const currentTime = Date.now(); - if (currentTime - lastKeyStrokeTime > 1000) { + if (currentTime - lastKeyStrokeTime > 400) { buffer = []; } @@ -144,6 +144,7 @@ function useShortcuts() { for (let action of actionsList) { if (JSON.stringify(action.shortcut) === JSON.stringify(buffer)) { action.perform?.(); + buffer = []; break; } } diff --git a/src/KBarAnimator.tsx b/src/KBarAnimator.tsx new file mode 100644 index 0000000..4f93b29 --- /dev/null +++ b/src/KBarAnimator.tsx @@ -0,0 +1,147 @@ +import * as React from "react"; +import { useOuterClick } from "./utils"; +import { VisualState } from "./types"; +import useKBar from "./useKBar"; + +interface KBarAnimatorProps { + style?: React.CSSProperties; + className?: string; +} + +const appearanceAnimationKeyframes = [ + { + opacity: 0, + transform: "scale(.95)", + }, + { opacity: 0.75, transform: "scale(1.02)" }, + { opacity: 1, transform: "scale(1)" }, +]; + +const bumpAnimationKeyframes = [ + { + transform: "scale(1)", + }, + { + transform: "scale(.98)", + }, + { + transform: "scale(1)", + }, +]; + +export const KBarAnimator: React.FC = ({ + children, + style, + className, +}) => { + const { visualState, currentRootActionId } = useKBar((state) => ({ + visualState: state.visualState, + currentRootActionId: state.currentRootActionId, + })); + + const outerRef = React.useRef(null); + const innerRef = React.useRef(null); + + const { options, query } = useKBar(); + + const enterMs = options?.animations?.enterMs || 0; + const exitMs = options?.animations?.exitMs || 0; + + // Show/hide animation + React.useEffect(() => { + if (visualState === VisualState.showing) { + return; + } + + const duration = visualState === VisualState.animatingIn ? enterMs : exitMs; + + const element = outerRef.current; + + element?.animate(appearanceAnimationKeyframes, { + duration, + easing: + // TODO: expose easing in options + visualState === VisualState.animatingOut ? "ease-in" : "ease-out", + direction: + visualState === VisualState.animatingOut ? "reverse" : "normal", + fill: "forwards", + }); + }, [options, visualState, enterMs, exitMs]); + + // Height animation + const previousHeight = React.useRef(); + React.useEffect(() => { + // Only animate if we're actually showing + if (visualState === VisualState.showing) { + const outer = outerRef.current; + const inner = innerRef.current; + + if (!outer || !inner) { + return; + } + + const ro = new ResizeObserver((entries) => { + for (let entry of entries) { + const cr = entry.contentRect; + + if (!previousHeight.current) { + previousHeight.current = cr.height; + } + + outer.animate( + [ + { + height: `${previousHeight.current}px`, + }, + { + height: `${cr.height}px`, + }, + ], + { + duration: enterMs / 2, + // TODO: expose configs here + easing: "ease-out", + fill: "forwards", + } + ); + previousHeight.current = cr.height; + } + }); + + ro.observe(inner); + return () => { + ro.unobserve(inner); + }; + } + }, [visualState, options, enterMs, exitMs]); + + // Bump animation between nested actions + const firstRender = React.useRef(true); + React.useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } + const element = outerRef.current; + if (element) { + element.animate(bumpAnimationKeyframes, { + duration: enterMs, + easing: "ease-out", + }); + } + }, [currentRootActionId, enterMs]); + + useOuterClick(outerRef, () => { + query.setVisualState(VisualState.animatingOut); + }); + + return ( +
+
{children}
+
+ ); +}; diff --git a/src/KBarContent.tsx b/src/KBarContent.tsx deleted file mode 100644 index 1c72438..0000000 --- a/src/KBarContent.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import Portal from "@reach/portal"; -import * as React from "react"; -import { useOuterClick } from "./utils"; -import { VisualState } from "./types"; -import useKBar from "./useKBar"; - -interface KBarContentProps { - children: React.ReactNode; - backgroundStyle?: React.CSSProperties; - contentStyle?: React.CSSProperties; -} - -export const KBarContent = (props: KBarContentProps) => { - const { visualState } = useKBar((state) => ({ - visualState: state.visualState, - })); - - if (visualState === VisualState.hidden) { - return null; - } - - return ( - - - - ); -}; - -const animationKeyframes = [ - { - opacity: 0, - transform: "scale(0.95)", - }, - { opacity: 0.75, transform: "scale(1.02)" }, - { opacity: 1, transform: "scale(1)" }, -]; - -const backgroundStyle: React.CSSProperties = { - position: "fixed", - display: "flex", - alignItems: "flex-start", - justifyContent: "center", - width: "100%", - inset: "0px", - padding: "14vh 16px 16px", - boxSizing: "border-box", -}; - -const contentStyle: React.CSSProperties = { - width: "min-content", - ...animationKeyframes[0], -}; - -const Animator: React.FC< - { - visualState: Omit; - } & KBarContentProps -> = (props) => { - const outerRef = React.useRef(null); - const innerRef = React.useRef(null); - - const { options, query } = useKBar(); - - // Show/hide animation - React.useEffect(() => { - if (props.visualState === VisualState.showing) { - return; - } - - const duration = - props.visualState === VisualState.animatingIn - ? options?.animations?.enterMs || 0 - : options?.animations?.exitMs || 0; - - const element = outerRef.current; - - element?.animate(animationKeyframes, { - duration, - easing: - // TODO: expose easing in options - props.visualState === VisualState.animatingOut ? "ease-in" : "ease-out", - direction: - props.visualState === VisualState.animatingOut ? "reverse" : "normal", - fill: "forwards", - }); - }, [options, props.visualState]); - - const previousHeight = React.useRef(); - // Height animation - React.useEffect(() => { - // Only animate if we're actually showing - if (props.visualState === VisualState.showing) { - const outer = outerRef.current; - const inner = innerRef.current; - - if (!outer || !inner) { - return; - } - - const ro = new ResizeObserver((entries) => { - for (let entry of entries) { - const cr = entry.contentRect; - - if (!previousHeight.current) { - previousHeight.current = cr.height; - } - - outer.animate( - [ - { - height: `${previousHeight.current}px`, - }, - { - height: `${cr.height}px`, - }, - ], - { - duration: options?.animations?.enterMs - ? options.animations.enterMs / 2 - : 0, - // TODO: expose configs here - easing: "ease-out", - fill: "forwards", - } - ); - previousHeight.current = cr.height; - } - }); - - ro.observe(inner); - return () => { - ro.unobserve(inner); - }; - } - }, [props.visualState, options]); - - useOuterClick(outerRef, () => { - query.setVisualState(VisualState.animatingOut); - }); - - return ( - // TODO: improve here; no need for spreading -
-
-
{props.children}
-
-
- ); -}; diff --git a/src/KBarPortal.tsx b/src/KBarPortal.tsx new file mode 100644 index 0000000..8eb3af7 --- /dev/null +++ b/src/KBarPortal.tsx @@ -0,0 +1,20 @@ +import Portal from "@reach/portal"; +import * as React from "react"; +import { VisualState } from "./types"; +import useKBar from "./useKBar"; + +interface Props { + children: React.ReactNode; +} + +export default function KBarPortal(props: Props) { + const { showing } = useKBar((state) => ({ + showing: state.visualState !== VisualState.hidden, + })); + + if (!showing) { + return null; + } + + return {props.children}; +} diff --git a/src/KBarPositioner.tsx b/src/KBarPositioner.tsx new file mode 100644 index 0000000..50562bc --- /dev/null +++ b/src/KBarPositioner.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +interface Props { + children: React.ReactNode; + className?: string; +} + +const defaultStyle: React.CSSProperties = { + position: "fixed", + display: "flex", + alignItems: "flex-start", + justifyContent: "center", + width: "100%", + inset: "0px", + padding: "14vh 16px 16px", +}; + +export default function KBarPositioner(props: Props) { + return ( +
+ {props.children} +
+ ); +} diff --git a/src/KBarResults.tsx b/src/KBarResults.tsx index 8388efe..3905051 100644 --- a/src/KBarResults.tsx +++ b/src/KBarResults.tsx @@ -125,12 +125,7 @@ export default function KBarResults(props: KBarResultsProps) { }, [filteredList.length, search]); return ( -
+
{matches.length ? matches.map((action, index) => { const handlers: ResultHandlers = { @@ -179,7 +174,7 @@ const DefaultResultWrapper: React.FC<{ isActive: boolean }> = ({ React.useEffect(() => { if (isActive) { - // wait for the KBarContent to resize, _then_ scrollIntoView. + // wait for the KBarAnimator to resize, _then_ scrollIntoView. // https://medium.com/@owencm/one-weird-trick-to-performant-touch-response-animations-with-react-9fe4a0838116 window.requestAnimationFrame(() => window.requestAnimationFrame(() => { diff --git a/src/index.tsx b/src/index.tsx index a0e9e0b..8c3d759 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,19 @@ +import KBarPortal from "./KBarPortal"; +import KBarPositioner from "./KBarPositioner"; import KBarSearch from "./KBarSearch"; import KBarResults from "./KBarResults"; import useKBar from "./useKBar"; import useRegisterActions from "./useRegisterActions"; -export { KBarSearch, KBarResults, useKBar, useRegisterActions }; +export { + KBarPortal, + KBarPositioner, + KBarSearch, + KBarResults, + useKBar, + useRegisterActions, +}; export * from "./KBarContextProvider"; -export * from "./KBarContent"; +export * from "./KBarAnimator"; export * from "./types"; diff --git a/src/types.ts b/src/types.ts index 76e451a..ccec2c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,10 +14,9 @@ export interface Action { export type ActionTree = Record; export interface KBarOptions { - animations: { + animations?: { enterMs?: number; exitMs?: number; - maxContentHeight?: number; }; } @@ -48,7 +47,7 @@ export interface IKBarContext { collector: (state: KBarState) => C, cb: (collected: C) => void ) => void; - options: any; + options: KBarOptions; } export enum VisualState { @@ -70,6 +69,8 @@ export interface ResultState { } export interface KBarResultsProps { + style?: React.CSSProperties; + className?: string; onRender?: ( action: Action, handlers: ResultHandlers, diff --git a/src/useKBar.tsx b/src/useKBar.tsx index 0090a7a..ff56ef4 100644 --- a/src/useKBar.tsx +++ b/src/useKBar.tsx @@ -1,10 +1,10 @@ import * as React from "react"; import { KBarContext } from "./KBarContextProvider"; -import type { KBarQuery, KBarState } from "./types"; +import type { KBarQuery, KBarState, KBarOptions } from "./types"; interface BaseKBarReturnType { query: KBarQuery; - options: any; + options: KBarOptions; } type useKBarReturnType = S extends null diff --git a/src/useRegisterActions.tsx b/src/useRegisterActions.tsx index 345cdf0..58cc79d 100644 --- a/src/useRegisterActions.tsx +++ b/src/useRegisterActions.tsx @@ -5,12 +5,13 @@ import useKBar from "./useKBar"; export default function useRegisterActions(actions: Action[]) { const { query } = useKBar(); - const actionsRef = React.useRef(actions); + const actionsRef = React.useRef(actions || []); React.useEffect(() => { const actions = actionsRef.current; - if (!actions) { + if (!actions.length) { return; } + const unregister = query.registerActions(actions); return () => { unregister(); diff --git a/src/useStore.tsx b/src/useStore.tsx index 456871a..03cacc9 100644 --- a/src/useStore.tsx +++ b/src/useStore.tsx @@ -6,6 +6,7 @@ import { ActionTree, KBarProviderProps, KBarState, + KBarOptions, VisualState, } from "./types"; @@ -40,7 +41,7 @@ export default function useStore(props: useStoreProps) { publisher.notify(); }, [state]); - const optionsRef = React.useRef(props.options || {}); + const optionsRef = React.useRef((props.options || {}) as KBarOptions); const registerActions = React.useCallback((actions: Action[]) => { const actionsByKey: ActionTree = actions.reduce((acc, curr) => { @@ -51,8 +52,8 @@ export default function useStore(props: useStoreProps) { setState((state) => ({ ...state, actions: { - ...state.actions, ...actionsByKey, + ...state.actions, }, }));