@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 +2 -2
- package/src/atoms/Button.jsx +9 -1
- package/src/atoms/IconFrame.jsx +47 -7
- package/src/hooks/useScrollSpy.js +8 -1
- package/src/index.js +10 -0
- package/src/molecules/CodeBlock.jsx +16 -2
- package/src/molecules/FieldRow.jsx +145 -0
- package/src/molecules/ShellDrawer.jsx +14 -7
- package/src/organisms/RecordManager.jsx +334 -0
- package/src/organisms/Table.jsx +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "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.
|
|
27
|
+
"@kolkrabbi/kol-icons": "0.10.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"framer-motion": "^12.0.0",
|
package/src/atoms/Button.jsx
CHANGED
|
@@ -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.
|
package/src/atoms/IconFrame.jsx
CHANGED
|
@@ -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
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
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
|
+
}
|
package/src/organisms/Table.jsx
CHANGED
|
@@ -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=
|
|
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] ?? '—'}
|