Skip to content

Commit ab01816

Browse files
committed
feat: Select - type to filter and navigate
Add the ability to navigate the options of the Select dropdown input with the up and down keys. If typing text, filter the results based on whether the display label includes the typed text. Also - Generic types for the Select Items list - Some other adaptions necessary in the component consumers
1 parent 12f3933 commit ab01816

File tree

5 files changed

+162
-19
lines changed

5 files changed

+162
-19
lines changed

app/src/lib/ai/types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ export interface AIClient {
3939
defaultCommitTemplate: Prompt;
4040
}
4141

42-
export interface UserPrompt {
42+
export type UserPrompt = {
4343
id: string;
4444
name: string;
4545
prompt: Prompt;
46-
}
46+
};
4747

4848
export interface Prompts {
4949
defaultPrompt: Prompt;

app/src/lib/components/BaseBranchSwitch.svelte

+16-2
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@
7676
wide={true}
7777
label="Current target branch"
7878
>
79-
<SelectItem slot="template" let:item let:selected {selected}>
79+
<SelectItem
80+
slot="template"
81+
let:item
82+
let:selected
83+
{selected}
84+
let:highlighted
85+
{highlighted}
86+
>
8087
{item.name}
8188
</SelectItem>
8289
</Select>
@@ -91,7 +98,14 @@
9198
disabled={targetChangeDisabled}
9299
label="Create branches on remote"
93100
>
94-
<SelectItem slot="template" let:item let:selected {selected}>
101+
<SelectItem
102+
slot="template"
103+
let:item
104+
let:selected
105+
{selected}
106+
let:highlighted
107+
{highlighted}
108+
>
95109
{item.name}
96110
</SelectItem>
97111
</Select>

app/src/lib/components/ProjectSwitcher.svelte

+19-4
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,26 @@
99
const projectService = getContext(ProjectService);
1010
const project = maybeGetContext(Project);
1111
12-
const projects = projectService.projects;
12+
type ProjectRecord = {
13+
id: string;
14+
title: string;
15+
};
16+
17+
let mappedProjects: ProjectRecord[] = [];
18+
19+
projectService.projects.subscribe((projectList) => {
20+
// Map the projectList to fit the ProjectRecord type
21+
mappedProjects = projectList.map((project) => {
22+
return {
23+
id: project.id,
24+
title: project.title
25+
};
26+
});
27+
});
1328
1429
let loading = false;
15-
let select: Select;
16-
let selectValue = project;
30+
let select: Select<ProjectRecord>;
31+
let selectValue: ProjectRecord | undefined = project;
1732
</script>
1833

1934
<div class="project-switcher">
@@ -22,7 +37,7 @@
2237
label="Switch to another project"
2338
itemId="id"
2439
labelId="title"
25-
items={$projects}
40+
items={mappedProjects}
2641
placeholder="Select a project..."
2742
wide
2843
bind:value={selectValue}

app/src/lib/components/Select.svelte

+113-10
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,30 @@
1-
<script lang="ts">
1+
<script lang="ts" context="module">
2+
export type SelectItemType<S extends string> = Record<S, unknown>;
3+
</script>
4+
5+
<script lang="ts" generics="SelectItemType extends Record<string, unknown>">
26
import ScrollableContainer from './ScrollableContainer.svelte';
37
import TextBox from './TextBox.svelte';
48
import { clickOutside } from '$lib/clickOutside';
9+
import { filterStringByKey } from '$lib/utils/filters';
10+
import { KeyName } from '$lib/utils/hotkeys';
11+
import { throttle } from '$lib/utils/misc';
512
import { pxToRem } from '$lib/utils/pxToRem';
13+
import { isChar } from '$lib/utils/string';
614
import { createEventDispatcher } from 'svelte';
715
16+
const INPUT_THROTTLE_TIME = 100;
17+
18+
type SelectItemKeyType = keyof SelectItemType;
19+
820
export let id: undefined | string = undefined;
921
export let label = '';
1022
export let disabled = false;
1123
export let loading = false;
1224
export let wide = false;
13-
export let items: any[];
14-
export let labelId = 'label';
15-
export let itemId = 'value';
25+
export let items: SelectItemType[];
26+
export let labelId: SelectItemKeyType = 'label';
27+
export let itemId: SelectItemKeyType = 'value';
1628
export let value: any = undefined;
1729
export let selectedItemId: any = undefined;
1830
export let placeholder = '';
@@ -27,8 +39,21 @@
2739
let listOpen = false;
2840
let element: HTMLElement;
2941
let options: HTMLDivElement;
42+
let highlightIndex: number | undefined = undefined;
43+
let highlightedItem: SelectItemType | undefined = undefined;
44+
let filterText: string | undefined = undefined;
45+
let filteredItems: SelectItemType[] = items;
3046
31-
function handleItemClick(item: any) {
47+
$: filterText === undefined
48+
? (filteredItems = items)
49+
: (filteredItems = filterStringByKey(items, labelId, filterText));
50+
51+
// Set highlighted item based on index
52+
$: highlightIndex !== undefined
53+
? (highlightedItem = filteredItems[highlightIndex])
54+
: (highlightedItem = undefined);
55+
56+
function handleItemClick(item: SelectItemType) {
3257
if (item?.selectable === false) return;
3358
if (value && value[itemId] === item[itemId]) return closeList();
3459
selectedItemId = item[itemId];
@@ -52,6 +77,78 @@
5277
5378
function closeList() {
5479
listOpen = false;
80+
highlightIndex = undefined;
81+
filterText = undefined;
82+
}
83+
84+
function handleEnter() {
85+
if (highlightIndex !== undefined) {
86+
handleItemClick(filteredItems[highlightIndex]);
87+
}
88+
closeList();
89+
}
90+
91+
function handleArrowUp() {
92+
if (highlightIndex === undefined) {
93+
highlightIndex = filteredItems.length - 1;
94+
} else {
95+
highlightIndex = highlightIndex === 0 ? filteredItems.length - 1 : highlightIndex - 1;
96+
}
97+
}
98+
99+
function handleArrowDown() {
100+
if (highlightIndex === undefined) {
101+
highlightIndex = 0;
102+
} else {
103+
highlightIndex = highlightIndex === filteredItems.length - 1 ? 0 : highlightIndex + 1;
104+
}
105+
}
106+
107+
const handleChar = throttle((char: string) => {
108+
highlightIndex = undefined;
109+
filterText ??= '';
110+
filterText += char;
111+
}, INPUT_THROTTLE_TIME);
112+
113+
const handleDelete = throttle(() => {
114+
if (filterText === undefined) return;
115+
116+
if (filterText.length === 1) {
117+
filterText = undefined;
118+
return;
119+
}
120+
121+
filterText = filterText.slice(0, -1);
122+
}, INPUT_THROTTLE_TIME);
123+
124+
function handleKeyDown(event: CustomEvent<KeyboardEvent>) {
125+
if (!listOpen) {
126+
return;
127+
}
128+
event.detail.stopPropagation();
129+
event.detail.preventDefault();
130+
131+
const { key } = event.detail;
132+
switch (key) {
133+
case KeyName.Escape:
134+
closeList();
135+
break;
136+
case KeyName.Up:
137+
handleArrowUp();
138+
break;
139+
case KeyName.Down:
140+
handleArrowDown();
141+
break;
142+
case KeyName.Enter:
143+
handleEnter();
144+
break;
145+
case KeyName.Delete:
146+
handleDelete();
147+
break;
148+
default:
149+
if (isChar(key)) handleChar(key);
150+
break;
151+
}
55152
}
56153
</script>
57154

@@ -67,9 +164,10 @@
67164
type="select"
68165
reversedDirection
69166
icon="select-chevron"
70-
value={value?.[labelId]}
167+
value={filterText ?? value?.[labelId]}
71168
disabled={disabled || loading}
72169
on:mousedown={() => toggleList()}
170+
on:keydown={(ev) => handleKeyDown(ev)}
73171
/>
74172
<div
75173
class="options card"
@@ -78,14 +176,14 @@
78176
style:max-height={maxHeight && pxToRem(maxHeight)}
79177
use:clickOutside={{
80178
trigger: element,
81-
handler: () => (listOpen = !listOpen),
179+
handler: closeList,
82180
enabled: listOpen
83181
}}
84182
>
85183
<ScrollableContainer initiallyVisible>
86-
{#if items}
184+
{#if filteredItems}
87185
<div class="options__group">
88-
{#each items as item}
186+
{#each filteredItems as item}
89187
<div
90188
class="option"
91189
class:selected={item == value}
@@ -94,7 +192,12 @@
94192
on:mousedown={() => handleItemClick(item)}
95193
on:keydown|preventDefault|stopPropagation
96194
>
97-
<slot name="template" {item} selected={item == value} />
195+
<slot
196+
name="template"
197+
{item}
198+
selected={item == value}
199+
highlighted={item === highlightedItem}
200+
/>
98201
</div>
99202
{/each}
100203
</div>

app/src/lib/components/SelectItem.svelte

+12-1
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@
77
export let selected = false;
88
export let disabled = false;
99
export let loading = false;
10+
export let highlighted = false;
1011
export let value: string | undefined = undefined;
1112
1213
const dispatch = createEventDispatcher<{ click: string | undefined }>();
1314
</script>
1415

15-
<button {disabled} class="button" class:selected on:click={() => dispatch('click', value)}>
16+
<button
17+
{disabled}
18+
class="button"
19+
class:selected
20+
class:highlighted
21+
on:click={() => dispatch('click', value)}
22+
>
1623
<div class="label text-base-13">
1724
<slot />
1825
</div>
@@ -67,4 +74,8 @@
6774
opacity: 0.5;
6875
}
6976
}
77+
78+
.highlighted {
79+
background-color: var(--clr-bg-3);
80+
}
7081
</style>

0 commit comments

Comments
 (0)