@kolkrabbi/kol-component 0.124.0 → 0.126.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.124.0",
3
+ "version": "0.126.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -61,6 +61,19 @@ const Dropdown = ({
61
61
  * Both write variables on the panel that `.kol-dd-list` reads. */
62
62
  maxRows = 10,
63
63
  rowHeight,
64
+ /* `onOptionHover(value | null)` (DropdownOptionHoverPreview, kol-mirror
65
+ * 2026-08-28): the pointer entering a row reports its value, leaving reports
66
+ * `null` — so a picker over a VISUAL setting can preview the hovered option
67
+ * live and revert on leave. mirror's blend-mode picker applies the hovered
68
+ * mode to the composite: you scrub 16 modes against the actual image instead
69
+ * of committing to each one to look at it. The same case is every picker over
70
+ * easing curves, palettes, filters, fonts — the DS owns the panel, only the
71
+ * consumer knows what to preview, so this reports and nothing else.
72
+ *
73
+ * `null` fires ON CLOSE too, and that half is load-bearing: a panel dismissed
74
+ * while a row is hovered would otherwise leave the consumer previewing
75
+ * forever. It never fires while closed — a closed dropdown has no rows. */
76
+ onOptionHover,
64
77
  defaultOpen = false,
65
78
  className = ''
66
79
  }) => {
@@ -96,6 +109,21 @@ const Dropdown = ({
96
109
  setIsOpen(false)
97
110
  }
98
111
 
112
+ /* the hover report, and the close that ends it. `hovered` is a ref, not
113
+ * state — the preview is the consumer's business and re-rendering the panel
114
+ * on every row crossed would be a render per pointermove for nothing. */
115
+ const hovered = useRef(null)
116
+ const reportHover = (v) => {
117
+ if (hovered.current === v) return
118
+ hovered.current = v
119
+ onOptionHover?.(v)
120
+ }
121
+ useEffect(() => {
122
+ if (isOpen || hovered.current == null) return
123
+ hovered.current = null
124
+ onOptionHover?.(null)
125
+ }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
126
+
99
127
  const currentOption = options.find((opt) => opt.value === value) || options[0]
100
128
 
101
129
  /* A clamped list (Popover caps the panel to the viewport) can open with the
@@ -174,6 +202,8 @@ const Dropdown = ({
174
202
  * brighten. The check mark is what marks the current value. */
175
203
  hover={false}
176
204
  height={rowHeight}
205
+ onPointerEnter={onOptionHover ? () => reportHover(option.value) : undefined}
206
+ onPointerLeave={onOptionHover ? () => reportHover(null) : undefined}
177
207
  onClick={() => handleSelect(option)}
178
208
  shortcut={isActive ? <Icon name="check" size={11} /> : undefined}
179
209
  >
@@ -99,12 +99,14 @@ export function MenuItem({
99
99
  * - children — main label, flex-1.
100
100
  * - shortcut — trailing content (text shortcut hint, ✓ marker, or icon).
101
101
  */
102
- export function MenuDropdownItem({ onClick, disabled, prefix, iconLeft, shortcut, hover = true, height, children }) {
102
+ export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disabled, prefix, iconLeft, shortcut, hover = true, height, children }) {
103
103
  return (
104
104
  <button
105
105
  type="button"
106
106
  data-menu-item
107
107
  onClick={onClick}
108
+ onPointerEnter={onPointerEnter}
109
+ onPointerLeave={onPointerLeave}
108
110
  disabled={disabled}
109
111
  role="menuitem"
110
112
  style={height != null ? { height } : undefined}
@@ -46,20 +46,32 @@ export const SPRING = {
46
46
  * near / sleep the pill wakes within 20px of the line and sleeps only past
47
47
  * 40 — hovering the line itself flapped the class every frame
48
48
  * and restarted the fade, so the hysteresis is not optional
49
- * marks it rests on 20/40/60/80 % of the rail's height and is STICKY:
50
- * it leaves a mark only within `stick` of the pitch to another
51
- * (user: "following the mouse a little too much"). The middle
52
- * of each gap is dead on purpose
53
- * travel a long chase, so the pill trails the pointer and catches up
49
+ * stick DWELL, not a grid (user ruling 2026-08-28: "I don't like the
50
+ * snapping of the grabber, it's too far let's make it come to
51
+ * the cursor but have some sticky time where it lands, so it's
52
+ * not constantly jerking"). The pill targets the pointer's own
53
+ * position on the line and re-targets only once the pointer is
54
+ * `stick` px away: it lands ON you, holds while you move inside
55
+ * the radius, then travels to where you are now.
56
+ *
57
+ * It was four marks at 20/40/60/80 % of the edge until this
58
+ * ruling — on a ~900px rail that is ~180px apart, so every
59
+ * re-target was a long throw to a point the pointer was not at,
60
+ * and the dwell read as the pill refusing to come to you and
61
+ * then lurching. The earlier "snap to something so it has
62
+ * somewhere to stick to" is served by dwell; the grid was the
63
+ * wrong model for it.
64
+ * travel the chase. 1.1s, down from 2.8 with the grid: the throw now
65
+ * covers a fraction of the distance, and a long tween over a
66
+ * short throw reads as lag rather than weight
54
67
  * snap the width tween on release
55
68
  * slop under this, a pointerdown/up is a CLICK, not a drag
56
69
  */
57
70
  export const GRAB = {
58
71
  near: 20,
59
72
  sleep: 40,
60
- marks: [0.2, 0.4, 0.6, 0.8],
61
- stick: 0.2 * 0.3,
62
- travel: { duration: 2.8, ease: EASE.outGsap },
73
+ stick: 90,
74
+ travel: { duration: 1.1, ease: EASE.outGsap },
63
75
  snap: { duration: 0.5, ease: EASE.outGsap },
64
76
  slop: 3,
65
77
  }