@kolkrabbi/kol-component 0.95.0 → 0.96.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.
|
|
3
|
+
"version": "0.96.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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
33
|
+
"@kolkrabbi/kol-icons": "^0.19.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/index.js
CHANGED
|
@@ -140,6 +140,7 @@ export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
|
140
140
|
export { default as SettingsPanel, SettingsRow, SettingsSwitch, SettingsChoice, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
|
|
141
141
|
export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
|
|
142
142
|
export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
|
|
143
|
+
export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
|
|
143
144
|
export { default as RecordManager } from './organisms/RecordManager.jsx'
|
|
144
145
|
export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
|
|
145
146
|
export { default as Table } from './organisms/Table.jsx'
|
|
@@ -40,7 +40,10 @@ const RATIOS = {
|
|
|
40
40
|
/* ruled box values per variant, verbatim from the §3 reference cards —
|
|
41
41
|
* paddings in tokens: pad-card-sm 12 · pad-card-md 16 · pad-card-lg 24 */
|
|
42
42
|
const BOX = {
|
|
43
|
-
|
|
43
|
+
/* no border (ColumnBrowser round, user 2026-08-27: "I don't like the border, I feel
|
|
44
|
+
* like I've said that before" — the ListingCardThumbBorder ruling): the selected
|
|
45
|
+
* state reads from the checked ToggleCheckbox, not a fg-64 border */
|
|
46
|
+
default: { layout: 'stack', border: null, bg: 'var(--kol-fg-02)', pad: 'var(--kol-pad-card-sm)' },
|
|
44
47
|
catalog: { layout: 'fill-card', border: 'var(--kol-fg-04)', bg: 'var(--kol-fg-04)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, plateBg: 'var(--kol-surface-primary)' },
|
|
45
48
|
/* flip: PrintGridCard's 3D turn on `isFlipped` → `selected` (ContentRowsAndPrintCard, 2026-08-27) */
|
|
46
49
|
print: { layout: 'fill-card', border: null, bg: 'var(--kol-surface-secondary)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, flip: true },
|
|
@@ -70,8 +70,9 @@ import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
|
70
70
|
* @param {ReactNode} children the real media
|
|
71
71
|
*/
|
|
72
72
|
const FIT = {
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
/* a consumer WRAPPER div fills the frame as an img/video does (ColumnBrowser round, 2026-08-27 — kol-r2b2's wrapped thumb fell back to the image's intrinsic size) */
|
|
74
|
+
cover: '[&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:h-full [&>video]:w-full [&>video]:object-cover [&>div]:h-full [&>div]:w-full',
|
|
75
|
+
natural: '[&>img]:h-full [&>img]:w-full [&>img]:object-contain [&>video]:h-full [&>video]:w-full [&>video]:object-contain [&>div]:h-full [&>div]:w-full',
|
|
75
76
|
compact: 'grid place-items-center [&>img]:max-h-[70%] [&>img]:max-w-[70%] [&>img]:object-contain',
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ColumnBrowser — Finder-style Miller columns over a flat key space (kol-r2b2's
|
|
6
|
+
* `src/ColumnBrowser.jsx`, built and ruled there first — user 2026-08-27: "make
|
|
7
|
+
* it locally first, then ship it" — and shipped verbatim; ticket ColumnBrowser).
|
|
8
|
+
* The listing surface's FOLDER view: it replaces the folder rows above the
|
|
9
|
+
* files, it is not a card layout.
|
|
10
|
+
*
|
|
11
|
+
* One column per path segment of `prefix`: column 0 is the root level, column
|
|
12
|
+
* k the contents of the k-th folder on the path. Picking a folder sets the
|
|
13
|
+
* prefix to it (and so truncates deeper columns); picking a file highlights it
|
|
14
|
+
* and opens a PREVIEW column on the right — the image and its facts, as Finder
|
|
15
|
+
* does.
|
|
16
|
+
*
|
|
17
|
+
* Keyboard, Finder's: ↑/↓ move within the column (a folder opens its column as
|
|
18
|
+
* you land on it, a file previews), → steps into the open folder's column,
|
|
19
|
+
* ← steps back to the parent column. Space = Quick Look: `onQuickLook({ files,
|
|
20
|
+
* index })` over the column's files; while it is open ↑/↓ step the file off
|
|
21
|
+
* `window` (the overlay holds focus) and space closes — the opening keystroke
|
|
22
|
+
* bubbles from here and is ignored.
|
|
23
|
+
*
|
|
24
|
+
* Rows wear the DS Table's cell metrics (12px 16px, mono 12, an oq-08 hairline
|
|
25
|
+
* between rows, none after the last). Selected and cursor rows draw the HOVER
|
|
26
|
+
* fill (`bg-fg-04`) — user ruling: "make hover state the selected state".
|
|
27
|
+
* Height is fixed at 528px = 12 rows × 44px (user ruling); columns scroll.
|
|
28
|
+
*
|
|
29
|
+
* The app-owned bits are SEAMS with working defaults: `urlOf(o)` (the preview
|
|
30
|
+
* image — no URL, no image), `kindOf(o)` (image / video / audio / file from
|
|
31
|
+
* `contentType`), `kindLabel`, `formatSize`, `partition(objects, level)`.
|
|
32
|
+
*
|
|
33
|
+
* @param {Array<{key: string, size?: number, uploaded?: string, contentType?: string}>} objects the flat key space
|
|
34
|
+
* @param {string} prefix the open folder path ('' = root, 'a/b/' = two deep)
|
|
35
|
+
* @param {Function} onPrefix (prefix) => void
|
|
36
|
+
* @param {{files: object[], index: number}|null} quickLook the open Quick Look, or null
|
|
37
|
+
* @param {Function} onQuickLook (quickLook|null) => void — space opens / closes, ↑/↓ step while open
|
|
38
|
+
* @param {Function} urlOf (o) => string — the object's public URL for the preview image
|
|
39
|
+
* @param {Function} kindOf (o) => 'image' | 'video' | 'audio' | string
|
|
40
|
+
* @param {Object} kindLabel kind → label shown when there is no visual preview
|
|
41
|
+
* @param {Function} formatSize (bytes) => string
|
|
42
|
+
* @param {Function} partition (objects, level) => { folders: string[], files: object[] }
|
|
43
|
+
* @param {string} className extra classes on the browser
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const defaultKindOf = (o) => {
|
|
47
|
+
const ct = o.contentType || ''
|
|
48
|
+
if (ct.startsWith('image/')) return 'image'
|
|
49
|
+
if (ct.startsWith('video/')) return 'video'
|
|
50
|
+
if (ct.startsWith('audio/')) return 'audio'
|
|
51
|
+
return 'file'
|
|
52
|
+
}
|
|
53
|
+
const DEFAULT_KIND_LABEL = { image: 'image', video: 'video', audio: 'audio', file: 'file' }
|
|
54
|
+
const defaultFormatSize = (bytes) => {
|
|
55
|
+
if (bytes == null) return '—'
|
|
56
|
+
if (bytes < 1024) return `${bytes} B`
|
|
57
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
58
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
59
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
|
60
|
+
}
|
|
61
|
+
/* kol-r2b2's partition: the next path segment is a folder, the rest are files */
|
|
62
|
+
const defaultPartition = (objects, prefix) => {
|
|
63
|
+
const folderSet = new Set()
|
|
64
|
+
const files = []
|
|
65
|
+
for (const o of objects) {
|
|
66
|
+
const rel = prefix ? o.key.slice(prefix.length) : o.key
|
|
67
|
+
const slash = rel.indexOf('/')
|
|
68
|
+
if (slash !== -1) folderSet.add(rel.slice(0, slash + 1))
|
|
69
|
+
else files.push({ ...o, displayKey: o.displayKey ?? rel })
|
|
70
|
+
}
|
|
71
|
+
return { folders: [...folderSet].sort(), files }
|
|
72
|
+
}
|
|
73
|
+
const isImage = (o) => (o.contentType || '').startsWith('image/')
|
|
74
|
+
|
|
75
|
+
/* kol-icons has no `audio` glyph yet — audio rows wear `file` until it does */
|
|
76
|
+
const COL_ICON = { image: 'image', video: 'video', audio: 'file' }
|
|
77
|
+
|
|
78
|
+
function Row({ icon, label, active, cursor = false, trailing, onClick, muted = false }) {
|
|
79
|
+
return (
|
|
80
|
+
<li
|
|
81
|
+
/* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
|
|
82
|
+
* 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last. */
|
|
83
|
+
/* `cursor` = the keyboard row, drawn with the hover fill so ↑/↓ always shows where you are. */
|
|
84
|
+
className={`kol-column-browser-row flex items-center gap-2 px-4 py-3 border-b last:border-b-0 cursor-pointer transition-colors ${
|
|
85
|
+
active || cursor ? 'bg-fg-04 text-fg-default' : muted ? 'text-fg-48 hover:bg-fg-04' : 'text-fg-default hover:bg-fg-04'
|
|
86
|
+
}`}
|
|
87
|
+
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
88
|
+
onClick={onClick}
|
|
89
|
+
>
|
|
90
|
+
<span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
|
|
91
|
+
<Icon name={icon} size={14} />
|
|
92
|
+
</span>
|
|
93
|
+
<span className="kol-mono-12 flex-1 truncate">{label}</span>
|
|
94
|
+
{trailing}
|
|
95
|
+
</li>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function Preview({ o, urlOf, kindOf, kindLabel, formatSize }) {
|
|
100
|
+
// Pixel size comes from the loaded image itself — the bucket stores none.
|
|
101
|
+
const [dims, setDims] = useState(null)
|
|
102
|
+
const src = isImage(o) ? urlOf?.(o) : null
|
|
103
|
+
const facts = [
|
|
104
|
+
['Kind', kindLabel[kindOf(o)] || 'file'],
|
|
105
|
+
['Type', o.contentType || '—'],
|
|
106
|
+
['Size', formatSize(o.size)],
|
|
107
|
+
...(src ? [['Dimensions', dims ? `${dims.w} × ${dims.h} px` : '…']] : []),
|
|
108
|
+
['Date', o.uploaded ? new Date(o.uploaded).toISOString().slice(0, 10) : '—'],
|
|
109
|
+
]
|
|
110
|
+
return (
|
|
111
|
+
<div className="kol-column-browser-preview w-[320px] shrink-0 overflow-y-auto p-4 flex flex-col gap-4">
|
|
112
|
+
<div className="w-full aspect-square bg-fg-04 rounded flex items-center justify-center overflow-hidden">
|
|
113
|
+
{src ? (
|
|
114
|
+
<img
|
|
115
|
+
src={src}
|
|
116
|
+
alt=""
|
|
117
|
+
className="max-w-full max-h-full object-contain"
|
|
118
|
+
loading="lazy"
|
|
119
|
+
onLoad={(e) => setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight })}
|
|
120
|
+
/>
|
|
121
|
+
) : (
|
|
122
|
+
<span className="kol-mono-12 text-fg-48">{kindLabel[kindOf(o)] || 'file'}</span>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
<p className="kol-mono-12 text-fg-default break-all">{o.displayKey ?? o.key}</p>
|
|
126
|
+
<dl className="flex flex-col gap-1">
|
|
127
|
+
{facts.map(([k, v]) => (
|
|
128
|
+
<div key={k} className="flex justify-between gap-4 kol-mono-12">
|
|
129
|
+
<dt className="text-fg-48">{k}</dt>
|
|
130
|
+
<dd className="text-fg-default text-right break-all">{v}</dd>
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</dl>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default function ColumnBrowser({
|
|
139
|
+
objects = [],
|
|
140
|
+
prefix = '',
|
|
141
|
+
onPrefix = () => {},
|
|
142
|
+
quickLook = null,
|
|
143
|
+
onQuickLook,
|
|
144
|
+
urlOf,
|
|
145
|
+
kindOf = defaultKindOf,
|
|
146
|
+
kindLabel = DEFAULT_KIND_LABEL,
|
|
147
|
+
formatSize = defaultFormatSize,
|
|
148
|
+
partition = defaultPartition,
|
|
149
|
+
className = '',
|
|
150
|
+
}) {
|
|
151
|
+
const [picked, setPicked] = useState(null)
|
|
152
|
+
// Keyboard cursor: which column, which row. Clicks keep it in sync.
|
|
153
|
+
const [cursor, setCursor] = useState({ col: 0, idx: 0 })
|
|
154
|
+
const rootRef = useRef(null)
|
|
155
|
+
|
|
156
|
+
const levelsOf = (pfx) => {
|
|
157
|
+
const out = ['']
|
|
158
|
+
if (pfx) {
|
|
159
|
+
const segs = pfx.replace(/\/$/, '').split('/')
|
|
160
|
+
segs.forEach((_, i) => out.push(segs.slice(0, i + 1).join('/') + '/'))
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
const itemsAt = (level) => {
|
|
165
|
+
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
166
|
+
return [...folders.map((f) => ({ type: 'folder', name: f })), ...files.map((o) => ({ type: 'file', o }))]
|
|
167
|
+
}
|
|
168
|
+
const land = (level, item) => {
|
|
169
|
+
if (!item) return
|
|
170
|
+
if (item.type === 'folder') { setPicked(null); onPrefix(level + item.name) }
|
|
171
|
+
else setPicked(item.o)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const onKeyDown = (e) => {
|
|
175
|
+
// Space = Quick Look over the picked column's files, starting at the picked one.
|
|
176
|
+
if (e.key === ' ' && picked) {
|
|
177
|
+
e.preventDefault()
|
|
178
|
+
const siblings = itemsAt(levelsOf(prefix)[cursor.col] ?? '').filter((it) => it.type === 'file').map((it) => it.o)
|
|
179
|
+
onQuickLook?.({ files: siblings, index: Math.max(0, siblings.findIndex((o) => o.key === picked.key)) })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
|
|
183
|
+
e.preventDefault()
|
|
184
|
+
const lv = levelsOf(prefix)
|
|
185
|
+
const col = Math.min(cursor.col, lv.length - 1)
|
|
186
|
+
const items = itemsAt(lv[col])
|
|
187
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
188
|
+
const idx = Math.max(0, Math.min(items.length - 1, cursor.idx + (e.key === 'ArrowDown' ? 1 : -1)))
|
|
189
|
+
setCursor({ col, idx })
|
|
190
|
+
land(lv[col], items[idx])
|
|
191
|
+
} else if (e.key === 'ArrowRight') {
|
|
192
|
+
// Into the open folder's column, first row.
|
|
193
|
+
if (lv[col + 1]) {
|
|
194
|
+
const next = itemsAt(lv[col + 1])
|
|
195
|
+
setCursor({ col: col + 1, idx: 0 })
|
|
196
|
+
land(lv[col + 1], next[0])
|
|
197
|
+
}
|
|
198
|
+
} else if (col > 0) {
|
|
199
|
+
// Back to the parent column, on the folder we came out of.
|
|
200
|
+
const parentItems = itemsAt(lv[col - 1])
|
|
201
|
+
const opened = lv[col].slice(lv[col - 1].length)
|
|
202
|
+
const idx = Math.max(0, parentItems.findIndex((it) => it.type === 'folder' && it.name === opened))
|
|
203
|
+
setCursor({ col: col - 1, idx })
|
|
204
|
+
setPicked(null)
|
|
205
|
+
onPrefix(lv[col])
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// While Quick Look is open the overlay holds focus, so the same keys are read
|
|
209
|
+
// off window: ↑/↓ step the picked file (overlay follows), space closes. The
|
|
210
|
+
// opening keystroke bubbles from this component and is ignored.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!quickLook) return
|
|
213
|
+
const onKey = (e) => {
|
|
214
|
+
if (rootRef.current?.contains(e.target)) return
|
|
215
|
+
if (e.key === ' ') { e.preventDefault(); onQuickLook?.(null); return }
|
|
216
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
|
|
217
|
+
e.preventDefault()
|
|
218
|
+
const lv = levelsOf(prefix)
|
|
219
|
+
const col = Math.min(cursor.col, lv.length - 1)
|
|
220
|
+
const items = itemsAt(lv[col])
|
|
221
|
+
let idx = cursor.idx
|
|
222
|
+
const step = e.key === 'ArrowDown' ? 1 : -1
|
|
223
|
+
do { idx += step } while (items[idx] && items[idx].type !== 'file')
|
|
224
|
+
if (!items[idx]) return
|
|
225
|
+
setCursor({ col, idx })
|
|
226
|
+
setPicked(items[idx].o)
|
|
227
|
+
const siblings = items.filter((it) => it.type === 'file').map((it) => it.o)
|
|
228
|
+
onQuickLook?.({ files: siblings, index: siblings.findIndex((o) => o.key === items[idx].o.key) })
|
|
229
|
+
}
|
|
230
|
+
window.addEventListener('keydown', onKey)
|
|
231
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const levels = levelsOf(prefix) // '' → ['']; 'a/b/' → ['', 'a/', 'a/b/']
|
|
235
|
+
// While Quick Look is open the overlay's index is the truth for the highlight.
|
|
236
|
+
const shown = quickLook ? quickLook.files[quickLook.index] : picked
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div
|
|
240
|
+
ref={rootRef}
|
|
241
|
+
tabIndex={0}
|
|
242
|
+
onKeyDown={onKeyDown}
|
|
243
|
+
// Fixed height — 12 rows of 44px (user ruling 2026-08-27), so the browser never jumps as columns change; each column scrolls.
|
|
244
|
+
className={`kol-column-browser flex overflow-x-auto h-[528px] border rounded outline-none ${className}`.trim()}
|
|
245
|
+
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
246
|
+
>
|
|
247
|
+
{levels.map((level, k) => {
|
|
248
|
+
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
249
|
+
const next = levels[k + 1]
|
|
250
|
+
const activeFolder = next ? next.slice(level.length) : null
|
|
251
|
+
return (
|
|
252
|
+
<ul
|
|
253
|
+
key={level || '/'}
|
|
254
|
+
className="kol-column-browser-column w-[260px] shrink-0 overflow-y-auto border-r last:border-r-0"
|
|
255
|
+
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
256
|
+
>
|
|
257
|
+
{folders.map((f, i) => (
|
|
258
|
+
<Row
|
|
259
|
+
key={f}
|
|
260
|
+
icon="folder"
|
|
261
|
+
label={f.replace(/\/$/, '')}
|
|
262
|
+
active={f === activeFolder}
|
|
263
|
+
cursor={cursor.col === k && cursor.idx === i}
|
|
264
|
+
trailing={<Icon name="chevron-right" size={12} className="text-fg-32" />}
|
|
265
|
+
onClick={() => { setCursor({ col: k, idx: i }); rootRef.current?.focus(); setPicked(null); onPrefix(level + f) }}
|
|
266
|
+
/>
|
|
267
|
+
))}
|
|
268
|
+
{files.map((o, i) => (
|
|
269
|
+
<Row
|
|
270
|
+
key={o.key}
|
|
271
|
+
icon={COL_ICON[kindOf(o)] || 'file'}
|
|
272
|
+
label={o.displayKey ?? o.key}
|
|
273
|
+
muted={shown?.key !== o.key}
|
|
274
|
+
active={shown?.key === o.key}
|
|
275
|
+
cursor={cursor.col === k && cursor.idx === folders.length + i}
|
|
276
|
+
onClick={() => { setCursor({ col: k, idx: folders.length + i }); rootRef.current?.focus(); setPicked(o) }}
|
|
277
|
+
/>
|
|
278
|
+
))}
|
|
279
|
+
{folders.length === 0 && files.length === 0 && (
|
|
280
|
+
<li className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
|
|
281
|
+
)}
|
|
282
|
+
</ul>
|
|
283
|
+
)
|
|
284
|
+
})}
|
|
285
|
+
{shown && <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} />}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|