diff --git a/example/src/App.tsx b/example/src/App.tsx index 35c7877..8dd9c9c 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -14,7 +14,7 @@ import Docs from "./Docs"; import SearchDocsActions from "./SearchDocsActions"; import { createAction } from "../../src/utils"; import { useAnalytics } from "./utils"; -import { Action } from "../../src"; +import type { BaseAction } from "../../src/types"; import Blog from "./Blog"; const searchStyle = { @@ -99,7 +99,6 @@ const App = () => { shortcut: [], keywords: "interface color dark light", section: "Preferences", - children: ["darkTheme", "lightTheme"], }, { id: "darkTheme", @@ -187,7 +186,7 @@ const ResultItem = React.forwardRef( action, active, }: { - action: Action; + action: BaseAction; active: boolean; }, ref: React.Ref diff --git a/example/src/SearchDocsActions.tsx b/example/src/SearchDocsActions.tsx index 44b5a50..fc9a815 100644 --- a/example/src/SearchDocsActions.tsx +++ b/example/src/SearchDocsActions.tsx @@ -1,9 +1,15 @@ import * as React from "react"; import { useHistory } from "react-router"; import useRegisterActions from "../../src/useRegisterActions"; +import { createAction } from "../../src/utils"; import data from "./Docs/data"; -const searchId = randomId(); +const rootSearchAction = createAction({ + name: "Search docs…", + shortcut: ["?"], + keywords: "find", + section: "", +}); export default function SearchDocsActions() { const history = useHistory(); @@ -17,14 +23,15 @@ export default function SearchDocsActions() { collectDocs(curr.children); } if (curr.component) { - actions.push({ - id: randomId(), - parent: searchId, - name: curr.name, - shortcut: [], - keywords: "", - perform: () => history.push(curr.slug), - }); + actions.push( + createAction({ + parent: rootSearchAction.id, + name: curr.name, + shortcut: [], + keywords: "", + perform: () => history.push(curr.slug), + }) + ); } }); return actions; @@ -32,26 +39,7 @@ export default function SearchDocsActions() { return collectDocs(data); }, [history]); - const rootSearchAction = React.useMemo( - () => - searchActions.length - ? { - id: searchId, - name: "Search docs…", - shortcut: ["?"], - keywords: "find", - section: "", - children: searchActions.map((action) => action.id), - } - : null, - [searchActions] - ); - useRegisterActions([rootSearchAction, ...searchActions].filter(Boolean)); return null; } - -function randomId() { - return Math.random().toString(36).substring(2, 9); -} diff --git a/src/InternalEvents.tsx b/src/InternalEvents.tsx index 2a6e0e6..513a158 100644 --- a/src/InternalEvents.tsx +++ b/src/InternalEvents.tsx @@ -134,9 +134,11 @@ function useDocumentLock() { * performs actions for patterns that match the user defined `shortcut`. */ function useShortcuts() { - const { actions, query } = useKBar((state) => ({ - actions: state.actions, - })); + const { actions, query } = useKBar((state) => { + return { + actions: state.actions, + }; + }); React.useEffect(() => { const actionsList = Object.keys(actions).map((key) => actions[key]); diff --git a/src/KBarResults.tsx b/src/KBarResults.tsx index 1b6c6b1..94799c7 100644 --- a/src/KBarResults.tsx +++ b/src/KBarResults.tsx @@ -1,12 +1,12 @@ import * as React from "react"; import { useVirtual } from "react-virtual"; import { useKBar } from "."; -import { Action } from "./types"; import { usePointerMovedSinceMount } from "./utils"; +import type { BaseAction } from "./types"; const START_INDEX = 0; -interface RenderParams { +interface RenderParams { item: T; active: boolean; } diff --git a/src/action/ActionImpl.ts b/src/action/ActionImpl.ts new file mode 100644 index 0000000..4fed064 --- /dev/null +++ b/src/action/ActionImpl.ts @@ -0,0 +1,38 @@ +import { ReactElement, JSXElementConstructor, ReactNode } from "react"; +import type { BaseAction, Action, ActionId } from "../types"; + +export class ActionImpl implements Action { + id: ActionId; + name: string; + keywords?: string | undefined; + shortcut?: string[] | undefined; + perform?: (() => void) | undefined; + section?: string | undefined; + parent?: string | null | undefined; + children?: ActionImpl[]; + icon?: ReactElement> | ReactNode; + subtitle?: string | undefined; + + constructor(action: BaseAction) { + this.parent = action.parent; + this.id = action.id; + this.name = action.name; + this.keywords = action.keywords; + this.shortcut = action.shortcut; + this.perform = action.perform; + this.section = action.section; + this.icon = action.icon; + this.subtitle = action.subtitle; + } + + addChild(action: ActionImpl) { + if (!this.children) this.children = []; + if (this.children.indexOf(action) > -1) return action; + this.children.push(action); + return action; + } + + static fromJSON(obj: Action) { + return new ActionImpl(obj); + } +} diff --git a/src/action/ActionInterface.ts b/src/action/ActionInterface.ts new file mode 100644 index 0000000..467f306 --- /dev/null +++ b/src/action/ActionInterface.ts @@ -0,0 +1,47 @@ +import type { BaseAction, ActionTree } from "../types"; +import { ActionImpl } from "./ActionImpl"; + +export default class ActionInterface { + readonly actions: ActionTree = {}; + + constructor(actions: BaseAction[]) { + this.actions = this.add(actions); + } + + add(actions: BaseAction[]) { + const [rootActions, nestedActions] = actions.reduce( + (acc, action) => { + const index = !action.parent ? 0 : 1; + acc[index].push(action); + return acc; + }, + [[], []] as BaseAction[][] + ); + + rootActions.forEach( + (action) => (this.actions[action.id] = ActionImpl.fromJSON(action)) + ); + + nestedActions.forEach((a) => { + const parent = this.actions[a.parent!]; + if (!parent) return; + const action = ActionImpl.fromJSON(a); + parent.addChild(action); + this.actions[action.id] = action; + }); + + return this.actions; + } + + remove(actions: BaseAction[]) { + actions.forEach((action) => { + const actionImpl = this.actions[action.id]; + delete this.actions[action.id]; + if (actionImpl?.children) { + this.remove(actionImpl.children); + } + }); + + return this.actions; + } +} diff --git a/src/types.ts b/src/types.ts index a960b72..e15aa03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,25 +1,29 @@ import * as React from "react"; +import { ActionImpl } from "./action/ActionImpl"; export type ActionId = string; -export interface Action { +export interface BaseAction { id: string; name: string; - shortcut: string[]; - keywords: string; + keywords?: string; + shortcut?: string[]; perform?: () => void; section?: string; parent?: ActionId | null | undefined; - children?: ActionId[]; icon?: string | React.ReactElement | React.ReactNode; subtitle?: string; } -export type ActionTree = Record; +export type Action = BaseAction & { + children?: ActionImpl[]; +}; + +export type ActionTree = Record; export interface ActionGroup { name: string; - actions: Action[]; + actions: BaseAction[]; } export interface KBarOptions { @@ -30,7 +34,7 @@ export interface KBarOptions { } export interface KBarProviderProps { - actions: Action[]; + actions: BaseAction[]; options?: KBarOptions; } @@ -46,7 +50,7 @@ export interface KBarQuery { setCurrentRootAction: (actionId: ActionId | null | undefined) => void; setVisualState: (cb: ((vs: VisualState) => any) | VisualState) => void; setSearch: (search: string) => void; - registerActions: (actions: Action[]) => () => void; + registerActions: (actions: BaseAction[]) => () => void; toggle: () => void; } diff --git a/src/useMatches.tsx b/src/useMatches.tsx index deb8029..ad49221 100644 --- a/src/useMatches.tsx +++ b/src/useMatches.tsx @@ -1,6 +1,6 @@ import { matchSorter } from "match-sorter"; import * as React from "react"; -import { Action, ActionGroup, ActionTree } from "./types"; +import type { BaseAction, ActionGroup, ActionTree } from "./types"; import useKBar from "./useKBar"; export const NO_GROUP = "none"; @@ -36,7 +36,7 @@ export default function useMatches() { } acc[section].push(action); return acc; - }, {} as Record); + }, {} as Record); let groups: ActionGroup[] = []; diff --git a/src/useRegisterActions.tsx b/src/useRegisterActions.tsx index 2cdbe45..312d783 100644 --- a/src/useRegisterActions.tsx +++ b/src/useRegisterActions.tsx @@ -1,9 +1,9 @@ import * as React from "react"; -import { Action } from "./types"; +import type { BaseAction } from "./types"; import useKBar from "./useKBar"; export default function useRegisterActions( - actions: Action[], + actions: BaseAction[], dependencies: React.DependencyList = [] ) { const { query } = useKBar(); diff --git a/src/useStore.tsx b/src/useStore.tsx index 28a2d38..9955105 100644 --- a/src/useStore.tsx +++ b/src/useStore.tsx @@ -1,33 +1,29 @@ import { deepEqual } from "fast-equals"; import * as React from "react"; -import { - Action, +import ActionInterface from "./action/ActionInterface"; +import { VisualState } from "./types"; +import type { + BaseAction, ActionId, - ActionTree, KBarProviderProps, KBarState, KBarOptions, - VisualState, } from "./types"; type useStoreProps = KBarProviderProps; -export default function useStore(props: useStoreProps) { - if (!props.actions) { - throw new Error( - "You must define a list of `actions` when calling KBarProvider" - ); +export default function useStore( + props: useStoreProps = { + actions: [], } +) { + const actionInterfaceRef = React.useRef(new ActionInterface(props.actions)); - // TODO: at this point useReducer might be a better approach to managing state. const [state, setState] = React.useState({ searchQuery: "", currentRootActionId: null, visualState: VisualState.hidden, - actions: props.actions.reduce((acc, curr) => { - acc[curr.id] = curr; - return acc; - }, {}), + actions: { ...actionInterfaceRef.current.actions }, }); const currState = React.useRef(state); @@ -49,60 +45,17 @@ export default function useStore(props: useStoreProps) { ...props.options, } as KBarOptions); - const registerActions = React.useCallback((actions: Action[]) => { - const actionsByKey: ActionTree = actions.reduce((acc, curr) => { - acc[curr.id] = curr; - return acc; - }, {}); - - setState((state) => { - actions.forEach((action) => { - if (action.parent) { - const parent = - // parent could have already existed or parent is defined alongside children. - state.actions[action.parent] || actionsByKey[action.parent]; - - if (!parent) { - throw new Error(`Action of id ${action.parent} does not exist.`); - } - - if (!parent.children) parent.children = []; - if (parent.children.includes(action.id)) return; - parent.children.push(action.id); - } - }); - - return { - ...state, - actions: { - ...actionsByKey, - ...state.actions, - }, - }; - }); + const registerActions = React.useCallback((actions: BaseAction[]) => { + setState((state) => ({ + ...state, + actions: { ...actionInterfaceRef.current.add(actions) }, + })); return function unregister() { - setState((state) => { - const allActions = state.actions; - const removeActionIds = actions.map((action) => action.id); - removeActionIds.forEach((actionId) => { - const action = state.actions[actionId]; - if (action?.parent) { - const parent = state.actions[action.parent]; - if (!parent?.children) { - return; - } - parent.children = parent.children.filter( - (child) => child !== actionId - ); - } - delete allActions[actionId]; - }); - return { - ...state, - actions: allActions, - }; - }); + setState((state) => ({ + ...state, + actions: { ...actionInterfaceRef.current.remove(actions) }, + })); }; }, []); diff --git a/src/utils.ts b/src/utils.ts index 97afce8..79950e8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import * as React from "react"; -import { Action } from "./types"; +import type { BaseAction } from "./types"; export function swallowEvent(event) { event.stopPropagation(); @@ -46,7 +46,7 @@ export function randomId() { return Math.random().toString(36).substring(2, 9); } -export function createAction(params: Omit): Action { +export function createAction(params: Omit): BaseAction { return { id: randomId(), ...params,