Move the inline repository management UI into a new RepositoryDashboard component and wire it into the application. Refactor app state to compute a reactive dashboardRepos list and update tab-closing logic so tabs can be closed while preserving the management view when appropriate. - Add a full dashboard with search, categories, workspaces and persisted prefs. - Refactor close behavior to support keeping the management view open. - Improve UI/CSS and accessibility for the management tab (compact layout and label).
178 lines
6.3 KiB
Svelte
178 lines
6.3 KiB
Svelte
<script lang="ts">
|
|
import { tick } from "svelte";
|
|
import { Check, ChevronDown } from "@lucide/svelte";
|
|
|
|
export interface SelectMenuOption {
|
|
value: string;
|
|
label: string;
|
|
group?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
value: string;
|
|
options: SelectMenuOption[];
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
ariaLabel?: string;
|
|
class?: string;
|
|
showSelectedGroup?: boolean;
|
|
onChange: (value: string) => void;
|
|
}
|
|
|
|
let {
|
|
value,
|
|
options = [],
|
|
placeholder = "Select an option",
|
|
disabled = false,
|
|
ariaLabel = "",
|
|
class: className = "",
|
|
showSelectedGroup = false,
|
|
onChange,
|
|
}: Props = $props();
|
|
|
|
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(options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
|
|
|
|
function groupCount(group: string): number {
|
|
return options.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(options.map((option) => option.group).filter(Boolean)).size;
|
|
const desiredHeight = Math.min(300, options.length * 32 + 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 || enabledIndices.length === 0) return;
|
|
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
|
|
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
|
|
open = true;
|
|
await tick();
|
|
positionMenu();
|
|
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
function close() {
|
|
open = false;
|
|
}
|
|
|
|
function choose(index: number) {
|
|
const option = options[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;
|
|
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 id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
|
|
{#each options as option, index (`${option.value}:${index}`)}
|
|
{#if option.group && (index === 0 || options[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)}
|
|
>
|
|
<span>{option.label}</span>
|
|
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|