@synthaxai/ui 1.0.0 → 1.1.0

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 (36) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +40 -220
  3. package/dist/data-display/DataTable/DataTable.svelte +14 -8
  4. package/dist/data-display/DataTable/DataTable.svelte.d.ts +64 -61
  5. package/dist/data-display/DataTable/DataTable.svelte.d.ts.map +1 -1
  6. package/dist/forms/DatePicker/DatePicker.svelte +1564 -0
  7. package/dist/forms/DatePicker/DatePicker.svelte.d.ts +129 -0
  8. package/dist/forms/DatePicker/DatePicker.svelte.d.ts.map +1 -0
  9. package/dist/forms/DatePicker/index.d.ts +8 -0
  10. package/dist/forms/DatePicker/index.d.ts.map +1 -0
  11. package/dist/forms/DatePicker/index.js +7 -0
  12. package/dist/forms/MultiSelect/MultiSelect.svelte +1651 -0
  13. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts +127 -0
  14. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts.map +1 -0
  15. package/dist/forms/MultiSelect/index.d.ts +9 -0
  16. package/dist/forms/MultiSelect/index.d.ts.map +1 -0
  17. package/dist/forms/MultiSelect/index.js +7 -0
  18. package/dist/forms/SearchInput/SearchInput.svelte +1319 -0
  19. package/dist/forms/SearchInput/SearchInput.svelte.d.ts +148 -0
  20. package/dist/forms/SearchInput/SearchInput.svelte.d.ts.map +1 -0
  21. package/dist/forms/SearchInput/index.d.ts +9 -0
  22. package/dist/forms/SearchInput/index.d.ts.map +1 -0
  23. package/dist/forms/SearchInput/index.js +7 -0
  24. package/dist/forms/TextInput/TextInput.svelte +763 -122
  25. package/dist/forms/TextInput/TextInput.svelte.d.ts +98 -13
  26. package/dist/forms/TextInput/TextInput.svelte.d.ts.map +1 -1
  27. package/dist/forms/TextInput/index.d.ts +7 -1
  28. package/dist/forms/TextInput/index.d.ts.map +1 -1
  29. package/dist/forms/TextInput/index.js +7 -1
  30. package/dist/forms/index.d.ts +3 -0
  31. package/dist/forms/index.d.ts.map +1 -1
  32. package/dist/forms/index.js +3 -0
  33. package/dist/index.d.ts +5 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -1
  36. package/package.json +3 -3
@@ -1,13 +1,15 @@
1
1
  <!--
2
2
  @component TextInput
3
3
 
4
- A fully accessible text input component with validation states,
5
- icons, character counting, and comprehensive ARIA support.
4
+ A fully accessible, enhanced text input component with validation states,
5
+ input masking, clearable button, password toggle, prefix/suffix support,
6
+ and comprehensive ARIA support.
6
7
 
7
- Designed for healthcare applications with proper error handling
8
- and clear visual feedback.
8
+ Designed for healthcare applications with proper error handling,
9
+ clear visual feedback, and support for common healthcare data formats
10
+ like SSN, phone numbers, dates, NPI, and member IDs.
9
11
 
10
- @example
12
+ @example Basic usage
11
13
  <TextInput
12
14
  id="patient-name"
13
15
  label="Patient Name"
@@ -16,22 +18,85 @@
16
18
  required
17
19
  />
18
20
 
19
- @example
21
+ @example With masking (phone number)
22
+ <TextInput
23
+ label="Phone Number"
24
+ value={phone}
25
+ mask="(###) ###-####"
26
+ inputMode="tel"
27
+ placeholder="(555) 555-5555"
28
+ />
29
+
30
+ @example Clearable with loading
20
31
  <TextInput
21
- id="member-id"
22
32
  label="Member ID"
23
33
  value={memberId}
24
- error={memberIdError}
25
- hint="Enter your insurance member ID"
26
- oninput={(e) => handleInput(e)}
34
+ clearable
35
+ loading={isValidating}
36
+ hint="Enter your 12-digit member ID"
37
+ />
38
+
39
+ @example Password with toggle
40
+ <TextInput
41
+ label="Password"
42
+ type="password"
43
+ value={password}
44
+ showPasswordToggle
45
+ />
46
+
47
+ @example With prefix/suffix
48
+ <TextInput
49
+ label="Amount"
50
+ value={amount}
51
+ prefix="$"
52
+ suffix=".00"
53
+ inputMode="decimal"
27
54
  />
28
55
  -->
56
+ <script lang="ts" module>
57
+ /**
58
+ * Healthcare-specific input mask presets.
59
+ * Use these with the `mask` prop for consistent formatting.
60
+ */
61
+ export const INPUT_MASKS = {
62
+ /** US Phone: (555) 555-5555 */
63
+ PHONE_US: '(###) ###-####',
64
+ /** US Phone with country code: +1 (555) 555-5555 */
65
+ PHONE_US_INTL: '+1 (###) ###-####',
66
+ /** Social Security Number: 123-45-6789 */
67
+ SSN: '###-##-####',
68
+ /** Date (US format): 01/15/1990 */
69
+ DATE_US: '##/##/####',
70
+ /** Date (ISO format): 1990-01-15 */
71
+ DATE_ISO: '####-##-##',
72
+ /** ZIP Code: 12345 */
73
+ ZIP: '#####',
74
+ /** ZIP+4: 12345-6789 */
75
+ ZIP_PLUS4: '#####-####',
76
+ /** National Provider Identifier: 1234567890 */
77
+ NPI: '##########',
78
+ /** 12-digit Member ID */
79
+ MEMBER_ID_12: '############',
80
+ /** Credit Card: 4111-1111-1111-1111 */
81
+ CREDIT_CARD: '####-####-####-####',
82
+ /** CVV: 123 */
83
+ CVV: '###',
84
+ /** Time 12hr: 12:30 */
85
+ TIME_12: '##:##',
86
+ /** Time 24hr: 14:30 */
87
+ TIME_24: '##:##',
88
+ } as const;
89
+
90
+ export type InputMask = (typeof INPUT_MASKS)[keyof typeof INPUT_MASKS] | string;
91
+ </script>
92
+
29
93
  <script lang="ts">
30
94
  import type { Snippet } from 'svelte';
31
95
  import type { HTMLInputAttributes } from 'svelte/elements';
32
- import { Check, AlertCircle, Info } from 'lucide-svelte';
96
+ import { Check, AlertCircle, Info, X, Eye, EyeOff, Loader2 } from 'lucide-svelte';
33
97
  import { cn } from '../../utils/cn.js';
34
98
  import { generateId } from '../../utils/keyboard.js';
99
+ import { announce, prefersReducedMotion } from '../../utils/accessibility.js';
35
100
  import type { InputMode, AutoComplete } from '../../types/index.js';
36
101
 
37
102
  type InputSize = 'sm' | 'md' | 'lg';
@@ -46,7 +111,7 @@
46
111
  type?: InputType;
47
112
  /** Label text for the input */
48
113
  label: string;
49
- /** Current value */
114
+ /** Current value (bindable) */
50
115
  value?: string;
51
116
  /** Placeholder text */
52
117
  placeholder?: string;
@@ -84,14 +149,45 @@
84
149
  hideLabel?: boolean;
85
150
  /** Additional CSS classes for the wrapper */
86
151
  class?: string;
87
- /** Format function to transform input value */
152
+ /** Format function to transform input value (legacy, prefer mask) */
88
153
  formatFn?: (value: string) => string;
154
+
155
+ // ============================================================================
156
+ // Enhanced Features
157
+ // ============================================================================
158
+
159
+ /**
160
+ * Input mask pattern. Use # for digits, A for letters, * for alphanumeric.
161
+ * Example: "###-##-####" for SSN, "(###) ###-####" for phone
162
+ */
163
+ mask?: InputMask;
164
+ /** Character to show for unfilled mask positions (default: none) */
165
+ maskPlaceholder?: string;
166
+ /** Whether to include mask literals in the value (default: false, returns only user input) */
167
+ maskIncludeLiterals?: boolean;
168
+ /** Show clear button when input has value */
169
+ clearable?: boolean;
170
+ /** Show password visibility toggle (only for type="password") */
171
+ showPasswordToggle?: boolean;
172
+ /** Static text to display before the input */
173
+ prefix?: string;
174
+ /** Static text to display after the input */
175
+ suffix?: string;
176
+ /** Show loading spinner */
177
+ loading?: boolean;
178
+ /** Debounce delay for oninput (in ms) */
179
+ debounce?: number;
180
+ /** Called with debounced value */
181
+ ondebounce?: (value: string) => void;
182
+
89
183
  /** Input handler */
90
184
  oninput?: (event: Event) => void;
91
185
  /** Blur handler */
92
186
  onblur?: (event: FocusEvent) => void;
93
187
  /** Focus handler */
94
188
  onfocus?: (event: FocusEvent) => void;
189
+ /** Called when clear button is clicked */
190
+ onclear?: () => void;
95
191
  /** Test ID for e2e testing (Playwright, Cypress) */
96
192
  testId?: string;
97
193
  /** Show validation state immediately without waiting for interaction */
@@ -103,7 +199,7 @@
103
199
  name,
104
200
  type = 'text',
105
201
  label,
106
- value = '',
202
+ value = $bindable(''),
107
203
  placeholder = '',
108
204
  required = false,
109
205
  disabled = false,
@@ -123,34 +219,249 @@
123
219
  hideLabel = false,
124
220
  class: className = '',
125
221
  formatFn,
222
+ // Enhanced features
223
+ mask,
224
+ maskPlaceholder,
225
+ maskIncludeLiterals = false,
226
+ clearable = false,
227
+ showPasswordToggle = false,
228
+ prefix,
229
+ suffix,
230
+ loading = false,
231
+ debounce = 0,
232
+ ondebounce,
126
233
  oninput,
127
234
  onblur,
128
235
  onfocus,
236
+ onclear,
129
237
  testId,
130
238
  validateOnMount = false,
131
239
  ...restProps
132
240
  }: Props = $props();
133
241
 
134
- // Internal state
242
+ // ============================================================================
243
+ // State
244
+ // ============================================================================
245
+
246
+ let inputRef: HTMLInputElement | undefined = $state();
135
247
  let isFocused = $state(false);
136
248
  let hasInteracted = $state(false);
249
+ let passwordVisible = $state(false);
250
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
251
+
252
+ // ============================================================================
253
+ // Derived State
254
+ // ============================================================================
137
255
 
138
- // Derived states
139
- // Validation shows: after interaction OR if validateOnMount is enabled
256
+ // Validation display logic
140
257
  const shouldValidate = $derived(hasInteracted || validateOnMount);
141
258
  const showError = $derived(shouldValidate && !!error);
142
259
  const showSuccess = $derived(shouldValidate && !error && !!value && !!successMessage);
143
260
  const isValid = $derived(shouldValidate && !error && !!value && !!successMessage);
261
+
262
+ // IDs for accessibility
144
263
  const errorId = $derived(`${id}-error`);
145
264
  const hintId = $derived(`${id}-hint`);
146
265
 
266
+ // Determine effective input type
267
+ const effectiveType = $derived(
268
+ type === 'password' && passwordVisible ? 'text' : type
269
+ );
270
+
271
+ // Determine if we need right-side space for icons/buttons
272
+ const hasRightContent = $derived(
273
+ loading ||
274
+ showError ||
275
+ isValid ||
276
+ (clearable && value && !disabled && !readonly) ||
277
+ (showPasswordToggle && type === 'password') ||
278
+ iconRight
279
+ );
280
+
281
+ // Count right icons for padding calculation
282
+ const rightIconCount = $derived.by(() => {
283
+ let count = 0;
284
+ if (loading) count++;
285
+ else if (showError || isValid) count++;
286
+ if (clearable && value && !disabled && !readonly) count++;
287
+ if (showPasswordToggle && type === 'password') count++;
288
+ if (iconRight && !loading && !showError && !isValid) count++;
289
+ return count;
290
+ });
291
+
292
+ // Calculate if mask is complete
293
+ const maskComplete = $derived.by(() => {
294
+ if (!mask) return true;
295
+ const requiredDigits = (mask.match(/#/g) || []).length;
296
+ const requiredLetters = (mask.match(/A/g) || []).length;
297
+ const requiredAny = (mask.match(/\*/g) || []).length;
298
+ const rawValue = getRawValue(value);
299
+ return rawValue.length >= requiredDigits + requiredLetters + requiredAny;
300
+ });
301
+
302
+ // Build aria-describedby
303
+ const ariaDescribedBy = $derived(
304
+ [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
305
+ );
306
+
307
+ // ============================================================================
308
+ // Mask Logic
309
+ // ============================================================================
310
+
311
+ /**
312
+ * Check if character is valid for mask position
313
+ */
314
+ function isValidMaskChar(char: string, maskChar: string): boolean {
315
+ if (maskChar === '#') return /\d/.test(char);
316
+ if (maskChar === 'A') return /[a-zA-Z]/.test(char);
317
+ if (maskChar === '*') return /[a-zA-Z0-9]/.test(char);
318
+ return false;
319
+ }
320
+
321
+ /**
322
+ * Extract raw characters from input based on mask type
323
+ */
324
+ function extractRawChars(input: string): string {
325
+ if (!mask) return input;
326
+
327
+ // Determine what character types the mask expects
328
+ const hasDigitSlots = mask.includes('#');
329
+ const hasLetterSlots = mask.includes('A');
330
+ const hasAnySlots = mask.includes('*');
331
+
332
+ let result = '';
333
+ for (const char of input) {
334
+ if (hasDigitSlots && /\d/.test(char)) {
335
+ result += char;
336
+ } else if (hasLetterSlots && /[a-zA-Z]/.test(char)) {
337
+ result += char;
338
+ } else if (hasAnySlots && /[a-zA-Z0-9]/.test(char)) {
339
+ result += char;
340
+ }
341
+ }
342
+ return result;
343
+ }
344
+
345
+ /**
346
+ * Get raw value without mask literals (for external use)
347
+ */
348
+ function getRawValue(maskedValue: string): string {
349
+ if (!mask) return maskedValue;
350
+ return extractRawChars(maskedValue);
351
+ }
352
+
353
+ /**
354
+ * Apply mask to raw input characters
355
+ */
356
+ function applyMask(input: string): string {
357
+ if (!mask) return input;
358
+
359
+ // Extract only valid characters
360
+ const rawChars = extractRawChars(input);
361
+
362
+ // Build masked output
363
+ let result = '';
364
+ let charIndex = 0;
365
+
366
+ for (let i = 0; i < mask.length && charIndex < rawChars.length; i++) {
367
+ const maskChar = mask[i];
368
+ if (maskChar === '#' || maskChar === 'A' || maskChar === '*') {
369
+ // This is a placeholder slot - fill with next raw char
370
+ const rawChar = rawChars[charIndex];
371
+ if (isValidMaskChar(rawChar, maskChar)) {
372
+ result += rawChar;
373
+ charIndex++;
374
+ } else {
375
+ // Character doesn't match expected type, skip it
376
+ charIndex++;
377
+ i--; // Try this mask position again
378
+ }
379
+ } else {
380
+ // This is a literal character - add it
381
+ result += maskChar;
382
+ }
383
+ }
384
+
385
+ // Add mask placeholder for remaining positions if specified and focused
386
+ if (maskPlaceholder && isFocused) {
387
+ for (let i = result.length; i < mask.length; i++) {
388
+ const maskChar = mask[i];
389
+ if (maskChar === '#' || maskChar === 'A' || maskChar === '*') {
390
+ result += maskPlaceholder;
391
+ } else {
392
+ result += maskChar;
393
+ }
394
+ }
395
+ }
396
+
397
+ return result;
398
+ }
399
+
400
+ /**
401
+ * Calculate cursor position after mask application
402
+ */
403
+ function calculateCursorPosition(rawLength: number): number {
404
+ if (!mask) return rawLength;
405
+
406
+ let position = 0;
407
+ let rawCount = 0;
408
+
409
+ for (let i = 0; i < mask.length && rawCount < rawLength; i++) {
410
+ const maskChar = mask[i];
411
+ position++;
412
+ if (maskChar === '#' || maskChar === 'A' || maskChar === '*') {
413
+ rawCount++;
414
+ }
415
+ }
416
+
417
+ return position;
418
+ }
419
+
420
+ // ============================================================================
421
+ // Event Handlers
422
+ // ============================================================================
423
+
147
424
  function handleInput(e: Event) {
148
425
  const target = e.target as HTMLInputElement;
149
426
  let newValue = target.value;
150
427
 
151
- if (formatFn) {
152
- newValue = formatFn(newValue);
428
+ // Apply mask if present
429
+ if (mask) {
430
+ // Apply mask to the input value
431
+ newValue = applyMask(newValue);
432
+ const rawValue = getRawValue(newValue);
433
+
434
+ // Calculate cursor position based on raw value length
435
+ const newCursorPos = calculateCursorPosition(rawValue.length);
436
+
153
437
  target.value = newValue;
438
+
439
+ // Restore cursor position
440
+ requestAnimationFrame(() => {
441
+ if (target && document.activeElement === target) {
442
+ target.setSelectionRange(newCursorPos, newCursorPos);
443
+ }
444
+ });
445
+
446
+ // Update value (raw or with literals based on setting)
447
+ value = maskIncludeLiterals ? newValue : rawValue;
448
+ } else {
449
+ // Apply legacy format function
450
+ if (formatFn) {
451
+ newValue = formatFn(newValue);
452
+ target.value = newValue;
453
+ }
454
+
455
+ // Update value
456
+ value = newValue;
457
+ }
458
+
459
+ // Handle debounce
460
+ if (debounce > 0 && ondebounce) {
461
+ if (debounceTimer) clearTimeout(debounceTimer);
462
+ debounceTimer = setTimeout(() => {
463
+ ondebounce(value);
464
+ }, debounce);
154
465
  }
155
466
 
156
467
  oninput?.(e);
@@ -159,18 +470,88 @@
159
470
  function handleBlur(e: FocusEvent) {
160
471
  isFocused = false;
161
472
  hasInteracted = true;
473
+
474
+ // Clear debounce and fire immediately
475
+ if (debounceTimer && ondebounce) {
476
+ clearTimeout(debounceTimer);
477
+ debounceTimer = null;
478
+ ondebounce(value);
479
+ }
480
+
481
+ // Remove mask placeholder on blur
482
+ if (mask && maskPlaceholder && inputRef) {
483
+ const rawValue = getRawValue(value);
484
+ if (rawValue) {
485
+ inputRef.value = applyMask(rawValue);
486
+ }
487
+ }
488
+
162
489
  onblur?.(e);
163
490
  }
164
491
 
165
492
  function handleFocus(e: FocusEvent) {
166
493
  isFocused = true;
494
+
495
+ // Show mask placeholder on focus
496
+ if (mask && maskPlaceholder && inputRef) {
497
+ const currentRaw = getRawValue(inputRef.value);
498
+ inputRef.value = applyMask(currentRaw);
499
+ }
500
+
167
501
  onfocus?.(e);
168
502
  }
169
503
 
170
- // Build aria-describedby
171
- const ariaDescribedBy = $derived(
172
- [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
173
- );
504
+ function handleClear() {
505
+ value = '';
506
+ if (inputRef) {
507
+ inputRef.value = '';
508
+ inputRef.focus();
509
+ }
510
+ announce('Input cleared', 'polite');
511
+ onclear?.();
512
+
513
+ // Trigger debounce callback with empty value
514
+ if (ondebounce) {
515
+ if (debounceTimer) clearTimeout(debounceTimer);
516
+ ondebounce('');
517
+ }
518
+ }
519
+
520
+ function togglePasswordVisibility() {
521
+ passwordVisible = !passwordVisible;
522
+ announce(passwordVisible ? 'Password shown' : 'Password hidden', 'polite');
523
+ // Keep focus on input
524
+ inputRef?.focus();
525
+ }
526
+
527
+ function handleKeyDown(e: KeyboardEvent) {
528
+ // Handle Escape to clear when clearable
529
+ if (e.key === 'Escape' && clearable && value && !disabled && !readonly) {
530
+ e.preventDefault();
531
+ handleClear();
532
+ }
533
+ }
534
+
535
+ // ============================================================================
536
+ // Effects
537
+ // ============================================================================
538
+
539
+ // Apply initial mask formatting
540
+ $effect(() => {
541
+ if (mask && inputRef && value) {
542
+ const maskedValue = applyMask(maskIncludeLiterals ? value : getRawValue(value) || value);
543
+ if (inputRef.value !== maskedValue) {
544
+ inputRef.value = maskedValue;
545
+ }
546
+ }
547
+ });
548
+
549
+ // Cleanup debounce timer
550
+ $effect(() => {
551
+ return () => {
552
+ if (debounceTimer) clearTimeout(debounceTimer);
553
+ };
554
+ });
174
555
  </script>
175
556
 
176
557
  <div class={cn('input-wrapper', `input-${size}`, disabled && 'input-disabled', className)}>
@@ -188,7 +569,23 @@
188
569
  </label>
189
570
 
190
571
  <!-- Input wrapper -->
191
- <div class="input-field-wrapper">
572
+ <div
573
+ class={cn(
574
+ 'input-field-wrapper',
575
+ isFocused && 'input-field-wrapper-focused',
576
+ showError && 'input-field-wrapper-error',
577
+ isValid && 'input-field-wrapper-valid',
578
+ disabled && 'input-field-wrapper-disabled'
579
+ )}
580
+ >
581
+ <!-- Prefix (inside input container) -->
582
+ {#if prefix}
583
+ <span class={cn('input-prefix', isFocused && 'input-prefix-focused')} aria-hidden="true">
584
+ {prefix}
585
+ </span>
586
+ {/if}
587
+
588
+ <!-- Left icon -->
192
589
  {#if iconLeft}
193
590
  <span class={cn('input-icon-left', isFocused && 'input-icon-focused')} aria-hidden="true">
194
591
  {@render iconLeft()}
@@ -196,10 +593,11 @@
196
593
  {/if}
197
594
 
198
595
  <input
596
+ bind:this={inputRef}
199
597
  {id}
200
598
  {name}
201
- {type}
202
- {value}
599
+ type={effectiveType}
600
+ value={mask ? applyMask(maskIncludeLiterals ? value : value) : value}
203
601
  {placeholder}
204
602
  {disabled}
205
603
  {readonly}
@@ -207,7 +605,7 @@
207
605
  {pattern}
208
606
  maxlength={maxLength}
209
607
  minlength={minLength}
210
- inputmode={inputMode}
608
+ inputmode={mask ? 'numeric' : inputMode}
211
609
  {autocomplete}
212
610
  aria-invalid={showError}
213
611
  aria-describedby={ariaDescribedBy}
@@ -215,29 +613,79 @@
215
613
  data-testid={testId}
216
614
  class={cn(
217
615
  'input-field',
616
+ prefix && 'input-has-prefix',
617
+ suffix && 'input-has-suffix',
218
618
  iconLeft && 'input-has-icon-left',
219
- (iconRight || showError || isValid) && 'input-has-icon-right',
220
- showError && 'input-error',
221
- isValid && 'input-valid'
619
+ (prefix && iconLeft) && 'input-has-prefix-and-icon',
620
+ hasRightContent && 'input-has-icon-right',
621
+ rightIconCount > 1 && 'input-has-multiple-right',
622
+ rightIconCount > 2 && 'input-has-triple-right',
623
+ (suffix && hasRightContent) && 'input-has-suffix-and-right'
222
624
  )}
223
625
  oninput={handleInput}
224
626
  onblur={handleBlur}
225
627
  onfocus={handleFocus}
628
+ onkeydown={handleKeyDown}
226
629
  {...restProps}
227
630
  />
228
631
 
229
- <!-- Status icon or custom right icon -->
230
- {#if showError}
231
- <span class="input-icon-right input-icon-error" aria-hidden="true">
232
- <AlertCircle size={18} />
233
- </span>
234
- {:else if isValid}
235
- <span class="input-icon-right input-icon-success" aria-hidden="true">
236
- <Check size={18} />
237
- </span>
238
- {:else if iconRight}
239
- <span class="input-icon-right" aria-hidden="true">
240
- {@render iconRight()}
632
+ <!-- Right side icons and buttons -->
633
+ <div class={cn('input-right-content', suffix && 'input-right-content-before-suffix')}>
634
+ <!-- Loading spinner -->
635
+ {#if loading}
636
+ <span class="input-icon-loading" aria-hidden="true">
637
+ <Loader2 size={size === 'sm' ? 14 : 16} class="input-spinner" />
638
+ </span>
639
+ {:else if showError}
640
+ <span class="input-icon-right input-icon-error" aria-hidden="true">
641
+ <AlertCircle size={size === 'sm' ? 14 : 16} />
642
+ </span>
643
+ {:else if isValid}
644
+ <span class="input-icon-right input-icon-success" aria-hidden="true">
645
+ <Check size={size === 'sm' ? 14 : 16} />
646
+ </span>
647
+ {:else if iconRight}
648
+ <span class="input-icon-right" aria-hidden="true">
649
+ {@render iconRight()}
650
+ </span>
651
+ {/if}
652
+
653
+ <!-- Clear button -->
654
+ {#if clearable && value && !disabled && !readonly}
655
+ <button
656
+ type="button"
657
+ class="input-clear-button"
658
+ onclick={handleClear}
659
+ aria-label="Clear input"
660
+ tabindex={-1}
661
+ >
662
+ <X size={size === 'sm' ? 14 : 16} />
663
+ </button>
664
+ {/if}
665
+
666
+ <!-- Password toggle -->
667
+ {#if showPasswordToggle && type === 'password'}
668
+ <button
669
+ type="button"
670
+ class="input-password-toggle"
671
+ onclick={togglePasswordVisibility}
672
+ aria-label={passwordVisible ? 'Hide password' : 'Show password'}
673
+ aria-pressed={passwordVisible}
674
+ tabindex={-1}
675
+ >
676
+ {#if passwordVisible}
677
+ <EyeOff size={size === 'sm' ? 14 : 16} />
678
+ {:else}
679
+ <Eye size={size === 'sm' ? 14 : 16} />
680
+ {/if}
681
+ </button>
682
+ {/if}
683
+ </div>
684
+
685
+ <!-- Suffix (inside input container) -->
686
+ {#if suffix}
687
+ <span class={cn('input-suffix', isFocused && 'input-suffix-focused')} aria-hidden="true">
688
+ {suffix}
241
689
  </span>
242
690
  {/if}
243
691
  </div>
@@ -322,156 +770,250 @@
322
770
  }
323
771
 
324
772
  /* ========================================
325
- FIELD WRAPPER
773
+ FIELD WRAPPER (Unified container)
326
774
  ======================================== */
327
775
  .input-field-wrapper {
328
776
  position: relative;
329
777
  display: flex;
330
778
  align-items: center;
331
- }
332
-
333
- /* ========================================
334
- INPUT FIELD
335
- ======================================== */
336
- .input-field {
337
- width: 100%;
338
779
  border: 2px solid var(--ui-border-default);
339
780
  border-radius: 0.5rem;
340
781
  background: var(--ui-bg-secondary);
341
- color: var(--ui-text-primary);
342
782
  transition: all 0.2s ease;
343
783
  }
344
784
 
345
- .input-field::placeholder {
346
- color: var(--ui-text-tertiary);
347
- }
348
-
349
- .input-field:focus-visible {
350
- outline: none;
785
+ .input-field-wrapper-focused {
351
786
  background: var(--ui-bg-primary);
352
787
  border-color: rgb(var(--ui-color-primary));
353
788
  box-shadow: 0 0 0 3px rgb(var(--ui-color-primary) / 0.4);
354
789
  }
355
790
 
356
- :global([data-theme='dark']) .input-field:focus-visible {
791
+ :global([data-theme='dark']) .input-field-wrapper-focused {
357
792
  border-color: rgb(var(--ui-color-primary-light));
358
793
  box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
359
794
  }
360
795
 
361
796
  @media (prefers-color-scheme: dark) {
362
- :global(:root:not([data-theme='light'])) .input-field:focus-visible {
797
+ :global(:root:not([data-theme='light'])) .input-field-wrapper-focused {
363
798
  border-color: rgb(var(--ui-color-primary-light));
364
799
  box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
365
800
  }
366
801
  }
367
802
 
368
- .input-field:disabled {
369
- cursor: not-allowed;
370
- background: var(--ui-bg-tertiary);
371
- }
372
-
373
- .input-field:read-only {
374
- background: var(--ui-bg-tertiary);
375
- cursor: default;
376
- }
377
-
378
- /* Size variants */
379
- .input-sm .input-field {
380
- height: 2rem;
381
- padding: 0 0.75rem;
382
- font-size: 0.875rem;
383
- }
384
-
385
- .input-md .input-field {
386
- height: 2.5rem;
387
- padding: 0 0.75rem;
388
- font-size: 0.875rem;
389
- }
390
-
391
- .input-lg .input-field {
392
- height: 3rem;
393
- padding: 0 1rem;
394
- font-size: 1rem;
395
- }
396
-
397
- /* Icon padding adjustments - use !important to override size-specific padding */
398
- .input-field.input-has-icon-left {
399
- padding-left: 2.25rem !important;
400
- }
401
-
402
- .input-field.input-has-icon-right {
403
- padding-right: 2.25rem !important;
404
- }
405
-
406
- /* Error state */
407
- .input-error {
803
+ .input-field-wrapper-error {
408
804
  border-color: rgb(var(--ui-color-error));
409
805
  }
410
806
 
411
- .input-error:focus-visible {
807
+ .input-field-wrapper-focused.input-field-wrapper-error {
412
808
  border-color: rgb(var(--ui-color-error));
413
809
  box-shadow: 0 0 0 3px rgb(var(--ui-color-error) / 0.4);
414
810
  }
415
811
 
416
- :global([data-theme='dark']) .input-error {
812
+ :global([data-theme='dark']) .input-field-wrapper-error {
417
813
  border-color: rgb(var(--ui-color-error-light));
418
814
  }
419
815
 
420
- :global([data-theme='dark']) .input-error:focus-visible {
816
+ :global([data-theme='dark']) .input-field-wrapper-focused.input-field-wrapper-error {
421
817
  border-color: rgb(var(--ui-color-error-light));
422
818
  box-shadow: 0 0 0 3px rgb(var(--ui-color-error-light) / 0.5);
423
819
  }
424
820
 
425
821
  @media (prefers-color-scheme: dark) {
426
- :global(:root:not([data-theme='light'])) .input-error {
822
+ :global(:root:not([data-theme='light'])) .input-field-wrapper-error {
427
823
  border-color: rgb(var(--ui-color-error-light));
428
824
  }
429
825
 
430
- :global(:root:not([data-theme='light'])) .input-error:focus-visible {
826
+ :global(:root:not([data-theme='light'])) .input-field-wrapper-focused.input-field-wrapper-error {
431
827
  border-color: rgb(var(--ui-color-error-light));
432
828
  box-shadow: 0 0 0 3px rgb(var(--ui-color-error-light) / 0.5);
433
829
  }
434
830
  }
435
831
 
436
- /* Valid state */
437
- .input-valid {
832
+ .input-field-wrapper-valid {
438
833
  border-color: rgb(var(--ui-color-success));
439
834
  }
440
835
 
441
- .input-valid:focus-visible {
836
+ .input-field-wrapper-focused.input-field-wrapper-valid {
442
837
  border-color: rgb(var(--ui-color-success));
443
838
  box-shadow: 0 0 0 3px rgb(var(--ui-color-success) / 0.4);
444
839
  }
445
840
 
446
- :global([data-theme='dark']) .input-valid {
841
+ :global([data-theme='dark']) .input-field-wrapper-valid {
447
842
  border-color: rgb(var(--ui-color-success-light));
448
843
  }
449
844
 
450
- :global([data-theme='dark']) .input-valid:focus-visible {
845
+ :global([data-theme='dark']) .input-field-wrapper-focused.input-field-wrapper-valid {
451
846
  border-color: rgb(var(--ui-color-success-light));
452
847
  box-shadow: 0 0 0 3px rgb(var(--ui-color-success-light) / 0.5);
453
848
  }
454
849
 
455
850
  @media (prefers-color-scheme: dark) {
456
- :global(:root:not([data-theme='light'])) .input-valid {
851
+ :global(:root:not([data-theme='light'])) .input-field-wrapper-valid {
457
852
  border-color: rgb(var(--ui-color-success-light));
458
853
  }
459
854
 
460
- :global(:root:not([data-theme='light'])) .input-valid:focus-visible {
855
+ :global(:root:not([data-theme='light'])) .input-field-wrapper-focused.input-field-wrapper-valid {
461
856
  border-color: rgb(var(--ui-color-success-light));
462
857
  box-shadow: 0 0 0 3px rgb(var(--ui-color-success-light) / 0.5);
463
858
  }
464
859
  }
465
860
 
861
+ .input-field-wrapper-disabled {
862
+ opacity: 0.6;
863
+ cursor: not-allowed;
864
+ background: var(--ui-bg-tertiary);
865
+ }
866
+
867
+ /* ========================================
868
+ PREFIX & SUFFIX (Inside container)
869
+ ======================================== */
870
+ .input-prefix,
871
+ .input-suffix {
872
+ display: flex;
873
+ align-items: center;
874
+ flex-shrink: 0;
875
+ padding: 0 0.75rem;
876
+ color: var(--ui-text-tertiary);
877
+ font-size: 0.875rem;
878
+ font-weight: 500;
879
+ white-space: nowrap;
880
+ pointer-events: none;
881
+ user-select: none;
882
+ transition: color 0.2s ease;
883
+ }
884
+
885
+ .input-prefix {
886
+ padding-right: 0.375rem;
887
+ border-right: 1px solid var(--ui-border-default);
888
+ margin-right: 0.5rem;
889
+ }
890
+
891
+ .input-suffix {
892
+ padding-left: 0.375rem;
893
+ border-left: 1px solid var(--ui-border-default);
894
+ margin-left: 0.5rem;
895
+ }
896
+
897
+ .input-prefix-focused,
898
+ .input-suffix-focused {
899
+ color: var(--ui-text-secondary);
900
+ }
901
+
902
+ .input-sm .input-prefix,
903
+ .input-sm .input-suffix {
904
+ padding: 0 0.5rem;
905
+ font-size: 0.8125rem;
906
+ }
907
+
908
+ .input-sm .input-prefix {
909
+ padding-right: 0.25rem;
910
+ margin-right: 0.375rem;
911
+ }
912
+
913
+ .input-sm .input-suffix {
914
+ padding-left: 0.25rem;
915
+ margin-left: 0.375rem;
916
+ }
917
+
918
+ .input-lg .input-prefix,
919
+ .input-lg .input-suffix {
920
+ padding: 0 1rem;
921
+ font-size: 1rem;
922
+ }
923
+
924
+ .input-lg .input-prefix {
925
+ padding-right: 0.5rem;
926
+ margin-right: 0.625rem;
927
+ }
928
+
929
+ .input-lg .input-suffix {
930
+ padding-left: 0.5rem;
931
+ margin-left: 0.625rem;
932
+ }
933
+
934
+ /* ========================================
935
+ INPUT FIELD (Inside unified container)
936
+ ======================================== */
937
+ .input-field {
938
+ flex: 1;
939
+ min-width: 0;
940
+ border: none;
941
+ border-radius: 0;
942
+ background: transparent;
943
+ color: var(--ui-text-primary);
944
+ outline: none;
945
+ }
946
+
947
+ .input-field::placeholder {
948
+ color: var(--ui-text-tertiary);
949
+ }
950
+
951
+ .input-field:focus-visible {
952
+ outline: none;
953
+ }
954
+
955
+ .input-field:disabled {
956
+ cursor: not-allowed;
957
+ }
958
+
959
+ .input-field:read-only {
960
+ cursor: default;
961
+ }
962
+
963
+ /* Size variants - now applies to wrapper */
964
+ .input-sm .input-field-wrapper {
965
+ min-height: 2rem;
966
+ }
967
+
968
+ .input-md .input-field-wrapper {
969
+ min-height: 2.5rem;
970
+ }
971
+
972
+ .input-lg .input-field-wrapper {
973
+ min-height: 3rem;
974
+ }
975
+
976
+ .input-sm .input-field {
977
+ padding: 0 0.5rem;
978
+ font-size: 0.875rem;
979
+ }
980
+
981
+ .input-md .input-field {
982
+ padding: 0 0.625rem;
983
+ font-size: 0.875rem;
984
+ }
985
+
986
+ .input-lg .input-field {
987
+ padding: 0 0.75rem;
988
+ font-size: 1rem;
989
+ }
990
+
991
+ /* Padding adjustments - input is now flex child */
992
+ .input-field.input-has-prefix {
993
+ padding-left: 0.25rem !important;
994
+ }
995
+
996
+ .input-field.input-has-suffix {
997
+ padding-right: 0.25rem !important;
998
+ }
999
+
1000
+ .input-field.input-has-icon-left {
1001
+ padding-left: 0.25rem !important;
1002
+ }
1003
+
1004
+ .input-field.input-has-prefix-and-icon {
1005
+ padding-left: 0.25rem !important;
1006
+ }
1007
+
466
1008
  /* ========================================
467
1009
  ICONS
468
1010
  ======================================== */
469
1011
  .input-icon-left {
470
- position: absolute;
471
- left: 0.75rem;
472
- top: 50%;
473
- transform: translateY(-50%);
474
- z-index: 1;
1012
+ display: flex;
1013
+ align-items: center;
1014
+ justify-content: center;
1015
+ flex-shrink: 0;
1016
+ padding-left: 0.75rem;
475
1017
  pointer-events: none;
476
1018
  color: var(--ui-text-tertiary);
477
1019
  transition: color 0.2s ease;
@@ -491,14 +1033,25 @@
491
1033
  }
492
1034
  }
493
1035
 
1036
+ /* Right content container */
1037
+ .input-right-content {
1038
+ display: flex;
1039
+ align-items: center;
1040
+ gap: 0.25rem;
1041
+ flex-shrink: 0;
1042
+ padding-right: 0.5rem;
1043
+ }
1044
+
1045
+ .input-right-content-before-suffix {
1046
+ padding-right: 0;
1047
+ }
1048
+
494
1049
  .input-icon-right {
495
- position: absolute;
496
- right: 0.75rem;
497
- top: 50%;
498
- transform: translateY(-50%);
499
- z-index: 1;
500
- pointer-events: none;
1050
+ display: flex;
1051
+ align-items: center;
1052
+ justify-content: center;
501
1053
  color: var(--ui-text-tertiary);
1054
+ pointer-events: none;
502
1055
  }
503
1056
 
504
1057
  .input-icon-error {
@@ -529,6 +1082,88 @@
529
1082
  }
530
1083
  }
531
1084
 
1085
+ /* Loading spinner */
1086
+ .input-icon-loading {
1087
+ display: flex;
1088
+ align-items: center;
1089
+ justify-content: center;
1090
+ color: rgb(var(--ui-color-primary));
1091
+ }
1092
+
1093
+ :global([data-theme='dark']) .input-icon-loading {
1094
+ color: rgb(var(--ui-color-primary-light));
1095
+ }
1096
+
1097
+ @media (prefers-color-scheme: dark) {
1098
+ :global(:root:not([data-theme='light'])) .input-icon-loading {
1099
+ color: rgb(var(--ui-color-primary-light));
1100
+ }
1101
+ }
1102
+
1103
+ .input-spinner {
1104
+ animation: spin 1s linear infinite;
1105
+ }
1106
+
1107
+ @keyframes spin {
1108
+ from { transform: rotate(0deg); }
1109
+ to { transform: rotate(360deg); }
1110
+ }
1111
+
1112
+ @media (prefers-reduced-motion: reduce) {
1113
+ .input-spinner {
1114
+ animation: none;
1115
+ }
1116
+ }
1117
+
1118
+ /* ========================================
1119
+ INTERACTIVE BUTTONS (Clear, Password Toggle)
1120
+ ======================================== */
1121
+ .input-clear-button,
1122
+ .input-password-toggle {
1123
+ display: flex;
1124
+ align-items: center;
1125
+ justify-content: center;
1126
+ width: 1.5rem;
1127
+ height: 1.5rem;
1128
+ padding: 0;
1129
+ border: none;
1130
+ border-radius: 0.25rem;
1131
+ background: transparent;
1132
+ color: var(--ui-text-tertiary);
1133
+ cursor: pointer;
1134
+ transition: all 0.15s ease;
1135
+ }
1136
+
1137
+ .input-clear-button:hover,
1138
+ .input-password-toggle:hover {
1139
+ background: var(--ui-bg-tertiary);
1140
+ color: var(--ui-text-primary);
1141
+ }
1142
+
1143
+ .input-clear-button:focus-visible,
1144
+ .input-password-toggle:focus-visible {
1145
+ outline: none;
1146
+ box-shadow: 0 0 0 2px rgb(var(--ui-color-primary) / 0.5);
1147
+ }
1148
+
1149
+ :global([data-theme='dark']) .input-clear-button:focus-visible,
1150
+ :global([data-theme='dark']) .input-password-toggle:focus-visible {
1151
+ box-shadow: 0 0 0 2px rgb(var(--ui-color-primary-light) / 0.5);
1152
+ }
1153
+
1154
+ @media (prefers-color-scheme: dark) {
1155
+ :global(:root:not([data-theme='light'])) .input-clear-button:focus-visible,
1156
+ :global(:root:not([data-theme='light'])) .input-password-toggle:focus-visible {
1157
+ box-shadow: 0 0 0 2px rgb(var(--ui-color-primary-light) / 0.5);
1158
+ }
1159
+ }
1160
+
1161
+ .input-sm .input-clear-button,
1162
+ .input-sm .input-password-toggle {
1163
+ width: 1.25rem;
1164
+ height: 1.25rem;
1165
+ }
1166
+
532
1167
  /* ========================================
533
1168
  FOOTER & MESSAGES
534
1169
  ======================================== */
@@ -548,6 +1183,12 @@
548
1183
  animation: slide-in 0.2s ease-out;
549
1184
  }
550
1185
 
1186
+ @media (prefers-reduced-motion: reduce) {
1187
+ .input-message {
1188
+ animation: none;
1189
+ }
1190
+ }
1191
+
551
1192
  .input-message-error {
552
1193
  color: rgb(var(--ui-color-error));
553
1194
  }