@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.
- package/README.md +24 -0
- package/package.json +46 -0
- package/src/atoms/Avatar.jsx +17 -0
- package/src/atoms/Button.jsx +172 -0
- package/src/atoms/ColorSwatch.jsx +126 -0
- package/src/atoms/Divider.jsx +34 -0
- package/src/atoms/Input.jsx +121 -0
- package/src/atoms/Label.jsx +12 -0
- package/src/atoms/Slider.jsx +129 -0
- package/src/atoms/Stepper.jsx +97 -0
- package/src/atoms/Textarea.jsx +70 -0
- package/src/atoms/ToggleCheckbox.jsx +40 -0
- package/src/atoms/ToggleSwitch.jsx +42 -0
- package/src/atoms/TransparentX.jsx +37 -0
- package/src/graphics/Graphic.jsx +53 -0
- package/src/graphics/svg/abstract/abstract-01.svg +3 -0
- package/src/graphics/svg/abstract/abstract-02.svg +3 -0
- package/src/graphics/svg/abstract/abstract-03.svg +3 -0
- package/src/graphics/svg/abstract/abstract-06.svg +4 -0
- package/src/graphics/svg/diagrams/.gitkeep +0 -0
- package/src/graphics/svg/diagrams/ac-structure.svg +90 -0
- package/src/graphics/svg/diagrams/rg-x-height.svg +3 -0
- package/src/graphics/svg/diagrams/structure-grid.svg +618 -0
- package/src/graphics/svg/diagrams/structure-logo.svg +23 -0
- package/src/graphics/svg/patterns/.gitkeep +0 -0
- package/src/graphics/svg/patterns/patt-01.svg +34 -0
- package/src/graphics/svg/patterns/patt-02.svg +34 -0
- package/src/graphics/svg/patterns/patt-03.svg +110 -0
- package/src/graphics/svg/patterns/patt-04.svg +10 -0
- package/src/graphics/svg/patterns/patt-05.svg +62 -0
- package/src/graphics/svg/patterns/patt-06.svg +66 -0
- package/src/graphics/svg/patterns/patt-07.svg +130 -0
- package/src/graphics/svg/patterns/patt-08.svg +434 -0
- package/src/graphics/svg/patterns/patt-09.svg +38 -0
- package/src/graphics/svg/patterns/patt-10.svg +20 -0
- package/src/graphics/svg/patterns/patt-11.svg +38 -0
- package/src/graphics/svg/patterns/patt-12.svg +58 -0
- package/src/graphics/svg/patterns/patt-13.svg +48 -0
- package/src/graphics/svg/patterns/patt-14.svg +90 -0
- package/src/graphics/svg/patterns/patt-15.svg +194 -0
- package/src/graphics/svg/patterns/patt-16.svg +12 -0
- package/src/graphics/svg/social/social-01.svg +18 -0
- package/src/graphics/svg/social/social-02.svg +18 -0
- package/src/graphics/svg/social/social-03.svg +18 -0
- package/src/graphics/svg/social/social-04.svg +18 -0
- package/src/graphics/svg/social/social-05.svg +18 -0
- package/src/graphics/svg/social/social-06.svg +18 -0
- package/src/graphics/svg/social/social-07.svg +22 -0
- package/src/graphics/svg/social/social-08.svg +22 -0
- package/src/graphics/svg/social/social-09.svg +22 -0
- package/src/graphics/svg/social/social-10.svg +6 -0
- package/src/graphics/svg/social/social-11.svg +6 -0
- package/src/graphics/svg/social/social-12.svg +6 -0
- package/src/graphics/svg/social/social-13.svg +9 -0
- package/src/graphics/svg/social/social-14.svg +9 -0
- package/src/graphics/svg/social/social-15.svg +9 -0
- package/src/graphics/svg/structure/diagram-grid-lockup-hori.svg +23 -0
- package/src/graphics/svg/structure/diagram-grid-lockup-vert.svg +25 -0
- package/src/graphics/svg/structure/diagram-grid-logomark.svg +20 -0
- package/src/graphics/svg/structure/diagram-grid-wordmark.svg +18 -0
- package/src/graphics/svg/structure/diagram-logo-lockup-hori.svg +9 -0
- package/src/graphics/svg/structure/diagram-logo-lockup-vert.svg +23 -0
- package/src/graphics/svg/structure/diagram-logo-logomark.svg +7 -0
- package/src/graphics/svg/structure/diagram-logo-wordmark.svg +3 -0
- package/src/graphics/svg/structure/diagram-x-lockup-hori.svg +5 -0
- package/src/graphics/svg/structure/diagram-x-lockup-vert.svg +10 -0
- package/src/graphics/svg/structure/diagram-x-logomark.svg +5 -0
- package/src/graphics/svg/structure/diagram-x-wordmark.svg +5 -0
- package/src/hooks/useReveal.js +28 -0
- package/src/hooks/useScrollSpy.js +56 -0
- package/src/index.js +59 -0
- package/src/molecules/Badge.jsx +51 -0
- package/src/molecules/ContentFilters.jsx +263 -0
- package/src/molecules/Dropdown.jsx +223 -0
- package/src/molecules/DropdownTagFilter.jsx +253 -0
- package/src/molecules/LabeledControl.jsx +66 -0
- package/src/molecules/MenuItem.jsx +148 -0
- package/src/molecules/MenuPopover.jsx +128 -0
- package/src/molecules/Modal.jsx +124 -0
- package/src/molecules/Pill.jsx +33 -0
- package/src/molecules/Popover.jsx +208 -0
- package/src/molecules/PropertyInput.jsx +32 -0
- package/src/molecules/QuantityInput.jsx +174 -0
- package/src/molecules/QuantityStepper.jsx +149 -0
- package/src/molecules/Section.jsx +16 -0
- package/src/molecules/SectionLabel.jsx +59 -0
- package/src/molecules/SegmentedToggle.jsx +56 -0
- package/src/molecules/Tag.jsx +79 -0
- package/src/molecules/ToggleBracket.jsx +45 -0
- package/src/molecules/ViewToggle.jsx +101 -0
- package/src/organisms/Table.jsx +46 -0
- package/src/primitives/Accordion.jsx +45 -0
- package/src/primitives/AssetPlaceholder.jsx +28 -0
- package/src/primitives/Carousel.jsx +50 -0
- package/src/primitives/CodeBlock.jsx +41 -0
- package/src/primitives/ExitPreview.jsx +12 -0
- package/src/primitives/FullscreenOverlay.jsx +39 -0
- 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
|