@spaethtech/svelte-ui 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/dist/components/Badge/Badge.svelte +80 -0
- package/dist/components/Badge/Badge.svelte.d.ts +14 -0
- package/dist/components/Badge/index.d.ts +1 -0
- package/dist/components/Badge/index.js +1 -0
- package/dist/components/Button/Button.svelte +172 -0
- package/dist/components/Button/Button.svelte.d.ts +32 -0
- package/dist/components/Button/index.d.ts +1 -0
- package/dist/components/Button/index.js +1 -0
- package/dist/components/Dialog/Dialog.svelte +101 -0
- package/dist/components/Dialog/Dialog.svelte.d.ts +18 -0
- package/dist/components/Dialog/index.d.ts +1 -0
- package/dist/components/Dialog/index.js +1 -0
- package/dist/components/ElementManager/ElementManager.svelte +397 -0
- package/dist/components/ElementManager/ElementManager.svelte.d.ts +43 -0
- package/dist/components/ElementManager/index.d.ts +1 -0
- package/dist/components/ElementManager/index.js +1 -0
- package/dist/components/EmailInput/EmailInput.svelte +47 -0
- package/dist/components/EmailInput/EmailInput.svelte.d.ts +14 -0
- package/dist/components/EmailInput/index.d.ts +1 -0
- package/dist/components/EmailInput/index.js +1 -0
- package/dist/components/Icon/Icon.svelte +74 -0
- package/dist/components/Icon/Icon.svelte.d.ts +13 -0
- package/dist/components/Icon/index.d.ts +1 -0
- package/dist/components/Icon/index.js +1 -0
- package/dist/components/Input/Input.svelte +268 -0
- package/dist/components/Input/Input.svelte.d.ts +18 -0
- package/dist/components/Input/index.d.ts +1 -0
- package/dist/components/Input/index.js +1 -0
- package/dist/components/List/List.svelte +580 -0
- package/dist/components/List/List.svelte.d.ts +38 -0
- package/dist/components/List/index.d.ts +1 -0
- package/dist/components/List/index.js +1 -0
- package/dist/components/ListItem/ListItem.svelte +175 -0
- package/dist/components/ListItem/ListItem.svelte.d.ts +24 -0
- package/dist/components/ListItem/index.d.ts +2 -0
- package/dist/components/ListItem/index.js +2 -0
- package/dist/components/ListView/ListView.svelte +463 -0
- package/dist/components/ListView/ListView.svelte.d.ts +37 -0
- package/dist/components/ListView/index.d.ts +2 -0
- package/dist/components/ListView/index.js +2 -0
- package/dist/components/NodeGraph/BaseNode.d.ts +57 -0
- package/dist/components/NodeGraph/BaseNode.js +30 -0
- package/dist/components/NodeGraph/Edge.svelte +60 -0
- package/dist/components/NodeGraph/Edge.svelte.d.ts +16 -0
- package/dist/components/NodeGraph/ExpressionEvaluator.d.ts +82 -0
- package/dist/components/NodeGraph/ExpressionEvaluator.js +152 -0
- package/dist/components/NodeGraph/Node.svelte +100 -0
- package/dist/components/NodeGraph/Node.svelte.d.ts +10 -0
- package/dist/components/NodeGraph/NodeGraph.svelte +52 -0
- package/dist/components/NodeGraph/NodeGraph.svelte.d.ts +14 -0
- package/dist/components/NodeGraph/NodeInstance.svelte.d.ts +80 -0
- package/dist/components/NodeGraph/NodeInstance.svelte.js +241 -0
- package/dist/components/NodeGraph/NodePort.svelte +75 -0
- package/dist/components/NodeGraph/NodePort.svelte.d.ts +11 -0
- package/dist/components/NodeGraph/decorators.d.ts +81 -0
- package/dist/components/NodeGraph/decorators.js +148 -0
- package/dist/components/NodeGraph/index.d.ts +9 -0
- package/dist/components/NodeGraph/index.js +9 -0
- package/dist/components/NodeGraph/types.d.ts +94 -0
- package/dist/components/NodeGraph/types.js +33 -0
- package/dist/components/NotesEditor/NotesEditor.svelte +203 -0
- package/dist/components/NotesEditor/NotesEditor.svelte.d.ts +9 -0
- package/dist/components/NotesEditor/index.d.ts +1 -0
- package/dist/components/NotesEditor/index.js +1 -0
- package/dist/components/NumberInput/NumberInput.svelte +353 -0
- package/dist/components/NumberInput/NumberInput.svelte.d.ts +26 -0
- package/dist/components/NumberInput/index.d.ts +1 -0
- package/dist/components/NumberInput/index.js +1 -0
- package/dist/components/PasswordInput/PasswordInput.svelte +41 -0
- package/dist/components/PasswordInput/PasswordInput.svelte.d.ts +13 -0
- package/dist/components/PasswordInput/index.d.ts +1 -0
- package/dist/components/PasswordInput/index.js +1 -0
- package/dist/components/Popup/index.d.ts +1 -0
- package/dist/components/Popup/index.js +1 -0
- package/dist/components/Rating/Rating.svelte +77 -0
- package/dist/components/Rating/Rating.svelte.d.ts +7 -0
- package/dist/components/Rating/index.d.ts +1 -0
- package/dist/components/Rating/index.js +1 -0
- package/dist/components/ScrollView/ScrollView.svelte +285 -0
- package/dist/components/ScrollView/ScrollView.svelte.d.ts +19 -0
- package/dist/components/ScrollView/index.d.ts +1 -0
- package/dist/components/ScrollView/index.js +1 -0
- package/dist/components/SearchInput/SearchInput.svelte +50 -0
- package/dist/components/SearchInput/SearchInput.svelte.d.ts +15 -0
- package/dist/components/SearchInput/index.d.ts +1 -0
- package/dist/components/SearchInput/index.js +1 -0
- package/dist/components/Select/Select.svelte +278 -0
- package/dist/components/Select/Select.svelte.d.ts +35 -0
- package/dist/components/Select/index.d.ts +1 -0
- package/dist/components/Select/index.js +1 -0
- package/dist/components/TextArea/TextArea.svelte +734 -0
- package/dist/components/TextArea/TextArea.svelte.d.ts +19 -0
- package/dist/components/TextArea/index.d.ts +1 -0
- package/dist/components/TextArea/index.js +1 -0
- package/dist/components/ThemeSelector/ThemeSelector.svelte +64 -0
- package/dist/components/ThemeSelector/ThemeSelector.svelte.d.ts +3 -0
- package/dist/components/ThemeSelector/index.d.ts +1 -0
- package/dist/components/ThemeSelector/index.js +1 -0
- package/dist/components/TooltipHandler/TooltipHandler.svelte +593 -0
- package/dist/components/TooltipHandler/TooltipHandler.svelte.d.ts +10 -0
- package/dist/components/TooltipHandler/index.d.ts +1 -0
- package/dist/components/TooltipHandler/index.js +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +26 -0
- package/dist/styles/sizes.d.ts +78 -0
- package/dist/styles/sizes.js +120 -0
- package/dist/styles/variants.d.ts +106 -0
- package/dist/styles/variants.js +194 -0
- package/dist/types/ManagerTypes.d.ts +42 -0
- package/dist/types/ManagerTypes.js +1 -0
- package/dist/types/sizes.d.ts +5 -0
- package/dist/types/sizes.js +1 -0
- package/dist/types/variants.d.ts +1 -0
- package/dist/types/variants.js +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Icon from "../Icon/Icon.svelte";
|
|
3
|
+
import { faCopy } from "@fortawesome/free-solid-svg-icons";
|
|
4
|
+
import type { HTMLTextareaAttributes } from "svelte/elements";
|
|
5
|
+
import { DEV } from "esm-env";
|
|
6
|
+
|
|
7
|
+
interface Props extends HTMLTextareaAttributes {
|
|
8
|
+
value?: string;
|
|
9
|
+
lines?: string[];
|
|
10
|
+
class?: string;
|
|
11
|
+
textareaClass?: string;
|
|
12
|
+
validate?: (value: string) => boolean | string;
|
|
13
|
+
valid?: boolean;
|
|
14
|
+
touched?: boolean;
|
|
15
|
+
element?: HTMLTextAreaElement;
|
|
16
|
+
minRows?: number;
|
|
17
|
+
maxRows?: number;
|
|
18
|
+
lineHeightRem?: number;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
allowCopy?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let {
|
|
24
|
+
value = $bindable(),
|
|
25
|
+
lines = $bindable(),
|
|
26
|
+
class: containerClass = "",
|
|
27
|
+
textareaClass = "",
|
|
28
|
+
validate,
|
|
29
|
+
valid = $bindable(true),
|
|
30
|
+
touched = $bindable(false),
|
|
31
|
+
element = $bindable(),
|
|
32
|
+
disabled = false,
|
|
33
|
+
minRows = 3,
|
|
34
|
+
maxRows,
|
|
35
|
+
lineHeightRem = 1.5,
|
|
36
|
+
required = false,
|
|
37
|
+
allowCopy = $bindable(true),
|
|
38
|
+
readonly = false,
|
|
39
|
+
...restProps
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
|
|
42
|
+
let textareaElement = $state<HTMLTextAreaElement>();
|
|
43
|
+
let containerElement = $state<HTMLDivElement>();
|
|
44
|
+
let isDragging = $state(false);
|
|
45
|
+
let dragStartY = 0;
|
|
46
|
+
let dragStartScrollTop = 0;
|
|
47
|
+
let thumbHeight = $state(20);
|
|
48
|
+
let thumbTop = $state(4);
|
|
49
|
+
|
|
50
|
+
// Development warnings for prop conflicts and empty content
|
|
51
|
+
if (DEV) {
|
|
52
|
+
if (value !== undefined && lines !== undefined) {
|
|
53
|
+
console.warn(
|
|
54
|
+
"TextArea: Both value and lines provided. Using value, ignoring lines."
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (value === undefined && !lines) {
|
|
58
|
+
console.warn(
|
|
59
|
+
"TextArea: No content provided (value or lines). TextArea will be empty."
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Determine content source and effective content
|
|
65
|
+
const hasValueProp = value !== undefined;
|
|
66
|
+
const hasLinesProp = lines !== undefined;
|
|
67
|
+
|
|
68
|
+
// Content precedence: value > lines
|
|
69
|
+
let effectiveContent = $derived(
|
|
70
|
+
hasValueProp ? value : hasLinesProp ? lines?.join("\n") || "" : ""
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Handle bidirectional binding for lines
|
|
74
|
+
$effect(() => {
|
|
75
|
+
if (hasLinesProp && !hasValueProp && textareaElement) {
|
|
76
|
+
const textValue = textareaElement.value;
|
|
77
|
+
if (textValue !== lines?.join("\n")) {
|
|
78
|
+
lines = textValue.split("\n");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Enforce minimum rows and handle maxRows auto-sizing
|
|
84
|
+
const effectiveMinRows = Math.max(minRows, 2);
|
|
85
|
+
const effectiveMaxRows = maxRows ?? Infinity;
|
|
86
|
+
// disabled overrides both readonly and allowCopy
|
|
87
|
+
const effectiveReadonly = disabled ? true : readonly;
|
|
88
|
+
const effectiveAllowCopy = disabled ? false : allowCopy;
|
|
89
|
+
|
|
90
|
+
// Calculate initial rows based on content
|
|
91
|
+
// NOTE: This only counts explicit line breaks, not wrapped lines.
|
|
92
|
+
// The browser's native rows attribute has this same limitation.
|
|
93
|
+
// After mount, updateHeight() will calculate the precise height including wrapped text.
|
|
94
|
+
let initialRows = $derived.by(() => {
|
|
95
|
+
if (effectiveContent) {
|
|
96
|
+
// Count actual lines in the content (newline characters only)
|
|
97
|
+
const lineCount = effectiveContent.split("\n").length;
|
|
98
|
+
// Clamp between min and max
|
|
99
|
+
return Math.min(
|
|
100
|
+
Math.max(lineCount, effectiveMinRows, minRows),
|
|
101
|
+
effectiveMaxRows
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
// Default to minRows if no content
|
|
105
|
+
return Math.max(minRows, effectiveMinRows);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Sync element binding
|
|
109
|
+
$effect(() => {
|
|
110
|
+
element = textareaElement;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Validation logic
|
|
114
|
+
let validationResult = $state<boolean | string>(true);
|
|
115
|
+
|
|
116
|
+
function runValidation() {
|
|
117
|
+
if (!validate) {
|
|
118
|
+
valid = true;
|
|
119
|
+
validationResult = true;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = validate(value || "");
|
|
124
|
+
validationResult = result;
|
|
125
|
+
valid = result === true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Run validation when value changes
|
|
129
|
+
$effect(() => {
|
|
130
|
+
runValidation();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Calculate line height for proper sizing
|
|
134
|
+
let lineHeight = $state(0); // Will be calculated from actual element
|
|
135
|
+
let actualLineHeightRem = $state(0); // The rounded rem value we actually use
|
|
136
|
+
|
|
137
|
+
// Auto-resize functionality
|
|
138
|
+
function updateHeight() {
|
|
139
|
+
if (!textareaElement) return;
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
// Get line height - calculate from the prop value, ensuring whole pixels
|
|
144
|
+
if (lineHeight === 0) {
|
|
145
|
+
const rootFontSize = parseFloat(
|
|
146
|
+
getComputedStyle(document.documentElement).fontSize
|
|
147
|
+
);
|
|
148
|
+
// Round to nearest whole pixel, then convert back to rem for consistency
|
|
149
|
+
lineHeight = Math.round(lineHeightRem * rootFontSize);
|
|
150
|
+
actualLineHeightRem = lineHeight / rootFontSize;
|
|
151
|
+
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Store current scroll position
|
|
155
|
+
const currentScroll = textareaElement.scrollTop;
|
|
156
|
+
|
|
157
|
+
// Force browser reflow by completely resetting styles
|
|
158
|
+
textareaElement.style.height = "";
|
|
159
|
+
textareaElement.style.overflow = "";
|
|
160
|
+
|
|
161
|
+
// Force a layout reflow
|
|
162
|
+
textareaElement.offsetHeight;
|
|
163
|
+
|
|
164
|
+
// Now set to auto for measurement
|
|
165
|
+
textareaElement.style.height = "auto";
|
|
166
|
+
textareaElement.style.overflow = "hidden";
|
|
167
|
+
|
|
168
|
+
// Force another reflow to ensure measurement is accurate
|
|
169
|
+
const scrollHeight = textareaElement.scrollHeight;
|
|
170
|
+
|
|
171
|
+
// Calculate rows needed
|
|
172
|
+
const neededRows = Math.ceil(scrollHeight / lineHeight);
|
|
173
|
+
const actualRows = Math.min(
|
|
174
|
+
Math.max(neededRows, effectiveMinRows),
|
|
175
|
+
effectiveMaxRows
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Update maxRows exceeded state
|
|
179
|
+
maxRowsExceeded = neededRows > effectiveMaxRows;
|
|
180
|
+
|
|
181
|
+
// Set the height
|
|
182
|
+
textareaElement.style.height = `${actualRows * lineHeight}px`;
|
|
183
|
+
textareaElement.style.overflow = ""; // Restore overflow
|
|
184
|
+
|
|
185
|
+
// Restore scroll position
|
|
186
|
+
textareaElement.scrollTop = currentScroll;
|
|
187
|
+
|
|
188
|
+
// Check if textarea is actually scrollable (after setting height)
|
|
189
|
+
// Only auto-sizing textareas (no maxRows) or those within maxRows bounds should allow page scrolling
|
|
190
|
+
// Need a brief delay to ensure DOM is updated
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
if (textareaElement && !effectiveReadonly) {
|
|
193
|
+
// If maxRows is not set (auto-sizing), never capture scroll events
|
|
194
|
+
if (maxRows === undefined) {
|
|
195
|
+
isScrollable = false;
|
|
196
|
+
} else {
|
|
197
|
+
// Only scrollable if content actually overflows the set height
|
|
198
|
+
isScrollable =
|
|
199
|
+
textareaElement.scrollHeight > textareaElement.clientHeight;
|
|
200
|
+
}
|
|
201
|
+
} else if (effectiveReadonly) {
|
|
202
|
+
// Readonly textareas are never scrollable in our context
|
|
203
|
+
isScrollable = false;
|
|
204
|
+
}
|
|
205
|
+
}, 0);
|
|
206
|
+
|
|
207
|
+
// Update scrollbar when content size changes
|
|
208
|
+
updateScrollbar();
|
|
209
|
+
|
|
210
|
+
// Force scrollbar update after DOM has settled, in case dimensions changed
|
|
211
|
+
setTimeout(() => updateScrollbar(), 0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Update height when value changes
|
|
215
|
+
$effect(() => {
|
|
216
|
+
if (textareaElement && value !== undefined) {
|
|
217
|
+
updateHeight();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Initial height setup
|
|
222
|
+
$effect(() => {
|
|
223
|
+
if (textareaElement) {
|
|
224
|
+
// Don't set rows attribute - let our height calculation handle it
|
|
225
|
+
// Call immediately
|
|
226
|
+
updateHeight();
|
|
227
|
+
|
|
228
|
+
// Also call after a brief delay to ensure DOM is fully settled
|
|
229
|
+
setTimeout(() => updateHeight(), 0);
|
|
230
|
+
|
|
231
|
+
// Check if mouse is already over the element on mount (scrollbar for readonly too)
|
|
232
|
+
if (
|
|
233
|
+
containerElement?.matches(":hover") ||
|
|
234
|
+
textareaElement?.matches(":hover")
|
|
235
|
+
) {
|
|
236
|
+
hovered = true;
|
|
237
|
+
updateScrollbar();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Computed classes
|
|
243
|
+
let containerClasses = $derived.by(() => {
|
|
244
|
+
const base = "relative w-full";
|
|
245
|
+
return `${base} ${containerClass}`.trim();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
let wrapperClasses = $derived.by(() => {
|
|
249
|
+
const base = "relative w-full rounded-md border-2 py-1 px-2";
|
|
250
|
+
|
|
251
|
+
// Use new semantic color system matching Input
|
|
252
|
+
const themeStyles = [
|
|
253
|
+
"bg-transparent",
|
|
254
|
+
"[border-color:var(--color-secondary)]",
|
|
255
|
+
"hover:[border-color:color-mix(in_srgb,var(--color-secondary)_80%,black)]",
|
|
256
|
+
"focus-within:[border-color:color-mix(in_srgb,var(--color-secondary)_90%,black)]",
|
|
257
|
+
"focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)]",
|
|
258
|
+
"focus-within:[outline-offset:0px]",
|
|
259
|
+
].join(" ");
|
|
260
|
+
|
|
261
|
+
// Validation state overrides
|
|
262
|
+
let validationStyles = "";
|
|
263
|
+
if (touched && !valid) {
|
|
264
|
+
validationStyles =
|
|
265
|
+
"[border-color:var(--color-danger)] focus-within:[outline:2px_solid_color-mix(in_srgb,var(--color-danger)_70%,transparent)]";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Disabled and readonly state
|
|
269
|
+
const disabledStyles = disabled
|
|
270
|
+
? "![background-color:color-mix(in_srgb,var(--color-text)_5%,transparent)] [border-color:color-mix(in_srgb,var(--color-secondary)_50%,transparent)] !border !py-[calc(0.25rem+1px)] !px-[calc(0.5rem+1px)] cursor-not-allowed"
|
|
271
|
+
: effectiveReadonly
|
|
272
|
+
? "[border-color:var(--color-secondary)] !border !py-[calc(0.25rem+1px)] !px-[calc(0.5rem+1px)]"
|
|
273
|
+
: "";
|
|
274
|
+
|
|
275
|
+
return `${base} ${themeStyles} ${validationStyles} ${disabledStyles}`.trim();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
let textareaClasses = $derived.by(() => {
|
|
279
|
+
const base =
|
|
280
|
+
"w-full bg-transparent border-none outline-none text-sm resize-none custom-textarea";
|
|
281
|
+
|
|
282
|
+
// Use new semantic color system
|
|
283
|
+
const themeStyles = [
|
|
284
|
+
"[color:var(--color-text)]",
|
|
285
|
+
"placeholder:[color:var(--color-secondary)]",
|
|
286
|
+
"disabled:bg-transparent",
|
|
287
|
+
"disabled:[color:color-mix(in_srgb,var(--color-text)_40%,transparent)]",
|
|
288
|
+
"disabled:cursor-not-allowed",
|
|
289
|
+
].join(" ");
|
|
290
|
+
|
|
291
|
+
// Padding adjustment for copy icon
|
|
292
|
+
const padding = effectiveAllowCopy ? "pr-8" : "";
|
|
293
|
+
|
|
294
|
+
// Cursor style based on allowCopy
|
|
295
|
+
const cursor = effectiveAllowCopy ? "" : "cursor-default";
|
|
296
|
+
|
|
297
|
+
return `${base} ${themeStyles} ${padding} ${cursor} ${textareaClass}`.trim();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
let copyIconClasses = $derived.by(() => {
|
|
301
|
+
const base =
|
|
302
|
+
"absolute top-0 right-0 w-8 h-8 flex items-center justify-center flex-shrink-0 select-none z-20";
|
|
303
|
+
const interactive =
|
|
304
|
+
"cursor-pointer focus:outline-none transition-colors group";
|
|
305
|
+
|
|
306
|
+
return `${base} ${interactive}`.trim();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Event handlers
|
|
310
|
+
function handleInput(event: Event) {
|
|
311
|
+
const target = event.target as HTMLTextAreaElement;
|
|
312
|
+
const newValue = target.value;
|
|
313
|
+
|
|
314
|
+
// Update the appropriate binding based on content source
|
|
315
|
+
if (hasValueProp) {
|
|
316
|
+
value = newValue;
|
|
317
|
+
} else if (hasLinesProp) {
|
|
318
|
+
lines = newValue.split("\n");
|
|
319
|
+
}
|
|
320
|
+
// For children mode, the textarea handles its own content
|
|
321
|
+
|
|
322
|
+
updateHeight();
|
|
323
|
+
if (restProps.oninput) restProps.oninput(event as any);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function handleFocus(event: FocusEvent) {
|
|
327
|
+
if (!effectiveAllowCopy) {
|
|
328
|
+
// Prevent focus when allowCopy is false
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
if (textareaElement) {
|
|
331
|
+
textareaElement.blur();
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
touched = true;
|
|
336
|
+
if (restProps.onfocus) restProps.onfocus(event as any);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Copy functionality
|
|
340
|
+
async function copyToClipboard() {
|
|
341
|
+
if (!effectiveContent) return;
|
|
342
|
+
try {
|
|
343
|
+
await navigator.clipboard.writeText(effectiveContent);
|
|
344
|
+
// In a real app, you might show a toast notification here
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.error("Failed to copy text: ", err);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function handleCopyKeydown(event: KeyboardEvent) {
|
|
351
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
copyToClipboard();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function handleCopyMouseDown(event: MouseEvent) {
|
|
358
|
+
event.preventDefault(); // Prevent text selection on double-click
|
|
359
|
+
event.stopPropagation(); // Prevent event from bubbling
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function handleCopySelectStart(event: Event) {
|
|
363
|
+
event.preventDefault(); // Prevent text selection
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleCopyDoubleClick(event: MouseEvent) {
|
|
368
|
+
event.preventDefault();
|
|
369
|
+
event.stopPropagation();
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Prevent text selection when allowCopy is false
|
|
374
|
+
function handleSelectStart(event: Event) {
|
|
375
|
+
if (!effectiveAllowCopy) {
|
|
376
|
+
event.preventDefault();
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Additional handlers for comprehensive text selection prevention
|
|
382
|
+
function handleDragStart(event: DragEvent) {
|
|
383
|
+
if (!effectiveAllowCopy) {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function handleContextMenu(event: MouseEvent) {
|
|
390
|
+
if (!effectiveAllowCopy) {
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function handleMouseDown(event: MouseEvent) {
|
|
397
|
+
// Handle restProps mousedown first
|
|
398
|
+
if (restProps.onmousedown) restProps.onmousedown(event as any);
|
|
399
|
+
|
|
400
|
+
if (!effectiveAllowCopy) {
|
|
401
|
+
// Prevent clicking inside entirely - no focus, no selection
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
event.stopPropagation();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
408
|
+
// Handle restProps keydown first
|
|
409
|
+
if (restProps.onkeydown) restProps.onkeydown(event as any);
|
|
410
|
+
|
|
411
|
+
if (!effectiveAllowCopy) {
|
|
412
|
+
// Prevent Ctrl+A (select all) and other selection shortcuts
|
|
413
|
+
if (
|
|
414
|
+
(event.ctrlKey || event.metaKey) &&
|
|
415
|
+
(event.key === "a" || event.key === "A")
|
|
416
|
+
) {
|
|
417
|
+
event.preventDefault();
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Override browser validation
|
|
424
|
+
$effect(() => {
|
|
425
|
+
if (textareaElement) {
|
|
426
|
+
textareaElement.setCustomValidity("");
|
|
427
|
+
if (touched && !valid && typeof validationResult === "string") {
|
|
428
|
+
textareaElement.setCustomValidity(validationResult);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Track hover state for scrollbar
|
|
434
|
+
let hovered = $state(false);
|
|
435
|
+
|
|
436
|
+
// Track if maxRows is exceeded
|
|
437
|
+
let maxRowsExceeded = $state(false);
|
|
438
|
+
|
|
439
|
+
// Track if the textarea is actually scrollable
|
|
440
|
+
let isScrollable = $state(false);
|
|
441
|
+
|
|
442
|
+
// Scrollbar functions
|
|
443
|
+
function showScrollbar() {
|
|
444
|
+
if (!textareaElement || !hovered || lineHeight === 0) return false;
|
|
445
|
+
|
|
446
|
+
// Show scrollbar only when textarea is actually scrollable and we're hovered
|
|
447
|
+
return isScrollable;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function updateScrollbar() {
|
|
451
|
+
if (!showScrollbar() || !textareaElement) {
|
|
452
|
+
thumbHeight = 20;
|
|
453
|
+
thumbTop = 0;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const visibleRatio =
|
|
458
|
+
textareaElement.clientHeight / textareaElement.scrollHeight;
|
|
459
|
+
const trackHeight = textareaElement.clientHeight;
|
|
460
|
+
thumbHeight = Math.max(
|
|
461
|
+
20,
|
|
462
|
+
Math.min(trackHeight, visibleRatio * trackHeight)
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const maxScrollTop =
|
|
466
|
+
textareaElement.scrollHeight - textareaElement.clientHeight;
|
|
467
|
+
if (maxScrollTop > 0) {
|
|
468
|
+
const scrollRatio = textareaElement.scrollTop / maxScrollTop;
|
|
469
|
+
const availableTrackHeight = trackHeight - thumbHeight;
|
|
470
|
+
thumbTop = scrollRatio * availableTrackHeight;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let isAdjustingScroll = false; // Prevent adjustment loops
|
|
475
|
+
|
|
476
|
+
function handleScroll() {
|
|
477
|
+
if (
|
|
478
|
+
textareaElement &&
|
|
479
|
+
lineHeight > 0 &&
|
|
480
|
+
!isAdjustingScroll &&
|
|
481
|
+
isScrollable &&
|
|
482
|
+
!effectiveReadonly
|
|
483
|
+
) {
|
|
484
|
+
// Get the current scroll position
|
|
485
|
+
const currentScroll = textareaElement.scrollTop;
|
|
486
|
+
|
|
487
|
+
// Calculate the nearest line boundary (ensure whole pixels)
|
|
488
|
+
const nearestLineScroll = Math.round(
|
|
489
|
+
Math.round(currentScroll / lineHeight) * lineHeight
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
// Only adjust if we're more than 0.5px away from a line boundary
|
|
493
|
+
// Use a smaller threshold to reduce bounce but still snap effectively
|
|
494
|
+
if (Math.abs(currentScroll - nearestLineScroll) > 0.5) {
|
|
495
|
+
isAdjustingScroll = true;
|
|
496
|
+
textareaElement.scrollTop = nearestLineScroll;
|
|
497
|
+
// Reset flag after a brief delay to allow the adjustment to complete
|
|
498
|
+
setTimeout(() => {
|
|
499
|
+
isAdjustingScroll = false;
|
|
500
|
+
}, 10);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
updateScrollbar();
|
|
504
|
+
} else if (textareaElement) {
|
|
505
|
+
// For non-scrollable textareas, just update the scrollbar without adjusting scroll
|
|
506
|
+
updateScrollbar();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function handleWheel(event: WheelEvent) {
|
|
511
|
+
if (!textareaElement || lineHeight === 0 || effectiveReadonly) return;
|
|
512
|
+
|
|
513
|
+
// Only handle wheel events if the textarea is actually scrollable
|
|
514
|
+
if (!isScrollable) return;
|
|
515
|
+
|
|
516
|
+
// Check if the scroll would stay within textarea bounds
|
|
517
|
+
const currentScroll = textareaElement.scrollTop;
|
|
518
|
+
const maxScrollTop =
|
|
519
|
+
textareaElement.scrollHeight - textareaElement.clientHeight;
|
|
520
|
+
const scrollingDown = event.deltaY > 0;
|
|
521
|
+
const scrollingUp = event.deltaY < 0;
|
|
522
|
+
|
|
523
|
+
// Only prevent default if the scroll would stay within bounds
|
|
524
|
+
const canScrollDown = scrollingDown && currentScroll < maxScrollTop;
|
|
525
|
+
const canScrollUp = scrollingUp && currentScroll > 0;
|
|
526
|
+
|
|
527
|
+
if (!canScrollDown && !canScrollUp) {
|
|
528
|
+
// Let the page handle the scroll if we're at the bounds
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Prevent default scrolling behavior only when we can handle it
|
|
533
|
+
event.preventDefault();
|
|
534
|
+
|
|
535
|
+
// Calculate scroll step in terms of line heights
|
|
536
|
+
// Use 3 lines per wheel "tick" for smooth scrolling
|
|
537
|
+
const linesPerStep = 3;
|
|
538
|
+
const scrollStep = lineHeight * linesPerStep;
|
|
539
|
+
|
|
540
|
+
// Determine scroll direction
|
|
541
|
+
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep;
|
|
542
|
+
|
|
543
|
+
// Get current position snapped to line boundary
|
|
544
|
+
const currentLine = Math.round(currentScroll / lineHeight);
|
|
545
|
+
|
|
546
|
+
// Calculate new line position
|
|
547
|
+
const newLine = Math.max(
|
|
548
|
+
0,
|
|
549
|
+
currentLine + (scrollDelta > 0 ? linesPerStep : -linesPerStep)
|
|
550
|
+
);
|
|
551
|
+
const newScrollTop = newLine * lineHeight;
|
|
552
|
+
|
|
553
|
+
// Apply bounds checking
|
|
554
|
+
const boundedScrollTop = Math.max(0, Math.min(newScrollTop, maxScrollTop));
|
|
555
|
+
|
|
556
|
+
// Set the new scroll position (this will trigger handleScroll which will fine-tune if needed)
|
|
557
|
+
textareaElement.scrollTop = boundedScrollTop;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function handleThumbMouseDown(event: MouseEvent) {
|
|
561
|
+
if (!textareaElement) return;
|
|
562
|
+
event.preventDefault();
|
|
563
|
+
isDragging = true;
|
|
564
|
+
dragStartY = event.clientY;
|
|
565
|
+
dragStartScrollTop = textareaElement.scrollTop;
|
|
566
|
+
|
|
567
|
+
document.addEventListener("mousemove", handleThumbMouseMove);
|
|
568
|
+
document.addEventListener("mouseup", handleThumbMouseUp);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function handleThumbMouseMove(event: MouseEvent) {
|
|
572
|
+
if (!isDragging || !textareaElement) return;
|
|
573
|
+
|
|
574
|
+
const deltaY = event.clientY - dragStartY;
|
|
575
|
+
const trackHeight = textareaElement.clientHeight - thumbHeight;
|
|
576
|
+
const scrollRatio = deltaY / trackHeight;
|
|
577
|
+
const maxScrollTop =
|
|
578
|
+
textareaElement.scrollHeight - textareaElement.clientHeight;
|
|
579
|
+
|
|
580
|
+
textareaElement.scrollTop = Math.max(
|
|
581
|
+
0,
|
|
582
|
+
Math.min(maxScrollTop, dragStartScrollTop + scrollRatio * maxScrollTop)
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function handleThumbMouseUp() {
|
|
587
|
+
isDragging = false;
|
|
588
|
+
document.removeEventListener("mousemove", handleThumbMouseMove);
|
|
589
|
+
document.removeEventListener("mouseup", handleThumbMouseUp);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Use Svelte reactivity to track window size changes
|
|
593
|
+
let windowWidth = $state(0);
|
|
594
|
+
let windowHeight = $state(0);
|
|
595
|
+
|
|
596
|
+
// Initialize window dimensions
|
|
597
|
+
$effect(() => {
|
|
598
|
+
if (typeof window !== 'undefined') {
|
|
599
|
+
windowWidth = window.innerWidth;
|
|
600
|
+
windowHeight = window.innerHeight;
|
|
601
|
+
|
|
602
|
+
const handleResize = () => {
|
|
603
|
+
windowWidth = window.innerWidth;
|
|
604
|
+
windowHeight = window.innerHeight;
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
window.addEventListener('resize', handleResize);
|
|
608
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// React to window size changes and update height immediately
|
|
613
|
+
$effect(() => {
|
|
614
|
+
if (textareaElement && windowWidth > 0) {
|
|
615
|
+
updateHeight();
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Update scrollbar when hover state changes
|
|
620
|
+
$effect(() => {
|
|
621
|
+
if (hovered !== undefined && !effectiveReadonly) {
|
|
622
|
+
updateScrollbar();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
</script>
|
|
626
|
+
|
|
627
|
+
<div class={containerClasses}>
|
|
628
|
+
<div
|
|
629
|
+
bind:this={containerElement}
|
|
630
|
+
class={wrapperClasses}
|
|
631
|
+
onmouseenter={effectiveReadonly
|
|
632
|
+
? undefined
|
|
633
|
+
: () => {
|
|
634
|
+
hovered = true;
|
|
635
|
+
updateScrollbar();
|
|
636
|
+
}}
|
|
637
|
+
onmouseleave={effectiveReadonly
|
|
638
|
+
? undefined
|
|
639
|
+
: () => {
|
|
640
|
+
hovered = false;
|
|
641
|
+
updateScrollbar();
|
|
642
|
+
}}
|
|
643
|
+
role="group"
|
|
644
|
+
aria-label="Text area with controls"
|
|
645
|
+
>
|
|
646
|
+
<textarea
|
|
647
|
+
bind:this={textareaElement}
|
|
648
|
+
value={effectiveContent}
|
|
649
|
+
oninput={handleInput}
|
|
650
|
+
onfocus={handleFocus}
|
|
651
|
+
onscroll={handleScroll}
|
|
652
|
+
onwheel={effectiveReadonly ? undefined : handleWheel}
|
|
653
|
+
onselectstart={handleSelectStart}
|
|
654
|
+
onmousedown={handleMouseDown}
|
|
655
|
+
onkeydown={handleKeyDown}
|
|
656
|
+
ondragstart={handleDragStart}
|
|
657
|
+
oncontextmenu={handleContextMenu}
|
|
658
|
+
{...restProps}
|
|
659
|
+
class={textareaClasses}
|
|
660
|
+
style="line-height: {actualLineHeightRem ||
|
|
661
|
+
lineHeightRem}rem; margin: 0; padding: 0; vertical-align: top; {effectiveReadonly
|
|
662
|
+
? ''
|
|
663
|
+
: 'scroll-snap-type: y mandatory; scroll-padding: 0; overscroll-behavior: contain;'}{effectiveAllowCopy
|
|
664
|
+
? ''
|
|
665
|
+
: ' user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;'}"
|
|
666
|
+
rows={initialRows}
|
|
667
|
+
{disabled}
|
|
668
|
+
readonly={effectiveReadonly}
|
|
669
|
+
{required}
|
|
670
|
+
tabindex={effectiveAllowCopy ? undefined : -1}
|
|
671
|
+
aria-invalid={touched && !valid ? "true" : "false"}
|
|
672
|
+
aria-describedby={touched &&
|
|
673
|
+
!valid &&
|
|
674
|
+
typeof validationResult === "string"
|
|
675
|
+
? "textarea-error"
|
|
676
|
+
: undefined}
|
|
677
|
+
></textarea>
|
|
678
|
+
|
|
679
|
+
{#if effectiveAllowCopy && effectiveContent}
|
|
680
|
+
<div
|
|
681
|
+
class={copyIconClasses}
|
|
682
|
+
onclick={copyToClipboard}
|
|
683
|
+
onkeydown={handleCopyKeydown}
|
|
684
|
+
onmousedown={handleCopyMouseDown}
|
|
685
|
+
ondblclick={handleCopyDoubleClick}
|
|
686
|
+
onselectstart={handleCopySelectStart}
|
|
687
|
+
role="button"
|
|
688
|
+
tabindex="0"
|
|
689
|
+
style="user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;"
|
|
690
|
+
title="Copy to clipboard"
|
|
691
|
+
>
|
|
692
|
+
<Icon
|
|
693
|
+
icon={faCopy}
|
|
694
|
+
class="w-4 h-4 [color:var(--color-secondary)!important] group-hover:[color:color-mix(in_srgb,var(--color-secondary)_80%,black)!important] group-focus:[color:color-mix(in_srgb,currentColor_70%,transparent)!important] transition-colors"
|
|
695
|
+
/>
|
|
696
|
+
</div>
|
|
697
|
+
{/if}
|
|
698
|
+
|
|
699
|
+
<!-- Custom scrollbar thumb -->
|
|
700
|
+
{#if showScrollbar()}
|
|
701
|
+
<div
|
|
702
|
+
class="absolute right-1 [background-color:var(--color-secondary)] rounded cursor-pointer hover:[background-color:color-mix(in_srgb,var(--color-secondary)_90%,black)] transition-colors z-10"
|
|
703
|
+
style="top: {thumbTop + 4}px; height: {thumbHeight}px; width: 6px;"
|
|
704
|
+
onmousedown={handleThumbMouseDown}
|
|
705
|
+
role="scrollbar"
|
|
706
|
+
aria-orientation="vertical"
|
|
707
|
+
aria-controls="textarea"
|
|
708
|
+
aria-valuenow={textareaElement
|
|
709
|
+
? Math.round(
|
|
710
|
+
(textareaElement.scrollTop /
|
|
711
|
+
(textareaElement.scrollHeight - textareaElement.clientHeight)) *
|
|
712
|
+
100
|
|
713
|
+
)
|
|
714
|
+
: 0}
|
|
715
|
+
aria-valuemin="0"
|
|
716
|
+
aria-valuemax="100"
|
|
717
|
+
tabindex="-1"
|
|
718
|
+
aria-label="Scroll thumb"
|
|
719
|
+
></div>
|
|
720
|
+
{/if}
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
|
|
724
|
+
<style>
|
|
725
|
+
/* Hide default scrollbar */
|
|
726
|
+
.custom-textarea::-webkit-scrollbar {
|
|
727
|
+
display: none;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.custom-textarea {
|
|
731
|
+
scrollbar-width: none;
|
|
732
|
+
-ms-overflow-style: none;
|
|
733
|
+
}
|
|
734
|
+
</style>
|