~james/core

core/web/src/lib/components/ui/Textarea.svelte -rw-r--r-- 3.6 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
<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>