20 | {tokens.map((line, i) => ( 21 |28 | )} 29 |22 | {line.map((token, key) => ( 23 | 24 | ))} 25 |26 | ))} 27 |
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 |
24 | Context provider for easy access to the internal state anywhere within 25 | the app tree. 26 |
27 |29 | Renders the contents of kbar in a DOM element outside of the root app 30 | element. 31 |
32 |34 | Handles all animations; showing, hiding, height scaling using the Web 35 | Animations API. 36 |
37 |Renders an input which controls the internal search query state.
39 |Renders a virtualized list of results.
41 |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 |
9 | When a user searches for something in kbar, the result is a list of
10 | ActionImpl
s. ActionImpls are a more complex, powerful
11 | representation of the action
object literal that the user
12 | defines.
13 |
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 |
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 |
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 |
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 |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 |
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 |
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 |
30 | You can use priority
when defining an action's{" "}
31 | section
property using the same interface.
32 |
{},
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 | - Signup
73 | - Login
74 | - Dark mode
75 |
76 | However, with the priorities in place, the order will be:
77 |
78 | - Dark mode
79 | - Login
80 | - Signup
81 |
82 | Groups are sorted first and actions within groups are sorted after.
83 |
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 |
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 |
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 |
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 |
{
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 |
9 | When instantiating kbar, we can optionally enable undo/redo
10 | functionality through options.enableHistory
:
11 |
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 |
28 | When enabled, keyboard shortcuts meta
29 | z
and meta
30 | shift
31 | z
will appropriately undo and redo actions in the stack.
32 |
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 |
{
43 | // logic to perform action
44 | return () => {
45 | // logic to undo the action
46 | }
47 | },
48 | // ...
49 | })
50 | `}
51 | />
52 |
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 |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 |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 |
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 |
--------------------------------------------------------------------------------