@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,124 @@
1
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import Button from '../atoms/Button.jsx'
4
+ import Input from '../atoms/Input.jsx'
5
+
6
+ /**
7
+ * Modal — promise-based prompt + confirm dialogs.
8
+ *
9
+ * const { prompt, confirm } = useModal()
10
+ * const name = await prompt('Name this frame:', 'Untitled')
11
+ * const proceed = await confirm('Discard unsaved changes?')
12
+ *
13
+ * Returned promise resolves to:
14
+ * - prompt → string (value) on submit, `null` on cancel
15
+ * - confirm → boolean: `true` on confirm, `false` on cancel
16
+ *
17
+ * Mounted once at the app root (BrandLayout). Renders into `document.body`
18
+ * via portal, so it floats above any rail / scroll-context.
19
+ */
20
+
21
+ const ModalCtx = createContext(null)
22
+
23
+ export function ModalProvider({ children }) {
24
+ const [state, setState] = useState(null)
25
+
26
+ const closeWith = useCallback((value) => {
27
+ setState((s) => {
28
+ if (s) s.resolve(value)
29
+ return null
30
+ })
31
+ }, [])
32
+
33
+ const prompt = useCallback((title, defaultValue = '') =>
34
+ new Promise((resolve) => setState({ kind: 'prompt', title, defaultValue, resolve })),
35
+ [])
36
+
37
+ const confirm = useCallback((title) =>
38
+ new Promise((resolve) => setState({ kind: 'confirm', title, resolve })),
39
+ [])
40
+
41
+ return (
42
+ <ModalCtx.Provider value={{ prompt, confirm }}>
43
+ {children}
44
+ {state && typeof document !== 'undefined' && createPortal(
45
+ <ModalView state={state} closeWith={closeWith} />,
46
+ document.body,
47
+ )}
48
+ </ModalCtx.Provider>
49
+ )
50
+ }
51
+
52
+ function ModalView({ state, closeWith }) {
53
+ const [val, setVal] = useState(state.defaultValue ?? '')
54
+ const inputRef = useRef(null)
55
+
56
+ useEffect(() => {
57
+ if (state.kind === 'prompt') inputRef.current?.focus()
58
+ const onKey = (e) => {
59
+ if (e.key === 'Escape') closeWith(state.kind === 'prompt' ? null : false)
60
+ if (e.key === 'Enter') closeWith(state.kind === 'prompt' ? val : true)
61
+ }
62
+ window.addEventListener('keydown', onKey)
63
+ return () => window.removeEventListener('keydown', onKey)
64
+ }, [state.kind, val, closeWith])
65
+
66
+ const submit = () => closeWith(state.kind === 'prompt' ? val : true)
67
+ const cancel = () => closeWith(state.kind === 'prompt' ? null : false)
68
+
69
+ return (
70
+ <div
71
+ onMouseDown={cancel}
72
+ style={{
73
+ position: 'fixed', inset: 0, zIndex: 1000,
74
+ background: 'rgba(0,0,0,0.5)',
75
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
76
+ }}
77
+ >
78
+ <div
79
+ onMouseDown={(e) => e.stopPropagation()}
80
+ className="kol-modal flex flex-col gap-4 p-5 rounded border border-fg-08"
81
+ style={{
82
+ background: 'var(--kol-surface-primary)',
83
+ color: 'var(--kol-surface-on-primary)',
84
+ minWidth: 320,
85
+ maxWidth: '90vw',
86
+ }}
87
+ >
88
+ <p className="kol-helper-12 text-emphasis">{state.title}</p>
89
+ {state.kind === 'prompt' && (
90
+ <Input
91
+ ref={inputRef}
92
+ variant="outline"
93
+ size="sm"
94
+ value={val}
95
+ onChange={(e) => setVal(e.target.value)}
96
+ className="w-full"
97
+ />
98
+ )}
99
+ <div className="flex gap-2 justify-end">
100
+ <Button variant="secondary" size="sm" onClick={cancel}>Cancel</Button>
101
+ <Button variant="primary" size="sm" onClick={submit}>OK</Button>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ export function useModal() {
109
+ const ctx = useContext(ModalCtx)
110
+ if (ctx) return ctx
111
+ /* No-context fallback — falls back to native prompt/confirm so callers
112
+ * don't need to null-check. */
113
+ return {
114
+ prompt: async (title, def = '') => {
115
+ if (typeof window === 'undefined') return null
116
+ const v = window.prompt(title, def)
117
+ return v
118
+ },
119
+ confirm: async (title) => {
120
+ if (typeof window === 'undefined') return false
121
+ return window.confirm(title)
122
+ },
123
+ }
124
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Pill Component
3
+ *
4
+ * Small badge/tag component for labels, categories, or status indicators
5
+ * Uses existing pill classes from components.css (pill-outline, pill-subtle, pill-inverse)
6
+ *
7
+ * @param {Object} props
8
+ * @param {string} props.children - Text content of the pill
9
+ * @param {string} props.variant - Visual variant: 'outline' | 'subtle' | 'inverse' (default: 'outline')
10
+ * @param {string} props.size - Size variant: 'sm' | 'md' | 'lg' (default: 'md')
11
+ * @param {string} props.className - Additional classes for customization
12
+ */
13
+ const Pill = ({ children, variant = 'outline', size = 'md', className = '' }) => {
14
+ const variantClasses = {
15
+ outline: 'pill-outline',
16
+ subtle: 'pill-subtle',
17
+ inverse: 'pill-inverse'
18
+ }
19
+
20
+ const sizeClasses = {
21
+ sm: 'pill-sm',
22
+ md: 'pill-md',
23
+ lg: 'pill-lg'
24
+ }
25
+
26
+ return (
27
+ <span className={`${variantClasses[variant]} ${sizeClasses[size]} ${className}`.trim()}>
28
+ {children}
29
+ </span>
30
+ )
31
+ }
32
+
33
+ export default Pill
@@ -0,0 +1,208 @@
1
+ import { useState } from 'react'
2
+ import {
3
+ useFloating,
4
+ autoUpdate,
5
+ offset as offsetMw,
6
+ flip as flipMw,
7
+ shift as shiftMw,
8
+ size as sizeMw,
9
+ FloatingPortal,
10
+ FloatingFocusManager,
11
+ useClick,
12
+ useHover,
13
+ useFocus,
14
+ useDismiss,
15
+ useRole,
16
+ useInteractions,
17
+ } from '@floating-ui/react'
18
+
19
+ /**
20
+ * Popover — anchored floating-element primitive built on @floating-ui/react.
21
+ * Two surfaces:
22
+ *
23
+ * 1. `usePopover({ open, onOpenChange, ... })` — full hook. Returns refs +
24
+ * `getReferenceProps` / `getFloatingProps` so the consumer wires the
25
+ * anchor and the floater explicitly. Use when the trigger is non-trivial
26
+ * (custom component, span wrapper, anchor on something other than a
27
+ * button).
28
+ *
29
+ * const popover = usePopover({ open, onOpenChange })
30
+ * <button ref={popover.refs.setReference} {...popover.getReferenceProps()} />
31
+ * <PopoverPanel popover={popover}>...</PopoverPanel>
32
+ *
33
+ * 2. `<PopoverPanel popover={...}>` — renders the floater into a
34
+ * `FloatingPortal` (escapes overflow:hidden ancestors), wraps in
35
+ * `FloatingFocusManager` (focus trap + return-focus), applies the
36
+ * `.kol-popover` panel chrome.
37
+ *
38
+ * Position middleware: `offset` (gap), `flip` (auto-flip on overflow),
39
+ * `shift` (slide along the cross-axis to stay in viewport), optional
40
+ * `size` (clamp to available width/height). `autoUpdate` keeps everything
41
+ * synced on scroll / resize / layout shift.
42
+ *
43
+ * Interaction defaults: click toggles, outside-click + Esc dismiss, role
44
+ * defaults to `dialog` (use `role: 'menu' | 'listbox' | 'tooltip'` for
45
+ * other shapes — also drives the right ARIA attrs on the reference).
46
+ */
47
+ export function usePopover({
48
+ open,
49
+ onOpenChange,
50
+ placement = 'bottom-start',
51
+ offset = 6,
52
+ flip = true,
53
+ flipPadding = 8,
54
+ shiftPadding = 8,
55
+ matchReferenceWidth = false,
56
+ click = true,
57
+ hover = false,
58
+ hoverDelay = { open: 400, close: 100 },
59
+ focus = false,
60
+ dismiss = true,
61
+ role = 'dialog',
62
+ referenceElement = null,
63
+ } = {}) {
64
+ const middleware = [offsetMw(offset)]
65
+ if (flip) middleware.push(flipMw({ padding: flipPadding }))
66
+ middleware.push(shiftMw({ padding: shiftPadding }))
67
+ if (matchReferenceWidth) {
68
+ middleware.push(
69
+ sizeMw({
70
+ apply({ rects, elements }) {
71
+ Object.assign(elements.floating.style, {
72
+ minWidth: `${rects.reference.width}px`,
73
+ })
74
+ },
75
+ })
76
+ )
77
+ }
78
+
79
+ /* `elements.reference` lets the consumer anchor the popover to an
80
+ * external DOM node (e.g. a parent container ref) instead of wiring
81
+ * `setReference` onto the trigger. Used by TypeBlockToolbar to anchor
82
+ * to its TypeFrame parent. */
83
+ const data = useFloating({
84
+ open,
85
+ onOpenChange,
86
+ placement,
87
+ middleware,
88
+ whileElementsMounted: autoUpdate,
89
+ elements: referenceElement ? { reference: referenceElement } : undefined,
90
+ })
91
+
92
+ const interactions = useInteractions([
93
+ useClick(data.context, { enabled: click }),
94
+ useHover(data.context, { enabled: hover, delay: hoverDelay, move: false }),
95
+ useFocus(data.context, { enabled: focus }),
96
+ useDismiss(data.context, { enabled: dismiss }),
97
+ useRole(data.context, { role }),
98
+ ])
99
+
100
+ return { ...data, ...interactions, open }
101
+ }
102
+
103
+ /**
104
+ * Tooltip — hover-triggered popover with text content. Wraps any trigger
105
+ * element. Built on `usePopover` with `hover: true`, `click: false`,
106
+ * `role: 'tooltip'`. Includes keyboard focus trigger so tab-focused
107
+ * controls also reveal the tooltip.
108
+ *
109
+ * <Tooltip label="Pattern" shortcut="P" placement="bottom">
110
+ * <Button iconOnly="ptrn-checker" ... />
111
+ * </Tooltip>
112
+ */
113
+ export function Tooltip({
114
+ label,
115
+ shortcut,
116
+ placement = 'bottom',
117
+ offset = 6,
118
+ children,
119
+ triggerClassName = 'inline-flex',
120
+ }) {
121
+ const [open, setOpen] = useState(false)
122
+ const popover = usePopover({
123
+ open,
124
+ onOpenChange: setOpen,
125
+ placement,
126
+ offset,
127
+ role: 'tooltip',
128
+ click: false,
129
+ hover: true,
130
+ focus: true,
131
+ })
132
+
133
+ return (
134
+ <>
135
+ <span
136
+ ref={popover.refs.setReference}
137
+ {...popover.getReferenceProps()}
138
+ className={triggerClassName}
139
+ >
140
+ {children}
141
+ </span>
142
+ <PopoverPanel
143
+ popover={popover}
144
+ focus={false}
145
+ panel={false}
146
+ className="kol-tooltip"
147
+ >
148
+ <span className="text-emphasis">{label}</span>
149
+ {shortcut && <span className="kol-tooltip-key">{shortcut}</span>}
150
+ </PopoverPanel>
151
+ </>
152
+ )
153
+ }
154
+
155
+ /**
156
+ * PopoverPanel — renders the floater into a portal with default panel chrome.
157
+ *
158
+ * Props:
159
+ * popover — the value returned from `usePopover`
160
+ * panel — apply default `.kol-popover` chrome (default: true)
161
+ * modal — focus modality (default: false — non-modal popover)
162
+ * focus — wrap in FloatingFocusManager (default: true)
163
+ * className — extra classes on the panel
164
+ * style — extra inline styles merged with floatingStyles
165
+ */
166
+ export function PopoverPanel({
167
+ popover,
168
+ children,
169
+ panel = true,
170
+ modal = false,
171
+ focus = true,
172
+ className = '',
173
+ style: extraStyle,
174
+ }) {
175
+ if (!popover.open) return null
176
+
177
+ const { refs, floatingStyles, context, getFloatingProps } = popover
178
+ const cls = [panel && 'kol-popover', className].filter(Boolean).join(' ')
179
+
180
+ /* `data-editor-keep-selection` mirrors the marker on the EditorShell
181
+ * root. Popovers render via FloatingPortal (mounted on <body>, outside
182
+ * the shell), so the editor's document-level click-away handler would
183
+ * otherwise treat clicks on popover content as "outside the editor"
184
+ * and deselect. Tagging the panel here keeps that handler happy for
185
+ * every popover — color picker, dropdowns, menus, tooltips. Harmless
186
+ * for non-editor consumers since they ignore the attr. */
187
+ const node = (
188
+ <div
189
+ ref={refs.setFloating}
190
+ style={extraStyle ? { ...floatingStyles, ...extraStyle } : floatingStyles}
191
+ className={cls}
192
+ data-editor-keep-selection
193
+ {...getFloatingProps()}
194
+ >
195
+ {children}
196
+ </div>
197
+ )
198
+
199
+ return (
200
+ <FloatingPortal>
201
+ {focus
202
+ ? <FloatingFocusManager context={context} modal={modal}>{node}</FloatingFocusManager>
203
+ : node}
204
+ </FloatingPortal>
205
+ )
206
+ }
207
+
208
+ export default PopoverPanel
@@ -0,0 +1,32 @@
1
+ /**
2
+ * PropertyInput — stacked label + input for inspector-style property panels.
3
+ * type="number" → Stepper (chevron buttons)
4
+ * type="text" / "color" / etc → Input (filled variant)
5
+ *
6
+ * Use in a `grid grid-cols-2 gap-4` for x/y/width/height/rotation row pairs.
7
+ */
8
+ import Label from '../atoms/Label'
9
+ import Input from '../atoms/Input.jsx'
10
+ import Stepper from '../atoms/Stepper'
11
+
12
+ export default function PropertyInput({
13
+ label,
14
+ value,
15
+ onChange,
16
+ type = 'text',
17
+ min,
18
+ max,
19
+ step,
20
+ className = '',
21
+ }) {
22
+ return (
23
+ <div className={`flex flex-col gap-2 ${className}`}>
24
+ <Label className="kol-helper-10">{label}</Label>
25
+ {type === 'number' ? (
26
+ <Stepper value={value} onChange={onChange} min={min} max={max} step={step} />
27
+ ) : (
28
+ <Input type={type} value={value} onChange={onChange} className="w-full" />
29
+ )}
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,174 @@
1
+ import { useEffect, useState } 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
+ const QuantityInput = ({
10
+ value = 1,
11
+ onChange,
12
+ min = 1,
13
+ max = 99,
14
+ size,
15
+ className = ''
16
+ }) => {
17
+ const [resolvedSize, setResolvedSize] = useState('md')
18
+ const [componentWidth, setComponentWidth] = useState('180px')
19
+
20
+ useEffect(() => {
21
+ const determineSize = () => {
22
+ if (size) {
23
+ setResolvedSize(size)
24
+ return
25
+ }
26
+
27
+ if (typeof window === 'undefined') {
28
+ setResolvedSize('md')
29
+ return
30
+ }
31
+
32
+ if (window.innerWidth >= 1024) {
33
+ setResolvedSize('lg')
34
+ } else if (window.innerWidth >= 768) {
35
+ setResolvedSize('md')
36
+ } else {
37
+ setResolvedSize('sm')
38
+ }
39
+ }
40
+
41
+ determineSize()
42
+ window.addEventListener('resize', determineSize)
43
+ return () => window.removeEventListener('resize', determineSize)
44
+ }, [size])
45
+
46
+ useEffect(() => {
47
+ const updateWidth = () => {
48
+ if (typeof window === 'undefined') return
49
+
50
+ if (window.innerWidth >= 1024) {
51
+ setComponentWidth('180px')
52
+ } else if (window.innerWidth >= 768) {
53
+ setComponentWidth('140px')
54
+ } else {
55
+ setComponentWidth('100px')
56
+ }
57
+ }
58
+ updateWidth()
59
+ window.addEventListener('resize', updateWidth)
60
+ return () => window.removeEventListener('resize', updateWidth)
61
+ }, [])
62
+
63
+ const metrics = SIZE_MAP[resolvedSize] || SIZE_MAP.md
64
+
65
+ const increment = () => {
66
+ if (value < max) onChange?.(value + 1)
67
+ }
68
+
69
+ const decrement = () => {
70
+ if (value > min) onChange?.(value - 1)
71
+ }
72
+
73
+ return (
74
+ <div
75
+ className={`relative block ${className}`}
76
+ >
77
+ {/* Border wrapper - EXPLICIT 42.5px height */}
78
+ <div
79
+ className="w-full flex items-center"
80
+ style={{
81
+ position: 'relative',
82
+ height: '42.5px',
83
+ border: '1px solid var(--kol-border-default)',
84
+ borderRadius: `${metrics.radius}px`,
85
+ backgroundColor: 'var(--kol-surface-primary)',
86
+ color: 'var(--kol-surface-on-primary)',
87
+ paddingLeft: `${metrics.paddingX}px`,
88
+ paddingRight: `${metrics.paddingX}px`,
89
+ fontSize: `${metrics.fontSize}px`,
90
+ lineHeight: '120%',
91
+ fontFamily: 'var(--kol-font-family-mono)',
92
+ boxSizing: 'border-box'
93
+ }}
94
+ >
95
+ <span>{value}</span>
96
+
97
+ {/* Chevrons - absolute, OUTSIDE the padded content */}
98
+ <div
99
+ style={{
100
+ position: 'absolute',
101
+ right: `${metrics.paddingX}px`,
102
+ top: '50%',
103
+ transform: 'translateY(-50%)',
104
+ display: 'flex',
105
+ flexDirection: 'column'
106
+ }}
107
+ >
108
+ <button
109
+ type="button"
110
+ onClick={increment}
111
+ disabled={value >= max}
112
+ style={{
113
+ backgroundColor: 'transparent',
114
+ border: 'none',
115
+ padding: '0',
116
+ cursor: value >= max ? 'not-allowed' : 'pointer',
117
+ opacity: value >= max ? 0.3 : 1,
118
+ lineHeight: 0,
119
+ display: 'block'
120
+ }}
121
+ aria-label="Increase quantity"
122
+ >
123
+ <svg
124
+ width={metrics.icon}
125
+ height={metrics.icon}
126
+ viewBox="0 0 12 12"
127
+ fill="none"
128
+ >
129
+ <path
130
+ d="m3 7 3-3 3 3"
131
+ stroke="currentColor"
132
+ strokeWidth="1.25"
133
+ strokeLinecap="round"
134
+ strokeLinejoin="round"
135
+ />
136
+ </svg>
137
+ </button>
138
+ <button
139
+ type="button"
140
+ onClick={decrement}
141
+ disabled={value <= min}
142
+ style={{
143
+ backgroundColor: 'transparent',
144
+ border: 'none',
145
+ padding: '0',
146
+ cursor: value <= min ? 'not-allowed' : 'pointer',
147
+ opacity: value <= min ? 0.3 : 1,
148
+ lineHeight: 0,
149
+ display: 'block'
150
+ }}
151
+ aria-label="Decrease quantity"
152
+ >
153
+ <svg
154
+ width={metrics.icon}
155
+ height={metrics.icon}
156
+ viewBox="0 0 12 12"
157
+ fill="none"
158
+ >
159
+ <path
160
+ d="m3 5 3 3 3-3"
161
+ stroke="currentColor"
162
+ strokeWidth="1.25"
163
+ strokeLinecap="round"
164
+ strokeLinejoin="round"
165
+ />
166
+ </svg>
167
+ </button>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ )
172
+ }
173
+
174
+ export default QuantityInput