@kolkrabbi/kol-component 0.216.0 → 0.217.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.
@@ -1,10 +1,12 @@
1
1
  import { Fragment, useEffect, useRef, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
- import KindPreview from '../molecules/KindPreview.jsx'
3
+ import KindPreview, { KIND_GLYPH } from '../molecules/KindPreview.jsx'
4
+ import FileIcon from '../atoms/FileIcon.jsx'
4
5
  import { formatLength } from '../molecules/AudioPreview.jsx'
5
6
  import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
6
7
  import useGrabEdge from '../hooks/useGrabEdge.js'
7
8
  import useMediaQuery from '../hooks/useMediaQuery.js'
9
+ import useMarquee from '../hooks/useMarquee.js'
8
10
  import { GRAB_COLUMN } from '../utilities/motion.js'
9
11
 
10
12
  /**
@@ -60,9 +62,12 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
60
62
  * @param {Function} formatDate (isoString) => string — the row/preview date, ISO date-only by default
61
63
  * @param {Function} partition (objects, level) => { folders: string[], files: object[] }
62
64
  * @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
63
- * @param {number} height controlled height in px (omit for uncontrolled)
65
+ * @param {number|string} height controlled height — px as a number, or ANY CSS length (`'100%'`, `'60vh'`,
66
+ * `'calc(100dvh - 240px)'`) so the browser can fill the space it is given
67
+ * (kol-client-olina 2026-09-22); a CSS length draws no height grabber;
68
+ * omit for uncontrolled
64
69
  * @param {number} defaultHeight uncontrolled start height (528); min 240
65
- * @param {Function} onHeightChange (px) => void — on every drag step; the consumer persists it
70
+ * @param {Function} onHeightChange (px) => void — on every drag step, always px; the consumer persists it
66
71
  * @param {number} columnWidth every column's start width (260); the preview column starts at 320; min 160
67
72
  * @param {Object} columnWidths the CONTROLLED counterpart to `onColumnResize` (ColumnBrowserWidthsPersist,
68
73
  * kol-r2b2 2026-08-27): a map keyed by column index plus `'preview'` —
@@ -70,6 +75,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
70
75
  * so a consumer hands back what it stored. A key with no column is ignored;
71
76
  * any column it does not name falls back to the drag state, then `columnWidth`
72
77
  * @param {Function} onColumnResize (index, px) => void — the column's index, or `'preview'`
78
+ * @param {Function} onDropFiles (files, folderPath) => void — OS files dropped on a folder row or column; the consumer uploads
73
79
  * @param {string} className extra classes on the browser
74
80
  *
75
81
  * @param {Function} thumbnailFor (o) => node — the 44px tile's content below the breakpoint; null falls back to the kind glyph. WHERE a thumbnail comes from is the consumer's: R2 and B2 serve originals, so a 44px tile can mean a 2 MB download
@@ -183,6 +189,8 @@ const defaultPartition = (objects, prefix) => {
183
189
  return { folders: [...folderSet].sort(), files }
184
190
  }
185
191
  const isImage = (o) => (o.contentType || '').startsWith('image/')
192
+ /* a drag carrying files from the OS, as opposed to one of our own rows */
193
+ export const isFileDrag = (e) => [...(e.dataTransfer?.types ?? [])].includes('Files')
186
194
 
187
195
  /* kol-icons has no `audio` glyph yet — audio rows wear `file` until it does */
188
196
  const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'video' }
@@ -239,22 +247,29 @@ const ZONE_BOX = 44
239
247
  * to what it was. */
240
248
  function Row({
241
249
  icon, label, active, cursor = false, trailing, onClick, muted = false,
242
- indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop,
250
+ indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop, dropFiles, markKey,
243
251
  }) {
244
252
  const [over, setOver] = useState(false)
245
253
  /* A COLUMN ROW IS THE SAME ROW as the list view's, so it takes the same two seams: right-click
246
- * and, for a folder, a drop target. Without them every handler is undefined and nothing moves. */
247
- const dropProps = drop ? {
248
- draggable: true,
249
- onDragStart: (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) },
250
- onDragOver: (e) => { if (drop.canDrop(drop.path)) { e.preventDefault(); setOver(true) } },
254
+ * and, for a folder, a drop target. Without them every handler is undefined and nothing moves.
255
+ * `dropFiles(files)` is the OS half — files dragged in from the desktop, same highlight. */
256
+ const takesFiles = (e) => dropFiles && isFileDrag(e)
257
+ const dropProps = drop || dropFiles ? {
258
+ draggable: !!drop,
259
+ onDragStart: drop ? (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) } : undefined,
260
+ onDragOver: (e) => { if (takesFiles(e) || (drop && !isFileDrag(e) && drop.canDrop(drop.path))) { e.preventDefault(); setOver(true) } },
251
261
  onDragLeave: () => setOver(false),
252
- onDrop: (e) => { e.preventDefault(); e.stopPropagation(); setOver(false); drop.onDrop(drop.path) },
262
+ onDrop: (e) => {
263
+ e.preventDefault(); e.stopPropagation(); setOver(false)
264
+ if (takesFiles(e)) dropFiles(e.dataTransfer.files)
265
+ else if (drop && !isFileDrag(e)) drop.onDrop(drop.path)
266
+ },
253
267
  } : {}
254
268
  return (
255
269
  <li
256
270
  onContextMenu={onContextMenu}
257
271
  {...dropProps}
272
+ data-marquee-key={markKey}
258
273
  data-drop-over={over || undefined}
259
274
  /* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
260
275
  * 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last.
@@ -305,11 +320,13 @@ function Row({
305
320
  className="kol-column-browser-thumb shrink-0 inline-flex items-center justify-center overflow-hidden"
306
321
  style={{ width: ZONE_BOX, height: ZONE_BOX, borderRadius: 5, background: thumb ? 'var(--kol-oq-04)' : 'transparent' }}
307
322
  >
308
- {thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-fg-48" />}
323
+ {thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-oq-48" />}
309
324
  </span>
310
325
  ) : (
311
- <span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
312
- <Icon name={icon} size={14} />
326
+ <span className="w-5 shrink-0 flex items-center justify-center text-oq-48">
327
+ {/* a folder's mark is the only glyph a row carries now, so it is drawn at the box's
328
+ size rather than floating in it (user 2026-09-23) */}
329
+ <Icon name={icon} size={icon === 'folder' ? 18 : 14} />
313
330
  </span>
314
331
  )}
315
332
 
@@ -367,7 +384,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
367
384
  </div>
368
385
  ) : (
369
386
  <div
370
- className="kol-column-browser-media w-full min-h-[160px] max-h-[60vh] overflow-auto rounded bg-fg-04 flex items-center justify-center"
387
+ className="kol-column-browser-media w-full min-h-[160px] max-h-[60vh] overflow-auto rounded bg-oq-04 flex items-center justify-center"
371
388
  onLoadCapture={(e) => { if (e.target?.tagName === 'IMG') setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight }) }}
372
389
  onLoadedMetadataCapture={(e) => {
373
390
  const t = e.target
@@ -459,6 +476,24 @@ export default function ColumnBrowser({
459
476
  * row can have done to it. Absent, the columns behave exactly as they did. */
460
477
  onRowContextMenu,
461
478
  dragFor,
479
+ /* `onDropFiles(files, folderPath)` — files dragged in FROM THE OS onto a folder row or a column
480
+ * (kol-client-olina 2026-09-22). The browser hands over the `FileList` and the target path and
481
+ * never uploads: each consumer's pipeline differs, so the upload is theirs, and they re-list.
482
+ * Absent, an OS drag shows nothing and does nothing. */
483
+ onDropFiles,
484
+ /* MULTI-SELECT BY DRAG (user 2026-09-22). `selectedKeys` is the set the caller owns — paths for
485
+ * folders, keys for files, exactly as this component hands them out — and `onSelectKeys(keys,
486
+ * additive)` reports what a band touched. Without the pair, a drag on the background does
487
+ * nothing, as before. */
488
+ selectedKeys,
489
+ onSelectKeys,
490
+ /* `onSelectClick(key, event, columnKeys)` — every row click, modifiers included, handed to the
491
+ * caller's selection model first (user 2026-09-23: ⇧-click *"should add to selection"*). Return
492
+ * true when the click was a selection gesture and the browser must not navigate. */
493
+ onSelectClick,
494
+ /* `folderIcon(path)` — the glyph a folder row wears (default `folder`). The media pages mark a
495
+ * bucket — a SOURCE, not a prefix — with `database` (user 2026-09-23). */
496
+ folderIcon,
462
497
  /* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
463
498
  * kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
464
499
  * consumer, so neither is computed here:
@@ -497,7 +532,9 @@ export default function ColumnBrowser({
497
532
  onColumnResize?.(i, w)
498
533
  }
499
534
  const resizeH = (dy) => {
500
- if (dragBase.current == null) dragBase.current = h
535
+ /* a string height (`'100%'`, `calc()`) has no arithmetic — the drag starts from what it
536
+ * RENDERED at, so there is no jump, and reports px from then on */
537
+ if (dragBase.current == null) dragBase.current = typeof h === 'number' ? h : (rootRef.current?.getBoundingClientRect().height ?? MIN_H)
501
538
  const next = Math.max(MIN_H, Math.round(dragBase.current + dy))
502
539
  if (height == null) setOwnH(next)
503
540
  onHeightChange?.(next)
@@ -573,13 +610,28 @@ export default function ColumnBrowser({
573
610
  * on `prefix`, so a bucket switch from the header (which takes focus) hands the keyboard back. */
574
611
  useEffect(() => { if (autoFocus) rootRef.current?.focus() }, [autoFocus, prefix])
575
612
 
613
+ /* THE KEYBOARD SELECTS TOO (user 2026-09-23: *"the highlight focus is on the correct item, this
614
+ * has been a problem for awhile"*). Clicks reported through `onSelectClick`; the arrows moved
615
+ * the cursor and the pick and told the caller nothing, so its selection kept the last CLICKED
616
+ * row lit beside the one the arrows had reached. Every keyboard move now replaces it. */
617
+ const selectOnly = (key) => onSelectKeys?.(key ? [key] : [], false)
576
618
  const land = (level, item) => {
577
619
  if (!item) return
578
- if (item.type === 'folder') { pick(null); onPrefix(level + item.name) }
579
- else pickFile(level, item.o)
620
+ if (item.type === 'folder') { pick(null); selectOnly(level + item.name); onPrefix(level + item.name) }
621
+ else { selectOnly(item.o.key); pickFile(level, item.o) }
580
622
  }
581
623
 
624
+ const shiftAnchor = useRef(null)
582
625
  const onKeyDown = (e) => {
626
+ /* ⌘↑ / ⌘↓ (user 2026-09-23: *"all modes should 'command up down' to move up and down parent
627
+ * child"*) — Finder's: ⌘↑ is ←, back to the enclosing folder; ⌘↓ opens what the cursor is on,
628
+ * → for a folder and Quick Look for a file. */
629
+ if (e.metaKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
630
+ e.preventDefault()
631
+ if (e.key === 'ArrowUp') return onKeyDown({ key: 'ArrowLeft', preventDefault() {} })
632
+ if (picked) return onKeyDown({ key: ' ', preventDefault() {} })
633
+ return onKeyDown({ key: 'ArrowRight', preventDefault() {} })
634
+ }
583
635
  // Space = Quick Look over the picked column's files, starting at the picked one.
584
636
  if (e.key === ' ' && picked) {
585
637
  e.preventDefault()
@@ -595,6 +647,20 @@ export default function ColumnBrowser({
595
647
  const items = itemsAt(lv[col])
596
648
  if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
597
649
  const idx = Math.max(0, Math.min(items.length - 1, cursor.idx + (e.key === 'ArrowDown' ? 1 : -1)))
650
+ /* ⇧ + ↑↓ EXTENDS within the column (user 2026-09-23): the run from where it began to the
651
+ * cursor becomes the selection; nothing opens, nothing is previewed but the stack */
652
+ if (e.shiftKey && onSelectKeys) {
653
+ /* a new run starts wherever the cursor is unless it is still where the last ⇧ step left it */
654
+ const sa = shiftAnchor.current
655
+ if (!sa || sa.col !== col || sa.last !== cursor.idx) shiftAnchor.current = { col, idx: cursor.idx }
656
+ shiftAnchor.current.last = idx
657
+ const a = shiftAnchor.current.idx
658
+ const keyOf = (it) => (it.type === 'folder' ? lv[col] + it.name : it.o.key)
659
+ setCursor({ col, idx })
660
+ onSelectKeys(items.slice(Math.min(a, idx), Math.max(a, idx) + 1).map(keyOf), false)
661
+ return
662
+ }
663
+ shiftAnchor.current = null
598
664
  /* an arrow that did not move the cursor does nothing — re-landing on an
599
665
  * open folder re-fired onPrefix (ColumnBrowserCursorSeed) */
600
666
  if (idx === cursor.idx && col === cursor.col) return
@@ -614,6 +680,7 @@ export default function ColumnBrowser({
614
680
  const idx = Math.max(0, parentItems.findIndex((it) => it.type === 'folder' && it.name === opened))
615
681
  setCursor({ col: col - 1, idx })
616
682
  pick(null)
683
+ selectOnly(lv[col])
617
684
  onPrefix(lv[col])
618
685
  }
619
686
  }
@@ -636,6 +703,7 @@ export default function ColumnBrowser({
636
703
  if (!items[idx]) return
637
704
  setCursor({ col, idx })
638
705
  pick(items[idx].o)
706
+ selectOnly(items[idx].o.key)
639
707
  const siblings = items.filter((it) => it.type === 'file').map((it) => it.o)
640
708
  onQuickLook?.({ files: siblings, index: siblings.findIndex((o) => o.key === items[idx].o.key) })
641
709
  }
@@ -673,6 +741,20 @@ export default function ColumnBrowser({
673
741
  * `md` (768) — the one `ContentFilters` already uses, so a page has one
674
742
  * responsive story and not two. */
675
743
  const stack = useMediaQuery('(max-width: 767px)')
744
+ /* one band for the whole browser rather than one per column: a drag that starts in a column and
745
+ * runs into the next is a drag the user meant */
746
+ const marquee = useMarquee({
747
+ enabled: !!onSelectKeys,
748
+ onSelect: (keys, additive) => onSelectKeys?.(keys, additive),
749
+ })
750
+ const isPicked = (key) => selectedKeys?.has(key)
751
+ /* THE DEEPEST COLUMN STAYS IN VIEW (user 2026-09-23: arrowing onto a file left the preview
752
+ * clipped under the frame's right edge). Whenever the trail or the pick changes, the strip scrolls
753
+ * to its end — the newest column, or the preview, is always the one you just reached. */
754
+ useEffect(() => {
755
+ const box = marquee.ref.current
756
+ if (box && box.scrollWidth > box.clientWidth) box.scrollTo({ left: box.scrollWidth, behavior: 'smooth' })
757
+ }, [prefix, picked?.key, selectedKeys?.size]) // eslint-disable-line react-hooks/exhaustive-deps
676
758
 
677
759
  if (stack) {
678
760
  /* THE CURRENT LEVEL IS THE LIST (StackModeChromeAndAncestors, kol-r2b2
@@ -710,7 +792,7 @@ export default function ColumnBrowser({
710
792
  className="kol-column-browser-back flex items-center gap-2 px-4 py-3 border-b text-fg-default"
711
793
  style={{ borderColor: 'var(--kol-oq-08)' }}
712
794
  >
713
- <Icon name="chevron-left" size={14} className="text-fg-48" />
795
+ <Icon name="chevron-left" size={14} className="text-oq-48" />
714
796
  {/* NAMES THE PARENT (item 2) — the folder it returns to, not '‹' */}
715
797
  <span className="kol-mono-12 truncate">
716
798
  {parent === '' ? 'All files' : parent.replace(/\/$/, '').split('/').pop()}
@@ -739,8 +821,8 @@ export default function ColumnBrowser({
739
821
  >
740
822
  <span className="kol-column-browser-tile-box">
741
823
  {isFolder
742
- ? <Icon name="folder" size={28} className="text-fg-48" />
743
- : (thumbnailFor?.(o) ?? <Icon name={COL_ICON[kindOf(o)] || 'file'} size={28} className="text-fg-48" />)}
824
+ ? <Icon name="folder" size={28} className="text-oq-48" />
825
+ : (thumbnailFor?.(o) ?? <Icon name={COL_ICON[kindOf(o)] || 'file'} size={28} className="text-oq-48" />)}
744
826
  </span>
745
827
  <span className="kol-mono-12 truncate">{isFolder ? r.name.replace(/\/$/, '') : (o.displayKey ?? o.key)}</span>
746
828
  {/* counts for a folder, size for a file — never dates (§2) */}
@@ -763,7 +845,7 @@ export default function ColumnBrowser({
763
845
  <Row
764
846
  key={r.key}
765
847
  zones
766
- icon="folder"
848
+ icon={folderIcon?.(r.level + r.name) ?? 'folder'}
767
849
  label={r.name.replace(/\/$/, '')}
768
850
  indent={r.depth}
769
851
  active={r.open}
@@ -816,12 +898,17 @@ export default function ColumnBrowser({
816
898
  className={`kol-column-browser relative border rounded outline-none ${className}`.trim()}
817
899
  style={{ borderColor: 'var(--kol-oq-08)', height: h }}
818
900
  >
819
- <div className="flex h-full overflow-x-auto">
901
+ {/* A CLICK ON THE BACKGROUND DESELECTS (user 2026-09-22) — the band's own closing click is
902
+ swallowed by the hook, so this only ever fires on a real click into empty space. */}
903
+ <div ref={marquee.ref} {...marquee.props} className="relative flex h-full overflow-x-auto"
904
+ onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { pick(null); onSelectKeys?.([], false) } }}>
905
+ {marquee.rect && <div className="kol-marquee" style={marquee.rect} />}
820
906
  {levels.map((level, k) => {
821
907
  const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
822
908
  const next = levels[k + 1]
823
909
  const activeFolder = next ? next.slice(level.length) : null
824
910
  const last = k === levels.length - 1 && !shown
911
+ const colKeys = [...folders.map((f) => level + f), ...files.map((o) => o.key)]
825
912
  return (
826
913
  <Fragment key={level || '/'}>
827
914
  <ul
@@ -840,31 +927,40 @@ export default function ColumnBrowser({
840
927
  * re-render per dragover across every column buys nothing. */
841
928
  onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })}
842
929
  onDragOver={(e) => {
843
- const d = dragFor?.(level)
844
- if (!d?.canDrop(level)) return
930
+ const files = onDropFiles && isFileDrag(e)
931
+ /* `d.path`, never `level`: the caller may re-root the path it was handed (the media
932
+ * pages prefix every level with `<title>/<bucket>/`), and moving to the raw level
933
+ * renamed files under a prefix nothing lists — they vanished (user 2026-09-23). */
934
+ const d = !isFileDrag(e) && dragFor?.(level)
935
+ if (!files && !d?.canDrop(d.path)) return
845
936
  e.preventDefault()
846
937
  e.currentTarget.dataset.dropOver = '1'
847
938
  }}
848
- onDragLeave={(e) => { if (e.currentTarget === e.target) delete e.currentTarget.dataset.dropOver }}
939
+ /* cleared however the drag ends: a drop on a row stops propagation, so capture runs
940
+ * first; and leaving through a child fires on the child, not on the column */
941
+ onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) delete e.currentTarget.dataset.dropOver }}
942
+ onDropCapture={(e) => { delete e.currentTarget.dataset.dropOver }}
849
943
  onDrop={(e) => {
850
- delete e.currentTarget.dataset.dropOver
851
- const d = dragFor?.(level)
852
- if (!d?.canDrop(level)) return
944
+ if (onDropFiles && isFileDrag(e)) { e.preventDefault(); onDropFiles(e.dataTransfer.files, level); return }
945
+ const d = !isFileDrag(e) && dragFor?.(level)
946
+ if (!d?.canDrop(d.path)) return
853
947
  e.preventDefault()
854
- d.onDrop(level)
948
+ d.onDrop(d.path)
855
949
  }}
856
950
  >
857
951
  {folders.map((f, i) => (
858
952
  <Row
859
953
  key={f}
860
- icon="folder"
954
+ icon={folderIcon?.(level + f) ?? 'folder'}
861
955
  label={f.replace(/\/$/, '')}
862
- active={f === activeFolder}
956
+ markKey={level + f}
957
+ active={f === activeFolder || isPicked(level + f)}
863
958
  cursor={cursorActive && cursor.col === k && cursor.idx === i}
864
- trailing={<Icon name="chevron-right" size={12} className="text-fg-32" />}
959
+ trailing={<Icon name="chevron-right" size={12} className="text-oq-32" />}
865
960
  onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path: level + f })}
866
961
  drop={dragFor?.(level + f)}
867
- onClick={() => { setCursor({ col: k, idx: i }); setCursorActive(true); rootRef.current?.focus(); pick(null); onPrefix(level + f) }}
962
+ dropFiles={onDropFiles ? (list) => onDropFiles(list, level + f) : undefined}
963
+ onClick={(e) => { if (onSelectClick?.(level + f, e, colKeys)) return; setCursor({ col: k, idx: i }); setCursorActive(true); rootRef.current?.focus(); pick(null); onPrefix(level + f) }}
868
964
  />
869
965
  ))}
870
966
  {files.map((o, i) => (
@@ -872,11 +968,12 @@ export default function ColumnBrowser({
872
968
  key={o.key}
873
969
  icon={COL_ICON[kindOf(o)] || 'file'}
874
970
  label={o.displayKey ?? o.key}
875
- active={shown?.key === o.key}
971
+ markKey={o.key}
972
+ active={shown?.key === o.key || isPicked(o.key)}
876
973
  cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
877
974
  onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
878
975
  drop={dragFor?.(o.key)}
879
- onClick={() => { setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
976
+ onClick={(e) => { if (onSelectClick?.(o.key, e, colKeys)) return; setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
880
977
  />
881
978
  ))}
882
979
  {/* AN EMPTY COLUMN IS VISIBLY EMPTY (user 2026-09-21). It used to print the word
@@ -891,14 +988,71 @@ export default function ColumnBrowser({
891
988
  </Fragment>
892
989
  )
893
990
  })}
894
- {shown && (
991
+ {selectedKeys?.size > 1 ? (
992
+ <>
993
+ <SelectionPreview keys={[...selectedKeys]} objects={objects} urlOf={urlOf} kindOf={kindOf} formatSize={formatSize} formatDate={formatDate} width={widthOf('preview')} />
994
+ <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
995
+ </>
996
+ ) : shown && (
895
997
  <>
896
998
  <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
897
999
  <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
898
1000
  </>
899
1001
  )}
900
1002
  </div>
901
- <ResizeHandle axis="y" onDrag={resizeH} onEnd={endDrag} />
1003
+ {/* A FILL HEIGHT HAS NO GRABBER (user 2026-09-22). A CSS length says "take the room you
1004
+ * are given"; dragging would fight it. A px height keeps its handle. */}
1005
+ {typeof h !== 'string' && <ResizeHandle axis="y" onDrag={resizeH} onEnd={endDrag} />}
1006
+ </div>
1007
+ )
1008
+ }
1009
+
1010
+ /* The preview column, for the media page's ROW view (user 2026-09-22 — Finder's "Show preview"):
1011
+ * one preview, the same pane beside rows as beside columns. Not in the barrel. */
1012
+ export { Preview, SelectionPreview }
1013
+
1014
+ /* SelectionPreview — the preview pane for MORE THAN ONE selected item (user 2026-09-23, Finder's:
1015
+ * *"preview of 3 overlapping files, 2 and 3 slightly rotated behind 1 … the info fields should say
1016
+ * how many files and calculated size"*). The pane kept describing one file while four were lit.
1017
+ * The stack says "several"; the facts do the counting. Shared by the column and row panes.
1018
+ *
1019
+ * `keys` — the selected keys (folders end in `/`); `objects` — every object the view knows, for
1020
+ * sizes, kinds and dates; `urlOf(o)` — an image's own picture. */
1021
+ function SelectionPreview({ keys, objects, urlOf, kindOf = dsKindOf, formatSize = (n) => `${n} B`, formatDate = (d) => d, width }) {
1022
+ const byKey = new Map(objects.map((o) => [o.key, o]))
1023
+ const folders = keys.filter((k) => k.endsWith('/'))
1024
+ const files = keys.filter((k) => !k.endsWith('/')).map((k) => byKey.get(k)).filter(Boolean)
1025
+ const inFolders = objects.filter((o) => folders.some((f) => o.key.startsWith(f)))
1026
+ const all = [...files, ...inFolders]
1027
+ const bytes = all.reduce((n, o) => n + (o.size || 0), 0)
1028
+ const dates = all.map((o) => o.uploaded).filter(Boolean).sort()
1029
+ const range = dates.length ? (dates[0] === dates[dates.length - 1] ? formatDate(dates[0]) : `${formatDate(dates[0])} – ${formatDate(dates[dates.length - 1])}`) : '—'
1030
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
1031
+ const kinds = [files.length && plural(files.length, 'document'), folders.length && plural(folders.length, 'folder')].filter(Boolean).join(', ')
1032
+ const face = (k) => {
1033
+ if (k.endsWith('/')) return <FileIcon glyph="folder" className="w-[46%]" />
1034
+ const o = byKey.get(k)
1035
+ if (o && isImage(o) && urlOf) return <img src={urlOf(o)} alt="" className="w-full h-full object-cover" />
1036
+ const ext = k.split('.').pop()
1037
+ return <FileIcon ext={ext} glyph={o ? KIND_GLYPH[kindOf(o)] : undefined} className="w-[46%]" />
1038
+ }
1039
+ const stack = keys.slice(0, 3)
1040
+ return (
1041
+ <div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
1042
+ <div className="kol-selection-stack">
1043
+ {stack.map((k, i) => (
1044
+ <div key={k} className="kol-selection-stack-card" data-depth={i}>{face(k)}</div>
1045
+ ))}
1046
+ </div>
1047
+ <p className="kol-mono-12 text-fg-default">{plural(keys.length, 'item')}</p>
1048
+ <dl className="flex flex-col gap-1">
1049
+ {[['Kind', kinds], ['Size', formatSize(bytes)], ['Date', range]].map(([k, v]) => (
1050
+ <div key={k} className="flex justify-between gap-4 kol-mono-12">
1051
+ <dt className="text-fg-48">{k}</dt>
1052
+ <dd className="text-fg-default text-right break-all">{v}</dd>
1053
+ </div>
1054
+ ))}
1055
+ </dl>
902
1056
  </div>
903
1057
  )
904
1058
  }
@@ -6,6 +6,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
6
6
  import { glyphSize } from '../hooks/glyphLadders.js'
7
7
  import SearchInput from '../molecules/SearchInput.jsx'
8
8
  import IconFrame from '../atoms/IconFrame.jsx'
9
+ import { Tooltip } from '../utilities/Popover.jsx'
9
10
 
10
11
  /**
11
12
  * ContentFilters — universal filter component for content grids.
@@ -76,6 +77,13 @@ const ContentFilters = ({
76
77
  totalCount,
77
78
  titleIcon,
78
79
  filterGroups = [],
80
+ /* THE PANEL, CONTROLLABLE (media merge, 2026-09-22). The bar's own funnel is the only way to
81
+ * reach the chips, so a consumer that already spends a control on "show me the filters" makes
82
+ * the user press two funnels to see one panel. Passing `filtersOpen` hands that state over —
83
+ * the funnel still renders and still fires, it just reports through `onFiltersOpenChange`
84
+ * instead of toggling in private. Absent, this is exactly what it was. */
85
+ filtersOpen,
86
+ onFiltersOpenChange,
79
87
  initialFilters,
80
88
  renderItem,
81
89
  viewModeOptions,
@@ -158,7 +166,12 @@ const ContentFilters = ({
158
166
  * sources of truth. Entries are `"<groupKey>:<value>"`, the same strings the
159
167
  * chips toggle. */
160
168
  const [activeFilters, setActiveFilters] = useState(() => new Set(initialFilters))
161
- const [isExpanded, setIsExpanded] = useState(false)
169
+ const [internalExpanded, setInternalExpanded] = useState(false)
170
+ const isExpanded = filtersOpen !== undefined ? filtersOpen : internalExpanded
171
+ const setIsExpanded = (next) => {
172
+ setInternalExpanded(next)
173
+ onFiltersOpenChange?.(next)
174
+ }
162
175
  const [internalViewMode, setInternalViewMode] = useState(defaultViewMode ?? viewModeOptions?.[0]?.value)
163
176
  const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
164
177
  /* Controlled when `layout` is passed, internal otherwise — the same pair viewMode uses. */
@@ -256,18 +269,21 @@ const ContentFilters = ({
256
269
  * short group ate a full row it did not need. */
257
270
  const layoutStrip = layoutOptions ? (
258
271
  <div className="flex items-center gap-4">
259
- {layoutOptions.map((opt) => (
260
- <span
261
- key={opt.value}
262
- onClick={opt.onClick ?? (() => setLayout(opt.value))}
263
- aria-pressed={opt.active !== undefined ? !!opt.active : undefined}
264
- title={opt.title}
265
- className={`${layoutClassName} cursor-pointer select-none ${(opt.active ?? layout === opt.value) ? stripActiveClassName : stripRestClassName}`}
266
- style={{ letterSpacing: 1 }}
267
- >
268
- {opt.label}
269
- </span>
270
- ))}
272
+ {layoutOptions.map((opt) => {
273
+ const chip = (
274
+ <span
275
+ key={opt.value}
276
+ onClick={opt.onClick ?? (() => setLayout(opt.value))}
277
+ aria-pressed={opt.active !== undefined ? !!opt.active : undefined}
278
+ className={`${layoutClassName} cursor-pointer select-none ${(opt.active ?? layout === opt.value) ? stripActiveClassName : stripRestClassName}`}
279
+ style={{ letterSpacing: 1 }}
280
+ >
281
+ {opt.label}
282
+ </span>
283
+ )
284
+ /* `opt.title` is a hint — the DS Tooltip, not a native `title` (kol-client-olina 2026-09-22) */
285
+ return opt.title ? <Tooltip key={opt.value} label={opt.title}>{chip}</Tooltip> : chip
286
+ })}
271
287
  </div>
272
288
  ) : null
273
289
 
@@ -498,11 +498,11 @@ function Thumb({ row, mediaUrl }) {
498
498
  function FolderRow({ folder, struck = false, onEnter }) {
499
499
  return (
500
500
  <li className="kol-media-folder" onClick={onEnter}>
501
- <span className="flex h-8 w-8 shrink-0 items-center justify-center text-meta">
501
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center text-oq-48">
502
502
  <Icon name="folder" size={18} />
503
503
  </span>
504
504
  <span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-meta' : 'text-body'}`}>{folder.label}</span>
505
- <Icon name="chevron-right" size={14} className="text-subtle" />
505
+ <Icon name="chevron-right" size={14} className="text-oq-24" />
506
506
  </li>
507
507
  )
508
508
  }