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
This commit is contained in:
estib 2024-06-04 15:29:46 +02:00
parent 75383cda21
commit 74aed280d2
5 changed files with 162 additions and 19 deletions

View File

@ -39,11 +39,11 @@ export interface AIClient {
defaultCommitTemplate: Prompt;
}
export interface UserPrompt {
export type UserPrompt = {
id: string;
name: string;
prompt: Prompt;
}
};
export interface Prompts {
defaultPrompt: Prompt;

View File

@ -76,7 +76,14 @@
wide={true}
label="Current target branch"
>
<SelectItem slot="template" let:item let:selected {selected}>
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
</Select>
@ -91,7 +98,14 @@
disabled={targetChangeDisabled}
label="Create branches on remote"
>
<SelectItem slot="template" let:item let:selected {selected}>
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
</Select>

View File

@ -9,11 +9,26 @@
const projectService = getContext(ProjectService);
const project = maybeGetContext(Project);
const projects = projectService.projects;
type ProjectRecord = {
id: string;
title: string;
};
let mappedProjects: ProjectRecord[] = [];
projectService.projects.subscribe((projectList) => {
// Map the projectList to fit the ProjectRecord type
mappedProjects = projectList.map((project) => {
return {
id: project.id,
title: project.title
};
});
});
let loading = false;
let select: Select;
let selectValue = project;
let select: Select<ProjectRecord>;
let selectValue: ProjectRecord | undefined = project;
</script>
<div class="project-switcher">
@ -22,7 +37,7 @@
label="Switch to another project"
itemId="id"
labelId="title"
items={$projects}
items={mappedProjects}
placeholder="Select a project..."
wide
bind:value={selectValue}

View File

@ -1,18 +1,30 @@
<script lang="ts">
<script lang="ts" context="module">
export type SelectItemType<S extends string> = Record<S, unknown>;
</script>
<script lang="ts" generics="SelectItemType extends Record<string, unknown>">
import ScrollableContainer from './ScrollableContainer.svelte';
import TextBox from './TextBox.svelte';
import { clickOutside } from '$lib/clickOutside';
import { filterStringByKey } from '$lib/utils/filters';
import { KeyName } from '$lib/utils/hotkeys';
import { throttle } from '$lib/utils/misc';
import { pxToRem } from '$lib/utils/pxToRem';
import { isChar } from '$lib/utils/string';
import { createEventDispatcher } from 'svelte';
const INPUT_THROTTLE_TIME = 100;
type SelectItemKeyType = keyof SelectItemType;
export let id: undefined | string = undefined;
export let label = '';
export let disabled = false;
export let loading = false;
export let wide = false;
export let items: any[];
export let labelId = 'label';
export let itemId = 'value';
export let items: SelectItemType[];
export let labelId: SelectItemKeyType = 'label';
export let itemId: SelectItemKeyType = 'value';
export let value: any = undefined;
export let selectedItemId: any = undefined;
export let placeholder = '';
@ -27,8 +39,21 @@
let listOpen = false;
let element: HTMLElement;
let options: HTMLDivElement;
let highlightIndex: number | undefined = undefined;
let highlightedItem: SelectItemType | undefined = undefined;
let filterText: string | undefined = undefined;
let filteredItems: SelectItemType[] = items;
function handleItemClick(item: any) {
$: filterText === undefined
? (filteredItems = items)
: (filteredItems = filterStringByKey(items, labelId, filterText));
// Set highlighted item based on index
$: highlightIndex !== undefined
? (highlightedItem = filteredItems[highlightIndex])
: (highlightedItem = undefined);
function handleItemClick(item: SelectItemType) {
if (item?.selectable === false) return;
if (value && value[itemId] === item[itemId]) return closeList();
selectedItemId = item[itemId];
@ -52,6 +77,78 @@
function closeList() {
listOpen = false;
highlightIndex = undefined;
filterText = undefined;
}
function handleEnter() {
if (highlightIndex !== undefined) {
handleItemClick(filteredItems[highlightIndex]);
}
closeList();
}
function handleArrowUp() {
if (highlightIndex === undefined) {
highlightIndex = filteredItems.length - 1;
} else {
highlightIndex = highlightIndex === 0 ? filteredItems.length - 1 : highlightIndex - 1;
}
}
function handleArrowDown() {
if (highlightIndex === undefined) {
highlightIndex = 0;
} else {
highlightIndex = highlightIndex === filteredItems.length - 1 ? 0 : highlightIndex + 1;
}
}
const handleChar = throttle((char: string) => {
highlightIndex = undefined;
filterText ??= '';
filterText += char;
}, INPUT_THROTTLE_TIME);
const handleDelete = throttle(() => {
if (filterText === undefined) return;
if (filterText.length === 1) {
filterText = undefined;
return;
}
filterText = filterText.slice(0, -1);
}, INPUT_THROTTLE_TIME);
function handleKeyDown(event: CustomEvent<KeyboardEvent>) {
if (!listOpen) {
return;
}
event.detail.stopPropagation();
event.detail.preventDefault();
const { key } = event.detail;
switch (key) {
case KeyName.Escape:
closeList();
break;
case KeyName.Up:
handleArrowUp();
break;
case KeyName.Down:
handleArrowDown();
break;
case KeyName.Enter:
handleEnter();
break;
case KeyName.Delete:
handleDelete();
break;
default:
if (isChar(key)) handleChar(key);
break;
}
}
</script>
@ -67,9 +164,10 @@
type="select"
reversedDirection
icon="select-chevron"
value={value?.[labelId]}
value={filterText ?? value?.[labelId]}
disabled={disabled || loading}
on:mousedown={() => toggleList()}
on:keydown={(ev) => handleKeyDown(ev)}
/>
<div
class="options card"
@ -78,14 +176,14 @@
style:max-height={maxHeight && pxToRem(maxHeight)}
use:clickOutside={{
trigger: element,
handler: () => (listOpen = !listOpen),
handler: closeList,
enabled: listOpen
}}
>
<ScrollableContainer initiallyVisible>
{#if items}
{#if filteredItems}
<div class="options__group">
{#each items as item}
{#each filteredItems as item}
<div
class="option"
class:selected={item === value}
@ -94,7 +192,12 @@
on:mousedown={() => handleItemClick(item)}
on:keydown|preventDefault|stopPropagation
>
<slot name="template" {item} selected={item === value} />
<slot
name="template"
{item}
selected={item === value}
highlighted={item === highlightedItem}
/>
</div>
{/each}
</div>

View File

@ -7,12 +7,19 @@
export let selected = false;
export let disabled = false;
export let loading = false;
export let highlighted = false;
export let value: string | undefined = undefined;
const dispatch = createEventDispatcher<{ click: string | undefined }>();
</script>
<button {disabled} class="button" class:selected on:click={() => dispatch('click', value)}>
<button
{disabled}
class="button"
class:selected
class:highlighted
on:click={() => dispatch('click', value)}
>
<div class="label text-base-13">
<slot />
</div>
@ -67,4 +74,8 @@
opacity: 0.5;
}
}
.highlighted {
background-color: var(--clr-bg-3);
}
</style>