@kolkrabbi/kol-component 0.24.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.24.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",
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
 
@@ -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
+ }
@@ -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] ?? '—'}