@karbonjs/ui-svelte 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +321 -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 +862 -108
  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,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import type { CheckboxClasses, ButtonColor } from '@karbonjs/ui-core'
3
+
2
4
  interface Props {
3
5
  name: string
4
6
  checked?: boolean
@@ -6,8 +8,14 @@
6
8
  label?: string
7
9
  description?: string
8
10
  disabled?: boolean
11
+ color?: ButtonColor
12
+ size?: 'sm' | 'md' | 'lg'
13
+ shape?: 'square' | 'rounded' | 'circle'
14
+ icon?: 'check' | 'cross' | 'dash' | 'heart' | 'star' | 'circle' | 'eye'
15
+ variant?: 'filled' | 'outlined' | 'ghost' | 'elegant'
16
+ classes?: CheckboxClasses
9
17
  class?: string
10
- onchange?: (e: Event) => void
18
+ onchange?: (checked: boolean) => void
11
19
  }
12
20
 
13
21
  let {
@@ -17,35 +25,119 @@
17
25
  label = '',
18
26
  description = '',
19
27
  disabled = false,
28
+ color,
29
+ size = 'md',
30
+ shape = 'rounded',
31
+ icon = 'check',
32
+ variant = 'filled',
33
+ classes,
20
34
  class: className = '',
21
35
  onchange
22
36
  }: Props = $props()
23
37
 
24
- let inputEl: HTMLInputElement
38
+ const accentColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
39
+ const accentHover = $derived(color ? `var(--karbon-${color}-400)` : 'var(--karbon-primary-hover, var(--karbon-primary))')
40
+
41
+ const sizeMap = {
42
+ sm: { box: 'w-3.5 h-3.5', icon: 10, text: 'text-xs', desc: 'text-[11px]', gap: 'gap-2' },
43
+ md: { box: 'w-4.5 h-4.5', icon: 13, text: 'text-sm', desc: 'text-xs', gap: 'gap-2.5' },
44
+ lg: { box: 'w-5.5 h-5.5', icon: 16, text: 'text-base', desc: 'text-sm', gap: 'gap-3' },
45
+ }
46
+ const s = $derived(sizeMap[size])
47
+
48
+ const shapeMap = { square: 'rounded-none', rounded: 'rounded', circle: 'rounded-full' }
49
+ const shapeClass = $derived(shapeMap[shape])
50
+
51
+ const isActive = $derived(checked || indeterminate)
52
+
53
+ const boxStyle = $derived.by(() => {
54
+ switch (variant) {
55
+ case 'filled':
56
+ return `background:${isActive ? accentColor : 'var(--karbon-bg-input,rgba(255,255,255,0.06))'};border:${isActive ? 'none' : '1.5px solid var(--karbon-border-input,rgba(255,255,255,0.12))'};box-shadow:${isActive ? `0 0 0 2px color-mix(in srgb,${accentColor} 20%,transparent)` : 'none'};`
57
+ case 'outlined':
58
+ return `background:transparent;border:2px solid ${isActive ? accentColor : 'var(--karbon-border-input,rgba(255,255,255,0.12))'};box-shadow:none;`
59
+ case 'ghost':
60
+ return `background:transparent;border:none;box-shadow:none;`
61
+ case 'elegant':
62
+ return `background:${isActive ? `color-mix(in srgb,${accentColor} 12%,transparent)` : 'transparent'};border:1.5px solid ${isActive ? accentColor : 'var(--karbon-border-input,rgba(255,255,255,0.12))'};box-shadow:${isActive ? `0 0 8px color-mix(in srgb,${accentColor} 15%,transparent)` : 'none'};`
63
+ default: return ''
64
+ }
65
+ })
25
66
 
26
- $effect(() => {
27
- if (inputEl) inputEl.indeterminate = indeterminate
67
+ const iconColor = $derived.by(() => {
68
+ switch (variant) {
69
+ case 'filled': return 'white'
70
+ case 'outlined': return accentColor
71
+ case 'ghost': return isActive ? accentColor : 'var(--karbon-text-4)'
72
+ case 'elegant': return accentColor
73
+ default: return 'white'
74
+ }
28
75
  })
76
+
77
+ function toggle() {
78
+ if (disabled) return
79
+ checked = !checked
80
+ onchange?.(checked)
81
+ }
82
+
83
+ function handleKeydown(e: KeyboardEvent) {
84
+ if (e.key === ' ' || e.key === 'Enter') {
85
+ e.preventDefault()
86
+ toggle()
87
+ }
88
+ }
29
89
  </script>
30
90
 
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
- />
91
+ <!-- Hidden real input for form submission -->
92
+ <input type="hidden" {name} value={checked ? 'on' : ''} />
93
+
94
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
95
+ <div
96
+ class="inline-flex items-start {s.gap} {disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} {classes?.root ?? ''} {className}"
97
+ onclick={toggle}
98
+ onkeydown={handleKeydown}
99
+ role="checkbox"
100
+ aria-checked={indeterminate ? 'mixed' : checked}
101
+ aria-disabled={disabled}
102
+ tabindex={disabled ? -1 : 0}
103
+ >
104
+ <!-- Custom checkbox box -->
105
+ <div
106
+ class="shrink-0 {s.box} {shapeClass} flex items-center justify-center transition-all duration-150 mt-0.5"
107
+ style={boxStyle}
108
+ >
109
+ {#if indeterminate}
110
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.icon} height={s.icon} viewBox="0 0 24 24" fill="none" stroke={iconColor} stroke-width="3" stroke-linecap="round">
111
+ <line x1="5" y1="12" x2="19" y2="12"/>
112
+ </svg>
113
+ {:else if checked}
114
+ {#if icon === 'check'}
115
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.icon} height={s.icon} viewBox="0 0 24 24" fill="none" stroke={iconColor} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
116
+ {:else if icon === 'cross'}
117
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.icon} height={s.icon} viewBox="0 0 24 24" fill="none" stroke={iconColor} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
118
+ {:else if icon === 'dash'}
119
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.icon} height={s.icon} viewBox="0 0 24 24" fill="none" stroke={iconColor} stroke-width="3" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
120
+ {:else if icon === 'heart'}
121
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.icon} height={s.icon} viewBox="0 0 24 24" fill={iconColor} stroke="none"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
122
+ {:else if icon === 'star'}
123
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.icon} height={s.icon} viewBox="0 0 24 24" fill={iconColor} stroke="none"><path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z"/></svg>
124
+ {:else if icon === 'circle'}
125
+ <svg xmlns="http://www.w3.org/2000/svg" width={Math.round(s.icon * 0.6)} height={Math.round(s.icon * 0.6)} viewBox="0 0 24 24" fill={iconColor} stroke="none"><circle cx="12" cy="12" r="12"/></svg>
126
+ {:else if icon === 'eye'}
127
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.icon} height={s.icon} viewBox="0 0 24 24" fill="none" stroke={iconColor} stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
128
+ {/if}
129
+ {/if}
130
+ </div>
131
+
132
+ <!-- Label + description -->
41
133
  {#if label || description}
42
- <div class="select-none">
134
+ <div class="select-none min-w-0">
43
135
  {#if label}
44
- <span class="text-sm font-medium text-[var(--karbon-text,#1a1635)]">{label}</span>
136
+ <span class="{s.text} font-medium text-[var(--karbon-text)] {classes?.label ?? ''}">{label}</span>
45
137
  {/if}
46
138
  {#if description}
47
- <p class="text-xs text-[var(--karbon-text-3,#8e8aae)] mt-0.5">{description}</p>
139
+ <p class="{s.desc} text-[var(--karbon-text-3)] mt-0.5 leading-relaxed {classes?.description ?? ''}">{description}</p>
48
140
  {/if}
49
141
  </div>
50
142
  {/if}
51
- </label>
143
+ </div>
@@ -1,38 +1,47 @@
1
1
  <script lang="ts">
2
+ import type { ColorPickerClasses, ButtonColor } from '@karbonjs/ui-core'
3
+
4
+ const defaultPresets = [
5
+ '#cc1a1a', '#ef4444', '#f59e0b', '#22c55e', '#10b981',
6
+ '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#ec4899',
7
+ '#f43f5e', '#14b8a6', '#000000', '#6b7280', '#ffffff'
8
+ ]
9
+
2
10
  interface Props {
3
11
  name: string
4
12
  value?: string
5
13
  label?: string
6
14
  presets?: string[]
7
15
  disabled?: boolean
16
+ color?: ButtonColor
17
+ classes?: ColorPickerClasses
8
18
  class?: string
9
19
  onchange?: (e: Event) => void
10
20
  }
11
21
 
12
- const defaultPresets = [
13
- '#cc1a1a', '#ef4444', '#f59e0b', '#22c55e', '#10b981',
14
- '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#ec4899',
15
- '#f43f5e', '#14b8a6', '#000000', '#6b7280', '#ffffff'
16
- ]
17
-
18
22
  let {
19
23
  name,
20
24
  value = $bindable('#cc1a1a'),
21
25
  label = '',
22
26
  presets = defaultPresets,
23
27
  disabled = false,
28
+ color,
29
+ classes,
24
30
  class: className = '',
25
31
  onchange
26
32
  }: Props = $props()
27
33
 
28
34
  let showPicker = $state(false)
35
+ let hexFocused = $state(false)
36
+
37
+ const focusColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
29
38
  </script>
30
39
 
31
40
  <svelte:window onclick={() => showPicker = false} />
32
41
 
33
- <div class="space-y-1.5 {className}">
42
+ <div class="space-y-1.5 {classes?.root ?? ''} {className}">
34
43
  {#if label}
35
- <label for={name} class="text-sm font-medium text-[var(--karbon-text,#1a1635)] block mb-1.5">{label}</label>
44
+ <label for={name} class="text-sm font-medium text-[var(--karbon-text,#1a1635)] block mb-1.5 {classes?.label ?? ''}">{label}</label>
36
45
  {/if}
37
46
 
38
47
  <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -41,7 +50,7 @@
41
50
  <button
42
51
  type="button"
43
52
  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))]"
53
+ 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))] {classes?.trigger ?? ''}"
45
54
  {disabled}
46
55
  >
47
56
  <span
@@ -54,7 +63,7 @@
54
63
  <input type="hidden" {name} bind:value {onchange} />
55
64
 
56
65
  {#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)]">
66
+ <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)] {classes?.dropdown ?? ''}">
58
67
  <!-- Native color input -->
59
68
  <div class="mb-3">
60
69
  <input
@@ -70,21 +79,24 @@
70
79
  type="text"
71
80
  bind:value
72
81
  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)]"
82
+ onfocus={() => hexFocused = true}
83
+ onblur={() => hexFocused = false}
84
+ 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 bg-[var(--karbon-bg-input,rgba(255,255,255,0.06))] text-[var(--karbon-text,#1a1635)] {classes?.hexInput ?? ''}"
85
+ style={hexFocused ? `border-color: ${focusColor}; box-shadow: 0 0 0 3px color-mix(in srgb, ${focusColor} 12%, transparent);` : ''}
74
86
  />
75
87
  </div>
76
88
 
77
89
  <!-- Presets -->
78
90
  {#if presets.length > 0}
79
91
  <div class="grid grid-cols-5 gap-1.5">
80
- {#each presets as color}
92
+ {#each presets as presetColor}
81
93
  <button
82
94
  type="button"
83
- onclick={() => value = color}
95
+ onclick={() => value = presetColor}
84
96
  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}
97
+ {value === presetColor ? 'scale-110' : 'border-[var(--karbon-border,rgba(0,0,0,0.07))]'}"
98
+ style={value === presetColor ? `border-color: ${focusColor}; box-shadow: 0 0 0 2px color-mix(in srgb, ${focusColor} 20%, transparent); background: ${presetColor}` : `background: ${presetColor}`}
99
+ aria-label={presetColor}
88
100
  ></button>
89
101
  {/each}
90
102
  </div>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { FormVariant } from '@karbonjs/ui-core'
2
+ import type { FormVariant, DatePickerClasses, ButtonColor } from '@karbonjs/ui-core'
3
3
 
4
4
  interface Props {
5
5
  name: string
@@ -12,6 +12,8 @@
12
12
  required?: boolean
13
13
  disabled?: boolean
14
14
  variant?: FormVariant
15
+ color?: ButtonColor
16
+ classes?: DatePickerClasses
15
17
  class?: string
16
18
  onchange?: (e: Event) => void
17
19
  }
@@ -27,16 +29,21 @@
27
29
  required = false,
28
30
  disabled = false,
29
31
  variant = 'dark',
32
+ color,
33
+ classes,
30
34
  class: className = '',
31
35
  onchange
32
36
  }: Props = $props()
33
37
 
34
38
  let showCalendar = $state(false)
39
+ let focused = $state(false)
40
+
41
+ const focusColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
35
42
 
36
43
  const themes = {
37
44
  dark: {
38
45
  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',
46
+ input: 'border-white/8 bg-white/3 text-white',
40
47
  error: 'text-red-400',
41
48
  calendar: 'bg-[var(--karbon-bg-card,#0a0820)] border-white/10 text-white',
42
49
  dayHover: 'hover:bg-white/10',
@@ -44,7 +51,7 @@
44
51
  },
45
52
  light: {
46
53
  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',
54
+ input: 'border-gray-300 bg-white text-gray-900',
48
55
  error: 'text-[var(--karbon-danger)]',
49
56
  calendar: 'bg-white border-gray-200 text-gray-900',
50
57
  dayHover: 'hover:bg-gray-100',
@@ -137,9 +144,9 @@
137
144
 
138
145
  <svelte:window onclick={() => showCalendar = false} />
139
146
 
140
- <div class="space-y-1.5 {className}">
147
+ <div class="space-y-1.5 {classes?.root ?? ''} {className}">
141
148
  {#if label}
142
- <label for={name} class="{theme.label} block mb-1.5">{label}</label>
149
+ <label for={name} class="{theme.label} block mb-1.5 {classes?.label ?? ''}">{label}</label>
143
150
  {/if}
144
151
 
145
152
  <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -148,7 +155,10 @@
148
155
  <button
149
156
  type="button"
150
157
  onclick={() => { if (!disabled) showCalendar = !showCalendar }}
151
- 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"
158
+ onfocus={() => focused = true}
159
+ onblur={() => focused = false}
160
+ 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 {classes?.trigger ?? ''}"
161
+ style={focused ? `border-color: ${focusColor}; box-shadow: 0 0 0 3px color-mix(in srgb, ${focusColor} 12%, transparent);` : ''}
152
162
  {disabled}
153
163
  >
154
164
  {value ? formatDisplay(value) : placeholder || 'Sélectionner une date'}
@@ -158,7 +168,7 @@
158
168
  <input type="hidden" {name} bind:value {required} {onchange} />
159
169
 
160
170
  {#if showCalendar}
161
- <div class="absolute z-50 mt-1 w-72 rounded-xl border shadow-xl p-3 {theme.calendar}">
171
+ <div class="absolute z-50 mt-1 w-72 rounded-xl border shadow-xl p-3 {theme.calendar} {classes?.calendar ?? ''}">
162
172
  <!-- Header -->
163
173
  <div class="flex items-center justify-between mb-3">
164
174
  <button type="button" onclick={prevMonth} aria-label="Mois précédent" class="p-1 rounded-lg {theme.dayHover} cursor-pointer">
@@ -184,10 +194,10 @@
184
194
  type="button"
185
195
  onclick={() => selectDay(day)}
186
196
  class="h-8 w-full rounded-lg text-xs font-medium transition-colors cursor-pointer
187
- {isSelected(day) ? 'bg-[var(--karbon-primary)] text-white' : ''}
188
- {isToday(day) && !isSelected(day) ? 'ring-1 ring-[var(--karbon-primary)]' : ''}
197
+ {isToday(day) && !isSelected(day) ? 'ring-1' : ''}
189
198
  {!day.current ? theme.dayOther : ''}
190
199
  {!isSelected(day) ? theme.dayHover : ''}"
200
+ style={isSelected(day) ? `background-color: ${focusColor}; color: white;` : isToday(day) ? `--tw-ring-color: ${focusColor};` : ''}
191
201
  >
192
202
  {day.date}
193
203
  </button>
@@ -198,6 +208,6 @@
198
208
  </div>
199
209
 
200
210
  {#if error}
201
- <p class="text-xs {theme.error}">{error}</p>
211
+ <p class="text-xs {theme.error} {classes?.error ?? ''}">{error}</p>
202
212
  {/if}
203
213
  </div>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte'
3
- import type { FormVariant } from '@karbonjs/ui-core'
3
+ import type { FormVariant, FormInputVariant, FormInputClasses, ButtonColor } from '@karbonjs/ui-core'
4
4
 
5
5
  interface Props {
6
6
  name: string
@@ -9,6 +9,7 @@
9
9
  placeholder?: string
10
10
  label?: string
11
11
  error?: string
12
+ errorIcon?: boolean
12
13
  required?: boolean
13
14
  disabled?: boolean
14
15
  readonly?: boolean
@@ -16,6 +17,9 @@
16
17
  clearable?: boolean
17
18
  icon?: Snippet
18
19
  variant?: FormVariant
20
+ inputVariant?: FormInputVariant
21
+ color?: ButtonColor
22
+ classes?: FormInputClasses
19
23
  class?: string
20
24
  inputClass?: string
21
25
  labelClass?: string
@@ -34,6 +38,7 @@
34
38
  placeholder = '',
35
39
  label = '',
36
40
  error = '',
41
+ errorIcon = true,
37
42
  required = false,
38
43
  disabled = false,
39
44
  readonly = false,
@@ -41,6 +46,9 @@
41
46
  clearable = false,
42
47
  icon,
43
48
  variant = 'dark',
49
+ inputVariant = 'outlined',
50
+ color,
51
+ classes,
44
52
  class: className = '',
45
53
  inputClass = '',
46
54
  labelClass = '',
@@ -60,6 +68,9 @@
60
68
  const hasRightAction = $derived(isPassword || (clearable && value))
61
69
  const hasIcon = $derived(!!icon)
62
70
 
71
+ const focusColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
72
+ const focusRingColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
73
+
63
74
  function handleFocus(e: FocusEvent) {
64
75
  focused = true
65
76
  onfocus?.(e)
@@ -77,30 +88,39 @@
77
88
  const themes = {
78
89
  dark: {
79
90
  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
91
  icon: 'text-gray-600',
82
- iconFocused: 'text-[var(--karbon-primary)]',
83
92
  action: 'text-gray-600 hover:text-gray-400',
84
93
  error: 'text-red-400',
85
- glow: true
94
+ glow: true,
95
+ base: 'text-white placeholder-gray-700',
96
+ variants: {
97
+ outlined: 'border border-white/8 bg-white/3',
98
+ filled: 'border-0 bg-white/8',
99
+ underline: 'rounded-none bg-transparent'
100
+ }
86
101
  },
87
102
  light: {
88
103
  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
104
  icon: 'text-gray-400',
91
- iconFocused: 'text-[var(--karbon-primary)]',
92
105
  action: 'text-gray-400 hover:text-gray-600',
93
106
  error: 'text-[var(--karbon-danger)]',
94
- glow: false
107
+ glow: false,
108
+ base: 'text-gray-900 placeholder-gray-400',
109
+ variants: {
110
+ outlined: 'border border-gray-300 bg-white',
111
+ filled: 'border-0 bg-gray-100',
112
+ underline: 'rounded-none bg-transparent'
113
+ }
95
114
  }
96
115
  } as const
97
116
 
98
117
  const theme = $derived(themes[variant])
118
+ const variantClass = $derived(theme.variants[inputVariant])
99
119
  </script>
100
120
 
101
- <div class="{wrapperClass || 'space-y-1.5'}">
121
+ <div class="{classes?.root ?? ''} {wrapperClass || 'space-y-1.5'}">
102
122
  {#if label}
103
- <label for={name} class="{theme.label} block mb-1.5 {labelClass}">
123
+ <label for={name} class="{theme.label} block mb-1.5 {classes?.label ?? ''} {labelClass}">
104
124
  {label}
105
125
  </label>
106
126
  {/if}
@@ -108,8 +128,9 @@
108
128
  <div class="relative {className}">
109
129
  {#if icon}
110
130
  <div
111
- class="absolute left-3 top-1/2 -translate-y-1/2 transition-colors {focused ? theme.iconFocused : theme.icon}"
131
+ class="absolute left-3 top-1/2 -translate-y-1/2 transition-colors {classes?.icon ?? ''} {focused ? '' : theme.icon}"
112
132
  class:z-10={variant === 'dark'}
133
+ style={focused ? `color: ${focusColor}` : ''}
113
134
  >
114
135
  {@render icon()}
115
136
  </div>
@@ -130,14 +151,15 @@
130
151
  {onkeydown}
131
152
  onfocus={handleFocus}
132
153
  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}"
154
+ class="w-full {inputVariant !== 'underline' ? 'rounded-lg' : ''} {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.base} {variantClass} {error ? 'border-red-500/50' : ''} {disabled ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''} {variant === 'dark' ? 'relative z-[1]' : ''} {classes?.input ?? ''} {inputClass}"
155
+ style="{inputVariant === 'underline' ? 'border:none;border-bottom:1px solid ' + (focused ? focusColor : error ? 'var(--karbon-danger)' : 'var(--karbon-border-input)') + ';border-radius:0;' : ''}{focused ? (inputVariant === 'underline' ? 'box-shadow:none;' : 'border-color:' + focusColor + ';box-shadow:0 0 0 3px color-mix(in srgb,' + focusRingColor + ' 12%,transparent);') : ''}"
134
156
  />
135
157
 
136
- {#if variant === 'dark' && theme.glow}
158
+ {#if variant === 'dark' && theme.glow && inputVariant === 'outlined'}
137
159
  <div
138
160
  class="absolute -inset-px rounded-lg opacity-0 transition-opacity duration-300 pointer-events-none"
139
161
  class:opacity-100={focused}
140
- style="background: linear-gradient(135deg, rgba(204, 26, 26, 0.1), transparent 50%);"
162
+ style="background: linear-gradient(135deg, color-mix(in srgb, {focusColor} 10%, transparent), transparent 50%);"
141
163
  ></div>
142
164
  {/if}
143
165
 
@@ -169,6 +191,11 @@
169
191
  </div>
170
192
 
171
193
  {#if error}
172
- <p class="text-xs {theme.error}">{error}</p>
194
+ <p class="flex items-center gap-1.5 text-xs {theme.error} {classes?.error ?? ''}">
195
+ {#if errorIcon}
196
+ <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>
197
+ {/if}
198
+ <span>{error}</span>
199
+ </p>
173
200
  {/if}
174
201
  </div>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { RadioOption, RadioDirection } from '@karbonjs/ui-core'
2
+ import type { RadioOption, RadioDirection, RadioClasses, ButtonColor } from '@karbonjs/ui-core'
3
3
 
4
4
  interface Props {
5
5
  name: string
@@ -8,8 +8,12 @@
8
8
  label?: string
9
9
  direction?: RadioDirection
10
10
  disabled?: boolean
11
+ color?: ButtonColor
12
+ size?: 'sm' | 'md' | 'lg'
13
+ variant?: 'filled' | 'outlined' | 'ghost' | 'elegant'
14
+ classes?: RadioClasses
11
15
  class?: string
12
- onchange?: (e: Event) => void
16
+ onchange?: (value: string) => void
13
17
  }
14
18
 
15
19
  let {
@@ -19,35 +23,99 @@
19
23
  label = '',
20
24
  direction = 'column',
21
25
  disabled = false,
26
+ color,
27
+ size = 'md',
28
+ variant = 'filled',
29
+ classes,
22
30
  class: className = '',
23
31
  onchange
24
32
  }: Props = $props()
33
+
34
+ const accentColor = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
35
+
36
+ const sizeMap = {
37
+ sm: { box: 16, dot: 8, text: 'text-xs', desc: 'text-[11px]', gap: 'gap-2' },
38
+ md: { box: 20, dot: 11, text: 'text-sm', desc: 'text-xs', gap: 'gap-2.5' },
39
+ lg: { box: 24, dot: 13, text: 'text-base', desc: 'text-sm', gap: 'gap-3' },
40
+ }
41
+ const s = $derived(sizeMap[size])
42
+
43
+ function boxStyle(selected: boolean): string {
44
+ switch (variant) {
45
+ case 'filled':
46
+ return `background:${selected ? accentColor : 'var(--karbon-bg-input,rgba(255,255,255,0.06))'};border:${selected ? 'none' : '1.5px solid var(--karbon-border-input,rgba(255,255,255,0.12))'};box-shadow:${selected ? `0 0 0 2px color-mix(in srgb,${accentColor} 20%,transparent)` : 'none'};`
47
+ case 'outlined':
48
+ return `background:transparent;border:2px solid ${selected ? accentColor : 'var(--karbon-border-input,rgba(255,255,255,0.12))'};`
49
+ case 'ghost':
50
+ return `background:transparent;border:none;`
51
+ case 'elegant':
52
+ return `background:${selected ? `color-mix(in srgb,${accentColor} 12%,transparent)` : 'transparent'};border:1.5px solid ${selected ? accentColor : 'var(--karbon-border-input,rgba(255,255,255,0.12))'};box-shadow:${selected ? `0 0 8px color-mix(in srgb,${accentColor} 15%,transparent)` : 'none'};`
53
+ default: return ''
54
+ }
55
+ }
56
+
57
+ function dotColor(selected: boolean): string {
58
+ switch (variant) {
59
+ case 'filled': return 'white'
60
+ case 'outlined': return accentColor
61
+ case 'ghost': return selected ? accentColor : 'var(--karbon-text-4)'
62
+ case 'elegant': return accentColor
63
+ default: return 'white'
64
+ }
65
+ }
66
+
67
+ function select(val: string) {
68
+ if (disabled) return
69
+ value = val
70
+ onchange?.(val)
71
+ }
25
72
  </script>
26
73
 
27
- <fieldset class={className} {disabled}>
74
+ <fieldset class="{classes?.root ?? ''} {className}" {disabled}>
28
75
  {#if label}
29
- <legend class="text-sm font-medium text-[var(--karbon-text,#1a1635)] mb-2">{label}</legend>
76
+ <legend class="{s.text} font-medium text-[var(--karbon-text)] mb-2 {classes?.legend ?? ''}">{label}</legend>
30
77
  {/if}
31
78
 
32
79
  <div class="{direction === 'row' ? 'flex flex-wrap items-center gap-4' : 'flex flex-col gap-2.5'}">
33
80
  {#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={(e) => { value = opt.value; onchange?.(e) }}
42
- 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"
43
- />
44
- <div class="select-none">
45
- <span class="text-sm font-medium text-[var(--karbon-text,#1a1635)]">{opt.label}</span>
81
+ {@const selected = value === opt.value}
82
+ {@const isDisabled = opt.disabled || disabled}
83
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
84
+ <div
85
+ class="inline-flex items-center {s.gap} {isDisabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}"
86
+ onclick={() => { if (!isDisabled) select(opt.value) }}
87
+ onkeydown={(e) => { if (!isDisabled && (e.key === ' ' || e.key === 'Enter')) { e.preventDefault(); select(opt.value) } }}
88
+ role="radio"
89
+ aria-checked={selected}
90
+ aria-disabled={isDisabled}
91
+ tabindex={isDisabled ? -1 : 0}
92
+ >
93
+ <!-- Custom radio circle -->
94
+ <div
95
+ class="shrink-0 rounded-full transition-all duration-150"
96
+ style="width:{s.box}px;height:{s.box}px;display:grid;place-items:center;box-sizing:border-box;{boxStyle(selected)}"
97
+ >
98
+ {#if selected}
99
+ <div
100
+ class="rounded-full"
101
+ style="width:{s.dot}px;height:{s.dot}px;background:{dotColor(selected)};"
102
+ ></div>
103
+ {:else if variant === 'ghost'}
104
+ <div
105
+ class="rounded-full"
106
+ style="width:{s.dot}px;height:{s.dot}px;background:var(--karbon-border-input,rgba(255,255,255,0.12));"
107
+ ></div>
108
+ {/if}
109
+ </div>
110
+
111
+ <!-- Label + description -->
112
+ <div class="select-none min-w-0">
113
+ <span class="{s.text} font-medium text-[var(--karbon-text)] {classes?.label ?? ''}">{opt.label}</span>
46
114
  {#if opt.description}
47
- <p class="text-xs text-[var(--karbon-text-3,#8e8aae)] mt-0.5">{opt.description}</p>
115
+ <p class="{s.desc} text-[var(--karbon-text-3)] mt-0.5 leading-relaxed {classes?.description ?? ''}">{opt.description}</p>
48
116
  {/if}
49
117
  </div>
50
- </label>
118
+ </div>
51
119
  {/each}
52
120
  </div>
53
121
  </fieldset>