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