@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.
Files changed (116) hide show
  1. package/README.md +81 -0
  2. package/dist/components/Badge/Badge.svelte +80 -0
  3. package/dist/components/Badge/Badge.svelte.d.ts +14 -0
  4. package/dist/components/Badge/index.d.ts +1 -0
  5. package/dist/components/Badge/index.js +1 -0
  6. package/dist/components/Button/Button.svelte +172 -0
  7. package/dist/components/Button/Button.svelte.d.ts +32 -0
  8. package/dist/components/Button/index.d.ts +1 -0
  9. package/dist/components/Button/index.js +1 -0
  10. package/dist/components/Dialog/Dialog.svelte +101 -0
  11. package/dist/components/Dialog/Dialog.svelte.d.ts +18 -0
  12. package/dist/components/Dialog/index.d.ts +1 -0
  13. package/dist/components/Dialog/index.js +1 -0
  14. package/dist/components/ElementManager/ElementManager.svelte +397 -0
  15. package/dist/components/ElementManager/ElementManager.svelte.d.ts +43 -0
  16. package/dist/components/ElementManager/index.d.ts +1 -0
  17. package/dist/components/ElementManager/index.js +1 -0
  18. package/dist/components/EmailInput/EmailInput.svelte +47 -0
  19. package/dist/components/EmailInput/EmailInput.svelte.d.ts +14 -0
  20. package/dist/components/EmailInput/index.d.ts +1 -0
  21. package/dist/components/EmailInput/index.js +1 -0
  22. package/dist/components/Icon/Icon.svelte +74 -0
  23. package/dist/components/Icon/Icon.svelte.d.ts +13 -0
  24. package/dist/components/Icon/index.d.ts +1 -0
  25. package/dist/components/Icon/index.js +1 -0
  26. package/dist/components/Input/Input.svelte +268 -0
  27. package/dist/components/Input/Input.svelte.d.ts +18 -0
  28. package/dist/components/Input/index.d.ts +1 -0
  29. package/dist/components/Input/index.js +1 -0
  30. package/dist/components/List/List.svelte +580 -0
  31. package/dist/components/List/List.svelte.d.ts +38 -0
  32. package/dist/components/List/index.d.ts +1 -0
  33. package/dist/components/List/index.js +1 -0
  34. package/dist/components/ListItem/ListItem.svelte +175 -0
  35. package/dist/components/ListItem/ListItem.svelte.d.ts +24 -0
  36. package/dist/components/ListItem/index.d.ts +2 -0
  37. package/dist/components/ListItem/index.js +2 -0
  38. package/dist/components/ListView/ListView.svelte +463 -0
  39. package/dist/components/ListView/ListView.svelte.d.ts +37 -0
  40. package/dist/components/ListView/index.d.ts +2 -0
  41. package/dist/components/ListView/index.js +2 -0
  42. package/dist/components/NodeGraph/BaseNode.d.ts +57 -0
  43. package/dist/components/NodeGraph/BaseNode.js +30 -0
  44. package/dist/components/NodeGraph/Edge.svelte +60 -0
  45. package/dist/components/NodeGraph/Edge.svelte.d.ts +16 -0
  46. package/dist/components/NodeGraph/ExpressionEvaluator.d.ts +82 -0
  47. package/dist/components/NodeGraph/ExpressionEvaluator.js +152 -0
  48. package/dist/components/NodeGraph/Node.svelte +100 -0
  49. package/dist/components/NodeGraph/Node.svelte.d.ts +10 -0
  50. package/dist/components/NodeGraph/NodeGraph.svelte +52 -0
  51. package/dist/components/NodeGraph/NodeGraph.svelte.d.ts +14 -0
  52. package/dist/components/NodeGraph/NodeInstance.svelte.d.ts +80 -0
  53. package/dist/components/NodeGraph/NodeInstance.svelte.js +241 -0
  54. package/dist/components/NodeGraph/NodePort.svelte +75 -0
  55. package/dist/components/NodeGraph/NodePort.svelte.d.ts +11 -0
  56. package/dist/components/NodeGraph/decorators.d.ts +81 -0
  57. package/dist/components/NodeGraph/decorators.js +148 -0
  58. package/dist/components/NodeGraph/index.d.ts +9 -0
  59. package/dist/components/NodeGraph/index.js +9 -0
  60. package/dist/components/NodeGraph/types.d.ts +94 -0
  61. package/dist/components/NodeGraph/types.js +33 -0
  62. package/dist/components/NotesEditor/NotesEditor.svelte +203 -0
  63. package/dist/components/NotesEditor/NotesEditor.svelte.d.ts +9 -0
  64. package/dist/components/NotesEditor/index.d.ts +1 -0
  65. package/dist/components/NotesEditor/index.js +1 -0
  66. package/dist/components/NumberInput/NumberInput.svelte +353 -0
  67. package/dist/components/NumberInput/NumberInput.svelte.d.ts +26 -0
  68. package/dist/components/NumberInput/index.d.ts +1 -0
  69. package/dist/components/NumberInput/index.js +1 -0
  70. package/dist/components/PasswordInput/PasswordInput.svelte +41 -0
  71. package/dist/components/PasswordInput/PasswordInput.svelte.d.ts +13 -0
  72. package/dist/components/PasswordInput/index.d.ts +1 -0
  73. package/dist/components/PasswordInput/index.js +1 -0
  74. package/dist/components/Popup/index.d.ts +1 -0
  75. package/dist/components/Popup/index.js +1 -0
  76. package/dist/components/Rating/Rating.svelte +77 -0
  77. package/dist/components/Rating/Rating.svelte.d.ts +7 -0
  78. package/dist/components/Rating/index.d.ts +1 -0
  79. package/dist/components/Rating/index.js +1 -0
  80. package/dist/components/ScrollView/ScrollView.svelte +285 -0
  81. package/dist/components/ScrollView/ScrollView.svelte.d.ts +19 -0
  82. package/dist/components/ScrollView/index.d.ts +1 -0
  83. package/dist/components/ScrollView/index.js +1 -0
  84. package/dist/components/SearchInput/SearchInput.svelte +50 -0
  85. package/dist/components/SearchInput/SearchInput.svelte.d.ts +15 -0
  86. package/dist/components/SearchInput/index.d.ts +1 -0
  87. package/dist/components/SearchInput/index.js +1 -0
  88. package/dist/components/Select/Select.svelte +278 -0
  89. package/dist/components/Select/Select.svelte.d.ts +35 -0
  90. package/dist/components/Select/index.d.ts +1 -0
  91. package/dist/components/Select/index.js +1 -0
  92. package/dist/components/TextArea/TextArea.svelte +734 -0
  93. package/dist/components/TextArea/TextArea.svelte.d.ts +19 -0
  94. package/dist/components/TextArea/index.d.ts +1 -0
  95. package/dist/components/TextArea/index.js +1 -0
  96. package/dist/components/ThemeSelector/ThemeSelector.svelte +64 -0
  97. package/dist/components/ThemeSelector/ThemeSelector.svelte.d.ts +3 -0
  98. package/dist/components/ThemeSelector/index.d.ts +1 -0
  99. package/dist/components/ThemeSelector/index.js +1 -0
  100. package/dist/components/TooltipHandler/TooltipHandler.svelte +593 -0
  101. package/dist/components/TooltipHandler/TooltipHandler.svelte.d.ts +10 -0
  102. package/dist/components/TooltipHandler/index.d.ts +1 -0
  103. package/dist/components/TooltipHandler/index.js +1 -0
  104. package/dist/index.d.ts +24 -0
  105. package/dist/index.js +26 -0
  106. package/dist/styles/sizes.d.ts +78 -0
  107. package/dist/styles/sizes.js +120 -0
  108. package/dist/styles/variants.d.ts +106 -0
  109. package/dist/styles/variants.js +194 -0
  110. package/dist/types/ManagerTypes.d.ts +42 -0
  111. package/dist/types/ManagerTypes.js +1 -0
  112. package/dist/types/sizes.d.ts +5 -0
  113. package/dist/types/sizes.js +1 -0
  114. package/dist/types/variants.d.ts +1 -0
  115. package/dist/types/variants.js +1 -0
  116. 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>