@karbonjs/ui-svelte 0.2.4 → 0.3.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 (40) hide show
  1. package/package.json +3 -2
  2. package/src/accordion/Accordion.svelte +198 -23
  3. package/src/alert/AlertMessage.svelte +114 -20
  4. package/src/avatar/Avatar.svelte +16 -2
  5. package/src/badge/Badge.svelte +99 -10
  6. package/src/breadcrumb/Breadcrumb.svelte +124 -13
  7. package/src/button/Button.svelte +106 -21
  8. package/src/button/ButtonBrand.svelte +229 -0
  9. package/src/carousel/Carousel.svelte +161 -28
  10. package/src/code/CodeBlock.svelte +323 -0
  11. package/src/data/DataTable.svelte +319 -8
  12. package/src/data/Pagination.svelte +168 -28
  13. package/src/divider/Divider.svelte +91 -10
  14. package/src/dropdown/Dropdown.svelte +171 -27
  15. package/src/editor/RichTextEditor.svelte +861 -107
  16. package/src/form/Checkbox.svelte +110 -18
  17. package/src/form/ColorPicker.svelte +28 -16
  18. package/src/form/DatePicker.svelte +20 -10
  19. package/src/form/{FormInput.svelte → Input.svelte} +41 -14
  20. package/src/form/Radio.svelte +86 -18
  21. package/src/form/Select.svelte +246 -33
  22. package/src/form/Slider.svelte +22 -7
  23. package/src/form/Textarea.svelte +53 -10
  24. package/src/form/Toggle.svelte +72 -18
  25. package/src/image/Image.svelte +6 -4
  26. package/src/image/ImageCompare.svelte +182 -0
  27. package/src/image/ImgZoom.svelte +131 -49
  28. package/src/index.ts +7 -1
  29. package/src/kbd/Kbd.svelte +4 -3
  30. package/src/layout/Card.svelte +12 -6
  31. package/src/layout/EmptyState.svelte +75 -8
  32. package/src/layout/PageHeader.svelte +111 -11
  33. package/src/overlay/Dialog.svelte +147 -67
  34. package/src/overlay/ImgBox.svelte +125 -21
  35. package/src/overlay/Modal.svelte +110 -28
  36. package/src/overlay/Toast.svelte +152 -55
  37. package/src/progress/Progress.svelte +137 -26
  38. package/src/skeleton/Skeleton.svelte +6 -4
  39. package/src/tabs/Tabs.svelte +133 -22
  40. package/src/tooltip/Tooltip.svelte +110 -20
@@ -1,73 +1,286 @@
1
1
  <script lang="ts">
2
- import type { FormVariant, SelectOption } from '@karbonjs/ui-core'
2
+ import type { Snippet } from 'svelte'
3
+ import type { FormVariant, FormInputVariant, SelectOption, SelectClasses, ButtonColor } from '@karbonjs/ui-core'
3
4
 
4
5
  interface Props {
5
6
  name: string
6
7
  options: SelectOption[]
7
8
  value?: string
9
+ values?: string[]
8
10
  placeholder?: string
9
11
  label?: string
10
12
  error?: string
13
+ errorIcon?: boolean
11
14
  required?: boolean
12
15
  disabled?: boolean
16
+ multiple?: boolean
17
+ searchable?: boolean
18
+ clearable?: boolean
13
19
  variant?: FormVariant
20
+ inputVariant?: FormInputVariant
21
+ color?: ButtonColor
22
+ classes?: SelectClasses
14
23
  class?: string
15
- onchange?: (e: Event) => void
24
+ onchange?: (value: string | string[]) => void
16
25
  }
17
26
 
18
27
  let {
19
28
  name,
20
29
  options,
21
30
  value = $bindable(''),
22
- placeholder = '',
31
+ values = $bindable([]),
32
+ placeholder = 'Selectionner...',
23
33
  label = '',
24
34
  error = '',
35
+ errorIcon = true,
25
36
  required = false,
26
37
  disabled = false,
38
+ multiple = false,
39
+ searchable = false,
40
+ clearable = false,
27
41
  variant = 'dark',
42
+ inputVariant = 'outlined',
43
+ color,
44
+ classes,
28
45
  class: className = '',
29
46
  onchange
30
47
  }: Props = $props()
31
48
 
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)]'
49
+ let open = $state(false)
50
+ let focused = $state(false)
51
+ let search = $state('')
52
+ let triggerEl: HTMLDivElement | undefined = $state()
53
+ let dropdownEl: HTMLDivElement | undefined = $state()
54
+
55
+ const focusColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
56
+ const isDark = $derived(variant === 'dark')
57
+
58
+ const filteredOptions = $derived(
59
+ searchable && search
60
+ ? options.filter(o => !o.disabled && o.label.toLowerCase().includes(search.toLowerCase()))
61
+ : options
62
+ )
63
+
64
+ const selectedLabel = $derived(() => {
65
+ if (multiple) {
66
+ return values.length ? `${values.length} selectionne${values.length > 1 ? 's' : ''}` : ''
67
+ }
68
+ const opt = options.find(o => o.value === value)
69
+ return opt?.label || ''
70
+ })
71
+
72
+ const selectedOptions = $derived(
73
+ multiple ? options.filter(o => values.includes(o.value)) : []
74
+ )
75
+
76
+ function toggle() {
77
+ if (disabled) return
78
+ open = !open
79
+ if (open) {
80
+ search = ''
81
+ focused = true
82
+ }
83
+ }
84
+
85
+ function selectOption(opt: SelectOption) {
86
+ if (opt.disabled) return
87
+ if (multiple) {
88
+ if (values.includes(opt.value)) {
89
+ values = values.filter(v => v !== opt.value)
90
+ } else {
91
+ values = [...values, opt.value]
92
+ }
93
+ onchange?.(values)
94
+ } else {
95
+ value = opt.value
96
+ open = false
97
+ onchange?.(value)
98
+ }
99
+ }
100
+
101
+ function removeChip(val: string) {
102
+ values = values.filter(v => v !== val)
103
+ onchange?.(values)
104
+ }
105
+
106
+ function clearAll() {
107
+ if (multiple) {
108
+ values = []
109
+ onchange?.(values)
110
+ } else {
111
+ value = ''
112
+ onchange?.('')
42
113
  }
43
- } as const
114
+ }
115
+
116
+ function handleClickOutside(e: MouseEvent) {
117
+ if (triggerEl && !triggerEl.contains(e.target as Node) && dropdownEl && !dropdownEl.contains(e.target as Node)) {
118
+ open = false
119
+ focused = false
120
+ }
121
+ }
122
+
123
+ function handleKeydown(e: KeyboardEvent) {
124
+ if (e.key === 'Escape') {
125
+ open = false
126
+ focused = false
127
+ }
128
+ }
44
129
 
45
- const theme = $derived(themes[variant])
130
+ const variantClasses = $derived.by(() => {
131
+ if (inputVariant === 'underline') return 'rounded-none bg-transparent'
132
+ if (inputVariant === 'filled') return isDark ? 'rounded-lg bg-white/8' : 'rounded-lg bg-gray-100'
133
+ return isDark ? 'rounded-lg border border-white/8 bg-white/3' : 'rounded-lg border border-gray-300 bg-white'
134
+ })
135
+
136
+ const inlineStyle = $derived.by(() => {
137
+ let s = ''
138
+ if (inputVariant === 'underline') {
139
+ const bc = focused ? focusColor : error ? 'var(--karbon-danger)' : isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.12)'
140
+ s = `border:none;border-bottom:1px solid ${bc};border-radius:0;`
141
+ } else if (focused) {
142
+ s = `border-color:${focusColor};box-shadow:0 0 0 3px color-mix(in srgb,${focusColor} 12%,transparent);`
143
+ }
144
+ return s
145
+ })
46
146
  </script>
47
147
 
48
- <div class="space-y-1.5 {className}">
148
+ <svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
149
+
150
+ <div class="space-y-1.5 {classes?.root ?? ''} {className}">
49
151
  {#if label}
50
- <label for={name} class="{theme.label} block mb-1.5">{label}</label>
152
+ <span class="{isDark ? 'text-[11px] font-medium text-gray-500 uppercase tracking-wider' : 'text-sm font-medium text-gray-700'} block mb-1.5 {classes?.label ?? ''}">{label}</span>
51
153
  {/if}
52
154
 
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>
155
+ <!-- Hidden input for form submission -->
156
+ <input type="hidden" {name} value={multiple ? values.join(',') : value} />
157
+
158
+ <div class="relative">
159
+ <!-- Trigger -->
160
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
161
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
162
+ <div
163
+ bind:this={triggerEl}
164
+ onclick={toggle}
165
+ class="relative w-full min-h-[42px] md:min-h-[46px] flex items-center gap-2 px-3 py-2 text-[13px] md:text-sm cursor-pointer transition-all
166
+ {variantClasses}
167
+ {error ? 'border-red-500/50' : ''}
168
+ {disabled ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''}
169
+ {classes?.select ?? ''}"
170
+ style={inlineStyle}
171
+ >
172
+ <!-- Content -->
173
+ <div class="flex-1 flex flex-wrap items-center gap-1.5 min-w-0">
174
+ {#if multiple && selectedOptions.length > 0}
175
+ {#each selectedOptions as opt}
176
+ <span
177
+ class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium transition-colors"
178
+ style="background: color-mix(in srgb, {focusColor} 15%, transparent); color: {focusColor};"
179
+ >
180
+ {opt.label}
181
+ <button
182
+ type="button"
183
+ onclick={(e) => { e.stopPropagation(); removeChip(opt.value) }}
184
+ class="hover:opacity-70 cursor-pointer"
185
+ aria-label="Retirer {opt.label}"
186
+ >
187
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
188
+ </button>
189
+ </span>
190
+ {/each}
191
+ {:else if !multiple && selectedLabel()}
192
+ <span class="{isDark ? 'text-white' : 'text-gray-900'}">{selectedLabel()}</span>
193
+ {:else}
194
+ <span class="{isDark ? 'text-gray-600' : 'text-gray-400'}">{placeholder}</span>
195
+ {/if}
196
+ </div>
197
+
198
+ <!-- Actions -->
199
+ <div class="flex items-center gap-1 shrink-0">
200
+ {#if clearable && (value || values.length > 0)}
201
+ <button
202
+ type="button"
203
+ onclick={(e) => { e.stopPropagation(); clearAll() }}
204
+ class="{isDark ? 'text-gray-600 hover:text-gray-400' : 'text-gray-400 hover:text-gray-600'} transition-colors cursor-pointer"
205
+ aria-label="Tout effacer"
206
+ >
207
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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>
208
+ </button>
209
+ {/if}
210
+ <svg
211
+ xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
212
+ class="{isDark ? 'text-gray-600' : 'text-gray-400'} transition-transform duration-200 {open ? 'rotate-180' : ''}"
213
+ ><path d="m6 9 6 6 6-6"/></svg>
214
+ </div>
215
+ </div>
216
+
217
+ <!-- Dropdown -->
218
+ {#if open}
219
+ <div
220
+ bind:this={dropdownEl}
221
+ class="absolute z-50 mt-1 w-full rounded-lg shadow-xl overflow-hidden"
222
+ style="background: {isDark ? 'var(--karbon-bg-card, #1a1a2e)' : '#fff'}; border: 1px solid {isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'};"
223
+ >
224
+ {#if searchable}
225
+ <div class="p-2" style="border-bottom: 1px solid {isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'};">
226
+ <input
227
+ type="text"
228
+ bind:value={search}
229
+ placeholder="Rechercher..."
230
+ class="w-full px-2.5 py-1.5 text-xs rounded-md outline-none"
231
+ style="background: {isDark ? 'rgba(255,255,255,0.05)' : '#f3f4f6'}; color: {isDark ? 'white' : '#111'};"
232
+ />
233
+ </div>
234
+ {/if}
235
+
236
+ <div class="max-h-60 overflow-y-auto py-1">
237
+ {#if filteredOptions.length === 0}
238
+ <div class="px-3 py-4 text-center text-xs {isDark ? 'text-gray-600' : 'text-gray-400'}">
239
+ Aucun resultat
240
+ </div>
241
+ {:else}
242
+ {#each filteredOptions as opt}
243
+ {@const isSelected = multiple ? values.includes(opt.value) : value === opt.value}
244
+ <button
245
+ type="button"
246
+ onclick={() => selectOption(opt)}
247
+ disabled={opt.disabled}
248
+ class="w-full flex items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors cursor-pointer
249
+ {opt.disabled ? 'opacity-30 cursor-not-allowed' : ''}
250
+ {isSelected && !isDark ? 'bg-gray-50 font-medium' : ''}
251
+ {isSelected && isDark ? 'font-medium' : ''}"
252
+ style="color: {isDark ? (isSelected ? focusColor : 'rgba(255,255,255,0.8)') : (isSelected ? focusColor : '#374151')}; background: {isSelected ? `color-mix(in srgb, ${focusColor} 8%, transparent)` : 'transparent'};"
253
+ onmouseenter={(e) => { if (!opt.disabled) (e.currentTarget as HTMLElement).style.background = isDark ? 'rgba(255,255,255,0.05)' : '#f9fafb' }}
254
+ onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.background = isSelected ? `color-mix(in srgb, ${focusColor} 8%, transparent)` : 'transparent' }}
255
+ >
256
+ {#if multiple}
257
+ <div
258
+ class="w-4 h-4 shrink-0 rounded border flex items-center justify-center transition-colors"
259
+ style="border-color: {isSelected ? focusColor : isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'}; background: {isSelected ? focusColor : 'transparent'};"
260
+ >
261
+ {#if isSelected}
262
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
263
+ {/if}
264
+ </div>
265
+ {/if}
266
+ <span class="flex-1 truncate">{opt.label}</span>
267
+ {#if !multiple && isSelected}
268
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
269
+ {/if}
270
+ </button>
271
+ {/each}
272
+ {/if}
273
+ </div>
274
+ </div>
64
275
  {/if}
65
- {#each options as opt}
66
- <option value={opt.value} disabled={opt.disabled}>{opt.label}</option>
67
- {/each}
68
- </select>
276
+ </div>
69
277
 
70
278
  {#if error}
71
- <p class="text-xs {theme.error}">{error}</p>
279
+ <p class="flex items-center gap-1.5 text-xs {isDark ? 'text-red-400' : 'text-[var(--karbon-danger)]'} {classes?.error ?? ''}">
280
+ {#if errorIcon}
281
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
282
+ {/if}
283
+ <span>{error}</span>
284
+ </p>
72
285
  {/if}
73
286
  </div>
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import type { SliderClasses, ButtonColor } from '@karbonjs/ui-core'
3
+
2
4
  interface Props {
3
5
  name: string
4
6
  value?: number
@@ -8,6 +10,8 @@
8
10
  label?: string
9
11
  showValue?: boolean
10
12
  disabled?: boolean
13
+ color?: ButtonColor
14
+ classes?: SliderClasses
11
15
  class?: string
12
16
  oninput?: (e: Event) => void
13
17
  }
@@ -21,21 +25,24 @@
21
25
  label = '',
22
26
  showValue = true,
23
27
  disabled = false,
28
+ color,
29
+ classes,
24
30
  class: className = '',
25
31
  oninput
26
32
  }: Props = $props()
27
33
 
28
34
  const percent = $derived(((value - min) / (max - min)) * 100)
35
+ const trackColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
29
36
  </script>
30
37
 
31
- <div class="space-y-2 {className}">
38
+ <div class="space-y-2 {classes?.root ?? ''} {className}">
32
39
  {#if label || showValue}
33
40
  <div class="flex items-center justify-between">
34
41
  {#if label}
35
- <label for={name} class="text-sm font-medium text-[var(--karbon-text,#1a1635)]">{label}</label>
42
+ <label for={name} class="text-sm font-medium text-[var(--karbon-text,#1a1635)] {classes?.label ?? ''}">{label}</label>
36
43
  {/if}
37
44
  {#if showValue}
38
- <span class="text-sm font-semibold text-[var(--karbon-primary)] tabular-nums">{value}</span>
45
+ <span class="text-sm font-semibold tabular-nums {classes?.value ?? ''}" style="color: {trackColor}">{value}</span>
39
46
  {/if}
40
47
  </div>
41
48
  {/if}
@@ -51,12 +58,10 @@
51
58
  {disabled}
52
59
  {oninput}
53
60
  class="w-full h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed
54
- bg-[linear-gradient(to_right,var(--karbon-primary)_{percent}%,var(--karbon-border,rgba(0,0,0,0.07))_{percent}%)]
55
61
  [&::-webkit-slider-thumb]:appearance-none
56
62
  [&::-webkit-slider-thumb]:w-4.5
57
63
  [&::-webkit-slider-thumb]:h-4.5
58
64
  [&::-webkit-slider-thumb]:rounded-full
59
- [&::-webkit-slider-thumb]:bg-[var(--karbon-primary)]
60
65
  [&::-webkit-slider-thumb]:border-2
61
66
  [&::-webkit-slider-thumb]:border-white
62
67
  [&::-webkit-slider-thumb]:shadow-md
@@ -66,9 +71,19 @@
66
71
  [&::-moz-range-thumb]:w-4
67
72
  [&::-moz-range-thumb]:h-4
68
73
  [&::-moz-range-thumb]:rounded-full
69
- [&::-moz-range-thumb]:bg-[var(--karbon-primary)]
70
74
  [&::-moz-range-thumb]:border-2
71
75
  [&::-moz-range-thumb]:border-white
72
- [&::-moz-range-thumb]:shadow-md"
76
+ [&::-moz-range-thumb]:shadow-md
77
+ {classes?.input ?? ''}"
78
+ style="background: linear-gradient(to right, {trackColor} {percent}%, var(--karbon-border, rgba(0,0,0,0.07)) {percent}%); --thumb-color: {trackColor};"
73
79
  />
74
80
  </div>
81
+
82
+ <style>
83
+ input[type="range"]::-webkit-slider-thumb {
84
+ background-color: var(--thumb-color, var(--karbon-primary));
85
+ }
86
+ input[type="range"]::-moz-range-thumb {
87
+ background-color: var(--thumb-color, var(--karbon-primary));
88
+ }
89
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { FormVariant } from '@karbonjs/ui-core'
2
+ import type { FormVariant, FormInputVariant, TextareaClasses, ButtonColor } from '@karbonjs/ui-core'
3
3
 
4
4
  interface Props {
5
5
  name: string
@@ -7,6 +7,7 @@
7
7
  placeholder?: string
8
8
  label?: string
9
9
  error?: string
10
+ errorIcon?: boolean
10
11
  rows?: number
11
12
  maxlength?: number
12
13
  showCount?: boolean
@@ -14,6 +15,9 @@
14
15
  disabled?: boolean
15
16
  readonly?: boolean
16
17
  variant?: FormVariant
18
+ inputVariant?: FormInputVariant
19
+ color?: ButtonColor
20
+ classes?: TextareaClasses
17
21
  class?: string
18
22
  oninput?: (e: Event) => void
19
23
  }
@@ -24,6 +28,7 @@
24
28
  placeholder = '',
25
29
  label = '',
26
30
  error = '',
31
+ errorIcon = true,
27
32
  rows = 4,
28
33
  maxlength,
29
34
  showCount = false,
@@ -31,32 +36,62 @@
31
36
  disabled = false,
32
37
  readonly = false,
33
38
  variant = 'dark',
39
+ inputVariant = 'outlined',
40
+ color,
41
+ classes,
34
42
  class: className = '',
35
43
  oninput
36
44
  }: Props = $props()
37
45
 
46
+ let focused = $state(false)
47
+
48
+ const focusColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
49
+
38
50
  const themes = {
39
51
  dark: {
40
52
  label: 'text-[11px] font-medium text-gray-500 uppercase tracking-wider',
41
- 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',
53
+ base: 'text-white placeholder-gray-700',
42
54
  error: 'text-red-400',
43
- count: 'text-gray-600'
55
+ count: 'text-gray-600',
56
+ variants: {
57
+ outlined: 'border border-white/8 bg-white/3',
58
+ filled: 'border-0 bg-white/8',
59
+ underline: 'rounded-none bg-transparent'
60
+ }
44
61
  },
45
62
  light: {
46
63
  label: 'text-sm font-medium text-gray-700',
47
- 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',
64
+ base: 'text-gray-900 placeholder-gray-400',
48
65
  error: 'text-[var(--karbon-danger)]',
49
- count: 'text-gray-400'
66
+ count: 'text-gray-400',
67
+ variants: {
68
+ outlined: 'border border-gray-300 bg-white',
69
+ filled: 'border-0 bg-gray-100',
70
+ underline: 'rounded-none bg-transparent'
71
+ }
50
72
  }
51
73
  } as const
52
74
 
53
75
  const theme = $derived(themes[variant])
76
+ const variantClass = $derived(theme.variants[inputVariant])
54
77
  const charCount = $derived(value.length)
78
+
79
+ const inlineStyle = $derived.by(() => {
80
+ let s = ''
81
+ if (inputVariant === 'underline') {
82
+ const bc = focused ? focusColor : error ? 'var(--karbon-danger)' : 'var(--karbon-border-input)'
83
+ s += `border:none;border-bottom:1px solid ${bc};border-radius:0;`
84
+ if (focused) s += 'box-shadow:none;'
85
+ } else if (focused) {
86
+ s += `border-color:${focusColor};box-shadow:0 0 0 3px color-mix(in srgb,${focusColor} 12%,transparent);`
87
+ }
88
+ return s
89
+ })
55
90
  </script>
56
91
 
57
- <div class="space-y-1.5 {className}">
92
+ <div class="space-y-1.5 {classes?.root ?? ''} {className}">
58
93
  {#if label}
59
- <label for={name} class="{theme.label} block mb-1.5">{label}</label>
94
+ <label for={name} class="{theme.label} block mb-1.5 {classes?.label ?? ''}">{label}</label>
60
95
  {/if}
61
96
 
62
97
  <textarea
@@ -70,17 +105,25 @@
70
105
  {disabled}
71
106
  {readonly}
72
107
  {oninput}
73
- class="w-full rounded-lg border px-3 py-2.5 text-[13px] md:text-sm focus:outline-none transition-all resize-y {theme.input} {error ? 'border-red-500/50' : ''}"
108
+ onfocus={() => focused = true}
109
+ onblur={() => focused = false}
110
+ class="w-full {inputVariant !== 'underline' ? 'rounded-lg' : ''} px-3 py-2.5 text-[13px] md:text-sm focus:outline-none transition-all resize-y {theme.base} {variantClass} {error ? 'border-red-500/50' : ''} {disabled ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''} {classes?.textarea ?? ''}"
111
+ style={inlineStyle}
74
112
  ></textarea>
75
113
 
76
114
  <div class="flex items-center justify-between">
77
115
  {#if error}
78
- <p class="text-xs {theme.error}">{error}</p>
116
+ <p class="flex items-center gap-1.5 text-xs {theme.error} {classes?.error ?? ''}">
117
+ {#if errorIcon}
118
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
119
+ {/if}
120
+ <span>{error}</span>
121
+ </p>
79
122
  {:else}
80
123
  <span></span>
81
124
  {/if}
82
125
  {#if showCount && maxlength}
83
- <span class="text-xs {theme.count}">{charCount}/{maxlength}</span>
126
+ <span class="text-xs {theme.count} {classes?.count ?? ''}">{charCount}/{maxlength}</span>
84
127
  {/if}
85
128
  </div>
86
129
  </div>
@@ -1,48 +1,102 @@
1
1
  <script lang="ts">
2
- import type { ToggleSize } from '@karbonjs/ui-core'
2
+ import type { ToggleClasses, ButtonColor } from '@karbonjs/ui-core'
3
3
 
4
4
  interface Props {
5
5
  name: string
6
6
  checked?: boolean
7
7
  label?: string
8
- size?: ToggleSize
8
+ description?: string
9
+ size?: 'sm' | 'md' | 'lg'
9
10
  disabled?: boolean
11
+ color?: ButtonColor
12
+ showIcons?: boolean
13
+ classes?: ToggleClasses
10
14
  class?: string
11
- onchange?: (e: Event) => void
15
+ onchange?: (checked: boolean) => void
12
16
  }
13
17
 
14
18
  let {
15
19
  name,
16
20
  checked = $bindable(false),
17
21
  label = '',
22
+ description = '',
18
23
  size = 'md',
19
24
  disabled = false,
25
+ color,
26
+ showIcons = false,
27
+ classes,
20
28
  class: className = '',
21
29
  onchange
22
30
  }: Props = $props()
23
31
 
24
- const sizes = {
25
- sm: { track: 'w-8 h-[18px]', dot: 'h-3.5 w-3.5', translate: 'translate-x-3.5' },
26
- md: { track: 'w-10 h-[22px]', dot: 'h-4.5 w-4.5', translate: 'translate-x-4.5' }
27
- } as const
32
+ const trackColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
28
33
 
34
+ // pad = space between dot edge and track edge (both sides)
35
+ // translate = trackWidth - dotSize - (2 * pad)
36
+ const sizes = {
37
+ sm: { track: 'w-7 h-4', dot: 12, pad: 2, translate: 14, text: 'text-xs', desc: 'text-[11px]', iconSize: 7 },
38
+ md: { track: 'w-10 h-[22px]', dot: 16, pad: 3, translate: 19, text: 'text-sm', desc: 'text-xs', iconSize: 9 },
39
+ lg: { track: 'w-14 h-7', dot: 20, pad: 4, translate: 30, text: 'text-base', desc: 'text-sm', iconSize: 11 },
40
+ }
29
41
  const s = $derived(sizes[size])
42
+
43
+ function toggle() {
44
+ if (disabled) return
45
+ checked = !checked
46
+ onchange?.(checked)
47
+ }
48
+
49
+ function handleKeydown(e: KeyboardEvent) {
50
+ if (e.key === ' ' || e.key === 'Enter') {
51
+ e.preventDefault()
52
+ toggle()
53
+ }
54
+ }
30
55
  </script>
31
56
 
32
- <label class="inline-flex items-center gap-2.5 {disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} {className}">
33
- <input type="checkbox" {name} bind:checked {disabled} {onchange} class="sr-only peer" />
57
+ <input type="hidden" {name} value={checked ? 'on' : ''} />
58
+
59
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
60
+ <div
61
+ class="inline-flex items-center gap-2.5 {disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} {classes?.root ?? ''} {className}"
62
+ onclick={toggle}
63
+ onkeydown={handleKeydown}
64
+ role="switch"
65
+ aria-checked={checked}
66
+ aria-disabled={disabled}
67
+ tabindex={disabled ? -1 : 0}
68
+ >
69
+ <!-- Track -->
34
70
  <span
35
- class="relative inline-flex shrink-0 items-center rounded-full transition-colors duration-200
36
- {s.track}
37
- {checked ? 'bg-[var(--karbon-primary)]' : 'bg-[var(--karbon-border,rgba(0,0,0,0.07))]'}
38
- peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--karbon-primary)]/20"
39
- aria-hidden="true"
71
+ class="relative inline-flex shrink-0 items-center rounded-full transition-all duration-200 {s.track} {classes?.track ?? ''}"
72
+ style="background: {checked ? trackColor : 'var(--karbon-border, rgba(255,255,255,0.12))'};{checked ? `box-shadow: 0 0 8px color-mix(in srgb, ${trackColor} 30%, transparent);` : ''}"
40
73
  >
74
+ <!-- Icons inside track -->
75
+ {#if showIcons}
76
+ <span class="absolute left-1.5 top-1/2 -translate-y-1/2 transition-opacity duration-150 {checked ? 'opacity-100' : 'opacity-0'}">
77
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.iconSize} height={s.iconSize} viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
78
+ </span>
79
+ <span class="absolute right-1.5 top-1/2 -translate-y-1/2 transition-opacity duration-150 {checked ? 'opacity-0' : 'opacity-50'}">
80
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.iconSize} height={s.iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
81
+ </span>
82
+ {/if}
83
+
84
+ <!-- Dot -->
41
85
  <span
42
- class="inline-block rounded-full bg-white shadow-sm transition-transform duration-200 {s.dot} {checked ? s.translate : 'translate-x-0.5'}"
86
+ class="inline-block rounded-full bg-white shadow-sm transition-transform duration-200 {classes?.dot ?? ''}"
87
+ style="width: {s.dot}px; height: {s.dot}px; transform: translateX({checked ? s.translate : s.pad}px);"
43
88
  ></span>
44
89
  </span>
45
- {#if label}
46
- <span class="text-sm font-medium text-[var(--karbon-text,#1a1635)] select-none">{label}</span>
90
+
91
+ <!-- Label + description -->
92
+ {#if label || description}
93
+ <div class="select-none min-w-0">
94
+ {#if label}
95
+ <span class="{s.text} font-medium text-[var(--karbon-text)] {classes?.label ?? ''}">{label}</span>
96
+ {/if}
97
+ {#if description}
98
+ <p class="{s.desc} text-[var(--karbon-text-3)] mt-0.5 leading-relaxed">{description}</p>
99
+ {/if}
100
+ </div>
47
101
  {/if}
48
- </label>
102
+ </div>