This repository has no description
1<script module lang="ts">
2 import { tv, type VariantProps } from "tailwind-variants";
3
4 export const textareaField = tv({
5 slots: {
6 root: "relative flex min-h-64 items-start gap-1 rounded border bg-background-default transition-colors",
7 textarea:
8 "min-h-24 flex-1 self-stretch bg-transparent py-2 text-sm leading-6 outline-none placeholder:text-foreground-placeholder read-only:resize-none disabled:cursor-not-allowed disabled:resize-none"
9 },
10 variants: {
11 error: {
12 false: {
13 root: "border-border-default focus-within:border-border-strong focus-within:ring-1 focus-within:ring-border-strong"
14 },
15 true: {
16 root: "border-border-danger bg-background-danger-subtle text-foreground-danger focus-within:border-border-focus-danger focus-within:ring-1 focus-within:ring-border-focus-danger",
17 textarea: "placeholder:text-foreground-danger"
18 }
19 },
20 disabled: {
21 false: "",
22 true: {
23 root: "cursor-not-allowed border-border-disabled bg-background-subtle text-foreground-muted"
24 }
25 },
26 readonly: {
27 false: "",
28 true: {
29 root: "cursor-default border-border-disabled bg-background-subtle text-foreground-muted"
30 }
31 },
32 resizeable: {
33 false: { textarea: "resize-none" },
34 true: { textarea: "resize-y" }
35 }
36 },
37 defaultVariants: {
38 error: false,
39 disabled: false,
40 readonly: false
41 }
42 });
43
44 export type TextareaFieldVariants = VariantProps<typeof textareaField>;
45</script>
46
47<script lang="ts">
48 import type { Component } from "svelte";
49 import type { HTMLTextareaAttributes, SvelteHTMLElements } from "svelte/elements";
50 import Spinner from "./Spinner.svelte";
51
52 interface Props extends Omit<HTMLTextareaAttributes, "class" | "value"> {
53 value?: string;
54 error?: boolean;
55 disabled?: boolean;
56 loading?: boolean;
57 readonly?: boolean;
58 resizeable?: boolean;
59 iconLeft?: Component<SvelteHTMLElements["svg"]>;
60 iconRight?: Component<SvelteHTMLElements["svg"]>;
61 class?: string;
62 element?: HTMLTextAreaElement;
63 }
64
65 let {
66 value = $bindable(""),
67 error = false,
68 disabled = false,
69 loading = false,
70 readonly = false,
71 resizeable = false,
72 iconLeft,
73 iconRight,
74 class: className,
75 element = $bindable(),
76 ...rest
77 }: Props = $props();
78
79 const { root, textarea } = $derived(textareaField({ error, disabled, readonly, resizeable }));
80
81 // The root no longer carries the text inset (so the scrollbar/resize handle can sit flush
82 // against the border) — the textarea itself owns its horizontal padding, collapsing to 0 on
83 // whichever side an icon (or the loading spinner) already provides the inset instead.
84 const textareaPadding = $derived(
85 [iconLeft ? "" : "pl-2.5", loading ? "pr-[1.875rem]" : iconRight ? "" : "pr-2.5"]
86 .filter(Boolean)
87 .join(" ")
88 );
89</script>
90
91<div class={root({ class: className })}>
92 {#if iconLeft}
93 {@const IconLeft = iconLeft}
94 <IconLeft
95 class="mt-2.5 mr-1 ml-2.5 size-4 shrink-0 text-foreground-subtle"
96 aria-hidden="true"
97 />
98 {/if}
99 <textarea
100 bind:this={element}
101 bind:value
102 {disabled}
103 {readonly}
104 aria-invalid={error}
105 aria-busy={loading}
106 class={textarea({ class: textareaPadding })}
107 {...rest}></textarea>
108 {#if loading}
109 <Spinner class="absolute top-2 right-2.5 size-4" />
110 {:else if iconRight}
111 {@const IconRight = iconRight}
112 <IconRight
113 class="mt-2.5 mr-2.5 ml-1 size-4 shrink-0 text-foreground-subtle"
114 aria-hidden="true"
115 />
116 {/if}
117</div>