mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-26 02:51:57 +03:00
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:
parent
75383cda21
commit
74aed280d2
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user