@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,253 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
const SIZE_MAP = {
|
|
4
|
+
sm: { fontSize: 11, paddingY: 12, paddingX: 24, radius: 20, icon: 10 },
|
|
5
|
+
md: { fontSize: 12, paddingY: 14, paddingX: 24, radius: 22, icon: 12 },
|
|
6
|
+
lg: { fontSize: 14, paddingY: 16, paddingX: 24, radius: 24, icon: 14 }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dropdown Tag Filter
|
|
11
|
+
* Multi-select dropdown where all items start selected
|
|
12
|
+
* Click to deselect, with "Deselect All" option
|
|
13
|
+
*/
|
|
14
|
+
const DropdownTagFilter = ({
|
|
15
|
+
options = [],
|
|
16
|
+
selectedValues = new Set(),
|
|
17
|
+
onChange,
|
|
18
|
+
className = '',
|
|
19
|
+
size
|
|
20
|
+
}) => {
|
|
21
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
22
|
+
const dropdownRef = useRef(null)
|
|
23
|
+
const [resolvedSize, setResolvedSize] = useState('md')
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const determineSize = () => {
|
|
27
|
+
if (size) {
|
|
28
|
+
setResolvedSize(size)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof window === 'undefined') {
|
|
33
|
+
setResolvedSize('md')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (window.innerWidth >= 1024) {
|
|
38
|
+
setResolvedSize('lg')
|
|
39
|
+
} else if (window.innerWidth >= 768) {
|
|
40
|
+
setResolvedSize('md')
|
|
41
|
+
} else {
|
|
42
|
+
setResolvedSize('sm')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
determineSize()
|
|
47
|
+
window.addEventListener('resize', determineSize)
|
|
48
|
+
return () => window.removeEventListener('resize', determineSize)
|
|
49
|
+
}, [size])
|
|
50
|
+
|
|
51
|
+
const metrics = SIZE_MAP[resolvedSize] || SIZE_MAP.md
|
|
52
|
+
|
|
53
|
+
// Close dropdown when clicking outside
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const handleClickOutside = (event) => {
|
|
56
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
|
57
|
+
setIsOpen(false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isOpen) {
|
|
62
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
67
|
+
}
|
|
68
|
+
}, [isOpen])
|
|
69
|
+
|
|
70
|
+
// Handle keyboard navigation
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const handleKeyDown = (event) => {
|
|
73
|
+
if (!isOpen) return
|
|
74
|
+
if (event.key === 'Escape') {
|
|
75
|
+
setIsOpen(false)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isOpen) {
|
|
80
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
85
|
+
}
|
|
86
|
+
}, [isOpen])
|
|
87
|
+
|
|
88
|
+
const handleToggle = (value) => {
|
|
89
|
+
onChange?.(value)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const handleDeselectAll = () => {
|
|
93
|
+
onChange?.(null) // Pass null to signal "deselect all"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const selectedCount = selectedValues.size
|
|
97
|
+
const totalCount = options.length
|
|
98
|
+
const label =
|
|
99
|
+
selectedCount === 0
|
|
100
|
+
? 'No tags'
|
|
101
|
+
: selectedCount === totalCount
|
|
102
|
+
? 'All tags'
|
|
103
|
+
: `${selectedCount} selected`
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
ref={dropdownRef}
|
|
108
|
+
className={`relative inline-block ${className}`}
|
|
109
|
+
style={{ zIndex: isOpen ? 100 : 50 }}
|
|
110
|
+
>
|
|
111
|
+
<div
|
|
112
|
+
className="min-w-[180px]"
|
|
113
|
+
style={{
|
|
114
|
+
border: '1px solid var(--kol-border-default)',
|
|
115
|
+
borderRadius: isOpen
|
|
116
|
+
? `${metrics.radius}px ${metrics.radius}px 0 0`
|
|
117
|
+
: `${metrics.radius}px`,
|
|
118
|
+
backgroundColor: 'var(--kol-surface-primary)',
|
|
119
|
+
color: 'var(--kol-surface-on-primary)'
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
125
|
+
className="w-full flex items-center justify-between transition-colors duration-200"
|
|
126
|
+
style={{
|
|
127
|
+
backgroundColor: 'transparent',
|
|
128
|
+
border: 'none',
|
|
129
|
+
padding: `${metrics.paddingY}px ${metrics.paddingX}px`,
|
|
130
|
+
fontSize: `${metrics.fontSize}px`,
|
|
131
|
+
lineHeight: '120%',
|
|
132
|
+
fontFamily: 'var(--kol-font-family-mono)'
|
|
133
|
+
}}
|
|
134
|
+
aria-haspopup="listbox"
|
|
135
|
+
aria-expanded={isOpen}
|
|
136
|
+
data-state={isOpen ? 'open' : 'closed'}
|
|
137
|
+
>
|
|
138
|
+
<span className="opacity-100">{label}</span>
|
|
139
|
+
<svg
|
|
140
|
+
className="ml-auto transition-transform duration-300"
|
|
141
|
+
style={{
|
|
142
|
+
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
143
|
+
width: `${metrics.icon}px`,
|
|
144
|
+
height: `${metrics.icon}px`
|
|
145
|
+
}}
|
|
146
|
+
viewBox="0 0 12 12"
|
|
147
|
+
fill="none"
|
|
148
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
149
|
+
>
|
|
150
|
+
<path
|
|
151
|
+
d="m3 5 3 3 3-3"
|
|
152
|
+
stroke="currentColor"
|
|
153
|
+
strokeWidth="1.25"
|
|
154
|
+
strokeLinecap="round"
|
|
155
|
+
strokeLinejoin="round"
|
|
156
|
+
/>
|
|
157
|
+
</svg>
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{isOpen && (
|
|
162
|
+
<div
|
|
163
|
+
className="absolute w-full border border-t-0"
|
|
164
|
+
style={{
|
|
165
|
+
backgroundColor: 'var(--kol-surface-primary)',
|
|
166
|
+
color: 'var(--kol-surface-on-primary)',
|
|
167
|
+
borderColor: 'var(--kol-border-default)',
|
|
168
|
+
top: '100%',
|
|
169
|
+
left: 0,
|
|
170
|
+
marginTop: '-1px',
|
|
171
|
+
borderRadius: `0 0 ${metrics.radius}px ${metrics.radius}px`
|
|
172
|
+
}}
|
|
173
|
+
role="listbox"
|
|
174
|
+
>
|
|
175
|
+
<div style={{ padding: `0 ${metrics.paddingX}px` }}>
|
|
176
|
+
<div
|
|
177
|
+
style={{
|
|
178
|
+
height: '1px',
|
|
179
|
+
backgroundColor: 'var(--kol-border-default)'
|
|
180
|
+
}}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div className="flex max-h-[300px] flex-col items-start overflow-y-auto py-2">
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
onClick={handleDeselectAll}
|
|
188
|
+
className="w-full text-left transition-opacity duration-150"
|
|
189
|
+
style={{
|
|
190
|
+
backgroundColor: 'transparent',
|
|
191
|
+
opacity: selectedCount === 0 ? 0.4 : 1,
|
|
192
|
+
padding: `8px ${metrics.paddingX}px`,
|
|
193
|
+
fontSize: `${metrics.fontSize}px`,
|
|
194
|
+
lineHeight: '120%',
|
|
195
|
+
fontFamily: 'var(--kol-font-family-mono)'
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
Deselect all
|
|
199
|
+
</button>
|
|
200
|
+
|
|
201
|
+
{options.map((option) => {
|
|
202
|
+
const isSelected = selectedValues.has(option.value)
|
|
203
|
+
return (
|
|
204
|
+
<button
|
|
205
|
+
key={option.value}
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={() => handleToggle(option.value)}
|
|
208
|
+
className="w-full text-left transition-opacity duration-150 relative"
|
|
209
|
+
style={{
|
|
210
|
+
backgroundColor: 'transparent',
|
|
211
|
+
opacity: isSelected ? 1 : 0.4,
|
|
212
|
+
padding: `8px ${metrics.paddingX}px`,
|
|
213
|
+
fontSize: `${metrics.fontSize}px`,
|
|
214
|
+
lineHeight: '120%',
|
|
215
|
+
fontFamily: 'var(--kol-font-family-mono)'
|
|
216
|
+
}}
|
|
217
|
+
role="option"
|
|
218
|
+
aria-selected={isSelected}
|
|
219
|
+
onMouseEnter={(event) => {
|
|
220
|
+
event.currentTarget.style.opacity = '1'
|
|
221
|
+
}}
|
|
222
|
+
onMouseLeave={(event) => {
|
|
223
|
+
if (!isSelected) {
|
|
224
|
+
event.currentTarget.style.opacity = '0.4'
|
|
225
|
+
}
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
{isSelected && (
|
|
229
|
+
<span
|
|
230
|
+
style={{
|
|
231
|
+
position: 'absolute',
|
|
232
|
+
left: '12px',
|
|
233
|
+
top: '50%',
|
|
234
|
+
transform: 'translateY(-50%)',
|
|
235
|
+
width: '4px',
|
|
236
|
+
height: '4px',
|
|
237
|
+
borderRadius: '50%',
|
|
238
|
+
backgroundColor: 'var(--kol-surface-on-primary)'
|
|
239
|
+
}}
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
<span>{option.label}</span>
|
|
243
|
+
</button>
|
|
244
|
+
)
|
|
245
|
+
})}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export default DropdownTagFilter
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LabeledControl — generic slot for label + interactive control body.
|
|
3
|
+
*
|
|
4
|
+
* Default layout: label on top (`flex-col`). Pass `inline` for a horizontal
|
|
5
|
+
* label-left / control-right layout (label fixed-width, control flex-1).
|
|
6
|
+
*
|
|
7
|
+
* Compose with any control as children:
|
|
8
|
+
* <LabeledControl label="Columns · 4">
|
|
9
|
+
* <Slider ... />
|
|
10
|
+
* </LabeledControl>
|
|
11
|
+
* <LabeledControl inline label="Weight">
|
|
12
|
+
* <Input ... />
|
|
13
|
+
* </LabeledControl>
|
|
14
|
+
*
|
|
15
|
+
* Props:
|
|
16
|
+
* label — small label text (uppercase, kol-helper-10).
|
|
17
|
+
* hint — optional secondary text after the label (lower-case, less
|
|
18
|
+
* weight). Useful for current-value displays, units, etc.
|
|
19
|
+
* inline — bool. When true, renders horizontally with label on the left.
|
|
20
|
+
* labelWidth — px width for the label column when `inline`. Default 48.
|
|
21
|
+
* children — the control body.
|
|
22
|
+
* className — additional classes on the wrapper.
|
|
23
|
+
*/
|
|
24
|
+
export default function LabeledControl({
|
|
25
|
+
label,
|
|
26
|
+
hint,
|
|
27
|
+
inline = false,
|
|
28
|
+
labelWidth = 48,
|
|
29
|
+
children,
|
|
30
|
+
className = '',
|
|
31
|
+
}) {
|
|
32
|
+
const showLabel = !!label
|
|
33
|
+
const labelInner = (
|
|
34
|
+
<>
|
|
35
|
+
{label}
|
|
36
|
+
{hint !== undefined && (
|
|
37
|
+
<span className="ml-2 normal-case tracking-normal text-subtle">{hint}</span>
|
|
38
|
+
)}
|
|
39
|
+
</>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if (inline) {
|
|
43
|
+
return (
|
|
44
|
+
<div className={`flex items-center gap-3 ${className}`}>
|
|
45
|
+
{showLabel && (
|
|
46
|
+
<span
|
|
47
|
+
className="kol-helper-10 uppercase tracking-widest text-meta shrink-0"
|
|
48
|
+
style={{ width: labelWidth }}
|
|
49
|
+
>
|
|
50
|
+
{labelInner}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
<div className="flex-1 min-w-0">{children}</div>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={`flex flex-col gap-2 ${className}`}>
|
|
60
|
+
{showLabel && (
|
|
61
|
+
<span className="kol-helper-10 uppercase tracking-widest text-meta">{labelInner}</span>
|
|
62
|
+
)}
|
|
63
|
+
{children}
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
3
|
+
import { PopoverPanel, usePopover } from './Popover.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MenuItem — top-level menu entry. Trigger button + popover panel.
|
|
7
|
+
*
|
|
8
|
+
* <MenuItem label="File">
|
|
9
|
+
* <MenuDropdownItem onClick={…}>Save</MenuDropdownItem>
|
|
10
|
+
* <MenuDropdownItem onClick={…}>Export…</MenuDropdownItem>
|
|
11
|
+
* </MenuItem>
|
|
12
|
+
*
|
|
13
|
+
* Built on `usePopover` (`@floating-ui/react`): portal-rendered panel
|
|
14
|
+
* (escapes overflow), auto-flip + viewport shift, click-outside / Escape
|
|
15
|
+
* dismiss, focus management. `align="end"` switches the placement to
|
|
16
|
+
* `bottom-end` for right-aligned panels (Templates menu).
|
|
17
|
+
*
|
|
18
|
+
* For value-list selection (pick one of N with active state) use the
|
|
19
|
+
* `Dropdown` molecule instead — MenuItem is for action menus / popovers
|
|
20
|
+
* that hold arbitrary children.
|
|
21
|
+
*/
|
|
22
|
+
export function MenuItem({
|
|
23
|
+
label,
|
|
24
|
+
children,
|
|
25
|
+
align = 'start',
|
|
26
|
+
panelClassName = '',
|
|
27
|
+
panelStyle,
|
|
28
|
+
buttonClassName = '',
|
|
29
|
+
}) {
|
|
30
|
+
const [open, setOpen] = useState(false)
|
|
31
|
+
const popover = usePopover({
|
|
32
|
+
open,
|
|
33
|
+
onOpenChange: setOpen,
|
|
34
|
+
placement: align === 'end' ? 'bottom-end' : 'bottom-start',
|
|
35
|
+
offset: 4,
|
|
36
|
+
role: 'menu',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const close = () => setOpen(false)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<button
|
|
44
|
+
ref={popover.refs.setReference}
|
|
45
|
+
{...popover.getReferenceProps()}
|
|
46
|
+
type="button"
|
|
47
|
+
className={`kol-helper-12 px-3 h-8 inline-flex items-center gap-2 rounded text-body hover:text-emphasis transition-colors ${buttonClassName}`}
|
|
48
|
+
>
|
|
49
|
+
<span>{label}</span>
|
|
50
|
+
<Icon
|
|
51
|
+
name="chevron-down"
|
|
52
|
+
size={10}
|
|
53
|
+
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 200ms' }}
|
|
54
|
+
/>
|
|
55
|
+
</button>
|
|
56
|
+
<PopoverPanel
|
|
57
|
+
popover={popover}
|
|
58
|
+
panel={false}
|
|
59
|
+
focus={false}
|
|
60
|
+
className={`bg-surface-secondary rounded ${panelClassName}`}
|
|
61
|
+
style={panelStyle}
|
|
62
|
+
>
|
|
63
|
+
<div
|
|
64
|
+
onClick={(e) => {
|
|
65
|
+
/* close on item click — items inside fire their handler then bubble. */
|
|
66
|
+
if (e.target.closest('[data-menu-item]')) close()
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{typeof children === 'function' ? children({ close }) : children}
|
|
70
|
+
</div>
|
|
71
|
+
</PopoverPanel>
|
|
72
|
+
</>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* MenuDropdownItem — action row inside a MenuItem's dropdown panel.
|
|
78
|
+
* Renders as a button so it picks up disabled, focus, and keyboard
|
|
79
|
+
* activation. The parent MenuItem closes automatically on click via a
|
|
80
|
+
* delegated handler that matches the `data-menu-item` attr.
|
|
81
|
+
*
|
|
82
|
+
* Slots:
|
|
83
|
+
* - prefix — leading content of arbitrary width (e.g. palette swatch
|
|
84
|
+
* strip). Use for visuals wider than a single icon.
|
|
85
|
+
* - iconLeft — leading icon, fixed 16px width column (rows align).
|
|
86
|
+
* - children — main label, flex-1.
|
|
87
|
+
* - shortcut — trailing content (text shortcut hint, ✓ marker, or icon).
|
|
88
|
+
*/
|
|
89
|
+
export function MenuDropdownItem({ onClick, disabled, prefix, iconLeft, shortcut, children }) {
|
|
90
|
+
return (
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
data-menu-item
|
|
94
|
+
onClick={onClick}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
role="menuitem"
|
|
97
|
+
className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-body hover:text-emphasis disabled:opacity-40 disabled:cursor-not-allowed text-left"
|
|
98
|
+
>
|
|
99
|
+
{prefix && <span className="shrink-0 inline-flex items-center">{prefix}</span>}
|
|
100
|
+
{iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center">{iconLeft}</span>}
|
|
101
|
+
<span className="flex-1 truncate">{children}</span>
|
|
102
|
+
{shortcut && <span className="kol-helper-10 text-emphasis shrink-0 inline-flex items-center">{shortcut}</span>}
|
|
103
|
+
</button>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function MenuDropdownDivider() {
|
|
108
|
+
return <div className="border-t border-fg-08 my-1" />
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* MenuDropdownNest — accordion-style row inside a dropdown panel. Click
|
|
113
|
+
* the row to expand/collapse its children inline (below the row, in the
|
|
114
|
+
* same panel). The trailing chevron rotates 90° to indicate state.
|
|
115
|
+
*
|
|
116
|
+
* Same visual shape as MenuDropdownItem at rest. Clicking a leaf
|
|
117
|
+
* MenuDropdownItem inside an open nest still closes the entire menu via
|
|
118
|
+
* the standard data-menu-item bubble. Clicking the nest row itself only
|
|
119
|
+
* toggles its own expansion.
|
|
120
|
+
*/
|
|
121
|
+
export function MenuDropdownNest({ prefix, iconLeft, label, children }) {
|
|
122
|
+
const [open, setOpen] = useState(false)
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={() => setOpen((v) => !v)}
|
|
128
|
+
aria-expanded={open}
|
|
129
|
+
className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-body hover:text-emphasis text-left"
|
|
130
|
+
>
|
|
131
|
+
{prefix && <span className="shrink-0 inline-flex items-center">{prefix}</span>}
|
|
132
|
+
{iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center">{iconLeft}</span>}
|
|
133
|
+
<span className="flex-1 truncate">{label}</span>
|
|
134
|
+
<Icon
|
|
135
|
+
name="chevron-down"
|
|
136
|
+
size={10}
|
|
137
|
+
className="text-emphasis shrink-0"
|
|
138
|
+
style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 200ms' }}
|
|
139
|
+
/>
|
|
140
|
+
</button>
|
|
141
|
+
{open && (
|
|
142
|
+
<div className="ml-3 border-l border-fg-08">
|
|
143
|
+
{children}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MenuPopover — generic action-menu / popover primitive.
|
|
5
|
+
*
|
|
6
|
+
* <MenuPopover label="File">
|
|
7
|
+
* <MenuItem onClick={…}>Save</MenuItem>
|
|
8
|
+
* <MenuItem onClick={…}>Export…</MenuItem>
|
|
9
|
+
* </MenuPopover>
|
|
10
|
+
*
|
|
11
|
+
* Opens on click, closes on outside-click + Escape, anchors to the trigger
|
|
12
|
+
* via getBoundingClientRect + position:fixed so it escapes overflow:auto
|
|
13
|
+
* clipping. Pass `panelClassName` to size the panel (e.g. wider for
|
|
14
|
+
* Templates).
|
|
15
|
+
*
|
|
16
|
+
* For value-list selection (single value, active state) use `Dropdown`
|
|
17
|
+
* instead — this primitive is for action menus / popover panels that
|
|
18
|
+
* hold arbitrary children.
|
|
19
|
+
*/
|
|
20
|
+
export function MenuPopover({
|
|
21
|
+
label,
|
|
22
|
+
children,
|
|
23
|
+
align = 'start',
|
|
24
|
+
panelClassName = '',
|
|
25
|
+
panelStyle,
|
|
26
|
+
buttonClassName = '',
|
|
27
|
+
}) {
|
|
28
|
+
const [open, setOpen] = useState(false)
|
|
29
|
+
const wrapRef = useRef(null)
|
|
30
|
+
const buttonRef = useRef(null)
|
|
31
|
+
const [panelPos, setPanelPos] = useState(null)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return
|
|
35
|
+
const onDown = (e) => {
|
|
36
|
+
if (!wrapRef.current?.contains(e.target)) setOpen(false)
|
|
37
|
+
}
|
|
38
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
|
39
|
+
document.addEventListener('mousedown', onDown)
|
|
40
|
+
document.addEventListener('keydown', onKey)
|
|
41
|
+
return () => {
|
|
42
|
+
document.removeEventListener('mousedown', onDown)
|
|
43
|
+
document.removeEventListener('keydown', onKey)
|
|
44
|
+
}
|
|
45
|
+
}, [open])
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) { setPanelPos(null); return }
|
|
49
|
+
const update = () => {
|
|
50
|
+
const rect = buttonRef.current?.getBoundingClientRect()
|
|
51
|
+
if (!rect) return
|
|
52
|
+
setPanelPos({ top: rect.bottom + 4, left: rect.left, right: rect.right })
|
|
53
|
+
}
|
|
54
|
+
update()
|
|
55
|
+
window.addEventListener('resize', update)
|
|
56
|
+
window.addEventListener('scroll', update, true)
|
|
57
|
+
return () => {
|
|
58
|
+
window.removeEventListener('resize', update)
|
|
59
|
+
window.removeEventListener('scroll', update, true)
|
|
60
|
+
}
|
|
61
|
+
}, [open])
|
|
62
|
+
|
|
63
|
+
const close = () => setOpen(false)
|
|
64
|
+
|
|
65
|
+
const positioned = panelPos && (
|
|
66
|
+
align === 'end'
|
|
67
|
+
? { top: panelPos.top, right: window.innerWidth - panelPos.right }
|
|
68
|
+
: { top: panelPos.top, left: panelPos.left }
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div ref={wrapRef} className="relative inline-block">
|
|
73
|
+
<button
|
|
74
|
+
ref={buttonRef}
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={() => setOpen((v) => !v)}
|
|
77
|
+
aria-haspopup="menu"
|
|
78
|
+
aria-expanded={open}
|
|
79
|
+
className={`kol-helper-12 px-3 h-8 inline-flex items-center gap-1 rounded text-meta hover:text-emphasis transition-colors ${buttonClassName}`}
|
|
80
|
+
>
|
|
81
|
+
<span>{label}</span>
|
|
82
|
+
<svg width="10" height="10" viewBox="0 0 12 12" aria-hidden="true">
|
|
83
|
+
<path d="m3 5 3 3 3-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
84
|
+
</svg>
|
|
85
|
+
</button>
|
|
86
|
+
{open && positioned && (
|
|
87
|
+
<div
|
|
88
|
+
role="menu"
|
|
89
|
+
className={`fixed z-[1000] bg-surface-primary border border-fg-08 rounded shadow-lg ${panelClassName}`}
|
|
90
|
+
style={{ ...positioned, ...panelStyle }}
|
|
91
|
+
onClick={(e) => {
|
|
92
|
+
/* close on item click — items inside fire their handler then bubble. */
|
|
93
|
+
if (e.target.closest('[data-menu-item]')) close()
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{typeof children === 'function' ? children({ close }) : children}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* MenuItem — action row inside a MenuPopover. Renders as a button so it
|
|
105
|
+
* picks up disabled state, focus, and keyboard activation. The popover
|
|
106
|
+
* closes automatically when an item is clicked (via the wrapper's
|
|
107
|
+
* delegate click — the data-menu-item attr marks rows for that match).
|
|
108
|
+
*/
|
|
109
|
+
export function MenuItem({ onClick, disabled, shortcut, iconLeft, children }) {
|
|
110
|
+
return (
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
data-menu-item
|
|
114
|
+
onClick={onClick}
|
|
115
|
+
disabled={disabled}
|
|
116
|
+
role="menuitem"
|
|
117
|
+
className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-meta hover:text-emphasis hover:bg-fg-08 disabled:opacity-40 disabled:cursor-not-allowed text-left"
|
|
118
|
+
>
|
|
119
|
+
{iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center text-meta">{iconLeft}</span>}
|
|
120
|
+
<span className="flex-1">{children}</span>
|
|
121
|
+
{shortcut && <span className="kol-helper-10 text-subtle shrink-0">{shortcut}</span>}
|
|
122
|
+
</button>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function MenuDivider() {
|
|
127
|
+
return <div className="border-t border-fg-08 my-1" />
|
|
128
|
+
}
|