Skip to content

Commit 27e7568

Browse files
committed
wip 0520
1 parent a6d469a commit 27e7568

7 files changed

Lines changed: 430 additions & 55 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import {ReactiveController, ReactiveControllerHost} from 'lit';
2+
import {FilterMethod, InternalOption, Option} from './types';
3+
import {
4+
containsSearch,
5+
fuzzySearch,
6+
SearchResult,
7+
startsWithPerTermSearch,
8+
startsWithSearch,
9+
} from './helpers';
10+
11+
export class OptionListController implements ReactiveController {
12+
private _activeIndex = -1;
13+
private _host: ReactiveControllerHost;
14+
private _options: InternalOption[] = [];
15+
private _filterPattern = '';
16+
private _filterMethod: FilterMethod = 'fuzzy';
17+
private _combobox = false;
18+
private _indexByValue: Map<string, number> = new Map();
19+
private _selectedIndex = -1;
20+
private _selectedIndexes: number[] = [];
21+
private _multiSelect = false;
22+
23+
constructor(host: ReactiveControllerHost) {
24+
(this._host = host).addController(this);
25+
}
26+
27+
hostConnected(): void {}
28+
29+
set activeIndex(index: number) {
30+
this._activeIndex = index;
31+
}
32+
33+
get activeIndex(): number {
34+
return this._activeIndex;
35+
}
36+
37+
get relativeActiveIndex(): number {
38+
const activeOption = this._options[this._activeIndex];
39+
return activeOption.relativeIndex;
40+
}
41+
42+
get comboboxMode() {
43+
return this._combobox;
44+
}
45+
46+
set multiSelect(multiSelect: boolean) {
47+
this._multiSelect = multiSelect;
48+
this._host.requestUpdate();
49+
}
50+
51+
get multiSelect() {
52+
return this._multiSelect;
53+
}
54+
55+
set selectedIndex(value: number) {
56+
this._selectedIndex = value;
57+
this._host.requestUpdate();
58+
}
59+
60+
get selectedIndex() {
61+
return this._selectedIndex;
62+
}
63+
64+
get value(): string | string[] {
65+
if (this._multiSelect) {
66+
return this._selectedIndexes.length > 0
67+
? this._selectedIndexes.map((v) => this._options[v].value)
68+
: [];
69+
} else {
70+
return this._selectedIndex > -1
71+
? this._options[this._selectedIndex].value
72+
: '';
73+
}
74+
}
75+
76+
set value(newValue: string | string[]) {
77+
if (this._multiSelect) {
78+
this._selectedIndexes = (newValue as string[])
79+
.map((v) => this._indexByValue.get(v))
80+
.filter((v) => v !== undefined);
81+
} else {
82+
this._selectedIndex = this._indexByValue.get(newValue as string) ?? -1;
83+
}
84+
this._host.requestUpdate();
85+
}
86+
87+
set filterPattern(pattern: string) {
88+
this._filterPattern = pattern;
89+
this._updateState();
90+
}
91+
92+
get filterPattern() {
93+
return this._filterPattern;
94+
}
95+
96+
set filterMethod(method: FilterMethod) {
97+
this._filterMethod = method;
98+
this._updateState();
99+
}
100+
101+
get filterMethod(): FilterMethod {
102+
return this._filterMethod;
103+
}
104+
105+
populate(options: Option[]) {
106+
this._indexByValue.clear();
107+
108+
this._options = options.map((op, index) => {
109+
this._indexByValue.set(op.value, index);
110+
111+
return {
112+
description: op.description ?? '',
113+
disabled: op.disabled ?? false,
114+
label: op.label ?? '',
115+
selected: op.selected ?? false,
116+
value: op.value ?? '',
117+
index,
118+
relativeIndex: index,
119+
absoluteIndex: index,
120+
ranges: [],
121+
visible: true,
122+
};
123+
});
124+
}
125+
126+
add(option: Option) {
127+
const nextIndex = this._options.length;
128+
const {description, disabled, label, selected, value} = option;
129+
let visible = true;
130+
let ranges: [number, number][] = [];
131+
132+
if (this._combobox) {
133+
const res = this._searchByPattern(label);
134+
visible = res.match;
135+
ranges = res.ranges;
136+
}
137+
138+
this._indexByValue.set(value, nextIndex);
139+
140+
if (selected) {
141+
this._selectedIndex = nextIndex;
142+
}
143+
144+
this._options.push({
145+
index: nextIndex,
146+
relativeIndex: nextIndex,
147+
description,
148+
disabled,
149+
label,
150+
selected,
151+
value,
152+
visible,
153+
ranges,
154+
});
155+
}
156+
157+
clear() {
158+
this._options = [];
159+
}
160+
161+
get options(): InternalOption[] {
162+
return this._options;
163+
}
164+
165+
get numOfVisibleOptions() {
166+
return this._options.filter((o) => o.visible).length;
167+
}
168+
169+
get numOptions() {
170+
return this._options.length;
171+
}
172+
173+
toggleComboboxMode(enabled: boolean) {
174+
this._combobox = enabled;
175+
this._host.requestUpdate();
176+
}
177+
178+
getOptionByValue(value: string): InternalOption | null {
179+
const index = this._indexByValue.get(value) ?? -1;
180+
181+
if (index === -1) {
182+
return null;
183+
}
184+
185+
return this._options[index];
186+
}
187+
188+
getNextSelectableOption(fromIndex?: number): InternalOption | null {
189+
const from = fromIndex ?? this._activeIndex;
190+
191+
if (this._options.length === 0) {
192+
return null;
193+
}
194+
195+
if (this._options.length === 1) {
196+
return this._options[0];
197+
}
198+
199+
if (!this._options[from] || !this._options[from + 1]) {
200+
return this._options[this._options.length - 1];
201+
}
202+
203+
let nextIndex = -1;
204+
205+
for (let i = from + 1; i < this._options.length; i++) {
206+
if (
207+
this._options[i] &&
208+
!this._options[i].disabled &&
209+
this._options[i].visible
210+
) {
211+
nextIndex = i;
212+
break;
213+
}
214+
}
215+
216+
return nextIndex > -1 ? this._options[nextIndex] : null;
217+
}
218+
219+
getPrevSelectableOption(fromIndex?: number): InternalOption | null {
220+
const from = fromIndex ?? this._activeIndex;
221+
222+
if (this._options.length === 0) {
223+
return null;
224+
}
225+
226+
if (this._options.length === 1) {
227+
return this._options[0];
228+
}
229+
230+
if (!this._options[from] || !this._options[from - 1]) {
231+
return this._options[0];
232+
}
233+
234+
let prevIndex = -1;
235+
236+
for (let i = from - 1; i >= 0; i--) {
237+
if (
238+
this._options[i] &&
239+
!this._options[i].disabled &&
240+
this._options[i].visible
241+
) {
242+
prevIndex = i;
243+
break;
244+
}
245+
}
246+
247+
return prevIndex > -1 ? this._options[prevIndex] : null;
248+
}
249+
250+
activateNext() {
251+
const nextOp = this.getNextSelectableOption();
252+
this._activeIndex = nextOp?.index ?? -1;
253+
this._host.requestUpdate();
254+
return nextOp;
255+
}
256+
257+
selectPrev() {
258+
const prevOp = this.getPrevSelectableOption();
259+
this._activeIndex = prevOp?.index ?? -1;
260+
this._host.requestUpdate();
261+
return prevOp;
262+
}
263+
264+
private _searchByPattern(text: string) {
265+
let result: SearchResult;
266+
267+
switch (this._filterMethod) {
268+
case 'startsWithPerTerm':
269+
result = startsWithPerTermSearch(text, this._filterPattern);
270+
break;
271+
case 'startsWith':
272+
result = startsWithSearch(text, this._filterPattern);
273+
break;
274+
case 'contains':
275+
result = containsSearch(text, this._filterPattern);
276+
break;
277+
default:
278+
result = fuzzySearch(text, this._filterPattern);
279+
}
280+
281+
return result;
282+
}
283+
284+
_updateState() {
285+
if (!this._combobox || this._filterPattern === '') {
286+
this._options.forEach((_, i) => {
287+
this._options[i].visible = true;
288+
});
289+
290+
this._host.requestUpdate();
291+
return;
292+
}
293+
294+
let filteredListNextIndex = -1;
295+
296+
this._options.forEach(({label}, i) => {
297+
const result = this._searchByPattern(label);
298+
this._options[i].visible = result.match;
299+
this._options[i].ranges = result.ranges;
300+
this._options[i].relativeIndex = result.match
301+
? ++filteredListNextIndex
302+
: -1;
303+
});
304+
305+
this._host.requestUpdate();
306+
}
307+
}

src/includes/vscode-select/helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {html, TemplateResult} from 'lit';
2-
import {InternalOption, SearchMethod} from './types.js';
2+
import {InternalOption, FilterMethod} from './types.js';
33

44
export type SearchResult = {
55
match: boolean;
@@ -112,7 +112,7 @@ export const fuzzySearch = (subject: string, pattern: string): SearchResult => {
112112
export const filterOptionsByPattern = (
113113
list: InternalOption[],
114114
pattern: string,
115-
method: SearchMethod
115+
method: FilterMethod
116116
): InternalOption[] => {
117117
const filtered: InternalOption[] = [];
118118

src/includes/vscode-select/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// TODO: Make all property optional
12
export interface Option {
23
label: string;
34
value: string;
@@ -8,10 +9,15 @@ export interface Option {
89

910
export interface InternalOption extends Option {
1011
index: number;
12+
relativeIndex: number;
13+
/** The original index of the option in the non-filtered list. */
14+
absoluteIndex?: number;
15+
/** Character ranges to highlight matches in the filtered list. */
1116
ranges?: [number, number][];
17+
visible?: boolean;
1218
}
1319

14-
export type SearchMethod =
20+
export type FilterMethod =
1521
| 'startsWithPerTerm'
1622
| 'startsWith'
1723
| 'contains'

0 commit comments

Comments
 (0)