@noya-app/noya-designsystem 0.1.44 → 0.1.46
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.
- package/.turbo/turbo-build.log +13 -10
- package/CHANGELOG.md +18 -0
- package/dist/index.css +1 -1
- package/dist/index.d.mts +1194 -878
- package/dist/index.d.ts +1194 -878
- package/dist/index.js +11432 -10219
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11556 -10360
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/combobox.test.ts +137 -89
- package/src/components/AnimatePresence.tsx +10 -1
- package/src/components/Avatar.tsx +2 -0
- package/src/components/Breadcrumbs.tsx +29 -0
- package/src/components/Checkbox.tsx +6 -1
- package/src/components/Collection.tsx +74 -0
- package/src/components/Combobox.tsx +75 -56
- package/src/components/ComboboxMenu.tsx +61 -28
- package/src/components/CommandPalette.tsx +69 -0
- package/src/components/ContextMenu.tsx +33 -134
- package/src/components/DropdownMenu.tsx +46 -155
- package/src/components/EditableText.tsx +203 -0
- package/src/components/Fade.tsx +62 -0
- package/src/components/Grid.tsx +243 -0
- package/src/components/GridView.tsx +86 -96
- package/src/components/InputField.tsx +109 -133
- package/src/components/Label.tsx +81 -7
- package/src/components/LabeledField.tsx +112 -0
- package/src/components/List.tsx +268 -0
- package/src/components/ListView.tsx +65 -61
- package/src/components/Popover.tsx +12 -1
- package/src/components/SearchCompletionMenu.tsx +210 -0
- package/src/components/SegmentedControl.tsx +12 -6
- package/src/components/SelectMenu.tsx +110 -126
- package/src/components/Slider.tsx +18 -26
- package/src/components/Text.tsx +1 -0
- package/src/components/TextArea.tsx +4 -1
- package/src/components/Toolbar.tsx +9 -4
- package/src/components/TreeView.tsx +4 -0
- package/src/components/WorkspaceLayout.tsx +64 -20
- package/src/components/internal/Menu.tsx +107 -18
- package/src/components/internal/MenuViewport.tsx +152 -0
- package/src/components/internal/SelectItem.tsx +111 -0
- package/src/hooks/useIndent.ts +12 -0
- package/src/hooks/useLabel.ts +51 -0
- package/src/hooks/usePreservePanelSize.tsx +50 -19
- package/src/index.tsx +15 -6
- package/src/utils/combobox.ts +116 -101
- package/src/utils/createSectionedMenu.ts +11 -14
- package/src/utils/fuzzyScorer.ts +11 -8
- package/src/utils/selection.ts +48 -0
package/src/utils/combobox.ts
CHANGED
|
@@ -1,80 +1,76 @@
|
|
|
1
1
|
import { chunkBy, partition } from "@noya-app/noya-utils";
|
|
2
|
-
import {
|
|
2
|
+
import { useSyncExternalStore } from "react";
|
|
3
|
+
import {
|
|
4
|
+
isMenuItemSectionHeader,
|
|
5
|
+
isNonSelectableMenuItem,
|
|
6
|
+
isSelectableMenuItem,
|
|
7
|
+
MenuItem,
|
|
8
|
+
ScoredMenuItem,
|
|
9
|
+
SectionHeaderMenuItem,
|
|
10
|
+
SelectableMenuItem,
|
|
11
|
+
} from "../components/internal/Menu";
|
|
3
12
|
import { fuzzyFilter, IScoredItem } from "./fuzzyScorer";
|
|
4
13
|
|
|
5
|
-
export type
|
|
6
|
-
type?: undefined;
|
|
7
|
-
id: string;
|
|
8
|
-
name: string;
|
|
9
|
-
icon?: ReactNode;
|
|
10
|
-
alwaysInclude?: boolean;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export type ComboboxSectionHeader = {
|
|
14
|
-
type: "sectionHeader";
|
|
15
|
-
id: string;
|
|
16
|
-
name: string;
|
|
17
|
-
maxVisibleItems?: number;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type ComboboxItem = ComboboxOption | ComboboxSectionHeader;
|
|
21
|
-
|
|
22
|
-
export type InternalComboboxItem =
|
|
23
|
-
| (ComboboxOption & IScoredItem)
|
|
24
|
-
| ComboboxSectionHeader;
|
|
25
|
-
|
|
26
|
-
export type ComboboxStateSnapshot = {
|
|
14
|
+
export type ComboboxStateSnapshot<T extends string> = {
|
|
27
15
|
filter: string;
|
|
28
16
|
selectedIndex: number;
|
|
29
|
-
filteredItems:
|
|
17
|
+
filteredItems: ScoredMenuItem<T>[];
|
|
30
18
|
lastSubmittedValue: { filter: string; itemName: string };
|
|
31
19
|
};
|
|
32
20
|
|
|
33
|
-
export class ComboboxState {
|
|
21
|
+
export class ComboboxState<T extends string> {
|
|
34
22
|
private filter: string;
|
|
35
23
|
private selectedIndex: number;
|
|
36
|
-
private items:
|
|
24
|
+
private items: MenuItem<T>[];
|
|
37
25
|
private lastSubmittedValue: { filter: string; itemName: string };
|
|
38
26
|
private subscribers: (() => void)[] = [];
|
|
39
27
|
private version = 0;
|
|
40
|
-
private _cachedSnapshot: [number, ComboboxStateSnapshot] | undefined;
|
|
28
|
+
private _cachedSnapshot: [number, ComboboxStateSnapshot<T>] | undefined;
|
|
41
29
|
|
|
42
30
|
constructor(
|
|
43
|
-
items:
|
|
44
|
-
|
|
31
|
+
items: MenuItem<T>[],
|
|
32
|
+
initialTitle: string,
|
|
45
33
|
allowArbitraryValues: boolean
|
|
46
34
|
) {
|
|
47
35
|
this.items = items;
|
|
48
36
|
this.lastSubmittedValue = {
|
|
49
|
-
filter:
|
|
50
|
-
itemName:
|
|
37
|
+
filter: initialTitle,
|
|
38
|
+
itemName: initialTitle,
|
|
51
39
|
};
|
|
52
40
|
|
|
53
41
|
// Initialize with the initial value
|
|
54
|
-
this.filter =
|
|
42
|
+
this.filter = initialTitle;
|
|
55
43
|
this.selectedIndex = 0;
|
|
56
44
|
|
|
57
45
|
// Create initial snapshot
|
|
58
46
|
this._cachedSnapshot = [
|
|
59
47
|
0,
|
|
60
48
|
{
|
|
61
|
-
filter:
|
|
49
|
+
filter: initialTitle,
|
|
62
50
|
selectedIndex: 0,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
51
|
+
|
|
52
|
+
filteredItems: this.items.flatMap((item, index) =>
|
|
53
|
+
isMenuItemSectionHeader(item)
|
|
54
|
+
? item
|
|
55
|
+
: isSelectableMenuItem(item)
|
|
56
|
+
? {
|
|
57
|
+
...item,
|
|
58
|
+
index,
|
|
59
|
+
score: 0,
|
|
60
|
+
}
|
|
61
|
+
: []
|
|
62
|
+
),
|
|
67
63
|
lastSubmittedValue: this.lastSubmittedValue,
|
|
68
64
|
},
|
|
69
65
|
];
|
|
70
66
|
|
|
71
67
|
// If there's an initial value, validate it
|
|
72
|
-
if (
|
|
68
|
+
if (initialTitle) {
|
|
73
69
|
// Check if the initial value exists in items
|
|
74
|
-
const hasMatch = items.some(
|
|
70
|
+
const hasMatch = this.items.some(
|
|
75
71
|
(item) =>
|
|
76
|
-
item.type
|
|
77
|
-
item.
|
|
72
|
+
item.type === undefined &&
|
|
73
|
+
item.title?.toString().toLowerCase() === initialTitle.toLowerCase()
|
|
78
74
|
);
|
|
79
75
|
|
|
80
76
|
// Get filtered items to check for fuzzy matches
|
|
@@ -89,10 +85,20 @@ export class ComboboxState {
|
|
|
89
85
|
{
|
|
90
86
|
filter: "",
|
|
91
87
|
selectedIndex: 0,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
|
|
89
|
+
filteredItems: this.items.flatMap((item): ScoredMenuItem<T>[] =>
|
|
90
|
+
isSelectableMenuItem(item)
|
|
91
|
+
? [
|
|
92
|
+
{
|
|
93
|
+
...item,
|
|
94
|
+
index: 0,
|
|
95
|
+
score: 0,
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
: item.type === "sectionHeader"
|
|
99
|
+
? [item]
|
|
100
|
+
: []
|
|
101
|
+
),
|
|
96
102
|
lastSubmittedValue: this.lastSubmittedValue,
|
|
97
103
|
},
|
|
98
104
|
];
|
|
@@ -113,11 +119,11 @@ export class ComboboxState {
|
|
|
113
119
|
this.subscribers.forEach((listener) => listener());
|
|
114
120
|
}
|
|
115
121
|
|
|
116
|
-
private getFilteredItems():
|
|
122
|
+
private getFilteredItems(): ScoredMenuItem<T>[] {
|
|
117
123
|
return filterWithGroupedSections(this.items, this.filter);
|
|
118
124
|
}
|
|
119
125
|
|
|
120
|
-
getSnapshot = (): ComboboxStateSnapshot => {
|
|
126
|
+
getSnapshot = (): ComboboxStateSnapshot<T> => {
|
|
121
127
|
if (this._cachedSnapshot && this._cachedSnapshot[0] === this.version) {
|
|
122
128
|
return this._cachedSnapshot[1];
|
|
123
129
|
}
|
|
@@ -133,7 +139,7 @@ export class ComboboxState {
|
|
|
133
139
|
return snapshot;
|
|
134
140
|
};
|
|
135
141
|
|
|
136
|
-
setItems(items:
|
|
142
|
+
setItems(items: MenuItem<T>[]) {
|
|
137
143
|
this.items = items;
|
|
138
144
|
this.notifyChange();
|
|
139
145
|
}
|
|
@@ -145,13 +151,13 @@ export class ComboboxState {
|
|
|
145
151
|
// Find exact match if it exists (case insensitive)
|
|
146
152
|
const exactMatchIndex = filteredItems.findIndex(
|
|
147
153
|
(item) =>
|
|
148
|
-
item
|
|
149
|
-
item.
|
|
154
|
+
isSelectableMenuItem(item) &&
|
|
155
|
+
item.title?.toString().toLowerCase().startsWith(filter.toLowerCase())
|
|
150
156
|
);
|
|
151
157
|
|
|
152
158
|
// Find the first non-header item
|
|
153
|
-
const firstSelectableIndex = filteredItems.findIndex(
|
|
154
|
-
(item)
|
|
159
|
+
const firstSelectableIndex = filteredItems.findIndex((item): boolean =>
|
|
160
|
+
isSelectableMenuItem(item)
|
|
155
161
|
);
|
|
156
162
|
|
|
157
163
|
this.selectedIndex =
|
|
@@ -166,29 +172,28 @@ export class ComboboxState {
|
|
|
166
172
|
filteredItems,
|
|
167
173
|
this.selectedIndex,
|
|
168
174
|
direction === "down" ? "next" : "previous",
|
|
169
|
-
(item) => item
|
|
175
|
+
(item) => isNonSelectableMenuItem(item)
|
|
170
176
|
);
|
|
171
177
|
this.notifyChange();
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
selectCurrentItem(
|
|
175
|
-
item?:
|
|
176
|
-
):
|
|
181
|
+
item?: ScoredMenuItem<T>
|
|
182
|
+
): SelectableMenuItem<T> | undefined {
|
|
177
183
|
const itemToSelect = item || this.getFilteredItems()[this.selectedIndex];
|
|
178
|
-
if (
|
|
184
|
+
if (itemToSelect && isSelectableMenuItem(itemToSelect)) {
|
|
185
|
+
this.lastSubmittedValue = {
|
|
186
|
+
filter: itemToSelect.title!.toString(),
|
|
187
|
+
itemName: itemToSelect.title!.toString(),
|
|
188
|
+
};
|
|
189
|
+
this.filter = itemToSelect.title!.toString();
|
|
190
|
+
this.notifyChange();
|
|
191
|
+
return itemToSelect;
|
|
192
|
+
} else {
|
|
179
193
|
// If no valid item to select and arbitrary values aren't allowed,
|
|
180
194
|
// restore the last valid value
|
|
181
195
|
this.restoreLastSubmittedValue();
|
|
182
|
-
return;
|
|
183
196
|
}
|
|
184
|
-
|
|
185
|
-
this.lastSubmittedValue = {
|
|
186
|
-
filter: itemToSelect.name,
|
|
187
|
-
itemName: itemToSelect.name,
|
|
188
|
-
};
|
|
189
|
-
this.filter = itemToSelect.name;
|
|
190
|
-
this.notifyChange();
|
|
191
|
-
return itemToSelect;
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
restoreLastSubmittedValue() {
|
|
@@ -202,25 +207,25 @@ export class ComboboxState {
|
|
|
202
207
|
this.notifyChange();
|
|
203
208
|
}
|
|
204
209
|
|
|
205
|
-
getSelectedItem():
|
|
210
|
+
getSelectedItem(): ScoredMenuItem<T> | undefined {
|
|
206
211
|
const filteredItems = this.getFilteredItems();
|
|
207
212
|
return filteredItems[this.selectedIndex];
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
getTypeaheadValue(): string | undefined {
|
|
211
216
|
const selectedItem = this.getSelectedItem();
|
|
212
|
-
if (
|
|
213
|
-
|
|
217
|
+
if (selectedItem && isSelectableMenuItem(selectedItem))
|
|
218
|
+
return selectedItem.title!.toString();
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
setSelectedIndex(index: number) {
|
|
217
222
|
const filteredItems = this.getFilteredItems();
|
|
218
223
|
|
|
219
|
-
// If trying to select a header, find the next selectable item
|
|
220
|
-
if (filteredItems[index]
|
|
221
|
-
// Look for the next
|
|
224
|
+
// If trying to select a header or separator, find the next selectable item
|
|
225
|
+
if (isNonSelectableMenuItem(filteredItems[index])) {
|
|
226
|
+
// Look for the next selectable item
|
|
222
227
|
for (let i = index + 1; i < filteredItems.length; i++) {
|
|
223
|
-
if (filteredItems[i]
|
|
228
|
+
if (isSelectableMenuItem(filteredItems[i])) {
|
|
224
229
|
this.selectedIndex = i;
|
|
225
230
|
this.notifyChange();
|
|
226
231
|
return;
|
|
@@ -228,7 +233,7 @@ export class ComboboxState {
|
|
|
228
233
|
}
|
|
229
234
|
// If no next item, look backwards
|
|
230
235
|
for (let i = index - 1; i >= 0; i--) {
|
|
231
|
-
if (filteredItems[i]
|
|
236
|
+
if (isSelectableMenuItem(filteredItems[i])) {
|
|
232
237
|
this.selectedIndex = i;
|
|
233
238
|
this.notifyChange();
|
|
234
239
|
return;
|
|
@@ -252,18 +257,18 @@ export class ComboboxState {
|
|
|
252
257
|
}
|
|
253
258
|
}
|
|
254
259
|
|
|
255
|
-
function filterWithGroupedSections(
|
|
256
|
-
items:
|
|
260
|
+
function filterWithGroupedSections<T extends string>(
|
|
261
|
+
items: MenuItem<T>[],
|
|
257
262
|
query: string
|
|
258
|
-
):
|
|
259
|
-
const sections = chunkBy(items, (_a, b) => b
|
|
260
|
-
let result:
|
|
263
|
+
): ScoredMenuItem<T>[] {
|
|
264
|
+
const sections = chunkBy(items, (_a, b) => isSelectableMenuItem(b));
|
|
265
|
+
let result: ScoredMenuItem<T>[] = [];
|
|
261
266
|
let currentIndex = 0;
|
|
262
267
|
|
|
263
268
|
const createExtraItem = (
|
|
264
|
-
item:
|
|
269
|
+
item: SelectableMenuItem<T>,
|
|
265
270
|
index: number
|
|
266
|
-
):
|
|
271
|
+
): ScoredMenuItem<T> => ({
|
|
267
272
|
...item,
|
|
268
273
|
index: currentIndex + index,
|
|
269
274
|
score: 0,
|
|
@@ -271,62 +276,72 @@ function filterWithGroupedSections(
|
|
|
271
276
|
|
|
272
277
|
const createScoredItem = (
|
|
273
278
|
item: IScoredItem,
|
|
274
|
-
|
|
275
|
-
):
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
279
|
+
selectableItems: Array<SelectableMenuItem<T>>
|
|
280
|
+
): ScoredMenuItem<T> => {
|
|
281
|
+
const menuItem = selectableItems[item.index];
|
|
282
|
+
return {
|
|
283
|
+
...item,
|
|
284
|
+
...menuItem,
|
|
285
|
+
index: currentIndex + item.index,
|
|
286
|
+
};
|
|
287
|
+
};
|
|
280
288
|
|
|
281
289
|
for (const section of sections) {
|
|
282
|
-
const [
|
|
283
|
-
section
|
|
284
|
-
|
|
290
|
+
const [sectionHeaders, selectableItems] = partition(
|
|
291
|
+
section.filter(
|
|
292
|
+
(item): item is SelectableMenuItem<T> | SectionHeaderMenuItem =>
|
|
293
|
+
item.type !== "submenu" && item.type !== "separator"
|
|
294
|
+
),
|
|
295
|
+
(item): item is SectionHeaderMenuItem => isMenuItemSectionHeader(item)
|
|
285
296
|
);
|
|
286
297
|
|
|
287
|
-
let newItems:
|
|
298
|
+
let newItems: ScoredMenuItem<T>[];
|
|
288
299
|
|
|
289
300
|
if (!query.trim()) {
|
|
290
301
|
// For empty query, include all items with score 0
|
|
291
|
-
newItems =
|
|
302
|
+
newItems = selectableItems.map((item, index) =>
|
|
303
|
+
createExtraItem(item, index)
|
|
304
|
+
);
|
|
292
305
|
} else {
|
|
293
306
|
const scoredItems = fuzzyFilter({
|
|
294
|
-
items:
|
|
307
|
+
items: selectableItems.map((item) => item.title.toString()),
|
|
295
308
|
query,
|
|
296
309
|
});
|
|
297
310
|
|
|
298
311
|
const usedIndexes = new Set(scoredItems.map((item) => item.index));
|
|
299
|
-
const extraItems =
|
|
300
|
-
(item, index):
|
|
301
|
-
item
|
|
312
|
+
const extraItems = selectableItems.flatMap(
|
|
313
|
+
(item, index): ScoredMenuItem<T>[] =>
|
|
314
|
+
item?.alwaysInclude && !usedIndexes.has(index)
|
|
302
315
|
? [createExtraItem(item, index)]
|
|
303
316
|
: []
|
|
304
317
|
);
|
|
305
318
|
|
|
306
319
|
newItems = scoredItems
|
|
307
|
-
.map((item) => createScoredItem(item,
|
|
320
|
+
.map((item) => createScoredItem(item, selectableItems))
|
|
308
321
|
.concat(extraItems);
|
|
309
322
|
}
|
|
310
323
|
|
|
311
324
|
// If we have items or this is an empty query
|
|
312
325
|
if (newItems.length > 0 || !query.trim()) {
|
|
313
326
|
const maxVisibleItems =
|
|
314
|
-
|
|
327
|
+
sectionHeaders.length > 0 && sectionHeaders[0]?.maxVisibleItems
|
|
328
|
+
? sectionHeaders[0]?.maxVisibleItems
|
|
329
|
+
: undefined;
|
|
315
330
|
|
|
316
331
|
if (maxVisibleItems !== undefined) {
|
|
317
332
|
newItems = newItems.slice(0, maxVisibleItems);
|
|
318
333
|
}
|
|
319
334
|
|
|
320
|
-
result.push(...
|
|
335
|
+
result.push(...sectionHeaders, ...newItems);
|
|
321
336
|
}
|
|
322
337
|
|
|
323
|
-
currentIndex +=
|
|
338
|
+
currentIndex += selectableItems.length;
|
|
324
339
|
}
|
|
325
340
|
|
|
326
341
|
return result;
|
|
327
342
|
}
|
|
328
343
|
|
|
329
|
-
function getNextIndex<T>(
|
|
344
|
+
export function getNextIndex<T>(
|
|
330
345
|
items: T[],
|
|
331
346
|
currentIndex: number,
|
|
332
347
|
direction: "next" | "previous",
|
|
@@ -360,7 +375,7 @@ function getNextIndex<T>(
|
|
|
360
375
|
return nextIndex;
|
|
361
376
|
}
|
|
362
377
|
|
|
363
|
-
export function useComboboxState(state: ComboboxState) {
|
|
378
|
+
export function useComboboxState<T extends string>(state: ComboboxState<T>) {
|
|
364
379
|
return useSyncExternalStore(
|
|
365
380
|
state.subscribe,
|
|
366
381
|
state.getSnapshot,
|
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
MenuItem,
|
|
3
|
-
RegularMenuItem,
|
|
4
|
-
SEPARATOR_ITEM,
|
|
5
|
-
} from '../components/internal/Menu';
|
|
1
|
+
import { MenuItem, SeparatorItem } from "../components/internal/Menu";
|
|
6
2
|
|
|
7
3
|
export type Optional<T> = T | false | null | undefined;
|
|
8
4
|
|
|
9
|
-
function withSeparators<T>(
|
|
10
|
-
|
|
5
|
+
function withSeparators<T extends string>(
|
|
6
|
+
elements: MenuItem<T>[][],
|
|
7
|
+
separator: SeparatorItem
|
|
8
|
+
): MenuItem<T>[] {
|
|
9
|
+
const result: MenuItem<T>[] = [];
|
|
11
10
|
|
|
12
11
|
for (let i = 0; i < elements.length; i++) {
|
|
13
|
-
result.push(elements[i]);
|
|
12
|
+
result.push(...elements[i]);
|
|
14
13
|
|
|
15
14
|
if (i !== elements.length - 1) {
|
|
16
15
|
result.push(separator);
|
|
@@ -20,9 +19,7 @@ function withSeparators<T>(elements: T[], separator: T) {
|
|
|
20
19
|
return result;
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
export type MenuConfig<T extends string> = Optional<
|
|
24
|
-
Optional<RegularMenuItem<T>>[]
|
|
25
|
-
>[];
|
|
22
|
+
export type MenuConfig<T extends string> = Optional<Optional<MenuItem<T>>[]>[];
|
|
26
23
|
|
|
27
24
|
export function createSectionedMenu<T extends string>(
|
|
28
25
|
...sections: MenuConfig<T>
|
|
@@ -32,7 +29,7 @@ export function createSectionedMenu<T extends string>(
|
|
|
32
29
|
.map((section) => section.flatMap((item) => (item ? [item] : [])))
|
|
33
30
|
.filter((section) => section.length > 0);
|
|
34
31
|
|
|
35
|
-
return withSeparators
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
return withSeparators(nonEmptySections, {
|
|
33
|
+
type: "separator",
|
|
34
|
+
}).flat();
|
|
38
35
|
}
|
package/src/utils/fuzzyScorer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IItemScore, scoreFilePathFuzzy } from
|
|
1
|
+
import { IItemScore, scoreFilePathFuzzy } from "vscode-fuzzy-scorer";
|
|
2
2
|
|
|
3
3
|
export function fuzzyScore({ item, query }: { item: string; query: string }) {
|
|
4
4
|
return scoreFilePathFuzzy({ path: item, query });
|
|
@@ -21,7 +21,7 @@ export function fuzzyFilter({
|
|
|
21
21
|
(text, index): IScoredItem => ({
|
|
22
22
|
index,
|
|
23
23
|
...fuzzyScore({ item: text, query }),
|
|
24
|
-
})
|
|
24
|
+
})
|
|
25
25
|
);
|
|
26
26
|
|
|
27
27
|
if (!query) return scoredItems;
|
|
@@ -32,8 +32,8 @@ export function fuzzyFilter({
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export type IToken =
|
|
35
|
-
| { type:
|
|
36
|
-
| { type:
|
|
35
|
+
| { type: "text"; text: string }
|
|
36
|
+
| { type: "match"; text: string };
|
|
37
37
|
|
|
38
38
|
export type MatchRange = { start: number; end: number };
|
|
39
39
|
|
|
@@ -44,8 +44,11 @@ export function fuzzyTokenize({
|
|
|
44
44
|
item: string;
|
|
45
45
|
itemScore: IItemScore;
|
|
46
46
|
}): IToken[] {
|
|
47
|
-
|
|
47
|
+
if (typeof item !== "string") {
|
|
48
|
+
return [{ type: "text", text: String(item) }];
|
|
49
|
+
}
|
|
48
50
|
|
|
51
|
+
const tokens: IToken[] = [];
|
|
49
52
|
let lastMatchIndex = 0;
|
|
50
53
|
|
|
51
54
|
const matches: MatchRange[] = mergeRanges([
|
|
@@ -56,13 +59,13 @@ export function fuzzyTokenize({
|
|
|
56
59
|
for (const match of matches) {
|
|
57
60
|
if (match.start > lastMatchIndex) {
|
|
58
61
|
tokens.push({
|
|
59
|
-
type:
|
|
62
|
+
type: "text",
|
|
60
63
|
text: item.slice(lastMatchIndex, match.start),
|
|
61
64
|
});
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
tokens.push({
|
|
65
|
-
type:
|
|
68
|
+
type: "match",
|
|
66
69
|
text: item.slice(match.start, match.end),
|
|
67
70
|
});
|
|
68
71
|
|
|
@@ -71,7 +74,7 @@ export function fuzzyTokenize({
|
|
|
71
74
|
|
|
72
75
|
if (lastMatchIndex < item.length) {
|
|
73
76
|
tokens.push({
|
|
74
|
-
type:
|
|
77
|
+
type: "text",
|
|
75
78
|
text: item.slice(lastMatchIndex),
|
|
76
79
|
});
|
|
77
80
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Updates the selection state based on user interaction.
|
|
3
|
+
* Handles single selection, multi-selection with meta/ctrl, and range selection with shift.
|
|
4
|
+
*/
|
|
5
|
+
export function updateSelection(
|
|
6
|
+
allIds: string[],
|
|
7
|
+
selectedIds: string[],
|
|
8
|
+
clickedId: string,
|
|
9
|
+
event?: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }
|
|
10
|
+
): string[] {
|
|
11
|
+
const metaOrCtrl = event?.metaKey || event?.ctrlKey;
|
|
12
|
+
|
|
13
|
+
if (!event || (!event.shiftKey && !metaOrCtrl)) {
|
|
14
|
+
// Store this as the anchor point for future shift selections
|
|
15
|
+
return [clickedId];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (metaOrCtrl) {
|
|
19
|
+
const isSelected = selectedIds.includes(clickedId);
|
|
20
|
+
return isSelected
|
|
21
|
+
? selectedIds.filter((id) => id !== clickedId)
|
|
22
|
+
: [...selectedIds, clickedId];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (event.shiftKey && selectedIds.length > 0) {
|
|
26
|
+
// Find the anchor based on which end of the current selection is closer to the clicked item
|
|
27
|
+
const firstSelectedIndex = allIds.indexOf(selectedIds[0]);
|
|
28
|
+
const lastSelectedIndex = allIds.indexOf(
|
|
29
|
+
selectedIds[selectedIds.length - 1]
|
|
30
|
+
);
|
|
31
|
+
const clickedIndex = allIds.indexOf(clickedId);
|
|
32
|
+
|
|
33
|
+
// Use the endpoint of the current selection that's furthest from the clicked item as the anchor
|
|
34
|
+
const anchorId =
|
|
35
|
+
Math.abs(clickedIndex - firstSelectedIndex) >
|
|
36
|
+
Math.abs(clickedIndex - lastSelectedIndex)
|
|
37
|
+
? selectedIds[0]
|
|
38
|
+
: selectedIds[selectedIds.length - 1];
|
|
39
|
+
|
|
40
|
+
const anchorIndex = allIds.indexOf(anchorId);
|
|
41
|
+
const start = Math.min(anchorIndex, clickedIndex);
|
|
42
|
+
const end = Math.max(anchorIndex, clickedIndex);
|
|
43
|
+
|
|
44
|
+
return allIds.slice(start, end + 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return [clickedId];
|
|
48
|
+
}
|