@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,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
+ }