sveltacular 1.0.0 → 1.0.1

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.
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte';
2
3
  import type { DropdownOption, FormFieldSizeOptions } from '../../types/form.js';
3
4
  import FormField from '../form-field.svelte';
4
- import FormLabel from '../form-label.svelte';
5
5
  import CheckBox from './check-box.svelte';
6
6
  import { uniqueId } from '../../helpers/unique-id.js';
7
7
 
@@ -31,13 +31,20 @@
31
31
  // Sync itemsWithState when items or group changes (one-way: items/group -> itemsWithState)
32
32
  // Reassign the entire array to avoid reading itemsWithState in the effect
33
33
  $effect(() => {
34
- // Rebuild itemsWithState from items, using group to determine checked state
35
- // Reassign instead of mutate to avoid circular dependency
36
- const newItems = items.map((item) => ({
37
- ...item,
38
- isChecked: group.includes(item.value ?? '')
39
- }));
40
- itemsWithState = newItems;
34
+ // Track items and group as dependencies
35
+ const currentItems = items;
36
+ const currentGroup = group;
37
+
38
+ // Use untrack to prevent writing to itemsWithState from triggering this effect again
39
+ untrack(() => {
40
+ // Rebuild itemsWithState from items, using group to determine checked state
41
+ // Reassign instead of mutate to avoid circular dependency
42
+ const newItems = currentItems.map((item) => ({
43
+ ...item,
44
+ isChecked: currentGroup.includes(item.value ?? '')
45
+ }));
46
+ itemsWithState = newItems;
47
+ });
41
48
  });
42
49
 
43
50
  const handleCheckboxChange = (data: { isChecked: boolean; value: string }) => {
@@ -53,10 +60,7 @@
53
60
  };
54
61
  </script>
55
62
 
56
- <FormField {size}>
57
- {#if label}
58
- <FormLabel {id} {required} {label} />
59
- {/if}
63
+ <FormField {size} {label} {id} {required} {disabled}>
60
64
  <div>
61
65
  {#each itemsWithState as item}
62
66
  <CheckBox
@@ -19,9 +19,9 @@
19
19
  * />
20
20
  * ```
21
21
  */
22
+ import { untrack } from 'svelte';
22
23
  import type { DropdownOption, FormFieldSizeOptions } from '../../types/form.js';
23
24
  import FormField from '../form-field.svelte';
24
- import FormLabel from '../form-label.svelte';
25
25
  import { uniqueId } from '../../helpers/unique-id.js';
26
26
  import Menu from '../../generic/menu/menu.svelte';
27
27
  import AngleUpIcon from '../../icons/angle-up-icon.svelte';
@@ -40,7 +40,7 @@
40
40
  multiSelect = false,
41
41
  placeholder = '',
42
42
  label = undefined,
43
- helpText = undefined,
43
+ helperText = undefined,
44
44
  errorText = undefined,
45
45
  successText = undefined,
46
46
  maxSelections = undefined as number | undefined,
@@ -68,8 +68,8 @@
68
68
  placeholder?: string;
69
69
  /** Label text for the combobox */
70
70
  label?: string;
71
- /** Help text displayed below the input */
72
- helpText?: string;
71
+ /** Helper text displayed below the input */
72
+ helperText?: string;
73
73
  /** Error message to display */
74
74
  errorText?: string;
75
75
  /** Success message to display */
@@ -368,9 +368,28 @@
368
368
  }
369
369
  };
370
370
 
371
- // Initial filter
371
+ // Filter items when items or searchQuery changes
372
+ // Use untrack to prevent reading filteredItems/highlightIndex from triggering the effect
372
373
  $effect(() => {
373
- applyFilter();
374
+ // Track only the dependencies we care about
375
+ const query = searchQuery.trim().toLowerCase();
376
+ const currentItems = items;
377
+
378
+ // Use untrack to write to state without triggering this effect again
379
+ untrack(() => {
380
+ if (query && searchable) {
381
+ filteredItems = currentItems.filter((item) =>
382
+ item.name.toLowerCase().includes(query)
383
+ );
384
+ } else {
385
+ filteredItems = [...currentItems];
386
+ }
387
+
388
+ // Reset highlight if out of bounds
389
+ if (highlightIndex >= filteredItems.length) {
390
+ highlightIndex = Math.max(0, filteredItems.length - 1);
391
+ }
392
+ });
374
393
  });
375
394
 
376
395
  // Derived state for open/closed
@@ -379,16 +398,15 @@
379
398
  // Clear search query when menu closes in single-select mode
380
399
  $effect(() => {
381
400
  if (!isMenuOpen && !multiSelect) {
382
- searchQuery = '';
401
+ // Use untrack to prevent triggering other effects
402
+ untrack(() => {
403
+ searchQuery = '';
404
+ });
383
405
  }
384
406
  });
385
407
  </script>
386
408
 
387
- <FormField {size}>
388
- {#if label}
389
- <FormLabel {id} {required} {disabled} {label} />
390
- {/if}
391
-
409
+ <FormField {size} {label} {id} {required} {disabled} {helperText} {errorText} {successText}>
392
410
  <div class="combobox-wrapper {open ? 'open' : 'closed'} {disabled ? 'disabled' : 'enabled'}">
393
411
  <!-- Multi-select chip display -->
394
412
  {#if multiSelect && selectedItems.length > 0}
@@ -423,7 +441,7 @@
423
441
  aria-activedescendant={activeDescendant}
424
442
  aria-haspopup="listbox"
425
443
  aria-label={label}
426
- aria-describedby={helpText || errorText || successText ? `${id}-description` : undefined}
444
+ aria-describedby={helperText || errorText || successText ? `${id}-helper ${id}-error ${id}-success` : undefined}
427
445
  aria-invalid={errorText ? 'true' : undefined}
428
446
  onfocus={() => {
429
447
  if (!disabled) {
@@ -489,19 +507,6 @@
489
507
  </div>
490
508
  </div>
491
509
 
492
- <!-- Help/Error/Success text -->
493
- {#if helpText || errorText || successText}
494
- <div id="{id}-description" class="description">
495
- {#if errorText}
496
- <span class="error-text" role="alert">{errorText}</span>
497
- {:else if successText}
498
- <span class="success-text" role="status">{successText}</span>
499
- {:else if helpText}
500
- <span class="help-text">{helpText}</span>
501
- {/if}
502
- </div>
503
- {/if}
504
-
505
510
  <!-- Max selections indicator -->
506
511
  {#if multiSelect && maxSelections !== undefined}
507
512
  <div class="max-selections-indicator">
@@ -629,23 +634,6 @@ button:focus-visible {
629
634
  margin-top: 0.25rem;
630
635
  }
631
636
 
632
- .description {
633
- margin-top: 0.5rem;
634
- font-size: var(--font-sm);
635
- line-height: 1.4;
636
- }
637
- .description .help-text {
638
- color: var(--gray-600);
639
- }
640
- .description .error-text {
641
- color: var(--danger);
642
- font-weight: 500;
643
- }
644
- .description .success-text {
645
- color: var(--success);
646
- font-weight: 500;
647
- }
648
-
649
637
  .max-selections-indicator {
650
638
  margin-top: 0.5rem;
651
639
  font-size: var(--font-sm);
@@ -1,23 +1,3 @@
1
- /**
2
- * Combobox Component
3
- *
4
- * A searchable select component with typeahead, multi-select support, and virtual scrolling.
5
- * Follows the ARIA 1.2 Combobox pattern for full accessibility.
6
- *
7
- * @component
8
- * @example
9
- * ```svelte
10
- * <ComboBox
11
- * bind:value
12
- * items={[
13
- * { value: '1', name: 'Option 1' },
14
- * { value: '2', name: 'Option 2' }
15
- * ]}
16
- * label="Select an option"
17
- * searchable
18
- * />
19
- * ```
20
- */
21
1
  import type { DropdownOption, FormFieldSizeOptions } from '../../types/form.js';
22
2
  type $$ComponentProps = {
23
3
  /** Current selected value(s) - string for single-select, string[] for multi-select */
@@ -38,8 +18,8 @@ type $$ComponentProps = {
38
18
  placeholder?: string;
39
19
  /** Label text for the combobox */
40
20
  label?: string;
41
- /** Help text displayed below the input */
42
- helpText?: string;
21
+ /** Helper text displayed below the input */
22
+ helperText?: string;
43
23
  /** Error message to display */
44
24
  errorText?: string;
45
25
  /** Success message to display */
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte';
2
3
  import { addUnits, currentDateTime, isDateString, isDateOrDateTimeString, isDateTimeString } from '../../helpers/date.js';
3
4
  import { uniqueId } from '../../helpers/unique-id.js';
4
5
  import FormField from '../form-field.svelte';
5
- import FormLabel from '../form-label.svelte';
6
6
  import type { DateUnit, FormFieldSizeOptions } from '../../index.js';
7
7
  import Button from '../button/button.svelte';
8
8
 
@@ -68,17 +68,17 @@
68
68
 
69
69
  $effect(() => {
70
70
  if (!value) {
71
- if (nullable) enabled = false;
72
- else value = getDefaultValue();
71
+ // Use untrack to prevent writes to enabled/value from triggering this effect again
72
+ untrack(() => {
73
+ if (nullable) enabled = false;
74
+ else value = getDefaultValue();
75
+ });
73
76
  }
74
77
  });
75
78
  let disabled = $derived(!enabled);
76
79
  </script>
77
80
 
78
- <FormField {size}>
79
- {#if label}
80
- <FormLabel {id} {required} {label} />
81
- {/if}
81
+ <FormField {size} {label} {id} {required} {disabled}>
82
82
  <div class:nullable class:disabled>
83
83
  <span class="input">
84
84
  <input {...{ type }} {id} {placeholder} {disabled} bind:value {required} oninput={onInput} />
@@ -2,7 +2,6 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { uniqueId } from '../../helpers/unique-id.js';
4
4
  import FormField from '../form-field.svelte';
5
- import FormLabel from '../form-label.svelte';
6
5
  import type { FormFieldSizeOptions } from '../../types/form.js';
7
6
 
8
7
  const id = uniqueId();
@@ -32,10 +31,7 @@
32
31
  } = $props();
33
32
  </script>
34
33
 
35
- <FormField {size}>
36
- {#if label}
37
- <FormLabel {id} {required} {label} />
38
- {/if}
34
+ <FormField {size} {label} {id} {required} {disabled}>
39
35
  <div>
40
36
  <input
41
37
  {id}
@@ -2,24 +2,82 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { ComponentSize } from '../types/size.js';
4
4
  import { getMaxWidth, getDisplayType } from '../types/size.js';
5
+ import FormLabel from './form-label.svelte';
5
6
 
6
7
  let {
7
8
  size = 'full',
9
+ label = undefined,
10
+ id = undefined,
11
+ required = false,
12
+ disabled = false,
13
+ helperText = undefined,
14
+ errorText = undefined,
15
+ successText = undefined,
8
16
  children
9
17
  }: {
10
18
  size?: ComponentSize;
19
+ label?: string | undefined;
20
+ id?: string | undefined;
21
+ required?: boolean;
22
+ disabled?: boolean;
23
+ helperText?: string | undefined;
24
+ errorText?: string | undefined;
25
+ successText?: string | undefined;
11
26
  children: Snippet;
12
27
  } = $props();
13
28
 
14
29
  let displayType = $derived(getDisplayType(size));
15
30
  let maxWidth = $derived(getMaxWidth(size));
31
+
32
+ let showHelperText = $derived(!!helperText && !errorText && !successText);
33
+ let showSuccessText = $derived(!!successText && !errorText);
34
+ let showErrorText = $derived(!!errorText);
16
35
  </script>
17
36
 
18
37
  <div style={`display: ${displayType}; width: 100%; min-width: 10rem; max-width: ${maxWidth}`}>
38
+ {#if label}
39
+ <FormLabel {id} {required} {disabled} {label} />
40
+ {/if}
19
41
  {@render children?.()}
42
+ {#if showHelperText}
43
+ <div class="helper-text" id="{id}-helper">{helperText}</div>
44
+ {/if}
45
+ {#if showSuccessText}
46
+ <div class="success-text" id="{id}-success" role="status" aria-live="polite">
47
+ {successText}
48
+ </div>
49
+ {/if}
50
+ {#if showErrorText}
51
+ <div class="error-text" id="{id}-error" role="alert" aria-live="assertive">
52
+ {errorText}
53
+ </div>
54
+ {/if}
20
55
  </div>
21
56
 
22
57
  <style>div {
23
58
  margin-bottom: var(--spacing-base);
24
59
  margin-right: var(--spacing-base);
60
+ }
61
+
62
+ .helper-text {
63
+ font-size: var(--font-sm);
64
+ line-height: 1.25rem;
65
+ padding: var(--spacing-xs);
66
+ color: var(--body-fg);
67
+ }
68
+
69
+ .success-text {
70
+ font-size: var(--font-sm);
71
+ line-height: 1.25rem;
72
+ padding: var(--spacing-xs);
73
+ color: var(--success, #28a745);
74
+ font-weight: 500;
75
+ }
76
+
77
+ .error-text {
78
+ font-size: var(--font-sm);
79
+ line-height: 1.25rem;
80
+ padding: var(--spacing-xs);
81
+ color: var(--danger, #dc3545);
82
+ font-weight: 500;
25
83
  }</style>
@@ -2,6 +2,13 @@ import type { Snippet } from 'svelte';
2
2
  import type { ComponentSize } from '../types/size.js';
3
3
  type $$ComponentProps = {
4
4
  size?: ComponentSize;
5
+ label?: string | undefined;
6
+ id?: string | undefined;
7
+ required?: boolean;
8
+ disabled?: boolean;
9
+ helperText?: string | undefined;
10
+ errorText?: string | undefined;
11
+ successText?: string | undefined;
5
12
  children: Snippet;
6
13
  };
7
14
  declare const FormField: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ interface Props {
5
+ children: Snippet;
6
+ }
7
+
8
+ let { children }: Props = $props();
9
+ </script>
10
+
11
+ <div class="form-row">
12
+ {@render children()}
13
+ </div>
14
+
15
+ <style>/* ============================================
16
+ BREAKPOINTS - Responsive Design
17
+ ============================================ */
18
+ .form-row {
19
+ display: flex;
20
+ flex-direction: row;
21
+ gap: var(--spacing-base);
22
+ align-items: flex-start;
23
+ }
24
+ @media (max-width: 479.98px) {
25
+ .form-row {
26
+ flex-direction: column;
27
+ align-items: stretch;
28
+ }
29
+ }</style>
@@ -0,0 +1,7 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ children: Snippet;
4
+ }
5
+ declare const FormRow: import("svelte").Component<Props, {}, "">;
6
+ type FormRow = ReturnType<typeof FormRow>;
7
+ export default FormRow;
@@ -33,3 +33,9 @@
33
33
  {@render children()}
34
34
  </form>
35
35
  </Section>
36
+
37
+ <style>form {
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: var(--spacing-base);
41
+ }</style>
@@ -2,7 +2,6 @@
2
2
  import LinkIcon from '../../icons/link-icon.svelte';
3
3
  import type { FormFieldSizeOptions } from '../../index.js';
4
4
  import FormField from '../form-field.svelte';
5
- import FormLabel from '../form-label.svelte';
6
5
 
7
6
  let {
8
7
  size = 'md' as FormFieldSizeOptions,
@@ -17,10 +16,7 @@
17
16
  } = $props();
18
17
  </script>
19
18
 
20
- <FormField {size}>
21
- {#if label}
22
- <FormLabel {label} />
23
- {/if}
19
+ <FormField {size} {label}>
24
20
  <div class="input">
25
21
  {#if href}
26
22
  <span class="icon">
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { DropdownOption, FormFieldSizeOptions, MenuOption } from '../../types/form.js';
3
3
  import FormField from '../form-field.svelte';
4
- import FormLabel from '../form-label.svelte';
5
4
  import { uniqueId } from '../../helpers/unique-id.js';
6
5
  import Menu from '../../generic/menu/menu.svelte';
7
6
  import AngleUpIcon from '../../icons/angle-up-icon.svelte';
@@ -151,10 +150,7 @@
151
150
  let open = $derived(isMenuOpen && !disabled);
152
151
  </script>
153
152
 
154
- <FormField {size}>
155
- {#if label}
156
- <FormLabel {id} {required} {disabled} {label} />
157
- {/if}
153
+ <FormField {size} {label} {id} {required} {disabled}>
158
154
  <div class="{open ? 'open' : 'closed'} {disabled ? 'disabled' : 'enabled'}">
159
155
  <input
160
156
  type="text"
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { uniqueId, type FormFieldSizeOptions } from '../../index.js';
3
3
  import FormField from '../form-field.svelte';
4
- import FormLabel from '../form-label.svelte';
5
4
  import { untrack } from 'svelte';
6
5
 
7
6
  const id = uniqueId();
@@ -58,8 +57,11 @@
58
57
  ]);
59
58
  $effect(() => {
60
59
  if (value !== null && value >= 0) {
61
- dollars = getDollarsFromValue();
62
- cents = getCentsFromValue();
60
+ // Use untrack to prevent writes to dollars/cents from triggering this effect again
61
+ untrack(() => {
62
+ dollars = getDollarsFromValue();
63
+ cents = getCentsFromValue();
64
+ });
63
65
  }
64
66
  });
65
67
 
@@ -207,10 +209,7 @@
207
209
 
208
210
  </script>
209
211
 
210
- <FormField {size}>
211
- {#if label}
212
- <FormLabel {id} {label} />
213
- {/if}
212
+ <FormField {size} {label} {id}>
214
213
  <div class="input {currency}" class:allowCents {id}>
215
214
  {#if prefix}
216
215
  <span class="prefix">{prefix}</span>
@@ -2,7 +2,6 @@
2
2
  import { roundToDecimals } from '../../helpers/round-to-decimals.js';
3
3
  import { uniqueId } from '../../helpers/unique-id.js';
4
4
  import FormField from '../form-field.svelte';
5
- import FormLabel from '../form-label.svelte';
6
5
  import type { FormFieldSizeOptions } from '../../types/form.js';
7
6
  const id = uniqueId();
8
7
 
@@ -64,10 +63,7 @@
64
63
  };
65
64
  </script>
66
65
 
67
- <FormField {size}>
68
- {#if label}
69
- <FormLabel {id} {label} />
70
- {/if}
66
+ <FormField {size} {label} {id}>
71
67
  <div class="input {type}">
72
68
  {#if prefix}
73
69
  <span class="prefix">{prefix}</span>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte';
2
3
  import { uniqueId, type FormFieldSizeOptions } from '../../index.js';
3
4
  import FormField from '../form-field.svelte';
4
- import FormLabel from '../form-label.svelte';
5
5
 
6
6
  let {
7
7
  value = $bindable('' as string | null),
@@ -135,16 +135,18 @@
135
135
  setValue(value ?? '');
136
136
 
137
137
  $effect(() => {
138
- if (areaCode || localExt || lastFour) {
139
- publishChange();
138
+ // Only trigger when the phone number parts change
139
+ const hasValue = areaCode || localExt || lastFour;
140
+ if (hasValue) {
141
+ // Use untrack to prevent value changes from triggering this effect again
142
+ untrack(() => {
143
+ publishChange();
144
+ });
140
145
  }
141
146
  });
142
147
  </script>
143
148
 
144
- <FormField {size}>
145
- {#if label}
146
- <FormLabel id="{id}-areaCode" {label} />
147
- {/if}
149
+ <FormField {size} {label} id="{id}-areaCode">
148
150
  <div class="input">
149
151
  <span class="areaCode segment">
150
152
  <span>(</span>
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { DropdownOption, FormFieldSizeOptions } from '../../types/form.js';
3
3
  import FormField from '../form-field.svelte';
4
- import FormLabel from '../form-label.svelte';
5
4
  import { uniqueId } from '../../helpers/unique-id.js';
6
5
  import RadioBox from './radio-box.svelte';
7
6
 
@@ -24,10 +23,7 @@
24
23
  } = $props();
25
24
  </script>
26
25
 
27
- <FormField {size}>
28
- {#if label}
29
- <FormLabel {id} {required} {label} />
30
- {/if}
26
+ <FormField {size} {label} {id} {required} {disabled}>
31
27
  <div>
32
28
  {#each items as item}
33
29
  <RadioBox bind:group {disabled} value={item.value}>{item.name}</RadioBox>
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { uniqueId } from '../../helpers/unique-id.js';
3
3
  import FormField from '../form-field.svelte';
4
- import FormLabel from '../form-label.svelte';
5
4
  import type { ComponentSize } from '../../types/size.js';
6
5
 
7
6
  const id = uniqueId();
@@ -53,10 +52,7 @@
53
52
  let displayValue = $derived(formatValue ? formatValue(value) : String(value));
54
53
  </script>
55
54
 
56
- <FormField {size}>
57
- {#if label}
58
- <FormLabel {id} {label} />
59
- {/if}
55
+ <FormField {size} {label} {id} {disabled}>
60
56
  <div class="slider-wrapper">
61
57
  <div class="slider-track-container">
62
58
  <input
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte';
2
3
  import { uniqueId } from '../../helpers/unique-id.js';
3
4
  import FormField from '../form-field.svelte';
4
- import FormLabel from '../form-label.svelte';
5
5
  import type { FormFieldSizeOptions } from '../../types/form.js';
6
6
 
7
7
  const id = uniqueId();
@@ -55,31 +55,34 @@
55
55
  // Run auto-resize when value changes
56
56
  $effect(() => {
57
57
  if (value !== null && value !== undefined) {
58
- handleAutoResize();
58
+ // Use untrack to prevent DOM manipulations from triggering effects
59
+ untrack(() => {
60
+ handleAutoResize();
61
+ });
59
62
  }
60
63
  });
61
64
 
62
65
  // Run auto-resize on mount
63
66
  $effect(() => {
64
67
  if (textareaElement && autoResize) {
65
- handleAutoResize();
68
+ // Use untrack to prevent DOM manipulations from triggering effects
69
+ untrack(() => {
70
+ handleAutoResize();
71
+ });
66
72
  }
67
73
  });
68
74
  </script>
69
75
 
70
- <FormField {size}>
71
- {#if label}
72
- <FormLabel {id} {required} {label} />
73
- {/if}
74
- <textarea
75
- wrap="soft"
76
- {id}
77
- {placeholder}
78
- rows={autoResize ? minRows : rows}
79
- bind:value
76
+ <FormField {size} {label} {id} {required} {disabled}>
77
+ <textarea
78
+ wrap="soft"
79
+ {id}
80
+ {placeholder}
81
+ rows={autoResize ? minRows : rows}
82
+ bind:value
80
83
  bind:this={textareaElement}
81
- {required}
82
- {disabled}
84
+ {required}
85
+ {disabled}
83
86
  {readonly}
84
87
  data-auto-resize={autoResize}
85
88
  oninput={handleAutoResize}
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte';
2
3
  import { uniqueId } from '../../helpers/unique-id.js';
3
4
  import { animateShake, animateScaleIn } from '../../helpers/animations.js';
4
5
  import FormField from '../form-field.svelte';
5
- import FormLabel from '../form-label.svelte';
6
6
  import CheckIcon from '../../icons/check-icon.svelte';
7
7
  import type { AllowedTextInputTypes, FormFieldSizeOptions } from '../../types/form.js';
8
8
 
@@ -73,26 +73,45 @@
73
73
  : 'near-limit'
74
74
  : ''
75
75
  );
76
-
76
+
77
+ // Update describedByIds array when helper/error/success text changes
77
78
  $effect(() => {
78
- describedByIds = [];
79
- if (helperText) describedByIds.push(`${id}-helper`);
80
- if (errorText) describedByIds.push(`${id}-error`);
81
- if (successText) describedByIds.push(`${id}-success`);
79
+ // Track the dependencies we care about
80
+ const hasHelper = !!helperText;
81
+ const hasErrorMsg = !!errorText;
82
+ const hasSuccessMsg = !!successText;
83
+
84
+ // Use untrack to write to describedByIds without triggering this effect again
85
+ untrack(() => {
86
+ describedByIds = [];
87
+ if (hasHelper) describedByIds.push(`${id}-helper`);
88
+ if (hasErrorMsg) describedByIds.push(`${id}-error`);
89
+ if (hasSuccessMsg) describedByIds.push(`${id}-success`);
90
+ });
82
91
  });
83
92
 
84
- // Trigger shake animation when error appears
93
+ // Trigger shake animation when error appears (track previous error state)
94
+ let prevHasError = $state(false);
85
95
  $effect(() => {
86
- if (hasError && inputElement) {
87
- animateShake(inputElement);
96
+ if (hasError && !prevHasError && inputElement) {
97
+ // Use untrack to prevent animation from triggering effect again
98
+ untrack(() => {
99
+ animateShake(inputElement!); // Already checked above
100
+ });
88
101
  }
102
+ prevHasError = hasError;
89
103
  });
90
104
 
91
- // Trigger scale-in animation when success appears
105
+ // Trigger scale-in animation when success appears (track previous success state)
106
+ let prevHasSuccess = $state(false);
92
107
  $effect(() => {
93
- if (hasSuccess && successIconElement) {
94
- animateScaleIn(successIconElement);
108
+ if (hasSuccess && !prevHasSuccess && successIconElement) {
109
+ // Use untrack to prevent animation from triggering effect again
110
+ untrack(() => {
111
+ animateScaleIn(successIconElement!); // Already checked above
112
+ });
95
113
  }
114
+ prevHasSuccess = hasSuccess;
96
115
  });
97
116
 
98
117
  // Don't allow certain characters to be typed into the input
@@ -130,11 +149,13 @@
130
149
  };
131
150
  </script>
132
151
 
133
- <FormField {size}>
134
- {#if label}
135
- <FormLabel {id} {required} {label} />
136
- {/if}
137
- <div class="input {disabled ? 'disabled' : 'enabled'}" class:error={hasError} class:success={hasSuccess} bind:this={inputElement}>
152
+ <FormField {size} {label} {id} {required} {disabled} {helperText} {errorText} {successText}>
153
+ <div
154
+ class="input {disabled ? 'disabled' : 'enabled'}"
155
+ class:error={hasError}
156
+ class:success={hasSuccess}
157
+ bind:this={inputElement}
158
+ >
138
159
  {#if prefix}
139
160
  <div class="prefix">{prefix}</div>
140
161
  {/if}
@@ -174,19 +195,6 @@
174
195
  {characterCount} / {maxlength}
175
196
  </div>
176
197
  {/if}
177
- {#if helperText}
178
- <div class="helper-text" id="{id}-helper">{helperText}</div>
179
- {/if}
180
- {#if successText}
181
- <div class="success-text" id="{id}-success" role="status" aria-live="polite">
182
- {successText}
183
- </div>
184
- {/if}
185
- {#if errorText}
186
- <div class="error-text" id="{id}-error" role="alert" aria-live="assertive">
187
- {errorText}
188
- </div>
189
- {/if}
190
198
  </FormField>
191
199
 
192
200
  <style>.input {
@@ -283,6 +291,8 @@
283
291
  padding: var(--spacing-xs);
284
292
  text-align: right;
285
293
  color: var(--body-fg);
294
+ position: absolute;
295
+ right: 0;
286
296
  }
287
297
  .character-count.near-limit {
288
298
  color: var(--warning, #ffc107);
@@ -291,27 +301,4 @@
291
301
  .character-count.at-limit {
292
302
  color: var(--danger, #dc3545);
293
303
  font-weight: 600;
294
- }
295
-
296
- .helper-text {
297
- font-size: var(--font-sm);
298
- line-height: 1.25rem;
299
- padding: var(--spacing-xs);
300
- color: var(--body-fg);
301
- }
302
-
303
- .success-text {
304
- font-size: var(--font-sm);
305
- line-height: 1.25rem;
306
- padding: var(--spacing-xs);
307
- color: var(--success, #28a745);
308
- font-weight: 500;
309
- }
310
-
311
- .error-text {
312
- font-size: var(--font-sm);
313
- line-height: 1.25rem;
314
- padding: var(--spacing-xs);
315
- color: var(--danger, #dc3545);
316
- font-weight: 500;
317
304
  }</style>
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { uniqueId } from '../../helpers/unique-id.js';
3
3
  import FormField from '../form-field.svelte';
4
- import FormLabel from '../form-label.svelte';
5
4
  import type { ComponentSize } from '../../types/size.js';
6
5
 
7
6
  const id = uniqueId();
@@ -29,10 +28,7 @@
29
28
  };
30
29
  </script>
31
30
 
32
- <FormField {size}>
33
- {#if label}
34
- <FormLabel {id} {required} {label} />
35
- {/if}
31
+ <FormField {size} {label} {id} {required} {disabled}>
36
32
  <input
37
33
  {id}
38
34
  type="time"
@@ -88,3 +88,4 @@
88
88
 
89
89
 
90
90
 
91
+
@@ -144,3 +144,4 @@ Due to limitations in the Storybook Svelte CSF parser with advanced Svelte 5 syn
144
144
 
145
145
 
146
146
 
147
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sveltacular",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "A Svelte component library",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",