@headless-tree/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/lib/core/create-tree.d.ts +2 -0
  2. package/lib/core/create-tree.js +116 -0
  3. package/lib/data-adapters/nested-data-adapter.d.ts +9 -0
  4. package/lib/data-adapters/nested-data-adapter.js +32 -0
  5. package/lib/data-adapters/types.d.ts +7 -0
  6. package/lib/data-adapters/types.js +2 -0
  7. package/lib/features/async-data-loader/feature.d.ts +5 -0
  8. package/lib/features/async-data-loader/feature.js +80 -0
  9. package/lib/features/async-data-loader/types.d.ts +41 -0
  10. package/lib/features/async-data-loader/types.js +2 -0
  11. package/lib/features/drag-and-drop/feature.d.ts +3 -0
  12. package/lib/features/drag-and-drop/feature.js +144 -0
  13. package/lib/features/drag-and-drop/types.d.ts +49 -0
  14. package/lib/features/drag-and-drop/types.js +9 -0
  15. package/lib/features/drag-and-drop/utils.d.ts +7 -0
  16. package/lib/features/drag-and-drop/utils.js +121 -0
  17. package/lib/features/expand-all/feature.d.ts +6 -0
  18. package/lib/features/expand-all/feature.js +39 -0
  19. package/lib/features/expand-all/types.d.ts +17 -0
  20. package/lib/features/expand-all/types.js +2 -0
  21. package/lib/features/hotkeys-core/feature.d.ts +4 -0
  22. package/lib/features/hotkeys-core/feature.js +73 -0
  23. package/lib/features/hotkeys-core/types.d.ts +25 -0
  24. package/lib/features/hotkeys-core/types.js +2 -0
  25. package/lib/features/main/types.d.ts +36 -0
  26. package/lib/features/main/types.js +2 -0
  27. package/lib/features/renaming/feature.d.ts +5 -0
  28. package/lib/features/renaming/feature.js +65 -0
  29. package/lib/features/renaming/types.d.ts +27 -0
  30. package/lib/features/renaming/types.js +2 -0
  31. package/lib/features/search/feature.d.ts +5 -0
  32. package/lib/features/search/feature.js +90 -0
  33. package/lib/features/search/types.d.ts +33 -0
  34. package/lib/features/search/types.js +2 -0
  35. package/lib/features/selection/feature.d.ts +5 -0
  36. package/lib/features/selection/feature.js +112 -0
  37. package/lib/features/selection/types.d.ts +21 -0
  38. package/lib/features/selection/types.js +2 -0
  39. package/lib/features/sync-data-loader/feature.d.ts +4 -0
  40. package/lib/features/sync-data-loader/feature.js +9 -0
  41. package/lib/features/sync-data-loader/types.d.ts +19 -0
  42. package/lib/features/sync-data-loader/types.js +2 -0
  43. package/lib/features/tree/feature.d.ts +6 -0
  44. package/lib/features/tree/feature.js +216 -0
  45. package/lib/features/tree/types.d.ts +57 -0
  46. package/lib/features/tree/types.js +2 -0
  47. package/lib/index.d.ts +21 -0
  48. package/lib/index.js +37 -0
  49. package/lib/mddocs-entry.d.ts +21 -0
  50. package/lib/mddocs-entry.js +17 -0
  51. package/lib/types/core.d.ts +68 -0
  52. package/lib/types/core.js +2 -0
  53. package/lib/types/deep-merge.d.ts +13 -0
  54. package/lib/types/deep-merge.js +2 -0
  55. package/lib/utils.d.ts +9 -0
  56. package/lib/utils.js +105 -0
  57. package/package.json +15 -0
  58. package/src/core/create-tree.ts +195 -0
  59. package/src/data-adapters/nested-data-adapter.ts +48 -0
  60. package/src/data-adapters/types.ts +9 -0
  61. package/src/features/async-data-loader/feature.ts +117 -0
  62. package/src/features/async-data-loader/types.ts +41 -0
  63. package/src/features/drag-and-drop/feature.ts +153 -0
  64. package/src/features/drag-and-drop/types.ts +64 -0
  65. package/src/features/drag-and-drop/utils.ts +88 -0
  66. package/src/features/expand-all/feature.ts +62 -0
  67. package/src/features/expand-all/types.ts +13 -0
  68. package/src/features/hotkeys-core/feature.ts +111 -0
  69. package/src/features/hotkeys-core/types.ts +36 -0
  70. package/src/features/main/types.ts +41 -0
  71. package/src/features/renaming/feature.ts +102 -0
  72. package/src/features/renaming/types.ts +28 -0
  73. package/src/features/search/feature.ts +142 -0
  74. package/src/features/search/types.ts +39 -0
  75. package/src/features/selection/feature.ts +153 -0
  76. package/src/features/selection/types.ts +28 -0
  77. package/src/features/sync-data-loader/feature.ts +27 -0
  78. package/src/features/sync-data-loader/types.ts +20 -0
  79. package/src/features/tree/feature.ts +307 -0
  80. package/src/features/tree/types.ts +70 -0
  81. package/src/index.ts +23 -0
  82. package/src/mddocs-entry.ts +26 -0
  83. package/src/types/core.ts +182 -0
  84. package/src/types/deep-merge.ts +31 -0
  85. package/src/utils.ts +136 -0
  86. package/tsconfig.json +7 -0
  87. package/typedoc.json +4 -0
@@ -0,0 +1,88 @@
1
+ import { ItemInstance, TreeInstance } from "../../types/core";
2
+ import { DropTarget, DropTargetPosition } from "./types";
3
+
4
+ export const getDragCode = ({ item, childIndex }: DropTarget<any>) =>
5
+ `${item.getId()}__${childIndex ?? "none"}`;
6
+
7
+ export const getDropOffset = (e: any, item: ItemInstance<any>): number => {
8
+ const bb = item.getElement()?.getBoundingClientRect();
9
+ return bb ? (e.pageY - bb.top) / bb.height : 0.5;
10
+ };
11
+
12
+ export const canDrop = (
13
+ dataTransfer: DataTransfer | null,
14
+ target: DropTarget<any>,
15
+ tree: TreeInstance<any>
16
+ ) => {
17
+ const draggedItems = tree.getState().dnd?.draggedItems;
18
+ const config = tree.getConfig();
19
+
20
+ if (draggedItems && !(config.canDrop?.(draggedItems, target) ?? true)) {
21
+ return false;
22
+ }
23
+
24
+ if (
25
+ !draggedItems &&
26
+ dataTransfer &&
27
+ !config.canDropForeignDragObject?.(dataTransfer, target)
28
+ ) {
29
+ return false;
30
+ }
31
+
32
+ return true;
33
+ };
34
+
35
+ const getDropTargetPosition = (
36
+ offset: number,
37
+ topLinePercentage: number,
38
+ bottomLinePercentage: number
39
+ ) => {
40
+ if (offset < topLinePercentage) {
41
+ return DropTargetPosition.Top;
42
+ }
43
+ if (offset > bottomLinePercentage) {
44
+ return DropTargetPosition.Bottom;
45
+ }
46
+ return DropTargetPosition.Item;
47
+ };
48
+
49
+ export const getDropTarget = (
50
+ e: any,
51
+ item: ItemInstance<any>,
52
+ tree: TreeInstance<any>
53
+ ): DropTarget<any> => {
54
+ const config = tree.getConfig();
55
+ const offset = getDropOffset(e, item);
56
+
57
+ const dropOnItemTarget = { item, childIndex: null };
58
+
59
+ const pos = getDropTargetPosition(
60
+ offset,
61
+ config.topLinePercentage ?? 0.3,
62
+ config.bottomLinePercentage ?? 0.7
63
+ );
64
+ const inbetweenPos = getDropTargetPosition(offset, 0.5, 0.5);
65
+
66
+ if (!config.canDropInbetween) {
67
+ return dropOnItemTarget;
68
+ }
69
+
70
+ if (!canDrop(e.dataTransfer, dropOnItemTarget, tree)) {
71
+ return {
72
+ item: item.getParent(),
73
+ childIndex:
74
+ item.getIndexInParent() +
75
+ (inbetweenPos === DropTargetPosition.Top ? 0 : 1),
76
+ };
77
+ }
78
+
79
+ if (pos === DropTargetPosition.Item) {
80
+ return dropOnItemTarget;
81
+ }
82
+
83
+ return {
84
+ item: item.getParent(),
85
+ childIndex:
86
+ item.getIndexInParent() + (pos === DropTargetPosition.Top ? 0 : 1),
87
+ };
88
+ };
@@ -0,0 +1,62 @@
1
+ import { FeatureImplementation } from "../../types/core";
2
+ import { ExpandAllFeatureDef } from "./types";
3
+ import { MainFeatureDef } from "../main/types";
4
+ import { TreeFeatureDef } from "../tree/types";
5
+ import { SyncDataLoaderFeatureDef } from "../sync-data-loader/types";
6
+ import { poll } from "../../utils";
7
+
8
+ export const expandAllFeature: FeatureImplementation<
9
+ any,
10
+ ExpandAllFeatureDef,
11
+ | MainFeatureDef
12
+ | TreeFeatureDef<any>
13
+ | SyncDataLoaderFeatureDef<any>
14
+ | ExpandAllFeatureDef
15
+ > = {
16
+ key: "expand-all",
17
+ dependingFeatures: ["main", "tree"],
18
+
19
+ createTreeInstance: (prev, tree) => ({
20
+ ...prev,
21
+
22
+ expandAll: async (cancelToken) => {
23
+ await Promise.all(
24
+ tree.getItems().map((item) => item.expandAll(cancelToken))
25
+ );
26
+ },
27
+
28
+ collapseAll: async () => {
29
+ tree.getConfig().setExpandedItems?.([]);
30
+ },
31
+ }),
32
+
33
+ createItemInstance: (prev, item, tree) => ({
34
+ ...prev,
35
+
36
+ expandAll: async (cancelToken) => {
37
+ if (cancelToken?.current) {
38
+ return;
39
+ }
40
+
41
+ item.expand();
42
+ await poll(() => !tree.getState().loadingItems.includes(item.getId()));
43
+ await Promise.all(
44
+ item.getChildren().map(async (child) => {
45
+ await poll(
46
+ () => !tree.getState().loadingItems.includes(child.getId())
47
+ );
48
+ await child?.expandAll(cancelToken);
49
+ })
50
+ );
51
+ },
52
+
53
+ collapseAll: async () => {
54
+ await Promise.all(
55
+ item.getChildren().map(async (child) => {
56
+ await child?.collapseAll();
57
+ })
58
+ );
59
+ item.collapse();
60
+ },
61
+ }),
62
+ };
@@ -0,0 +1,13 @@
1
+ export type ExpandAllFeatureDef = {
2
+ state: {};
3
+ config: {};
4
+ treeInstance: {
5
+ expandAll: (cancelToken?: { current: boolean }) => Promise<void>;
6
+ collapseAll: () => Promise<void>;
7
+ };
8
+ itemInstance: {
9
+ expandAll: (cancelToken?: { current: boolean }) => Promise<void>;
10
+ collapseAll: () => Promise<void>;
11
+ };
12
+ hotkeys: never;
13
+ };
@@ -0,0 +1,111 @@
1
+ import {
2
+ FeatureImplementation,
3
+ HotkeysConfig,
4
+ TreeInstance,
5
+ } from "../../types/core";
6
+ import {
7
+ HotkeyConfig,
8
+ HotkeysCoreDataRef,
9
+ HotkeysCoreFeatureDef,
10
+ } from "./types";
11
+ import { MainFeatureDef } from "../main/types";
12
+
13
+ const specialKeys: Record<string, RegExp> = {
14
+ Letter: /^[a-z]$/,
15
+ LetterOrNumber: /^[a-z0-9]$/,
16
+ };
17
+
18
+ const testHotkeyMatch = (
19
+ pressedKeys: Set<string>,
20
+ tree: TreeInstance<any>,
21
+ hotkey: HotkeyConfig<any>
22
+ ) => {
23
+ const supposedKeys = hotkey.hotkey.split("+");
24
+ const doKeysMatch = supposedKeys.every((key) =>
25
+ key in specialKeys
26
+ ? [...pressedKeys].some((pressedKey) => specialKeys[key].test(pressedKey))
27
+ : pressedKeys.has(key)
28
+ );
29
+ const isEnabled = !hotkey.isEnabled || hotkey.isEnabled(tree);
30
+ const equalCounts = pressedKeys.size === supposedKeys.length;
31
+ return doKeysMatch && isEnabled && equalCounts;
32
+ };
33
+
34
+ const findHotkeyMatch = (
35
+ pressedKeys: Set<string>,
36
+ tree: TreeInstance<any>,
37
+ config1: HotkeysConfig<any, any>,
38
+ config2: HotkeysConfig<any, any>
39
+ ) => {
40
+ return Object.entries({ ...config1, ...config2 }).find(([, hotkey]) =>
41
+ testHotkeyMatch(pressedKeys, tree, hotkey)
42
+ )?.[0];
43
+ };
44
+
45
+ export const hotkeysCoreFeature: FeatureImplementation<
46
+ any,
47
+ HotkeysCoreFeatureDef<any>,
48
+ MainFeatureDef | HotkeysCoreFeatureDef<any>
49
+ > = {
50
+ key: "hotkeys-core",
51
+ dependingFeatures: ["main", "tree"],
52
+
53
+ onTreeMount: (tree, element) => {
54
+ const data = tree.getDataRef<HotkeysCoreDataRef>();
55
+ const keydown = (e: KeyboardEvent) => {
56
+ data.current.pressedKeys ??= new Set();
57
+ const newMatch = !data.current.pressedKeys.has(e.key);
58
+ data.current.pressedKeys.add(e.key);
59
+
60
+ const hotkeyName = findHotkeyMatch(
61
+ data.current.pressedKeys,
62
+ tree as any,
63
+ tree.getHotkeyPresets(),
64
+ tree.getConfig().hotkeys as HotkeysConfig<any>
65
+ );
66
+
67
+ if (!hotkeyName) return;
68
+
69
+ const hotkeyConfig: HotkeyConfig<any> = {
70
+ ...tree.getHotkeyPresets()[hotkeyName],
71
+ ...tree.getConfig().hotkeys?.[hotkeyName],
72
+ };
73
+
74
+ if (!hotkeyConfig) return;
75
+ if (
76
+ !hotkeyConfig.allowWhenInputFocused &&
77
+ e.target instanceof HTMLInputElement
78
+ )
79
+ return;
80
+ if (!hotkeyConfig.canRepeat && !newMatch) return;
81
+ if (hotkeyConfig.preventDefault) e.preventDefault();
82
+
83
+ hotkeyConfig.handler(e, tree as any);
84
+ };
85
+
86
+ const keyup = (e: KeyboardEvent) => {
87
+ data.current.pressedKeys ??= new Set();
88
+ data.current.pressedKeys.delete(e.key);
89
+ };
90
+
91
+ // keyup is registered on document, because some hotkeys shift
92
+ // the focus away from the tree (i.e. search)
93
+ // and then we wouldn't get the keyup event anymore
94
+ element.addEventListener("keydown", keydown);
95
+ document.addEventListener("keyup", keyup);
96
+ data.current.keydownHandler = keydown;
97
+ data.current.keyupHandler = keyup;
98
+ },
99
+
100
+ onTreeUnmount: (tree, element) => {
101
+ const data = tree.getDataRef<HotkeysCoreDataRef>();
102
+ if (data.current.keyupHandler) {
103
+ document.removeEventListener("keyup", data.current.keyupHandler);
104
+ delete data.current.keyupHandler;
105
+ }
106
+ if (data.current.keydownHandler) {
107
+ element.removeEventListener("keydown", data.current.keydownHandler);
108
+ delete data.current.keydownHandler;
109
+ }
110
+ },
111
+ };
@@ -0,0 +1,36 @@
1
+ import {
2
+ CustomHotkeysConfig,
3
+ ItemInstance,
4
+ TreeInstance,
5
+ } from "../../types/core";
6
+
7
+ export interface HotkeyConfig<T> {
8
+ hotkey: string;
9
+ canRepeat?: boolean;
10
+ allowWhenInputFocused?: boolean;
11
+ isEnabled?: (tree: TreeInstance<T>) => boolean;
12
+ preventDefault?: boolean;
13
+ handler: (e: KeyboardEvent, tree: TreeInstance<T>) => void;
14
+ }
15
+
16
+ export type HotkeysCoreDataRef = {
17
+ keydownHandler?: (e: KeyboardEvent) => void;
18
+ keyupHandler?: (e: KeyboardEvent) => void;
19
+ pressedKeys: Set<string>;
20
+ };
21
+
22
+ export type HotkeysCoreFeatureDef<T> = {
23
+ state: {};
24
+ config: {
25
+ hotkeys?: CustomHotkeysConfig<T>;
26
+ onTreeHotkey?: (name: string, element: HTMLElement) => void;
27
+ onItemHotkey?: (
28
+ name: string,
29
+ item: ItemInstance<T>,
30
+ element: HTMLElement
31
+ ) => void;
32
+ };
33
+ treeInstance: {};
34
+ itemInstance: {};
35
+ hotkeys: never;
36
+ };
@@ -0,0 +1,41 @@
1
+ import {
2
+ FeatureImplementation,
3
+ HotkeysConfig,
4
+ ItemInstance,
5
+ SetStateFn,
6
+ TreeConfig,
7
+ TreeState,
8
+ } from "../../types/core";
9
+ import { ItemMeta } from "../tree/types";
10
+
11
+ export type MainFeatureDef<T = any> = {
12
+ state: {};
13
+ config: {
14
+ features?: FeatureImplementation<any>[];
15
+ state?: Partial<TreeState<T>>;
16
+ setState?: SetStateFn<TreeState<T>>;
17
+ };
18
+ treeInstance: {
19
+ setState: SetStateFn<TreeState<T>>;
20
+ getState: () => TreeState<T>;
21
+ setConfig: SetStateFn<TreeConfig<T>>;
22
+ getConfig: () => TreeConfig<T>;
23
+ getItemInstance: (itemId: string) => ItemInstance<T>;
24
+ getItems: () => ItemInstance<T>[];
25
+ registerElement: (element: HTMLElement | null) => void;
26
+ getElement: () => HTMLElement | undefined | null;
27
+ /** @internal */
28
+ getDataRef: <D>() => { current: D };
29
+ /* @internal */
30
+ getHotkeyPresets: () => HotkeysConfig<T>;
31
+ rebuildTree: () => void;
32
+ };
33
+ itemInstance: {
34
+ registerElement: (element: HTMLElement | null) => void;
35
+ getItemMeta: () => ItemMeta;
36
+ getElement: () => HTMLElement | undefined | null;
37
+ /** @internal */
38
+ getDataRef: <D>() => { current: D };
39
+ };
40
+ hotkeys: never;
41
+ };
@@ -0,0 +1,102 @@
1
+ import { FeatureImplementation, ItemInstance } from "../../types/core";
2
+ import { RenamingFeatureDef } from "./types";
3
+ import { MainFeatureDef } from "../main/types";
4
+ import { TreeFeatureDef } from "../tree/types";
5
+ import { makeStateUpdater } from "../../utils";
6
+
7
+ export const renamingFeature: FeatureImplementation<
8
+ any,
9
+ RenamingFeatureDef<any>,
10
+ MainFeatureDef | TreeFeatureDef<any> | RenamingFeatureDef<any>
11
+ > = {
12
+ key: "renaming",
13
+ dependingFeatures: ["main", "tree"],
14
+
15
+ getDefaultConfig: (defaultConfig, tree) => ({
16
+ setRenamingItem: makeStateUpdater("renamingItem", tree),
17
+ setRenamingValue: makeStateUpdater("renamingValue", tree),
18
+ canRename: () => true,
19
+ ...defaultConfig,
20
+ }),
21
+
22
+ createTreeInstance: (prev, instance) => ({
23
+ ...prev,
24
+
25
+ startRenamingItem: (itemId) => {
26
+ const config = instance.getConfig();
27
+ const item = instance.getItemInstance(itemId);
28
+
29
+ if (!item.canRename()) {
30
+ return;
31
+ }
32
+
33
+ config.setRenamingItem?.(itemId);
34
+ config.setRenamingValue?.(item.getItemName());
35
+ },
36
+
37
+ getRenamingItem: () => {
38
+ const itemId = instance.getState().renamingItem;
39
+ return itemId ? instance.getItemInstance(itemId) : null;
40
+ },
41
+
42
+ getRenamingValue: () => instance.getState().renamingValue || "",
43
+
44
+ abortRenaming: () => {
45
+ instance.getConfig().setRenamingItem?.(null);
46
+ },
47
+
48
+ completeRenaming: () => {
49
+ const config = instance.getConfig();
50
+ const item = instance.getRenamingItem();
51
+ if (item) {
52
+ config.onRename?.(item, instance.getState().renamingValue || "");
53
+ }
54
+ instance.getConfig().setRenamingItem?.(null);
55
+ },
56
+
57
+ isRenamingItem: () => !!instance.getState().renamingItem,
58
+ }),
59
+
60
+ createItemInstance: (prev, instance, tree) => ({
61
+ ...prev,
62
+ getRenameInputProps: () => ({
63
+ onBlur: () => tree.abortRenaming(),
64
+ value: tree.getRenamingValue(),
65
+ onChange: (e) => {
66
+ tree.getConfig().setRenamingValue?.(e.target.value);
67
+ },
68
+ }),
69
+
70
+ canRename: () =>
71
+ tree.getConfig().canRename?.(instance as ItemInstance<any>) ?? true,
72
+
73
+ isRenaming: () => instance.getId() === tree.getState().renamingItem,
74
+ }),
75
+
76
+ hotkeys: {
77
+ renameItem: {
78
+ hotkey: "F2",
79
+ handler: (e, tree) => {
80
+ tree.startRenamingItem(tree.getFocusedItem().getId());
81
+ },
82
+ },
83
+
84
+ abortRenaming: {
85
+ hotkey: "Escape",
86
+ allowWhenInputFocused: true,
87
+ isEnabled: (tree) => tree.isRenamingItem(),
88
+ handler: (e, tree) => {
89
+ tree.abortRenaming();
90
+ },
91
+ },
92
+
93
+ completeRenaming: {
94
+ hotkey: "Enter",
95
+ allowWhenInputFocused: true,
96
+ isEnabled: (tree) => tree.isRenamingItem(),
97
+ handler: (e, tree) => {
98
+ tree.completeRenaming();
99
+ },
100
+ },
101
+ },
102
+ };
@@ -0,0 +1,28 @@
1
+ import { ItemInstance, SetStateFn } from "../../types/core";
2
+
3
+ export type RenamingFeatureDef<T> = {
4
+ state: {
5
+ renamingItem?: string | null;
6
+ renamingValue?: string;
7
+ };
8
+ config: {
9
+ setRenamingItem?: SetStateFn<string | null>;
10
+ setRenamingValue?: SetStateFn<string | undefined>;
11
+ canRename?: (item: ItemInstance<T>) => boolean;
12
+ onRename?: (item: ItemInstance<T>, value: string) => void;
13
+ };
14
+ treeInstance: {
15
+ startRenamingItem: (itemId: string) => void;
16
+ getRenamingItem: () => ItemInstance<T> | null;
17
+ getRenamingValue: () => string;
18
+ abortRenaming: () => void;
19
+ completeRenaming: () => void;
20
+ isRenamingItem: () => boolean;
21
+ };
22
+ itemInstance: {
23
+ getRenameInputProps: () => any;
24
+ canRename: () => boolean;
25
+ isRenaming: () => boolean;
26
+ };
27
+ hotkeys: "renameItem" | "abortRenaming" | "completeRenaming";
28
+ };
@@ -0,0 +1,142 @@
1
+ import { FeatureImplementation } from "../../types/core";
2
+ import { SearchFeatureDataRef, SearchFeatureDef } from "./types";
3
+ import { MainFeatureDef } from "../main/types";
4
+ import { TreeFeatureDef } from "../tree/types";
5
+ import { makeStateUpdater, memo } from "../../utils";
6
+
7
+ export const searchFeature: FeatureImplementation<
8
+ any,
9
+ SearchFeatureDef<any>,
10
+ MainFeatureDef | TreeFeatureDef<any> | SearchFeatureDef<any>
11
+ > = {
12
+ key: "search",
13
+ dependingFeatures: ["main", "tree"],
14
+
15
+ getInitialState: (initialState) => ({
16
+ search: null,
17
+ ...initialState,
18
+ }),
19
+
20
+ getDefaultConfig: (defaultConfig, tree) => ({
21
+ setSearch: makeStateUpdater("search", tree),
22
+ isSearchMatchingItem: (search, item) =>
23
+ search.length > 0 &&
24
+ item.getItemName().toLowerCase().includes(search.toLowerCase()),
25
+ ...defaultConfig,
26
+ }),
27
+
28
+ createTreeInstance: (prev, instance) => ({
29
+ ...prev,
30
+
31
+ setSearch: (search) => {
32
+ instance.getConfig().setSearch?.(search);
33
+ instance
34
+ .getItems()
35
+ .find((item) =>
36
+ instance
37
+ .getConfig()
38
+ .isSearchMatchingItem?.(instance.getSearchValue(), item)
39
+ )
40
+ ?.setFocused();
41
+ },
42
+ openSearch: (initialValue = "") => {
43
+ instance.setSearch(initialValue);
44
+ setTimeout(() => {
45
+ instance
46
+ .getDataRef<SearchFeatureDataRef>()
47
+ .current.searchInput?.focus();
48
+ });
49
+ },
50
+ closeSearch: () => {
51
+ instance.setSearch(null);
52
+ instance.updateDomFocus();
53
+ },
54
+ isSearchOpen: () => instance.getState().search !== null,
55
+ getSearchValue: () => instance.getState().search || "",
56
+ registerSearchInputElement: (element) => {
57
+ const dataRef = instance.getDataRef<SearchFeatureDataRef>();
58
+ dataRef.current.searchInput = element;
59
+ if (element && dataRef.current.keydownHandler) {
60
+ element.addEventListener("keydown", dataRef.current.keydownHandler);
61
+ }
62
+ },
63
+ getSearchInputElement: () =>
64
+ instance.getDataRef<SearchFeatureDataRef>().current.searchInput ?? null,
65
+
66
+ getSearchInputElementProps: () => ({
67
+ value: instance.getSearchValue(),
68
+ onChange: (e: any) => instance.setSearch(e.target.value),
69
+ onBlur: () => instance.closeSearch(),
70
+ }),
71
+
72
+ getSearchMatchingItems: memo(
73
+ (search, items) =>
74
+ items.filter((item) =>
75
+ instance.getConfig().isSearchMatchingItem?.(search, item)
76
+ ),
77
+ () => [instance.getSearchValue(), instance.getItems()]
78
+ ),
79
+ }),
80
+
81
+ createItemInstance: (prev, item, tree) => ({
82
+ ...prev,
83
+ isMatchingSearch: () =>
84
+ tree.getSearchMatchingItems().some((i) => i.getId() === item.getId()),
85
+ }),
86
+
87
+ hotkeys: {
88
+ openSearch: {
89
+ hotkey: "LetterOrNumber",
90
+ preventDefault: true, // TODO make true default
91
+ isEnabled: (tree) => !tree.isSearchOpen(),
92
+ handler: (e, tree) => {
93
+ e.stopPropagation();
94
+ tree.openSearch(e.key);
95
+ },
96
+ },
97
+
98
+ closeSearch: {
99
+ // TODO allow multiple, i.e. Enter
100
+ hotkey: "Escape",
101
+ allowWhenInputFocused: true,
102
+ isEnabled: (tree) => tree.isSearchOpen(),
103
+ handler: (e, tree) => {
104
+ tree.closeSearch();
105
+ },
106
+ },
107
+
108
+ nextSearchItem: {
109
+ hotkey: "ArrowDown",
110
+ allowWhenInputFocused: true,
111
+ isEnabled: (tree) => tree.isSearchOpen(),
112
+ handler: (e, tree) => {
113
+ // TODO scroll into view
114
+ const focusItem = tree
115
+ .getSearchMatchingItems()
116
+ .find(
117
+ (item) =>
118
+ item.getItemMeta().index >
119
+ tree.getFocusedItem().getItemMeta().index
120
+ );
121
+ focusItem?.setFocused();
122
+ },
123
+ },
124
+
125
+ previousSearchItem: {
126
+ hotkey: "ArrowUp",
127
+ allowWhenInputFocused: true,
128
+ isEnabled: (tree) => tree.isSearchOpen(),
129
+ handler: (e, tree) => {
130
+ // TODO scroll into view
131
+ const focusItem = [...tree.getSearchMatchingItems()]
132
+ .reverse()
133
+ .find(
134
+ (item) =>
135
+ item.getItemMeta().index <
136
+ tree.getFocusedItem().getItemMeta().index
137
+ );
138
+ focusItem?.setFocused();
139
+ },
140
+ },
141
+ },
142
+ };
@@ -0,0 +1,39 @@
1
+ import { ItemInstance, SetStateFn } from "../../types/core";
2
+ import { HotkeysCoreDataRef } from "../hotkeys-core/types";
3
+
4
+ export type SearchFeatureDataRef<T = any> = HotkeysCoreDataRef & {
5
+ matchingItems: ItemInstance<T>[];
6
+ searchInput: HTMLInputElement | null;
7
+ };
8
+
9
+ export type SearchFeatureDef<T> = {
10
+ state: {
11
+ search: string | null;
12
+ };
13
+ config: {
14
+ setSearch?: SetStateFn<string | null>;
15
+ onOpenSearch?: () => void;
16
+ onCloseSearch?: () => void;
17
+ onSearchMatchesItems?: (search: string, items: ItemInstance<T>[]) => void;
18
+ isSearchMatchingItem?: (search: string, item: ItemInstance<T>) => boolean;
19
+ };
20
+ treeInstance: {
21
+ setSearch: (search: string | null) => void;
22
+ openSearch: (initialValue?: string) => void;
23
+ closeSearch: () => void;
24
+ isSearchOpen: () => boolean;
25
+ getSearchValue: () => string;
26
+ registerSearchInputElement: (element: HTMLInputElement | null) => void;
27
+ getSearchInputElement: () => HTMLInputElement | null;
28
+ getSearchInputElementProps: () => any;
29
+ getSearchMatchingItems: () => ItemInstance<T>[];
30
+ };
31
+ itemInstance: {
32
+ isMatchingSearch: () => boolean;
33
+ };
34
+ hotkeys:
35
+ | "openSearch"
36
+ | "closeSearch"
37
+ | "nextSearchItem"
38
+ | "previousSearchItem";
39
+ };