<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
// the field's resting height (3 text lines), exported so anything that swaps itself in for
// the field — MarkdownEditor's preview pane — can match it and avoid resizing the box as you
// toggle between them.
export const textareaMinHeight = "min-h-19";
export const textareaField = tv({
slots: {
root: "relative flex items-start gap-2xs rounded-md bg-background-default edge-ring-inset transition-colors",
textarea:
"flex-1 self-stretch bg-transparent py-xs outline-none placeholder:text-foreground-placeholder read-only:resize-none disabled:cursor-not-allowed disabled:resize-none pointer-coarse:text-[1rem]"
},
variants: {
// `regular` keeps textareaMinHeight above in sync — it's 2px shorter because the
// root's border sits outside it.
size: {
regular: { textarea: textareaMinHeight },
small: { textarea: "min-h-14" },
unsized: { textarea: "" }
},
error: {
false: {
root: "focus-within:ring-1 focus-within:ring-border-focus focus-within:inset-ring-border-focus"
},
true: {
root: "bg-background-danger-subtle text-foreground-danger inset-ring-border-danger",
textarea: "placeholder:text-foreground-danger"
}
},
disabled: {
false: "",
true: {
root: "cursor-not-allowed bg-background-subtle text-foreground-muted inset-ring-border-disabled"
}
},
readonly: {
false: "",
true: {
root: "cursor-default bg-background-subtle text-foreground-muted inset-ring-border-disabled"
}
},
resizeable: {
false: { textarea: "resize-none" },
true: { textarea: "resize-y" }
}
},
defaultVariants: {
error: false,
disabled: false,
readonly: false,
size: "regular"
}
});
export type TextareaFieldVariants = VariantProps<typeof textareaField>;
</script>
<script lang="ts">
import type { Component } from "svelte";
import type { HTMLTextareaAttributes, SvelteHTMLElements } from "svelte/elements";
import Spinner from "#lib/components/ui/Spinner.svelte";
interface Props extends Omit<HTMLTextareaAttributes, "class" | "value"> {
value?: string;
error?: boolean;
disabled?: boolean;
loading?: boolean;
readonly?: boolean;
resizeable?: boolean;
size?: TextareaFieldVariants["size"];
iconLeft?: Component<SvelteHTMLElements["svg"]>;
iconRight?: Component<SvelteHTMLElements["svg"]>;
class?: string;
element?: HTMLTextAreaElement;
}
let {
value = $bindable(""),
error = false,
disabled = false,
loading = false,
readonly = false,
resizeable = false,
size = "regular",
iconLeft,
iconRight,
class: className,
element = $bindable(),
...rest
}: Props = $props();
const { root, textarea } = $derived(
textareaField({ error, disabled, readonly, resizeable, size })
);
const textareaPadding = $derived(
[iconLeft ? "" : "pl-2.5", loading ? "pr-[1.875rem]" : iconRight ? "" : "pr-2.5"]
.filter(Boolean)
.join(" ")
);
</script>
<div class={root({ class: className })}>
{#if iconLeft}
{@const IconLeft = iconLeft}
<IconLeft
class="mt-2.5 mr-2xs ml-2.5 size-3.5 shrink-0 text-foreground-subtle"
aria-hidden="true"
/>
{/if}
<textarea
bind:this={element}
bind:value
{disabled}
{readonly}
aria-invalid={error}
aria-busy={loading}
class={textarea({ class: textareaPadding })}
{...rest}></textarea>
{#if loading}
<Spinner class="absolute top-2 right-2.5 size-3.5" />
{:else if iconRight}
{@const IconRight = iconRight}
<IconRight
class="mt-2.5 mr-2.5 ml-2xs size-3.5 shrink-0 text-foreground-subtle"
aria-hidden="true"
/>
{/if}
</div>