├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── stale.yml └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── example ├── dist │ ├── index.html │ └── index.js ├── src │ ├── App.tsx │ ├── Blog.tsx │ ├── Code.module.scss │ ├── Code.tsx │ ├── Docs │ │ ├── APIReference.tsx │ │ ├── Actions.tsx │ │ ├── GettingStarted.tsx │ │ ├── Overview.tsx │ │ ├── Priority.tsx │ │ ├── Shortcuts.tsx │ │ ├── State.tsx │ │ ├── UndoRedo.tsx │ │ ├── data.ts │ │ ├── index.tsx │ │ └── styles.module.scss │ ├── Home.tsx │ ├── Layout.module.scss │ ├── Layout.tsx │ ├── Logo.tsx │ ├── fonts │ │ ├── Inter-Bold.woff2 │ │ └── Inter-Regular.woff2 │ ├── hooks │ │ ├── useDocsActions.tsx │ │ └── useThemeActions.tsx │ ├── index.scss │ ├── index.tsx │ └── utils.tsx ├── webpack.config.cjs └── webpack.prod.cjs ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── InternalEvents.tsx ├── KBarAnimator.tsx ├── KBarContextProvider.tsx ├── KBarPortal.tsx ├── KBarPositioner.tsx ├── KBarResults.tsx ├── KBarSearch.tsx ├── __tests__ │ ├── ActionImpl.test.ts │ ├── ActionInterface.test.ts │ ├── Command.test.ts │ └── useMatches.test.tsx ├── action │ ├── ActionImpl.ts │ ├── ActionInterface.ts │ ├── Command.ts │ ├── HistoryImpl.ts │ └── index.tsx ├── index.tsx ├── tinykeys.ts ├── types.ts ├── useKBar.tsx ├── useMatches.tsx ├── useRegisterActions.tsx ├── useStore.tsx └── utils.ts ├── tsconfig.json └── vercel.json /.eslintignore: -------------------------------------------------------------------------------- 1 | *.woff2 2 | *.scss -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "react-app", 9 | "prettier" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "jsx": true 15 | }, 16 | "ecmaVersion": 2018, 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "react", 21 | "@typescript-eslint", 22 | "react-hooks", 23 | "prettier" 24 | ], 25 | "rules": { 26 | "react-hooks/rules-of-hooks": 2, 27 | "react-hooks/exhaustive-deps": 2 28 | } 29 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [timc1] 4 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 120 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | Hey! This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: npm install 9 | run: npx npm@7 install 10 | - name: test 11 | run: npm run test 12 | - name: eslint 13 | run: npm run lint 14 | - name: tsc 15 | run: npm run typecheck 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kbar 2 | 3 | kbar is a simple plug-n-play React component to add a fast, portable, and extensible command + k (command palette) interface to your site. 4 | 5 | ![demo](https://user-images.githubusercontent.com/12195101/143491194-1d3ad5d6-24ac-4e6e-8867-65f643ac2d24.gif) 6 | 7 | ## Background 8 | 9 | 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. 10 | 11 | With macOS's Spotlight and Linear's command + k experience in mind, kbar aims to be a simple 12 | abstraction to add a fast and extensible command + k menu to your site. 13 | 14 | ## Features 15 | 16 | - Built in animations and fully customizable components 17 | - Keyboard navigation support; e.g. control + n or control + p for the navigation wizards 18 | - Keyboard shortcuts support for registering keystrokes to specific actions; e.g. hit t 19 | for Twitter, hit ? to immediate bring up documentation search 20 | - Nested actions enable creation of rich navigation experiences; e.g. hit backspace to navigate to 21 | the previous action 22 | - Performance as a first class priority; tens of thousands of actions? No problem. 23 | - History management; easily add undo and redo to each action 24 | - Built in screen reader support 25 | - A simple data structure which enables anyone to easily build their own custom components 26 | 27 | ### Usage 28 | 29 | Have a fully functioning command menu for your site in minutes. First, install kbar. 30 | 31 | ``` 32 | npm install kbar 33 | ``` 34 | 35 | There is a single provider which you will wrap your app around; you do not have to wrap your 36 | _entire_ app; however, there are no performance implications by doing so. 37 | 38 | ```tsx 39 | // app.tsx 40 | import { KBarProvider } from "kbar"; 41 | 42 | function MyApp() { 43 | return ( 44 | 45 | // ... 46 | 47 | ); 48 | } 49 | ``` 50 | 51 | Let's add a few default actions. Actions are the core of kbar – an action define what to execute 52 | when a user selects it. 53 | 54 | ```tsx 55 | const actions = [ 56 | { 57 | id: "blog", 58 | name: "Blog", 59 | shortcut: ["b"], 60 | keywords: "writing words", 61 | perform: () => (window.location.pathname = "blog"), 62 | }, 63 | { 64 | id: "contact", 65 | name: "Contact", 66 | shortcut: ["c"], 67 | keywords: "email", 68 | perform: () => (window.location.pathname = "contact"), 69 | }, 70 | ] 71 | 72 | return ( 73 | 74 | // ... 75 | 76 | ); 77 | } 78 | ``` 79 | 80 | Next, we will pull in the provided UI components from kbar: 81 | 82 | ```tsx 83 | // app.tsx 84 | import { 85 | KBarProvider, 86 | KBarPortal, 87 | KBarPositioner, 88 | KBarAnimator, 89 | KBarSearch, 90 | useMatches, 91 | NO_GROUP 92 | } from "kbar"; 93 | 94 | // ... 95 | return ( 96 | 97 | // Renders the content outside the root node 98 | // Centers the content 99 | // Handles the show/hide and height animations 100 | // Search input 101 | 102 | 103 | 104 | 105 | ; 106 | ); 107 | } 108 | ``` 109 | 110 | At this point hitting cmd+k (macOS) or ctrl+k (Linux/Windows) will animate in a search input and nothing more. 111 | 112 | kbar provides a few utilities to render a performant list of search results. 113 | 114 | - `useMatches` at its core returns a flattened list of results and group name based on the current 115 | search query; i.e. `["Section name", Action, Action, "Another section name", Action, Action]` 116 | - `KBarResults` renders a performant virtualized list of these results 117 | 118 | Combine the two utilities to create a powerful search interface: 119 | 120 | ```tsx 121 | import { 122 | // ... 123 | KBarResults, 124 | useMatches, 125 | NO_GROUP, 126 | } from "kbar"; 127 | 128 | // ... 129 | // 130 | // 131 | ; 132 | // ... 133 | 134 | function RenderResults() { 135 | const { results } = useMatches(); 136 | 137 | return ( 138 | 141 | typeof item === "string" ? ( 142 |
{item}
143 | ) : ( 144 |
149 | {item.name} 150 |
151 | ) 152 | } 153 | /> 154 | ); 155 | } 156 | ``` 157 | 158 | Hit cmd+k (macOS) or ctrl+k (Linux/Windows) and you should see a primitive command menu. kbar allows you to have full control over all 159 | aspects of your command menu – refer to the docs to get 160 | an understanding of further capabilities. Looking forward to see what you build. 161 | 162 | ## Used by 163 | 164 | Listed are some of the various usages of kbar in the wild – check them out! Create a PR to add your 165 | site below. 166 | 167 | - [Outline](https://www.getoutline.com/) 168 | - [zenorocha.com](https://zenorocha.com/) 169 | - [griko.id](https://griko.id/) 170 | - [lavya.me](https://www.lavya.me/) 171 | - [OlivierAlexander.com](https://olivier-alexander-com-git-master-olivierdijkstra.vercel.app/) 172 | - [dhritigabani.me](https://dhritigabani.me/) 173 | - [jpedromagalhaes](https://jpedromagalhaes.vercel.app/) 174 | - [animo](https://demo.animo.id/) 175 | - [tobyb.xyz](https://www.tobyb.xyz/) 176 | - [higoralves.dev](https://www.higoralves.dev/) 177 | - [coderdiaz.dev](https://coderdiaz.dev/) 178 | - [NextUI](https://nextui.org/) 179 | - [evm.codes](https://www.evm.codes/) 180 | - [filiphalas.com](https://filiphalas.com/) 181 | - [benslv.dev](https://benslv.dev/) 182 | - [vortex](https://hydralite.io/vortex) 183 | - [ladislavprix](https://ladislavprix.cz/) 184 | - [pixiebrix](https://www.pixiebrix.com/) 185 | - [nfaustino.com](https://nfaustino-com.vercel.app/) 186 | - [bradleyyeo.com](https://bradleyyeo-com.vercel.app/) 187 | - [andredevries.dev](https://www.andredevries.dev/) 188 | - [about-ebon](https://about-ebon.vercel.app/) 189 | - [frankrocha.dev](https://www.frankrocha.dev/) 190 | - [cameronbrill.me](https://www.cameronbrill.me/) 191 | - [codaxx.ml](https://codaxx.ml/) 192 | - [jeremytenjo.com](https://jeremytenjo.com/) 193 | - [villivald.com](https://villivald.com/) 194 | - [maxthestranger](https://code.maxthestranger.com/) 195 | - [koripallopaikat](https://koripallopaikat.com/) 196 | - [alexcarpenter.me](https://alexcarpenter.me/) 197 | - [hackbar](https://github.com/Uier/hackbar) 198 | - [web3kbar](https://web3kbar.vercel.app/) 199 | - [burakgur](https://burakgur-com.vercel.app/) 200 | - [ademilter.com](https://ademilter.com/) 201 | - [anasaraid.me](https://anasaraid.me/) 202 | - [daniloleal.co](https://daniloleal.co/) 203 | - [hyperround](https://github.com/heyAyushh/hyperound) 204 | - [Omnivore](https://omnivore.app) 205 | - [tiagohermano.dev](https://tiagohermano.dev/) 206 | - [tryapis.com](https://tryapis.com/) 207 | - [fillout.com](https://fillout.com/) 208 | - [vinniciusgomes.dev](https://vinniciusgomes.dev/) 209 | 210 | ## Contributing to kbar 211 | 212 | Contributions are welcome! 213 | 214 | ### New features 215 | 216 | Please [open a new issue](https://github.com/timc1/kbar/issues) so we can discuss prior to moving 217 | forward. 218 | 219 | ### Bug fixes 220 | 221 | Please [open a new Pull Request](https://github.com/timc1/kbar/pulls) for the given bug fix. 222 | 223 | ### Nits and spelling mistakes 224 | 225 | Please [open a new issue](https://github.com/timc1/kbar/issues) for things like spelling mistakes 226 | and README tweaks – we will group the issues together and tackle them as a group. Please do not 227 | create a PR for it! 228 | -------------------------------------------------------------------------------- /example/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | kbar – command+k interface for your site 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./index.scss"; 2 | import { Toaster } from "react-hot-toast"; 3 | import * as React from "react"; 4 | import { Switch, Route, useHistory, Redirect } from "react-router-dom"; 5 | import Layout from "./Layout"; 6 | import Home from "./Home"; 7 | import Docs from "./Docs"; 8 | import useDocsActions from "./hooks/useDocsActions"; 9 | import { useAnalytics } from "./utils"; 10 | import Blog from "./Blog"; 11 | 12 | import { 13 | ActionId, 14 | KBarAnimator, 15 | KBarProvider, 16 | KBarPortal, 17 | KBarPositioner, 18 | KBarSearch, 19 | KBarResults, 20 | createAction, 21 | useMatches, 22 | ActionImpl, 23 | useKBar, 24 | } from "../../src"; 25 | import useThemeActions from "./hooks/useThemeActions"; 26 | 27 | const searchStyle = { 28 | padding: "12px 16px", 29 | fontSize: "16px", 30 | width: "100%", 31 | boxSizing: "border-box" as React.CSSProperties["boxSizing"], 32 | outline: "none", 33 | border: "none", 34 | background: "var(--background)", 35 | color: "var(--foreground)", 36 | }; 37 | 38 | const animatorStyle = { 39 | maxWidth: "600px", 40 | width: "100%", 41 | background: "var(--background)", 42 | color: "var(--foreground)", 43 | borderRadius: "8px", 44 | overflow: "hidden", 45 | boxShadow: "var(--shadow)", 46 | }; 47 | 48 | const groupNameStyle = { 49 | padding: "8px 16px", 50 | fontSize: "10px", 51 | textTransform: "uppercase" as const, 52 | opacity: 0.5, 53 | }; 54 | 55 | const App = () => { 56 | useAnalytics(); 57 | const history = useHistory(); 58 | const initialActions = [ 59 | { 60 | id: "homeAction", 61 | name: "Home", 62 | shortcut: ["h"], 63 | keywords: "back", 64 | section: "Navigation", 65 | perform: () => history.push("/"), 66 | icon: , 67 | subtitle: "Subtitles can help add more context.", 68 | }, 69 | { 70 | id: "docsAction", 71 | name: "Docs", 72 | shortcut: ["g", "d"], 73 | keywords: "help", 74 | section: "Navigation", 75 | perform: () => history.push("/docs"), 76 | }, 77 | { 78 | id: "contactAction", 79 | name: "Contact", 80 | shortcut: ["c"], 81 | keywords: "email hello", 82 | section: "Navigation", 83 | perform: () => window.open("mailto:timchang@hey.com", "_blank"), 84 | }, 85 | { 86 | id: "twitterAction", 87 | name: "Twitter", 88 | shortcut: ["g", "t"], 89 | keywords: "social contact dm", 90 | section: "Navigation", 91 | perform: () => window.open("https://twitter.com/timcchang", "_blank"), 92 | }, 93 | createAction({ 94 | name: "Github", 95 | shortcut: ["g", "h"], 96 | keywords: "sourcecode", 97 | section: "Navigation", 98 | perform: () => window.open("https://github.com/timc1/kbar", "_blank"), 99 | }), 100 | ]; 101 | 102 | return ( 103 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 131 | 132 | ); 133 | }; 134 | 135 | function CommandBar() { 136 | useDocsActions(); 137 | useThemeActions(); 138 | return ( 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | ); 148 | } 149 | 150 | function RenderResults() { 151 | const { results, rootActionId } = useMatches(); 152 | 153 | return ( 154 | 157 | typeof item === "string" ? ( 158 |
{item}
159 | ) : ( 160 | 165 | ) 166 | } 167 | /> 168 | ); 169 | } 170 | 171 | const ResultItem = React.forwardRef( 172 | ( 173 | { 174 | action, 175 | active, 176 | currentRootActionId, 177 | }: { 178 | action: ActionImpl; 179 | active: boolean; 180 | currentRootActionId: ActionId; 181 | }, 182 | ref: React.Ref 183 | ) => { 184 | const ancestors = React.useMemo(() => { 185 | if (!currentRootActionId) return action.ancestors; 186 | const index = action.ancestors.findIndex( 187 | (ancestor) => ancestor.id === currentRootActionId 188 | ); 189 | // +1 removes the currentRootAction; e.g. 190 | // if we are on the "Set theme" parent action, 191 | // the UI should not display "Set theme… > Dark" 192 | // but rather just "Dark" 193 | return action.ancestors.slice(index + 1); 194 | }, [action.ancestors, currentRootActionId]); 195 | 196 | return ( 197 |
211 |
219 | {action.icon && action.icon} 220 |
221 |
222 | {ancestors.length > 0 && 223 | ancestors.map((ancestor) => ( 224 | 225 | 231 | {ancestor.name} 232 | 233 | 238 | › 239 | 240 | 241 | ))} 242 | {action.name} 243 |
244 | {action.subtitle && ( 245 | {action.subtitle} 246 | )} 247 |
248 |
249 | {action.shortcut?.length ? ( 250 |
254 | {action.shortcut.map((sc) => ( 255 | 264 | {sc} 265 | 266 | ))} 267 |
268 | ) : null} 269 |
270 | ); 271 | } 272 | ); 273 | 274 | export default App; 275 | 276 | function HomeIcon() { 277 | return ( 278 | 279 | 283 | 284 | ); 285 | } 286 | -------------------------------------------------------------------------------- /example/src/Blog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createAction, useRegisterActions } from "../../src"; 3 | 4 | const parent = createAction({ 5 | name: "parent", 6 | }); 7 | 8 | const child = createAction({ 9 | parent: parent.id, 10 | name: "child", 11 | }); 12 | 13 | const grandchild = createAction({ 14 | parent: child.id, 15 | name: "grandchild", 16 | }); 17 | 18 | const greatgrandchild = createAction({ 19 | parent: grandchild.id, 20 | name: "greatgrandchild", 21 | }); 22 | 23 | export default function Blog() { 24 | const [actions, setActions] = React.useState([ 25 | ...Array.from(Array(100000)).map((_, i) => 26 | createAction({ 27 | name: i.toString(), 28 | shortcut: [], 29 | keywords: "", 30 | perform: () => alert(i), 31 | }) 32 | ), 33 | parent, 34 | child, 35 | grandchild, 36 | greatgrandchild, 37 | ]); 38 | 39 | React.useEffect(() => { 40 | setTimeout(() => { 41 | setActions((actions) => [ 42 | ...actions, 43 | createAction({ 44 | name: "Surprise", 45 | shortcut: [], 46 | keywords: "", 47 | perform: () => alert("Surprise"), 48 | }), 49 | createAction({ 50 | name: "Surprise 2", 51 | shortcut: [], 52 | keywords: "", 53 | perform: () => alert("Surprise 2"), 54 | }), 55 | createAction({ 56 | name: "Surprise 3", 57 | shortcut: [], 58 | keywords: "", 59 | perform: () => alert("Surprise 3"), 60 | }), 61 | ]); 62 | }, 2000); 63 | }, []); 64 | 65 | useRegisterActions(actions, [actions]); 66 | 67 | return
Blog
; 68 | } 69 | -------------------------------------------------------------------------------- /example/src/Code.module.scss: -------------------------------------------------------------------------------- 1 | .pre { 2 | padding: calc(var(--unit) * 2); 3 | background: var(--a1); 4 | border-radius: var(--unit); 5 | overflow: auto; 6 | font-size: 15px; 7 | } 8 | -------------------------------------------------------------------------------- /example/src/Code.tsx: -------------------------------------------------------------------------------- 1 | import Highlight, { defaultProps } from "prism-react-renderer"; 2 | import * as React from "react"; 3 | import { classnames } from "./utils"; 4 | import styles from "./Code.module.scss"; 5 | 6 | interface Props { 7 | code: string; 8 | } 9 | 10 | export default function Code(props: Props) { 11 | return ( 12 | 18 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 19 |
 20 |           {tokens.map((line, i) => (
 21 |             
22 | {line.map((token, key) => ( 23 | 24 | ))} 25 |
26 | ))} 27 |
28 | )} 29 |
30 | ); 31 | } 32 | 33 | const theme = { 34 | plain: { 35 | color: "var(--foreground)", 36 | }, 37 | styles: [ 38 | { 39 | types: ["comment"], 40 | style: { 41 | color: "var(--foreground)", 42 | }, 43 | }, 44 | { 45 | types: ["builtin"], 46 | style: { 47 | color: "var(--foreground)", 48 | }, 49 | }, 50 | { 51 | types: ["number", "variable", "inserted"], 52 | style: { 53 | color: "var(--foreground)", 54 | }, 55 | }, 56 | { 57 | types: ["operator"], 58 | style: { 59 | color: "var(--foreground)", 60 | }, 61 | }, 62 | { 63 | types: ["constant", "char"], 64 | style: { 65 | color: "var(--foreground)", 66 | }, 67 | }, 68 | { 69 | types: ["tag"], 70 | style: { 71 | color: "var(--foreground)", 72 | }, 73 | }, 74 | { 75 | types: ["attr-name"], 76 | style: { 77 | color: "var(--foreground)", 78 | }, 79 | }, 80 | { 81 | types: ["deleted", "string"], 82 | style: { 83 | color: "var(--foreground)", 84 | }, 85 | }, 86 | { 87 | types: ["changed", "punctuation"], 88 | style: { 89 | color: "var(--foreground)", 90 | }, 91 | }, 92 | { 93 | types: ["function", "keyword"], 94 | style: { 95 | color: "var(--foreground)", 96 | }, 97 | }, 98 | { 99 | types: ["class-name"], 100 | style: { 101 | color: "var(--foreground)", 102 | }, 103 | }, 104 | ], 105 | }; 106 | -------------------------------------------------------------------------------- /example/src/Docs/APIReference.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useLocation } from "react-router-dom"; 3 | import Code from "../Code"; 4 | import { useKBar } from "../../../src/useKBar"; 5 | 6 | export default function APIReference() { 7 | const { disabled, query } = useKBar((state) => ({ 8 | disabled: state.disabled, 9 | })); 10 | 11 | return ( 12 |
13 |

API Reference

14 | 15 |

16 | Internal state management for all of kbar. It is built on top of a small 17 | publish/subscribe model in order to give us the ability to only re 18 | render components that hook into kbar state through the use of the 19 | public facing useKBar hook. Components which do not hook 20 | into the internal kbar state will never re render. 21 |

22 | 23 |

24 | Context provider for easy access to the internal state anywhere within 25 | the app tree. 26 |

27 | 28 |

29 | Renders the contents of kbar in a DOM element outside of the root app 30 | element. 31 |

32 | 33 |

34 | Handles all animations; showing, hiding, height scaling using the Web 35 | Animations API. 36 |

37 | 38 |

Renders an input which controls the internal search query state.

39 | 40 |

Renders a virtualized list of results.

41 | 42 |

43 | Accepts a collector function to retrieve specific values from state. 44 | Only re renders the component when return value deeply changes. All kbar 45 | components are built using this hook. 46 |

47 | 48 |

For instance, let's disable kbar at any given time.

49 | ({ 55 | disabled: state.disabled 56 | })); 57 | 58 | return 59 | } 60 | `} 61 | /> 62 | 63 |

Try it!

64 | 65 | 72 | 73 | 74 |

75 | An internal history implementation which maintains a simple in memory 76 | list of actions that contain an undoable, negatable action. 77 |

78 |
79 | ); 80 | } 81 | 82 | function Heading({ name }) { 83 | const location = useLocation(); 84 | return ( 85 |

91 | {name} 92 |

93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /example/src/Docs/Actions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Code from "../Code"; 3 | 4 | export default function Actions() { 5 | return ( 6 |
7 |

Actions

8 |

9 | When a user searches for something in kbar, the result is a list of 10 | ActionImpls. ActionImpls are a more complex, powerful 11 | representation of the action object literal that the user 12 | defines. 13 |

14 |

15 | The way users register actions is by first passing a list of default 16 | action objects to KBarProvider, and subsequently using{" "} 17 | useRegisterActions to dynamic register actions. 18 |

19 |

The object looks like this:

20 | any; 31 | parent?: ActionId; 32 | };`} 33 | /> 34 |

35 | kbar manages an internal state of action objects. We take the list of 36 | actions provided by the user and transform them under the hood into our 37 | own representation of these objects, ActionImpl. 38 |

39 |

40 | You don't need to know too much of the specifics of{" "} 41 | ActionImpl – we transform what the user passes to us to add 42 | a few extra properties that are useful to kbar internally. 43 |

44 |

All you need to know is:

45 |
    46 |
  • 47 | Pass initial list of actions if you have them to{" "} 48 | KBarProvider 49 |
  • 50 |
  • 51 | Register actions dynamically by using the{" "} 52 | useRegisterActions hook 53 |
  • 54 |
55 |

56 | Actions can have nested actions, represented by parent{" "} 57 | above. With this, we can do things like building a folder-like 58 | experience where toggling one action leads to displaying a "nested" list 59 | of other actions. 60 |

61 |

Static, global actions

62 |

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

67 |

Dynamic actions

68 |

69 | While it is good to have a set of actions registered up front and 70 | available globally, sometimes you will want to have actions available 71 | only when on a specific page, or even when a specific component is 72 | mounted. 73 |

74 |

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

79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /example/src/Docs/GettingStarted.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Code from "../Code"; 3 | 4 | export default function GettingStarted() { 5 | return ( 6 |
7 |

Getting started

8 |

9 | Get a simple command bar up and running quickly. In this example, we 10 | will use the APIs provided by kbar out of the box: 11 |

12 | 13 | 14 | 15 |

16 | There is a single provider which you will wrap your app around; you do 17 | not have to wrap your entire app; however, there are no 18 | performance implications by doing so. 19 |

20 | 21 | 29 | // ... 30 | 31 | ); 32 | } 33 | `} 34 | /> 35 | 36 |

37 | Let's add a few default actions. Actions are the core of kbar – an 38 | action define what to execute when a user selects it. 39 |

40 | 41 | (window.location.pathname = "blog"), 49 | }, 50 | { 51 | id: "contact", 52 | name: "Contact", 53 | shortcut: ["c"], 54 | keywords: "email", 55 | perform: () => (window.location.pathname = "contact"), 56 | }, 57 | ] 58 | 59 | return ( 60 | 61 | // ... 62 | 63 | ); 64 | `} 65 | /> 66 | 67 |

Next, we will pull in the provided UI components from kbar:

68 | 69 | 83 | // Renders the content outside the root node 84 | // Centers the content 85 | // Handles the show/hide and height animations 86 | // Search input 87 | 88 | 89 | 90 | 91 | ; 92 | ); 93 | } 94 | `} 95 | /> 96 | 97 |

98 | At this point hitting cmd+k will animate in a 99 | search input and nothing more. 100 |

101 | 102 |

103 | kbar provides a few utilities to render a performant list of search 104 | results. 105 |

106 | 107 |
    108 |
  • 109 | useMatches at its core returns a flattened list of 110 | results based on the current search query. This is a list of{" "} 111 | string and Action types. 112 |
  • 113 |
  • 114 | KBarResults takes the flattened list of results and 115 | renders them within a virtualized window. 116 |
  • 117 |
118 | 119 |

Combine the two utilities to create a powerful search interface:

120 | 121 | 130 | // 131 | 132 | // ... 133 | 134 | function RenderResults() { 135 | const { results } = useMatches(); 136 | 137 | return ( 138 | 141 | typeof item === "string" ? ( 142 |
{item}
143 | ) : ( 144 |
149 | {item.name} 150 |
151 | ) 152 | } 153 | /> 154 | ); 155 | }`} 156 | /> 157 | 158 |

159 | Hit cmd+k (or ctrl+k) and 160 | you should see a primitive command menu. kbar allows you to have full 161 | control over all aspects of your command menu. 162 |

163 |
164 | ); 165 | } 166 | -------------------------------------------------------------------------------- /example/src/Docs/Overview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export default function Overview() { 4 | return ( 5 |
6 |

Overview

7 |

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

12 |

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

17 |

Features

18 |
    19 |
  • Built in animations alongside fully customizable components
  • 20 |
  • Keyboard navigation support
  • 21 |
  • Undo/redo support
  • 22 |
  • 23 | Keyboard shortcut support for registering keystroke patterns to 24 | triggering actions 25 |
  • 26 |
  • Performance as a priority; large search results, not a problem
  • 27 |
  • 28 | Simple data structure which enables anyone to easily build advanced 29 | custom components 30 |
  • 31 |
  • Screen reader support
  • 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /example/src/Docs/Priority.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Code from "../Code"; 3 | 4 | export default function Priority() { 5 | return ( 6 |
7 |

Priority

8 |

9 | You've successfully registered hundreds of actions and realize you 10 | want to control the order in which they appear. 11 |

12 |

13 | By default, each action has a base priority value of 0 (Priority.NORMAL). 14 | This means each action will be rendered in the order in which it was 15 | defined. 16 |

17 |

Command score

18 |

19 | kbar uses command-score under the hood to filter actions 20 | based on the current search query. Each action will be given a score and 21 | we take the score and simply order by the highest to lowest value. 22 |

23 |

24 | For finer control, kbar enables you to pass a priority{" "} 25 | property as part of an action definition. priority is any 26 | number value which is then combined with the command score to determine 27 | the final sort order. 28 |

29 |

30 | You can use priority when defining an action's{" "} 31 | section property using the same interface. 32 |

33 | {}, 40 | section: "Recents" 41 | priority: Priority.LOW 42 | }) 43 | 44 | const loginAction = createAction({ 45 | name: "Login", 46 | perform: () => {}, 47 | priority: Priority.HIGH 48 | }) 49 | 50 | const themeAction = createAction({ 51 | name: "Dark mode", 52 | perform: () => {}, 53 | section: { 54 | name: "Settings", 55 | priority: Priority.HIGH 56 | } 57 | }) 58 | 59 | useRegisterActions([ 60 | signupAction, 61 | loginAction, 62 | themeAction 63 | ]) 64 | `} 65 | /> 66 |

67 | Using the above code as a reference, without any usage of{" "} 68 | priority, the order in which the actions will appear will 69 | be the order in which they were called: 70 |

71 |
    72 |
  1. Signup
  2. 73 |
  3. Login
  4. 74 |
  5. Dark mode
  6. 75 |
76 |
However, with the priorities in place, the order will be:
77 |
    78 |
  1. Dark mode
  2. 79 |
  3. Login
  4. 80 |
  5. Signup
  6. 81 |
82 |

Groups are sorted first and actions within groups are sorted after.

83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /example/src/Docs/Shortcuts.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Code from "../Code"; 3 | 4 | export default function Shortcuts() { 5 | return ( 6 |
7 |

Shortcuts

8 |

9 | kbar comes out of the box for registering keystroke patterns and 10 | triggering actions even when kbar is hidden. 11 |

12 |

13 | When registering an action, passing a valid property to{" "} 14 | shortcut will ensure that when users' keystroke pattern 15 | matches, that kbar will trigger that action. 16 |

17 |

18 | Imagine if you wanted to open a link to Twitter when the user types{" "} 19 | g+t, the action would look something like this: 20 |

21 | window.open("https://twitter.com/jack", "_blank") 26 | })`} 27 | /> 28 |

29 | You can also use shortcuts to open kbar at a specific parent action. For 30 | example, if a user types ?, you want to open kbar with the 31 | nested actions for "Search docs". Try it on this site – press{" "} 32 | ? and you will see the results for searching through docs 33 | immediately shown. 34 |

35 |

36 | Actions without a perform property will already have this 37 | implicitly handled. 38 |

39 |

Changing the default command+k shortcut

40 |

41 | Say you want to trigger kbar using a different shortcut, cmd+ 42 | shift+p. 43 |

44 |

45 | You can override the default behavior by passing a valid string sequence 46 | to KBarProvider.options.toggleShortcut based on{" "} 47 | 52 | tinykeys 53 | 54 | . 55 |

56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /example/src/Docs/State.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Code from "../Code"; 3 | 4 | export default function State() { 5 | return ( 6 |
7 |

Interfacing with state

8 |

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

14 |

useKBar

15 |

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

19 | { 24 | let actionAncestors = []; 25 | const collectAncestors = (actionId) => { 26 | const action = state.actions[actionId]; 27 | if (!action.parent) { 28 | return null; 29 | } 30 | actionWithAncestors.unshift(action); 31 | const parent = state.actions[action.parent]; 32 | collectAncestors(parent); 33 | }; 34 | 35 | return { 36 | actionAncestors 37 | } 38 | }) 39 | } 40 | 41 | return ( 42 |
    43 | {actionWithAncestors.map(action => ( 44 |
  • 45 | // ... 46 |
  • 47 | ))} 48 |
49 | );`} 50 | /> 51 |

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

61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /example/src/Docs/UndoRedo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Code from "../Code"; 3 | 4 | export default function UndoRedo() { 5 | return ( 6 |
7 |

Undo/Redo

8 |

9 | When instantiating kbar, we can optionally enable undo/redo 10 | functionality through options.enableHistory: 11 |

12 | 20 | `} 21 | /> 22 |

23 | When we use enableHistory, we let kbar know that we would 24 | like to use the internal HistoryImpl object to store 25 | actions that are undoable in a history stack. 26 |

27 |

28 | When enabled, keyboard shortcuts meta 29 | z and meta 30 | shift 31 | z will appropriately undo and redo actions in the stack. 32 |

33 |

Creating the negate action

34 |

35 | You may notice that we only have a perform property on an{" "} 36 | Action. To define the negate action, simply return a 37 | function from the perform: 38 |

39 | { 43 | // logic to perform action 44 | return () => { 45 | // logic to undo the action 46 | } 47 | }, 48 | // ... 49 | }) 50 | `} 51 | /> 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /example/src/Docs/data.ts: -------------------------------------------------------------------------------- 1 | import Actions from "./Actions"; 2 | import APIReference from "./APIReference"; 3 | import GettingStarted from "./GettingStarted"; 4 | import Overview from "./Overview"; 5 | import Priority from "./Priority"; 6 | import Shortcuts from "./Shortcuts"; 7 | import State from "./State"; 8 | import UndoRedo from "./UndoRedo"; 9 | 10 | const data = { 11 | introduction: { 12 | name: "Introduction", 13 | slug: "/docs", 14 | children: { 15 | overview: { 16 | name: "Overview", 17 | slug: "/docs/overview", 18 | component: Overview, 19 | section: "Overview", 20 | }, 21 | gettingStarted: { 22 | name: "Getting started", 23 | slug: "/docs/getting-started", 24 | component: GettingStarted, 25 | section: "Overview", 26 | }, 27 | }, 28 | }, 29 | concepts: { 30 | name: "Concepts", 31 | slug: "/concepts", 32 | children: { 33 | overview: { 34 | name: "Actions", 35 | slug: "/docs/concepts/actions", 36 | component: Actions, 37 | section: "Concepts", 38 | }, 39 | shortcuts: { 40 | name: "Shortcuts", 41 | slug: "/docs/concepts/shortcuts", 42 | component: Shortcuts, 43 | section: "Concepts", 44 | }, 45 | accessingState: { 46 | name: "State", 47 | slug: "/docs/concepts/state", 48 | component: State, 49 | section: "Concepts", 50 | }, 51 | history: { 52 | name: "Undo/Redo", 53 | slug: "/docs/concepts/history", 54 | component: UndoRedo, 55 | section: "Concepts", 56 | }, 57 | priority: { 58 | name: "Priority", 59 | slug: "/docs/concepts/priority", 60 | component: Priority, 61 | section: "Concepts", 62 | }, 63 | }, 64 | }, 65 | apiReference: { 66 | name: "API Reference", 67 | slug: "/api", 68 | children: { 69 | useStore: { 70 | name: "useStore", 71 | slug: "/docs/api/#useStore", 72 | component: APIReference, 73 | section: "API Reference", 74 | }, 75 | kbarProvider: { 76 | name: "KBarProvider", 77 | slug: "/docs/api/#KBarProvider", 78 | component: APIReference, 79 | section: "API Reference", 80 | }, 81 | kbarPortal: { 82 | name: "KBarPortal", 83 | slug: "/docs/api/#KBarPortal", 84 | component: APIReference, 85 | section: "API Reference", 86 | }, 87 | kbarAnimator: { 88 | name: "KBarAnimator", 89 | slug: "/docs/api/#KBarAnimator", 90 | component: APIReference, 91 | section: "API Reference", 92 | }, 93 | kbarSearch: { 94 | name: "KBarSearch", 95 | slug: "/docs/api/#KBarSearch", 96 | component: APIReference, 97 | section: "API Reference", 98 | }, 99 | kbarResults: { 100 | name: "KBarResults", 101 | slug: "/docs/api/#KBarResults", 102 | component: APIReference, 103 | section: "API Reference", 104 | }, 105 | useKBar: { 106 | name: "useKBar", 107 | slug: "/docs/api/#useKBar", 108 | component: APIReference, 109 | section: "API Reference", 110 | }, 111 | historyImpl: { 112 | name: "HistoryImpl", 113 | slug: "/docs/api/#HistoryImpl", 114 | component: APIReference, 115 | section: "API Reference", 116 | }, 117 | }, 118 | }, 119 | tutorials: { 120 | name: "Tutorials", 121 | slug: "/tutorials", 122 | children: { 123 | basic: { 124 | name: "Basic tutorial", 125 | slug: "/docs/tutorials/basic", 126 | component: null, 127 | section: "Tutorials", 128 | }, 129 | custom: { 130 | name: "Custom styles", 131 | slug: "/docs/tutorials/custom-styles", 132 | component: null, 133 | section: "Tutorials", 134 | }, 135 | }, 136 | }, 137 | }; 138 | 139 | export default data; 140 | -------------------------------------------------------------------------------- /example/src/Docs/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | Accordion, 4 | AccordionButton, 5 | AccordionPanel, 6 | AccordionItem, 7 | } from "@reach/accordion"; 8 | import styles from "./styles.module.scss"; 9 | import { Link, Switch, useLocation, Route } from "react-router-dom"; 10 | import data from "./data"; 11 | import { classnames } from "../utils"; 12 | 13 | export default function Docs() { 14 | const location = useLocation(); 15 | 16 | const routes = React.useMemo(() => { 17 | function generateRoute(tree) { 18 | return Object.keys(tree).map((key) => { 19 | const item = tree[key]; 20 | if (item.children) { 21 | return generateRoute(item.children); 22 | } 23 | return ( 24 | 29 | ); 30 | }); 31 | } 32 | return generateRoute(data); 33 | }, []); 34 | 35 | return ( 36 |
37 |
38 | 39 | {Object.keys(data).map((key) => { 40 | const section = data[key]; 41 | return ( 42 | 43 |

44 | {section.name} 45 |

46 | {Object.keys(section.children).length > 0 ? ( 47 | 48 |
    49 | {Object.keys(section.children).map((key) => { 50 | const child = section.children[key]; 51 | return ( 52 |
  • 53 | 62 | {child.name} 63 | 64 |
  • 65 | ); 66 | })} 67 |
68 |
69 | ) : null} 70 |
71 | ); 72 | })} 73 |
74 |
75 | {routes} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /example/src/Docs/styles.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | gap: 24px; 4 | 5 | h1 { 6 | margin-top: 0; 7 | } 8 | 9 | code { 10 | background: var(--a1); 11 | font-family: monospace; 12 | padding: 4px; 13 | border-radius: 4px; 14 | } 15 | } 16 | 17 | .toc { 18 | max-width: 200px; 19 | width: 100%; 20 | flex-grow: 1; 21 | flex-shrink: 0; 22 | 23 | ul { 24 | margin: 0; 25 | padding: 0; 26 | list-style: none; 27 | display: grid; 28 | gap: 8px; 29 | } 30 | 31 | h3 { 32 | margin: 0 0 12px 0; 33 | font-weight: 400; 34 | } 35 | 36 | a { 37 | text-decoration: none; 38 | display: block; 39 | } 40 | 41 | a.active { 42 | font-weight: 600; 43 | } 44 | 45 | a.comingSoon { 46 | opacity: 0.5; 47 | &::after { 48 | content: " 🚧"; 49 | } 50 | } 51 | 52 | [data-reach-accordion-button] { 53 | background: none; 54 | border: none; 55 | text-align: left; 56 | width: 100%; 57 | cursor: pointer; 58 | padding: 0; 59 | color: var(--foreground); 60 | } 61 | 62 | [data-reach-accordion-item] { 63 | margin-bottom: 12px; 64 | } 65 | 66 | [data-reach-accordion-panel] { 67 | padding-left: 12px; 68 | } 69 | } 70 | 71 | @media (max-width: 676px) { 72 | .wrapper { 73 | flex-direction: column; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /example/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import Code from "./Code"; 4 | 5 | export default function Home() { 6 | return ( 7 | <> 8 |

9 | 10 | 15 | kbar 16 | 17 | {" "} 18 | is a fully extensible command+k interface for your site. 19 |

20 |

21 | Try it out – press cmd+k (macOS) or{" "} 22 | ctrl+k (Linux/Windows), or click the logo above. 23 |

24 |

Background

25 |

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

30 |

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

35 |

Features

36 |
    37 |
  • Built in animations, fully customizable
  • 38 |
  • 39 | Keyboard navigation support; e.g. ctrl n /{" "} 40 | ctrl p for the navigation wizards 41 |
  • 42 |
  • 43 | Keyboard shortcuts support for registering keystrokes to specific 44 | actions; e.g. hit t for Twitter 45 |
  • 46 |
  • Navigate between nested actions with backspace
  • 47 |
  • 48 | A simple data structure which enables anyone to easily build their 49 | custom components 50 |
  • 51 |
52 |

Usage

53 |

54 | Have a fully functioning command menu for your site in minutes. First, 55 | install kbar. 56 |

57 | 58 |

59 | There is a single provider which you will wrap your app around; you do 60 | not have to wrap your entire app; however, there are no performance 61 | implications by doing so. 62 |

63 | 71 | // ... 72 | 73 | ); 74 | } 75 | 76 | `} 77 | /> 78 | 79 |

80 | Let's add a few default actions. Actions are the core of kbar – an 81 | action define what to execute when a user selects it. 82 |

83 | 84 | (window.location.pathname = "blog"), 93 | }, 94 | { 95 | id: "contact", 96 | name: "Contact", 97 | shortcut: ["c"], 98 | keywords: "email", 99 | perform: () => (window.location.pathname = "contact"), 100 | }, 101 | ] 102 | 103 | return ( 104 | 105 | // ... 106 | 107 | ); 108 | } 109 | `} 110 | /> 111 | 112 |

Next, we will pull in the provided UI components from kbar:

113 | 114 | 130 | // Renders the content outside the root node 131 | // Centers the content 132 | // Handles the show/hide and height animations 133 | // Search input 134 | 135 | 136 | 137 | 138 | ; 139 | ); 140 | } 141 | 142 | `} 143 | /> 144 | 145 |

146 | At this point hitting cmd+k (macOS) or ctrl+k (Linux/Windows) will 147 | animate in a search input and nothing more. 148 |

149 | 150 |

151 | kbar provides a few utilities to render a performant list of search 152 | results. 153 |

154 | 155 |
    156 |
  • 157 | useMatches at its core returns a flattened list of 158 | results and group name based on the current search query; i.e.{" "} 159 | 160 | ["Section name", Action, Action, "Another section name", Action, 161 | Action] 162 | 163 |
  • 164 |
  • 165 | KBarResults renders a performant virtualized list of these results 166 |
  • 167 |
168 | 169 |

Combine the two utilities to create a powerful search interface:

170 | 171 | 182 | // 183 | 184 | // ... 185 | 186 | function RenderResults() { 187 | const { results } = useMatches(); 188 | 189 | return ( 190 | 193 | typeof item === "string" ? ( 194 |
{item}
195 | ) : ( 196 |
201 | {item.name} 202 |
203 | ) 204 | } 205 | /> 206 | ); 207 | } 208 | 209 | `} 210 | /> 211 | 212 |

213 | Hit cmd+k (macOS) or ctrl+k (Linux/Windows) 214 | and you should see a primitive command menu. kbar allows you to have 215 | full control over all aspects of your command menu – refer to the{" "} 216 | docs to get an understanding of further 217 | capabilities. Looking forward to see what you build. 218 |

219 | 220 | ); 221 | } 222 | -------------------------------------------------------------------------------- /example/src/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | max-width: 1024px; 3 | width: 100%; 4 | margin: auto; 5 | padding: 32px; 6 | } 7 | 8 | .header { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | margin-bottom: calc(var(--unit) * 4); 13 | 14 | h1 { 15 | margin: 0; 16 | font-size: 24px; 17 | } 18 | 19 | button { 20 | cursor: pointer; 21 | background: none; 22 | border: none; 23 | border-radius: calc(var(--unit) * 0.5); 24 | color: var(--foreground); 25 | height: 100px; 26 | width: 100px; 27 | padding: 0; 28 | transition: background 100ms; 29 | 30 | &:hover { 31 | background: var(--a1); 32 | } 33 | 34 | &:active { 35 | background: var(--a2); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/src/Layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useKBar } from "../../src"; 3 | import styles from "./Layout.module.scss"; 4 | import Logo from "./Logo"; 5 | 6 | interface Props { 7 | children: React.ReactNode; 8 | } 9 | 10 | export default function Layout(props: Props) { 11 | const { query } = useKBar(); 12 | return ( 13 |
14 |
15 | 18 |

kbar

19 |
20 | {props.children} 21 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /example/src/Logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export default function Logo() { 4 | const style = { 5 | fill: "var(--background)", 6 | stroke: "var(--foreground)", 7 | strokeMiterLimit: 10, 8 | strokeWidth: "13px", 9 | }; 10 | return ( 11 | 12 | 13 | 22 | 31 | 40 | 48 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /example/src/fonts/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timc1/kbar/82505990d1dd6b9fd99c43a294107d163902a6fe/example/src/fonts/Inter-Bold.woff2 -------------------------------------------------------------------------------- /example/src/fonts/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timc1/kbar/82505990d1dd6b9fd99c43a294107d163902a6fe/example/src/fonts/Inter-Regular.woff2 -------------------------------------------------------------------------------- /example/src/hooks/useDocsActions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { Action, useRegisterActions } from "../../../src"; 4 | import data from "../Docs/data"; 5 | 6 | const searchId = randomId(); 7 | 8 | export default function useDocsActions() { 9 | const history = useHistory(); 10 | 11 | const searchActions = React.useMemo(() => { 12 | let actions: Action[] = []; 13 | const collectDocs = (tree) => { 14 | Object.keys(tree).forEach((key) => { 15 | const curr = tree[key]; 16 | if (curr.children) { 17 | collectDocs(curr.children); 18 | } 19 | 20 | if (curr.component) { 21 | actions.push({ 22 | id: randomId(), 23 | parent: searchId, 24 | name: curr.name, 25 | shortcut: [], 26 | keywords: "api reference docs", 27 | section: curr.section, 28 | perform: () => history.push(curr.slug), 29 | }); 30 | } 31 | }); 32 | return actions; 33 | }; 34 | return collectDocs(data); 35 | }, [history]); 36 | 37 | const rootSearchAction = React.useMemo( 38 | () => 39 | searchActions.length 40 | ? { 41 | id: searchId, 42 | name: "Search docs…", 43 | shortcut: ["?"], 44 | keywords: "find", 45 | section: "Documentation", 46 | } 47 | : null, 48 | [searchActions] 49 | ); 50 | useRegisterActions( 51 | [rootSearchAction, ...searchActions].filter(Boolean) as Action[] 52 | ); 53 | } 54 | 55 | function randomId() { 56 | return Math.random().toString(36).substring(2, 9); 57 | } 58 | -------------------------------------------------------------------------------- /example/src/hooks/useThemeActions.tsx: -------------------------------------------------------------------------------- 1 | import { useRegisterActions } from "../../../src/useRegisterActions"; 2 | import toast from "react-hot-toast"; 3 | import * as React from "react"; 4 | 5 | function Toast({ title, action, buttonText }) { 6 | return ( 7 |
8 | {title} 9 | 23 |
24 | ); 25 | } 26 | 27 | export default function useThemeActions() { 28 | useRegisterActions([ 29 | { 30 | id: "theme", 31 | name: "Change theme…", 32 | keywords: "interface color dark light", 33 | section: "Preferences", 34 | }, 35 | { 36 | id: "darkTheme", 37 | name: "Dark", 38 | keywords: "dark theme", 39 | section: "", 40 | perform: (actionImpl) => { 41 | const attribute = "data-theme-dark"; 42 | const doc = document.documentElement; 43 | doc.setAttribute(attribute, ""); 44 | toast( 45 |
46 | { 50 | actionImpl.command.history.undo(); 51 | toast.dismiss("dark"); 52 | 53 | toast( 54 | { 58 | actionImpl.command.history.redo(); 59 | toast.dismiss("dark-undo"); 60 | }} 61 | />, 62 | { 63 | id: "dark-undo", 64 | } 65 | ); 66 | }} 67 | /> 68 |
, 69 | { 70 | id: "dark", 71 | } 72 | ); 73 | return () => { 74 | doc.removeAttribute(attribute); 75 | }; 76 | }, 77 | parent: "theme", 78 | }, 79 | { 80 | id: "lightTheme", 81 | name: "Light", 82 | keywords: "light theme", 83 | section: "", 84 | perform: (actionImpl) => { 85 | const attribute = "data-theme-dark"; 86 | const doc = document.documentElement; 87 | const isDark = doc.getAttribute(attribute) !== null; 88 | document.documentElement.removeAttribute(attribute); 89 | 90 | toast( 91 |
92 | { 96 | actionImpl.command.history.undo(); 97 | toast.dismiss("light"); 98 | 99 | toast( 100 | { 104 | actionImpl.command.history.redo(); 105 | toast.dismiss("light-undo"); 106 | }} 107 | />, 108 | { 109 | id: "light-undo", 110 | } 111 | ); 112 | }} 113 | /> 114 |
, 115 | { 116 | id: "light", 117 | } 118 | ); 119 | 120 | return () => { 121 | if (isDark) doc.setAttribute(attribute, ""); 122 | }; 123 | }, 124 | parent: "theme", 125 | }, 126 | ]); 127 | } 128 | -------------------------------------------------------------------------------- /example/src/index.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Inter"; 3 | src: url("./fonts/Inter-Bold.woff2") format("woff2"); 4 | font-weight: 600; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: "Inter"; 10 | src: url("./fonts/Inter-Regular.woff2") format("woff2"); 11 | font-weight: 400; 12 | } 13 | 14 | // https://piccalil.li/blog/a-modern-css-reset/ 15 | /* Box sizing rules */ 16 | *, 17 | *::before, 18 | *::after { 19 | box-sizing: border-box; 20 | } 21 | 22 | /* Remove default margin */ 23 | body, 24 | h1, 25 | h2, 26 | h3, 27 | h4, 28 | p, 29 | figure, 30 | blockquote, 31 | dl, 32 | dd { 33 | font-size: 16px; 34 | } 35 | 36 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 37 | ul[role="list"], 38 | ol[role="list"] { 39 | list-style: none; 40 | padding: 0; 41 | } 42 | 43 | /* Set core root defaults */ 44 | html:focus-within { 45 | scroll-behavior: smooth; 46 | } 47 | 48 | /* Set core body defaults */ 49 | body { 50 | min-height: 100vh; 51 | text-rendering: optimizeSpeed; 52 | line-height: 1.6; 53 | } 54 | 55 | /* A elements that don't have a class get default styles */ 56 | a:not([class]) { 57 | text-decoration-skip-ink: auto; 58 | } 59 | 60 | /* Make images easier to work with */ 61 | img, 62 | picture { 63 | max-width: 100%; 64 | display: block; 65 | } 66 | 67 | /* Inherit fonts for inputs and buttons */ 68 | input, 69 | button, 70 | textarea, 71 | select { 72 | font: inherit; 73 | } 74 | 75 | /* Remove all animations, transitions and smooth scroll for people that prefer not to see them */ 76 | @media (prefers-reduced-motion: reduce) { 77 | html:focus-within { 78 | scroll-behavior: auto; 79 | } 80 | 81 | *, 82 | *::before, 83 | *::after { 84 | animation-duration: 0.01ms !important; 85 | animation-iteration-count: 1 !important; 86 | transition-duration: 0.01ms !important; 87 | scroll-behavior: auto !important; 88 | } 89 | } 90 | 91 | * { 92 | font-family: "Inter"; 93 | box-sizing: border-box; 94 | } 95 | 96 | :root { 97 | --background: rgb(252 252 252); 98 | --a1: rgba(0 0 0 / 0.05); 99 | --a2: rgba(0 0 0 / 0.1); 100 | --foreground: rgb(28 28 29); 101 | --shadow: 0px 6px 20px rgb(0 0 0 / 20%); 102 | 103 | --unit: 8px; 104 | } 105 | 106 | html[data-theme-dark]:root { 107 | --background: rgb(28 28 29); 108 | --a1: rgb(53 53 54); 109 | --a2: rgba(255 255 255 / 0.1); 110 | --foreground: rgba(252 252 252 / 0.9); 111 | --shadow: rgb(0 0 0 / 50%) 0px 16px 70px; 112 | } 113 | 114 | html { 115 | background: var(--background); 116 | color: var(--foreground); 117 | } 118 | 119 | kbd { 120 | font-family: monospace; 121 | background: var(--a2); 122 | padding: calc(var(--unit) * 0.5); 123 | border-radius: calc(var(--unit) * 0.5); 124 | } 125 | 126 | a { 127 | color: var(--foreground); 128 | text-decoration: underline; 129 | text-underline-position: under; 130 | } 131 | 132 | #carbonads * { 133 | margin: initial; 134 | padding: initial; 135 | } 136 | #carbonads { 137 | margin-left: auto; 138 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 139 | Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Helvetica, Arial, 140 | sans-serif; 141 | position: sticky; 142 | top: var(--unit); 143 | } 144 | @media (max-width: 676px) { 145 | #carbonads { 146 | margin: auto; 147 | } 148 | } 149 | #carbonads { 150 | display: flex; 151 | max-width: 330px; 152 | background-color: var(--background); 153 | box-shadow: 0 1px 4px 1px hsla(0, 0%, 0%, 0.1); 154 | z-index: 100; 155 | } 156 | #carbonads a { 157 | color: inherit; 158 | text-decoration: none; 159 | } 160 | #carbonads a:hover { 161 | color: inherit; 162 | } 163 | #carbonads span { 164 | position: relative; 165 | display: block; 166 | overflow: hidden; 167 | } 168 | #carbonads .carbon-wrap { 169 | display: flex; 170 | } 171 | #carbonads .carbon-img { 172 | display: block; 173 | margin: 0; 174 | line-height: 1; 175 | } 176 | #carbonads .carbon-img img { 177 | display: block; 178 | } 179 | #carbonads .carbon-text { 180 | font-size: 13px; 181 | padding: 10px; 182 | margin-bottom: 16px; 183 | line-height: 1.5; 184 | text-align: left; 185 | } 186 | #carbonads .carbon-poweredby { 187 | display: block; 188 | padding: 6px 8px; 189 | background: var(--background); 190 | text-align: center; 191 | text-transform: uppercase; 192 | letter-spacing: 0.5px; 193 | font-weight: 600; 194 | font-size: 8px; 195 | line-height: 1; 196 | border-top-left-radius: 3px; 197 | position: absolute; 198 | bottom: 0; 199 | right: 0; 200 | } -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "react-dom"; 3 | import App from "./App"; 4 | import { BrowserRouter as Router } from "react-router-dom"; 5 | 6 | render( 7 | 8 | 9 | 10 | 11 | , 12 | document.getElementById("root") 13 | ); 14 | -------------------------------------------------------------------------------- /example/src/utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function classnames(...args: (string | undefined | null)[]) { 4 | return args.filter(Boolean).join(" "); 5 | } 6 | 7 | export function useAnalytics() { 8 | React.useEffect(() => { 9 | const dev = window.location.host.includes("localhost"); 10 | 11 | if (!dev) { 12 | const script = document.createElement("script"); 13 | script.src = "https://www.googletagmanager.com/gtag/js?id=G-TGC84TSLJZ"; 14 | 15 | document.body.appendChild(script); 16 | 17 | script.onload = () => { 18 | // @ts-ignore 19 | window.dataLayer = window.dataLayer || []; 20 | function gtag() { 21 | // @ts-ignore 22 | dataLayer.push(arguments); 23 | } 24 | // @ts-ignore 25 | gtag("js", new Date()); 26 | // @ts-ignore 27 | gtag("config", "G-TGC84TSLJZ"); 28 | }; 29 | 30 | return () => { 31 | document.removeChild(script); 32 | }; 33 | } 34 | }, []); 35 | } 36 | -------------------------------------------------------------------------------- /example/webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { ESBuildMinifyPlugin } = require("esbuild-loader"); 3 | 4 | module.exports = { 5 | mode: "development", 6 | resolve: { 7 | extensions: [".tsx", ".ts", ".js"], 8 | }, 9 | entry: path.resolve(__dirname, "src/index.tsx"), 10 | output: { 11 | filename: "index.js", 12 | path: path.resolve(__dirname, "dist"), 13 | }, 14 | devServer: { 15 | historyApiFallback: true, 16 | static: path.resolve(__dirname, "dist"), 17 | hot: true, 18 | }, 19 | optimization: { 20 | minimizer: [ 21 | new ESBuildMinifyPlugin({ 22 | target: "es2015", 23 | css: true, 24 | }), 25 | ], 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.tsx?$/, 31 | loader: "esbuild-loader", 32 | exclude: /node_modules/, 33 | options: { 34 | loader: "tsx", 35 | }, 36 | }, 37 | { 38 | test: /\.s[ac]ss$/i, 39 | use: ["style-loader", "css-loader", "sass-loader"], 40 | }, 41 | { 42 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 43 | type: "asset/resource", 44 | }, 45 | ], 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /example/webpack.prod.cjs: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { ESBuildMinifyPlugin } = require("esbuild-loader"); 3 | 4 | module.exports = { 5 | mode: "production", 6 | resolve: { 7 | extensions: [".tsx", ".ts", ".js"], 8 | }, 9 | entry: path.resolve(__dirname, "src/index.tsx"), 10 | output: { 11 | filename: "index.js", 12 | path: path.resolve(__dirname, "dist"), 13 | }, 14 | optimization: { 15 | minimizer: [ 16 | new ESBuildMinifyPlugin({ 17 | target: "es2015", 18 | css: true, 19 | }), 20 | ], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.tsx?$/, 26 | loader: "esbuild-loader", 27 | exclude: /node_modules/, 28 | options: { 29 | loader: "tsx", 30 | }, 31 | }, 32 | { 33 | test: /\.s[ac]ss$/i, 34 | use: ["style-loader", "css-loader", "sass-loader"], 35 | }, 36 | { 37 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 38 | type: "asset/resource", 39 | }, 40 | ], 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.tsx?$": "esbuild-jest", 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kbar", 3 | "version": "0.1.0-beta.46", 4 | "main": "lib/index.js", 5 | "types": "lib/index.d.ts", 6 | "scripts": { 7 | "build": "rm -rf ./lib & tsc", 8 | "build:example": "webpack --config ./example/webpack.prod.cjs", 9 | "dev": "webpack-dev-server --config ./example/webpack.config.cjs --hot", 10 | "test": "jest src", 11 | "lint": "eslint src/**/* example/src/**/* --ext .js,.ts,.tsx", 12 | "typecheck": "tsc --noEmit" 13 | }, 14 | "babel": { 15 | "presets": [ 16 | "@babel/preset-typescript" 17 | ] 18 | }, 19 | "devDependencies": { 20 | "@babel/preset-typescript": "^7.16.7", 21 | "@reach/accordion": "^0.16.1", 22 | "@testing-library/react": "^12.1.4", 23 | "@testing-library/user-event": "^13.5.0", 24 | "@types/jest": "^27.0.2", 25 | "@types/react": "^17.0.64", 26 | "@types/react-dom": "^17.0.20", 27 | "@types/react-router-dom": "^5.3.3", 28 | "@typescript-eslint/eslint-plugin": "^4.33.0", 29 | "@typescript-eslint/parser": "^4.33.0", 30 | "css-loader": "^6.2.0", 31 | "esbuild": "^0.13.10", 32 | "esbuild-jest": "^0.5.0", 33 | "esbuild-loader": "^2.15.1", 34 | "eslint": "^7.32.0", 35 | "eslint-config-prettier": "^8.3.0", 36 | "eslint-config-react-app": "^6.0.0", 37 | "eslint-plugin-flowtype": "^5.10.0", 38 | "eslint-plugin-import": "^2.24.2", 39 | "eslint-plugin-jsx-a11y": "^6.4.1", 40 | "eslint-plugin-prettier": "^4.0.0", 41 | "eslint-plugin-react": "^7.26.1", 42 | "eslint-plugin-react-hooks": "^4.2.0", 43 | "font-loader": "^0.1.2", 44 | "jest": "^27.3.1", 45 | "prettier": "^2.4.1", 46 | "prism-react-renderer": "^1.2.1", 47 | "react": "^16.0.0 || ^17.0.0", 48 | "react-dom": "^16.0.0 || ^17.0.0", 49 | "react-hot-toast": "^2.1.1", 50 | "react-router-dom": "^5.3.0", 51 | "sass": "^1.39.2", 52 | "sass-loader": "^12.1.0", 53 | "style-loader": "^3.2.1", 54 | "typescript": "^4.4.2", 55 | "webpack": "^5.88.2", 56 | "webpack-cli": "^4.10.0", 57 | "webpack-dev-server": "^4.15.1" 58 | }, 59 | "dependencies": { 60 | "@radix-ui/react-portal": "^1.0.1", 61 | "fast-equals": "^2.0.3", 62 | "fuse.js": "^6.6.2", 63 | "react-virtual": "^2.8.2", 64 | "tiny-invariant": "^1.2.0" 65 | }, 66 | "peerDependencies": { 67 | "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 68 | "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 69 | }, 70 | "keywords": [ 71 | "command bar", 72 | "search" 73 | ], 74 | "author": "Tim Chang ", 75 | "license": "MIT", 76 | "repository": { 77 | "type": "git", 78 | "url": "https://github.com/timc1/kbar.git" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/InternalEvents.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ActionImpl } from "./action"; 3 | import tinykeys from "./tinykeys"; 4 | import { VisualState } from "./types"; 5 | import { useKBar } from "./useKBar"; 6 | import { getScrollbarWidth, shouldRejectKeystrokes } from "./utils"; 7 | 8 | type Timeout = ReturnType; 9 | 10 | export function InternalEvents() { 11 | useToggleHandler(); 12 | useDocumentLock(); 13 | useShortcuts(); 14 | useFocusHandler(); 15 | return null; 16 | } 17 | 18 | /** 19 | * `useToggleHandler` handles the keyboard events for toggling kbar. 20 | */ 21 | function useToggleHandler() { 22 | const { query, options, visualState, showing, disabled } = useKBar( 23 | (state) => ({ 24 | visualState: state.visualState, 25 | showing: state.visualState !== VisualState.hidden, 26 | disabled: state.disabled, 27 | }) 28 | ); 29 | 30 | React.useEffect(() => { 31 | const close = () => { 32 | query.setVisualState((vs) => { 33 | if (vs === VisualState.hidden || vs === VisualState.animatingOut) { 34 | return vs; 35 | } 36 | return VisualState.animatingOut; 37 | }); 38 | }; 39 | 40 | if (disabled) { 41 | close(); 42 | return; 43 | } 44 | 45 | const shortcut = options.toggleShortcut || "$mod+k"; 46 | 47 | const unsubscribe = tinykeys(window, { 48 | [shortcut]: (event: KeyboardEvent) => { 49 | if (event.defaultPrevented) return; 50 | event.preventDefault(); 51 | query.toggle(); 52 | 53 | if (showing) { 54 | options.callbacks?.onClose?.(); 55 | } else { 56 | options.callbacks?.onOpen?.(); 57 | } 58 | }, 59 | Escape: (event: KeyboardEvent) => { 60 | if (showing) { 61 | event.stopPropagation(); 62 | event.preventDefault(); 63 | options.callbacks?.onClose?.(); 64 | } 65 | 66 | close(); 67 | }, 68 | }); 69 | return () => { 70 | unsubscribe(); 71 | }; 72 | }, [options.callbacks, options.toggleShortcut, query, showing, disabled]); 73 | 74 | const timeoutRef = React.useRef(); 75 | const runAnimateTimer = React.useCallback( 76 | (vs: VisualState.animatingIn | VisualState.animatingOut) => { 77 | let ms = 0; 78 | if (vs === VisualState.animatingIn) { 79 | ms = options.animations?.enterMs || 0; 80 | } 81 | if (vs === VisualState.animatingOut) { 82 | ms = options.animations?.exitMs || 0; 83 | } 84 | 85 | clearTimeout(timeoutRef.current as Timeout); 86 | timeoutRef.current = setTimeout(() => { 87 | let backToRoot = false; 88 | 89 | // TODO: setVisualState argument should be a function or just a VisualState value. 90 | query.setVisualState(() => { 91 | const finalVs = 92 | vs === VisualState.animatingIn 93 | ? VisualState.showing 94 | : VisualState.hidden; 95 | 96 | if (finalVs === VisualState.hidden) { 97 | backToRoot = true; 98 | } 99 | 100 | return finalVs; 101 | }); 102 | 103 | if (backToRoot) { 104 | query.setCurrentRootAction(null); 105 | } 106 | }, ms); 107 | }, 108 | [options.animations?.enterMs, options.animations?.exitMs, query] 109 | ); 110 | 111 | React.useEffect(() => { 112 | switch (visualState) { 113 | case VisualState.animatingIn: 114 | case VisualState.animatingOut: 115 | runAnimateTimer(visualState); 116 | break; 117 | } 118 | }, [runAnimateTimer, visualState]); 119 | } 120 | 121 | /** 122 | * `useDocumentLock` is a simple implementation for preventing the 123 | * underlying page content from scrolling when kbar is open. 124 | */ 125 | function useDocumentLock() { 126 | const { visualState, options } = useKBar((state) => ({ 127 | visualState: state.visualState, 128 | })); 129 | 130 | React.useEffect(() => { 131 | if (options.disableDocumentLock) return; 132 | if (visualState === VisualState.animatingIn) { 133 | document.body.style.overflow = "hidden"; 134 | 135 | if (!options.disableScrollbarManagement) { 136 | let scrollbarWidth = getScrollbarWidth(); 137 | // take into account the margins explicitly added by the consumer 138 | const mr = getComputedStyle(document.body)["margin-right"]; 139 | if (mr) { 140 | // remove non-numeric values; px, rem, em, etc. 141 | scrollbarWidth += Number(mr.replace(/\D/g, "")); 142 | } 143 | document.body.style.marginRight = scrollbarWidth + "px"; 144 | } 145 | } else if (visualState === VisualState.hidden) { 146 | document.body.style.removeProperty("overflow"); 147 | 148 | if (!options.disableScrollbarManagement) { 149 | document.body.style.removeProperty("margin-right"); 150 | } 151 | } 152 | }, [ 153 | options.disableDocumentLock, 154 | options.disableScrollbarManagement, 155 | visualState, 156 | ]); 157 | } 158 | 159 | /** 160 | * Reference: https://github.com/jamiebuilds/tinykeys/issues/37 161 | * 162 | * Fixes an issue where simultaneous key commands for shortcuts; 163 | * ie given two actions with shortcuts ['t','s'] and ['s'], pressing 164 | * 't' and 's' consecutively will cause both shortcuts to fire. 165 | * 166 | * `wrap` sets each keystroke event in a WeakSet, and ensures that 167 | * if ['t', 's'] are pressed, then the subsequent ['s'] event will 168 | * be ignored. This depends on the order in which we register the 169 | * shortcuts to tinykeys, which is handled below. 170 | */ 171 | const handled = new WeakSet(); 172 | function wrap(handler: (event: KeyboardEvent) => void) { 173 | return (event: KeyboardEvent) => { 174 | if (handled.has(event)) return; 175 | handler(event); 176 | handled.add(event); 177 | }; 178 | } 179 | 180 | /** 181 | * `useShortcuts` registers and listens to keyboard strokes and 182 | * performs actions for patterns that match the user defined `shortcut`. 183 | */ 184 | function useShortcuts() { 185 | const { actions, query, open, options, disabled } = useKBar((state) => ({ 186 | actions: state.actions, 187 | open: state.visualState === VisualState.showing, 188 | disabled: state.disabled, 189 | })); 190 | 191 | React.useEffect(() => { 192 | if (open || disabled) return; 193 | 194 | const actionsList = Object.keys(actions).map((key) => actions[key]); 195 | 196 | let actionsWithShortcuts: ActionImpl[] = []; 197 | for (let action of actionsList) { 198 | if (!action.shortcut?.length) { 199 | continue; 200 | } 201 | actionsWithShortcuts.push(action); 202 | } 203 | 204 | actionsWithShortcuts = actionsWithShortcuts.sort( 205 | (a, b) => b.shortcut!.join(" ").length - a.shortcut!.join(" ").length 206 | ); 207 | 208 | const shortcutsMap = {}; 209 | for (let action of actionsWithShortcuts) { 210 | const shortcut = action.shortcut!.join(" "); 211 | 212 | shortcutsMap[shortcut] = wrap((event: KeyboardEvent) => { 213 | if (shouldRejectKeystrokes()) return; 214 | 215 | event.preventDefault(); 216 | if (action.children?.length) { 217 | query.setCurrentRootAction(action.id); 218 | query.toggle(); 219 | options.callbacks?.onOpen?.(); 220 | } else { 221 | action.command?.perform(); 222 | options.callbacks?.onSelectAction?.(action); 223 | } 224 | }); 225 | } 226 | 227 | const unsubscribe = tinykeys(window, shortcutsMap, { 228 | timeout: 400, 229 | }); 230 | 231 | return () => { 232 | unsubscribe(); 233 | }; 234 | }, [actions, open, options.callbacks, query, disabled]); 235 | } 236 | 237 | /** 238 | * `useFocusHandler` ensures that focus is set back on the element which was 239 | * in focus prior to kbar being triggered. 240 | */ 241 | function useFocusHandler() { 242 | const rFirstRender = React.useRef(true); 243 | const { isShowing, query } = useKBar((state) => ({ 244 | isShowing: 245 | state.visualState === VisualState.showing || 246 | state.visualState === VisualState.animatingIn, 247 | })); 248 | 249 | const activeElementRef = React.useRef(null); 250 | 251 | React.useEffect(() => { 252 | if (rFirstRender.current) { 253 | rFirstRender.current = false; 254 | return; 255 | } 256 | if (isShowing) { 257 | activeElementRef.current = document.activeElement as HTMLElement; 258 | return; 259 | } 260 | 261 | // This fixes an issue on Safari where closing kbar causes the entire 262 | // page to scroll to the bottom. The reason this was happening was due 263 | // to the search input still in focus when we removed it from the dom. 264 | const currentActiveElement = document.activeElement as HTMLElement; 265 | if (currentActiveElement?.tagName.toLowerCase() === "input") { 266 | currentActiveElement.blur(); 267 | } 268 | 269 | const activeElement = activeElementRef.current; 270 | if (activeElement && activeElement !== currentActiveElement) { 271 | activeElement.focus(); 272 | } 273 | }, [isShowing]); 274 | 275 | // When focus is blurred from the search input while kbar is still 276 | // open, any keystroke should set focus back to the search input. 277 | React.useEffect(() => { 278 | function handler(event: KeyboardEvent) { 279 | const input = query.getInput(); 280 | if (event.target !== input) { 281 | input.focus(); 282 | } 283 | } 284 | if (isShowing) { 285 | window.addEventListener("keydown", handler); 286 | return () => { 287 | window.removeEventListener("keydown", handler); 288 | }; 289 | } 290 | }, [isShowing, query]); 291 | } 292 | -------------------------------------------------------------------------------- /src/KBarAnimator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { VisualState } from "./types"; 3 | import { useKBar } from "./useKBar"; 4 | import { useOuterClick } from "./utils"; 5 | 6 | interface KBarAnimatorProps { 7 | style?: React.CSSProperties; 8 | className?: string; 9 | disableCloseOnOuterClick?: boolean; 10 | } 11 | 12 | const appearanceAnimationKeyframes = [ 13 | { 14 | opacity: 0, 15 | transform: "scale(.99)", 16 | }, 17 | { opacity: 1, transform: "scale(1.01)" }, 18 | { opacity: 1, transform: "scale(1)" }, 19 | ]; 20 | 21 | const bumpAnimationKeyframes = [ 22 | { 23 | transform: "scale(1)", 24 | }, 25 | { 26 | transform: "scale(.98)", 27 | }, 28 | { 29 | transform: "scale(1)", 30 | }, 31 | ]; 32 | 33 | export const KBarAnimator: React.FC< 34 | React.PropsWithChildren 35 | > = ({ children, style, className, disableCloseOnOuterClick }) => { 36 | const { visualState, currentRootActionId, query, options } = useKBar( 37 | (state) => ({ 38 | visualState: state.visualState, 39 | currentRootActionId: state.currentRootActionId, 40 | }) 41 | ); 42 | 43 | const outerRef = React.useRef(null); 44 | const innerRef = React.useRef(null); 45 | 46 | const enterMs = options?.animations?.enterMs || 0; 47 | const exitMs = options?.animations?.exitMs || 0; 48 | 49 | // Show/hide animation 50 | React.useEffect(() => { 51 | if (visualState === VisualState.showing) { 52 | return; 53 | } 54 | 55 | const duration = visualState === VisualState.animatingIn ? enterMs : exitMs; 56 | 57 | const element = outerRef.current; 58 | 59 | element?.animate(appearanceAnimationKeyframes, { 60 | duration, 61 | easing: 62 | // TODO: expose easing in options 63 | visualState === VisualState.animatingOut ? "ease-in" : "ease-out", 64 | direction: 65 | visualState === VisualState.animatingOut ? "reverse" : "normal", 66 | fill: "forwards", 67 | }); 68 | }, [options, visualState, enterMs, exitMs]); 69 | 70 | // Height animation 71 | const previousHeight = React.useRef(); 72 | React.useEffect(() => { 73 | // Only animate if we're actually showing 74 | if (visualState === VisualState.showing) { 75 | const outer = outerRef.current; 76 | const inner = innerRef.current; 77 | 78 | if (!outer || !inner) { 79 | return; 80 | } 81 | 82 | const ro = new ResizeObserver((entries) => { 83 | for (let entry of entries) { 84 | const cr = entry.contentRect; 85 | 86 | if (!previousHeight.current) { 87 | previousHeight.current = cr.height; 88 | } 89 | 90 | outer.animate( 91 | [ 92 | { 93 | height: `${previousHeight.current}px`, 94 | }, 95 | { 96 | height: `${cr.height}px`, 97 | }, 98 | ], 99 | { 100 | duration: enterMs / 2, 101 | // TODO: expose configs here 102 | easing: "ease-out", 103 | fill: "forwards", 104 | } 105 | ); 106 | previousHeight.current = cr.height; 107 | } 108 | }); 109 | 110 | ro.observe(inner); 111 | return () => { 112 | ro.unobserve(inner); 113 | }; 114 | } 115 | }, [visualState, options, enterMs, exitMs]); 116 | 117 | // Bump animation between nested actions 118 | const firstRender = React.useRef(true); 119 | React.useEffect(() => { 120 | if (firstRender.current) { 121 | firstRender.current = false; 122 | return; 123 | } 124 | const element = outerRef.current; 125 | if (element) { 126 | element.animate(bumpAnimationKeyframes, { 127 | duration: enterMs, 128 | easing: "ease-out", 129 | }); 130 | } 131 | }, [currentRootActionId, enterMs]); 132 | 133 | useOuterClick(outerRef, () => { 134 | if (disableCloseOnOuterClick) { 135 | return; 136 | } 137 | query.setVisualState(VisualState.animatingOut); 138 | options.callbacks?.onClose?.(); 139 | }); 140 | 141 | return ( 142 |
151 |
{children}
152 |
153 | ); 154 | }; 155 | -------------------------------------------------------------------------------- /src/KBarContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from "./useStore"; 2 | import * as React from "react"; 3 | import { InternalEvents } from "./InternalEvents"; 4 | import type { IKBarContext, KBarProviderProps } from "./types"; 5 | 6 | export const KBarContext = React.createContext( 7 | {} as IKBarContext 8 | ); 9 | 10 | export const KBarProvider: React.FC< 11 | React.PropsWithChildren 12 | > = (props) => { 13 | const contextValue = useStore(props); 14 | 15 | return ( 16 | 17 | 18 | {props.children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/KBarPortal.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from "@radix-ui/react-portal"; 2 | import * as React from "react"; 3 | import { VisualState } from "./types"; 4 | import { useKBar } from "./useKBar"; 5 | 6 | interface Props { 7 | children: React.ReactNode; 8 | container?: HTMLElement; 9 | } 10 | 11 | export function KBarPortal({ children, container }: Props) { 12 | const { showing } = useKBar((state) => ({ 13 | showing: state.visualState !== VisualState.hidden, 14 | })); 15 | 16 | if (!showing) { 17 | return null; 18 | } 19 | 20 | return {children}; 21 | } 22 | -------------------------------------------------------------------------------- /src/KBarPositioner.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface Props { 4 | className?: string; 5 | style?: React.CSSProperties; 6 | } 7 | 8 | const defaultStyle: React.CSSProperties = { 9 | position: "fixed", 10 | display: "flex", 11 | alignItems: "flex-start", 12 | justifyContent: "center", 13 | width: "100%", 14 | inset: "0px", 15 | padding: "14vh 16px 16px", 16 | }; 17 | 18 | function getStyle(style: React.CSSProperties | undefined) { 19 | return style ? { ...defaultStyle, ...style } : defaultStyle; 20 | } 21 | 22 | export const KBarPositioner: React.FC> = 23 | React.forwardRef( 24 | ({ style, children, ...props }, ref) => ( 25 |
26 | {children} 27 |
28 | ) 29 | ); 30 | -------------------------------------------------------------------------------- /src/KBarResults.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useVirtual } from "react-virtual"; 3 | import { ActionImpl } from "./action/ActionImpl"; 4 | import { getListboxItemId, KBAR_LISTBOX } from "./KBarSearch"; 5 | import { useKBar } from "./useKBar"; 6 | import { usePointerMovedSinceMount } from "./utils"; 7 | 8 | const START_INDEX = 0; 9 | 10 | interface RenderParams { 11 | item: T; 12 | active: boolean; 13 | } 14 | 15 | interface KBarResultsProps { 16 | items: any[]; 17 | onRender: (params: RenderParams) => React.ReactElement; 18 | maxHeight?: number; 19 | } 20 | 21 | export const KBarResults: React.FC = (props) => { 22 | const activeRef = React.useRef(null); 23 | const parentRef = React.useRef(null); 24 | 25 | // store a ref to all items so we do not have to pass 26 | // them as a dependency when setting up event listeners. 27 | const itemsRef = React.useRef(props.items); 28 | itemsRef.current = props.items; 29 | 30 | const rowVirtualizer = useVirtual({ 31 | size: itemsRef.current.length, 32 | parentRef, 33 | }); 34 | 35 | const { query, search, currentRootActionId, activeIndex, options } = useKBar( 36 | (state) => ({ 37 | search: state.searchQuery, 38 | currentRootActionId: state.currentRootActionId, 39 | activeIndex: state.activeIndex, 40 | }) 41 | ); 42 | 43 | React.useEffect(() => { 44 | const handler = (event) => { 45 | if (event.isComposing) { 46 | return; 47 | } 48 | 49 | if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) { 50 | event.preventDefault(); 51 | event.stopPropagation(); 52 | query.setActiveIndex((index) => { 53 | let nextIndex = index > START_INDEX ? index - 1 : index; 54 | // avoid setting active index on a group 55 | if (typeof itemsRef.current[nextIndex] === "string") { 56 | if (nextIndex === 0) return index; 57 | nextIndex -= 1; 58 | } 59 | return nextIndex; 60 | }); 61 | } else if ( 62 | event.key === "ArrowDown" || 63 | (event.ctrlKey && event.key === "n") 64 | ) { 65 | event.preventDefault(); 66 | event.stopPropagation(); 67 | query.setActiveIndex((index) => { 68 | let nextIndex = 69 | index < itemsRef.current.length - 1 ? index + 1 : index; 70 | // avoid setting active index on a group 71 | if (typeof itemsRef.current[nextIndex] === "string") { 72 | if (nextIndex === itemsRef.current.length - 1) return index; 73 | nextIndex += 1; 74 | } 75 | return nextIndex; 76 | }); 77 | } else if (event.key === "Enter") { 78 | event.preventDefault(); 79 | event.stopPropagation(); 80 | // storing the active dom element in a ref prevents us from 81 | // having to calculate the current action to perform based 82 | // on the `activeIndex`, which we would have needed to add 83 | // as part of the dependencies array. 84 | activeRef.current?.click(); 85 | } 86 | }; 87 | window.addEventListener("keydown", handler, {capture: true}); 88 | return () => window.removeEventListener("keydown", handler, {capture: true}); 89 | }, [query]); 90 | 91 | // destructuring here to prevent linter warning to pass 92 | // entire rowVirtualizer in the dependencies array. 93 | const { scrollToIndex } = rowVirtualizer; 94 | React.useEffect(() => { 95 | scrollToIndex(activeIndex, { 96 | // ensure that if the first item in the list is a group 97 | // name and we are focused on the second item, to not 98 | // scroll past that group, hiding it. 99 | align: activeIndex <= 1 ? "end" : "auto", 100 | }); 101 | }, [activeIndex, scrollToIndex]); 102 | 103 | React.useEffect(() => { 104 | // TODO(tim): fix scenario where async actions load in 105 | // and active index is reset to the first item. i.e. when 106 | // users register actions and bust the `useRegisterActions` 107 | // cache, we won't want to reset their active index as they 108 | // are navigating the list. 109 | query.setActiveIndex( 110 | // avoid setting active index on a group 111 | typeof props.items[START_INDEX] === "string" 112 | ? START_INDEX + 1 113 | : START_INDEX 114 | ); 115 | }, [search, currentRootActionId, props.items, query]); 116 | 117 | const execute = React.useCallback( 118 | (item: RenderParams["item"]) => { 119 | if (typeof item === "string") return; 120 | if (item.command) { 121 | item.command.perform(item); 122 | query.toggle(); 123 | } else { 124 | query.setSearch(""); 125 | query.setCurrentRootAction(item.id); 126 | } 127 | options.callbacks?.onSelectAction?.(item); 128 | }, 129 | [query, options] 130 | ); 131 | 132 | const pointerMoved = usePointerMovedSinceMount(); 133 | 134 | return ( 135 |
143 |
151 | {rowVirtualizer.virtualItems.map((virtualRow) => { 152 | const item = itemsRef.current[virtualRow.index]; 153 | const handlers = typeof item !== "string" && { 154 | onPointerMove: () => 155 | pointerMoved && 156 | activeIndex !== virtualRow.index && 157 | query.setActiveIndex(virtualRow.index), 158 | onPointerDown: () => query.setActiveIndex(virtualRow.index), 159 | onClick: () => execute(item), 160 | }; 161 | const active = virtualRow.index === activeIndex; 162 | 163 | return ( 164 |
179 | {React.cloneElement( 180 | props.onRender({ 181 | item, 182 | active, 183 | }), 184 | { 185 | ref: virtualRow.measureRef, 186 | } 187 | )} 188 |
189 | ); 190 | })} 191 |
192 |
193 | ); 194 | }; 195 | -------------------------------------------------------------------------------- /src/KBarSearch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { VisualState } from "./types"; 3 | import { useKBar } from "./useKBar"; 4 | 5 | export const KBAR_LISTBOX = "kbar-listbox"; 6 | export const getListboxItemId = (id: number) => `kbar-listbox-item-${id}`; 7 | 8 | export function KBarSearch( 9 | props: React.InputHTMLAttributes & { 10 | defaultPlaceholder?: string; 11 | } 12 | ) { 13 | const { 14 | query, 15 | search, 16 | actions, 17 | currentRootActionId, 18 | activeIndex, 19 | showing, 20 | options, 21 | } = useKBar((state) => ({ 22 | search: state.searchQuery, 23 | currentRootActionId: state.currentRootActionId, 24 | actions: state.actions, 25 | activeIndex: state.activeIndex, 26 | showing: state.visualState === VisualState.showing, 27 | })); 28 | 29 | const [inputValue, setInputValue] = React.useState(search); 30 | React.useEffect(() => { 31 | query.setSearch(inputValue); 32 | }, [inputValue, query]); 33 | 34 | const { defaultPlaceholder, ...rest } = props; 35 | 36 | React.useEffect(() => { 37 | query.setSearch(""); 38 | query.getInput().focus(); 39 | return () => query.setSearch(""); 40 | }, [currentRootActionId, query]); 41 | 42 | const placeholder = React.useMemo((): string => { 43 | const defaultText = defaultPlaceholder ?? "Type a command or search…"; 44 | return currentRootActionId && actions[currentRootActionId] 45 | ? actions[currentRootActionId].name 46 | : defaultText; 47 | }, [actions, currentRootActionId, defaultPlaceholder]); 48 | 49 | return ( 50 | { 63 | props.onChange?.(event); 64 | setInputValue(event.target.value); 65 | options?.callbacks?.onQueryChange?.(event.target.value); 66 | }} 67 | onKeyDown={(event) => { 68 | props.onKeyDown?.(event); 69 | if (currentRootActionId && !search && event.key === "Backspace") { 70 | const parent = actions[currentRootActionId].parent; 71 | query.setCurrentRootAction(parent); 72 | } 73 | }} 74 | /> 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/__tests__/ActionImpl.test.ts: -------------------------------------------------------------------------------- 1 | import { ActionImpl } from "../action"; 2 | import { Action } from "../types"; 3 | import { createAction } from "../utils"; 4 | 5 | const perform = jest.fn(); 6 | const baseAction: Action = createAction({ 7 | name: "Test action", 8 | perform, 9 | }); 10 | 11 | const store = {}; 12 | 13 | describe("ActionImpl", () => { 14 | it("should create an instance of ActionImpl", () => { 15 | const action = ActionImpl.create(createAction(baseAction), { 16 | store, 17 | }); 18 | expect(action instanceof ActionImpl).toBe(true); 19 | }); 20 | 21 | it("should be able to add children", () => { 22 | const parent = ActionImpl.create(createAction({ name: "parent" }), { 23 | store: {}, 24 | }); 25 | 26 | expect(parent.children).toEqual([]); 27 | 28 | const child = ActionImpl.create( 29 | createAction({ name: "child", parent: parent.id }), 30 | { 31 | store: { 32 | [parent.id]: parent, 33 | }, 34 | } 35 | ); 36 | 37 | expect(parent.children[0]).toEqual(child); 38 | }); 39 | 40 | it("should be able to get children", () => { 41 | const parent = ActionImpl.create(createAction({ name: "parent" }), { 42 | store: {}, 43 | }); 44 | const child = ActionImpl.create( 45 | createAction({ name: "child", parent: parent.id }), 46 | { 47 | store: { 48 | [parent.id]: parent, 49 | }, 50 | } 51 | ); 52 | const grandchild = ActionImpl.create( 53 | createAction({ name: "grandchild", parent: child.id }), 54 | { 55 | store: { 56 | [parent.id]: parent, 57 | [child.id]: child, 58 | }, 59 | } 60 | ); 61 | 62 | expect(parent.children.length).toEqual(1); 63 | expect(child.children.length).toEqual(1); 64 | expect(grandchild.children.length).toEqual(0); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/__tests__/ActionInterface.test.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from "../utils"; 2 | import { ActionInterface } from "../action/ActionInterface"; 3 | 4 | const parent = createAction({ 5 | name: "parent", 6 | }); 7 | const parent2 = createAction({ 8 | name: "parent2", 9 | }); 10 | const child = createAction({ 11 | name: "child", 12 | parent: parent.id, 13 | }); 14 | const grandchild = createAction({ 15 | name: "grandchild", 16 | parent: child.id, 17 | }); 18 | 19 | const dummyActions = [parent, parent2, child, grandchild]; 20 | 21 | describe("ActionInterface", () => { 22 | let actionInterface: ActionInterface; 23 | beforeEach(() => { 24 | actionInterface = new ActionInterface(); 25 | }); 26 | 27 | it("throws an error when children are register before parents", () => { 28 | const bad = [grandchild, child, parent]; 29 | expect(() => actionInterface.add(bad)).toThrow(); 30 | }); 31 | 32 | it("sets actions internally", () => { 33 | const actions = actionInterface.add(dummyActions); 34 | expect(Object.keys(actionInterface.actions).length).toEqual( 35 | dummyActions.length 36 | ); 37 | expect(actionInterface.actions).toEqual(actions); 38 | }); 39 | 40 | it("removes actions and their respective children", () => { 41 | actionInterface.add(dummyActions); 42 | actionInterface.remove([parent]); 43 | expect(Object.keys(actionInterface.actions).length).toEqual(1); 44 | expect(Object.keys(actionInterface.actions)[0]).toEqual(parent2.id); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/__tests__/Command.test.ts: -------------------------------------------------------------------------------- 1 | import { ActionImpl } from ".."; 2 | import { Action } from "../types"; 3 | import { createAction } from "../utils"; 4 | import { Command } from "../action/Command"; 5 | import { history } from "../action/HistoryImpl"; 6 | 7 | const negate = jest.fn(); 8 | const perform = jest.fn().mockReturnValue(negate); 9 | const baseAction: Action = createAction({ 10 | name: "Test action", 11 | perform, 12 | }); 13 | 14 | const anotherAction: Action = createAction({ 15 | name: "Test action 2", 16 | perform, 17 | }); 18 | 19 | const store = {}; 20 | 21 | describe("Command", () => { 22 | let actionImpl: ActionImpl; 23 | let actionImpl2: ActionImpl; 24 | 25 | beforeEach(() => { 26 | [actionImpl, actionImpl2] = [baseAction, anotherAction].map((action) => 27 | ActionImpl.create(createAction(action), { 28 | store, 29 | history, 30 | }) 31 | ); 32 | }); 33 | 34 | it("should create an instance of Command", () => { 35 | expect(actionImpl.command instanceof Command).toBe(true); 36 | expect(actionImpl2.command instanceof Command).toBe(true); 37 | }); 38 | 39 | describe("History", () => { 40 | afterEach(() => { 41 | history.reset(); 42 | }); 43 | it("should properly interface with History", () => { 44 | expect(history.undoStack.length).toEqual(0); 45 | actionImpl.command?.perform(); 46 | expect(history.undoStack.length).toEqual(1); 47 | actionImpl.command?.history?.undo(); 48 | actionImpl.command?.history?.undo(); 49 | actionImpl.command?.history?.undo(); 50 | actionImpl.command?.history?.undo(); 51 | expect(history.undoStack.length).toEqual(0); 52 | expect(history.redoStack.length).toEqual(1); 53 | }); 54 | it("should only register a single history record for each action", () => { 55 | actionImpl.command?.perform(); 56 | actionImpl.command?.perform(); 57 | actionImpl2.command?.perform(); 58 | actionImpl2.command?.perform(); 59 | expect(history.undoStack.length).toEqual(2); 60 | }); 61 | it("should undo/redo specific actions, not just at the top of the history stack", () => { 62 | expect(history.undoStack.length).toEqual(0); 63 | actionImpl.command?.perform(); 64 | actionImpl2.command?.perform(); 65 | 66 | actionImpl.command?.history?.undo(); 67 | // @ts-ignore historyItem is private, but using for purposes of testing equality 68 | expect(history.undoStack[0]).toEqual(actionImpl2.command?.historyItem); 69 | // @ts-ignore 70 | expect(history.redoStack[0]).toEqual(actionImpl.command?.historyItem); 71 | }); 72 | it("should place redo actions back in the undo stack if action was re-perform", () => { 73 | actionImpl.command?.perform(); 74 | actionImpl.command?.history?.undo(); 75 | expect(history.undoStack.length).toEqual(0); 76 | actionImpl.command?.history?.redo(); 77 | expect(history.undoStack.length).toEqual(1); 78 | expect(history.redoStack.length).toEqual(0); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/__tests__/useMatches.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { useKBar } from "../useKBar"; 6 | import { KBarProvider } from "../KBarContextProvider"; 7 | import { render, fireEvent, RenderResult } from "@testing-library/react"; 8 | import * as React from "react"; 9 | import { createAction, Priority } from "../utils"; 10 | import { useMatches } from "../useMatches"; 11 | 12 | jest.mock("../utils", () => { 13 | return { 14 | ...jest.requireActual("../utils"), 15 | // Mock out throttling as we don't need it in our test environment. 16 | useThrottledValue: (value) => value, 17 | }; 18 | }); 19 | 20 | function Search() { 21 | const { search, query } = useKBar((state) => ({ 22 | search: state.searchQuery, 23 | })); 24 | 25 | return ( 26 | query.setSearch(e.target.value)} 30 | /> 31 | ); 32 | } 33 | 34 | function Results() { 35 | const { results } = useMatches(); 36 | 37 | return ( 38 |
    39 | {results.map((result) => 40 | typeof result === "string" ? ( 41 |
  • {result}
  • 42 | ) : ( 43 |
  • {result.name}
  • 44 | ) 45 | )} 46 |
47 | ); 48 | } 49 | 50 | function BasicComponent() { 51 | const action1 = createAction({ name: "Action 1" }); 52 | const action2 = createAction({ name: "Action 2" }); 53 | const action3 = createAction({ name: "Action 3" }); 54 | const childAction1 = createAction({ 55 | name: "Child Action 1", 56 | parent: action1.id, 57 | }); 58 | 59 | return ( 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | function WithPriorityComponent() { 68 | const action1 = createAction({ name: "Action 1", priority: Priority.LOW }); 69 | const action2 = createAction({ name: "Action 2", priority: Priority.HIGH }); 70 | const action3 = createAction({ name: "Action 3", priority: Priority.HIGH }); 71 | const action4 = createAction({ 72 | name: "Action 4", 73 | priority: Priority.HIGH, 74 | section: { 75 | name: "Section 1", 76 | priority: Priority.HIGH, 77 | }, 78 | }); 79 | const childAction1 = createAction({ 80 | name: "Child Action 1", 81 | parent: action1.id, 82 | }); 83 | 84 | return ( 85 | 86 | 87 | 88 | 89 | ); 90 | } 91 | 92 | function WithLongNamesComponent() { 93 | const action1 = createAction({ 94 | name: "Action: This is a long name ending by toto", 95 | }); 96 | const action2 = createAction({ 97 | name: "Action: This is a long name also ending by toto", 98 | }); 99 | const action3 = createAction({ 100 | name: "Action: This is a long name ending by titi", 101 | }); 102 | 103 | return ( 104 | 105 | 106 | 107 | 108 | ); 109 | } 110 | 111 | const setup = (Component: React.ComponentType) => { 112 | const utils = render(); 113 | const input = utils.getByLabelText("search-input"); 114 | return { 115 | input, 116 | ...utils, 117 | } as Utils; 118 | }; 119 | 120 | type Utils = RenderResult & { input: HTMLInputElement }; 121 | 122 | describe("useMatches", () => { 123 | describe("Basic", () => { 124 | let utils: Utils; 125 | beforeEach(() => { 126 | utils = setup(BasicComponent); 127 | }); 128 | 129 | it("returns root results with an empty search query", () => { 130 | const results = utils.getAllByText(/Action/i); 131 | expect(results.length).toEqual(3); 132 | expect(results[0].textContent).toEqual("Action 1"); 133 | expect(results[1].textContent).toEqual("Action 2"); 134 | expect(results[2].textContent).toEqual("Action 3"); 135 | }); 136 | 137 | it("returns nested results when search query is present", () => { 138 | const { input } = utils; 139 | fireEvent.change(input, { target: { value: "1" } }); 140 | const results = utils.getAllByText(/Action/i); 141 | expect(results.length).toEqual(2); 142 | expect(results[0].textContent).toEqual("Action 1"); 143 | expect(results[1].textContent).toEqual("Child Action 1"); 144 | }); 145 | }); 146 | 147 | describe("With priority", () => { 148 | let utils: Utils; 149 | beforeEach(() => { 150 | utils = setup(WithPriorityComponent); 151 | }); 152 | 153 | it("returns a prioritized list", () => { 154 | const results = utils.getAllByText(/Action/i); 155 | expect(results.length).toEqual(4); 156 | 157 | expect(results[0].textContent).toEqual("Action 4"); 158 | expect(results[1].textContent).toEqual("Action 2"); 159 | expect(results[2].textContent).toEqual("Action 3"); 160 | expect(results[3].textContent).toEqual("Action 1"); 161 | 162 | expect(utils.queryAllByText(/Section 1/i)); 163 | }); 164 | }); 165 | describe("With long names", () => { 166 | let utils: Utils; 167 | beforeEach(() => { 168 | utils = setup(WithLongNamesComponent); 169 | }); 170 | 171 | it("returns result matching the query even if match is on a word far in the name", () => { 172 | const { input } = utils; 173 | fireEvent.change(input, { target: { value: "toto" } }); 174 | const results = utils.getAllByText(/Action/i); 175 | expect(results.length).toEqual(2); 176 | expect(results[0].textContent).toEqual( 177 | "Action: This is a long name ending by toto" 178 | ); 179 | expect(results[1].textContent).toEqual( 180 | "Action: This is a long name also ending by toto" 181 | ); 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /src/action/ActionImpl.ts: -------------------------------------------------------------------------------- 1 | import invariant from "tiny-invariant"; 2 | import { Command } from "./Command"; 3 | import type { Action, ActionStore, History } from "../types"; 4 | import { Priority } from "../utils"; 5 | 6 | interface ActionImplOptions { 7 | store: ActionStore; 8 | ancestors?: ActionImpl[]; 9 | history?: History; 10 | } 11 | 12 | /** 13 | * Extends the configured keywords to include the section 14 | * This allows section names to be searched for. 15 | */ 16 | const extendKeywords = ({ keywords = "", section = "" }: Action): string => { 17 | return `${keywords} ${ 18 | typeof section === "string" ? section : section.name 19 | }`.trim(); 20 | }; 21 | 22 | export class ActionImpl implements Action { 23 | id: Action["id"]; 24 | name: Action["name"]; 25 | shortcut: Action["shortcut"]; 26 | keywords: Action["keywords"]; 27 | section: Action["section"]; 28 | icon: Action["icon"]; 29 | subtitle: Action["subtitle"]; 30 | parent?: Action["parent"]; 31 | /** 32 | * @deprecated use action.command.perform 33 | */ 34 | perform: Action["perform"]; 35 | priority: number = Priority.NORMAL; 36 | 37 | command?: Command; 38 | 39 | ancestors: ActionImpl[] = []; 40 | children: ActionImpl[] = []; 41 | 42 | constructor(action: Action, options: ActionImplOptions) { 43 | Object.assign(this, action); 44 | this.id = action.id; 45 | this.name = action.name; 46 | this.keywords = extendKeywords(action); 47 | const perform = action.perform; 48 | this.command = 49 | perform && 50 | new Command( 51 | { 52 | perform: () => perform(this), 53 | }, 54 | { 55 | history: options.history, 56 | } 57 | ); 58 | // Backwards compatibility 59 | this.perform = this.command?.perform; 60 | 61 | if (action.parent) { 62 | const parentActionImpl = options.store[action.parent]; 63 | invariant( 64 | parentActionImpl, 65 | `attempted to create an action whos parent: ${action.parent} does not exist in the store.` 66 | ); 67 | parentActionImpl.addChild(this); 68 | } 69 | } 70 | 71 | addChild(childActionImpl: ActionImpl) { 72 | // add all ancestors for the child action 73 | childActionImpl.ancestors.unshift(this); 74 | let parent = this.parentActionImpl; 75 | while (parent) { 76 | childActionImpl.ancestors.unshift(parent); 77 | parent = parent.parentActionImpl; 78 | } 79 | // we ensure that order of adding always goes 80 | // parent -> children, so no need to recurse 81 | this.children.push(childActionImpl); 82 | } 83 | 84 | removeChild(actionImpl: ActionImpl) { 85 | // recursively remove all children 86 | const index = this.children.indexOf(actionImpl); 87 | if (index !== -1) { 88 | this.children.splice(index, 1); 89 | } 90 | if (actionImpl.children) { 91 | actionImpl.children.forEach((child) => { 92 | this.removeChild(child); 93 | }); 94 | } 95 | } 96 | 97 | // easily access parentActionImpl after creation 98 | get parentActionImpl() { 99 | return this.ancestors[this.ancestors.length - 1]; 100 | } 101 | 102 | static create(action: Action, options: ActionImplOptions) { 103 | return new ActionImpl(action, options); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/action/ActionInterface.ts: -------------------------------------------------------------------------------- 1 | import invariant from "tiny-invariant"; 2 | import type { ActionId, Action, History } from "../types"; 3 | import { ActionImpl } from "./ActionImpl"; 4 | 5 | interface ActionInterfaceOptions { 6 | historyManager?: History; 7 | } 8 | export class ActionInterface { 9 | actions: Record = {}; 10 | options: ActionInterfaceOptions; 11 | 12 | constructor(actions: Action[] = [], options: ActionInterfaceOptions = {}) { 13 | this.options = options; 14 | this.add(actions); 15 | } 16 | 17 | add(actions: Action[]) { 18 | for (let i = 0; i < actions.length; i++) { 19 | const action = actions[i]; 20 | if (action.parent) { 21 | invariant( 22 | this.actions[action.parent], 23 | `Attempted to create action "${action.name}" without registering its parent "${action.parent}" first.` 24 | ); 25 | } 26 | this.actions[action.id] = ActionImpl.create(action, { 27 | history: this.options.historyManager, 28 | store: this.actions, 29 | }); 30 | } 31 | 32 | return { ...this.actions }; 33 | } 34 | 35 | remove(actions: Action[]) { 36 | actions.forEach((action) => { 37 | const actionImpl = this.actions[action.id]; 38 | if (!actionImpl) return; 39 | let children = actionImpl.children; 40 | while (children.length) { 41 | let child = children.pop(); 42 | if (!child) return; 43 | delete this.actions[child.id]; 44 | if (child.parentActionImpl) child.parentActionImpl.removeChild(child); 45 | if (child.children) children.push(...child.children); 46 | } 47 | if (actionImpl.parentActionImpl) { 48 | actionImpl.parentActionImpl.removeChild(actionImpl); 49 | } 50 | delete this.actions[action.id]; 51 | }); 52 | 53 | return { ...this.actions }; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/action/Command.ts: -------------------------------------------------------------------------------- 1 | import { HistoryItem } from ".."; 2 | import type { History } from "../types"; 3 | 4 | interface CommandOptions { 5 | history?: History; 6 | } 7 | export class Command { 8 | perform: (...args: any) => any; 9 | 10 | private historyItem?: HistoryItem; 11 | 12 | history?: { 13 | undo: History["undo"]; 14 | redo: History["redo"]; 15 | }; 16 | 17 | constructor( 18 | command: { perform: Command["perform"] }, 19 | options: CommandOptions = {} 20 | ) { 21 | this.perform = () => { 22 | const negate = command.perform(); 23 | // no need for history if non negatable 24 | if (typeof negate !== "function") return; 25 | // return if no history enabled 26 | const history = options.history; 27 | if (!history) return; 28 | // since we are performing the same action, we'll clean up the 29 | // previous call to the action and create a new history record 30 | if (this.historyItem) { 31 | history.remove(this.historyItem); 32 | } 33 | this.historyItem = history.add({ 34 | perform: command.perform, 35 | negate, 36 | }); 37 | 38 | this.history = { 39 | undo: () => history.undo(this.historyItem), 40 | redo: () => history.redo(this.historyItem), 41 | }; 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/action/HistoryImpl.ts: -------------------------------------------------------------------------------- 1 | import type { History, HistoryItem } from "../types"; 2 | import { shouldRejectKeystrokes } from "../utils"; 3 | 4 | export class HistoryItemImpl implements HistoryItem { 5 | perform: HistoryItem["perform"]; 6 | negate: HistoryItem["negate"]; 7 | 8 | constructor(item: HistoryItem) { 9 | this.perform = item.perform; 10 | this.negate = item.negate; 11 | } 12 | 13 | static create(item: HistoryItem) { 14 | return new HistoryItemImpl(item); 15 | } 16 | } 17 | 18 | class HistoryImpl implements History { 19 | static instance: HistoryImpl; 20 | 21 | undoStack: HistoryItemImpl[] = []; 22 | redoStack: HistoryItemImpl[] = []; 23 | 24 | constructor() { 25 | if (!HistoryImpl.instance) { 26 | HistoryImpl.instance = this; 27 | this.init(); 28 | } 29 | return HistoryImpl.instance; 30 | } 31 | 32 | init() { 33 | if (typeof window === "undefined") return; 34 | 35 | window.addEventListener("keydown", (event) => { 36 | if ( 37 | (!this.redoStack.length && !this.undoStack.length) || 38 | shouldRejectKeystrokes() 39 | ) { 40 | return; 41 | } 42 | const key = event.key?.toLowerCase(); 43 | if (event.metaKey && key === "z" && event.shiftKey) { 44 | this.redo(); 45 | } else if (event.metaKey && key === "z") { 46 | this.undo(); 47 | } 48 | }); 49 | } 50 | 51 | add(item: HistoryItem) { 52 | const historyItem = HistoryItemImpl.create(item); 53 | this.undoStack.push(historyItem); 54 | return historyItem; 55 | } 56 | 57 | remove(item: HistoryItem) { 58 | const undoIndex = this.undoStack.findIndex((i) => i === item); 59 | if (undoIndex !== -1) { 60 | this.undoStack.splice(undoIndex, 1); 61 | return; 62 | } 63 | const redoIndex = this.redoStack.findIndex((i) => i === item); 64 | if (redoIndex !== -1) { 65 | this.redoStack.splice(redoIndex, 1); 66 | } 67 | } 68 | 69 | undo(item?: HistoryItem) { 70 | // if not undoing a specific item, just undo the latest 71 | if (!item) { 72 | const item = this.undoStack.pop(); 73 | if (!item) return; 74 | item?.negate(); 75 | this.redoStack.push(item); 76 | return item; 77 | } 78 | // else undo the specific item 79 | const index = this.undoStack.findIndex((i) => i === item); 80 | if (index === -1) return; 81 | this.undoStack.splice(index, 1); 82 | item.negate(); 83 | this.redoStack.push(item); 84 | return item; 85 | } 86 | 87 | redo(item?: HistoryItem) { 88 | if (!item) { 89 | const item = this.redoStack.pop(); 90 | if (!item) return; 91 | item?.perform(); 92 | this.undoStack.push(item); 93 | return item; 94 | } 95 | const index = this.redoStack.findIndex((i) => i === item); 96 | if (index === -1) return; 97 | this.redoStack.splice(index, 1); 98 | item.perform(); 99 | this.undoStack.push(item); 100 | return item; 101 | } 102 | 103 | reset() { 104 | this.undoStack.splice(0); 105 | this.redoStack.splice(0); 106 | } 107 | } 108 | 109 | const history = new HistoryImpl(); 110 | Object.freeze(history); 111 | export { history }; 112 | -------------------------------------------------------------------------------- /src/action/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./ActionInterface"; 2 | export * from "./ActionImpl"; 3 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createAction, Priority } from "./utils"; 2 | export { createAction, Priority }; 3 | 4 | export * from "./useMatches"; 5 | export * from "./KBarPortal"; 6 | export * from "./KBarPositioner"; 7 | export * from "./KBarSearch"; 8 | export * from "./KBarResults"; 9 | export * from "./useKBar"; 10 | export * from "./useRegisterActions"; 11 | export * from "./KBarContextProvider"; 12 | export * from "./KBarAnimator"; 13 | export * from "./types"; 14 | export * from "./action"; 15 | -------------------------------------------------------------------------------- /src/tinykeys.ts: -------------------------------------------------------------------------------- 1 | // Fixes special character issues; `?` -> `shift+/` + build issue 2 | // https://github.com/jamiebuilds/tinykeys 3 | 4 | type KeyBindingPress = [string[], string]; 5 | 6 | /** 7 | * A map of keybinding strings to event handlers. 8 | */ 9 | export interface KeyBindingMap { 10 | [keybinding: string]: (event: KeyboardEvent) => void; 11 | } 12 | 13 | /** 14 | * Options to configure the behavior of keybindings. 15 | */ 16 | export interface KeyBindingOptions { 17 | /** 18 | * Key presses will listen to this event (default: "keydown"). 19 | */ 20 | event?: "keydown" | "keyup"; 21 | 22 | /** 23 | * Keybinding sequences will wait this long between key presses before 24 | * cancelling (default: 1000). 25 | * 26 | * **Note:** Setting this value too low (i.e. `300`) will be too fast for many 27 | * of your users. 28 | */ 29 | timeout?: number; 30 | } 31 | 32 | /** 33 | * These are the modifier keys that change the meaning of keybindings. 34 | * 35 | * Note: Ignoring "AltGraph" because it is covered by the others. 36 | */ 37 | let KEYBINDING_MODIFIER_KEYS = ["Shift", "Meta", "Alt", "Control"]; 38 | 39 | /** 40 | * Keybinding sequences should timeout if individual key presses are more than 41 | * 1s apart by default. 42 | */ 43 | let DEFAULT_TIMEOUT = 1000; 44 | 45 | /** 46 | * Keybinding sequences should bind to this event by default. 47 | */ 48 | let DEFAULT_EVENT = "keydown"; 49 | 50 | /** 51 | * An alias for creating platform-specific keybinding aliases. 52 | */ 53 | let MOD = 54 | typeof navigator === "object" && 55 | /Mac|iPod|iPhone|iPad/.test(navigator.platform) 56 | ? "Meta" 57 | : "Control"; 58 | 59 | /** 60 | * There's a bug in Chrome that causes event.getModifierState not to exist on 61 | * KeyboardEvent's for F1/F2/etc keys. 62 | */ 63 | function getModifierState(event: KeyboardEvent, mod: string) { 64 | return typeof event.getModifierState === "function" 65 | ? event.getModifierState(mod) 66 | : false; 67 | } 68 | 69 | /** 70 | * Parses a "Key Binding String" into its parts 71 | * 72 | * grammar = `` 73 | * = ` ...` 74 | * = `` or `+` 75 | * = `++...` 76 | */ 77 | function parse(str: string): KeyBindingPress[] { 78 | return str 79 | .trim() 80 | .split(" ") 81 | .map((press) => { 82 | let mods = press.split(/\b\+/); 83 | let key = mods.pop() as string; 84 | mods = mods.map((mod) => (mod === "$mod" ? MOD : mod)); 85 | return [mods, key]; 86 | }); 87 | } 88 | 89 | /** 90 | * This tells us if a series of events matches a key binding sequence either 91 | * partially or exactly. 92 | */ 93 | function match(event: KeyboardEvent, press: KeyBindingPress): boolean { 94 | // Special characters; `?` `!` 95 | if (/^[^A-Za-z0-9]$/.test(event.key) && press[1] === event.key) { 96 | return true; 97 | } 98 | 99 | // prettier-ignore 100 | return !( 101 | // Allow either the `event.key` or the `event.code` 102 | // MDN event.key: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key 103 | // MDN event.code: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code 104 | ( 105 | press[1].toUpperCase() !== event.key.toUpperCase() && 106 | press[1] !== event.code 107 | ) || 108 | 109 | // Ensure all the modifiers in the keybinding are pressed. 110 | press[0].find(mod => { 111 | return !getModifierState(event, mod) 112 | }) || 113 | 114 | // KEYBINDING_MODIFIER_KEYS (Shift/Control/etc) change the meaning of a 115 | // keybinding. So if they are pressed but aren't part of the current 116 | // keybinding press, then we don't have a match. 117 | KEYBINDING_MODIFIER_KEYS.find(mod => { 118 | return !press[0].includes(mod) && press[1] !== mod && getModifierState(event, mod) 119 | }) 120 | ) 121 | } 122 | 123 | /** 124 | * Subscribes to keybindings. 125 | * 126 | * Returns an unsubscribe method. 127 | * 128 | * @example 129 | * ```js 130 | * import keybindings from "../src/keybindings" 131 | * 132 | * keybindings(window, { 133 | * "Shift+d": () => { 134 | * alert("The 'Shift' and 'd' keys were pressed at the same time") 135 | * }, 136 | * "y e e t": () => { 137 | * alert("The keys 'y', 'e', 'e', and 't' were pressed in order") 138 | * }, 139 | * "$mod+d": () => { 140 | * alert("Either 'Control+d' or 'Meta+d' were pressed") 141 | * }, 142 | * }) 143 | * ``` 144 | */ 145 | export default function keybindings( 146 | target: Window | HTMLElement, 147 | keyBindingMap: KeyBindingMap, 148 | options: KeyBindingOptions = {} 149 | ): () => void { 150 | let timeout = options.timeout ?? DEFAULT_TIMEOUT; 151 | let event = options.event ?? DEFAULT_EVENT; 152 | 153 | let keyBindings = Object.keys(keyBindingMap).map((key) => { 154 | return [parse(key), keyBindingMap[key]] as const; 155 | }); 156 | 157 | let possibleMatches = new Map(); 158 | let timer: number | null = null; 159 | 160 | let onKeyEvent: EventListener = (event) => { 161 | // Ensure and stop any event that isn't a full keyboard event. 162 | // Autocomplete option navigation and selection would fire a instanceof Event, 163 | // instead of the expected KeyboardEvent 164 | if (!(event instanceof KeyboardEvent)) { 165 | return; 166 | } 167 | 168 | keyBindings.forEach((keyBinding) => { 169 | let sequence = keyBinding[0]; 170 | let callback = keyBinding[1]; 171 | 172 | let prev = possibleMatches.get(sequence); 173 | let remainingExpectedPresses = prev ? prev : sequence; 174 | let currentExpectedPress = remainingExpectedPresses[0]; 175 | 176 | let matches = match(event, currentExpectedPress); 177 | 178 | if (!matches) { 179 | // Modifier keydown events shouldn't break sequences 180 | // Note: This works because: 181 | // - non-modifiers will always return false 182 | // - if the current keypress is a modifier then it will return true when we check its state 183 | // MDN: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState 184 | if (!getModifierState(event, event.key)) { 185 | possibleMatches.delete(sequence); 186 | } 187 | } else if (remainingExpectedPresses.length > 1) { 188 | possibleMatches.set(sequence, remainingExpectedPresses.slice(1)); 189 | } else { 190 | possibleMatches.delete(sequence); 191 | callback(event); 192 | } 193 | }); 194 | 195 | if (timer) { 196 | clearTimeout(timer); 197 | } 198 | 199 | // @ts-ignore 200 | timer = setTimeout(possibleMatches.clear.bind(possibleMatches), timeout); 201 | }; 202 | 203 | target.addEventListener(event, onKeyEvent); 204 | 205 | return () => { 206 | target.removeEventListener(event, onKeyEvent); 207 | }; 208 | } 209 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ActionImpl } from "./action/ActionImpl"; 3 | 4 | export type ActionId = string; 5 | 6 | export type Priority = number; 7 | 8 | export type ActionSection = 9 | | string 10 | | { 11 | name: string; 12 | priority: Priority; 13 | }; 14 | 15 | export type Action = { 16 | id: ActionId; 17 | name: string; 18 | shortcut?: string[]; 19 | keywords?: string; 20 | section?: ActionSection; 21 | icon?: string | React.ReactElement | React.ReactNode; 22 | subtitle?: string; 23 | perform?: (currentActionImpl: ActionImpl) => any; 24 | parent?: ActionId; 25 | priority?: Priority; 26 | }; 27 | 28 | export type ActionStore = Record; 29 | 30 | export type ActionTree = Record; 31 | 32 | export interface ActionGroup { 33 | name: string; 34 | actions: ActionImpl[]; 35 | } 36 | 37 | export interface KBarOptions { 38 | animations?: { 39 | enterMs?: number; 40 | exitMs?: number; 41 | }; 42 | callbacks?: { 43 | onOpen?: () => void; 44 | onClose?: () => void; 45 | onQueryChange?: (searchQuery: string) => void; 46 | onSelectAction?: (action: ActionImpl) => void; 47 | }; 48 | /** 49 | * `disableScrollBarManagement` ensures that kbar will not 50 | * manipulate the document's `margin-right` property when open. 51 | * By default, kbar will add additional margin to the document 52 | * body when opened in order to prevent any layout shift with 53 | * the appearance/disappearance of the scrollbar. 54 | */ 55 | disableScrollbarManagement?: boolean; 56 | /** 57 | * `disableDocumentLock` disables the "document lock" functionality 58 | * of kbar, where the body element's scrollbar is hidden and pointer 59 | * events are disabled when kbar is open. This is useful if you're using 60 | * a custom modal component that has its own implementation of this 61 | * functionality. 62 | */ 63 | disableDocumentLock?: boolean; 64 | enableHistory?: boolean; 65 | /** 66 | * `toggleShortcut` enables customizing which keyboard shortcut triggers 67 | * kbar. Defaults to "$mod+k" (cmd+k / ctrl+k) 68 | */ 69 | toggleShortcut?: string; 70 | } 71 | 72 | export interface KBarProviderProps { 73 | actions?: Action[]; 74 | options?: KBarOptions; 75 | } 76 | 77 | export interface KBarState { 78 | searchQuery: string; 79 | visualState: VisualState; 80 | actions: ActionTree; 81 | currentRootActionId?: ActionId | null; 82 | activeIndex: number; 83 | disabled: boolean; 84 | } 85 | 86 | export interface KBarQuery { 87 | setCurrentRootAction: (actionId?: ActionId | null) => void; 88 | setVisualState: ( 89 | cb: ((vs: VisualState) => VisualState) | VisualState 90 | ) => void; 91 | setSearch: (search: string) => void; 92 | registerActions: (actions: Action[]) => () => void; 93 | toggle: () => void; 94 | setActiveIndex: (cb: number | ((currIndex: number) => number)) => void; 95 | inputRefSetter: (el: HTMLInputElement) => void; 96 | getInput: () => HTMLInputElement; 97 | disable: (disable: boolean) => void; 98 | } 99 | 100 | export interface IKBarContext { 101 | getState: () => KBarState; 102 | query: KBarQuery; 103 | subscribe: ( 104 | collector: (state: KBarState) => C, 105 | cb: (collected: C) => void 106 | ) => void; 107 | options: KBarOptions; 108 | } 109 | 110 | export enum VisualState { 111 | animatingIn = "animating-in", 112 | showing = "showing", 113 | animatingOut = "animating-out", 114 | hidden = "hidden", 115 | } 116 | 117 | export interface HistoryItem { 118 | perform: () => any; 119 | negate: () => any; 120 | } 121 | 122 | export interface History { 123 | undoStack: HistoryItem[]; 124 | redoStack: HistoryItem[]; 125 | add: (item: HistoryItem) => HistoryItem; 126 | remove: (item: HistoryItem) => void; 127 | undo: (item?: HistoryItem) => void; 128 | redo: (item?: HistoryItem) => void; 129 | reset: () => void; 130 | } 131 | -------------------------------------------------------------------------------- /src/useKBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { KBarContext } from "./KBarContextProvider"; 3 | import type { KBarOptions, KBarQuery, KBarState } from "./types"; 4 | 5 | interface BaseKBarReturnType { 6 | query: KBarQuery; 7 | options: KBarOptions; 8 | } 9 | 10 | type useKBarReturnType = S extends null 11 | ? BaseKBarReturnType 12 | : S & BaseKBarReturnType; 13 | 14 | export function useKBar( 15 | collector?: (state: KBarState) => C 16 | ): useKBarReturnType { 17 | const { query, getState, subscribe, options } = React.useContext(KBarContext); 18 | 19 | const collected = React.useRef(collector?.(getState())); 20 | const collectorRef = React.useRef(collector); 21 | 22 | const onCollect = React.useCallback( 23 | (collected: any) => ({ 24 | ...collected, 25 | query, 26 | options, 27 | }), 28 | [query, options] 29 | ); 30 | 31 | const [render, setRender] = React.useState(onCollect(collected.current)); 32 | 33 | React.useEffect(() => { 34 | let unsubscribe; 35 | if (collectorRef.current) { 36 | unsubscribe = subscribe( 37 | (current) => (collectorRef.current as any)(current), 38 | (collected) => setRender(onCollect(collected)) 39 | ); 40 | } 41 | return () => { 42 | if (unsubscribe) { 43 | unsubscribe(); 44 | } 45 | }; 46 | }, [onCollect, subscribe]); 47 | 48 | return render; 49 | } 50 | -------------------------------------------------------------------------------- /src/useMatches.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { ActionImpl } from "./action/ActionImpl"; 3 | import { useKBar } from "./useKBar"; 4 | import { Priority, useThrottledValue } from "./utils"; 5 | import Fuse from "fuse.js"; 6 | 7 | export const NO_GROUP = { 8 | name: "none", 9 | priority: Priority.NORMAL, 10 | }; 11 | 12 | const fuseOptions: Fuse.IFuseOptions = { 13 | keys: [ 14 | { 15 | name: "name", 16 | weight: 0.5, 17 | }, 18 | { 19 | name: "keywords", 20 | getFn: (item) => (item.keywords ?? "").split(","), 21 | weight: 0.5, 22 | }, 23 | "subtitle", 24 | ], 25 | ignoreLocation: true, 26 | includeScore: true, 27 | includeMatches: true, 28 | threshold: 0.2, 29 | minMatchCharLength: 1, 30 | }; 31 | 32 | function order(a, b) { 33 | /** 34 | * Larger the priority = higher up the list 35 | */ 36 | return b.priority - a.priority; 37 | } 38 | 39 | type SectionName = string; 40 | 41 | /** 42 | * returns deep matches only when a search query is present 43 | */ 44 | export function useMatches() { 45 | const { search, actions, rootActionId } = useKBar((state) => ({ 46 | search: state.searchQuery, 47 | actions: state.actions, 48 | rootActionId: state.currentRootActionId, 49 | })); 50 | 51 | const rootResults = React.useMemo(() => { 52 | return Object.keys(actions) 53 | .reduce((acc, actionId) => { 54 | const action = actions[actionId]; 55 | if (!action.parent && !rootActionId) { 56 | acc.push(action); 57 | } 58 | if (action.id === rootActionId) { 59 | for (let i = 0; i < action.children.length; i++) { 60 | acc.push(action.children[i]); 61 | } 62 | } 63 | return acc; 64 | }, [] as ActionImpl[]) 65 | .sort(order); 66 | }, [actions, rootActionId]); 67 | 68 | const getDeepResults = React.useCallback((actions: ActionImpl[]) => { 69 | let actionsClone: ActionImpl[] = []; 70 | for (let i = 0; i < actions.length; i++) { 71 | actionsClone.push(actions[i]); 72 | } 73 | return (function collectChildren( 74 | actions: ActionImpl[], 75 | all = actionsClone 76 | ) { 77 | for (let i = 0; i < actions.length; i++) { 78 | if (actions[i].children.length > 0) { 79 | let childsChildren = actions[i].children; 80 | for (let i = 0; i < childsChildren.length; i++) { 81 | all.push(childsChildren[i]); 82 | } 83 | collectChildren(actions[i].children, all); 84 | } 85 | } 86 | return all; 87 | })(actions); 88 | }, []); 89 | 90 | const emptySearch = !search; 91 | 92 | const filtered = React.useMemo(() => { 93 | if (emptySearch) return rootResults; 94 | return getDeepResults(rootResults); 95 | }, [getDeepResults, rootResults, emptySearch]); 96 | 97 | const fuse = React.useMemo(() => new Fuse(filtered, fuseOptions), [filtered]); 98 | 99 | const matches = useInternalMatches(filtered, search, fuse); 100 | 101 | const results = React.useMemo(() => { 102 | /** 103 | * Store a reference to a section and it's list of actions. 104 | * Alongside these actions, we'll keep a temporary record of the 105 | * final priority calculated by taking the commandScore + the 106 | * explicitly set `action.priority` value. 107 | */ 108 | let map: Record = 109 | {}; 110 | /** 111 | * Store another reference to a list of sections alongside 112 | * the section's final priority, calculated the same as above. 113 | */ 114 | let list: { priority: number; name: SectionName }[] = []; 115 | /** 116 | * We'll take the list above and sort by its priority. Then we'll 117 | * collect all actions from the map above for this specific name and 118 | * sort by its priority as well. 119 | */ 120 | let ordered: { name: SectionName; actions: ActionImpl[] }[] = []; 121 | 122 | for (let i = 0; i < matches.length; i++) { 123 | const match = matches[i]; 124 | const action = match.action; 125 | const score = match.score || Priority.NORMAL; 126 | 127 | const section = { 128 | name: 129 | typeof action.section === "string" 130 | ? action.section 131 | : action.section?.name || NO_GROUP.name, 132 | priority: 133 | typeof action.section === "string" 134 | ? score 135 | : action.section?.priority || 0 + score, 136 | }; 137 | 138 | if (!map[section.name]) { 139 | map[section.name] = []; 140 | list.push(section); 141 | } 142 | 143 | map[section.name].push({ 144 | priority: action.priority + score, 145 | action, 146 | }); 147 | } 148 | 149 | ordered = list.sort(order).map((group) => ({ 150 | name: group.name, 151 | actions: map[group.name].sort(order).map((item) => item.action), 152 | })); 153 | 154 | /** 155 | * Our final result is simply flattening the ordered list into 156 | * our familiar (ActionImpl | string)[] shape. 157 | */ 158 | let results: (string | ActionImpl)[] = []; 159 | for (let i = 0; i < ordered.length; i++) { 160 | let group = ordered[i]; 161 | if (group.name !== NO_GROUP.name) results.push(group.name); 162 | for (let i = 0; i < group.actions.length; i++) { 163 | results.push(group.actions[i]); 164 | } 165 | } 166 | return results; 167 | }, [matches]); 168 | 169 | // ensure that users have an accurate `currentRootActionId` 170 | // that syncs with the throttled return value. 171 | // eslint-disable-next-line react-hooks/exhaustive-deps 172 | const memoRootActionId = React.useMemo(() => rootActionId, [results]); 173 | 174 | return React.useMemo( 175 | () => ({ 176 | results, 177 | rootActionId: memoRootActionId, 178 | }), 179 | [memoRootActionId, results] 180 | ); 181 | } 182 | 183 | type Match = { 184 | action: ActionImpl; 185 | /** 186 | * Represents the commandScore matchiness value which we use 187 | * in addition to the explicitly set `action.priority` to 188 | * calculate a more fine tuned fuzzy search. 189 | */ 190 | score: number; 191 | }; 192 | 193 | function useInternalMatches( 194 | filtered: ActionImpl[], 195 | search: string, 196 | fuse: Fuse 197 | ) { 198 | const value = React.useMemo( 199 | () => ({ 200 | filtered, 201 | search, 202 | }), 203 | [filtered, search] 204 | ); 205 | 206 | const { filtered: throttledFiltered, search: throttledSearch } = 207 | useThrottledValue(value); 208 | 209 | return React.useMemo(() => { 210 | if (throttledSearch.trim() === "") { 211 | return throttledFiltered.map((action) => ({ score: 0, action })); 212 | } 213 | 214 | let matches: Match[] = []; 215 | // Use Fuse's `search` method to perform the search efficiently 216 | const searchResults = fuse.search(throttledSearch); 217 | // Format the search results to match the existing structure 218 | matches = searchResults.map(({ item: action, score }) => ({ 219 | score: 1 / ((score ?? 0) + 1), // Convert the Fuse score to the format used in the original code 220 | action, 221 | })); 222 | 223 | return matches; 224 | }, [throttledFiltered, throttledSearch, fuse]) as Match[]; 225 | } 226 | 227 | /** 228 | * @deprecated use useMatches 229 | */ 230 | export const useDeepMatches = useMatches; 231 | -------------------------------------------------------------------------------- /src/useRegisterActions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { Action } from "./types"; 3 | import { useKBar } from "./useKBar"; 4 | 5 | export function useRegisterActions( 6 | actions: Action[], 7 | dependencies: React.DependencyList = [] 8 | ) { 9 | const { query } = useKBar(); 10 | 11 | // eslint-disable-next-line react-hooks/exhaustive-deps 12 | const actionsCache = React.useMemo(() => actions, dependencies); 13 | 14 | React.useEffect(() => { 15 | if (!actionsCache.length) { 16 | return; 17 | } 18 | 19 | const unregister = query.registerActions(actionsCache); 20 | return () => { 21 | unregister(); 22 | }; 23 | }, [query, actionsCache]); 24 | } 25 | -------------------------------------------------------------------------------- /src/useStore.tsx: -------------------------------------------------------------------------------- 1 | import { deepEqual } from "fast-equals"; 2 | import * as React from "react"; 3 | import invariant from "tiny-invariant"; 4 | import { ActionInterface } from "./action/ActionInterface"; 5 | import { history } from "./action/HistoryImpl"; 6 | import type { 7 | Action, 8 | IKBarContext, 9 | KBarOptions, 10 | KBarProviderProps, 11 | KBarQuery, 12 | KBarState, 13 | } from "./types"; 14 | import { VisualState } from "./types"; 15 | 16 | type useStoreProps = KBarProviderProps; 17 | 18 | export function useStore(props: useStoreProps) { 19 | const optionsRef = React.useRef({ 20 | animations: { 21 | enterMs: 200, 22 | exitMs: 100, 23 | }, 24 | ...props.options, 25 | } as KBarOptions); 26 | 27 | const actionsInterface = React.useMemo( 28 | () => 29 | new ActionInterface(props.actions || [], { 30 | historyManager: optionsRef.current.enableHistory ? history : undefined, 31 | }), 32 | // eslint-disable-next-line react-hooks/exhaustive-deps 33 | [] 34 | ); 35 | 36 | // TODO: at this point useReducer might be a better approach to managing state. 37 | const [state, setState] = React.useState({ 38 | searchQuery: "", 39 | currentRootActionId: null, 40 | visualState: VisualState.hidden, 41 | actions: { ...actionsInterface.actions }, 42 | activeIndex: 0, 43 | disabled: false, 44 | }); 45 | 46 | const currState = React.useRef(state); 47 | currState.current = state; 48 | 49 | const getState = React.useCallback(() => currState.current, []); 50 | const publisher = React.useMemo(() => new Publisher(getState), [getState]); 51 | 52 | React.useEffect(() => { 53 | currState.current = state; 54 | publisher.notify(); 55 | }, [state, publisher]); 56 | 57 | const registerActions = React.useCallback( 58 | (actions: Action[]) => { 59 | setState((state) => { 60 | return { 61 | ...state, 62 | actions: actionsInterface.add(actions), 63 | }; 64 | }); 65 | 66 | return function unregister() { 67 | setState((state) => { 68 | return { 69 | ...state, 70 | actions: actionsInterface.remove(actions), 71 | }; 72 | }); 73 | }; 74 | }, 75 | [actionsInterface] 76 | ); 77 | 78 | const inputRef = React.useRef(null); 79 | 80 | return React.useMemo(() => { 81 | const query: KBarQuery = { 82 | setCurrentRootAction: (actionId) => { 83 | setState((state) => ({ 84 | ...state, 85 | currentRootActionId: actionId, 86 | })); 87 | }, 88 | setVisualState: (cb) => { 89 | setState((state) => ({ 90 | ...state, 91 | visualState: typeof cb === "function" ? cb(state.visualState) : cb, 92 | })); 93 | }, 94 | setSearch: (searchQuery) => 95 | setState((state) => ({ 96 | ...state, 97 | searchQuery, 98 | })), 99 | registerActions, 100 | toggle: () => 101 | setState((state) => ({ 102 | ...state, 103 | visualState: [VisualState.animatingOut, VisualState.hidden].includes( 104 | state.visualState 105 | ) 106 | ? VisualState.animatingIn 107 | : VisualState.animatingOut, 108 | })), 109 | setActiveIndex: (cb) => 110 | setState((state) => ({ 111 | ...state, 112 | activeIndex: typeof cb === "number" ? cb : cb(state.activeIndex), 113 | })), 114 | inputRefSetter: (el: HTMLInputElement) => { 115 | inputRef.current = el; 116 | }, 117 | getInput: () => { 118 | invariant( 119 | inputRef.current, 120 | "Input ref is undefined, make sure you attach `query.inputRefSetter` to your search input." 121 | ); 122 | return inputRef.current; 123 | }, 124 | disable: (disable: boolean) => { 125 | setState((state) => ({ 126 | ...state, 127 | disabled: disable, 128 | })); 129 | }, 130 | }; 131 | return { 132 | getState, 133 | query, 134 | options: optionsRef.current, 135 | subscribe: (collector, cb) => publisher.subscribe(collector, cb), 136 | } as IKBarContext; 137 | }, [getState, publisher, registerActions]); 138 | } 139 | 140 | class Publisher { 141 | getState; 142 | subscribers: Subscriber[] = []; 143 | 144 | constructor(getState: () => KBarState) { 145 | this.getState = getState; 146 | } 147 | 148 | subscribe( 149 | collector: (state: KBarState) => C, 150 | onChange: (collected: C) => void 151 | ) { 152 | const subscriber = new Subscriber( 153 | () => collector(this.getState()), 154 | onChange 155 | ); 156 | this.subscribers.push(subscriber); 157 | return this.unsubscribe.bind(this, subscriber); 158 | } 159 | 160 | unsubscribe(subscriber: Subscriber) { 161 | if (this.subscribers.length) { 162 | const index = this.subscribers.indexOf(subscriber); 163 | if (index > -1) { 164 | return this.subscribers.splice(index, 1); 165 | } 166 | } 167 | } 168 | 169 | notify() { 170 | this.subscribers.forEach((subscriber) => subscriber.collect()); 171 | } 172 | } 173 | 174 | class Subscriber { 175 | collected: any; 176 | collector; 177 | onChange; 178 | 179 | constructor(collector: () => any, onChange: (collected: any) => any) { 180 | this.collector = collector; 181 | this.onChange = onChange; 182 | } 183 | 184 | collect() { 185 | try { 186 | // grab latest state 187 | const recollect = this.collector(); 188 | if (!deepEqual(recollect, this.collected)) { 189 | this.collected = recollect; 190 | if (this.onChange) { 191 | this.onChange(this.collected); 192 | } 193 | } 194 | } catch (error) { 195 | console.warn(error); 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { Action } from "./types"; 3 | 4 | export function swallowEvent(event) { 5 | event.stopPropagation(); 6 | event.preventDefault(); 7 | } 8 | 9 | export function useOuterClick( 10 | dom: React.RefObject, 11 | cb: () => void 12 | ) { 13 | const cbRef = React.useRef(cb); 14 | cbRef.current = cb; 15 | 16 | React.useEffect(() => { 17 | function handler(event) { 18 | if ( 19 | dom.current?.contains(event.target) || 20 | // Add support for ReactShadowRoot 21 | // @ts-expect-error wrong types, the `host` property exists https://stackoverflow.com/a/25340456 22 | event.target === dom.current?.getRootNode().host 23 | ) { 24 | return; 25 | } 26 | event.preventDefault(); 27 | event.stopPropagation(); 28 | cbRef.current(); 29 | } 30 | window.addEventListener("pointerdown", handler, true); 31 | return () => window.removeEventListener("pointerdown", handler, true); 32 | }, [dom]); 33 | } 34 | 35 | export function usePointerMovedSinceMount() { 36 | const [moved, setMoved] = React.useState(false); 37 | 38 | React.useEffect(() => { 39 | function handler() { 40 | setMoved(true); 41 | } 42 | 43 | if (!moved) { 44 | window.addEventListener("pointermove", handler); 45 | return () => window.removeEventListener("pointermove", handler); 46 | } 47 | }, [moved]); 48 | 49 | return moved; 50 | } 51 | 52 | export function randomId() { 53 | return Math.random().toString(36).substring(2, 9); 54 | } 55 | 56 | export function createAction(params: Omit) { 57 | return { 58 | id: randomId(), 59 | ...params, 60 | } as Action; 61 | } 62 | 63 | export function noop() {} 64 | 65 | export const useIsomorphicLayout = 66 | typeof window === "undefined" ? noop : React.useLayoutEffect; 67 | 68 | // https://stackoverflow.com/questions/13382516/getting-scroll-bar-width-using-javascript 69 | export function getScrollbarWidth() { 70 | const outer = document.createElement("div"); 71 | outer.style.visibility = "hidden"; 72 | outer.style.overflow = "scroll"; 73 | document.body.appendChild(outer); 74 | const inner = document.createElement("div"); 75 | outer.appendChild(inner); 76 | const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; 77 | outer.parentNode!.removeChild(outer); 78 | return scrollbarWidth; 79 | } 80 | 81 | export function useThrottledValue(value: T, ms: number = 100) { 82 | const [throttledValue, setThrottledValue] = React.useState(value); 83 | const lastRan = React.useRef(Date.now()); 84 | 85 | React.useEffect(() => { 86 | if (ms === 0) return; 87 | 88 | const timeout = setTimeout(() => { 89 | setThrottledValue(value); 90 | lastRan.current = Date.now(); 91 | }, lastRan.current - (Date.now() - ms)); 92 | 93 | return () => { 94 | clearTimeout(timeout); 95 | }; 96 | }, [ms, value]); 97 | 98 | return ms === 0 ? value : throttledValue; 99 | } 100 | 101 | export function shouldRejectKeystrokes( 102 | { 103 | ignoreWhenFocused, 104 | }: { 105 | ignoreWhenFocused: string[]; 106 | } = { ignoreWhenFocused: [] } 107 | ) { 108 | const inputs = ["input", "textarea", ...ignoreWhenFocused].map((el) => 109 | el.toLowerCase() 110 | ); 111 | 112 | const activeElement = document.activeElement; 113 | 114 | const ignoreStrokes = 115 | activeElement && 116 | (inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1 || 117 | activeElement.attributes.getNamedItem("role")?.value === "textbox" || 118 | activeElement.attributes.getNamedItem("contenteditable")?.value === 119 | "true" || 120 | activeElement.attributes.getNamedItem("contenteditable")?.value === 121 | "plaintext-only"); 122 | 123 | return ignoreStrokes; 124 | } 125 | 126 | const SSR = typeof window === "undefined"; 127 | const isMac = !SSR && window.navigator.platform === "MacIntel"; 128 | 129 | export function isModKey( 130 | event: KeyboardEvent | MouseEvent | React.KeyboardEvent 131 | ) { 132 | return isMac ? event.metaKey : event.ctrlKey; 133 | } 134 | 135 | export const Priority = { 136 | HIGH: 1, 137 | NORMAL: 0, 138 | LOW: -1, 139 | }; 140 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "declaration": true, 5 | "target": "es5", 6 | "lib": [ 7 | "es5", 8 | "DOM" 9 | ], 10 | "strict": true, 11 | "noImplicitAny": false, 12 | "esModuleInterop": true, 13 | "moduleResolution": "node", 14 | "rootDir": "src", 15 | "outDir": "lib", 16 | "module": "commonjs", 17 | "skipLibCheck": true, 18 | "typeRoots": [ 19 | "src/types", 20 | "node_modules/@types" 21 | ], 22 | }, 23 | "include": [ 24 | "src/**/*" 25 | ], 26 | "exclude": [ 27 | "node_modules", 28 | "example" 29 | ] 30 | } -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }] 3 | } 4 | --------------------------------------------------------------------------------