fix(ui): use our own dropdown for the canvas toolbar filters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 18:02:34 +02:00
co-authored by Claude Opus 5.5
parent 7e612422b6
commit ec5d11790e
2 changed files with 87 additions and 11 deletions
@@ -0,0 +1,76 @@
<script>
// Our listbox (same look as <x-forms.listbox>) instead of the native <select>.
// The panel is absolute, not fixed: the zoom controls bar has a backdrop filter,
// which makes it the containing block of fixed children.
let { options, value, label, class: className = '', onchange } = $props();
let open = $state(false);
let above = $state(false);
let active = $state(0);
let root;
let trigger;
let list = $state();
const current = $derived(options.find(o => o.value === value) ?? options[0]);
function toggle() {
if (open) return close();
const r = trigger.getBoundingClientRect();
above = innerHeight - r.bottom < 280 && r.top > innerHeight - r.bottom;
active = Math.max(0, options.findIndex(o => o.value === value));
open = true;
}
function close(focus = false) {
open = false;
if (focus) trigger.focus();
}
function pick(option) {
close(true);
if (option.value !== value) onchange(option.value);
}
function keys(e) {
if (e.key === 'Escape' && open) {
e.preventDefault();
close(true);
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!open) return toggle();
active = (active + (e.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length;
} else if (e.key === 'Enter' && open) {
e.preventDefault();
pick(options[active]);
}
}
$effect(() => {
if (open) list?.children[active]?.scrollIntoView({ block: 'nearest' });
});
</script>
<svelte:window onpointerdown={e => open && !root.contains(e.target) && close()} />
<div class="relative {className}" bind:this={root} onkeydown={keys} role="presentation">
<button type="button" class="listbox-trigger h-8! text-[12px]!" bind:this={trigger} onclick={toggle}
aria-haspopup="listbox" aria-expanded={open} aria-label={label} title={label}>
<span class="listbox-trigger-label">{current?.label}</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
</button>
{#if open}
<div class="listbox-panel" style={above ? 'top: auto; bottom: calc(100% + 0.25rem)' : ''} role="listbox" aria-label={label} bind:this={list}>
{#each options as option, i (option.value)}
<button type="button" class="listbox-option text-[13px]!" style:background={i === active ? 'var(--coollabs-fill)' : null} role="option" aria-selected={option.value === value}
onpointerenter={() => (active = i)} onclick={() => pick(option)}>
<span class="truncate">{option.label}</span>
{#if option.value === value}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="ml-auto size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
+11 -11
View File
@@ -1,11 +1,13 @@
<script>
import BetaBadge from '../BetaBadge.svelte';
import Select from './Select.svelte';
// Filters and view toggles. The root docks it (use:dock) into the zoom controls bar
// (CanvasControls, bottom left) on desktop, or in a rounded box above the canvas on small screens.
let { viewOnly, viewOnlyTitle, filter, projects, environments, views, dock, onfilter } = $props();
const select = 'select h-8! w-44 py-0! text-[12px]! max-lg:w-auto max-lg:min-w-0 max-lg:flex-1 max-lg:basis-32';
const select = 'w-44 max-lg:w-auto max-lg:min-w-0 max-lg:flex-1 max-lg:basis-32';
const options = (all, list) => [{ value: '', label: all }, ...list.map(o => ({ value: o.uuid, label: o.name }))];
const value = (options, wanted) => (options.some(o => o.uuid === wanted) ? wanted : '');
/** Table / grid / canvas switcher from the page (Blade), when the page has one. */
@@ -24,15 +26,13 @@
<!-- Small screens: the actions wrap onto more rows instead of covering the title. -->
<div class="ml-auto flex shrink-0 items-center gap-2 max-lg:min-w-0 max-lg:shrink max-lg:flex-wrap" use:adopt={views}>
<!-- Connect and server views: show the resources of one project and / or environment only. -->
<select class={select} class:hidden={projects.length < 2} aria-label="Filter by project" title="Filter by project"
value={value(projects, filter.project)} onchange={e => onfilter({ project: e.currentTarget.value, environment: '' })}>
<option value="">All projects</option>
{#each projects as o (o.uuid)}<option value={o.uuid}>{o.name}</option>{/each}
</select>
<select class={select} class:hidden={environments.length < 2} aria-label="Filter by environment" title="Filter by environment"
value={value(environments, filter.environment)} onchange={e => onfilter({ ...filter, environment: e.currentTarget.value })}>
<option value="">All environments</option>
{#each environments as o (o.uuid)}<option value={o.uuid}>{o.name}</option>{/each}
</select>
{#if projects.length > 1}
<Select class={select} label="Filter by project" value={value(projects, filter.project)}
options={options('All projects', projects)} onchange={project => onfilter({ project, environment: '' })} />
{/if}
{#if environments.length > 1}
<Select class={select} label="Filter by environment" value={value(environments, filter.environment)}
options={options('All environments', environments)} onchange={environment => onfilter({ ...filter, environment })} />
{/if}
</div>
</div>