@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,32 +1,143 @@
1
1
  <script lang="ts">
2
- import type { BreadcrumbItem } from '@karbonjs/ui-core'
2
+ import type { ButtonColor } from '@karbonjs/ui-core'
3
+
4
+ interface BreadcrumbItem {
5
+ label: string
6
+ href?: string
7
+ icon?: string
8
+ }
3
9
 
4
10
  interface Props {
5
11
  items: BreadcrumbItem[]
6
- separator?: string
12
+ separator?: 'chevron' | 'slash' | 'dot' | 'arrow' | 'dash' | string
13
+ variant?: 'default' | 'pills' | 'bordered'
14
+ color?: ButtonColor
15
+ size?: 'sm' | 'md' | 'lg'
16
+ collapse?: number
7
17
  class?: string
18
+ classes?: { root?: string, item?: string, separator?: string, active?: string, link?: string }
8
19
  }
9
20
 
10
21
  let {
11
22
  items,
12
- separator = '/',
13
- class: className = ''
23
+ separator = 'chevron',
24
+ variant = 'default',
25
+ color,
26
+ size = 'md',
27
+ collapse = 0,
28
+ class: className = '',
29
+ classes = {}
14
30
  }: Props = $props()
31
+
32
+ function sanitizeSvg(html: string): string {
33
+ return html.replace(/on\w+\s*=/gi, '').replace(/<script/gi, '&lt;script')
34
+ }
35
+
36
+ const accent = $derived(color ? `var(--karbon-${color}-500)` : 'var(--karbon-primary)')
37
+
38
+ const sizeMap = {
39
+ sm: { text: 'text-xs', gap: 'gap-1', icon: 12, sepSize: 10, px: 'px-1.5 py-0.5' },
40
+ md: { text: 'text-sm', gap: 'gap-1.5', icon: 14, sepSize: 12, px: 'px-2 py-0.5' },
41
+ lg: { text: 'text-base', gap: 'gap-2', icon: 16, sepSize: 14, px: 'px-2.5 py-1' },
42
+ }
43
+ const s = $derived(sizeMap[size])
44
+
45
+ const separators: Record<string, string> = {
46
+ chevron: '<path d="m9 18 6-6-6-6"/>',
47
+ slash: '<line x1="16" y1="4" x2="8" y2="20"/>',
48
+ dot: '<circle cx="12" cy="12" r="3"/>',
49
+ arrow: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
50
+ dash: '<line x1="5" y1="12" x2="19" y2="12"/>',
51
+ }
52
+
53
+ // Collapse: show first, ellipsis, then last N items
54
+ const displayItems = $derived.by(() => {
55
+ if (collapse <= 0 || items.length <= collapse + 2) return items
56
+ return [
57
+ items[0],
58
+ { label: '...', href: undefined, icon: undefined } as BreadcrumbItem,
59
+ ...items.slice(-(collapse))
60
+ ]
61
+ })
62
+
63
+ let expanded = $state(false)
64
+ const finalItems = $derived(expanded ? items : displayItems)
15
65
  </script>
16
66
 
17
- <nav aria-label="Breadcrumb" class={className}>
18
- <ol class="flex items-center gap-1.5 text-sm">
19
- {#each items as item, i}
67
+ <nav aria-label="Breadcrumb" class="{classes?.root ?? className}">
68
+ <ol class="flex flex-wrap items-center {s.gap} {s.text}">
69
+ {#each finalItems as item, i}
70
+ {@const isLast = i === finalItems.length - 1}
71
+ {@const isEllipsis = item.label === '...'}
72
+
20
73
  {#if i > 0}
21
- <li class="text-[var(--karbon-text-4,#b5b2cc)] select-none" aria-hidden="true">{separator}</li>
74
+ <li class="select-none {classes?.separator ?? ''}" aria-hidden="true" style="color:var(--karbon-text-4);">
75
+ {#if separators[separator]}
76
+ <svg xmlns="http://www.w3.org/2000/svg" width={s.sepSize} height={s.sepSize} viewBox="0 0 24 24" fill={separator === 'dot' ? 'currentColor' : 'none'} stroke={separator === 'dot' ? 'none' : 'currentColor'} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{@html separators[separator]}</svg>
77
+ {:else}
78
+ <span>{separator}</span>
79
+ {/if}
80
+ </li>
22
81
  {/if}
23
- <li>
24
- {#if item.href && i < items.length - 1}
25
- <a href={item.href} class="text-[var(--karbon-text-3,#8e8aae)] hover:text-[var(--karbon-text,#1a1635)] transition-colors">
82
+
83
+ <li class="{classes?.item ?? ''}">
84
+ {#if isEllipsis}
85
+ <button
86
+ onclick={() => expanded = true}
87
+ class="rounded-md transition-colors cursor-pointer {s.px}"
88
+ style="color:var(--karbon-text-3);"
89
+ onmouseenter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--karbon-nav-hover-bg)' }}
90
+ onmouseleave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent' }}
91
+ aria-label="Afficher le chemin complet"
92
+ >···</button>
93
+ {:else if isLast}
94
+ <!-- Active (last) item -->
95
+ {#if variant === 'pills'}
96
+ <span
97
+ class="inline-flex items-center {s.gap} rounded-full font-medium {s.px}"
98
+ style="background:color-mix(in srgb,{accent} 15%,transparent);color:{accent};"
99
+ >
100
+ {#if item.icon}<span class="shrink-0">{@html sanitizeSvg(item.icon)}</span>{/if}
101
+ {item.label}
102
+ </span>
103
+ {:else if variant === 'bordered'}
104
+ <span
105
+ class="inline-flex items-center {s.gap} rounded-md font-medium {s.px}"
106
+ style="border:1px solid {accent};color:{accent};"
107
+ >
108
+ {#if item.icon}<span class="shrink-0">{@html sanitizeSvg(item.icon)}</span>{/if}
109
+ {item.label}
110
+ </span>
111
+ {:else}
112
+ <span
113
+ class="inline-flex items-center {s.gap} font-semibold {classes?.active ?? ''}"
114
+ style="color:{color ? accent : 'var(--karbon-text)'};"
115
+ >
116
+ {#if item.icon}<span class="shrink-0">{@html sanitizeSvg(item.icon)}</span>{/if}
117
+ {item.label}
118
+ </span>
119
+ {/if}
120
+ {:else}
121
+ <!-- Link item -->
122
+ <a
123
+ href={item.href || '#'}
124
+ class="inline-flex items-center {s.gap} transition-colors {classes?.link ?? ''} {variant === 'pills' ? `rounded-full ${s.px}` : ''} {variant === 'bordered' ? `rounded-md ${s.px}` : ''}"
125
+ style="color:var(--karbon-text-3);"
126
+ onmouseenter={(e) => {
127
+ const el = e.currentTarget as HTMLElement
128
+ el.style.color = color ? accent : 'var(--karbon-text)'
129
+ if (variant === 'pills') el.style.background = 'var(--karbon-nav-hover-bg)'
130
+ if (variant === 'bordered') el.style.background = 'var(--karbon-nav-hover-bg)'
131
+ }}
132
+ onmouseleave={(e) => {
133
+ const el = e.currentTarget as HTMLElement
134
+ el.style.color = 'var(--karbon-text-3)'
135
+ el.style.background = 'transparent'
136
+ }}
137
+ >
138
+ {#if item.icon}<span class="shrink-0">{@html sanitizeSvg(item.icon)}</span>{/if}
26
139
  {item.label}
27
140
  </a>
28
- {:else}
29
- <span class="text-[var(--karbon-text,#1a1635)] font-medium">{item.label}</span>
30
141
  {/if}
31
142
  </li>
32
143
  {/each}
@@ -1,10 +1,12 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte'
3
- import type { ButtonVariant, ButtonSize } from '@karbonjs/ui-core'
3
+ import type { ButtonVariant, ButtonSize, ButtonColor, ButtonShape } from '@karbonjs/ui-core'
4
4
 
5
5
  interface Props {
6
6
  variant?: ButtonVariant
7
7
  size?: ButtonSize
8
+ color?: ButtonColor
9
+ shape?: ButtonShape
8
10
  type?: 'button' | 'submit'
9
11
  disabled?: boolean
10
12
  loading?: boolean
@@ -12,13 +14,16 @@
12
14
  arrow?: boolean
13
15
  fullWidth?: boolean
14
16
  class?: string
17
+ classes?: { root?: string }
15
18
  onclick?: () => void
16
19
  children: Snippet
17
20
  }
18
21
 
19
22
  let {
20
- variant = 'primary',
23
+ variant = 'solid',
21
24
  size = 'md',
25
+ color,
26
+ shape = 'rounded',
22
27
  type = 'button',
23
28
  disabled = false,
24
29
  loading = false,
@@ -26,24 +31,83 @@
26
31
  arrow = false,
27
32
  fullWidth = false,
28
33
  class: className = '',
34
+ classes = {},
29
35
  onclick,
30
36
  children
31
37
  }: Props = $props()
32
38
 
33
39
  const isDisabled = $derived(disabled || loading)
34
40
 
35
- const variantClasses: Record<string, string> = {
36
- primary: 'bg-[var(--karbon-primary)] text-white hover:bg-[var(--karbon-primary-hover)] focus:ring-[var(--karbon-primary)]',
37
- secondary: 'bg-[var(--karbon-bg-2)] text-[var(--karbon-text-2)] hover:bg-[var(--karbon-border)] focus:ring-[var(--karbon-primary)]',
38
- danger: 'bg-[var(--karbon-danger)] text-white hover:bg-red-600 focus:ring-[var(--karbon-danger)]',
39
- ghost: 'text-[var(--karbon-text-3)] hover:bg-[var(--karbon-nav-hover-bg)] focus:ring-[var(--karbon-primary)]',
40
- outline: 'border border-[var(--karbon-border)] text-[var(--karbon-text-2)] hover:bg-[var(--karbon-nav-hover-bg)] focus:ring-[var(--karbon-primary)]'
41
+ function c(shade: number): string {
42
+ return color ? `var(--karbon-${color}-${shade})` : ''
41
43
  }
42
44
 
45
+ // Primary color references
46
+ const pri = $derived(color ? c(500) : 'var(--karbon-primary)')
47
+ const priHover = $derived(color ? c(600) : 'var(--karbon-primary-hover)')
48
+ const priLight = $derived(color ? c(400) : 'var(--karbon-primary)')
49
+ const priFg = $derived(color ? 'white' : 'var(--karbon-primary-foreground, white)')
50
+
51
+ // Build base + hover as CSS custom properties on the element
52
+ // This way hover never "loses" base styles
53
+ const style = $derived.by(() => {
54
+ let vars = ''
55
+ switch (variant) {
56
+ case 'solid':
57
+ vars = `--kb-bg:${pri};--kb-bg-h:${priHover};--kb-c:${priFg};--kb-c-h:${priFg};--kb-b:none;--kb-b-h:none;--kb-sh:none;--kb-sh-h:none`
58
+ break
59
+ case 'flat':
60
+ vars = `--kb-bg:color-mix(in srgb,${pri} 15%,transparent);--kb-bg-h:color-mix(in srgb,${pri} 25%,transparent);--kb-c:${priLight};--kb-c-h:${priLight};--kb-b:none;--kb-b-h:none;--kb-sh:none;--kb-sh-h:none`
61
+ break
62
+ case 'bordered':
63
+ vars = `--kb-bg:transparent;--kb-bg-h:color-mix(in srgb,${pri} 8%,transparent);--kb-c:${priLight};--kb-c-h:${priLight};--kb-b:2px solid ${priLight};--kb-b-h:2px solid ${pri};--kb-sh:none;--kb-sh-h:none`
64
+ break
65
+ case 'light':
66
+ vars = `--kb-bg:transparent;--kb-bg-h:color-mix(in srgb,${pri} 10%,transparent);--kb-c:${priLight};--kb-c-h:${pri};--kb-b:none;--kb-b-h:none;--kb-sh:none;--kb-sh-h:none`
67
+ break
68
+ case 'outline':
69
+ vars = `--kb-bg:transparent;--kb-bg-h:color-mix(in srgb,${pri} 6%,transparent);--kb-c:${priLight};--kb-c-h:${priLight};--kb-b:1px solid color-mix(in srgb,${priLight} 35%,transparent);--kb-b-h:1px solid ${priLight};--kb-sh:none;--kb-sh-h:none`
70
+ break
71
+ case 'ghost':
72
+ vars = color
73
+ ? `--kb-bg:transparent;--kb-bg-h:color-mix(in srgb,${pri} 10%,transparent);--kb-c:${priLight};--kb-c-h:${priLight};--kb-b:none;--kb-b-h:none;--kb-sh:none;--kb-sh-h:none`
74
+ : `--kb-bg:transparent;--kb-bg-h:var(--karbon-nav-hover-bg,rgba(255,255,255,0.05));--kb-c:var(--karbon-text-3);--kb-c-h:var(--karbon-text-2);--kb-b:none;--kb-b-h:none;--kb-sh:none;--kb-sh-h:none`
75
+ break
76
+ case 'shadow':
77
+ vars = `--kb-bg:${pri};--kb-bg-h:${priHover};--kb-c:${priFg};--kb-c-h:${priFg};--kb-b:none;--kb-b-h:none;--kb-sh:0 4px 14px 0 color-mix(in srgb,${pri} 40%,transparent);--kb-sh-h:0 6px 20px 0 color-mix(in srgb,${pri} 55%,transparent)`
78
+ break
79
+ }
80
+ return vars
81
+ })
82
+
43
83
  const sizeClasses: Record<string, string> = {
84
+ '2xs': 'px-1.5 py-0.5 text-[10px] rounded-sm',
85
+ xs: 'px-2.5 py-1 text-xs',
44
86
  sm: 'px-3 py-1.5 text-sm',
45
87
  md: 'px-4 py-2 text-sm',
46
- lg: 'px-6 py-3 text-base'
88
+ lg: 'px-5 py-2.5 text-base',
89
+ xl: 'px-6 py-3 text-base',
90
+ '2xl': 'px-8 py-3.5 text-lg',
91
+ '3xl': 'px-10 py-4 text-xl',
92
+ }
93
+
94
+ const circleSizeClasses: Record<string, string> = {
95
+ '2xs': 'w-5 h-5 text-[10px]',
96
+ xs: 'w-7 h-7 text-xs',
97
+ sm: 'w-8 h-8 text-sm',
98
+ md: 'w-10 h-10 text-sm',
99
+ lg: 'w-12 h-12 text-base',
100
+ xl: 'w-14 h-14 text-base',
101
+ '2xl': 'w-16 h-16 text-lg',
102
+ '3xl': 'w-20 h-20 text-xl',
103
+ }
104
+
105
+ const shapeClasses: Record<string, string> = {
106
+ sharp: 'rounded-none',
107
+ soft: 'rounded-md',
108
+ rounded: 'rounded-lg',
109
+ pill: 'rounded-full',
110
+ circle: 'rounded-full',
47
111
  }
48
112
  </script>
49
113
 
@@ -51,21 +115,21 @@
51
115
  {type}
52
116
  disabled={isDisabled}
53
117
  {onclick}
118
+ {style}
54
119
  class="
55
- inline-flex items-center justify-center font-semibold rounded-lg
56
- transition-all duration-300 ease-out
57
- focus:outline-none focus:ring-2 focus:ring-offset-0
58
- cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed
59
- {variantClasses[variant]}
60
- {arrow || fullWidth ? 'relative overflow-hidden py-3 md:py-3.5 px-4 text-[0.8125rem] md:text-sm' : sizeClasses[size]}
61
- {fullWidth ? 'w-full' : ''}
120
+ karbon-btn
121
+ inline-flex items-center justify-center gap-2 font-semibold
122
+ {shapeClasses[shape]}
123
+ transition-all duration-200 ease-out
124
+ focus-visible:outline-2 focus-visible:outline-offset-2
125
+ cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
126
+ {arrow ? 'relative overflow-hidden py-3 md:py-3.5 px-8 text-[0.8125rem] md:text-sm' : fullWidth ? 'w-full ' + sizeClasses[size] : shape === 'circle' ? circleSizeClasses[size] : sizeClasses[size]}
62
127
  {arrow ? 'group' : ''}
63
- active:enabled:scale-[0.97]
64
- {className}
128
+ {classes?.root ?? className}
65
129
  "
66
130
  >
67
131
  {#if arrow}
68
- <span class="flex items-center gap-2 transition-transform duration-300 group-hover:enabled:-translate-x-2.5">
132
+ <span class="flex items-center gap-2 transition-transform duration-300 group-hover:-translate-x-3">
69
133
  {#if loading}
70
134
  <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="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
71
135
  {#if loadingText}<span>{loadingText}</span>{/if}
@@ -74,16 +138,37 @@
74
138
  {/if}
75
139
  </span>
76
140
  {#if !loading}
77
- <span class="absolute right-4 flex items-center opacity-0 -translate-x-2 transition-all duration-300 group-hover:enabled:opacity-100 group-hover:enabled:translate-x-0">
141
+ <span class="absolute right-5 flex items-center opacity-0 translate-x-1 transition-all duration-300 group-hover:opacity-100 group-hover:translate-x-0">
78
142
  <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="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
79
143
  </span>
80
144
  {/if}
81
145
  {:else}
82
146
  {#if loading}
83
- <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="animate-spin mr-2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
147
+ <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="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
84
148
  {#if loadingText}<span>{loadingText}</span>{:else}{@render children()}{/if}
85
149
  {:else}
86
150
  {@render children()}
87
151
  {/if}
88
152
  {/if}
89
153
  </button>
154
+
155
+ <style>
156
+ .karbon-btn {
157
+ background: var(--kb-bg);
158
+ color: var(--kb-c);
159
+ border: var(--kb-b);
160
+ box-shadow: var(--kb-sh);
161
+ }
162
+ .karbon-btn:hover:not(:disabled) {
163
+ background: var(--kb-bg-h);
164
+ color: var(--kb-c-h);
165
+ border: var(--kb-b-h);
166
+ box-shadow: var(--kb-sh-h);
167
+ }
168
+ .karbon-btn:active:not(:disabled) {
169
+ transform: scale(0.97);
170
+ }
171
+ .karbon-btn:focus-visible {
172
+ outline-color: var(--kb-bg);
173
+ }
174
+ </style>
@@ -0,0 +1,229 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import type { BrandProvider, BrandButtonVariant } from '@karbonjs/ui-core'
4
+ import type { ButtonSize, ButtonShape } from '@karbonjs/ui-core'
5
+ import * as si from 'simple-icons'
6
+
7
+ interface Props {
8
+ brand: BrandProvider
9
+ variant?: BrandButtonVariant
10
+ size?: ButtonSize
11
+ shape?: ButtonShape
12
+ type?: 'button' | 'submit'
13
+ disabled?: boolean
14
+ loading?: boolean
15
+ loadingText?: string
16
+ fullWidth?: boolean
17
+ iconOnly?: boolean
18
+ class?: string
19
+ classes?: { root?: string }
20
+ onclick?: () => void
21
+ children?: Snippet
22
+ }
23
+
24
+ let {
25
+ brand,
26
+ variant = 'solid',
27
+ size = 'md',
28
+ shape = 'rounded',
29
+ type = 'button',
30
+ disabled = false,
31
+ loading = false,
32
+ loadingText = '',
33
+ fullWidth = false,
34
+ iconOnly = false,
35
+ class: className = '',
36
+ classes = {},
37
+ onclick,
38
+ children
39
+ }: Props = $props()
40
+
41
+ const isDisabled = $derived(disabled || loading)
42
+
43
+ // Map brand names to simple-icons slugs (only those that exist)
44
+ const slugMap: Record<string, string> = {
45
+ google: 'siGoogle', facebook: 'siFacebook', apple: 'siApple',
46
+ github: 'siGithub', gitlab: 'siGitlab', x: 'siX',
47
+ discord: 'siDiscord', reddit: 'siReddit',
48
+ twitch: 'siTwitch', youtube: 'siYoutube', tiktok: 'siTiktok',
49
+ instagram: 'siInstagram', snapchat: 'siSnapchat', pinterest: 'siPinterest',
50
+ spotify: 'siSpotify', netflix: 'siNetflix', hbo: 'siHbomax',
51
+ appletv: 'siAppletv', crunchyroll: 'siCrunchyroll',
52
+ steam: 'siSteam', playstation: 'siPlaystation', epicgames: 'siEpicgames',
53
+ stripe: 'siStripe', paypal: 'siPaypal',
54
+ figma: 'siFigma', notion: 'siNotion', vercel: 'siVercel', netlify: 'siNetlify',
55
+ }
56
+
57
+ // Fallback SVGs for brands not in simple-icons (24x24 viewBox)
58
+ const fallbackSvg: Record<string, string> = {
59
+ microsoft: '<rect x="1" y="1" width="10" height="10"/><rect x="13" y="1" width="10" height="10"/><rect x="1" y="13" width="10" height="10"/><rect x="13" y="13" width="10" height="10"/>',
60
+ twitter: '<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>',
61
+ slack: '<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>',
62
+ linkedin: '<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>',
63
+ disneyplus: '<path d="M1.57 8.793c-.192.14-.257.372-.115.588.167.253.488.318.717.14l6.946-5.126c.192-.14.245-.372.091-.588a.52.52 0 0 0-.142-.152.46.46 0 0 0-.575.012zm16.09 2.184c-.68-1.167-1.698-2.146-2.98-2.87-.744-.42-1.565-.742-2.438-.965l1.14-.825c.77-.557 1.052-1.42.68-2.082-.384-.662-1.321-.84-2.091-.283L2.505 10.88c-.77.558-1.052 1.421-.668 2.082.372.65 1.321.84 2.091.283l3.7-2.677c.744.15 1.437.396 2.065.72 1.744.906 2.864 2.478 2.864 4.205 0 2.735-2.812 4.944-6.275 4.944-1.18 0-2.297-.269-3.206-.751a.447.447 0 0 0-.614.163.464.464 0 0 0 .16.63c1.053.558 2.338.87 3.66.87 4.044 0 7.165-2.644 7.165-5.856 0-1.61-.82-3.09-2.184-4.212l2.132-1.543c.128-.093.2-.258.128-.395a.23.23 0 0 0-.064-.082z"/><circle cx="21.5" cy="8.5" r="1.5"/>',
64
+ primeVideo: '<path d="M1.285 11.953C.41 11.665 0 11.143 0 10.445c0-.476.182-.894.545-1.256.364-.362.79-.543 1.28-.543.42 0 .79.13 1.107.39.318.26.554.63.71 1.108l-1.003.38c-.076-.293-.19-.507-.34-.643a.73.73 0 0 0-.498-.203.783.783 0 0 0-.56.227.757.757 0 0 0-.235.564c0 .372.236.665.708.879zm4.638-2.558L8.17 9.22c-.158-.39-.37-.675-.635-.857a1.501 1.501 0 0 0-.88-.273c-.56 0-1.03.21-1.413.627-.384.419-.575.94-.575 1.563 0 .614.188 1.124.563 1.53.375.406.847.61 1.416.61.36 0 .672-.1.937-.297.265-.198.48-.5.645-.91l-2.23-.173.15-.886h3.38l-.006.115c-.058.846-.34 1.534-.845 2.063-.505.53-1.15.794-1.935.794-.866 0-1.582-.3-2.148-.9-.566-.6-.85-1.366-.85-2.299 0-.95.295-1.73.884-2.342.589-.611 1.327-.917 2.214-.917.646 0 1.2.166 1.665.498.464.332.8.81 1.007 1.434z" transform="translate(4 2) scale(1.2)"/>',
65
+ amazon: '<path d="M.045 18.02c.072-.116.187-.124.348-.024 2.862 1.773 6.093 2.66 9.693 2.66 2.602 0 5.145-.588 7.63-1.764.366-.173.674-.244.92-.211.246.032.373.171.381.415.007.244-.105.46-.337.647-.232.187-.586.424-1.062.71a16.87 16.87 0 0 1-2.742 1.279 16.76 16.76 0 0 1-5.028 1.025c-3.455.06-6.543-.88-9.262-2.822-.21-.153-.262-.308-.157-.467l.373-.448zm13.514-3.533c-.152-.195-.296-.151-.433.133-.137.284-.18.574-.13.87.05.294.207.523.47.685a9.532 9.532 0 0 0 1.768.887c1.032.39 1.69.642 1.976.755.286.113.462.183.53.21.264.1.503.122.717.062.213-.06.32-.222.32-.486v-.14c0-.41-.154-.78-.463-1.112-.31-.33-.765-.626-1.369-.886-.604-.26-1.024-.438-1.26-.534a17.86 17.86 0 0 0-2.127-.443z"/>',
66
+ xbox: '<path d="M6.97 3.846c-.987.567-1.878 1.323-2.604 2.222C2.9 8.088 2.3 10.6 2.9 12.87c.438 1.655 1.47 3.106 2.873 4.072.182-.22 1.263-1.685 3.243-4.593 1.78-2.613 2.282-3.64 2.282-4.282 0-.415-.106-.739-.354-1.175-.622-1.094-1.79-2.156-3.098-2.838-.287-.15-.598-.29-.876-.208zM12 2.04c-1.076 0-2.122.192-3.106.547 1.524.81 2.9 2.07 3.674 3.362.33.552.514 1.106.514 1.775 0 .97-.465 2.046-2.452 4.975-1.734 2.557-2.862 4.153-3.318 4.744.66.384 1.31.59 2.128.722.684.11 1.483.066 2.142-.052 2.688-.48 4.81-2.35 5.678-4.933.737-2.193.414-4.698-.88-6.737A7.893 7.893 0 0 0 12 2.04z"/><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 1.2c5.934 0 10.8 4.866 10.8 10.8S17.934 22.8 12 22.8 1.2 17.934 1.2 12 6.066 1.2 12 1.2z"/>',
67
+ nintendo: '<path d="M5.818 0A5.818 5.818 0 0 0 0 5.818v12.364A5.818 5.818 0 0 0 5.818 24h12.364A5.818 5.818 0 0 0 24 18.182V5.818A5.818 5.818 0 0 0 18.182 0H5.818zm0 1.745h4.91v20.51h-4.91a4.073 4.073 0 0 1-4.073-4.073V5.818a4.073 4.073 0 0 1 4.073-4.073zm8.509 0h3.855a4.073 4.073 0 0 1 4.073 4.073v12.364a4.073 4.073 0 0 1-4.073 4.073h-3.855V1.745zM7.636 6.545a3.273 3.273 0 1 0 0 6.546 3.273 3.273 0 0 0 0-6.546zm0 1.746a1.527 1.527 0 1 1 0 3.054 1.527 1.527 0 0 1 0-3.054zm8.728 2.182a1.745 1.745 0 1 0 0 3.49 1.745 1.745 0 0 0 0-3.49z"/>',
68
+ }
69
+
70
+ // Brand metadata (label + foreground override for light-colored brands)
71
+ const meta: Record<string, { label: string, fg?: string }> = {
72
+ google: { label: 'Google' }, facebook: { label: 'Facebook' }, apple: { label: 'Apple' },
73
+ microsoft: { label: 'Microsoft' }, github: { label: 'GitHub' }, gitlab: { label: 'GitLab' },
74
+ twitter: { label: 'Twitter' }, x: { label: 'X' }, discord: { label: 'Discord' },
75
+ slack: { label: 'Slack' }, linkedin: { label: 'LinkedIn' }, reddit: { label: 'Reddit' },
76
+ twitch: { label: 'Twitch' }, youtube: { label: 'YouTube' }, tiktok: { label: 'TikTok' },
77
+ instagram: { label: 'Instagram' }, snapchat: { label: 'Snapchat', fg: '#000' },
78
+ pinterest: { label: 'Pinterest' }, spotify: { label: 'Spotify', fg: '#000' },
79
+ netflix: { label: 'Netflix' }, disneyplus: { label: 'Disney+' }, hbo: { label: 'HBO Max' },
80
+ primeVideo: { label: 'Prime Video' }, appletv: { label: 'Apple TV+' },
81
+ crunchyroll: { label: 'Crunchyroll' }, steam: { label: 'Steam' },
82
+ playstation: { label: 'PlayStation' }, xbox: { label: 'Xbox' },
83
+ nintendo: { label: 'Nintendo' }, epicgames: { label: 'Epic Games' },
84
+ stripe: { label: 'Stripe' }, paypal: { label: 'PayPal' },
85
+ amazon: { label: 'Amazon', fg: '#000' }, figma: { label: 'Figma' },
86
+ notion: { label: 'Notion' }, vercel: { label: 'Vercel' },
87
+ netlify: { label: 'Netlify', fg: '#000' },
88
+ }
89
+
90
+ // Get icon data from simple-icons, fallback to custom SVG
91
+ const icon = $derived.by(() => {
92
+ const key = slugMap[brand]
93
+ if (key && key in si) {
94
+ const data = (si as any)[key] as { svg: string, hex: string, title: string }
95
+ return { svg: data.svg, hex: data.hex, viewBox: '0 0 24 24' }
96
+ }
97
+ if (fallbackSvg[brand]) {
98
+ return { svg: fallbackSvg[brand], hex: fallbackHex[brand] || '666666', viewBox: '0 0 24 24' }
99
+ }
100
+ return null
101
+ })
102
+
103
+ // Fallback hex colors for brands not in simple-icons
104
+ const fallbackHex: Record<string, string> = {
105
+ microsoft: '00A4EF', twitter: '1DA1F2', slack: '4A154B', linkedin: '0A66C2',
106
+ disneyplus: '113CCF', primeVideo: '00A8E1', amazon: 'FF9900',
107
+ xbox: '107C10', nintendo: 'E60012',
108
+ }
109
+
110
+ const brandLabel = $derived(meta[brand]?.label || brand)
111
+ const brandFg = $derived(meta[brand]?.fg || 'white')
112
+
113
+ const rawHex = $derived(icon ? `#${icon.hex}` : '#666')
114
+
115
+ // Lighten a hex color by mixing with white
116
+ function lighten(hex: string, amount: number): string {
117
+ const r = parseInt(hex.slice(1, 3), 16)
118
+ const g = parseInt(hex.slice(3, 5), 16)
119
+ const b = parseInt(hex.slice(5, 7), 16)
120
+ const lr = Math.round(r + (255 - r) * amount)
121
+ const lg = Math.round(g + (255 - g) * amount)
122
+ const lb = Math.round(b + (255 - b) * amount)
123
+ return `#${lr.toString(16).padStart(2,'0')}${lg.toString(16).padStart(2,'0')}${lb.toString(16).padStart(2,'0')}`
124
+ }
125
+
126
+ // Detect if brand color is too dark for dark backgrounds
127
+ function luminance(hex: string): number {
128
+ const r = parseInt(hex.slice(1, 3), 16) / 255
129
+ const g = parseInt(hex.slice(3, 5), 16) / 255
130
+ const b = parseInt(hex.slice(5, 7), 16) / 255
131
+ return 0.299 * r + 0.587 * g + 0.114 * b
132
+ }
133
+
134
+ const isDark = $derived(luminance(rawHex) < 0.25)
135
+ // For dark brands: lighten the color so it's visible on dark bg
136
+ const visibleColor = $derived(isDark ? lighten(rawHex, 0.6) : rawHex)
137
+ // Solid bg for dark brands: slightly lighter so it pops from the page bg
138
+ const solidBg = $derived(isDark ? lighten(rawHex, 0.15) : rawHex)
139
+ const solidBgHover = $derived(isDark ? lighten(rawHex, 0.25) : rawHex + 'cc')
140
+
141
+ const style = $derived.by(() => {
142
+ switch (variant) {
143
+ case 'solid':
144
+ return `--kb-bg:${solidBg};--kb-bg-h:${solidBgHover};--kb-c:${brandFg};--kb-c-h:${brandFg};--kb-b:none;--kb-b-h:none`
145
+ case 'outline':
146
+ return `--kb-bg:transparent;--kb-bg-h:${visibleColor}15;--kb-c:${visibleColor};--kb-c-h:${visibleColor};--kb-b:1px solid ${visibleColor}40;--kb-b-h:1px solid ${visibleColor}80`
147
+ case 'light':
148
+ return `--kb-bg:${visibleColor}15;--kb-bg-h:${visibleColor}25;--kb-c:${visibleColor};--kb-c-h:${visibleColor};--kb-b:none;--kb-b-h:none`
149
+ default: return ''
150
+ }
151
+ })
152
+
153
+ const sizeClasses: Record<string, string> = {
154
+ '2xs': 'px-1.5 py-0.5 text-[10px] gap-1',
155
+ xs: 'px-2.5 py-1 text-xs gap-1.5',
156
+ sm: 'px-3 py-1.5 text-sm gap-2',
157
+ md: 'px-4 py-2 text-sm gap-2',
158
+ lg: 'px-5 py-2.5 text-base gap-2.5',
159
+ xl: 'px-6 py-3 text-base gap-2.5',
160
+ '2xl': 'px-8 py-3.5 text-lg gap-3',
161
+ '3xl': 'px-10 py-4 text-xl gap-3',
162
+ }
163
+
164
+ const iconSizes: Record<string, number> = {
165
+ '2xs': 10, xs: 12, sm: 14, md: 16, lg: 18, xl: 20, '2xl': 22, '3xl': 24,
166
+ }
167
+
168
+ const shapeClasses: Record<string, string> = {
169
+ sharp: 'rounded-none',
170
+ soft: 'rounded-md',
171
+ rounded: 'rounded-lg',
172
+ pill: 'rounded-full',
173
+ circle: 'rounded-full',
174
+ }
175
+
176
+ const circleSizes: Record<string, string> = {
177
+ '2xs': 'w-5 h-5', xs: 'w-7 h-7', sm: 'w-8 h-8', md: 'w-10 h-10',
178
+ lg: 'w-12 h-12', xl: 'w-14 h-14', '2xl': 'w-16 h-16', '3xl': 'w-20 h-20',
179
+ }
180
+ </script>
181
+
182
+ <button
183
+ {type}
184
+ disabled={isDisabled}
185
+ {onclick}
186
+ {style}
187
+ class="
188
+ karbon-btn
189
+ inline-flex items-center justify-center font-semibold
190
+ {shapeClasses[shape]}
191
+ {shape === 'circle' && iconOnly ? circleSizes[size] : sizeClasses[size]}
192
+ transition-all duration-200 ease-out
193
+ cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
194
+ {fullWidth ? 'w-full' : ''}
195
+ {classes?.root ?? className}
196
+ "
197
+ >
198
+ {#if loading}
199
+ <svg xmlns="http://www.w3.org/2000/svg" width={iconSizes[size]} height={iconSizes[size]} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
200
+ {#if loadingText}<span>{loadingText}</span>{/if}
201
+ {:else}
202
+ {#if icon}
203
+ <svg xmlns="http://www.w3.org/2000/svg" width={iconSizes[size]} height={iconSizes[size]} viewBox={icon.viewBox} fill="currentColor" role="img" aria-label={brandLabel}>{@html icon.svg}</svg>
204
+ {/if}
205
+ {#if !iconOnly}
206
+ {#if children}
207
+ {@render children()}
208
+ {:else}
209
+ <span>{brandLabel}</span>
210
+ {/if}
211
+ {/if}
212
+ {/if}
213
+ </button>
214
+
215
+ <style>
216
+ .karbon-btn {
217
+ background: var(--kb-bg);
218
+ color: var(--kb-c);
219
+ border: var(--kb-b);
220
+ }
221
+ .karbon-btn:hover:not(:disabled) {
222
+ background: var(--kb-bg-h);
223
+ color: var(--kb-c-h);
224
+ border: var(--kb-b-h);
225
+ }
226
+ .karbon-btn:active:not(:disabled) {
227
+ transform: scale(0.97);
228
+ }
229
+ </style>