@kolkrabbi/kol-component 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +24 -0
  2. package/package.json +46 -0
  3. package/src/atoms/Avatar.jsx +17 -0
  4. package/src/atoms/Button.jsx +172 -0
  5. package/src/atoms/ColorSwatch.jsx +126 -0
  6. package/src/atoms/Divider.jsx +34 -0
  7. package/src/atoms/Input.jsx +121 -0
  8. package/src/atoms/Label.jsx +12 -0
  9. package/src/atoms/Slider.jsx +129 -0
  10. package/src/atoms/Stepper.jsx +97 -0
  11. package/src/atoms/Textarea.jsx +70 -0
  12. package/src/atoms/ToggleCheckbox.jsx +40 -0
  13. package/src/atoms/ToggleSwitch.jsx +42 -0
  14. package/src/atoms/TransparentX.jsx +37 -0
  15. package/src/graphics/Graphic.jsx +53 -0
  16. package/src/graphics/svg/abstract/abstract-01.svg +3 -0
  17. package/src/graphics/svg/abstract/abstract-02.svg +3 -0
  18. package/src/graphics/svg/abstract/abstract-03.svg +3 -0
  19. package/src/graphics/svg/abstract/abstract-06.svg +4 -0
  20. package/src/graphics/svg/diagrams/.gitkeep +0 -0
  21. package/src/graphics/svg/diagrams/ac-structure.svg +90 -0
  22. package/src/graphics/svg/diagrams/rg-x-height.svg +3 -0
  23. package/src/graphics/svg/diagrams/structure-grid.svg +618 -0
  24. package/src/graphics/svg/diagrams/structure-logo.svg +23 -0
  25. package/src/graphics/svg/patterns/.gitkeep +0 -0
  26. package/src/graphics/svg/patterns/patt-01.svg +34 -0
  27. package/src/graphics/svg/patterns/patt-02.svg +34 -0
  28. package/src/graphics/svg/patterns/patt-03.svg +110 -0
  29. package/src/graphics/svg/patterns/patt-04.svg +10 -0
  30. package/src/graphics/svg/patterns/patt-05.svg +62 -0
  31. package/src/graphics/svg/patterns/patt-06.svg +66 -0
  32. package/src/graphics/svg/patterns/patt-07.svg +130 -0
  33. package/src/graphics/svg/patterns/patt-08.svg +434 -0
  34. package/src/graphics/svg/patterns/patt-09.svg +38 -0
  35. package/src/graphics/svg/patterns/patt-10.svg +20 -0
  36. package/src/graphics/svg/patterns/patt-11.svg +38 -0
  37. package/src/graphics/svg/patterns/patt-12.svg +58 -0
  38. package/src/graphics/svg/patterns/patt-13.svg +48 -0
  39. package/src/graphics/svg/patterns/patt-14.svg +90 -0
  40. package/src/graphics/svg/patterns/patt-15.svg +194 -0
  41. package/src/graphics/svg/patterns/patt-16.svg +12 -0
  42. package/src/graphics/svg/social/social-01.svg +18 -0
  43. package/src/graphics/svg/social/social-02.svg +18 -0
  44. package/src/graphics/svg/social/social-03.svg +18 -0
  45. package/src/graphics/svg/social/social-04.svg +18 -0
  46. package/src/graphics/svg/social/social-05.svg +18 -0
  47. package/src/graphics/svg/social/social-06.svg +18 -0
  48. package/src/graphics/svg/social/social-07.svg +22 -0
  49. package/src/graphics/svg/social/social-08.svg +22 -0
  50. package/src/graphics/svg/social/social-09.svg +22 -0
  51. package/src/graphics/svg/social/social-10.svg +6 -0
  52. package/src/graphics/svg/social/social-11.svg +6 -0
  53. package/src/graphics/svg/social/social-12.svg +6 -0
  54. package/src/graphics/svg/social/social-13.svg +9 -0
  55. package/src/graphics/svg/social/social-14.svg +9 -0
  56. package/src/graphics/svg/social/social-15.svg +9 -0
  57. package/src/graphics/svg/structure/diagram-grid-lockup-hori.svg +23 -0
  58. package/src/graphics/svg/structure/diagram-grid-lockup-vert.svg +25 -0
  59. package/src/graphics/svg/structure/diagram-grid-logomark.svg +20 -0
  60. package/src/graphics/svg/structure/diagram-grid-wordmark.svg +18 -0
  61. package/src/graphics/svg/structure/diagram-logo-lockup-hori.svg +9 -0
  62. package/src/graphics/svg/structure/diagram-logo-lockup-vert.svg +23 -0
  63. package/src/graphics/svg/structure/diagram-logo-logomark.svg +7 -0
  64. package/src/graphics/svg/structure/diagram-logo-wordmark.svg +3 -0
  65. package/src/graphics/svg/structure/diagram-x-lockup-hori.svg +5 -0
  66. package/src/graphics/svg/structure/diagram-x-lockup-vert.svg +10 -0
  67. package/src/graphics/svg/structure/diagram-x-logomark.svg +5 -0
  68. package/src/graphics/svg/structure/diagram-x-wordmark.svg +5 -0
  69. package/src/hooks/useReveal.js +28 -0
  70. package/src/hooks/useScrollSpy.js +56 -0
  71. package/src/index.js +59 -0
  72. package/src/molecules/Badge.jsx +51 -0
  73. package/src/molecules/ContentFilters.jsx +263 -0
  74. package/src/molecules/Dropdown.jsx +223 -0
  75. package/src/molecules/DropdownTagFilter.jsx +253 -0
  76. package/src/molecules/LabeledControl.jsx +66 -0
  77. package/src/molecules/MenuItem.jsx +148 -0
  78. package/src/molecules/MenuPopover.jsx +128 -0
  79. package/src/molecules/Modal.jsx +124 -0
  80. package/src/molecules/Pill.jsx +33 -0
  81. package/src/molecules/Popover.jsx +208 -0
  82. package/src/molecules/PropertyInput.jsx +32 -0
  83. package/src/molecules/QuantityInput.jsx +174 -0
  84. package/src/molecules/QuantityStepper.jsx +149 -0
  85. package/src/molecules/Section.jsx +16 -0
  86. package/src/molecules/SectionLabel.jsx +59 -0
  87. package/src/molecules/SegmentedToggle.jsx +56 -0
  88. package/src/molecules/Tag.jsx +79 -0
  89. package/src/molecules/ToggleBracket.jsx +45 -0
  90. package/src/molecules/ViewToggle.jsx +101 -0
  91. package/src/organisms/Table.jsx +46 -0
  92. package/src/primitives/Accordion.jsx +45 -0
  93. package/src/primitives/AssetPlaceholder.jsx +28 -0
  94. package/src/primitives/Carousel.jsx +50 -0
  95. package/src/primitives/CodeBlock.jsx +41 -0
  96. package/src/primitives/ExitPreview.jsx +12 -0
  97. package/src/primitives/FullscreenOverlay.jsx +39 -0
  98. package/src/primitives/Image.jsx +38 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Badge — status / categorization indicator
3
+ *
4
+ * Converted from Badge.tsx (shadcn/CVA) → plain JSX with kol- CSS variables.
5
+ * CSS classes live in components.css under 2-LABELS → Badges.
6
+ */
7
+
8
+ import { Icon } from '@kolkrabbi/kol-loader'
9
+
10
+ const VARIANT_MAP = {
11
+ default: 'kol-badge-default',
12
+ secondary: 'kol-badge-secondary',
13
+ destructive: 'kol-badge-destructive',
14
+ outline: 'kol-badge-outline',
15
+ success: 'kol-badge-success',
16
+ warning: 'kol-badge-warning',
17
+ critical: 'kol-badge-critical',
18
+ info: 'kol-badge-info'
19
+ }
20
+
21
+ const SIZE_MAP = {
22
+ sm: 'kol-badge-sm',
23
+ md: 'kol-badge-md',
24
+ lg: 'kol-badge-lg'
25
+ }
26
+
27
+ const ICON_SIZES = { sm: 12, md: 14, lg: 16 }
28
+
29
+ const Badge = ({
30
+ children,
31
+ variant = 'default',
32
+ size = 'md',
33
+ icon,
34
+ className = '',
35
+ ...props
36
+ }) => {
37
+ const variantClass = VARIANT_MAP[variant] || VARIANT_MAP.default
38
+ const sizeClass = SIZE_MAP[size] || SIZE_MAP.md
39
+
40
+ return (
41
+ <div
42
+ className={`kol-badge ${variantClass} ${sizeClass} ${className}`.trim()}
43
+ {...props}
44
+ >
45
+ {icon && <Icon name={icon} size={ICON_SIZES[size] ?? ICON_SIZES.md} />}
46
+ {children}
47
+ </div>
48
+ )
49
+ }
50
+
51
+ export default Badge
@@ -0,0 +1,263 @@
1
+ import { useState, useMemo, useRef, useEffect } from 'react'
2
+ import Tag from './Tag.jsx'
3
+ import Divider from '../atoms/Divider.jsx'
4
+ import { Icon } from '@kolkrabbi/kol-loader'
5
+ import ViewToggle from './ViewToggle'
6
+
7
+ /**
8
+ * ContentFilters — universal filter component for content grids.
9
+ *
10
+ * Reusable filter component with expandable panel, tag-based filtering,
11
+ * search, and view-mode toggle. Used across Shop, Collections, Specimens,
12
+ * Typefaces, etc.
13
+ *
14
+ * @param {Object} props
15
+ * @param {Array} props.items — array of items to filter
16
+ * @param {string} props.title — section title (e.g., "Shop", "Collections")
17
+ * @param {number} props.totalCount — total count before filtering
18
+ * @param {Array} props.filterGroups — [{label, key, values}, ...]
19
+ * @param {Function} props.renderItem — (filteredItems, viewMode, layout) => ReactNode
20
+ * @param {Array} props.viewModeOptions — optional view mode options for ViewToggle
21
+ * @param {string} props.defaultViewMode — default view mode (default: 'list')
22
+ * @param {Function} props.onFilterChange — optional callback when filters change
23
+ * @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
24
+ * @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
25
+ */
26
+ const ContentFilters = ({
27
+ items,
28
+ title,
29
+ totalCount,
30
+ filterGroups = [],
31
+ renderItem,
32
+ viewModeOptions,
33
+ viewMode: viewModeProp,
34
+ onViewModeChange,
35
+ defaultViewMode = 'list',
36
+ layoutOptions,
37
+ defaultLayout = 'grid',
38
+ onFilterChange,
39
+ mutuallyExclusiveFilters = [],
40
+ customFilterKeys = [],
41
+ searchKeys = ['label', 'name', 'title', 'type'],
42
+ headerActions,
43
+ showCountOnlyWhenFiltering = false,
44
+ }) => {
45
+ const [activeFilters, setActiveFilters] = useState(new Set())
46
+ const [isExpanded, setIsExpanded] = useState(false)
47
+ const [internalViewMode, setInternalViewMode] = useState(defaultViewMode)
48
+ const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
49
+ const [layout, setLayout] = useState(defaultLayout)
50
+ const [searchOpen, setSearchOpen] = useState(false)
51
+ const [searchText, setSearchText] = useState('')
52
+ const searchRef = useRef(null)
53
+
54
+ useEffect(() => {
55
+ if (searchOpen && searchRef.current) searchRef.current.focus()
56
+ }, [searchOpen])
57
+
58
+ const toggleFilter = (filterType, value) => {
59
+ const newFilters = new Set(activeFilters)
60
+ const filterKey = `${filterType}:${value}`
61
+ if (newFilters.has(filterKey)) {
62
+ newFilters.delete(filterKey)
63
+ } else {
64
+ if (mutuallyExclusiveFilters.includes(filterType)) {
65
+ Array.from(newFilters).forEach((existing) => {
66
+ if (existing.startsWith(`${filterType}:`)) newFilters.delete(existing)
67
+ })
68
+ }
69
+ newFilters.add(filterKey)
70
+ }
71
+ setActiveFilters(newFilters)
72
+ onFilterChange?.(newFilters, viewMode)
73
+ }
74
+
75
+ const clearAllFilters = () => {
76
+ setActiveFilters(new Set())
77
+ onFilterChange?.(new Set(), viewMode)
78
+ }
79
+
80
+ const handleViewModeChange = (mode) => {
81
+ if (onViewModeChange) onViewModeChange(mode)
82
+ else setInternalViewMode(mode)
83
+ onFilterChange?.(activeFilters, mode)
84
+ }
85
+
86
+ const filteredItems = useMemo(() => {
87
+ let result = items
88
+ if (searchText) {
89
+ const q = searchText.toLowerCase()
90
+ result = result.filter((item) =>
91
+ searchKeys.some((key) => {
92
+ const val = item[key]
93
+ return val && String(val).toLowerCase().includes(q)
94
+ }),
95
+ )
96
+ }
97
+ if (activeFilters.size === 0) return result
98
+ return result.filter((item) => {
99
+ let matches = true
100
+ activeFilters.forEach((filter) => {
101
+ const [filterType, value] = filter.split(':')
102
+ if (customFilterKeys.includes(filterType)) return
103
+ const itemValue = item[filterType]
104
+ if (Array.isArray(itemValue)) {
105
+ if (!itemValue.includes(value)) matches = false
106
+ } else if (itemValue !== value) {
107
+ matches = false
108
+ }
109
+ })
110
+ return matches
111
+ })
112
+ }, [items, activeFilters, customFilterKeys, searchText, searchKeys])
113
+
114
+ const renderFilterGroup = (group) => (
115
+ <div key={group.key}>
116
+ <h4 className="kol-helper-12 text-fg-48">{group.label}</h4>
117
+ <div className="flex flex-wrap gap-2 pt-3">
118
+ {group.values.map((value) => {
119
+ const filterKey = `${group.key}:${value}`
120
+ const isActive = activeFilters.has(filterKey)
121
+ return (
122
+ <div key={value} onClick={() => toggleFilter(group.key, value)}>
123
+ <Tag size="md" variant="default" hash={false} className={isActive ? 'border-fg-32' : 'border-fg-08'}>
124
+ {value}
125
+ </Tag>
126
+ </div>
127
+ )
128
+ })}
129
+ </div>
130
+ </div>
131
+ )
132
+
133
+ return (
134
+ <div className="w-full" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
135
+ <div className="flex items-center justify-between mb-4">
136
+ <div className="flex items-center gap-6">
137
+ <h2 className="kol-helper-14">{title}</h2>
138
+ <div className="flex items-center gap-1">
139
+ <button
140
+ type="button"
141
+ onClick={() => setIsExpanded(!isExpanded)}
142
+ className="p-2 hover:bg-fg-04 rounded-sm transition-colors leading-none"
143
+ aria-label="Toggle filters"
144
+ style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'inherit' }}
145
+ >
146
+ <Icon name="filter" size={16} />
147
+ </button>
148
+ <div
149
+ className="flex items-center rounded-full cursor-pointer"
150
+ style={{
151
+ height: 28,
152
+ width: searchOpen ? 200 : 28,
153
+ background: searchOpen ? 'var(--kol-opacity-hex-04, rgba(255,255,255,0.04))' : 'transparent',
154
+ transition: 'width 600ms cubic-bezier(0.16, 1, 0.3, 1), background 400ms cubic-bezier(0.16, 1, 0.3, 1)',
155
+ overflow: 'hidden',
156
+ }}
157
+ onClick={() => {
158
+ if (searchOpen) { setSearchOpen(false); setSearchText('') }
159
+ else setSearchOpen(true)
160
+ }}
161
+ >
162
+ <span
163
+ className="flex items-center justify-center flex-shrink-0"
164
+ style={{
165
+ width: 28, height: 28,
166
+ opacity: searchOpen ? 0 : 1,
167
+ transition: 'opacity 300ms cubic-bezier(0.16, 1, 0.3, 1)',
168
+ position: searchOpen ? 'absolute' : 'relative',
169
+ }}
170
+ >
171
+ <Icon name="search" size={16} />
172
+ </span>
173
+ {searchOpen && (
174
+ <input
175
+ ref={searchRef}
176
+ type="text"
177
+ value={searchText}
178
+ onChange={(e) => setSearchText(e.target.value)}
179
+ onClick={(e) => e.stopPropagation()}
180
+ placeholder=""
181
+ className="bg-transparent outline-none kol-helper-12 flex-1 text-fg-80 caret-current px-4"
182
+ onBlur={() => { if (!searchText) setSearchOpen(false) }}
183
+ onKeyDown={(e) => { if (e.key === 'Escape') { setSearchOpen(false); setSearchText('') } }}
184
+ />
185
+ )}
186
+ </div>
187
+ {headerActions}
188
+ </div>
189
+ {activeFilters.size > 0 && (
190
+ <span
191
+ className="kol-helper-12 text-fg-48 cursor-pointer select-none group flex items-center gap-2"
192
+ onClick={(e) => { e.stopPropagation(); clearAllFilters() }}
193
+ >
194
+ <span className="underline">({activeFilters.size}) {activeFilters.size === 1 ? 'filter' : 'filters'} active</span>
195
+ <span className="hidden group-hover:inline text-fg-64">×</span>
196
+ </span>
197
+ )}
198
+ </div>
199
+
200
+ <div className="flex items-center gap-8">
201
+ {(!showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0) && (
202
+ <span className="kol-helper-14 text-fg-64">
203
+ {filteredItems.length} of {totalCount}
204
+ </span>
205
+ )}
206
+ {viewModeOptions && (
207
+ <div className="flex gap-6">
208
+ {viewModeOptions.map((opt) => (
209
+ <span
210
+ key={opt.value}
211
+ onClick={() => handleViewModeChange(opt.value)}
212
+ className={`kol-helper-14 cursor-pointer select-none ${viewMode === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
213
+ style={{ textTransform: 'uppercase', letterSpacing: 1 }}
214
+ >
215
+ {opt.label}
216
+ </span>
217
+ ))}
218
+ </div>
219
+ )}
220
+ </div>
221
+ </div>
222
+
223
+ <Divider className="mb-4" />
224
+
225
+ {isExpanded && (
226
+ <div className="flex items-start gap-16 pb-4">
227
+ {filterGroups.map((group) => renderFilterGroup(group))}
228
+ {activeFilters.size > 0 && (
229
+ <button
230
+ type="button"
231
+ onClick={clearAllFilters}
232
+ className="kol-helper-12 transition-colors underline text-fg-48"
233
+ style={{ marginLeft: 'auto', background: 'transparent', border: 'none', cursor: 'pointer' }}
234
+ >
235
+ Clear all ({activeFilters.size})
236
+ </button>
237
+ )}
238
+ </div>
239
+ )}
240
+
241
+ {layoutOptions && (
242
+ <div className="flex items-center justify-end gap-4 mt-4">
243
+ {layoutOptions.map((opt) => (
244
+ <span
245
+ key={opt.value}
246
+ onClick={() => setLayout(opt.value)}
247
+ className={`kol-helper-12 cursor-pointer select-none ${layout === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
248
+ style={{ textTransform: 'uppercase', letterSpacing: 1 }}
249
+ >
250
+ {opt.label}
251
+ </span>
252
+ ))}
253
+ </div>
254
+ )}
255
+
256
+ <div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
257
+ {renderItem(filteredItems, viewMode, layout)}
258
+ </div>
259
+ </div>
260
+ )
261
+ }
262
+
263
+ export default ContentFilters
@@ -0,0 +1,223 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-loader'
3
+ import { MenuDropdownItem } from './MenuItem.jsx'
4
+ import { PopoverPanel, usePopover } from './Popover.jsx'
5
+
6
+ const SIZE_MAP = {
7
+ sm: { fontSize: 12, paddingY: 4, paddingX: 12, radius: 14, panelRadius: 14, icon: 10 },
8
+ md: { fontSize: 12, paddingY: 6, paddingX: 16, radius: 22, panelRadius: 22, icon: 12 },
9
+ lg: { fontSize: 14, paddingY: 8, paddingX: 20, radius: 24, panelRadius: 24, icon: 14 }
10
+ }
11
+
12
+ const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-12', lg: 'kol-mono-14' }
13
+
14
+ const Dropdown = ({
15
+ options = [],
16
+ value,
17
+ onChange,
18
+ size,
19
+ variant = 'default',
20
+ className = ''
21
+ }) => {
22
+ const [isOpen, setIsOpen] = useState(false)
23
+ const [resolvedSize, setResolvedSize] = useState('md')
24
+ const [dropdownWidth, setDropdownWidth] = useState('100px')
25
+
26
+ /* Floating-ui popover. `flip: false` keeps the panel below the button
27
+ * — the seamless border-radius edge between button and panel assumes
28
+ * the panel sits below; flipping above would visually disconnect them.
29
+ * `matchReferenceWidth: true` pins panel min-width to the button. */
30
+ const popover = usePopover({
31
+ open: isOpen,
32
+ onOpenChange: setIsOpen,
33
+ placement: 'bottom-start',
34
+ offset: variant === 'minimal' ? 0 : -1,
35
+ flip: false,
36
+ matchReferenceWidth: true,
37
+ role: 'listbox',
38
+ })
39
+
40
+ useEffect(() => {
41
+ const determineSize = () => {
42
+ if (size) {
43
+ setResolvedSize(size)
44
+ return
45
+ }
46
+
47
+ if (typeof window === 'undefined') {
48
+ setResolvedSize('md')
49
+ return
50
+ }
51
+
52
+ if (window.innerWidth >= 1024) {
53
+ setResolvedSize('lg')
54
+ } else if (window.innerWidth >= 768) {
55
+ setResolvedSize('md')
56
+ } else {
57
+ setResolvedSize('sm')
58
+ }
59
+ }
60
+
61
+ determineSize()
62
+ window.addEventListener('resize', determineSize)
63
+
64
+ return () => {
65
+ window.removeEventListener('resize', determineSize)
66
+ }
67
+ }, [size])
68
+
69
+ // Width management for variants
70
+ useEffect(() => {
71
+ const updateWidth = () => {
72
+ if (typeof window === 'undefined') return
73
+
74
+ if (variant === 'minimal' || variant === 'subtle') {
75
+ // Minimal/subtle: 100px mobile, 140px tablet+
76
+ setDropdownWidth(window.innerWidth >= 768 ? '140px' : '100px')
77
+ } else if (variant === 'default') {
78
+ // Default: 100px mobile, 140px tablet, 180px desktop
79
+ if (window.innerWidth >= 1024) {
80
+ setDropdownWidth('180px')
81
+ } else if (window.innerWidth >= 768) {
82
+ setDropdownWidth('140px')
83
+ } else {
84
+ setDropdownWidth('100px')
85
+ }
86
+ }
87
+ }
88
+ updateWidth()
89
+ window.addEventListener('resize', updateWidth)
90
+ return () => window.removeEventListener('resize', updateWidth)
91
+ }, [variant])
92
+
93
+ const metrics = SIZE_MAP[resolvedSize] || SIZE_MAP.md
94
+
95
+ // Variant-specific styles
96
+ const variantStyles = {
97
+ default: {
98
+ border: '1px solid var(--kol-border-default)',
99
+ borderRadius: isOpen
100
+ ? `${metrics.radius}px ${metrics.radius}px 0 0`
101
+ : `${metrics.radius}px`,
102
+ backgroundColor: 'var(--kol-surface-primary)',
103
+ padding: `${metrics.paddingY}px ${metrics.paddingX}px`
104
+ },
105
+ minimal: {
106
+ border: 'none',
107
+ borderRadius: '0',
108
+ backgroundColor: 'transparent',
109
+ padding: '0',
110
+ height: '24px',
111
+ display: 'flex',
112
+ alignItems: 'center'
113
+ },
114
+ subtle: {
115
+ /* 1px transparent border so total height matches Button + Input + the
116
+ * other shared-control atoms (which have a transparent 1px border for
117
+ * hover/focus consistency). Without this, subtle is 2px shorter. */
118
+ border: '1px solid transparent',
119
+ borderRadius: isOpen ? '4px 4px 0 0' : '4px',
120
+ backgroundColor: 'var(--kol-surface-secondary)',
121
+ padding: `${metrics.paddingY}px ${metrics.paddingX}px`
122
+ }
123
+ }
124
+
125
+ const styles = variantStyles[variant] || variantStyles.default
126
+
127
+ const handleSelect = (option) => {
128
+ onChange?.(option.value)
129
+ setIsOpen(false)
130
+ }
131
+
132
+ const currentOption = options.find((opt) => opt.value === value) || options[0]
133
+
134
+ return (
135
+ <div
136
+ className={`relative block ${className}`}
137
+ style={{
138
+ ...((variant === 'minimal' || variant === 'default' || variant === 'subtle') && dropdownWidth && !className.includes('w-full') && {
139
+ width: dropdownWidth,
140
+ minWidth: dropdownWidth
141
+ })
142
+ }}
143
+ >
144
+ <button
145
+ ref={popover.refs.setReference}
146
+ {...popover.getReferenceProps()}
147
+ type="button"
148
+ className={`w-full flex items-center justify-between transition-colors duration-200 ${SIZE_TYPE[resolvedSize]}`}
149
+ style={{
150
+ border: styles.border,
151
+ borderRadius: styles.borderRadius,
152
+ backgroundColor: styles.backgroundColor,
153
+ color: 'var(--kol-surface-on-primary)',
154
+ padding: styles.padding,
155
+ transition: 'background-color 0.2s, color 0.2s, border-color 0.2s',
156
+ ...(variant === 'minimal' && {
157
+ height: styles.height,
158
+ }),
159
+ }}
160
+ aria-haspopup="listbox"
161
+ aria-expanded={isOpen}
162
+ data-state={isOpen ? 'open' : 'closed'}
163
+ >
164
+ <span className="opacity-100">{currentOption?.label}</span>
165
+ <Icon
166
+ name="chevron-down"
167
+ size={metrics.icon}
168
+ className="ml-auto"
169
+ style={{
170
+ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
171
+ transition: 'transform 300ms',
172
+ }}
173
+ />
174
+ </button>
175
+
176
+ <PopoverPanel
177
+ popover={popover}
178
+ panel={false}
179
+ focus={false}
180
+ style={{
181
+ backgroundColor: variant === 'minimal'
182
+ ? 'var(--kol-surface-primary)'
183
+ : styles.backgroundColor,
184
+ color: 'var(--kol-surface-on-primary)',
185
+ border: variant === 'subtle' ? 'none' : styles.border,
186
+ borderRadius: variant === 'minimal'
187
+ ? '0'
188
+ : (variant === 'subtle'
189
+ ? '0 0 4px 4px'
190
+ : `0 0 ${metrics.panelRadius}px ${metrics.panelRadius}px`),
191
+ }}
192
+ >
193
+ {variant !== 'minimal' && (
194
+ <div style={{ padding: `0 ${metrics.paddingX}px` }}>
195
+ <div
196
+ style={{
197
+ height: '1px',
198
+ backgroundColor: 'var(--kol-border-default)'
199
+ }}
200
+ />
201
+ </div>
202
+ )}
203
+
204
+ <div className="flex max-h-[300px] flex-col items-stretch overflow-y-auto" role="listbox">
205
+ {options.map((option) => {
206
+ const isActive = option.value === currentOption?.value
207
+ return (
208
+ <MenuDropdownItem
209
+ key={option.value}
210
+ onClick={() => handleSelect(option)}
211
+ shortcut={isActive ? <Icon name="check" size={11} /> : undefined}
212
+ >
213
+ {option.label}
214
+ </MenuDropdownItem>
215
+ )
216
+ })}
217
+ </div>
218
+ </PopoverPanel>
219
+ </div>
220
+ )
221
+ }
222
+
223
+ export default Dropdown