<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { HTMLInputAttributes, SvelteHTMLElements } from "svelte/elements";
import {
inputField,
type InputFieldVariants,
type InputTag,
type TagCommitKey
} from "#lib/components/ui/inputField.ts";
import Spinner from "#lib/components/ui/Spinner.svelte";
import TagInput from "#lib/components/ui/TagInput.svelte";
interface Props extends Omit<HTMLInputAttributes, "class" | "value" | "size" | "list"> {
value?: string;
size?: InputFieldVariants["size"];
error?: boolean;
disabled?: boolean;
loading?: boolean;
iconLeft?: Component<SvelteHTMLElements["svg"]>;
iconRight?: Component<SvelteHTMLElements["svg"]>;
suffix?: string;
/** classes for the suffix element; override the default muted text, e.g. a gray chip */
suffixClass?: string;
suffixInline?: boolean;
element?: HTMLInputElement;
class?: string;
rich?: boolean;
// rich only
tags?: InputTag[];
maxTags?: number;
commitOn?: TagCommitKey[];
allowDuplicates?: boolean;
validate?: (value: string) => boolean | string;
suggestions?: InputTag[];
onSearch?: (query: string) => void;
onAdd?: (tag: InputTag) => void;
onRemove?: (tag: InputTag) => void;
hint?: string;
emptyLabel?: string;
label?: string;
panelClass?: string;
tag?: Snippet<[InputTag]>;
suggestion?: Snippet<[InputTag]>;
}
let {
value = $bindable(""),
size = "regular",
error = false,
disabled = false,
loading = false,
iconLeft,
iconRight,
suffix,
suffixClass,
suffixInline = false,
element = $bindable(),
class: className,
rich = false,
tags = $bindable([]),
maxTags,
commitOn,
allowDuplicates,
validate,
suggestions,
onSearch,
onAdd,
onRemove,
hint,
emptyLabel,
label,
panelClass,
tag,
suggestion,
...rest
}: Props = $props();
const classes = $derived(
inputField({ size, error, disabled, class: [!disabled && "cursor-text", className] })
);
const iconSize = $derived(
size === "large" ? "size-4" : size === "mini" ? "size-3" : "size-3.5"
);
const mirror = $derived(value || (rest.placeholder ?? ""));
const focusOnPress = (container: HTMLDivElement) => {
const press = (event: MouseEvent) => {
if (!element || element.disabled || event.target === element) return;
event.preventDefault();
element.focus();
};
container.addEventListener("mousedown", press);
return () => container.removeEventListener("mousedown", press);
};
</script>
{#if rich}
<TagInput
bind:tags
bind:value
bind:element
{size}
{error}
{disabled}
{loading}
{iconLeft}
{maxTags}
{commitOn}
{allowDuplicates}
{validate}
{suggestions}
{onSearch}
{onAdd}
{onRemove}
{hint}
{emptyLabel}
{label}
{panelClass}
{tag}
{suggestion}
class={className}
{...rest}
/>
{:else}
<div class={classes} {@attach focusOnPress}>
{#if iconLeft}
{@const IconLeft = iconLeft}
<IconLeft class="{iconSize} shrink-0 text-foreground-subtle" aria-hidden="true" />
{/if}
{#snippet field(sizing: string)}
<input
bind:this={element}
bind:value
{disabled}
aria-invalid={error}
aria-busy={loading}
aria-label={label}
class="min-w-0 bg-transparent outline-none placeholder:text-foreground-placeholder disabled:cursor-not-allowed pointer-coarse:text-[1rem] {sizing}"
{...rest}
/>
{/snippet}
{#if suffix && suffixInline}
<!-- the mirror sizes the field, so it takes the field's touch size too -->
<span class="relative min-w-0 shrink overflow-hidden pointer-coarse:text-[1rem]">
<span class="invisible block pr-px whitespace-pre" aria-hidden="true">{mirror}</span
>
{@render field("absolute inset-0 w-full")}
</span>
{:else}
{@render field("flex-1")}
{/if}
{#if suffix}
<span
class={[
"shrink-0 select-none",
suffixInline && "pointer-coarse:text-[1rem]",
suffixClass ?? "text-foreground-muted"
]}>{suffix}</span
>
{/if}
{#if loading}
<Spinner class="{iconSize} shrink-0" />
{:else if iconRight}
{@const IconRight = iconRight}
<IconRight class="{iconSize} shrink-0 text-foreground-subtle" aria-hidden="true" />
{/if}
</div>
{/if}