@kolkrabbi/kol-component 0.215.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,10 +247,30 @@ 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,
250
+ indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop, dropFiles, markKey,
243
251
  }) {
252
+ const [over, setOver] = useState(false)
253
+ /* A COLUMN ROW IS THE SAME ROW as the list view's, so it takes the same two seams: right-click
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) } },
261
+ onDragLeave: () => setOver(false),
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
+ },
267
+ } : {}
244
268
  return (
245
269
  <li
270
+ onContextMenu={onContextMenu}
271
+ {...dropProps}
272
+ data-marquee-key={markKey}
273
+ data-drop-over={over || undefined}
246
274
  /* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
247
275
  * 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last.
248
276
  * The `zones` row sets its own 60px instead — a 44px thumbnail in a
@@ -292,11 +320,13 @@ function Row({
292
320
  className="kol-column-browser-thumb shrink-0 inline-flex items-center justify-center overflow-hidden"
293
321
  style={{ width: ZONE_BOX, height: ZONE_BOX, borderRadius: 5, background: thumb ? 'var(--kol-oq-04)' : 'transparent' }}
294
322
  >
295
- {thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-fg-48" />}
323
+ {thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-oq-48" />}
296
324
  </span>
297
325
  ) : (
298
- <span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
299
- <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} />
300
330
  </span>
301
331
  )}
302
332
 
@@ -354,7 +384,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
354
384
  </div>
355
385
  ) : (
356
386
  <div
357
- 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"
358
388
  onLoadCapture={(e) => { if (e.target?.tagName === 'IMG') setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight }) }}
359
389
  onLoadedMetadataCapture={(e) => {
360
390
  const t = e.target
@@ -441,6 +471,29 @@ export default function ColumnBrowser({
441
471
  columnWidth = 260,
442
472
  columnWidths,
443
473
  onColumnResize,
474
+ /* RIGHT-CLICK AND DROP, handed down (2026-09-21). `onRowContextMenu(event, { type, path, o })`
475
+ * and `dragFor(path)` are the page's; this component owns the geometry of a row, never what a
476
+ * row can have done to it. Absent, the columns behave exactly as they did. */
477
+ onRowContextMenu,
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,
444
497
  /* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
445
498
  * kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
446
499
  * consumer, so neither is computed here:
@@ -479,7 +532,9 @@ export default function ColumnBrowser({
479
532
  onColumnResize?.(i, w)
480
533
  }
481
534
  const resizeH = (dy) => {
482
- 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)
483
538
  const next = Math.max(MIN_H, Math.round(dragBase.current + dy))
484
539
  if (height == null) setOwnH(next)
485
540
  onHeightChange?.(next)
@@ -555,13 +610,28 @@ export default function ColumnBrowser({
555
610
  * on `prefix`, so a bucket switch from the header (which takes focus) hands the keyboard back. */
556
611
  useEffect(() => { if (autoFocus) rootRef.current?.focus() }, [autoFocus, prefix])
557
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)
558
618
  const land = (level, item) => {
559
619
  if (!item) return
560
- if (item.type === 'folder') { pick(null); onPrefix(level + item.name) }
561
- 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) }
562
622
  }
563
623
 
624
+ const shiftAnchor = useRef(null)
564
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
+ }
565
635
  // Space = Quick Look over the picked column's files, starting at the picked one.
566
636
  if (e.key === ' ' && picked) {
567
637
  e.preventDefault()
@@ -577,6 +647,20 @@ export default function ColumnBrowser({
577
647
  const items = itemsAt(lv[col])
578
648
  if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
579
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
580
664
  /* an arrow that did not move the cursor does nothing — re-landing on an
581
665
  * open folder re-fired onPrefix (ColumnBrowserCursorSeed) */
582
666
  if (idx === cursor.idx && col === cursor.col) return
@@ -596,6 +680,7 @@ export default function ColumnBrowser({
596
680
  const idx = Math.max(0, parentItems.findIndex((it) => it.type === 'folder' && it.name === opened))
597
681
  setCursor({ col: col - 1, idx })
598
682
  pick(null)
683
+ selectOnly(lv[col])
599
684
  onPrefix(lv[col])
600
685
  }
601
686
  }
@@ -618,6 +703,7 @@ export default function ColumnBrowser({
618
703
  if (!items[idx]) return
619
704
  setCursor({ col, idx })
620
705
  pick(items[idx].o)
706
+ selectOnly(items[idx].o.key)
621
707
  const siblings = items.filter((it) => it.type === 'file').map((it) => it.o)
622
708
  onQuickLook?.({ files: siblings, index: siblings.findIndex((o) => o.key === items[idx].o.key) })
623
709
  }
@@ -655,6 +741,20 @@ export default function ColumnBrowser({
655
741
  * `md` (768) — the one `ContentFilters` already uses, so a page has one
656
742
  * responsive story and not two. */
657
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
658
758
 
659
759
  if (stack) {
660
760
  /* THE CURRENT LEVEL IS THE LIST (StackModeChromeAndAncestors, kol-r2b2
@@ -692,7 +792,7 @@ export default function ColumnBrowser({
692
792
  className="kol-column-browser-back flex items-center gap-2 px-4 py-3 border-b text-fg-default"
693
793
  style={{ borderColor: 'var(--kol-oq-08)' }}
694
794
  >
695
- <Icon name="chevron-left" size={14} className="text-fg-48" />
795
+ <Icon name="chevron-left" size={14} className="text-oq-48" />
696
796
  {/* NAMES THE PARENT (item 2) — the folder it returns to, not '‹' */}
697
797
  <span className="kol-mono-12 truncate">
698
798
  {parent === '' ? 'All files' : parent.replace(/\/$/, '').split('/').pop()}
@@ -721,8 +821,8 @@ export default function ColumnBrowser({
721
821
  >
722
822
  <span className="kol-column-browser-tile-box">
723
823
  {isFolder
724
- ? <Icon name="folder" size={28} className="text-fg-48" />
725
- : (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" />)}
726
826
  </span>
727
827
  <span className="kol-mono-12 truncate">{isFolder ? r.name.replace(/\/$/, '') : (o.displayKey ?? o.key)}</span>
728
828
  {/* counts for a folder, size for a file — never dates (§2) */}
@@ -734,15 +834,18 @@ export default function ColumnBrowser({
734
834
  })}
735
835
  </div>
736
836
  ) : (
737
- <ul className="kol-column-browser-column flex-1 overflow-y-auto">
837
+ <ul className="kol-column-browser-column flex-1 overflow-y-auto"
838
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: prefix })}>
738
839
  {rows.map((r) =>
840
+ /* Same ruling as the desktop column: nothing to say, so it says nothing. This is the
841
+ * mobile stack's row for an empty folder. */
739
842
  r.kind === 'empty' ? (
740
- <li key={r.key} className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
843
+ <li key={r.key} className="min-h-12" />
741
844
  ) : r.kind === 'folder' ? (
742
845
  <Row
743
846
  key={r.key}
744
847
  zones
745
- icon="folder"
848
+ icon={folderIcon?.(r.level + r.name) ?? 'folder'}
746
849
  label={r.name.replace(/\/$/, '')}
747
850
  indent={r.depth}
748
851
  active={r.open}
@@ -795,27 +898,69 @@ export default function ColumnBrowser({
795
898
  className={`kol-column-browser relative border rounded outline-none ${className}`.trim()}
796
899
  style={{ borderColor: 'var(--kol-oq-08)', height: h }}
797
900
  >
798
- <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} />}
799
906
  {levels.map((level, k) => {
800
907
  const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
801
908
  const next = levels[k + 1]
802
909
  const activeFolder = next ? next.slice(level.length) : null
803
910
  const last = k === levels.length - 1 && !shown
911
+ const colKeys = [...folders.map((f) => level + f), ...files.map((o) => o.key)]
804
912
  return (
805
913
  <Fragment key={level || '/'}>
806
914
  <ul
807
915
  className="kol-column-browser-column shrink-0 overflow-y-auto border-r"
808
916
  style={{ borderColor: 'var(--kol-oq-08)', width: widthOf(k) }}
917
+ /* THE WHOLE COLUMN IS A TARGET, not just its rows and not just an empty column.
918
+ *
919
+ * RIGHT-CLICK: `openAt` stops propagation, so a row that was hit answers first and
920
+ * this only fires on the blank area below the last row — where you reach for
921
+ * "new folder" in every file manager.
922
+ *
923
+ * DROP: a column IS the folder it is showing, so dropping anywhere in it files the
924
+ * item there. You could drop ON a folder row but not INTO the folder you were looking
925
+ * at, which is the one target that was missing. The highlight goes straight to a data
926
+ * attribute rather than through state — it is transient, it belongs to one node, and a
927
+ * re-render per dragover across every column buys nothing. */
928
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })}
929
+ onDragOver={(e) => {
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
936
+ e.preventDefault()
937
+ e.currentTarget.dataset.dropOver = '1'
938
+ }}
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 }}
943
+ onDrop={(e) => {
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
947
+ e.preventDefault()
948
+ d.onDrop(d.path)
949
+ }}
809
950
  >
810
951
  {folders.map((f, i) => (
811
952
  <Row
812
953
  key={f}
813
- icon="folder"
954
+ icon={folderIcon?.(level + f) ?? 'folder'}
814
955
  label={f.replace(/\/$/, '')}
815
- active={f === activeFolder}
956
+ markKey={level + f}
957
+ active={f === activeFolder || isPicked(level + f)}
816
958
  cursor={cursorActive && cursor.col === k && cursor.idx === i}
817
- trailing={<Icon name="chevron-right" size={12} className="text-fg-32" />}
818
- onClick={() => { setCursor({ col: k, idx: i }); setCursorActive(true); rootRef.current?.focus(); pick(null); onPrefix(level + f) }}
959
+ trailing={<Icon name="chevron-right" size={12} className="text-oq-32" />}
960
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path: level + f })}
961
+ drop={dragFor?.(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) }}
819
964
  />
820
965
  ))}
821
966
  {files.map((o, i) => (
@@ -823,27 +968,91 @@ export default function ColumnBrowser({
823
968
  key={o.key}
824
969
  icon={COL_ICON[kindOf(o)] || 'file'}
825
970
  label={o.displayKey ?? o.key}
826
- active={shown?.key === o.key}
971
+ markKey={o.key}
972
+ active={shown?.key === o.key || isPicked(o.key)}
827
973
  cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
828
- onClick={() => { setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
974
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
975
+ drop={dragFor?.(o.key)}
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) }}
829
977
  />
830
978
  ))}
979
+ {/* AN EMPTY COLUMN IS VISIBLY EMPTY (user 2026-09-21). It used to print the word
980
+ * "empty", which tells you what you can already see. The row stays as a right-click
981
+ * target — that is the only thing it was ever load-bearing for — and says nothing. */}
831
982
  {folders.length === 0 && files.length === 0 && (
832
- <li className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
983
+ <li className="flex-1 min-h-12"
984
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })} />
833
985
  )}
834
986
  </ul>
835
987
  <ResizeHandle axis="x" onDrag={resizeCol(k)} onEnd={endDrag} />
836
988
  </Fragment>
837
989
  )
838
990
  })}
839
- {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 && (
840
997
  <>
841
998
  <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
842
999
  <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
843
1000
  </>
844
1001
  )}
845
1002
  </div>
846
- <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>
847
1056
  </div>
848
1057
  )
849
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
 
@@ -8,11 +8,13 @@ import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
8
8
  import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
9
9
  import ViewToggle from '../atoms/ViewToggle.jsx'
10
10
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
11
+ import { Tooltip } from '../utilities/Popover.jsx'
11
12
  import ContentCard from '../molecules/ContentCard.jsx'
12
13
  import ContentRow from '../molecules/ContentRow.jsx'
13
14
  import ContentFilters from './ContentFilters.jsx'
14
15
  import MediaViewer from './MediaViewer.jsx'
15
16
  import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
17
+ import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
16
18
  import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
17
19
 
18
20
  /**
@@ -496,11 +498,11 @@ function Thumb({ row, mediaUrl }) {
496
498
  function FolderRow({ folder, struck = false, onEnter }) {
497
499
  return (
498
500
  <li className="kol-media-folder" onClick={onEnter}>
499
- <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">
500
502
  <Icon name="folder" size={18} />
501
503
  </span>
502
504
  <span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-meta' : 'text-body'}`}>{folder.label}</span>
503
- <Icon name="chevron-right" size={14} className="text-subtle" />
505
+ <Icon name="chevron-right" size={14} className="text-oq-24" />
504
506
  </li>
505
507
  )
506
508
  }
@@ -629,15 +631,17 @@ function Toolbar({ viewMode, onViewMode }) {
629
631
  )}
630
632
  </div>
631
633
  <div className="flex items-center gap-3">
632
- <button
633
- type="button"
634
- aria-pressed={flat}
635
- title="Show all files recursively"
636
- onClick={() => setFlat(!flat)}
637
- className={chipCls(flat)}
638
- >
639
- Flat
640
- </button>
634
+ {/* The DS's own Tooltip, not a `title` attribute — see LibraryHeader. */}
635
+ <Tooltip label="Show all files recursively">
636
+ <button
637
+ type="button"
638
+ aria-pressed={flat}
639
+ onClick={() => setFlat(!flat)}
640
+ className={chipCls(flat)}
641
+ >
642
+ Flat
643
+ </button>
644
+ </Tooltip>
641
645
  <ViewToggle viewMode={viewMode} onViewChange={onViewMode} variant="icon" />
642
646
  <Divider variant="vertical" />
643
647
  <SortControls />
@@ -970,7 +974,10 @@ function PickerShell({ onClose, onPick }) {
970
974
  * `defaults` to seed) · `folderTree` (browse: the baked tree) · `headerActions`
971
975
  * (the app's own upload / write icons) · `refreshKey`.
972
976
  *
973
- * @param {string} variant 'browse' | 'library' | 'modal' | 'page' (alias of library)
977
+ * @param {string} variant 'explorer' | 'browse' | 'library' | 'modal' | 'page' (alias of library)
978
+ * `explorer` is browse + library as two views of ONE surface — one header, one
979
+ * count, one listing, a switch beside the bucket dropdown. Prefer it for a new
980
+ * consumer; the other two remain for the ones that stack them by hand.
974
981
  * @param {boolean} open modal only — mounts the overlay
975
982
  * @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
976
983
  * @param {string|string[]} accept 'all' (default) = everything · one kind · an
@@ -999,6 +1006,12 @@ export default function MediaLibrary({
999
1006
  return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, opts)
1000
1007
  }
1001
1008
  if (variant === 'browse') return <MediaLibraryBrowse client={client} {...pageProps} />
1009
+ /* `explorer` = the two above as TWO VIEWS OF ONE SURFACE, one mounted at a
1010
+ * time behind a switch in the header — one wordmark, one count, one listing,
1011
+ * and the upload target reachable without scrolling past a full-height
1012
+ * browser. Added 2026-09-21; `browse` and `library` are unchanged, so no
1013
+ * consumer moves until it chooses to. */
1014
+ if (variant === 'explorer') return <MediaLibraryExplorer client={client} {...pageProps} />
1002
1015
  /* `page` = the library wall (alias, one release): its old knobs map onto the settings seed */
1003
1016
  const seed = variant === 'page' ? { defaults: { pageSize, sortBy: defaultSort?.by, sortDir: defaultSort?.dir, flat, ...(pageProps.defaults ?? {}) } } : {}
1004
1017
  return <MediaLibraryLibrary client={client} {...pageProps} {...seed} />
@@ -0,0 +1,31 @@
1
+ import { useState } from 'react'
2
+ import { MediaLibraryBrowse } from './MediaLibraryPages.jsx'
3
+
4
+ /**
5
+ * MediaLibraryExplorer — the media surface, and since 2026-09-22 a thin name for it.
6
+ *
7
+ * IT USED TO BE THE MERGE ITSELF: it held a BROWSE · FILES switch and mounted one of two pages,
8
+ * each of which listed the bucket, drew its own header and printed its own count. The user's read
9
+ * of that (2026-09-22) ended it — *"they both just display files … 4 views, not 2 modes each with
10
+ * 2 views"* — so the surface is ONE page now with four views (columns · rows · grid · list), one
11
+ * listing, one header, one count line and one filter bar, and this component only carries the
12
+ * view state for a consumer that wants to own it.
13
+ *
14
+ * `MediaLibraryBrowse` and `MediaLibraryLibrary` remain exported: the first IS the surface, the
15
+ * second is the standalone wall a site embeds without a tree (kol-website's asset page).
16
+ *
17
+ * @param {'columns'|'rows'|'grid'|'list'} view controlled view; omit and the page reads its own setting
18
+ * @param {Function} onViewChange (view) => void
19
+ * @param {'columns'|'rows'|'grid'|'list'} defaultView first view when uncontrolled and nothing is stored
20
+ */
21
+ export default function MediaLibraryExplorer({ view, onViewChange, defaultView, ...pageProps }) {
22
+ const [ownView, setOwnView] = useState(view ?? defaultView)
23
+ const current = view ?? ownView
24
+ return (
25
+ <MediaLibraryBrowse
26
+ {...pageProps}
27
+ view={current}
28
+ onViewChange={(v) => { setOwnView(v); onViewChange?.(v) }}
29
+ />
30
+ )
31
+ }