@karbonjs/ui-svelte 0.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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +30 -0
  3. package/src/accordion/Accordion.svelte +63 -0
  4. package/src/alert/AlertMessage.svelte +44 -0
  5. package/src/avatar/Avatar.svelte +48 -0
  6. package/src/badge/Badge.svelte +24 -0
  7. package/src/breadcrumb/Breadcrumb.svelte +34 -0
  8. package/src/button/Button.svelte +89 -0
  9. package/src/carousel/Carousel.svelte +118 -0
  10. package/src/data/DataTable.svelte +18 -0
  11. package/src/data/Pagination.svelte +45 -0
  12. package/src/divider/Divider.svelte +27 -0
  13. package/src/dropdown/Dropdown.svelte +61 -0
  14. package/src/form/Checkbox.svelte +51 -0
  15. package/src/form/ColorPicker.svelte +95 -0
  16. package/src/form/DatePicker.svelte +196 -0
  17. package/src/form/FormInput.svelte +174 -0
  18. package/src/form/Radio.svelte +54 -0
  19. package/src/form/Select.svelte +73 -0
  20. package/src/form/Slider.svelte +74 -0
  21. package/src/form/Textarea.svelte +86 -0
  22. package/src/form/Toggle.svelte +55 -0
  23. package/src/image/Image.svelte +89 -0
  24. package/src/image/ImgZoom.svelte +96 -0
  25. package/src/index.ts +71 -0
  26. package/src/kbd/Kbd.svelte +19 -0
  27. package/src/layout/Card.svelte +67 -0
  28. package/src/layout/EmptyState.svelte +25 -0
  29. package/src/layout/PageHeader.svelte +27 -0
  30. package/src/overlay/Dialog.svelte +135 -0
  31. package/src/overlay/ImgBox.svelte +174 -0
  32. package/src/overlay/Modal.svelte +98 -0
  33. package/src/overlay/Toast.svelte +92 -0
  34. package/src/progress/Progress.svelte +50 -0
  35. package/src/skeleton/Skeleton.svelte +50 -0
  36. package/src/tabs/Tabs.svelte +59 -0
  37. package/src/tooltip/Tooltip.svelte +49 -0
@@ -0,0 +1,51 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ name: string
4
+ checked?: boolean
5
+ indeterminate?: boolean
6
+ label?: string
7
+ description?: string
8
+ disabled?: boolean
9
+ class?: string
10
+ onchange?: (e: Event) => void
11
+ }
12
+
13
+ let {
14
+ name,
15
+ checked = $bindable(false),
16
+ indeterminate = false,
17
+ label = '',
18
+ description = '',
19
+ disabled = false,
20
+ class: className = '',
21
+ onchange
22
+ }: Props = $props()
23
+
24
+ let inputEl: HTMLInputElement
25
+
26
+ $effect(() => {
27
+ if (inputEl) inputEl.indeterminate = indeterminate
28
+ })
29
+ </script>
30
+
31
+ <label class="inline-flex items-start gap-3 {disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} {className}">
32
+ <input
33
+ bind:this={inputEl}
34
+ type="checkbox"
35
+ {name}
36
+ bind:checked
37
+ {disabled}
38
+ {onchange}
39
+ class="mt-0.5 h-4 w-4 shrink-0 rounded border-[var(--karbon-border-input,rgba(255,255,255,0.10))] bg-[var(--karbon-bg-input,rgba(255,255,255,0.06))] text-[var(--karbon-primary)] focus:ring-2 focus:ring-[var(--karbon-primary)]/20 focus:ring-offset-0 transition-colors cursor-pointer disabled:cursor-not-allowed"
40
+ />
41
+ {#if label || description}
42
+ <div class="select-none">
43
+ {#if label}
44
+ <span class="text-sm font-medium text-[var(--karbon-text,#1a1635)]">{label}</span>
45
+ {/if}
46
+ {#if description}
47
+ <p class="text-xs text-[var(--karbon-text-3,#8e8aae)] mt-0.5">{description}</p>
48
+ {/if}
49
+ </div>
50
+ {/if}
51
+ </label>
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ name: string
4
+ value?: string
5
+ label?: string
6
+ presets?: string[]
7
+ disabled?: boolean
8
+ class?: string
9
+ onchange?: (e: Event) => void
10
+ }
11
+
12
+ const defaultPresets = [
13
+ '#cc1a1a', '#ef4444', '#f59e0b', '#22c55e', '#10b981',
14
+ '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#ec4899',
15
+ '#f43f5e', '#14b8a6', '#000000', '#6b7280', '#ffffff'
16
+ ]
17
+
18
+ let {
19
+ name,
20
+ value = $bindable('#cc1a1a'),
21
+ label = '',
22
+ presets = defaultPresets,
23
+ disabled = false,
24
+ class: className = '',
25
+ onchange
26
+ }: Props = $props()
27
+
28
+ let showPicker = $state(false)
29
+ </script>
30
+
31
+ <svelte:window onclick={() => showPicker = false} />
32
+
33
+ <div class="space-y-1.5 {className}">
34
+ {#if label}
35
+ <label for={name} class="text-sm font-medium text-[var(--karbon-text,#1a1635)] block mb-1.5">{label}</label>
36
+ {/if}
37
+
38
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
39
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
40
+ <div class="relative" onclick={(e) => e.stopPropagation()}>
41
+ <button
42
+ type="button"
43
+ onclick={() => { if (!disabled) showPicker = !showPicker }}
44
+ class="flex items-center gap-2.5 rounded-lg border border-[var(--karbon-border,rgba(0,0,0,0.07))] px-3 py-2 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:bg-[var(--karbon-nav-hover-bg,rgba(0,0,0,0.04))]"
45
+ {disabled}
46
+ >
47
+ <span
48
+ class="w-6 h-6 rounded-md border border-[var(--karbon-border,rgba(0,0,0,0.07))] shrink-0"
49
+ style="background: {value}"
50
+ ></span>
51
+ <span class="text-sm font-mono text-[var(--karbon-text-2,#5a567e)]">{value}</span>
52
+ </button>
53
+
54
+ <input type="hidden" {name} bind:value {onchange} />
55
+
56
+ {#if showPicker}
57
+ <div class="absolute z-50 mt-1 w-64 rounded-xl border border-[var(--karbon-border,rgba(0,0,0,0.07))] shadow-xl p-4 bg-[var(--karbon-bg-card,#fff)]">
58
+ <!-- Native color input -->
59
+ <div class="mb-3">
60
+ <input
61
+ type="color"
62
+ bind:value
63
+ class="w-full h-10 rounded-lg cursor-pointer border-0 p-0"
64
+ />
65
+ </div>
66
+
67
+ <!-- Hex input -->
68
+ <div class="mb-3">
69
+ <input
70
+ type="text"
71
+ bind:value
72
+ maxlength="7"
73
+ class="w-full rounded-lg border border-[var(--karbon-border,rgba(0,0,0,0.07))] px-3 py-1.5 text-sm font-mono text-center focus:outline-none focus:ring-2 focus:ring-[var(--karbon-primary)]/20 bg-[var(--karbon-bg-input,rgba(255,255,255,0.06))] text-[var(--karbon-text,#1a1635)]"
74
+ />
75
+ </div>
76
+
77
+ <!-- Presets -->
78
+ {#if presets.length > 0}
79
+ <div class="grid grid-cols-5 gap-1.5">
80
+ {#each presets as color}
81
+ <button
82
+ type="button"
83
+ onclick={() => value = color}
84
+ class="w-full aspect-square rounded-lg border transition-transform cursor-pointer hover:scale-110
85
+ {value === color ? 'border-[var(--karbon-primary)] ring-2 ring-[var(--karbon-primary)]/20 scale-110' : 'border-[var(--karbon-border,rgba(0,0,0,0.07))]'}"
86
+ style="background: {color}"
87
+ aria-label={color}
88
+ ></button>
89
+ {/each}
90
+ </div>
91
+ {/if}
92
+ </div>
93
+ {/if}
94
+ </div>
95
+ </div>
@@ -0,0 +1,196 @@
1
+ <script lang="ts">
2
+ import type { FormVariant } from '@karbonjs/ui-core'
3
+
4
+ interface Props {
5
+ name: string
6
+ value?: string
7
+ label?: string
8
+ error?: string
9
+ placeholder?: string
10
+ min?: string
11
+ max?: string
12
+ required?: boolean
13
+ disabled?: boolean
14
+ variant?: FormVariant
15
+ class?: string
16
+ onchange?: (e: Event) => void
17
+ }
18
+
19
+ let {
20
+ name,
21
+ value = $bindable(''),
22
+ label = '',
23
+ error = '',
24
+ placeholder = '',
25
+ min = '',
26
+ max = '',
27
+ required = false,
28
+ disabled = false,
29
+ variant = 'dark',
30
+ class: className = '',
31
+ onchange
32
+ }: Props = $props()
33
+
34
+ let showCalendar = $state(false)
35
+
36
+ const themes = {
37
+ dark: {
38
+ label: 'text-[11px] font-medium text-gray-500 uppercase tracking-wider',
39
+ input: 'border-white/8 bg-white/3 text-white focus:border-[var(--karbon-primary)]/50 focus:bg-white/5 focus:ring-[3px] focus:ring-[var(--karbon-primary)]/8',
40
+ error: 'text-red-400',
41
+ calendar: 'bg-[var(--karbon-bg-card,#0a0820)] border-white/10 text-white',
42
+ dayHover: 'hover:bg-white/10',
43
+ dayOther: 'text-gray-600'
44
+ },
45
+ light: {
46
+ label: 'text-sm font-medium text-gray-700',
47
+ input: 'border-gray-300 bg-white text-gray-900 focus:border-[var(--karbon-primary)] focus:ring-2 focus:ring-[var(--karbon-primary)]/20',
48
+ error: 'text-[var(--karbon-danger)]',
49
+ calendar: 'bg-white border-gray-200 text-gray-900',
50
+ dayHover: 'hover:bg-gray-100',
51
+ dayOther: 'text-gray-300'
52
+ }
53
+ } as const
54
+
55
+ const theme = $derived(themes[variant])
56
+
57
+ // Calendar state
58
+ const selectedDate = $derived(value ? new Date(value + 'T00:00:00') : null)
59
+ let viewYear = $state(selectedDate?.getFullYear() ?? new Date().getFullYear())
60
+ let viewMonth = $state(selectedDate?.getMonth() ?? new Date().getMonth())
61
+
62
+ const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
63
+ const dayNames = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di']
64
+
65
+ const calendarDays = $derived((() => {
66
+ const first = new Date(viewYear, viewMonth, 1)
67
+ const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate()
68
+ let startDay = first.getDay() - 1
69
+ if (startDay < 0) startDay = 6
70
+
71
+ const days: { date: number; month: number; year: number; current: boolean }[] = []
72
+
73
+ // Previous month padding
74
+ const prevLastDay = new Date(viewYear, viewMonth, 0).getDate()
75
+ for (let i = startDay - 1; i >= 0; i--) {
76
+ days.push({ date: prevLastDay - i, month: viewMonth - 1, year: viewYear, current: false })
77
+ }
78
+
79
+ // Current month
80
+ for (let i = 1; i <= lastDay; i++) {
81
+ days.push({ date: i, month: viewMonth, year: viewYear, current: true })
82
+ }
83
+
84
+ // Next month padding
85
+ const remaining = 42 - days.length
86
+ for (let i = 1; i <= remaining; i++) {
87
+ days.push({ date: i, month: viewMonth + 1, year: viewYear, current: false })
88
+ }
89
+
90
+ return days
91
+ })())
92
+
93
+ function prevMonth() {
94
+ if (viewMonth === 0) { viewMonth = 11; viewYear-- }
95
+ else viewMonth--
96
+ }
97
+
98
+ function nextMonth() {
99
+ if (viewMonth === 11) { viewMonth = 0; viewYear++ }
100
+ else viewMonth++
101
+ }
102
+
103
+ function selectDay(day: { date: number; month: number; year: number }) {
104
+ const m = day.month < 0 ? 11 : day.month > 11 ? 0 : day.month
105
+ const y = day.month < 0 ? day.year - 1 : day.month > 11 ? day.year + 1 : day.year
106
+ const d = String(day.date).padStart(2, '0')
107
+ const mo = String(m + 1).padStart(2, '0')
108
+ value = `${y}-${mo}-${d}`
109
+ showCalendar = false
110
+ }
111
+
112
+ function isSelected(day: { date: number; month: number; year: number }) {
113
+ if (!selectedDate) return false
114
+ const m = day.month < 0 ? 11 : day.month > 11 ? 0 : day.month
115
+ return selectedDate.getDate() === day.date && selectedDate.getMonth() === m && selectedDate.getFullYear() === day.year
116
+ }
117
+
118
+ function isToday(day: { date: number; month: number; year: number; current: boolean }) {
119
+ if (!day.current) return false
120
+ const now = new Date()
121
+ return day.date === now.getDate() && viewMonth === now.getMonth() && viewYear === now.getFullYear()
122
+ }
123
+
124
+ function formatDisplay(val: string): string {
125
+ if (!val) return ''
126
+ const d = new Date(val + 'T00:00:00')
127
+ return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
128
+ }
129
+ </script>
130
+
131
+ <svelte:window onclick={() => showCalendar = false} />
132
+
133
+ <div class="space-y-1.5 {className}">
134
+ {#if label}
135
+ <label for={name} class="{theme.label} block mb-1.5">{label}</label>
136
+ {/if}
137
+
138
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
139
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
140
+ <div class="relative" onclick={(e) => e.stopPropagation()}>
141
+ <button
142
+ type="button"
143
+ onclick={() => { if (!disabled) showCalendar = !showCalendar }}
144
+ class="w-full rounded-lg border px-3 py-2.5 md:py-3 text-[13px] md:text-sm text-left focus:outline-none transition-all {theme.input} {error ? 'border-red-500/50' : ''} {!value ? 'text-gray-500' : ''} cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
145
+ {disabled}
146
+ >
147
+ {value ? formatDisplay(value) : placeholder || 'Sélectionner une date'}
148
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>
149
+ </button>
150
+
151
+ <input type="hidden" {name} bind:value {required} {onchange} />
152
+
153
+ {#if showCalendar}
154
+ <div class="absolute z-50 mt-1 w-72 rounded-xl border shadow-xl p-3 {theme.calendar}">
155
+ <!-- Header -->
156
+ <div class="flex items-center justify-between mb-3">
157
+ <button type="button" onclick={prevMonth} aria-label="Mois précédent" class="p-1 rounded-lg {theme.dayHover} cursor-pointer">
158
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
159
+ </button>
160
+ <span class="text-sm font-semibold">{monthNames[viewMonth]} {viewYear}</span>
161
+ <button type="button" onclick={nextMonth} aria-label="Mois suivant" class="p-1 rounded-lg {theme.dayHover} cursor-pointer">
162
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
163
+ </button>
164
+ </div>
165
+
166
+ <!-- Day names -->
167
+ <div class="grid grid-cols-7 mb-1">
168
+ {#each dayNames as d}
169
+ <div class="text-center text-[10px] font-medium text-[var(--karbon-text-4,#b5b2cc)] py-1">{d}</div>
170
+ {/each}
171
+ </div>
172
+
173
+ <!-- Days grid -->
174
+ <div class="grid grid-cols-7">
175
+ {#each calendarDays as day}
176
+ <button
177
+ type="button"
178
+ onclick={() => selectDay(day)}
179
+ class="h-8 w-full rounded-lg text-xs font-medium transition-colors cursor-pointer
180
+ {isSelected(day) ? 'bg-[var(--karbon-primary)] text-white' : ''}
181
+ {isToday(day) && !isSelected(day) ? 'ring-1 ring-[var(--karbon-primary)]' : ''}
182
+ {!day.current ? theme.dayOther : ''}
183
+ {!isSelected(day) ? theme.dayHover : ''}"
184
+ >
185
+ {day.date}
186
+ </button>
187
+ {/each}
188
+ </div>
189
+ </div>
190
+ {/if}
191
+ </div>
192
+
193
+ {#if error}
194
+ <p class="text-xs {theme.error}">{error}</p>
195
+ {/if}
196
+ </div>
@@ -0,0 +1,174 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import type { FormVariant } from '@karbonjs/ui-core'
4
+
5
+ interface Props {
6
+ name: string
7
+ type?: 'text' | 'email' | 'password' | 'search' | 'tel' | 'url' | 'number'
8
+ value?: string
9
+ placeholder?: string
10
+ label?: string
11
+ error?: string
12
+ required?: boolean
13
+ disabled?: boolean
14
+ readonly?: boolean
15
+ autocomplete?: string
16
+ clearable?: boolean
17
+ icon?: Snippet
18
+ variant?: FormVariant
19
+ class?: string
20
+ inputClass?: string
21
+ labelClass?: string
22
+ wrapperClass?: string
23
+ oninput?: (e: Event) => void
24
+ onchange?: (e: Event) => void
25
+ onfocus?: (e: FocusEvent) => void
26
+ onblur?: (e: FocusEvent) => void
27
+ onkeydown?: (e: KeyboardEvent) => void
28
+ }
29
+
30
+ let {
31
+ name,
32
+ type = 'text',
33
+ value = $bindable(''),
34
+ placeholder = '',
35
+ label = '',
36
+ error = '',
37
+ required = false,
38
+ disabled = false,
39
+ readonly = false,
40
+ autocomplete = '',
41
+ clearable = false,
42
+ icon,
43
+ variant = 'dark',
44
+ class: className = '',
45
+ inputClass = '',
46
+ labelClass = '',
47
+ wrapperClass = '',
48
+ oninput,
49
+ onchange,
50
+ onfocus,
51
+ onblur,
52
+ onkeydown
53
+ }: Props = $props()
54
+
55
+ let focused = $state(false)
56
+ let showPassword = $state(false)
57
+
58
+ const isPassword = $derived(type === 'password')
59
+ const inputType = $derived(isPassword && showPassword ? 'text' : type)
60
+ const hasRightAction = $derived(isPassword || (clearable && value))
61
+ const hasIcon = $derived(!!icon)
62
+
63
+ function handleFocus(e: FocusEvent) {
64
+ focused = true
65
+ onfocus?.(e)
66
+ }
67
+
68
+ function handleBlur(e: FocusEvent) {
69
+ focused = false
70
+ onblur?.(e)
71
+ }
72
+
73
+ function handleClear() {
74
+ value = ''
75
+ }
76
+
77
+ const themes = {
78
+ dark: {
79
+ label: 'text-[11px] font-medium text-gray-500 uppercase tracking-wider',
80
+ input: 'border-white/8 bg-white/3 text-white placeholder-gray-700 focus:border-[var(--karbon-primary)]/50 focus:bg-white/5 focus:ring-[3px] focus:ring-[var(--karbon-primary)]/8',
81
+ icon: 'text-gray-600',
82
+ iconFocused: 'text-[var(--karbon-primary)]',
83
+ action: 'text-gray-600 hover:text-gray-400',
84
+ error: 'text-red-400',
85
+ glow: true
86
+ },
87
+ light: {
88
+ label: 'text-sm font-medium text-gray-700',
89
+ input: 'border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:border-[var(--karbon-primary)] focus:ring-2 focus:ring-[var(--karbon-primary)]/20',
90
+ icon: 'text-gray-400',
91
+ iconFocused: 'text-[var(--karbon-primary)]',
92
+ action: 'text-gray-400 hover:text-gray-600',
93
+ error: 'text-[var(--karbon-danger)]',
94
+ glow: false
95
+ }
96
+ } as const
97
+
98
+ const theme = $derived(themes[variant])
99
+ </script>
100
+
101
+ <div class="{wrapperClass || 'space-y-1.5'}">
102
+ {#if label}
103
+ <label for={name} class="{theme.label} block mb-1.5 {labelClass}">
104
+ {label}
105
+ </label>
106
+ {/if}
107
+
108
+ <div class="relative {className}">
109
+ {#if icon}
110
+ <div
111
+ class="absolute left-3 top-1/2 -translate-y-1/2 transition-colors {focused ? theme.iconFocused : theme.icon}"
112
+ class:z-10={variant === 'dark'}
113
+ >
114
+ {@render icon()}
115
+ </div>
116
+ {/if}
117
+
118
+ <input
119
+ id={name}
120
+ {name}
121
+ type={inputType}
122
+ bind:value
123
+ {placeholder}
124
+ {required}
125
+ {disabled}
126
+ {readonly}
127
+ {autocomplete}
128
+ {oninput}
129
+ {onchange}
130
+ {onkeydown}
131
+ onfocus={handleFocus}
132
+ onblur={handleBlur}
133
+ class="w-full rounded-lg border {hasIcon ? 'pl-9' : 'pl-3'} {hasRightAction ? 'pr-10' : 'pr-3'} py-2.5 md:py-3 text-[13px] md:text-sm focus:outline-none transition-all {theme.input} {error ? 'border-red-500/50' : ''} {variant === 'dark' ? 'relative z-[1]' : ''} {inputClass}"
134
+ />
135
+
136
+ {#if variant === 'dark' && theme.glow}
137
+ <div
138
+ class="absolute -inset-px rounded-lg opacity-0 transition-opacity duration-300 pointer-events-none"
139
+ class:opacity-100={focused}
140
+ style="background: linear-gradient(135deg, rgba(204, 26, 26, 0.1), transparent 50%);"
141
+ ></div>
142
+ {/if}
143
+
144
+ {#if isPassword}
145
+ <button
146
+ type="button"
147
+ onclick={() => showPassword = !showPassword}
148
+ aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
149
+ class="absolute right-3 top-1/2 -translate-y-1/2 {theme.action} transition-colors cursor-pointer z-10"
150
+ tabindex={-1}
151
+ >
152
+ {#if showPassword}
153
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>
154
+ {:else}
155
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
156
+ {/if}
157
+ </button>
158
+ {:else if clearable && value}
159
+ <button
160
+ type="button"
161
+ onclick={handleClear}
162
+ aria-label="Effacer"
163
+ class="absolute right-3 top-1/2 -translate-y-1/2 {theme.action} transition-colors cursor-pointer z-10"
164
+ tabindex={-1}
165
+ >
166
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
167
+ </button>
168
+ {/if}
169
+ </div>
170
+
171
+ {#if error}
172
+ <p class="text-xs {theme.error}">{error}</p>
173
+ {/if}
174
+ </div>
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import type { RadioOption, RadioDirection } from '@karbonjs/ui-core'
3
+
4
+ interface Props {
5
+ name: string
6
+ options: RadioOption[]
7
+ value?: string
8
+ label?: string
9
+ direction?: RadioDirection
10
+ disabled?: boolean
11
+ class?: string
12
+ onchange?: (e: Event) => void
13
+ }
14
+
15
+ let {
16
+ name,
17
+ options,
18
+ value = $bindable(''),
19
+ label = '',
20
+ direction = 'column',
21
+ disabled = false,
22
+ class: className = '',
23
+ onchange
24
+ }: Props = $props()
25
+ </script>
26
+
27
+ <fieldset class={className} {disabled}>
28
+ {#if label}
29
+ <legend class="text-sm font-medium text-[var(--karbon-text,#1a1635)] mb-2">{label}</legend>
30
+ {/if}
31
+
32
+ <div class="{direction === 'row' ? 'flex flex-wrap items-center gap-4' : 'flex flex-col gap-2.5'}">
33
+ {#each options as opt}
34
+ <label class="inline-flex items-start gap-2.5 {opt.disabled || disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}">
35
+ <input
36
+ type="radio"
37
+ {name}
38
+ value={opt.value}
39
+ checked={value === opt.value}
40
+ disabled={opt.disabled || disabled}
41
+ {onchange}
42
+ onchange={() => value = opt.value}
43
+ class="mt-0.5 h-4 w-4 shrink-0 border-[var(--karbon-border-input,rgba(255,255,255,0.10))] bg-[var(--karbon-bg-input,rgba(255,255,255,0.06))] text-[var(--karbon-primary)] focus:ring-2 focus:ring-[var(--karbon-primary)]/20 focus:ring-offset-0 transition-colors cursor-pointer disabled:cursor-not-allowed"
44
+ />
45
+ <div class="select-none">
46
+ <span class="text-sm font-medium text-[var(--karbon-text,#1a1635)]">{opt.label}</span>
47
+ {#if opt.description}
48
+ <p class="text-xs text-[var(--karbon-text-3,#8e8aae)] mt-0.5">{opt.description}</p>
49
+ {/if}
50
+ </div>
51
+ </label>
52
+ {/each}
53
+ </div>
54
+ </fieldset>
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import type { FormVariant, SelectOption } from '@karbonjs/ui-core'
3
+
4
+ interface Props {
5
+ name: string
6
+ options: SelectOption[]
7
+ value?: string
8
+ placeholder?: string
9
+ label?: string
10
+ error?: string
11
+ required?: boolean
12
+ disabled?: boolean
13
+ variant?: FormVariant
14
+ class?: string
15
+ onchange?: (e: Event) => void
16
+ }
17
+
18
+ let {
19
+ name,
20
+ options,
21
+ value = $bindable(''),
22
+ placeholder = '',
23
+ label = '',
24
+ error = '',
25
+ required = false,
26
+ disabled = false,
27
+ variant = 'dark',
28
+ class: className = '',
29
+ onchange
30
+ }: Props = $props()
31
+
32
+ const themes = {
33
+ dark: {
34
+ label: 'text-[11px] font-medium text-gray-500 uppercase tracking-wider',
35
+ select: 'border-white/8 bg-white/3 text-white focus:border-[var(--karbon-primary)]/50 focus:bg-white/5 focus:ring-[3px] focus:ring-[var(--karbon-primary)]/8',
36
+ error: 'text-red-400'
37
+ },
38
+ light: {
39
+ label: 'text-sm font-medium text-gray-700',
40
+ select: 'border-gray-300 bg-white text-gray-900 focus:border-[var(--karbon-primary)] focus:ring-2 focus:ring-[var(--karbon-primary)]/20',
41
+ error: 'text-[var(--karbon-danger)]'
42
+ }
43
+ } as const
44
+
45
+ const theme = $derived(themes[variant])
46
+ </script>
47
+
48
+ <div class="space-y-1.5 {className}">
49
+ {#if label}
50
+ <label for={name} class="{theme.label} block mb-1.5">{label}</label>
51
+ {/if}
52
+
53
+ <select
54
+ id={name}
55
+ {name}
56
+ bind:value
57
+ {required}
58
+ {disabled}
59
+ {onchange}
60
+ class="w-full rounded-lg border px-3 py-2.5 md:py-3 text-[13px] md:text-sm focus:outline-none transition-all appearance-none bg-[length:16px] bg-[right_0.75rem_center] bg-no-repeat bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%239ca3af%22 stroke-width=%222%22><path d=%22m6 9 6 6 6-6%22/></svg>')] {theme.select} {error ? 'border-red-500/50' : ''}"
61
+ >
62
+ {#if placeholder}
63
+ <option value="" disabled selected class="text-gray-500">{placeholder}</option>
64
+ {/if}
65
+ {#each options as opt}
66
+ <option value={opt.value} disabled={opt.disabled}>{opt.label}</option>
67
+ {/each}
68
+ </select>
69
+
70
+ {#if error}
71
+ <p class="text-xs {theme.error}">{error}</p>
72
+ {/if}
73
+ </div>