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