@kolkrabbi/kol-component 0.244.0 → 0.245.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.244.0",
3
+ "version": "0.245.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,8 +29,12 @@
29
29
  * strip (canvas alignment, transform cluster); onChange is the
30
30
  * action dispatch.
31
31
  * onChange — handler (newValue) => void
32
- * options — [{ value, label, ariaLabel?, tooltip? }] — a cell whose label is not a string
33
- * (a glyph) shows its `ariaLabel` as a KOL Tooltip; `tooltip` overrides it
32
+ * options — [{ value, label, ariaLabel?, tooltip?, disabled? }] — a cell whose label is not a string
33
+ * (a glyph) shows its `ariaLabel` as a KOL Tooltip; `tooltip` overrides it.
34
+ * `disabled` (kol-fxr 2026-10-09): the cell draws the family's disabled ink
35
+ * (`aria-disabled`, not the attribute — the tooltip still gives the reason),
36
+ * refuses the press, and ←/→ step over it. It may still be the current `value`
37
+ * (the ground changed under it): selected + disabled, no throw.
34
38
  * variant — 'default' (shipped chrome: shared outer stroke + dividers,
35
39
  * filled active cell) | 'filled' (the state law's tiles: every
36
40
  * cell a surface-secondary tile with 1px transparent gaps, NO
@@ -66,7 +70,12 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
66
70
  const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key]
67
71
  if (!dir || !options.length) return
68
72
  e.preventDefault()
69
- const next = (focusIdx + dir + options.length) % options.length
73
+ let next = focusIdx
74
+ for (let step = 0; step < options.length; step++) {
75
+ next = (next + dir + options.length) % options.length
76
+ if (!options[next].disabled) break
77
+ }
78
+ if (options[next].disabled) return
70
79
  onChange?.(options[next].value)
71
80
  e.currentTarget.children[next]?.focus()
72
81
  }
@@ -94,8 +103,9 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
94
103
  role={stateless ? undefined : 'radio'}
95
104
  aria-checked={stateless ? undefined : isActive}
96
105
  aria-label={opt.ariaLabel}
106
+ aria-disabled={opt.disabled || undefined}
97
107
  tabIndex={stateless ? 0 : (i === focusIdx ? 0 : -1)}
98
- onClick={() => onChange?.(opt.value)}
108
+ onClick={opt.disabled ? undefined : () => onChange?.(opt.value)}
99
109
  className={['kol-seg-cell', cellType, isActive && 'is-active'].filter(Boolean).join(' ')}
100
110
  >
101
111
  {opt.label}
@@ -0,0 +1,51 @@
1
+ import { useRef, useState } from 'react'
2
+
3
+ /**
4
+ * usePointerSort — a vertical reorder by pointer, the ~50 lines `RecordManager` carried inline
5
+ * (2026-08-09, "util before any dependency"), lifted so `StepList` is not a second copy (kol-fxr
6
+ * StepList, 2026-10-09). Pointer events, so a finger works where HTML drag does not.
7
+ *
8
+ * const { drag, startDrag } = usePointerSort(onReorder)
9
+ * <button onPointerDown={(e) => startDrag(e, i, rowEls)} className="touch-none">⠿</button>
10
+ *
11
+ * `startDrag(e, from, rowEls)`: `rowEls` is the list of row elements in order (a NodeList or an
12
+ * array); their rects are measured once at the start. While the pointer is down `drag` is
13
+ * `{ from, over, x, y }` — `over` is the row under the pointer, `x`/`y` the pointer, for a
14
+ * floating label — and null otherwise. On release `onReorder(from, over)` fires when they differ.
15
+ * Without `onReorder` a start is a no-op, so a handle can render conditionally on it.
16
+ */
17
+ export default function usePointerSort(onReorder) {
18
+ const dragRef = useRef(null)
19
+ const [drag, setDrag] = useState(null)
20
+
21
+ const startDrag = (e, from, rowEls) => {
22
+ if (!onReorder) return
23
+ e.preventDefault()
24
+ if (!rowEls?.length) return
25
+ const rects = [...rowEls].map((el) => el.getBoundingClientRect())
26
+ const move = (ev) => {
27
+ const hit = rects.findIndex((r) => ev.clientY < r.bottom)
28
+ const over = hit === -1 ? rects.length - 1 : hit
29
+ dragRef.current = { from, over, x: ev.clientX, y: ev.clientY }
30
+ setDrag(dragRef.current)
31
+ }
32
+ const up = () => {
33
+ window.removeEventListener('pointermove', move)
34
+ window.removeEventListener('pointerup', up)
35
+ document.body.style.cursor = ''
36
+ document.body.style.userSelect = ''
37
+ const d = dragRef.current
38
+ dragRef.current = null
39
+ setDrag(null)
40
+ if (d && d.over !== d.from) onReorder(d.from, d.over)
41
+ }
42
+ window.addEventListener('pointermove', move)
43
+ window.addEventListener('pointerup', up)
44
+ document.body.style.cursor = 'grabbing'
45
+ document.body.style.userSelect = 'none'
46
+ dragRef.current = { from, over: from, x: e.clientX, y: e.clientY }
47
+ setDrag(dragRef.current)
48
+ }
49
+
50
+ return { drag, startDrag }
51
+ }
package/src/index.js CHANGED
@@ -79,6 +79,7 @@ export { default as InspectorRail } from './molecules/InspectorRail.jsx'
79
79
  export { default as PathNodeOverlay } from './utilities/PathNodeOverlay.jsx'
80
80
  export { default as CropOverlay } from './utilities/CropOverlay.jsx'
81
81
  export { default as LayerStack, AddLayerButton, BLEND_MODES } from './organisms/LayerStack.jsx'
82
+ export { default as StepList } from './molecules/StepList.jsx'
82
83
  export { TYPE_LABELS, BOOL_OP_LABELS, SHAPE_KIND_LABELS, labelForLayer, rowLabelForLayer, findLayerDeep } from './hooks/layerTree.js'
83
84
  export { default as TimelineDock, sampleTrack, TIMELINE_EASINGS } from './organisms/TimelineDock.jsx'
84
85
  export { default as CurveEditor, CURVE_KINDS, defaultCurveFor } from './organisms/CurveEditor.jsx'
@@ -0,0 +1,109 @@
1
+ import { useRef } from 'react'
2
+ import Button from '../atoms/Button.jsx'
3
+ import { Icon } from '@kolkrabbi/kol-icons'
4
+ import usePointerSort from '../hooks/usePointerSort.js'
5
+
6
+ /**
7
+ * StepList — A numbered list of slots ordered by hand. the Morph rail's steps in kol-fxr (each row a
8
+ * generator preset, the morph tweening from row 1 to row N), generic enough for any ordered slots —
9
+ * a playlist, a chain of stages (lobby: StepList, kol-fxr 2026-10-09). One row is active, × removes
10
+ * a row, the dashed last row adds one.
11
+ *
12
+ * <StepList items={steps} activeIndex={i} onSelect={setI} onRemove={remove} onMove={move} onAdd={add} />
13
+ *
14
+ * Reorder is `usePointerSort` — the pointer sort `RecordManager` runs — not the HTML drag the brief
15
+ * named from `LayerStack`: HTML drag does nothing under a finger, and fxr runs on a phone. The
16
+ * `arrows` variant is the keyboard path.
17
+ *
18
+ * @param {{ id: string|number, label: import('react').ReactNode }[]} items the rows, in order
19
+ * @param {number|null} activeIndex the highlighted (editing) row
20
+ * @param {'grab'|'arrows'} reorder `grab`: a ⠿ handle and a pointer drag (touch too) · `arrows`: ↑ ↓ quiet buttons, one slot a press
21
+ * @param {boolean} readOnly no handle or arrows, no ×, no add row; the body does not open
22
+ * @param {'xs'|'sm'|'md'|'lg'} size the control rung the × and the arrows ride
23
+ * @param {(index: number) => void} onSelect the body pressed
24
+ * @param {(index: number) => void} onRemove ×
25
+ * @param {(from: number, to: number) => void} onMove after a drop or an arrow press
26
+ * @param {() => void} onAdd the dashed row
27
+ * @param {import('react').ReactNode} addLabel the dashed row's words — the consumer's copy
28
+ * @param {string} className on the list
29
+ */
30
+ export default function StepList({
31
+ items = [],
32
+ activeIndex = null,
33
+ reorder = 'grab',
34
+ readOnly = false,
35
+ size = 'sm',
36
+ onSelect,
37
+ onRemove,
38
+ onMove,
39
+ onAdd,
40
+ addLabel = 'Add a step',
41
+ className = '',
42
+ }) {
43
+ const listRef = useRef(null)
44
+ const { drag, startDrag } = usePointerSort(readOnly ? null : onMove)
45
+ const grab = !readOnly && reorder === 'grab' && !!onMove
46
+ const arrows = !readOnly && reorder === 'arrows' && !!onMove
47
+ const rows = () => listRef.current?.querySelectorAll('.kol-step-list-item')
48
+
49
+ return (
50
+ <ul ref={listRef} className={`flex flex-col gap-1 ${className}`.trim()}>
51
+ {items.map((it, i) => {
52
+ const active = i === activeIndex
53
+ const dragging = drag && drag.from === i
54
+ const over = drag && drag.over === i && drag.over !== drag.from
55
+ const mark = over ? (drag.over > drag.from ? 'bottom-0' : 'top-0') : null
56
+ return (
57
+ <li
58
+ key={it.id}
59
+ className={`kol-step-list-item relative flex items-center gap-2 rounded px-2 py-1${active ? ' bg-fg-08' : ''}${dragging ? ' opacity-40' : ''}`}
60
+ >
61
+ {/* the drop mark: a 1px accent line above or below the row under the pointer */}
62
+ {mark && <span aria-hidden="true" className={`absolute left-2 right-2 h-px ${mark}`} style={{ background: 'var(--kol-accent-primary)' }} />}
63
+ {grab && (
64
+ <button
65
+ type="button"
66
+ aria-label={`Reorder step ${i + 1}`}
67
+ className="cursor-grab shrink-0 touch-none border-0 bg-transparent p-0"
68
+ onPointerDown={(e) => startDrag(e, i, rows())}
69
+ >
70
+ <Icon name="drag-handle" size={12} className="text-oq-48" />
71
+ </button>
72
+ )}
73
+ {arrows && (
74
+ <span className="flex shrink-0 items-center">
75
+ <Button tone="ghost" quiet iconOnly="chevron-up" size={size} aria-label={`Move step ${i + 1} up`} disabled={i === 0} onClick={() => onMove(i, i - 1)} />
76
+ <Button tone="ghost" quiet iconOnly="chevron-down" size={size} aria-label={`Move step ${i + 1} down`} disabled={i === items.length - 1} onClick={() => onMove(i, i + 1)} />
77
+ </span>
78
+ )}
79
+ <button
80
+ type="button"
81
+ className="flex min-w-0 flex-1 items-center gap-2 border-0 bg-transparent p-0 text-left"
82
+ onClick={readOnly ? undefined : () => onSelect?.(i)}
83
+ disabled={readOnly}
84
+ >
85
+ <span className="kol-helper-10 text-meta tabular-nums w-4 shrink-0">{i + 1}</span>
86
+ <span className="kol-mono-12 text-emphasis truncate">{it.label}</span>
87
+ </button>
88
+ {!readOnly && onRemove && (
89
+ <Button tone="ghost" quiet iconOnly="x" size={size} aria-label={`Remove step ${i + 1}`} onClick={() => onRemove(i)} />
90
+ )}
91
+ </li>
92
+ )
93
+ })}
94
+ {!readOnly && onAdd && (
95
+ <li>
96
+ <button
97
+ type="button"
98
+ className="flex w-full items-center gap-2 rounded border border-dashed border-oq-16 px-2 py-1 text-meta hover:text-body hover:border-oq-24"
99
+ onClick={onAdd}
100
+ >
101
+ <span className="kol-helper-10 tabular-nums w-4 shrink-0">{items.length + 1}</span>
102
+ <Icon name="plus" size={12} className="text-oq-48" />
103
+ <span className="kol-mono-12">{addLabel}</span>
104
+ </button>
105
+ </li>
106
+ )}
107
+ </ul>
108
+ )
109
+ }
@@ -10,6 +10,7 @@ import FieldRow, { StatusChip } from '../molecules/FieldRow'
10
10
  import { Tooltip } from '../utilities/Popover'
11
11
  import Table from './Table'
12
12
  import MediaLibrary from './MediaLibrary'
13
+ import usePointerSort from '../hooks/usePointerSort.js'
13
14
 
14
15
  /* TOOLBAR ICONS ARE BARE GLYPHS (user ruling 2026-08-09, verbatim: "did I
15
16
  * ask for a button?? … the only thing that should hover is full opacity and
@@ -93,8 +94,9 @@ export default function RecordManager({
93
94
  }) {
94
95
  const wrapRef = useRef(null)
95
96
  const [searchOpen, setSearchOpen] = useState(false)
96
- const dragRef = useRef(null)
97
- const [drag, setDrag] = useState(null) // { from, over, x, y } during a drag
97
+ /* { from, over, x, y } during a drag — the sort lives in hooks/usePointerSort (lifted 2026-10-09
98
+ * so StepList is not a second copy); this file keeps only which rows it measures */
99
+ const { drag, startDrag: beginSort } = usePointerSort(onReorder)
98
100
  const [active, setActive] = useState(null)
99
101
  const [picker, setPicker] = useState(null) // the field being picked for
100
102
  const [selected, setSelected] = useState(() => new Set())
@@ -118,36 +120,7 @@ export default function RecordManager({
118
120
  onSelectionChange?.([...next])
119
121
  }
120
122
 
121
- /* ── pointer sort ─────────────────────────────────────────────────────── */
122
- const startDrag = (e, from) => {
123
- if (!onReorder) return
124
- e.preventDefault()
125
- const rowEls = wrapRef.current?.querySelectorAll('.kol-table-row')
126
- if (!rowEls?.length) return
127
- const rects = [...rowEls].map((el) => el.getBoundingClientRect())
128
- const move = (ev) => {
129
- const hit = rects.findIndex((r) => ev.clientY < r.bottom)
130
- const over = hit === -1 ? rects.length - 1 : hit
131
- dragRef.current = { from, over, x: ev.clientX, y: ev.clientY }
132
- setDrag(dragRef.current)
133
- }
134
- const up = () => {
135
- window.removeEventListener('pointermove', move)
136
- window.removeEventListener('pointerup', up)
137
- document.body.style.cursor = ''
138
- document.body.style.userSelect = ''
139
- const d = dragRef.current
140
- dragRef.current = null
141
- setDrag(null)
142
- if (d && d.over !== d.from) onReorder(d.from, d.over)
143
- }
144
- window.addEventListener('pointermove', move)
145
- window.addEventListener('pointerup', up)
146
- document.body.style.cursor = 'grabbing'
147
- document.body.style.userSelect = 'none'
148
- dragRef.current = { from, over: from, x: e.clientX, y: e.clientY }
149
- setDrag(dragRef.current)
150
- }
123
+ const startDrag = (e, from) => beginSort(e, from, wrapRef.current?.querySelectorAll('.kol-table-row'))
151
124
 
152
125
  /* ── columns: resolve types, prepend handle + checkbox ────────────────── */
153
126
  const builtColumns = useMemo(() => {