Show label validation in a popover

So it looks the same as the assignee input. Both popovers now open
above the input when there's room for the full list and below it
otherwise, instead of switching sides depending on how many results
there are.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-09 10:29:32 +02:00
parent a1515fe4a6
commit 1b07495946
3 changed files with 80 additions and 50 deletions
+2 -36
View File
@@ -1,15 +1,6 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import {
autoUpdate,
computePosition,
flip,
hide,
offset,
shift,
size,
} from "@floating-ui/dom";
import debounce from "lodash/debounce";
import type { AssigneeSuggestion } from "@app/lib/assigneeSuggestions";
@@ -17,6 +8,7 @@
matchesAssignee,
rankAssigneeSuggestions,
} from "@app/lib/assigneeSuggestions";
import { positionInputPopover } from "@app/lib/inputPopover";
import { parseAssignee } from "@app/lib/inputValidation";
import { cachedSearchAliases, invoke } from "@app/lib/invoke";
import { portal } from "@app/lib/portal";
@@ -131,33 +123,7 @@
$effect(() => {
if (!panelVisible || !floatingEl || !anchorEl) return;
const reference = anchorEl;
const floating = floatingEl;
return autoUpdate(reference, floating, () => {
void computePosition(reference, floating, {
placement: "bottom-start",
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
hide({ padding: 8 }),
size({
padding: 8,
apply({ availableHeight, rects, elements }) {
elements.floating.style.minWidth = `${rects.reference.width}px`;
elements.floating.style.maxHeight = `${Math.min(availableHeight, 384)}px`;
},
}),
],
}).then(({ x, y, middlewareData }) => {
floating.style.left = `${x}px`;
floating.style.top = `${y}px`;
floating.style.visibility = middlewareData.hide?.referenceHidden
? "hidden"
: "visible";
});
});
return positionInputPopover(anchorEl, floatingEl);
});
$effect(() => {
+34 -14
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { positionInputPopover } from "@app/lib/inputPopover";
import { labelError } from "@app/lib/inputValidation";
import { portal } from "@app/lib/portal";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
@@ -31,6 +33,16 @@
let removeToggles: Record<string, boolean> = $state({});
let anchorEl: HTMLDivElement | undefined = $state();
let floatingEl: HTMLDivElement | undefined = $state();
const panelVisible = $derived(showInput && validationMessage !== undefined);
$effect(() => {
if (!panelVisible || !floatingEl || !anchorEl) return;
return positionInputPopover(anchorEl, floatingEl);
});
$effect(() => {
// Reset component state whenever the labels change in the parent. This
// happens when the issue ID changes for example when the user navigates
@@ -64,13 +76,23 @@
flex-wrap: wrap;
gap: 0.5rem;
}
.popover {
position: fixed;
top: 0;
left: 0;
visibility: hidden;
z-index: 400;
max-width: 28rem;
padding: 0.25rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
background-color: var(--color-surface-canvas);
box-shadow: var(--elevation-low);
}
.validation-message {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--color-feedback-error-text);
position: relative;
margin-top: 0.5rem;
padding: 0.375rem 0.5rem;
color: var(--color-text-secondary);
font: var(--txt-body-m-regular);
}
.removable-label {
display: flex;
@@ -113,15 +135,19 @@
<div class="row">
{#if showInput}
<div class="input-row">
<div style:flex="1" style:min-width="0">
<div style:flex="1" style:min-width="0" bind:this={anchorEl}>
<TextInput
autofocus
{valid}
disabled={submitInProgress}
placeholder="Add label"
bind:value={inputValue}
onSubmit={addLabel} />
</div>
{#if panelVisible}
<div class="popover" bind:this={floatingEl} use:portal>
<div class="validation-message">{validationMessage}</div>
</div>
{/if}
<Button
variant="outline"
onclick={() => {
@@ -160,10 +186,4 @@
{/if}
{/each}
</div>
{#if !valid && validationMessage}
<div class="validation-message">
<Icon name="warning" />{validationMessage}
</div>
{/if}
{/if}
+44
View File
@@ -0,0 +1,44 @@
import {
autoUpdate,
computePosition,
hide,
offset,
shift,
size,
} from "@floating-ui/dom";
const gap = 8;
const padding = 8;
const maxHeight = 384;
export function positionInputPopover(
reference: HTMLElement,
floating: HTMLElement,
): () => void {
return autoUpdate(reference, floating, () => {
const spaceAbove = reference.getBoundingClientRect().top - gap - padding;
const placement = spaceAbove >= maxHeight ? "top-start" : "bottom-start";
void computePosition(reference, floating, {
placement,
middleware: [
offset(gap),
shift({ padding }),
hide({ padding }),
size({
padding,
apply({ availableHeight, rects, elements }) {
elements.floating.style.minWidth = `${rects.reference.width}px`;
elements.floating.style.maxHeight = `${Math.min(availableHeight, maxHeight)}px`;
},
}),
],
}).then(({ x, y, middlewareData }) => {
floating.style.left = `${x}px`;
floating.style.top = `${y}px`;
floating.style.visibility = middlewareData.hide?.referenceHidden
? "hidden"
: "visible";
});
});
}