@kolkrabbi/kol-component 0.21.0 → 0.25.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.21.0",
3
+ "version": "0.25.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",
@@ -24,7 +24,7 @@
24
24
  "@floating-ui/react": "^0.27.19",
25
25
  "embla-carousel-react": "^8.6.0",
26
26
  "react-syntax-highlighter": "^16.1.1",
27
- "@kolkrabbi/kol-icons": "0.8.11"
27
+ "@kolkrabbi/kol-icons": "0.10.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "framer-motion": "^12.0.0",
@@ -11,7 +11,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
11
11
  *
12
12
  * @param {Object} props
13
13
  * @param {ReactNode} props.children - Button content
14
- * @param {'primary'|'secondary'|'accent'|'outline'|'ghost'|'danger'|'grey'|'control'} props.variant - Visual variant. `danger` is the destructive treatment (--ui-error fill); `control` is an alias for `ghost` (legacy call-sites).
14
+ * @param {'primary'|'secondary'|'accent'|'outline'|'ghost'|'nav'|'danger'|'grey'|'control'} props.variant - Visual variant. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
15
15
  * @param {'sm'|'md'|'lg'} props.size - Button size (default: 'md')
16
16
  * @param {string} props.iconLeft - Icon name to display on the left
17
17
  * @param {string} props.iconRight - Icon name to display on the right
@@ -82,6 +82,14 @@ const Button = ({
82
82
  ? 'kol-btn-danger'
83
83
  : resolvedVariant === 'grey'
84
84
  ? 'kol-btn-grey'
85
+ /* `nav` — transparent box, oq-64 ink (2026-08-01). The class had lived in
86
+ * the theme since the shell landed with NO component able to emit it, so
87
+ * every consumer that wanted this exact weight hand-wrote the box instead:
88
+ * that orphan is the direct cause of the four-container header. It is the
89
+ * chrome rung — one step brighter than `ghost` at oq-48 — and it is what
90
+ * `text-fg-64` meant every time a call site typed it. */
91
+ : resolvedVariant === 'nav'
92
+ ? 'kol-btn-nav'
85
93
  : 'kol-btn-secondary'
86
94
 
87
95
  // Add size class — pairs the padding rule with its mono type class.
@@ -36,8 +36,27 @@ import { SOLO } from '../hooks/glyphLadders.js'
36
36
  * moves with it — that is the 2026-07-28 law, and it only means something if the
37
37
  * two are separable.
38
38
  *
39
- * Deliberately absent: `onClick`, `href`, `disabled`, `aria-pressed`, `title`.
40
- * Wanting any of those means wanting a `Button` with `iconOnly`, not this.
39
+ * IT TAKES A CLICK (2026-08-01 ruling) — *"you dont use a button… you use the
40
+ * ICON COMPONENT… it has no interactive states."* This file used to say the
41
+ * opposite, and called it "the entire point": no `onClick`, no `href`, and
42
+ * *"wanting any of those means wanting a Button with iconOnly, not this."*
43
+ *
44
+ * That was one sentence too strong. Two separate things had been welded
45
+ * together — **is it clickable** and **does it light up** — and only the second
46
+ * was ever the point. The shell header proved it: six chrome controls all
47
+ * needing a click, none wanting a hover wash, so every one of them hand-wrote
48
+ * its own box and the row ended up on four different containers.
49
+ *
50
+ * So `onClick` renders a `<button>`, `href` renders an `<a>`, and neither
51
+ * gains a state rule. The UA's own button chrome is reset in the theme
52
+ * (`button.kol-icon-frame, a.kol-icon-frame`) so "no states" is a property of
53
+ * the CLASS rather than of the tag — which is exactly the correction this
54
+ * component's own docstring already argued for when it refused to borrow
55
+ * `kol-btn-*` on a span.
56
+ *
57
+ * Still deliberately absent: `disabled` and `aria-pressed`. Both describe a
58
+ * control that CHANGES appearance with state, which is the line that stays.
59
+ * Want the wash, the pressed fill or the disabled dim? That is `Button`.
41
60
  *
42
61
  * @param {string} name icon name (kol-icons)
43
62
  * @param {string} variant primary|secondary|accent|outline|ghost|nav|grey|danger
@@ -62,18 +81,39 @@ export default function IconFrame({
62
81
  size = 'md',
63
82
  radius = 'sm',
64
83
  iconSize = null,
84
+ onClick,
85
+ href,
65
86
  className = '',
66
87
  ...rest
67
88
  }) {
68
89
  if (!name) return null
69
90
  const radiusCls = radius === 'full' ? ' kol-icon-frame-radius-full' : ''
70
91
  const resolvedIconSize = iconSize ?? GLYPH[size] ?? GLYPH.md
92
+ const cls = `kol-icon-frame kol-icon-frame-${variant} kol-icon-frame-${size}${radiusCls} ${className}`.trim()
93
+ const glyph = <Icon name={name} size={resolvedIconSize} />
94
+
95
+ /* The element follows the affordance, and the CLASS is identical in all three
96
+ * branches — that is the whole contract. A frame that can be clicked must be
97
+ * a real button or a real link (keyboard, focus order, middle-click, screen
98
+ * readers); a frame that cannot must not be either, or it lands in the tab
99
+ * order announcing itself as something to press. */
100
+ if (href) {
101
+ return (
102
+ <a className={cls} href={href} {...rest}>
103
+ {glyph}
104
+ </a>
105
+ )
106
+ }
107
+ if (onClick) {
108
+ return (
109
+ <button type="button" className={cls} onClick={onClick} {...rest}>
110
+ {glyph}
111
+ </button>
112
+ )
113
+ }
71
114
  return (
72
- <span
73
- className={`kol-icon-frame kol-icon-frame-${variant} kol-icon-frame-${size}${radiusCls} ${className}`.trim()}
74
- {...rest}
75
- >
76
- <Icon name={name} size={resolvedIconSize} />
115
+ <span className={cls} {...rest}>
116
+ {glyph}
77
117
  </span>
78
118
  )
79
119
  }
@@ -29,8 +29,15 @@ export default function useScrollSpy(ids, { rootMargin = '-30% 0px -60% 0px', ed
29
29
  const atTop = top < edgeOffset
30
30
  const atBottom = top + viewH >= fullH - edgeOffset * 0.8
31
31
  if (atTop) {
32
+ /* THE FIRST HEADING, not null (user ruling 2026-08-01): *"at any given
33
+ * time you are at some place in the file, THAT LOCATION SHOULD
34
+ * HIGHLIGHT"*. The top lock used to clear the active id, so the rail
35
+ * highlighted nothing at rest — and a page opens at rest, which made
36
+ * "no active row" the state the reader saw first and most. The bottom
37
+ * lock has always activated the LAST id; this is that rule, both ends.
38
+ * You are at the top of the document, so you are in its first section. */
32
39
  edgeLockRef.current = 'top'
33
- setActiveId(null)
40
+ setActiveId(ids[0])
34
41
  } else if (atBottom) {
35
42
  edgeLockRef.current = 'bottom'
36
43
  setActiveId(ids[ids.length - 1])
package/src/index.js CHANGED
@@ -71,6 +71,7 @@ export { default as ColorInputRow } from './molecules/ColorInputRow.jsx'
71
71
  export { default as ColorRamp } from './molecules/ColorRamp.jsx'
72
72
  export { default as ColorSwatch } from './molecules/ColorSwatch.jsx'
73
73
  export { default as Dropdown } from './molecules/Dropdown.jsx'
74
+ export { default as FieldRow, StatusChip } from './molecules/FieldRow.jsx'
74
75
  export { default as FramedMediaBand } from './molecules/FramedMediaBand.jsx'
75
76
  export { default as Image } from './molecules/Image.jsx'
76
77
  export { default as MediaCard } from './molecules/MediaCard.jsx'
@@ -113,6 +114,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
113
114
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
114
115
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
115
116
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
117
+ export { default as RecordManager } from './organisms/RecordManager.jsx'
116
118
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
117
119
  export { default as Table } from './organisms/Table.jsx'
118
120
 
@@ -123,6 +125,14 @@ export { Icon } from '@kolkrabbi/kol-icons'
123
125
  export { default as Graphic, GRAPHICS } from './graphics/Graphic.jsx'
124
126
  export { GRAPHIC_RAW } from './graphics/graphicData.js'
125
127
 
128
+ /* The glyph ladders, exported 2026-08-01. They were internal, so anything
129
+ * OUTSIDE this package that pairs an icon with a label had to hardcode a
130
+ * number — the shell header's tabs took `size={14}`, foundry's section header
131
+ * took `20`, and neither could reference the rule it was meant to follow.
132
+ * Cross-package imports go through the `@kolkrabbi/*` specifier (ARCHITECTURE
133
+ * §3), so an export is the only way another package can obey the ladder. */
134
+ export { SOLO, ADJACENT, glyphSize } from './hooks/glyphLadders.js'
135
+
126
136
  // hooks
127
137
  export { default as usePrefersReducedMotion } from './hooks/usePrefersReducedMotion.js'
128
138
  export { default as useReveal } from './hooks/useReveal.js'
@@ -19,6 +19,14 @@ import { Icon } from '@kolkrabbi/kol-icons'
19
19
  * • Portable Text: `value={{ code, language, filename }}`
20
20
  * • Direct props: `code` / `language` / `filename`
21
21
  * • Children: `<CodeBlock language="js">{'…'}</CodeBlock>`
22
+ *
23
+ * `language` falls back to `'text'`, and a `'text'` block draws NO chip — so a
24
+ * fence that declares nothing renders as an unlabelled slab. That fallback is
25
+ * kept (a chip reading "text" is worse than none) and the fix is upstream:
26
+ * every fence declares a language, enforced by `pnpm validate:fences`.
27
+ *
28
+ * @param {string} [size='md'] 'sm' | 'md' — the box and the type step together.
29
+ * @param {boolean} [bare] drop the FRAME; the host owns it. Not a size.
22
30
  */
23
31
 
24
32
  const CheckMarkIcon = () => (
@@ -65,7 +73,13 @@ const syntaxTheme = (foregroundToken = 80) => ({
65
73
  /* `bare` (2026-07-30): highlight + chip + copy WITHOUT the framed chrome — for
66
74
  * hosts that already own the frame (PreviewCard's Code tab sat a full
67
75
  * CodeBlock frame inside the kol-doc-figure border: frame-in-frame). */
68
- export default function CodeBlock({ children, code: codeProp, language: languageProp, filename: filenameProp, value, bare = false }) {
76
+ /* `size` (2026-08-01, user ruling). The block had no size at all its padding
77
+ * and type size sat in `.kol-codeblock` as unnamed constants, so *"its just
78
+ * whatever its defaulting to"* was literally true and no call site could ask
79
+ * for anything else. `md` is those exact values, named; `sm` is one step down
80
+ * on both axes. Size is INDEPENDENT of `bare`: bare removes the frame, size
81
+ * sets the box, and a bare block still has one. */
82
+ export default function CodeBlock({ children, code: codeProp, language: languageProp, filename: filenameProp, value, bare = false, size = 'md' }) {
69
83
  const [copied, setCopied] = useState(false)
70
84
 
71
85
  const code = String(value?.code ?? codeProp ?? children ?? '')
@@ -84,7 +98,7 @@ export default function CodeBlock({ children, code: codeProp, language: language
84
98
 
85
99
  return (
86
100
  <div className={bare ? '' : 'kol-codeblock-wrapper'}>
87
- <div className={`kol-codeblock${bare ? ' kol-codeblock--bare' : ''}`}>
101
+ <div className={`kol-codeblock kol-codeblock--${size}${bare ? ' kol-codeblock--bare' : ''}`}>
88
102
  {(filename || (language && language !== 'text')) && (
89
103
  <div className="kol-codeblock-filename">{filename || language}</div>
90
104
  )}
@@ -0,0 +1,145 @@
1
+ import { useState } from 'react'
2
+ import Input from '../atoms/Input'
3
+ import Tag from '../atoms/Tag'
4
+ import Button from '../atoms/Button'
5
+ import { usePopover, PopoverPanel } from '../atoms/Popover'
6
+ import Dropdown from './Dropdown'
7
+
8
+ /**
9
+ * FieldRow — one labeled field row in a record surface (lobby: RecordManager).
10
+ * Label column left, control right; `type` picks the control:
11
+ *
12
+ * text → Input, with an optional hint line under it (the slug's derived URL)
13
+ * status → StatusChip — interactive Tag opening a listbox in place
14
+ * select → Dropdown
15
+ * media → thumbnail + hover-reveal remove ×; empty state offers onPick
16
+ * file → filename as a removable Tag (chip taxonomy: interactive → Tag);
17
+ * empty state offers onPick
18
+ *
19
+ * All labels, option strings and values are authored at the call site — this
20
+ * component bakes no copy and transforms no casing. Media/file `value` is
21
+ * whatever object the consumer's picker returns; FieldRow reads only
22
+ * `.thumb`/`.url` for the image and `.name` for the token, and hands the whole
23
+ * object back through onChange untouched.
24
+ */
25
+
26
+ /* StatusChip — the interactive status control ("Live ▾" in the reference).
27
+ * Chip taxonomy (Tag source, 2026-07-30): interactive → Tag, never Pill. The
28
+ * menu is usePopover/PopoverPanel — the same primitive Dropdown builds on —
29
+ * with click handled by the Tag itself (click: false here) so the chip stays
30
+ * the only trigger. Shared by FieldRow and RecordManager's status column. */
31
+ export function StatusChip({ value, options = [], onChange, size = 'sm' }) {
32
+ const [open, setOpen] = useState(false)
33
+ const popover = usePopover({
34
+ open,
35
+ onOpenChange: setOpen,
36
+ placement: 'bottom-start',
37
+ click: false,
38
+ role: 'listbox',
39
+ })
40
+ return (
41
+ <>
42
+ <span ref={popover.refs.setReference} {...popover.getReferenceProps()} className="inline-flex">
43
+ <Tag variant="primary" size={size} hash={false} active={open} onClick={() => setOpen((o) => !o)}>
44
+ {value}
45
+ </Tag>
46
+ </span>
47
+ <PopoverPanel popover={popover}>
48
+ <ul className="flex flex-col p-1 m-0 list-none">
49
+ {options.map((opt) => {
50
+ const label = typeof opt === 'object' ? opt.label : opt
51
+ const val = typeof opt === 'object' ? opt.value : opt
52
+ return (
53
+ <li key={val}>
54
+ <button
55
+ type="button"
56
+ role="option"
57
+ aria-selected={val === value}
58
+ className={`kol-helper-12 w-full text-left px-3 py-2 bg-transparent border-0 cursor-pointer rounded-sm hover:bg-fg-08 ${val === value ? 'text-emphasis' : 'text-body'}`}
59
+ onClick={() => {
60
+ onChange?.(val)
61
+ setOpen(false)
62
+ }}
63
+ >
64
+ {label}
65
+ </button>
66
+ </li>
67
+ )
68
+ })}
69
+ </ul>
70
+ </PopoverPanel>
71
+ </>
72
+ )
73
+ }
74
+
75
+ /* Media/file thumbnail — same small fixed-aspect rounded treatment as
76
+ * MediaRow's thumb cell. */
77
+ const THUMB_CLS = 'h-10 w-14 rounded object-cover bg-fg-04'
78
+
79
+ export default function FieldRow({
80
+ label,
81
+ type = 'text',
82
+ value,
83
+ onChange,
84
+ options = [],
85
+ hint,
86
+ onPick,
87
+ placeholder,
88
+ disabled = false,
89
+ }) {
90
+ const empty = onPick ? (
91
+ <Button variant="nav" size="sm" iconOnly="plus" iconSize={12} onClick={onPick} aria-label={label} disabled={disabled} />
92
+ ) : (
93
+ <span className="kol-helper-10 text-meta">—</span>
94
+ )
95
+
96
+ let control = null
97
+ if (type === 'text') {
98
+ control = (
99
+ <Input
100
+ value={value ?? ''}
101
+ onChange={(e) => onChange?.(e?.target ? e.target.value : e)}
102
+ placeholder={placeholder}
103
+ disabled={disabled}
104
+ className="w-full"
105
+ />
106
+ )
107
+ } else if (type === 'status') {
108
+ control = <StatusChip value={value} options={options} onChange={onChange} />
109
+ } else if (type === 'select') {
110
+ control = <Dropdown options={options} value={value} onChange={onChange} />
111
+ } else if (type === 'media') {
112
+ control = value ? (
113
+ <span className="group relative inline-flex">
114
+ <img src={value.thumb ?? value.url} alt={value.name ?? ''} className={THUMB_CLS} />
115
+ {onChange && (
116
+ <Button
117
+ variant="nav"
118
+ size="sm"
119
+ iconOnly="x"
120
+ iconSize={10}
121
+ aria-label={label}
122
+ className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
123
+ onClick={() => onChange(null)}
124
+ />
125
+ )}
126
+ </span>
127
+ ) : empty
128
+ } else if (type === 'file') {
129
+ control = value ? (
130
+ <Tag variant="secondary" size="sm" hash={false} onRemove={onChange ? () => onChange(null) : undefined}>
131
+ {value.name ?? value}
132
+ </Tag>
133
+ ) : empty
134
+ }
135
+
136
+ return (
137
+ <div className="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] items-center gap-x-6 py-3 border-b border-fg-08">
138
+ <div className="kol-mono-12 text-body">{label}</div>
139
+ <div className="min-w-0">{control}</div>
140
+ {hint != null && (
141
+ <div className="col-start-2 kol-helper-10 text-meta pt-1 truncate">{hint}</div>
142
+ )}
143
+ </div>
144
+ )
145
+ }
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { Icon } from '@kolkrabbi/kol-icons'
3
+ import Button from '../atoms/Button.jsx'
4
4
  import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
5
5
 
6
6
  /* taxonomy-ok: nests kol-icons's Icon (a package import the relative-import
@@ -150,14 +150,21 @@ export default function ShellDrawer({
150
150
  >
151
151
  <div className="mb-6 flex items-center gap-4">
152
152
  {header != null && <div className="min-w-0 flex-1">{header}</div>}
153
- <button
154
- type="button"
153
+ {/* The box has an owner (2026-08-01). This hand-wrote the icon-button
154
+ * square and its hover wash; `Button variant="nav"` IS that string.
155
+ * `iconSize` holds the glyph where it was — the ladder's md rung is
156
+ * heavier than a drawer close wants, and Button documents iconSize
157
+ * for exactly the cases the ladder cannot serve. The SQUARE is what
158
+ * needed an owner, and it now has one. */}
159
+ <Button
160
+ variant="nav"
161
+ size="md"
162
+ iconOnly="x"
163
+ iconSize={14}
155
164
  onClick={onClose}
156
165
  aria-label="Close"
157
- className="ml-auto flex h-8 w-8 shrink-0 items-center justify-center rounded-md border-0 bg-transparent cursor-pointer text-fg-64 transition-colors hover:bg-fg-08 hover:text-emphasis"
158
- >
159
- <Icon name="x" size={14} />
160
- </button>
166
+ className="ml-auto shrink-0"
167
+ />
161
168
  </div>
162
169
  <div className="flex-1 overflow-y-auto pr-1" style={{ overflowAnchor: 'none' }}>
163
170
  {children}
@@ -0,0 +1,334 @@
1
+ import { useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Icon } from '@kolkrabbi/kol-icons'
4
+ import Button from '../atoms/Button'
5
+ import SearchInput from '../atoms/SearchInput'
6
+ import ToggleCheckbox from '../atoms/ToggleCheckbox'
7
+ import ShellDrawer from '../molecules/ShellDrawer'
8
+ import Dropdown from '../molecules/Dropdown'
9
+ import FieldRow, { StatusChip } from '../molecules/FieldRow'
10
+ import Table from './Table'
11
+ import MediaLibrary from './MediaLibrary'
12
+
13
+ /**
14
+ * RecordManager — full-screen CMS record surface (lobby: RecordManager,
15
+ * reference: Framer CMS). A reorderable record table plus a slide-over detail
16
+ * panel of FieldRows.
17
+ *
18
+ * Composes existing DS only: Table (with rowClassName), ShellDrawer as the
19
+ * slide-over, FieldRow/StatusChip for the panel, MediaLibrary variant="modal"
20
+ * as the media picker (client INJECTED via `mediaClient`, never imported —
21
+ * ARCHITECTURE §3), ToggleCheckbox for row selection, SearchInput/Button for
22
+ * the toolbar.
23
+ *
24
+ * Columns use Table's contract (accessor/header/render/sortable…), plus an
25
+ * optional `type` this organism resolves before handing them down:
26
+ * 'title' → truncating cell + hover-reveal open affordance (opens the panel)
27
+ * 'status' → StatusChip; column carries `options` + `onStatusChange(row, v)`
28
+ * 'select' → Dropdown; column carries `options` + `onSelectChange(row, v)`
29
+ * 'thumb' → small fixed-aspect thumbnail (row[accessor] = {thumb|url, name})
30
+ *
31
+ * Reorder is a ~50-line pointer sort (the brief's call: util before any
32
+ * dependency): pointerdown on the ⠿ handle, row rects measured once, live
33
+ * over-index on pointermove, onReorder(from, to) on release. The floating
34
+ * "Reorder row N" label rides the existing .kol-tooltip chrome. Handles render
35
+ * only when onReorder is passed.
36
+ *
37
+ * Fields follow FieldRow's contract: {key, label, type, options?, accept?,
38
+ * placeholder?, hint? (value or fn(record))}. The open record's values come
39
+ * from `value` (falling back to the row object); edits bubble through
40
+ * onChange(key, next) — data is consumer-owned, this surface never fetches.
41
+ */
42
+ export default function RecordManager({
43
+ columns = [],
44
+ rows = [],
45
+ onReorder,
46
+ onSelectRow,
47
+ fields = [],
48
+ value,
49
+ onChange,
50
+ onAdd,
51
+ onSortToggle,
52
+ onFilterToggle,
53
+ query,
54
+ onQueryChange,
55
+ searchPlaceholder,
56
+ onSelectionChange,
57
+ saveState,
58
+ onPublish,
59
+ publishLabel = 'Publish',
60
+ onPreview,
61
+ onOverflow,
62
+ mediaClient,
63
+ reorderLabel = (n) => `Reorder row ${n}`,
64
+ className = '',
65
+ }) {
66
+ const wrapRef = useRef(null)
67
+ const dragRef = useRef(null)
68
+ const [drag, setDrag] = useState(null) // { from, over, x, y } during a drag
69
+ const [active, setActive] = useState(null)
70
+ const [picker, setPicker] = useState(null) // the field being picked for
71
+ const [selected, setSelected] = useState(() => new Set())
72
+
73
+ const rowId = (row, i) => row.id ?? i
74
+
75
+ const openRecord = (row) => {
76
+ setActive(row)
77
+ onSelectRow?.(row)
78
+ }
79
+
80
+ const updateSelection = (next) => {
81
+ setSelected(next)
82
+ onSelectionChange?.([...next])
83
+ }
84
+
85
+ /* ── pointer sort ─────────────────────────────────────────────────────── */
86
+ const startDrag = (e, from) => {
87
+ if (!onReorder) return
88
+ e.preventDefault()
89
+ const rowEls = wrapRef.current?.querySelectorAll('.kol-table-row')
90
+ if (!rowEls?.length) return
91
+ const rects = [...rowEls].map((el) => el.getBoundingClientRect())
92
+ const move = (ev) => {
93
+ const hit = rects.findIndex((r) => ev.clientY < r.bottom)
94
+ const over = hit === -1 ? rects.length - 1 : hit
95
+ dragRef.current = { from, over, x: ev.clientX, y: ev.clientY }
96
+ setDrag(dragRef.current)
97
+ }
98
+ const up = () => {
99
+ window.removeEventListener('pointermove', move)
100
+ window.removeEventListener('pointerup', up)
101
+ document.body.style.cursor = ''
102
+ document.body.style.userSelect = ''
103
+ const d = dragRef.current
104
+ dragRef.current = null
105
+ setDrag(null)
106
+ if (d && d.over !== d.from) onReorder(d.from, d.over)
107
+ }
108
+ window.addEventListener('pointermove', move)
109
+ window.addEventListener('pointerup', up)
110
+ document.body.style.cursor = 'grabbing'
111
+ document.body.style.userSelect = 'none'
112
+ dragRef.current = { from, over: from, x: e.clientX, y: e.clientY }
113
+ setDrag(dragRef.current)
114
+ }
115
+
116
+ /* ── columns: resolve types, prepend handle + checkbox ────────────────── */
117
+ const builtColumns = useMemo(() => {
118
+ const resolved = columns.map((col) => {
119
+ if (col.type === 'title') {
120
+ return {
121
+ ...col,
122
+ render: (row) => (
123
+ <span className="inline-flex items-center gap-2 min-w-0 max-w-full">
124
+ <span className="truncate text-emphasis">{col.render ? col.render(row) : row[col.accessor]}</span>
125
+ <Button
126
+ variant="nav"
127
+ size="sm"
128
+ iconOnly="maximize"
129
+ iconSize={12}
130
+ aria-label={typeof col.header === 'string' ? col.header : 'Open'}
131
+ className="shrink-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
132
+ onClick={() => openRecord(row)}
133
+ />
134
+ </span>
135
+ ),
136
+ }
137
+ }
138
+ if (col.type === 'status') {
139
+ return {
140
+ ...col,
141
+ render: (row) => (
142
+ <StatusChip
143
+ value={row[col.accessor]}
144
+ options={col.options ?? []}
145
+ onChange={(next) => col.onStatusChange?.(row, next)}
146
+ />
147
+ ),
148
+ }
149
+ }
150
+ if (col.type === 'select') {
151
+ return {
152
+ ...col,
153
+ render: (row) => (
154
+ <Dropdown
155
+ options={col.options ?? []}
156
+ value={row[col.accessor]}
157
+ onChange={(next) => col.onSelectChange?.(row, next)}
158
+ />
159
+ ),
160
+ }
161
+ }
162
+ if (col.type === 'thumb') {
163
+ return {
164
+ ...col,
165
+ render: (row) => {
166
+ const media = row[col.accessor]
167
+ return media ? (
168
+ <img src={media.thumb ?? media.url} alt={media.name ?? ''} className="h-10 w-14 rounded object-cover bg-fg-04" />
169
+ ) : (
170
+ <span className="kol-helper-10 text-meta">—</span>
171
+ )
172
+ },
173
+ }
174
+ }
175
+ return col
176
+ })
177
+
178
+ const prefix = []
179
+ if (onReorder) {
180
+ prefix.push({
181
+ accessor: '__handle',
182
+ header: '',
183
+ className: 'kol-table-cell-text w-8',
184
+ render: (row) => {
185
+ const i = rows.indexOf(row)
186
+ return (
187
+ <button
188
+ type="button"
189
+ aria-label={reorderLabel(i + 1)}
190
+ className="cursor-grab touch-none bg-transparent border-0 p-0 text-meta hover:text-emphasis opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
191
+ onPointerDown={(e) => startDrag(e, i)}
192
+ >
193
+ <Icon name="resize-grip" size={14} />
194
+ </button>
195
+ )
196
+ },
197
+ })
198
+ }
199
+ prefix.push({
200
+ accessor: '__select',
201
+ header: (
202
+ <ToggleCheckbox
203
+ checked={rows.length > 0 && selected.size === rows.length}
204
+ onChange={() =>
205
+ updateSelection(selected.size === rows.length ? new Set() : new Set(rows.map(rowId)))
206
+ }
207
+ />
208
+ ),
209
+ className: 'kol-table-cell-text w-8',
210
+ render: (row) => {
211
+ const id = rowId(row, rows.indexOf(row))
212
+ return (
213
+ <ToggleCheckbox
214
+ checked={selected.has(id)}
215
+ onChange={() => {
216
+ const next = new Set(selected)
217
+ next.has(id) ? next.delete(id) : next.add(id)
218
+ updateSelection(next)
219
+ }}
220
+ />
221
+ )
222
+ },
223
+ })
224
+ return [...prefix, ...resolved]
225
+ }, [columns, rows, selected, onReorder, drag]) // eslint-disable-line react-hooks/exhaustive-deps
226
+
227
+ const record = value ?? active
228
+ const hasToolbar = onAdd || onSortToggle || onFilterToggle || onQueryChange
229
+
230
+ return (
231
+ <div ref={wrapRef} className={`flex flex-col min-w-0 ${className}`}>
232
+ {hasToolbar && (
233
+ <div className="flex items-center gap-1 pb-3">
234
+ {onAdd && <Button variant="nav" size="md" iconOnly="plus" iconSize={14} onClick={onAdd} aria-label="Add" />}
235
+ {onSortToggle && <Button variant="nav" size="md" iconOnly="swap" iconSize={14} onClick={onSortToggle} aria-label="Sort" />}
236
+ {onFilterToggle && <Button variant="nav" size="md" iconOnly="filter" iconSize={14} onClick={onFilterToggle} aria-label="Filter" />}
237
+ {onQueryChange && (
238
+ <SearchInput
239
+ value={query ?? ''}
240
+ onChange={(e) => onQueryChange(e?.target ? e.target.value : e)}
241
+ placeholder={searchPlaceholder}
242
+ size="sm"
243
+ className="ml-2"
244
+ />
245
+ )}
246
+ </div>
247
+ )}
248
+
249
+ <Table
250
+ columns={builtColumns}
251
+ rows={rows}
252
+ width="column"
253
+ rowClassName={(row, i) =>
254
+ `group${drag && i === drag.from ? ' opacity-40' : ''}${
255
+ drag && i === drag.over && drag.over !== drag.from ? ' bg-fg-04' : ''
256
+ }`
257
+ }
258
+ />
259
+
260
+ {/* floating reorder label — the existing .kol-tooltip chrome, ridden
261
+ * imperatively (hover machinery doesn't fit a drag) */}
262
+ {drag &&
263
+ createPortal(
264
+ <div className="kol-tooltip fixed" style={{ left: drag.x + 12, top: drag.y + 12 }}>
265
+ {reorderLabel(drag.from + 1)}
266
+ </div>,
267
+ document.body,
268
+ )}
269
+
270
+ {/* detail panel — ShellDrawer owns the slide-over chrome AND the close ×;
271
+ * this header carries only the record actions (reference: … ▶ save-state
272
+ * Publish). aria-label defaults follow ShellDrawer's own precedent. */}
273
+ <ShellDrawer
274
+ open={!!active}
275
+ onClose={() => setActive(null)}
276
+ side="right"
277
+ width="min(37.5rem, 92vw)"
278
+ header={
279
+ <div className="flex items-center gap-2 min-w-0">
280
+ {onOverflow && (
281
+ <Button variant="nav" size="md" iconOnly="more" iconSize={14} onClick={() => onOverflow(record)} aria-label="More" />
282
+ )}
283
+ {onPreview && (
284
+ <Button variant="nav" size="md" iconOnly="play" iconSize={14} onClick={() => onPreview(record)} aria-label="Preview" />
285
+ )}
286
+ {saveState != null && <span className="kol-helper-10 text-meta ml-auto truncate">{saveState}</span>}
287
+ {onPublish && (
288
+ <Button variant="primary" size="sm" className={saveState == null ? 'ml-auto' : ''} onClick={() => onPublish(record)}>
289
+ {publishLabel}
290
+ </Button>
291
+ )}
292
+ </div>
293
+ }
294
+ >
295
+ {record && (
296
+ <div className="flex flex-col">
297
+ {fields.map((f) => (
298
+ <FieldRow
299
+ key={f.key}
300
+ label={f.label}
301
+ type={f.type}
302
+ options={f.options}
303
+ placeholder={f.placeholder}
304
+ hint={typeof f.hint === 'function' ? f.hint(record) : f.hint}
305
+ value={record[f.key]}
306
+ onChange={(next) => onChange?.(f.key, next)}
307
+ onPick={
308
+ (f.type === 'media' || f.type === 'file') && mediaClient ? () => setPicker(f) : undefined
309
+ }
310
+ disabled={f.disabled}
311
+ />
312
+ ))}
313
+ </div>
314
+ )}
315
+ </ShellDrawer>
316
+
317
+ {/* media picker — the EXISTING MediaLibrary organism, modal variant;
318
+ * client injected, never imported (§3). accept comes from the field. */}
319
+ {picker && mediaClient && (
320
+ <MediaLibrary
321
+ variant="modal"
322
+ open
323
+ client={mediaClient}
324
+ accept={picker.accept ?? 'all'}
325
+ onClose={() => setPicker(null)}
326
+ onSelect={(asset) => {
327
+ onChange?.(picker.key, asset)
328
+ setPicker(null)
329
+ }}
330
+ />
331
+ )}
332
+ </div>
333
+ )
334
+ }
@@ -32,7 +32,7 @@ const WIDTHS = {
32
32
  column: '',
33
33
  }
34
34
 
35
- const Table = ({ caption, columns, rows, variant = 'default', className = '', width = 'panel' }) => {
35
+ const Table = ({ caption, columns, rows, variant = 'default', className = '', width = 'panel', rowClassName }) => {
36
36
  const [sort, setSort] = useState({ key: null, dir: null })
37
37
 
38
38
  const cycle = (key) =>
@@ -92,7 +92,7 @@ const Table = ({ caption, columns, rows, variant = 'default', className = '', wi
92
92
  </thead>
93
93
  <tbody>
94
94
  {sorted.map((row, rowIndex) => (
95
- <tr key={row.id ?? row.token ?? rowIndex} className="kol-table-row">
95
+ <tr key={row.id ?? row.token ?? rowIndex} className={`kol-table-row${rowClassName ? ` ${rowClassName(row, rowIndex)}` : ''}`}>
96
96
  {columns.map((column) => (
97
97
  <td key={column.accessor} className={(typeof column.className === 'function' ? column.className(row) : column.className) ?? 'kol-table-cell-text'} style={column.style}>
98
98
  {column.render ? column.render(row) : row[column.accessor] ?? '—'}