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