yaxa-svelte 1.8.0 → 1.9.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 (46) hide show
  1. package/dist/components/elements/Badge.test.d.ts +1 -0
  2. package/dist/components/elements/Badge.test.js +27 -0
  3. package/dist/components/elements/Button.svelte +52 -12
  4. package/dist/components/elements/Button.svelte.d.ts +5 -6
  5. package/dist/components/elements/Button.test.d.ts +1 -0
  6. package/dist/components/elements/Button.test.js +39 -0
  7. package/dist/components/elements/DataTable.dom.test.d.ts +1 -0
  8. package/dist/components/elements/DataTable.dom.test.js +74 -0
  9. package/dist/components/elements/DataTable.svelte +11 -12
  10. package/dist/components/elements/Skeleton.svelte +1 -1
  11. package/dist/components/elements/Skeleton.svelte.d.ts +3 -3
  12. package/dist/components/elements/Skeleton.test.d.ts +1 -0
  13. package/dist/components/elements/Skeleton.test.js +49 -0
  14. package/dist/components/forms/Combobox.dom.test.d.ts +1 -0
  15. package/dist/components/forms/Combobox.dom.test.js +32 -0
  16. package/dist/components/forms/InputOTP.test.d.ts +1 -0
  17. package/dist/components/forms/InputOTP.test.js +61 -0
  18. package/dist/components/forms/Switch.test.d.ts +1 -0
  19. package/dist/components/forms/Switch.test.js +27 -0
  20. package/dist/components/layout/ResizablePanels.svelte +32 -13
  21. package/dist/components/overlays/Modal.dom.test.d.ts +1 -0
  22. package/dist/components/overlays/Modal.dom.test.js +31 -0
  23. package/dist/components/overlays/Modal.test.d.ts +1 -0
  24. package/dist/components/overlays/Modal.test.js +23 -0
  25. package/dist/components/overlays/Tooltip.dom.test.d.ts +1 -0
  26. package/dist/components/overlays/Tooltip.dom.test.js +14 -0
  27. package/dist/components/overlays/Tooltip.svelte +25 -23
  28. package/dist/composables/useClipboard.test.d.ts +1 -0
  29. package/dist/composables/useClipboard.test.js +33 -0
  30. package/dist/composables/useToast.test.d.ts +1 -0
  31. package/dist/composables/useToast.test.js +46 -0
  32. package/dist/server/email/email.test.d.ts +1 -0
  33. package/dist/server/email/email.test.js +25 -0
  34. package/dist/server/endpoints.test.d.ts +1 -0
  35. package/dist/server/endpoints.test.js +88 -0
  36. package/dist/server/storage/storage.test.d.ts +1 -0
  37. package/dist/server/storage/storage.test.js +62 -0
  38. package/dist/site/og.js +1 -1
  39. package/dist/styles/yaxa.css +9 -0
  40. package/dist/testing/index.d.ts +135 -0
  41. package/dist/testing/index.js +206 -0
  42. package/dist/testing/index.test.js +87 -1
  43. package/dist/testing/setup.d.ts +0 -0
  44. package/dist/testing/setup.js +38 -0
  45. package/dist/theme/theme.svelte.js +4 -1
  46. package/package.json +22 -10
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { badgeVariants } from './Badge.svelte';
3
+ describe('Badge Component', () => {
4
+ it('generates solid primary badge classes by default', () => {
5
+ const classes = badgeVariants({ variant: 'solid', color: 'primary' });
6
+ expect(classes).toContain('bg-primary-600');
7
+ expect(classes).toContain('inline-flex');
8
+ expect(classes).toContain('font-medium');
9
+ });
10
+ it('supports multiple variants: outline, soft, subtle', () => {
11
+ const outline = badgeVariants({ variant: 'outline', color: 'success' });
12
+ expect(outline).toContain('border');
13
+ expect(outline).toContain('bg-transparent');
14
+ const soft = badgeVariants({ variant: 'soft', color: 'warning' });
15
+ expect(soft).toContain('inline-flex');
16
+ const subtle = badgeVariants({ variant: 'subtle', color: 'error' });
17
+ expect(subtle).toContain('border');
18
+ });
19
+ it('supports size scales (xs, sm, md, lg)', () => {
20
+ const xs = badgeVariants({ size: 'xs' });
21
+ expect(xs).toContain('text-[10px]');
22
+ expect(xs).toContain('px-1.5');
23
+ const lg = badgeVariants({ size: 'lg' });
24
+ expect(lg).toContain('text-sm');
25
+ expect(lg).toContain('px-3');
26
+ });
27
+ });
@@ -193,48 +193,48 @@
193
193
  });
194
194
 
195
195
  export type ButtonProps = VariantProps<typeof buttonVariants> & {
196
+ type?: 'button' | 'submit' | 'reset';
196
197
  href?: string;
197
- loading?: boolean;
198
+ as?: 'button' | 'a' | 'span' | 'div';
198
199
  disabled?: boolean;
200
+ loading?: boolean;
199
201
  icon?: IconSource;
200
202
  trailingIcon?: IconSource;
201
- type?: 'button' | 'submit' | 'reset';
202
- class?: string;
203
203
  leading?: Snippet;
204
204
  trailing?: Snippet;
205
205
  children?: Snippet;
206
- onclick?: (e: MouseEvent) => void;
207
- 'aria-label'?: string;
208
- 'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | boolean;
206
+ onclick?: (event: MouseEvent) => void;
207
+ class?: string;
209
208
  [key: string]: unknown;
210
209
  };
211
210
  </script>
212
211
 
213
212
  <script lang="ts">
214
- import Icon from './Icon.svelte';
215
213
  import Spinner from './Spinner.svelte';
214
+ import Icon from './Icon.svelte';
216
215
 
217
216
  let {
217
+ type = 'button',
218
218
  href,
219
+ as = 'button',
219
220
  variant = 'solid',
220
221
  color = 'primary',
221
222
  size = 'md',
222
223
  block = false,
223
224
  square = false,
224
- loading = false,
225
225
  disabled = false,
226
+ loading = false,
226
227
  icon,
227
228
  trailingIcon,
228
- type = 'button',
229
- class: className = '',
230
229
  leading,
231
230
  trailing,
232
231
  children,
233
232
  onclick,
233
+ class: className = '',
234
234
  ...restProps
235
235
  }: ButtonProps = $props();
236
236
 
237
- let classes = $derived(
237
+ const classes = $derived(
238
238
  buttonVariants({
239
239
  variant,
240
240
  color,
@@ -246,7 +246,47 @@
246
246
  );
247
247
  </script>
248
248
 
249
- {#if href}
249
+ {#if as === 'span'}
250
+ <span class={classes} aria-disabled={disabled || loading} {onclick} {...restProps}>
251
+ {#if loading}
252
+ <Spinner size={size === 'xs' || size === 'sm' ? 'sm' : 'md'} />
253
+ {:else if leading}
254
+ {@render leading()}
255
+ {:else if icon}
256
+ <Icon name={icon} size={size === 'xs' ? 'xs' : size === 'sm' ? 'sm' : 'md'} />
257
+ {/if}
258
+
259
+ {#if children}
260
+ {@render children()}
261
+ {/if}
262
+
263
+ {#if trailing}
264
+ {@render trailing()}
265
+ {:else if trailingIcon}
266
+ <Icon name={trailingIcon} size={size === 'xs' ? 'xs' : size === 'sm' ? 'sm' : 'md'} />
267
+ {/if}
268
+ </span>
269
+ {:else if as === 'div'}
270
+ <div class={classes} aria-disabled={disabled || loading} {onclick} {...restProps}>
271
+ {#if loading}
272
+ <Spinner size={size === 'xs' || size === 'sm' ? 'sm' : 'md'} />
273
+ {:else if leading}
274
+ {@render leading()}
275
+ {:else if icon}
276
+ <Icon name={icon} size={size === 'xs' ? 'xs' : size === 'sm' ? 'sm' : 'md'} />
277
+ {/if}
278
+
279
+ {#if children}
280
+ {@render children()}
281
+ {/if}
282
+
283
+ {#if trailing}
284
+ {@render trailing()}
285
+ {:else if trailingIcon}
286
+ <Icon name={trailingIcon} size={size === 'xs' ? 'xs' : size === 'sm' ? 'sm' : 'md'} />
287
+ {/if}
288
+ </div>
289
+ {:else if href || as === 'a'}
250
290
  <a
251
291
  {href}
252
292
  class={classes}
@@ -90,19 +90,18 @@ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
90
90
  };
91
91
  }, undefined>>;
92
92
  export type ButtonProps = VariantProps<typeof buttonVariants> & {
93
+ type?: 'button' | 'submit' | 'reset';
93
94
  href?: string;
94
- loading?: boolean;
95
+ as?: 'button' | 'a' | 'span' | 'div';
95
96
  disabled?: boolean;
97
+ loading?: boolean;
96
98
  icon?: IconSource;
97
99
  trailingIcon?: IconSource;
98
- type?: 'button' | 'submit' | 'reset';
99
- class?: string;
100
100
  leading?: Snippet;
101
101
  trailing?: Snippet;
102
102
  children?: Snippet;
103
- onclick?: (e: MouseEvent) => void;
104
- 'aria-label'?: string;
105
- 'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | boolean;
103
+ onclick?: (event: MouseEvent) => void;
104
+ class?: string;
106
105
  [key: string]: unknown;
107
106
  };
108
107
  declare const Button: import("svelte").Component<ButtonProps, {}, "">;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buttonVariants } from './Button.svelte';
3
+ describe('Button Component', () => {
4
+ it('generates solid primary button classes by default', () => {
5
+ const classes = buttonVariants({ variant: 'solid', color: 'primary' });
6
+ expect(classes).toContain('bg-primary-600');
7
+ expect(classes).toContain('inline-flex');
8
+ expect(classes).toContain('select-none');
9
+ });
10
+ it('supports multiple variants: outline, ghost, soft, subtle, link', () => {
11
+ const outline = buttonVariants({ variant: 'outline', color: 'neutral' });
12
+ expect(outline).toContain('border');
13
+ expect(outline).toContain('bg-transparent');
14
+ const ghost = buttonVariants({ variant: 'ghost', color: 'error' });
15
+ expect(ghost).toContain('bg-transparent');
16
+ const link = buttonVariants({ variant: 'link' });
17
+ expect(link).toContain('underline-offset-4');
18
+ });
19
+ it('supports size scales and square/block modifiers', () => {
20
+ const xs = buttonVariants({ size: 'xs' });
21
+ expect(xs).toContain('text-xs');
22
+ expect(xs).toContain('px-2');
23
+ const xl = buttonVariants({ size: 'xl' });
24
+ expect(xl).toContain('text-base');
25
+ expect(xl).toContain('px-5');
26
+ const block = buttonVariants({ block: true });
27
+ expect(block).toContain('w-full');
28
+ const square = buttonVariants({ square: true });
29
+ expect(square).toContain('aspect-square');
30
+ });
31
+ it('applies compound color classes for success, warning, and error', () => {
32
+ const success = buttonVariants({ variant: 'solid', color: 'success' });
33
+ expect(success).toContain('bg-emerald-600');
34
+ const warning = buttonVariants({ variant: 'solid', color: 'warning' });
35
+ expect(warning).toContain('bg-amber-600');
36
+ const error = buttonVariants({ variant: 'solid', color: 'error' });
37
+ expect(error).toContain('bg-rose-600');
38
+ });
39
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { flushSync } from 'svelte';
3
+ import { renderComponent } from '../../testing';
4
+ import DataTable, {} from './DataTable.svelte';
5
+ const testData = [
6
+ { id: '1', name: 'Alice Smith', role: 'Engineer', score: 95 },
7
+ { id: '2', name: 'Bob Jones', role: 'Designer', score: 82 },
8
+ { id: '3', name: 'Charlie Brown', role: 'Manager', score: 88 }
9
+ ];
10
+ const testColumns = [
11
+ { key: 'name', label: 'Full Name', sortable: true },
12
+ { key: 'role', label: 'Job Role', sortable: true },
13
+ { key: 'score', label: 'Performance Score', sortable: true }
14
+ ];
15
+ describe('DataTable Component (DOM & Interactions)', () => {
16
+ it('renders table headers and rows accurately', () => {
17
+ const { target, cleanup } = renderComponent(DataTable, {
18
+ data: testData,
19
+ columns: testColumns
20
+ });
21
+ const thElements = target.querySelectorAll('thead th');
22
+ expect(thElements.length).toBe(3);
23
+ expect(thElements[0].textContent).toContain('Full Name');
24
+ expect(thElements[1].textContent).toContain('Job Role');
25
+ const trElements = target.querySelectorAll('tbody tr');
26
+ expect(trElements.length).toBe(3);
27
+ expect(trElements[0].textContent).toContain('Alice Smith');
28
+ expect(trElements[1].textContent).toContain('Bob Jones');
29
+ cleanup();
30
+ });
31
+ it('filters rows according to searchQuery', () => {
32
+ const { target, cleanup } = renderComponent(DataTable, {
33
+ data: testData,
34
+ columns: testColumns,
35
+ searchQuery: 'Designer'
36
+ });
37
+ const trElements = target.querySelectorAll('tbody tr');
38
+ expect(trElements.length).toBe(1);
39
+ expect(trElements[0].textContent).toContain('Bob Jones');
40
+ expect(trElements[0].textContent).toContain('Designer');
41
+ cleanup();
42
+ });
43
+ it('displays custom emptyText when no matching rows are found', () => {
44
+ const { target, cleanup } = renderComponent(DataTable, {
45
+ data: testData,
46
+ columns: testColumns,
47
+ searchQuery: 'NonExistentQueryXYZ',
48
+ emptyText: 'No members found'
49
+ });
50
+ const trElements = target.querySelectorAll('tbody tr');
51
+ expect(trElements.length).toBe(1);
52
+ expect(trElements[0].textContent).toContain('No members found');
53
+ cleanup();
54
+ });
55
+ it('sorts rows when clicking on sortable column headers', () => {
56
+ const { target, cleanup } = renderComponent(DataTable, {
57
+ data: testData,
58
+ columns: testColumns
59
+ });
60
+ const sortButton = target.querySelector('thead th button');
61
+ expect(sortButton).toBeDefined();
62
+ // Click to sort by name (ascending)
63
+ sortButton.click();
64
+ flushSync();
65
+ let firstRowName = target.querySelector('tbody tr td')?.textContent;
66
+ expect(firstRowName).toContain('Alice Smith');
67
+ // Click again to sort by name (descending)
68
+ sortButton.click();
69
+ flushSync();
70
+ firstRowName = target.querySelector('tbody tr td')?.textContent;
71
+ expect(firstRowName).toContain('Charlie Brown');
72
+ cleanup();
73
+ });
74
+ });
@@ -48,15 +48,14 @@
48
48
 
49
49
  let sortKey = $state<string | null>(null);
50
50
  let sortOrder = $state<'asc' | 'desc'>('asc');
51
- let columnWidths = $state<Record<string, number>>({});
51
+ let customWidths = $state<Record<string, number>>({});
52
52
 
53
- // Initialize defined column widths
54
- $effect(() => {
55
- const initial: Record<string, number> = {};
53
+ let effectiveWidths = $derived.by(() => {
54
+ const widths: Record<string, number> = {};
56
55
  columns.forEach((c) => {
57
- if (c.width) initial[String(c.key)] = c.width;
56
+ if (c.width) widths[String(c.key)] = c.width;
58
57
  });
59
- columnWidths = { ...initial, ...columnWidths };
58
+ return { ...widths, ...customWidths };
60
59
  });
61
60
 
62
61
  function handleSort(key: string) {
@@ -73,7 +72,7 @@
73
72
 
74
73
  const startX = 'touches' in e ? e.touches[0].clientX : e.clientX;
75
74
  const currentCol = columns.find((c) => String(c.key) === columnKey);
76
- const currentWidth = columnWidths[columnKey] || 150;
75
+ const currentWidth = effectiveWidths[columnKey] || 150;
77
76
  const minWidth = currentCol?.minWidth || 60;
78
77
  const maxWidth = currentCol?.maxWidth || 600;
79
78
 
@@ -81,7 +80,7 @@
81
80
  const clientX = 'touches' in moveEvent ? moveEvent.touches[0].clientX : moveEvent.clientX;
82
81
  const delta = clientX - startX;
83
82
  const newWidth = Math.max(minWidth, Math.min(maxWidth, currentWidth + delta));
84
- columnWidths[columnKey] = newWidth;
83
+ customWidths[columnKey] = newWidth;
85
84
  }
86
85
 
87
86
  function onEnd() {
@@ -180,8 +179,8 @@
180
179
  {/if}
181
180
  {#each columns as column}
182
181
  {@const colKey = String(column.key)}
183
- {@const widthStyle = columnWidths[colKey]
184
- ? `width: ${columnWidths[colKey]}px; min-width: ${columnWidths[colKey]}px;`
182
+ {@const widthStyle = effectiveWidths[colKey]
183
+ ? `width: ${effectiveWidths[colKey]}px; min-width: ${effectiveWidths[colKey]}px;`
185
184
  : ''}
186
185
  <th
187
186
  class="relative px-4 py-3 {getPinClass(column.pinned)} {column.class || ''}"
@@ -291,8 +290,8 @@
291
290
  {/if}
292
291
  {#each columns as column}
293
292
  {@const colKey = String(column.key)}
294
- {@const widthStyle = columnWidths[colKey]
295
- ? `width: ${columnWidths[colKey]}px; min-width: ${columnWidths[colKey]}px;`
293
+ {@const widthStyle = effectiveWidths[colKey]
294
+ ? `width: ${effectiveWidths[colKey]}px; min-width: ${effectiveWidths[colKey]}px;`
296
295
  : ''}
297
296
  <td
298
297
  class="px-4 py-3.5 {getPinClass(column.pinned)} {column.class || ''}"
@@ -7,7 +7,7 @@
7
7
  variant: {
8
8
  pulse: 'animate-pulse',
9
9
  shimmer:
10
- 'before:absolute before:inset-0 before:-translate-x-full before:animate-[yaxa-shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent dark:before:via-white/10',
10
+ 'before:absolute before:inset-0 before:-translate-x-full before:animate-[yaxa-shimmer-slide_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent dark:before:via-white/20',
11
11
  none: ''
12
12
  },
13
13
  shape: {
@@ -2,7 +2,7 @@ import { type VariantProps } from '../../utils/cn';
2
2
  export declare const skeletonVariants: import("tailwind-variants").TVReturnType<{
3
3
  variant: {
4
4
  pulse: "animate-pulse";
5
- shimmer: "before:absolute before:inset-0 before:-translate-x-full before:animate-[yaxa-shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent dark:before:via-white/10";
5
+ shimmer: "before:absolute before:inset-0 before:-translate-x-full before:animate-[yaxa-shimmer-slide_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent dark:before:via-white/20";
6
6
  none: "";
7
7
  };
8
8
  shape: {
@@ -14,7 +14,7 @@ export declare const skeletonVariants: import("tailwind-variants").TVReturnType<
14
14
  }, undefined, "relative overflow-hidden bg-neutral-200 dark:bg-neutral-800 select-none", {
15
15
  variant: {
16
16
  pulse: "animate-pulse";
17
- shimmer: "before:absolute before:inset-0 before:-translate-x-full before:animate-[yaxa-shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent dark:before:via-white/10";
17
+ shimmer: "before:absolute before:inset-0 before:-translate-x-full before:animate-[yaxa-shimmer-slide_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent dark:before:via-white/20";
18
18
  none: "";
19
19
  };
20
20
  shape: {
@@ -26,7 +26,7 @@ export declare const skeletonVariants: import("tailwind-variants").TVReturnType<
26
26
  }, undefined, import("tailwind-variants").TVReturnTypeLike<{
27
27
  variant: {
28
28
  pulse: "animate-pulse";
29
- shimmer: "before:absolute before:inset-0 before:-translate-x-full before:animate-[yaxa-shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent dark:before:via-white/10";
29
+ shimmer: "before:absolute before:inset-0 before:-translate-x-full before:animate-[yaxa-shimmer-slide_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent dark:before:via-white/20";
30
30
  none: "";
31
31
  };
32
32
  shape: {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderComponent } from '../../testing';
3
+ import Skeleton, { skeletonVariants } from './Skeleton.svelte';
4
+ describe('Skeleton Component (DOM & Variants)', () => {
5
+ it('generates pulse animation classes by default', () => {
6
+ const classes = skeletonVariants({ variant: 'pulse', shape: 'rectangle' });
7
+ expect(classes).toContain('animate-pulse');
8
+ expect(classes).toContain('rounded-lg');
9
+ });
10
+ it('generates shimmer sliding gradient classes for variant shimmer', () => {
11
+ const classes = skeletonVariants({ variant: 'shimmer' });
12
+ expect(classes).toContain('yaxa-shimmer-slide');
13
+ expect(classes).toContain('before:bg-gradient-to-r');
14
+ });
15
+ it('supports shape variants: circle, pill, text', () => {
16
+ const circle = skeletonVariants({ shape: 'circle' });
17
+ expect(circle).toContain('rounded-full');
18
+ expect(circle).toContain('aspect-square');
19
+ const pill = skeletonVariants({ shape: 'pill' });
20
+ expect(pill).toContain('rounded-full');
21
+ const text = skeletonVariants({ shape: 'text' });
22
+ expect(text).toContain('h-4');
23
+ });
24
+ it('mounts single skeleton DOM element with custom width and height', () => {
25
+ const { target, cleanup } = renderComponent(Skeleton, {
26
+ variant: 'shimmer',
27
+ shape: 'circle',
28
+ width: '48px',
29
+ height: '48px'
30
+ });
31
+ const el = target.querySelector('div');
32
+ expect(el).toBeDefined();
33
+ expect(el?.getAttribute('aria-hidden')).toBe('true');
34
+ expect(el?.style.width).toBe('48px');
35
+ expect(el?.style.height).toBe('48px');
36
+ expect(el?.className).toContain('rounded-full');
37
+ cleanup();
38
+ });
39
+ it('renders multiple lines for lines prop > 1', () => {
40
+ const { target, cleanup } = renderComponent(Skeleton, {
41
+ lines: 4,
42
+ variant: 'shimmer'
43
+ });
44
+ const container = target.querySelector('.space-y-2');
45
+ expect(container).toBeDefined();
46
+ expect(container?.children.length).toBe(4);
47
+ cleanup();
48
+ });
49
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderComponent } from '../../testing';
3
+ import Combobox, { comboboxVariants } from './Combobox.svelte';
4
+ const testOptions = [
5
+ { value: 'svelte', label: 'Svelte 5' },
6
+ { value: 'react', label: 'React 19' },
7
+ { value: 'vue', label: 'Vue 3' }
8
+ ];
9
+ describe('Combobox Component (DOM & Interactions)', () => {
10
+ it('computes size and status variant classes', () => {
11
+ expect(comboboxVariants({ size: 'sm' })).toContain('h-9');
12
+ expect(comboboxVariants({ size: 'md' })).toContain('h-10');
13
+ expect(comboboxVariants({ size: 'lg' })).toContain('h-12');
14
+ expect(comboboxVariants({ status: 'error' })).toContain('border-rose-500');
15
+ });
16
+ it('renders placeholder when value is empty', () => {
17
+ const { target, cleanup } = renderComponent(Combobox, {
18
+ options: testOptions,
19
+ placeholder: 'Choose framework...'
20
+ });
21
+ expect(target.textContent).toContain('Choose framework...');
22
+ cleanup();
23
+ });
24
+ it('renders selected option label when bound value matches', () => {
25
+ const { target, cleanup } = renderComponent(Combobox, {
26
+ options: testOptions,
27
+ value: 'svelte'
28
+ });
29
+ expect(target.textContent).toContain('Svelte 5');
30
+ cleanup();
31
+ });
32
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderComponent } from '../../testing';
3
+ import InputOTP from './InputOTP.svelte';
4
+ describe('InputOTP Component (DOM & Interactions)', () => {
5
+ it('renders specified number of input slots with aria-labels', () => {
6
+ const { target, cleanup } = renderComponent(InputOTP, { length: 6 });
7
+ const inputs = target.querySelectorAll('input');
8
+ expect(inputs.length).toBe(6);
9
+ expect(inputs[0].getAttribute('aria-label')).toBe('Digit 1 of 6');
10
+ expect(inputs[5].getAttribute('aria-label')).toBe('Digit 6 of 6');
11
+ cleanup();
12
+ });
13
+ it('renders separator hyphen at specified index', () => {
14
+ const { target, cleanup } = renderComponent(InputOTP, {
15
+ length: 6,
16
+ separator: true,
17
+ separatorIndex: 3
18
+ });
19
+ const separator = target.querySelector('span');
20
+ expect(separator).toBeDefined();
21
+ expect(separator?.textContent).toContain('−');
22
+ cleanup();
23
+ });
24
+ it('populates slots when bound value is provided', () => {
25
+ const { target, cleanup } = renderComponent(InputOTP, {
26
+ length: 4,
27
+ value: '4281'
28
+ });
29
+ const inputs = target.querySelectorAll('input');
30
+ expect(inputs[0].value).toBe('4');
31
+ expect(inputs[1].value).toBe('2');
32
+ expect(inputs[2].value).toBe('8');
33
+ expect(inputs[3].value).toBe('1');
34
+ cleanup();
35
+ });
36
+ it('handles keyboard typing and updates input slot', () => {
37
+ let updatedCode = '';
38
+ const { target, cleanup } = renderComponent(InputOTP, {
39
+ length: 4,
40
+ onchange: (code) => {
41
+ updatedCode = code;
42
+ }
43
+ });
44
+ const inputs = target.querySelectorAll('input');
45
+ inputs[0].value = '9';
46
+ inputs[0].dispatchEvent(new Event('input', { bubbles: true }));
47
+ expect(updatedCode).toBe('9');
48
+ cleanup();
49
+ });
50
+ it('disables all input slots when disabled prop is true', () => {
51
+ const { target, cleanup } = renderComponent(InputOTP, {
52
+ length: 4,
53
+ disabled: true
54
+ });
55
+ const inputs = target.querySelectorAll('input');
56
+ inputs.forEach((input) => {
57
+ expect(input.disabled).toBe(true);
58
+ });
59
+ cleanup();
60
+ });
61
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { switchVariants, thumbVariants } from './Switch.svelte';
3
+ describe('Switch Component', () => {
4
+ it('generates default switch track classes', () => {
5
+ const classes = switchVariants();
6
+ expect(classes).toContain('rounded-full');
7
+ expect(classes).toContain('h-6');
8
+ expect(classes).toContain('w-11');
9
+ expect(classes).toContain('data-[state=checked]:bg-primary-600');
10
+ });
11
+ it('supports size variations for track and thumb', () => {
12
+ const smTrack = switchVariants({ size: 'sm' });
13
+ expect(smTrack).toContain('h-5');
14
+ expect(smTrack).toContain('w-9');
15
+ const smThumb = thumbVariants({ size: 'sm' });
16
+ expect(smThumb).toContain('h-4');
17
+ expect(smThumb).toContain('w-4');
18
+ expect(smThumb).toContain('data-[state=checked]:translate-x-4');
19
+ const lgTrack = switchVariants({ size: 'lg' });
20
+ expect(lgTrack).toContain('h-7');
21
+ expect(lgTrack).toContain('w-14');
22
+ const lgThumb = thumbVariants({ size: 'lg' });
23
+ expect(lgThumb).toContain('h-6');
24
+ expect(lgThumb).toContain('w-6');
25
+ expect(lgThumb).toContain('data-[state=checked]:translate-x-7');
26
+ });
27
+ });
@@ -90,37 +90,56 @@
90
90
  }
91
91
  }
92
92
 
93
+ let rafId: number | null = null;
94
+
93
95
  function startDrag(e: MouseEvent | TouchEvent) {
94
96
  e.preventDefault();
95
97
  isDragging = true;
96
98
 
97
99
  function onMove(moveEvent: MouseEvent | TouchEvent) {
98
100
  if (!containerRef) return;
99
- const rect = containerRef.getBoundingClientRect();
100
- let newPercent: number;
101
-
102
- if (direction === 'horizontal') {
103
- const clientX = 'touches' in moveEvent ? moveEvent.touches[0].clientX : moveEvent.clientX;
104
- newPercent = ((clientX - rect.left) / rect.width) * 100;
105
- } else {
106
- const clientY = 'touches' in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY;
107
- newPercent = ((clientY - rect.top) / rect.height) * 100;
108
- }
101
+ const isTouch = 'touches' in moveEvent;
102
+ const clientX =
103
+ isTouch && moveEvent.touches.length > 0
104
+ ? moveEvent.touches[0].clientX
105
+ : (moveEvent as MouseEvent).clientX;
106
+ const clientY =
107
+ isTouch && moveEvent.touches.length > 0
108
+ ? moveEvent.touches[0].clientY
109
+ : (moveEvent as MouseEvent).clientY;
110
+
111
+ if (rafId !== null) return;
112
+ rafId = requestAnimationFrame(() => {
113
+ rafId = null;
114
+ if (!containerRef) return;
115
+ const rect = containerRef.getBoundingClientRect();
116
+ let newPercent: number;
117
+
118
+ if (direction === 'horizontal') {
119
+ newPercent = ((clientX - rect.left) / rect.width) * 100;
120
+ } else {
121
+ newPercent = ((clientY - rect.top) / rect.height) * 100;
122
+ }
109
123
 
110
- saveSize(newPercent);
124
+ saveSize(newPercent);
125
+ });
111
126
  }
112
127
 
113
128
  function onEnd() {
114
129
  isDragging = false;
130
+ if (rafId !== null) {
131
+ cancelAnimationFrame(rafId);
132
+ rafId = null;
133
+ }
115
134
  window.removeEventListener('mousemove', onMove);
116
135
  window.removeEventListener('mouseup', onEnd);
117
136
  window.removeEventListener('touchmove', onMove);
118
137
  window.removeEventListener('touchend', onEnd);
119
138
  }
120
139
 
121
- window.addEventListener('mousemove', onMove);
140
+ window.addEventListener('mousemove', onMove, { passive: true });
122
141
  window.addEventListener('mouseup', onEnd);
123
- window.addEventListener('touchmove', onMove);
142
+ window.addEventListener('touchmove', onMove, { passive: true });
124
143
  window.addEventListener('touchend', onEnd);
125
144
  }
126
145
 
@@ -0,0 +1 @@
1
+ export {};