yaxa-svelte 1.5.2 → 1.6.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.
- package/README.md +5 -2
- package/dist/components/elements/EmptyState.svelte +99 -0
- package/dist/components/elements/EmptyState.svelte.d.ts +45 -0
- package/dist/components/elements/EmptyState.test.d.ts +1 -0
- package/dist/components/elements/EmptyState.test.js +16 -0
- package/dist/components/elements/Icon.svelte +1 -0
- package/dist/components/forms/Combobox.svelte +243 -0
- package/dist/components/forms/Combobox.svelte.d.ts +56 -0
- package/dist/components/forms/Combobox.test.d.ts +1 -0
- package/dist/components/forms/Combobox.test.js +16 -0
- package/dist/components/forms/MultiSelect.svelte +238 -0
- package/dist/components/forms/MultiSelect.svelte.d.ts +54 -0
- package/dist/components/forms/MultiSelect.test.d.ts +1 -0
- package/dist/components/forms/MultiSelect.test.js +16 -0
- package/dist/components/forms/NumberInput.svelte +141 -0
- package/dist/components/forms/NumberInput.svelte.d.ts +47 -0
- package/dist/components/forms/NumberInput.test.d.ts +1 -0
- package/dist/components/forms/NumberInput.test.js +16 -0
- package/dist/components/layout/Footer.svelte +11 -0
- package/dist/components/layout/Header.svelte +14 -0
- package/dist/components/navigation/Stepper.svelte +139 -0
- package/dist/components/navigation/Stepper.svelte.d.ts +36 -0
- package/dist/components/navigation/Stepper.test.d.ts +1 -0
- package/dist/components/navigation/Stepper.test.js +14 -0
- package/dist/components/overlays/Drawer.svelte +105 -0
- package/dist/components/overlays/Drawer.svelte.d.ts +32 -0
- package/dist/components/overlays/Drawer.test.d.ts +1 -0
- package/dist/components/overlays/Drawer.test.js +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +8 -0
- package/dist/site/config.d.ts +1 -0
- package/dist/site/schema.js +2 -0
- package/dist/testing/index.d.ts +24 -0
- package/dist/testing/index.js +41 -0
- package/dist/testing/index.test.d.ts +1 -0
- package/dist/testing/index.test.js +27 -0
- package/dist/vite/index.d.ts +18 -3
- package/dist/vite/index.js +38 -8
- package/dist/vite/index.test.d.ts +1 -0
- package/dist/vite/index.test.js +63 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
**The Intuitive Svelte UI & Solo SaaS Library**
|
|
6
6
|
_Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2.7+ & Svelte 5 with Built-in SEO & SaaS Parity._
|
|
7
7
|
|
|
8
|
+
[](https://www.npmjs.com/package/yaxa-svelte)
|
|
9
|
+
[](https://www.npmjs.com/package/yaxa-svelte)
|
|
8
10
|
[](https://svelte.dev)
|
|
9
11
|
[](https://kit.svelte.dev)
|
|
10
12
|
[](https://tailwindcss.com)
|
|
@@ -51,16 +53,17 @@ _(Optional full-stack SaaS features: `pnpm add better-auth drizzle-orm @polar-sh
|
|
|
51
53
|
|
|
52
54
|
### 2. Configure `vite.config.ts`
|
|
53
55
|
|
|
54
|
-
Add Tailwind CSS to your Vite plugins:
|
|
56
|
+
Add Tailwind CSS and the `yaxa()` plugin to your Vite plugins:
|
|
55
57
|
|
|
56
58
|
```ts
|
|
57
59
|
// vite.config.ts
|
|
58
60
|
import { sveltekit } from '@sveltejs/kit/vite';
|
|
59
61
|
import tailwindcss from '@tailwindcss/vite';
|
|
62
|
+
import { yaxa } from 'yaxa-svelte/vite';
|
|
60
63
|
import { defineConfig } from 'vite';
|
|
61
64
|
|
|
62
65
|
export default defineConfig({
|
|
63
|
-
plugins: [tailwindcss(), sveltekit()]
|
|
66
|
+
plugins: [tailwindcss(), sveltekit(), yaxa()]
|
|
64
67
|
});
|
|
65
68
|
```
|
|
66
69
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import { tv, type VariantProps } from '../../utils/cn';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import type { IconSource } from './Icon.svelte';
|
|
5
|
+
|
|
6
|
+
export const emptyStateVariants = tv({
|
|
7
|
+
base: 'flex flex-col items-center justify-center text-center p-8 rounded-2xl bg-white dark:bg-neutral-900/50',
|
|
8
|
+
variants: {
|
|
9
|
+
size: {
|
|
10
|
+
sm: 'py-6 px-4 gap-2',
|
|
11
|
+
md: 'py-10 px-6 gap-3',
|
|
12
|
+
lg: 'py-16 px-8 gap-4'
|
|
13
|
+
},
|
|
14
|
+
bordered: {
|
|
15
|
+
true: 'border border-dashed border-neutral-300 dark:border-neutral-800',
|
|
16
|
+
false: 'border-0'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
defaultVariants: {
|
|
20
|
+
size: 'md',
|
|
21
|
+
bordered: true
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type EmptyStateProps = VariantProps<typeof emptyStateVariants> & {
|
|
26
|
+
title?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
icon?: IconSource;
|
|
29
|
+
actions?: Snippet;
|
|
30
|
+
children?: Snippet;
|
|
31
|
+
class?: string;
|
|
32
|
+
};
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<script lang="ts">
|
|
36
|
+
import Icon from './Icon.svelte';
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
title,
|
|
40
|
+
description,
|
|
41
|
+
icon = 'sparkles',
|
|
42
|
+
size = 'md',
|
|
43
|
+
bordered = true,
|
|
44
|
+
actions,
|
|
45
|
+
children,
|
|
46
|
+
class: className = ''
|
|
47
|
+
}: EmptyStateProps = $props();
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<div class={emptyStateVariants({ size, bordered, class: className })}>
|
|
51
|
+
{#if icon}
|
|
52
|
+
<div
|
|
53
|
+
class="flex items-center justify-center rounded-2xl bg-neutral-100 p-4 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400 {size ===
|
|
54
|
+
'sm'
|
|
55
|
+
? 'h-10 w-10 p-2'
|
|
56
|
+
: size === 'lg'
|
|
57
|
+
? 'h-16 w-16 p-5'
|
|
58
|
+
: 'h-12 w-12 p-3'}"
|
|
59
|
+
>
|
|
60
|
+
<Icon name={icon} size={size === 'sm' ? 'sm' : size === 'lg' ? 'lg' : 'md'} />
|
|
61
|
+
</div>
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
{#if title}
|
|
65
|
+
<h3
|
|
66
|
+
class="font-bold text-neutral-900 dark:text-white {size === 'sm'
|
|
67
|
+
? 'text-sm'
|
|
68
|
+
: size === 'lg'
|
|
69
|
+
? 'text-xl'
|
|
70
|
+
: 'text-base'}"
|
|
71
|
+
>
|
|
72
|
+
{title}
|
|
73
|
+
</h3>
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
{#if description}
|
|
77
|
+
<p
|
|
78
|
+
class="max-w-md text-neutral-500 dark:text-neutral-400 {size === 'sm'
|
|
79
|
+
? 'text-xs'
|
|
80
|
+
: size === 'lg'
|
|
81
|
+
? 'text-base'
|
|
82
|
+
: 'text-sm'}"
|
|
83
|
+
>
|
|
84
|
+
{description}
|
|
85
|
+
</p>
|
|
86
|
+
{/if}
|
|
87
|
+
|
|
88
|
+
{#if children}
|
|
89
|
+
<div class="mt-1">
|
|
90
|
+
{@render children()}
|
|
91
|
+
</div>
|
|
92
|
+
{/if}
|
|
93
|
+
|
|
94
|
+
{#if actions}
|
|
95
|
+
<div class="mt-4 flex flex-wrap items-center justify-center gap-3">
|
|
96
|
+
{@render actions()}
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type VariantProps } from '../../utils/cn';
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { IconSource } from './Icon.svelte';
|
|
4
|
+
export declare const emptyStateVariants: import("tailwind-variants").TVReturnType<{
|
|
5
|
+
size: {
|
|
6
|
+
sm: "py-6 px-4 gap-2";
|
|
7
|
+
md: "py-10 px-6 gap-3";
|
|
8
|
+
lg: "py-16 px-8 gap-4";
|
|
9
|
+
};
|
|
10
|
+
bordered: {
|
|
11
|
+
true: "border border-dashed border-neutral-300 dark:border-neutral-800";
|
|
12
|
+
false: "border-0";
|
|
13
|
+
};
|
|
14
|
+
}, undefined, "flex flex-col items-center justify-center text-center p-8 rounded-2xl bg-white dark:bg-neutral-900/50", {
|
|
15
|
+
size: {
|
|
16
|
+
sm: "py-6 px-4 gap-2";
|
|
17
|
+
md: "py-10 px-6 gap-3";
|
|
18
|
+
lg: "py-16 px-8 gap-4";
|
|
19
|
+
};
|
|
20
|
+
bordered: {
|
|
21
|
+
true: "border border-dashed border-neutral-300 dark:border-neutral-800";
|
|
22
|
+
false: "border-0";
|
|
23
|
+
};
|
|
24
|
+
}, undefined, import("tailwind-variants").TVReturnTypeLike<{
|
|
25
|
+
size: {
|
|
26
|
+
sm: "py-6 px-4 gap-2";
|
|
27
|
+
md: "py-10 px-6 gap-3";
|
|
28
|
+
lg: "py-16 px-8 gap-4";
|
|
29
|
+
};
|
|
30
|
+
bordered: {
|
|
31
|
+
true: "border border-dashed border-neutral-300 dark:border-neutral-800";
|
|
32
|
+
false: "border-0";
|
|
33
|
+
};
|
|
34
|
+
}, undefined>>;
|
|
35
|
+
export type EmptyStateProps = VariantProps<typeof emptyStateVariants> & {
|
|
36
|
+
title?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
icon?: IconSource;
|
|
39
|
+
actions?: Snippet;
|
|
40
|
+
children?: Snippet;
|
|
41
|
+
class?: string;
|
|
42
|
+
};
|
|
43
|
+
declare const EmptyState: import("svelte").Component<EmptyStateProps, {}, "">;
|
|
44
|
+
type EmptyState = ReturnType<typeof EmptyState>;
|
|
45
|
+
export default EmptyState;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { emptyStateVariants } from './EmptyState.svelte';
|
|
3
|
+
describe('EmptyState Component', () => {
|
|
4
|
+
it('generates default variant classes', () => {
|
|
5
|
+
const classes = emptyStateVariants();
|
|
6
|
+
expect(classes).toContain('border-dashed');
|
|
7
|
+
expect(classes).toContain('py-10');
|
|
8
|
+
});
|
|
9
|
+
it('supports size and bordered variants', () => {
|
|
10
|
+
const smClasses = emptyStateVariants({ size: 'sm', bordered: false });
|
|
11
|
+
expect(smClasses).toContain('py-6');
|
|
12
|
+
expect(smClasses).toContain('border-0');
|
|
13
|
+
const lgClasses = emptyStateVariants({ size: 'lg' });
|
|
14
|
+
expect(lgClasses).toContain('py-16');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
'<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
|
99
99
|
twitter:
|
|
100
100
|
'<path d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
|
101
|
+
npm: '<path d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z" fill="currentColor"/>',
|
|
101
102
|
lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2" fill="none"/><path d="M7 11V7a5 5 0 0110 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
|
102
103
|
trash:
|
|
103
104
|
'<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import { tv, type VariantProps } from '../../utils/cn';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import type { IconSource } from '../elements/Icon.svelte';
|
|
5
|
+
|
|
6
|
+
export interface ComboboxOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
icon?: IconSource;
|
|
10
|
+
description?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const comboboxVariants = tv({
|
|
15
|
+
base: 'flex w-full items-center justify-between rounded-lg border bg-white text-neutral-900 transition-all duration-150 focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-900 dark:text-neutral-100',
|
|
16
|
+
variants: {
|
|
17
|
+
size: {
|
|
18
|
+
sm: 'h-9 px-3 text-xs gap-2',
|
|
19
|
+
md: 'h-10 px-3.5 text-sm gap-2.5',
|
|
20
|
+
lg: 'h-12 px-4 text-base gap-3'
|
|
21
|
+
},
|
|
22
|
+
status: {
|
|
23
|
+
default:
|
|
24
|
+
'border-neutral-300 focus:border-primary-500 focus:ring-primary-500/20 dark:border-neutral-700',
|
|
25
|
+
error:
|
|
26
|
+
'border-rose-500 text-rose-900 focus:border-rose-500 focus:ring-rose-500/20 dark:text-rose-100'
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
size: 'md',
|
|
31
|
+
status: 'default'
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type ComboboxProps = VariantProps<typeof comboboxVariants> & {
|
|
36
|
+
value?: string;
|
|
37
|
+
options?: ComboboxOption[];
|
|
38
|
+
placeholder?: string;
|
|
39
|
+
searchPlaceholder?: string;
|
|
40
|
+
icon?: IconSource;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
clearable?: boolean;
|
|
43
|
+
name?: string;
|
|
44
|
+
class?: string;
|
|
45
|
+
itemSnippet?: Snippet<[ComboboxOption, boolean]>;
|
|
46
|
+
};
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<script lang="ts">
|
|
50
|
+
import { Popover } from 'bits-ui';
|
|
51
|
+
import Icon from '../elements/Icon.svelte';
|
|
52
|
+
|
|
53
|
+
let {
|
|
54
|
+
value = $bindable(''),
|
|
55
|
+
options = [],
|
|
56
|
+
placeholder = 'Select an option...',
|
|
57
|
+
searchPlaceholder = 'Search options...',
|
|
58
|
+
icon,
|
|
59
|
+
disabled = false,
|
|
60
|
+
clearable = true,
|
|
61
|
+
name,
|
|
62
|
+
size = 'md',
|
|
63
|
+
status = 'default',
|
|
64
|
+
class: className = '',
|
|
65
|
+
itemSnippet
|
|
66
|
+
}: ComboboxProps = $props();
|
|
67
|
+
|
|
68
|
+
let open = $state(false);
|
|
69
|
+
let query = $state('');
|
|
70
|
+
let highlightedIndex = $state(0);
|
|
71
|
+
|
|
72
|
+
let selectedOption = $derived(options.find((opt) => opt.value === value));
|
|
73
|
+
|
|
74
|
+
let filteredOptions = $derived.by(() => {
|
|
75
|
+
if (!query.trim()) return options;
|
|
76
|
+
const q = query.toLowerCase();
|
|
77
|
+
return options.filter(
|
|
78
|
+
(opt) =>
|
|
79
|
+
opt.label.toLowerCase().includes(q) ||
|
|
80
|
+
opt.value.toLowerCase().includes(q) ||
|
|
81
|
+
opt.description?.toLowerCase().includes(q)
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function handleSelect(option: ComboboxOption) {
|
|
86
|
+
if (option.disabled) return;
|
|
87
|
+
value = option.value;
|
|
88
|
+
open = false;
|
|
89
|
+
query = '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleClear(e: MouseEvent) {
|
|
93
|
+
e.stopPropagation();
|
|
94
|
+
value = '';
|
|
95
|
+
query = '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
99
|
+
if (!open) {
|
|
100
|
+
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
open = true;
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (e.key === 'ArrowDown') {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
highlightedIndex = (highlightedIndex + 1) % Math.max(1, filteredOptions.length);
|
|
110
|
+
} else if (e.key === 'ArrowUp') {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
highlightedIndex =
|
|
113
|
+
(highlightedIndex - 1 + filteredOptions.length) % Math.max(1, filteredOptions.length);
|
|
114
|
+
} else if (e.key === 'Enter') {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
const opt = filteredOptions[highlightedIndex];
|
|
117
|
+
if (opt) handleSelect(opt);
|
|
118
|
+
} else if (e.key === 'Escape') {
|
|
119
|
+
open = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
{#if name}
|
|
125
|
+
<input type="hidden" {name} {value} />
|
|
126
|
+
{/if}
|
|
127
|
+
|
|
128
|
+
<Popover.Root bind:open>
|
|
129
|
+
<Popover.Trigger
|
|
130
|
+
{disabled}
|
|
131
|
+
class={comboboxVariants({ size, status, class: className })}
|
|
132
|
+
aria-haspopup="listbox"
|
|
133
|
+
aria-expanded={open}
|
|
134
|
+
>
|
|
135
|
+
<div class="flex flex-1 items-center gap-2 overflow-hidden text-left">
|
|
136
|
+
{#if icon}
|
|
137
|
+
<Icon name={icon} size={size === 'sm' ? 'xs' : 'sm'} class="shrink-0 text-neutral-400" />
|
|
138
|
+
{:else if selectedOption?.icon}
|
|
139
|
+
<Icon
|
|
140
|
+
name={selectedOption.icon}
|
|
141
|
+
size={size === 'sm' ? 'xs' : 'sm'}
|
|
142
|
+
class="shrink-0 text-neutral-400"
|
|
143
|
+
/>
|
|
144
|
+
{/if}
|
|
145
|
+
|
|
146
|
+
{#if selectedOption}
|
|
147
|
+
<span class="truncate font-medium">{selectedOption.label}</span>
|
|
148
|
+
{:else}
|
|
149
|
+
<span class="truncate text-neutral-400 dark:text-neutral-500">{placeholder}</span>
|
|
150
|
+
{/if}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
154
|
+
{#if clearable && value && !disabled}
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onclick={handleClear}
|
|
158
|
+
class="rounded p-0.5 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
|
|
159
|
+
aria-label="Clear selection"
|
|
160
|
+
>
|
|
161
|
+
<Icon name="cross" size="xs" />
|
|
162
|
+
</button>
|
|
163
|
+
{/if}
|
|
164
|
+
<Icon
|
|
165
|
+
name="chevron-down"
|
|
166
|
+
size="xs"
|
|
167
|
+
class="text-neutral-400 transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
</Popover.Trigger>
|
|
171
|
+
|
|
172
|
+
<Popover.Portal>
|
|
173
|
+
<Popover.Content
|
|
174
|
+
sideOffset={6}
|
|
175
|
+
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 w-(--bits-popover-anchor-width) min-w-[200px] overflow-hidden rounded-xl border border-neutral-200 bg-white p-1.5 shadow-xl dark:border-neutral-800 dark:bg-neutral-900"
|
|
176
|
+
>
|
|
177
|
+
<!-- Search Input -->
|
|
178
|
+
<div
|
|
179
|
+
class="flex items-center border-b border-neutral-100 px-2.5 pb-1.5 dark:border-neutral-800"
|
|
180
|
+
>
|
|
181
|
+
<Icon name="search" size="xs" class="mr-2 shrink-0 text-neutral-400" />
|
|
182
|
+
<input
|
|
183
|
+
type="text"
|
|
184
|
+
bind:value={query}
|
|
185
|
+
placeholder={searchPlaceholder}
|
|
186
|
+
onkeydown={handleKeydown}
|
|
187
|
+
class="h-8 w-full bg-transparent text-xs text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white"
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<!-- Options List -->
|
|
192
|
+
<div class="mt-1 max-h-56 overflow-y-auto" role="listbox">
|
|
193
|
+
{#if filteredOptions.length === 0}
|
|
194
|
+
<div class="py-6 text-center text-xs text-neutral-400 dark:text-neutral-500">
|
|
195
|
+
No options match "{query}".
|
|
196
|
+
</div>
|
|
197
|
+
{:else}
|
|
198
|
+
{#each filteredOptions as option, index}
|
|
199
|
+
{@const isSelected = option.value === value}
|
|
200
|
+
{@const isHighlighted = index === highlightedIndex}
|
|
201
|
+
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
role="option"
|
|
205
|
+
aria-selected={isSelected}
|
|
206
|
+
disabled={option.disabled}
|
|
207
|
+
onclick={() => handleSelect(option)}
|
|
208
|
+
class="flex w-full items-center justify-between rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors {isSelected
|
|
209
|
+
? 'bg-primary-50 font-semibold text-primary-900 dark:bg-primary-950/60 dark:text-primary-100'
|
|
210
|
+
: isHighlighted
|
|
211
|
+
? 'bg-neutral-100 text-neutral-900 dark:bg-neutral-800/60 dark:text-white'
|
|
212
|
+
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/40'} {option.disabled
|
|
213
|
+
? 'cursor-not-allowed opacity-40'
|
|
214
|
+
: ''}"
|
|
215
|
+
>
|
|
216
|
+
{#if itemSnippet}
|
|
217
|
+
{@render itemSnippet(option, isSelected)}
|
|
218
|
+
{:else}
|
|
219
|
+
<div class="flex items-center gap-2 overflow-hidden">
|
|
220
|
+
{#if option.icon}
|
|
221
|
+
<Icon name={option.icon} size="xs" class="shrink-0 text-neutral-400" />
|
|
222
|
+
{/if}
|
|
223
|
+
<div>
|
|
224
|
+
<div>{option.label}</div>
|
|
225
|
+
{#if option.description}
|
|
226
|
+
<div class="text-[10px] text-neutral-400 dark:text-neutral-500">
|
|
227
|
+
{option.description}
|
|
228
|
+
</div>
|
|
229
|
+
{/if}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{#if isSelected}
|
|
234
|
+
<Icon name="check" size="xs" class="text-primary-600 dark:text-primary-400" />
|
|
235
|
+
{/if}
|
|
236
|
+
{/if}
|
|
237
|
+
</button>
|
|
238
|
+
{/each}
|
|
239
|
+
{/if}
|
|
240
|
+
</div>
|
|
241
|
+
</Popover.Content>
|
|
242
|
+
</Popover.Portal>
|
|
243
|
+
</Popover.Root>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type VariantProps } from '../../utils/cn';
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { IconSource } from '../elements/Icon.svelte';
|
|
4
|
+
export interface ComboboxOption {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
icon?: IconSource;
|
|
8
|
+
description?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare const comboboxVariants: import("tailwind-variants").TVReturnType<{
|
|
12
|
+
size: {
|
|
13
|
+
sm: "h-9 px-3 text-xs gap-2";
|
|
14
|
+
md: "h-10 px-3.5 text-sm gap-2.5";
|
|
15
|
+
lg: "h-12 px-4 text-base gap-3";
|
|
16
|
+
};
|
|
17
|
+
status: {
|
|
18
|
+
default: "border-neutral-300 focus:border-primary-500 focus:ring-primary-500/20 dark:border-neutral-700";
|
|
19
|
+
error: "border-rose-500 text-rose-900 focus:border-rose-500 focus:ring-rose-500/20 dark:text-rose-100";
|
|
20
|
+
};
|
|
21
|
+
}, undefined, "flex w-full items-center justify-between rounded-lg border bg-white text-neutral-900 transition-all duration-150 focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-900 dark:text-neutral-100", {
|
|
22
|
+
size: {
|
|
23
|
+
sm: "h-9 px-3 text-xs gap-2";
|
|
24
|
+
md: "h-10 px-3.5 text-sm gap-2.5";
|
|
25
|
+
lg: "h-12 px-4 text-base gap-3";
|
|
26
|
+
};
|
|
27
|
+
status: {
|
|
28
|
+
default: "border-neutral-300 focus:border-primary-500 focus:ring-primary-500/20 dark:border-neutral-700";
|
|
29
|
+
error: "border-rose-500 text-rose-900 focus:border-rose-500 focus:ring-rose-500/20 dark:text-rose-100";
|
|
30
|
+
};
|
|
31
|
+
}, undefined, import("tailwind-variants").TVReturnTypeLike<{
|
|
32
|
+
size: {
|
|
33
|
+
sm: "h-9 px-3 text-xs gap-2";
|
|
34
|
+
md: "h-10 px-3.5 text-sm gap-2.5";
|
|
35
|
+
lg: "h-12 px-4 text-base gap-3";
|
|
36
|
+
};
|
|
37
|
+
status: {
|
|
38
|
+
default: "border-neutral-300 focus:border-primary-500 focus:ring-primary-500/20 dark:border-neutral-700";
|
|
39
|
+
error: "border-rose-500 text-rose-900 focus:border-rose-500 focus:ring-rose-500/20 dark:text-rose-100";
|
|
40
|
+
};
|
|
41
|
+
}, undefined>>;
|
|
42
|
+
export type ComboboxProps = VariantProps<typeof comboboxVariants> & {
|
|
43
|
+
value?: string;
|
|
44
|
+
options?: ComboboxOption[];
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
searchPlaceholder?: string;
|
|
47
|
+
icon?: IconSource;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
clearable?: boolean;
|
|
50
|
+
name?: string;
|
|
51
|
+
class?: string;
|
|
52
|
+
itemSnippet?: Snippet<[ComboboxOption, boolean]>;
|
|
53
|
+
};
|
|
54
|
+
declare const Combobox: import("svelte").Component<ComboboxProps, {}, "value">;
|
|
55
|
+
type Combobox = ReturnType<typeof Combobox>;
|
|
56
|
+
export default Combobox;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { comboboxVariants } from './Combobox.svelte';
|
|
3
|
+
describe('Combobox Component', () => {
|
|
4
|
+
it('generates default variant classes', () => {
|
|
5
|
+
const classes = comboboxVariants();
|
|
6
|
+
expect(classes).toContain('h-10');
|
|
7
|
+
expect(classes).toContain('border-neutral-300');
|
|
8
|
+
});
|
|
9
|
+
it('supports different sizes and error status', () => {
|
|
10
|
+
const smClasses = comboboxVariants({ size: 'sm', status: 'error' });
|
|
11
|
+
expect(smClasses).toContain('h-9');
|
|
12
|
+
expect(smClasses).toContain('border-rose-500');
|
|
13
|
+
const lgClasses = comboboxVariants({ size: 'lg' });
|
|
14
|
+
expect(lgClasses).toContain('h-12');
|
|
15
|
+
});
|
|
16
|
+
});
|