Files
GitLite/src/lib/components/SelectMenu.svelte
T
Christoph af1c70d45d feat(review-center): add repository filter and SelectMenu option meta/icon
Add a repository picker to the Review Center and wire it into the request
filtering logic. Introduces repositoryFilter state and a derived
repositoryOptions list (grouped by owner and carrying counts). If the
chosen repository disappears from the options, the filter is cleared
automatically.

Enhance SelectMenu to support per-option meta text and an optional
optionIcon snippet. Render options as [icon] label [meta] with updated
markup and CSS to align and style icon/label/meta. Adjust toolbar grid
and responsive CSS to make room for the new repository picker and to
tweak select popup/option styles.
2026-09-17 23:08:06 +02:00

216 lines
8.8 KiB
Svelte

<script lang="ts">
import type { Snippet } from "svelte";
import { tick } from "svelte";
import { Check, ChevronDown, Search } from "@lucide/svelte";
export interface SelectMenuOption {
value: string;
label: string;
group?: string;
disabled?: boolean;
/** Small right-aligned text, e.g. a count. */
meta?: string;
}
interface Props {
value: string;
options: SelectMenuOption[];
placeholder?: string;
disabled?: boolean;
ariaLabel?: string;
class?: string;
showSelectedGroup?: boolean;
searchable?: boolean;
searchPlaceholder?: string;
emptyText?: string;
/** Optional icon in front of every option, e.g. a branch or repository mark. */
optionIcon?: Snippet<[SelectMenuOption]>;
onChange: (value: string) => void;
}
let {
value,
options = [],
placeholder = "Select an option",
disabled = false,
ariaLabel = "",
class: className = "",
showSelectedGroup = false,
searchable = false,
searchPlaceholder = "Search…",
emptyText = "No results",
optionIcon = undefined,
onChange,
}: Props = $props();
let search = $state("");
let searchInput = $state<HTMLInputElement>();
const visibleOptions = $derived(options.filter(option => !searchable || `${option.label} ${option.group ?? ""}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
let root = $state<HTMLDivElement>();
let trigger = $state<HTMLButtonElement>();
let open = $state(false);
let activeIndex = $state(-1);
let menuStyle = $state("");
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
let selectedOption = $derived(options.find((option) => option.value === value));
let enabledIndices = $derived(visibleOptions.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
function groupCount(group: string): number {
return visibleOptions.filter((option) => option.group === group).length;
}
function positionMenu() {
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportGap = 8;
const menuGap = 5;
const groupHeaderCount = new Set(visibleOptions.map((option) => option.group).filter(Boolean)).size;
const desiredHeight = Math.min(300, visibleOptions.length * 32 + (searchable ? 46 : 0) + groupHeaderCount * 36 + 12);
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
const spaceAbove = rect.top - viewportGap;
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
const maxHeight = Math.max(96, Math.min(desiredHeight, openAbove ? spaceAbove - menuGap : spaceBelow - menuGap));
const width = Math.max(rect.width, 180);
const left = Math.min(rect.left, window.innerWidth - width - viewportGap);
const top = openAbove ? rect.top - menuGap : rect.bottom + menuGap;
menuStyle = `left:${Math.max(viewportGap, left)}px;${openAbove ? `bottom:${window.innerHeight - top}px;` : `top:${top}px;`}min-width:${width}px;max-width:${Math.max(180, window.innerWidth - viewportGap * 2)}px;max-height:${maxHeight}px;`;
}
async function show() {
if (disabled || !options.some(option => !option.disabled)) return;
search = "";
const selectedIndex = visibleOptions.findIndex((option) => option.value === value && !option.disabled);
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
open = true;
await tick();
positionMenu();
if (searchable) searchInput?.focus();
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}
function close() {
open = false;
}
function choose(index: number) {
const option = visibleOptions[index];
if (!option || option.disabled) return;
onChange(option.value);
close();
trigger?.focus();
}
function moveActive(direction: -1 | 1) {
if (enabledIndices.length === 0) return;
const current = enabledIndices.indexOf(activeIndex);
const next = current < 0
? (direction > 0 ? 0 : enabledIndices.length - 1)
: (current + direction + enabledIndices.length) % enabledIndices.length;
activeIndex = enabledIndices[next];
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}
function handleKeydown(event: KeyboardEvent) {
if (disabled) return;
if (!open && ["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
event.preventDefault();
void show();
return;
}
if (!open) return;
const editingSearch = event.target === searchInput;
if (editingSearch && [" ", "Home", "End"].includes(event.key)) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
activeIndex = event.key === "Home" ? enabledIndices[0] : enabledIndices[enabledIndices.length - 1];
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
choose(activeIndex);
} else if (event.key === "Escape" || event.key === "Tab") {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
}
close();
}
}
function handleWindowPointerDown(event: PointerEvent) {
if (open && root && !root.contains(event.target as Node)) close();
}
</script>
<svelte:window onpointerdown={handleWindowPointerDown} onresize={positionMenu} onscroll={positionMenu} />
<div class={`select-menu ${className}`} class:open bind:this={root}>
<button
bind:this={trigger}
class="select-menu-trigger"
type="button"
{disabled}
aria-label={ariaLabel || undefined}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onclick={() => open ? close() : void show()}
onkeydown={handleKeydown}
>
<span class="select-menu-value" class:placeholder={!selectedOption} title={selectedOption?.label}>
{#if showSelectedGroup && selectedOption?.group}<small>{selectedOption.group}</small>{/if}
<span>{selectedOption?.label ?? placeholder}</span>
</span>
<ChevronDown size={14} aria-hidden="true" />
</button>
{#if open}
<div class="select-menu-popup" class:searchable style={menuStyle}>
{#if searchable}
<div class="select-search"><Search size={14} aria-hidden="true"/><input bind:this={searchInput} bind:value={search} placeholder={searchPlaceholder} aria-label={searchPlaceholder} role="combobox" aria-expanded={open} aria-controls={menuId} aria-autocomplete="list" aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined} onkeydown={handleKeydown} oninput={async () => { await tick(); activeIndex = enabledIndices[0] ?? -1; positionMenu(); }}/></div>
{/if}
<div id={menuId} class="select-options" role="listbox" aria-label={ariaLabel || undefined}>
{#each visibleOptions as option, index (`${option.value}:${index}`)}
{#if option.group && (index === 0 || visibleOptions[index - 1]?.group !== option.group)}
<div class="select-menu-group" role="presentation">
<span>{option.group}</span>
<small>{groupCount(option.group)}</small>
</div>
{/if}
<button
id={`${menuId}-option-${index}`}
class="select-menu-option"
class:active={index === activeIndex}
class:selected={option.value === value}
type="button"
role="option"
aria-selected={option.value === value}
disabled={option.disabled}
onmouseenter={() => { if (!option.disabled) activeIndex = index; }}
onclick={() => choose(index)}
>
{#if optionIcon}<span class="select-menu-option-icon">{@render optionIcon(option)}</span>{/if}
<span class="select-menu-option-label">{option.label}</span>
{#if option.meta}<small class="select-menu-option-meta">{option.meta}</small>{/if}
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
</button>
{/each}
</div>
{#if searchable && !visibleOptions.length}<div class="select-empty" role="status">{emptyText}</div>{/if}
</div>
{/if}
</div>
<style>
.select-options{display:grid;gap:2px;min-height:0}
.select-menu-popup.searchable{display:flex;flex-direction:column;overflow:hidden}
.searchable .select-options{overflow:auto}
.select-search{display:flex;flex-shrink:0;align-items:center;gap:9px;margin:2px 3px 5px;padding:0 8px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-faint)}
.select-search input{width:100%;min-width:0;height:36px;padding:0;border:0;background:transparent;color:var(--color-ink);font:inherit;outline:none;box-shadow:none}
.select-empty{padding:15px 12px;color:var(--color-ink-muted);font-size:12px}
</style>