@kolkrabbi/kol-component 0.15.2 → 0.19.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.
@@ -0,0 +1,516 @@
1
+ import { createContext, useContext, useEffect, useMemo, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+ import Button from '../atoms/Button.jsx'
4
+ import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
5
+ import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
6
+ import MediaCard from '../molecules/MediaCard.jsx'
7
+ import MediaRow from '../molecules/MediaRow.jsx'
8
+ import ContentFilters from './ContentFilters.jsx'
9
+ import MediaViewer from './MediaViewer.jsx'
10
+
11
+ /**
12
+ * MediaLibrary — a browser over an object bucket, in two views over one
13
+ * headless core. Consolidates four consumer forks (kol-ds-fxr, kol-labs-single,
14
+ * kol-client-kolkrabbi, kol-website/brand — 9 files, ~1542 lines) that had
15
+ * already diverged: only fxr carried the canvas-taint fix, only labs carried
16
+ * the write paths, and neither page view ever learned folders.
17
+ *
18
+ * THE CLIENT IS INJECTED, NEVER IMPORTED. ARCHITECTURE §3 keeps the clients
19
+ * tier free of UI dependencies in both directions, so this package does not
20
+ * import `@kolkrabbi/kol-media-client` — the consumer passes an instance in.
21
+ * Same contract as kol-dashboards / kol-chess / kol-content.
22
+ *
23
+ * COMPOSED, NOT BUILT. Every part is an existing DS member:
24
+ * ContentFilters — filter groups, animated search, view toggle, N-of-M count
25
+ * MediaCard — the grid tile (thumb · download chip · name · meta · actions)
26
+ * MediaRow — the list row (thumb · name · date · size · actions)
27
+ * MediaViewer — the lightbox, via its `actions` slot
28
+ * FullscreenOverlay — the picker's scrim, dismissal and close button
29
+ * The first pass hand-rolled a tile grid and a folder row while MediaCard and
30
+ * MediaRow — built from this same source in the 2026-07-03 sweep — sat unused.
31
+ *
32
+ * NAVIGATION IS FINDER'S LIST MODEL, not click-to-enter. Folders are rows in
33
+ * the same list with a disclosure chevron and expand IN PLACE, so the parent
34
+ * never leaves the screen and there is no breadcrumb stacked above a divider.
35
+ * The path bar sits at the FOOT, where Finder puts it.
36
+ *
37
+ * Read-only by design. Upload / rename / delete stay in kol-media-admin —
38
+ * write auth does not belong in a browser-shipped package.
39
+ */
40
+
41
+ const MediaLibraryContext = createContext(null)
42
+
43
+ const isImage = (ct) => !!ct && ct.startsWith('image/')
44
+ const isVideo = (ct) => !!ct && ct.startsWith('video/')
45
+
46
+ /* Bytes → a human-readable weight. Duplicated from the client on purpose:
47
+ * importing it would create the very UI→clients edge §3 forbids, and it is
48
+ * four lines of arithmetic with no contract behind it. */
49
+ function formatSize(bytes) {
50
+ if (bytes == null) return ''
51
+ if (bytes < 1024) return `${bytes} B`
52
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
53
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
54
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
55
+ }
56
+
57
+ const fileName = (key) => key.slice(key.lastIndexOf('/') + 1)
58
+ const folderOf = (key) => key.slice(0, key.lastIndexOf('/') + 1)
59
+
60
+ /* A video element with no poster paints an empty box until played, and 222 of
61
+ * the reference bucket's 433 objects are video — a time fragment makes the
62
+ * browser seek and paint frame one instead. */
63
+ const posterSrc = (url) => `${url}#t=0.1`
64
+
65
+ /**
66
+ * Flatten the bucket's flat key list into ONE ordered row list, folders and
67
+ * files interleaved, honouring which folders are open. The list endpoint
68
+ * returns keys with no `prefixes` key and `?delimiter=/` changes nothing
69
+ * (probed 2026-08-01), so the tree is derived here — this function is the
70
+ * whole navigation feature.
71
+ */
72
+ function buildRows(objects, expanded, sort) {
73
+ const childrenOf = new Map()
74
+ const folders = new Set()
75
+
76
+ for (const o of objects) {
77
+ const dir = folderOf(o.key)
78
+ if (dir) {
79
+ /* register every ancestor so a deep key materialises its whole chain */
80
+ const parts = dir.slice(0, -1).split('/')
81
+ for (let i = 0; i < parts.length; i += 1) folders.add(`${parts.slice(0, i + 1).join('/')}/`)
82
+ }
83
+ if (!childrenOf.has(dir)) childrenOf.set(dir, [])
84
+ childrenOf.get(dir).push(o)
85
+ }
86
+
87
+ const subFoldersOf = (prefix) =>
88
+ [...folders].filter((f) => folderOf(f.slice(0, -1)) === prefix).sort()
89
+
90
+ const sorted = (list) => {
91
+ const by = {
92
+ name: (a, b) => a.key.localeCompare(b.key),
93
+ date: (a, b) => String(b.uploaded ?? '').localeCompare(String(a.uploaded ?? '')),
94
+ size: (a, b) => (b.size ?? 0) - (a.size ?? 0),
95
+ kind: (a, b) => String(a.contentType ?? '').localeCompare(String(b.contentType ?? '')),
96
+ }
97
+ return [...list].sort(by[sort] ?? by.name)
98
+ }
99
+
100
+ const rows = []
101
+ const walk = (prefix, depth) => {
102
+ for (const f of subFoldersOf(prefix)) {
103
+ rows.push({ type: 'folder', key: f, label: fileName(f.slice(0, -1)) + '/', depth })
104
+ if (expanded.has(f)) walk(f, depth + 1)
105
+ }
106
+ for (const o of sorted(childrenOf.get(prefix) ?? [])) {
107
+ rows.push({ type: 'file', depth, ...o, displayKey: fileName(o.key) })
108
+ }
109
+ }
110
+ walk('', 0)
111
+ return rows
112
+ }
113
+
114
+ /**
115
+ * MediaLibraryProvider — the headless core: one list call, client-side tree
116
+ * derivation, the open-folder set and the sort key.
117
+ *
118
+ * @param {object} client `{ listMedia, mediaUrl, proxied? }` — required
119
+ * @param {string} accept 'image' | 'video' | 'all' — which types are listed
120
+ */
121
+ export function MediaLibraryProvider({ client, accept = 'all', children }) {
122
+ const [objects, setObjects] = useState([])
123
+ const [expanded, setExpanded] = useState(() => new Set())
124
+ const [sort, setSort] = useState('name')
125
+ const [loading, setLoading] = useState(true)
126
+ const [error, setError] = useState(null)
127
+
128
+ useEffect(() => {
129
+ if (!client) return undefined
130
+ let cancelled = false
131
+ const controller = new AbortController()
132
+ setLoading(true)
133
+ setError(null)
134
+ client
135
+ .listMedia('', { signal: controller.signal })
136
+ .then((objs) => { if (!cancelled) setObjects(objs) })
137
+ .catch((e) => { if (!cancelled && e.name !== 'AbortError') setError(e.message) })
138
+ .finally(() => { if (!cancelled) setLoading(false) })
139
+ return () => { cancelled = true; controller.abort() }
140
+ }, [client])
141
+
142
+ const toggleFolder = (key) =>
143
+ setExpanded((prev) => {
144
+ const next = new Set(prev)
145
+ if (next.has(key)) next.delete(key)
146
+ else next.add(key)
147
+ return next
148
+ })
149
+
150
+ const value = useMemo(() => {
151
+ const wanted = (o) =>
152
+ accept === 'video' ? isVideo(o.contentType)
153
+ : accept === 'image' ? isImage(o.contentType)
154
+ : isImage(o.contentType) || isVideo(o.contentType)
155
+
156
+ const kept = objects.filter(wanted)
157
+ return {
158
+ objects: kept,
159
+ rows: buildRows(kept, expanded, sort),
160
+ files: kept,
161
+ expanded,
162
+ toggleFolder,
163
+ sort,
164
+ setSort,
165
+ loading,
166
+ error,
167
+ mediaUrl: client?.mediaUrl ?? ((key) => key),
168
+ proxied: client?.proxied ?? ((url) => url),
169
+ }
170
+ }, [objects, expanded, sort, loading, error, accept, client])
171
+
172
+ return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>
173
+ }
174
+
175
+ /** Read the surrounding library. Throws outside a provider — a silent null
176
+ * here would surface as an empty grid with no explanation. */
177
+ export function useMediaLibrary() {
178
+ const ctx = useContext(MediaLibraryContext)
179
+ if (!ctx) throw new Error('useMediaLibrary must be used inside <MediaLibraryProvider>')
180
+ return ctx
181
+ }
182
+
183
+ function withProvider(node, { client, accept }) {
184
+ if (!client) return node
185
+ return <MediaLibraryProvider client={client} accept={accept}>{node}</MediaLibraryProvider>
186
+ }
187
+
188
+ /* Indentation per tree depth. A rem step rather than a magic pixel, and it
189
+ * rides the spacing scale's 1rem rung. */
190
+ const indent = (depth) => ({ paddingInlineStart: `calc(${depth} * var(--kol-spacing-4))` })
191
+
192
+ function FolderRow({ row, open, onToggle }) {
193
+ return (
194
+ <li
195
+ className="kol-media-folder"
196
+ style={indent(row.depth)}
197
+ onClick={onToggle}
198
+ aria-expanded={open}
199
+ >
200
+ <Icon name={open ? 'chevron-down' : 'chevron-right'} size={14} />
201
+ <Icon name="folder" size={16} />
202
+ <span className="kol-mono-12 text-emphasis flex-1">{row.label}</span>
203
+ </li>
204
+ )
205
+ }
206
+
207
+ /* Copy-URL is the one action every view carries; the admin's Rename/Delete are
208
+ * write ops and stay out of the DS (ARCHITECTURE §3, and the spec's own note). */
209
+ function useCopy() {
210
+ const [copied, setCopied] = useState(null)
211
+ const copy = async (url) => {
212
+ try { await navigator.clipboard.writeText(url) } catch { /* clipboard blocked */ }
213
+ setCopied(url)
214
+ setTimeout(() => setCopied(null), 1500)
215
+ }
216
+ return [copied, copy]
217
+ }
218
+
219
+ function Thumb({ row, mediaUrl }) {
220
+ return isVideo(row.contentType)
221
+ ? <video src={posterSrc(mediaUrl(row.key))} muted preload="metadata" className="w-full h-full object-cover" />
222
+ : <img src={mediaUrl(row.key)} alt="" loading="lazy" className="w-full h-full object-cover" />
223
+ }
224
+
225
+ /* Folders, then the tiles or rows. Shared by both views — the modal shell and
226
+ * the pick action are the ONLY differences between them. */
227
+ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
228
+ const { expanded, toggleFolder, mediaUrl, loading, error } = useMediaLibrary()
229
+ const [copied, copy] = useCopy()
230
+
231
+ if (error) return <p className="kol-helper-12 text-ui-error">Couldn’t load: {error}</p>
232
+ if (loading) return <p className="kol-helper-12 text-meta">Loading…</p>
233
+ if (rows.length === 0) return <p className="kol-helper-12 text-meta">Nothing here.</p>
234
+
235
+ const files = rows.filter((r) => r.type === 'file')
236
+ const indexOfFile = (row) => files.findIndex((f) => f.key === row.key)
237
+
238
+ const actionsFor = (row) => (
239
+ <div className="flex items-center gap-2">
240
+ {onPick && <Button size="sm" onClick={() => onPick(row)}>Use</Button>}
241
+ <Button variant="secondary" size="sm" onClick={() => copy(mediaUrl(row.key))}>
242
+ {copied === mediaUrl(row.key) ? 'Copied' : 'Copy URL'}
243
+ </Button>
244
+ </div>
245
+ )
246
+
247
+ if (viewMode === 'list') {
248
+ return (
249
+ <ul className="kol-media-scroll kol-media-list">
250
+ {rows.map((row) =>
251
+ row.type === 'folder' ? (
252
+ <FolderRow key={row.key} row={row} open={expanded.has(row.key)} onToggle={() => toggleFolder(row.key)} />
253
+ ) : (
254
+ <div key={row.key} style={indent(row.depth)}>
255
+ <MediaRow
256
+ thumb={<Thumb row={row} mediaUrl={mediaUrl} />}
257
+ name={
258
+ <button type="button" className="kol-mono-12 text-emphasis" onClick={() => onOpen(indexOfFile(row))}>
259
+ {row.displayKey}
260
+ </button>
261
+ }
262
+ date={row.uploaded ? String(row.uploaded).slice(0, 10) : ''}
263
+ size={formatSize(row.size)}
264
+ actions={actionsFor(row)}
265
+ />
266
+ </div>
267
+ ),
268
+ )}
269
+ </ul>
270
+ )
271
+ }
272
+
273
+ return (
274
+ <div className="kol-media-scroll">
275
+ <ul className="kol-media-list">
276
+ {rows.filter((r) => r.type === 'folder').map((row) => (
277
+ <FolderRow key={row.key} row={row} open={expanded.has(row.key)} onToggle={() => toggleFolder(row.key)} />
278
+ ))}
279
+ </ul>
280
+ <ul className="kol-media-grid">
281
+ {files.map((row, i) => (
282
+ <MediaCard
283
+ key={row.key}
284
+ thumb={
285
+ <div className="w-full h-full cursor-pointer" onClick={() => onOpen(i)}>
286
+ <Thumb row={row} mediaUrl={mediaUrl} />
287
+ </div>
288
+ }
289
+ name={<p className="kol-mono-12 text-emphasis truncate">{row.displayKey}</p>}
290
+ meta={`${formatSize(row.size)}${row.uploaded ? ` · ${String(row.uploaded).slice(0, 10)}` : ''}`}
291
+ downloadHref={mediaUrl(row.key)}
292
+ actions={actionsFor(row)}
293
+ />
294
+ ))}
295
+ </ul>
296
+ </div>
297
+ )
298
+ }
299
+
300
+ /* Finder puts the path at the window FOOT, not stacked above the content. */
301
+ function PathBar({ rows }) {
302
+ const open = rows.filter((r) => r.type === 'folder' && r.depth > 0)
303
+ const trail = open.length ? open[open.length - 1].key.replace(/\/$/, '').split('/') : []
304
+ return (
305
+ <div className="kol-media-pathbar">
306
+ <Icon name="folder" size={12} />
307
+ <span className="kol-helper-12 text-meta">root</span>
308
+ {trail.map((seg) => (
309
+ <span key={seg} className="flex items-center gap-1">
310
+ <Icon name="chevron-right" size={10} />
311
+ <span className="kol-helper-12 text-meta">{seg}</span>
312
+ </span>
313
+ ))}
314
+ </div>
315
+ )
316
+ }
317
+
318
+ const VIEW_OPTIONS = [
319
+ { value: 'grid', icon: 'grid', label: 'Grid' },
320
+ { value: 'list', icon: 'view-list', label: 'List' },
321
+ ]
322
+
323
+ /* Sort is a SegmentedToggle — the DS's joined N-way control. The first
324
+ * pass hand-rolled four <button className="kol-helper-12"> instead. */
325
+ const SORTS = [
326
+ { value: 'name', label: 'name' },
327
+ { value: 'date', label: 'date' },
328
+ { value: 'size', label: 'size' },
329
+ { value: 'kind', label: 'kind' },
330
+ ]
331
+
332
+ /* The chrome — ContentFilters owns the animated search, the filter groups, the
333
+ * view toggle and the N-of-M count. It was hand-rolled as a static <Input> on
334
+ * the first pass while this organism sat one import away. */
335
+ function LibraryChrome({ onOpen, onPick }) {
336
+ const { rows, sort, setSort } = useMediaLibrary()
337
+ const [viewMode, setViewMode] = useState('grid')
338
+
339
+ const items = useMemo(
340
+ () => rows.map((r) => ({
341
+ ...r,
342
+ name: r.type === 'folder' ? r.label : r.displayKey,
343
+ kind: r.type === 'folder' ? 'folder' : isVideo(r.contentType) ? 'video' : 'image',
344
+ })),
345
+ [rows],
346
+ )
347
+
348
+ return (
349
+ <>
350
+ <ContentFilters
351
+ items={items}
352
+ title="Media library"
353
+ titleIcon="folder"
354
+ totalCount={items.length}
355
+ searchKeys={['name']}
356
+ viewMode={viewMode}
357
+ onViewModeChange={setViewMode}
358
+ viewModeOptions={VIEW_OPTIONS}
359
+ mutuallyExclusiveFilters={['kind']}
360
+ filterGroups={[{ label: 'Kind', key: 'kind', values: ['image', 'video', 'folder'] }]}
361
+ headerActions={
362
+ <SegmentedToggle
363
+ size="sm"
364
+ value={sort}
365
+ onChange={setSort}
366
+ options={SORTS}
367
+ ariaLabel="Sort by"
368
+ />
369
+ }
370
+ renderItem={(filtered, mode) => (
371
+ <LibraryBody rows={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
372
+ )}
373
+ />
374
+ <PathBar rows={rows} />
375
+ </>
376
+ )
377
+ }
378
+
379
+ /* The lightbox is MediaViewer — the DS already has ONE fullscreen paged viewer
380
+ * and this is not a second one. Use / Copy URL ride its `actions` slot. */
381
+ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
382
+ const { files, mediaUrl } = useMediaLibrary()
383
+ const [copied, copy] = useCopy()
384
+
385
+ const media = files.map((o) => ({
386
+ url: mediaUrl(o.key),
387
+ alt: fileName(o.key),
388
+ kind: isVideo(o.contentType) ? 'video' : 'image',
389
+ caption: `${fileName(o.key)} · ${formatSize(o.size)}`,
390
+ }))
391
+
392
+ return (
393
+ <MediaViewer
394
+ open={index !== null}
395
+ media={media}
396
+ index={index ?? 0}
397
+ onIndexChange={onIndexChange}
398
+ onClose={onClose}
399
+ actions={(item, i) => (
400
+ <>
401
+ {onPick && <Button size="sm" onClick={() => onPick(files[i])}>Use</Button>}
402
+ <Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
403
+ {copied === item.url ? 'Copied' : 'Copy URL'}
404
+ </Button>
405
+ </>
406
+ )}
407
+ />
408
+ )
409
+ }
410
+
411
+ function PickerShell({ onClose, onPick }) {
412
+ const { files, mediaUrl } = useMediaLibrary()
413
+ const [viewerIndex, setViewerIndex] = useState(null)
414
+
415
+ const pick = (o) => {
416
+ onPick?.(mediaUrl(o.key), { contentType: o.contentType })
417
+ onClose?.()
418
+ }
419
+
420
+ return (
421
+ <>
422
+ {/* While the viewer is up it owns Escape — handing the picker a no-op
423
+ * close means one keypress steps back one level instead of exiting the
424
+ * whole picker, which is the behaviour the fxr fork hand-rolled. */}
425
+ <FullscreenOverlay open onClose={viewerIndex === null ? onClose : () => {}}>
426
+ <div className="kol-media-picker">
427
+ <LibraryChrome onOpen={setViewerIndex} onPick={pick} />
428
+ </div>
429
+ </FullscreenOverlay>
430
+
431
+ {viewerIndex !== null && files[viewerIndex] && (
432
+ <LibraryViewer
433
+ index={viewerIndex}
434
+ onIndexChange={setViewerIndex}
435
+ onClose={() => setViewerIndex(null)}
436
+ onPick={pick}
437
+ />
438
+ )}
439
+ </>
440
+ )
441
+ }
442
+
443
+ /**
444
+ * MediaLibrary — ONE component, two variants. The user's ruling 2026-08-01:
445
+ * "arent different components, they are more like variants, same shit
446
+ * different viewing." He is right — `page` and `modal` render the identical
447
+ * body and differ only in the shell around it and whether picking closes.
448
+ *
449
+ * Variant is CONTAINER GEOMETRY ONLY, the ThemeToggle precedent: everything
450
+ * else is a prop. `MediaBrowser` and `MediaPicker` survive below as thin
451
+ * aliases so no call site breaks.
452
+ *
453
+ * @param {string} variant 'page' (in-flow, fills its box) | 'modal' (overlay)
454
+ * @param {boolean} open modal only — mounts the overlay
455
+ * @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
456
+ * @param {string} accept 'image' | 'video' | 'all'
457
+ * @param {Function} onClose modal only — Esc, backdrop, close button
458
+ * @param {Function} onSelect `(url, { contentType })`. In `modal` it also closes.
459
+ */
460
+ export default function MediaLibrary({
461
+ variant = 'page',
462
+ open = true,
463
+ client,
464
+ accept = 'all',
465
+ onClose,
466
+ onSelect = null,
467
+ }) {
468
+ if (variant === 'modal') {
469
+ if (!open) return null
470
+ return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, { client, accept })
471
+ }
472
+ return withProvider(<BrowserShell onSelect={onSelect} />, { client, accept })
473
+ }
474
+
475
+ /** Alias — `MediaLibrary variant="modal"`. Kept so existing call sites and the
476
+ * fxr editor's `onPick` naming keep working. */
477
+ export function MediaPicker({ open, client, accept = 'all', onClose, onPick }) {
478
+ return (
479
+ <MediaLibrary
480
+ variant="modal"
481
+ open={open}
482
+ client={client}
483
+ accept={accept}
484
+ onClose={onClose}
485
+ onSelect={onPick}
486
+ />
487
+ )
488
+ }
489
+
490
+ function BrowserShell({ onSelect }) {
491
+ const { files, mediaUrl } = useMediaLibrary()
492
+ const [viewerIndex, setViewerIndex] = useState(null)
493
+
494
+ const pick = onSelect ? (o) => onSelect(mediaUrl(o.key), { contentType: o.contentType }) : undefined
495
+
496
+ return (
497
+ <div className="kol-media-browser">
498
+ <LibraryChrome onOpen={setViewerIndex} onPick={pick} />
499
+
500
+ {viewerIndex !== null && files[viewerIndex] && (
501
+ <LibraryViewer
502
+ index={viewerIndex}
503
+ onIndexChange={setViewerIndex}
504
+ onClose={() => setViewerIndex(null)}
505
+ onPick={pick}
506
+ />
507
+ )}
508
+ </div>
509
+ )
510
+ }
511
+
512
+ /** Alias — `MediaLibrary variant="page"`. Without `onSelect` the actions offer
513
+ * Copy URL only, which is the read-only page a brand book wants. */
514
+ export function MediaBrowser({ client, accept = 'all', onSelect = null }) {
515
+ return <MediaLibrary variant="page" client={client} accept={accept} onSelect={onSelect} />
516
+ }
@@ -22,6 +22,9 @@ import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
22
22
  * @param {number} index active item index (parent-owned)
23
23
  * @param {Function} onIndexChange fires with the new index on page
24
24
  * @param {Function} onClose close request (Esc, backdrop, close button)
25
+ * @param {Function|ReactNode} actions optional row under the stage; a function
26
+ * receives `(activeItem, index)` so openers can offer per-item actions
27
+ * (MediaPicker's Use / Copy URL) without forking a second lightbox
25
28
  */
26
29
 
27
30
  /* Inverse-tier chip tokens: the overlay scrim is surface-inverse, and .kol-overlay
@@ -109,12 +112,17 @@ function ViewerStage({ media, index, onIndexChange }) {
109
112
  )
110
113
  }
111
114
 
112
- export default function MediaViewer({ open, media = [], index = 0, onIndexChange, onClose }) {
115
+ export default function MediaViewer({ open, media = [], index = 0, onIndexChange, onClose, actions }) {
113
116
  if (!open || !media[index]) return null
114
117
 
118
+ const actionRow = typeof actions === 'function' ? actions(media[index], index) : actions
119
+
115
120
  return (
116
121
  <FullscreenOverlay open onClose={onClose}>
117
122
  <ViewerStage media={media} index={index} onIndexChange={onIndexChange} />
123
+ {actionRow && (
124
+ <div className="mt-4 flex items-center justify-center gap-2">{actionRow}</div>
125
+ )}
118
126
  </FullscreenOverlay>
119
127
  )
120
128
  }
@@ -1,3 +1,6 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+
1
4
  /**
2
5
  * Table — data table.
3
6
  * Styles: src/styles/kol-components-organisms.css.
@@ -5,10 +8,54 @@
5
8
  * Variants:
6
9
  * default — bordered, column dividers, header bg
7
10
  * simple — borderless, flush, no column dividers
11
+ *
12
+ * SORTING (2026-08-01): a column opts in with `sortable: true`. Clicking its
13
+ * header cycles asc → desc → none, and the glyph says which. The header is a
14
+ * real <button> inside the <th>, so it is keyboard-reachable and screen
15
+ * readers get `aria-sort` on the cell. Uncontrolled by design — a table that
16
+ * sorts itself needs no state from the page. Pass `sortValue(row)` when the
17
+ * rendered cell is not what should be compared (a chip, a link, a date
18
+ * string).
8
19
  */
9
- const Table = ({ caption, columns, rows, variant = 'default', className = '' }) => {
20
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
21
+
22
+ /* WIDTH IS A CONTRACT, NOT A COMMENT (2026-08-01). A table used to be capped
23
+ * by whatever div a page happened to wrap it in, and the gate policed that by
24
+ * grepping the page for a token name — so a legitimate wide table needed a
25
+ * magic `width-ok:` comment to buy clearance. The table declares its own width
26
+ * now: `panel` (the default, prose tables) caps at the panel rung; `column`
27
+ * runs the content column for a DATA table whose columns need the room. Both
28
+ * are correct by construction, so the gate can assert the contract instead of
29
+ * a string. */
30
+ const WIDTHS = {
31
+ panel: 'max-w-[var(--kol-content-panel)]',
32
+ column: '',
33
+ }
34
+
35
+ const Table = ({ caption, columns, rows, variant = 'default', className = '', width = 'panel' }) => {
36
+ const [sort, setSort] = useState({ key: null, dir: null })
37
+
38
+ const cycle = (key) =>
39
+ setSort((prev) =>
40
+ prev.key !== key ? { key, dir: 'asc' }
41
+ : prev.dir === 'asc' ? { key, dir: 'desc' }
42
+ : { key: null, dir: null })
43
+
44
+ const sorted = useMemo(() => {
45
+ if (!sort.key || !sort.dir) return rows
46
+ const col = columns.find((c) => c.accessor === sort.key)
47
+ if (!col) return rows
48
+ const val = (r) => (col.sortValue ? col.sortValue(r) : r[col.accessor])
49
+ const sign = sort.dir === 'asc' ? 1 : -1
50
+ return [...rows].sort((a, b) => {
51
+ const x = val(a), y = val(b)
52
+ if (typeof x === 'number' && typeof y === 'number') return (x - y) * sign
53
+ return collator.compare(String(x ?? ''), String(y ?? '')) * sign
54
+ })
55
+ }, [rows, columns, sort])
56
+
10
57
  const variantClass = variant === 'simple' ? 'kol-table--simple' : ''
11
- const wrapperClass = ['kol-table-wrapper', variantClass, className].filter(Boolean).join(' ')
58
+ const wrapperClass = ['kol-table-wrapper', variantClass, WIDTHS[width] ?? WIDTHS.panel, className].filter(Boolean).join(' ')
12
59
  return (
13
60
  <div className={wrapperClass}>
14
61
  <table className="kol-table">
@@ -21,14 +68,30 @@ const Table = ({ caption, columns, rows, variant = 'default', className = '' })
21
68
  scope="col"
22
69
  className={column.headerClassName ?? 'kol-table-cell-title'}
23
70
  style={column.style}
71
+ aria-sort={sort.key === column.accessor ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
24
72
  >
25
- {column.header}
73
+ {column.sortable ? (
74
+ <button
75
+ type="button"
76
+ className="kol-table-sort"
77
+ onClick={() => cycle(column.accessor)}
78
+ >
79
+ {column.header}
80
+ <Icon
81
+ name={sort.key === column.accessor && sort.dir === 'desc' ? 'chevron-down' : 'chevron-up'}
82
+ size={12}
83
+ className={sort.key === column.accessor ? 'opacity-100' : 'opacity-0 group-hover:opacity-40'}
84
+ />
85
+ </button>
86
+ ) : (
87
+ column.header
88
+ )}
26
89
  </th>
27
90
  ))}
28
91
  </tr>
29
92
  </thead>
30
93
  <tbody>
31
- {rows.map((row, rowIndex) => (
94
+ {sorted.map((row, rowIndex) => (
32
95
  <tr key={row.id ?? row.token ?? rowIndex} className="kol-table-row">
33
96
  {columns.map((column) => (
34
97
  <td key={column.accessor} className={(typeof column.className === 'function' ? column.className(row) : column.className) ?? 'kol-table-cell-text'} style={column.style}>