Skip to content

Commit

Permalink
Merge pull request gitbutlerapp#3992 from estib-vega/select-input-imp…
Browse files Browse the repository at this point in the history
…rovements

Select input improvements: Arrow navigation & text filtering
  • Loading branch information
krlvi committed Jun 10, 2024
2 parents c2b4314 + bd10fc9 commit ea31b78
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 19 deletions.
4 changes: 2 additions & 2 deletions app/src/lib/ai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 16 additions & 2 deletions app/src/lib/components/BaseBranchSwitch.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand Down
19 changes: 15 additions & 4 deletions app/src/lib/components/ProjectSwitcher.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@
import SelectItem from './SelectItem.svelte';
import { ProjectService, Project } from '$lib/backend/projects';
import { getContext, maybeGetContext } from '$lib/utils/context';
import { derived } from 'svelte/store';
import { goto } from '$app/navigation';
const projectService = getContext(ProjectService);
const project = maybeGetContext(Project);
const projects = projectService.projects;
type ProjectRecord = {
id: string;
title: string;
};
const mappedProjects = derived(projectService.projects, ($projects) =>
$projects.map((project) => ({
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">
Expand All @@ -22,7 +33,7 @@
label="Switch to another project"
itemId="id"
labelId="title"
items={$projects}
items={$mappedProjects}
placeholder="Select a project..."
wide
bind:value={selectValue}
Expand Down
131 changes: 121 additions & 10 deletions app/src/lib/components/Select.svelte
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
<script lang="ts">
<script lang="ts" context="module">
export type Selectable<S extends string> = Record<S, unknown>;
</script>

<script lang="ts" generics="Selectable extends Record<string, unknown>">
import ScrollableContainer from './ScrollableContainer.svelte';
import TextBox from './TextBox.svelte';
import { clickOutside } from '$lib/clickOutside';
import { KeyName } from '$lib/utils/hotkeys';
import { throttle } from '$lib/utils/misc';
import { pxToRem } from '$lib/utils/pxToRem';
import { isChar, isStr } from '$lib/utils/string';
import { createEventDispatcher } from 'svelte';
const INPUT_THROTTLE_TIME = 100;
type SelectableKey = keyof Selectable;
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: Selectable[];
export let labelId: SelectableKey = 'label';
export let itemId: SelectableKey = 'value';
export let value: any = undefined;
export let selectedItemId: any = undefined;
export let placeholder = '';
Expand All @@ -27,8 +38,28 @@
let listOpen = false;
let element: HTMLElement;
let options: HTMLDivElement;
let highlightIndex: number | undefined = undefined;
let highlightedItem: Selectable | undefined = undefined;
let filterText: string | undefined = undefined;
let filteredItems: Selectable[] = items;
function filterItems(items: Selectable[], filterText: string | undefined) {
if (!filterText) {
return items;
}
return items.filter((it) => {
const property = it[labelId];
if (!isStr(property)) return false;
return property.includes(filterText);
});
}
function handleItemClick(item: any) {
$: filteredItems = filterItems(items, filterText);
$: highlightedItem = highlightIndex !== undefined ? filteredItems[highlightIndex] : undefined;
function handleItemClick(item: Selectable) {
if (item?.selectable === false) return;
if (value && value[itemId] === item[itemId]) return closeList();
selectedItemId = item[itemId];
Expand All @@ -52,6 +83,80 @@
function closeList() {
listOpen = false;
highlightIndex = undefined;
filterText = undefined;
}
function handleEnter() {
if (highlightIndex !== undefined) {
handleItemClick(filteredItems[highlightIndex]);
}
closeList();
}
function handleArrowUp() {
if (filteredItems.length === 0) return;
if (highlightIndex === undefined) {
highlightIndex = filteredItems.length - 1;
} else {
highlightIndex = highlightIndex === 0 ? filteredItems.length - 1 : highlightIndex - 1;
}
}
function handleArrowDown() {
if (filteredItems.length === 0) return;
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(e: CustomEvent<KeyboardEvent>) {
if (!listOpen) {
return;
}
e.detail.stopPropagation();
e.detail.preventDefault();
const { key } = e.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>

Expand All @@ -67,9 +172,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"
Expand All @@ -78,14 +184,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}
Expand All @@ -94,7 +200,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>
Expand Down
13 changes: 12 additions & 1 deletion app/src/lib/components/SelectItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -67,4 +74,8 @@
opacity: 0.5;
}
}
.highlighted {
background-color: var(--clr-bg-3);
}
</style>
15 changes: 15 additions & 0 deletions app/src/lib/utils/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ interface KeybindDefinitions {
[combo: string]: (event: KeyboardEvent) => void;
}

export enum KeyName {
Space = ' ',
Meta = 'Meta',
Alt = 'Alt',
Ctrl = 'Ctrl',
Enter = 'Enter',
Escape = 'Escape',
Tab = 'Tab',
Up = 'ArrowUp',
Down = 'ArrowDown',
Left = 'ArrowLeft',
Right = 'ArrowRight',
Delete = 'Backspace'
}

export function createKeybind(keybinds: KeybindDefinitions) {
const keys: KeybindDefinitions = {
// Ignore backspace keydown events always
Expand Down
18 changes: 18 additions & 0 deletions app/src/lib/utils/misc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function throttle<T extends (...args: any[]) => any>(
fn: T,
wait: number
): (...args: Parameters<T>) => ReturnType<T> {
let inThrottle: boolean;
let lastResult: ReturnType<T>;

return function (...args: Parameters<T>) {
if (!inThrottle) {
inThrottle = true;
lastResult = fn(...args);
setTimeout(() => {
inThrottle = false;
}, wait);
}
return lastResult;
};
}
8 changes: 8 additions & 0 deletions app/src/lib/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ export function hashCode(s: string) {
}
return hash.toString();
}

export function isChar(char: string) {
return char.length === 1;
}

export function isStr(s: unknown): s is string {
return typeof s === 'string';
}

0 comments on commit ea31b78

Please sign in to comment.