@kolkrabbi/kol-component 0.24.0 → 0.26.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 +1 -1
- package/src/atoms/Popover.jsx +4 -1
- package/src/hooks/glyphLadders.js +15 -0
- package/src/index.js +3 -1
- package/src/molecules/Dropdown.jsx +24 -31
- package/src/molecules/FieldRow.jsx +145 -0
- package/src/organisms/RecordManager.jsx +334 -0
- package/src/organisms/Table.jsx +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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/atoms/Popover.jsx
CHANGED
|
@@ -68,8 +68,11 @@ export function usePopover({
|
|
|
68
68
|
middleware.push(
|
|
69
69
|
sizeMw({
|
|
70
70
|
apply({ rects, elements }) {
|
|
71
|
+
/* EXACT width, not a floor (2026-08-09) — "one piece means one
|
|
72
|
+
* width": with minWidth alone, a wide row let the panel outgrow the
|
|
73
|
+
* trigger it claims to continue. Sole consumer is Dropdown. */
|
|
71
74
|
Object.assign(elements.floating.style, {
|
|
72
|
-
|
|
75
|
+
width: `${rects.reference.width}px`,
|
|
73
76
|
})
|
|
74
77
|
},
|
|
75
78
|
})
|
|
@@ -29,6 +29,16 @@ export const SOLO = { sm: 16, md: 20, lg: 24 }
|
|
|
29
29
|
/** Icon beside a label, inside the rung's line box. */
|
|
30
30
|
export const ADJACENT = { sm: 14, md: 16, lg: 18 }
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Indicator glyph — a caret/chevron that DECORATES a control rather than
|
|
34
|
+
* naming it (a dropdown caret, a sort arrow). Pairs 1:1 with the control's
|
|
35
|
+
* mono text rung, one step under ADJACENT: an indicator never outweighs the
|
|
36
|
+
* label it points at. Promoted 2026-08-09 — Table's sort chevron and
|
|
37
|
+
* Dropdown's caret each hand-typed their number (Dropdown took the ADJACENT
|
|
38
|
+
* rung, the oversize the user called): two transcriptions, the folklore
|
|
39
|
+
* threshold. */
|
|
40
|
+
export const INDICATOR = { sm: 12, md: 14, lg: 16 }
|
|
41
|
+
|
|
32
42
|
/**
|
|
33
43
|
* Resolve a glyph size. `solo` picks the ladder; `size` indexes it.
|
|
34
44
|
* Falls back to the md rung so an unknown size never yields undefined.
|
|
@@ -37,3 +47,8 @@ export function glyphSize(size, solo = false) {
|
|
|
37
47
|
const ladder = solo ? SOLO : ADJACENT
|
|
38
48
|
return ladder[size] ?? ladder.md
|
|
39
49
|
}
|
|
50
|
+
|
|
51
|
+
/** Resolve an indicator size. Separate helper — `solo` never applies. */
|
|
52
|
+
export function indicatorSize(size) {
|
|
53
|
+
return INDICATOR[size] ?? INDICATOR.md
|
|
54
|
+
}
|
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
|
|
|
@@ -129,7 +131,7 @@ export { GRAPHIC_RAW } from './graphics/graphicData.js'
|
|
|
129
131
|
* took `20`, and neither could reference the rule it was meant to follow.
|
|
130
132
|
* Cross-package imports go through the `@kolkrabbi/*` specifier (ARCHITECTURE
|
|
131
133
|
* §3), so an export is the only way another package can obey the ladder. */
|
|
132
|
-
export { SOLO, ADJACENT, glyphSize } from './hooks/glyphLadders.js'
|
|
134
|
+
export { SOLO, ADJACENT, INDICATOR, glyphSize, indicatorSize } from './hooks/glyphLadders.js'
|
|
133
135
|
|
|
134
136
|
// hooks
|
|
135
137
|
export { default as usePrefersReducedMotion } from './hooks/usePrefersReducedMotion.js'
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
4
4
|
import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
|
|
5
|
+
import { indicatorSize } from '../hooks/glyphLadders.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Dropdown — trigger IS button chrome (2026-07-08 chrome law).
|
|
@@ -28,7 +29,10 @@ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
|
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }
|
|
31
|
-
|
|
32
|
+
/* Caret size comes from the INDICATOR ladder (glyphLadders.js) — the private
|
|
33
|
+
* map that lived here was a transcription of ADJACENT, which is the wrong
|
|
34
|
+
* ladder for a decoration: it put a caret one rung HEAVIER than the label
|
|
35
|
+
* beside it (2026-08-09 user call). */
|
|
32
36
|
|
|
33
37
|
const LEGACY_VARIANTS = { default: 'primary', subtle: 'primary', minimal: 'outline' }
|
|
34
38
|
|
|
@@ -42,7 +46,6 @@ const Dropdown = ({
|
|
|
42
46
|
className = ''
|
|
43
47
|
}) => {
|
|
44
48
|
const [isOpen, setIsOpen] = useState(defaultOpen)
|
|
45
|
-
const [dropdownWidth, setDropdownWidth] = useState('100px')
|
|
46
49
|
|
|
47
50
|
// sm everywhere unless explicitly overridden (see docblock size law).
|
|
48
51
|
const resolvedSize = size || 'sm'
|
|
@@ -63,23 +66,11 @@ const Dropdown = ({
|
|
|
63
66
|
role: 'listbox',
|
|
64
67
|
})
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (window.innerWidth >= 1024) {
|
|
72
|
-
setDropdownWidth('180px')
|
|
73
|
-
} else if (window.innerWidth >= 768) {
|
|
74
|
-
setDropdownWidth('140px')
|
|
75
|
-
} else {
|
|
76
|
-
setDropdownWidth('100px')
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
updateWidth()
|
|
80
|
-
window.addEventListener('resize', updateWidth)
|
|
81
|
-
return () => window.removeEventListener('resize', updateWidth)
|
|
82
|
-
}, [])
|
|
69
|
+
/* Width belongs to the CALL SITE (2026-08-09 user call — "width without any
|
|
70
|
+
* regard to context"). The viewport-keyed resize listener that handed every
|
|
71
|
+
* dropdown a fixed width by window size is gone: default is hug-content,
|
|
72
|
+
* and the consumer sizes it through className exactly as on Input. The open
|
|
73
|
+
* panel follows the trigger via matchReferenceWidth either way. */
|
|
83
74
|
|
|
84
75
|
const handleSelect = (option) => {
|
|
85
76
|
onChange?.(option.value)
|
|
@@ -100,15 +91,7 @@ const Dropdown = ({
|
|
|
100
91
|
].filter(Boolean).join(' ')
|
|
101
92
|
|
|
102
93
|
return (
|
|
103
|
-
<div
|
|
104
|
-
className={`relative block ${className}`}
|
|
105
|
-
style={{
|
|
106
|
-
...(dropdownWidth && !className.includes('w-full') && {
|
|
107
|
-
width: dropdownWidth,
|
|
108
|
-
minWidth: dropdownWidth
|
|
109
|
-
})
|
|
110
|
-
}}
|
|
111
|
-
>
|
|
94
|
+
<div className={`relative inline-block align-middle ${className}`}>
|
|
112
95
|
<button
|
|
113
96
|
ref={popover.refs.setReference}
|
|
114
97
|
{...popover.getReferenceProps()}
|
|
@@ -118,10 +101,20 @@ const Dropdown = ({
|
|
|
118
101
|
aria-expanded={isOpen}
|
|
119
102
|
data-state={isOpen ? 'open' : 'closed'}
|
|
120
103
|
>
|
|
121
|
-
|
|
104
|
+
{/* every option's label rides along hidden so the trigger is as wide
|
|
105
|
+
* as its widest value — the panel matches the trigger's width, so
|
|
106
|
+
* trigger and list stay one piece at every selection */}
|
|
107
|
+
<span className="kol-dd-label">
|
|
108
|
+
<span>{currentOption?.label}</span>
|
|
109
|
+
{options.map((option) => (
|
|
110
|
+
<span key={option.value} className="kol-dd-ghost" aria-hidden="true">
|
|
111
|
+
{option.label}
|
|
112
|
+
</span>
|
|
113
|
+
))}
|
|
114
|
+
</span>
|
|
122
115
|
{/* chrome lives in .kol-dd-caret (trailing edge + open-state flip) —
|
|
123
116
|
* keyed off the trigger's data-state, no inline styles */}
|
|
124
|
-
<Icon name="chevron-down" size={
|
|
117
|
+
<Icon name="chevron-down" size={indicatorSize(resolvedSize)} className="kol-dd-caret" />
|
|
125
118
|
</button>
|
|
126
119
|
|
|
127
120
|
<PopoverPanel
|
|
@@ -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
|
+
}
|
package/src/organisms/Table.jsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import { INDICATOR } from '../hooks/glyphLadders.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Table — data table.
|
|
@@ -32,7 +33,7 @@ const WIDTHS = {
|
|
|
32
33
|
column: '',
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
const Table = ({ caption, columns, rows, variant = 'default', className = '', width = 'panel' }) => {
|
|
36
|
+
const Table = ({ caption, columns, rows, variant = 'default', className = '', width = 'panel', rowClassName }) => {
|
|
36
37
|
const [sort, setSort] = useState({ key: null, dir: null })
|
|
37
38
|
|
|
38
39
|
const cycle = (key) =>
|
|
@@ -79,7 +80,7 @@ const Table = ({ caption, columns, rows, variant = 'default', className = '', wi
|
|
|
79
80
|
{column.header}
|
|
80
81
|
<Icon
|
|
81
82
|
name={sort.key === column.accessor && sort.dir === 'desc' ? 'chevron-down' : 'chevron-up'}
|
|
82
|
-
size={
|
|
83
|
+
size={INDICATOR.sm}
|
|
83
84
|
className={sort.key === column.accessor ? 'opacity-100' : 'opacity-0 group-hover:opacity-40'}
|
|
84
85
|
/>
|
|
85
86
|
</button>
|
|
@@ -92,7 +93,7 @@ const Table = ({ caption, columns, rows, variant = 'default', className = '', wi
|
|
|
92
93
|
</thead>
|
|
93
94
|
<tbody>
|
|
94
95
|
{sorted.map((row, rowIndex) => (
|
|
95
|
-
<tr key={row.id ?? row.token ?? rowIndex} className=
|
|
96
|
+
<tr key={row.id ?? row.token ?? rowIndex} className={`kol-table-row${rowClassName ? ` ${rowClassName(row, rowIndex)}` : ''}`}>
|
|
96
97
|
{columns.map((column) => (
|
|
97
98
|
<td key={column.accessor} className={(typeof column.className === 'function' ? column.className(row) : column.className) ?? 'kol-table-cell-text'} style={column.style}>
|
|
98
99
|
{column.render ? column.render(row) : row[column.accessor] ?? '—'}
|