@kolkrabbi/kol-component 0.243.0 → 0.244.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.243.0",
3
+ "version": "0.244.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from 'react'
2
2
  import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
3
+ import { pushLayer, popLayer } from '../utilities/layerStack.js'
3
4
 
4
5
  /**
5
6
  * ContextMenu — a right-click menu, anchored at the pointer.
@@ -63,6 +64,13 @@ export function useContextMenu() {
63
64
  }
64
65
 
65
66
  export default function ContextMenu({ menu, children, className = '' }) {
67
+ /* ONE ESCAPE, ONE LEVEL (kol-fxr 2026-10-08: a menu opened inside a FullscreenOverlay took the
68
+ * dialog with it). Open, the menu is the top layer, so the overlay under it ignores that Escape. */
69
+ useEffect(() => {
70
+ if (!menu.open) return undefined
71
+ const layer = pushLayer()
72
+ return () => popLayer(layer)
73
+ }, [menu.open])
66
74
  if (!menu.open) return null
67
75
  return (
68
76
  /* `focus={false}` — FloatingFocusManager reads attributes off the reference,
@@ -87,6 +87,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
87
87
  * `KOL-R2B2/<label>/…`) must strip its own prefix before it can look anything up or build a URL —
88
88
  * the browser has no idea which part of a path is yours (kol-r2b2 2026-09-04).
89
89
  * @param {Function} folderMeta (prefix, view) => string — a folder's own meta line (`date · N items` in list, `N items` in grid). A SEAM, not a computation: counting by prefix is O(n) per folder, and a consumer that already holds a folder tree answers it for free
90
+ * @param {boolean} tapSelects below the breakpoint a file tap selects only, no Quick Look (a picker)
90
91
  * @param {'list'|'grid'} stackView the mobile view below the breakpoint (default 'list'); `grid` is 3-up tiles, flat — a grid has nowhere to put an inline child list
91
92
  *
92
93
  * BELOW `md` (768) THIS IS A DIFFERENT TREE — one full-width inline-expanding
@@ -573,6 +574,9 @@ export default function ColumnBrowser({
573
574
  * media bucket the thumbnail IS the tile, so it carries counts and sizes
574
575
  * rather than dates (§2). Above the breakpoint the columns are unaffected. */
575
576
  stackView = 'list',
577
+ /* `tapSelects` — below the breakpoint a file tap SELECTS and opens nothing (kol-fxr 2026-10-08: a
578
+ * picker's tap must pick, and Quick Look covered the host's Open button). Off: a tap opens Quick Look. */
579
+ tapSelects = false,
576
580
  autoFocus = false,
577
581
  className = '',
578
582
  }) {
@@ -899,6 +903,7 @@ export default function ColumnBrowser({
899
903
  if (isFolder) { onPrefix(r.level + r.name); return }
900
904
  const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
901
905
  pick(o)
906
+ if (tapSelects) return
902
907
  onQuickLook?.({ files: files.length ? files : [o], index: Math.max(0, files.findIndex((f) => f.key === o.key)) })
903
908
  }}
904
909
  >
@@ -966,6 +971,7 @@ export default function ColumnBrowser({
966
971
  onClick={() => {
967
972
  const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
968
973
  pick(r.o)
974
+ if (tapSelects) return
969
975
  onQuickLook?.({ files: files.length ? files : [r.o], index: Math.max(0, files.findIndex((f) => f.key === r.o.key)) })
970
976
  }}
971
977
  />
@@ -247,7 +247,10 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
247
247
  const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
248
248
  const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
249
249
  const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
250
- return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, folderInfo, setFolderInfo, reload, error, ready, mediaUrl, downloadUrl, writable }
250
+ /* A CLIENT WITH NO `mediaUrl` HAS NO URLS (kol-fxr 2026-10-08: a stored preset is not a fetchable
251
+ * object) — Copy URL, Download and Quick Look's fetch all go, as write verbs go without a writer. */
252
+ const hasUrl = typeof client?.mediaUrl === 'function'
253
+ return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, folderInfo, setFolderInfo, reload, error, ready, mediaUrl, downloadUrl, writable, hasUrl }
251
254
  }
252
255
 
253
256
  /* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
@@ -622,7 +625,7 @@ function FileEditorHost({ o, client, bucket, onClose, onSaved, assets }) {
622
625
  * @param {boolean} showNav - ‹ › and n / N in the header (default on); off when not paging a selection
623
626
  * @param {Function} onEdit - `(file) => void` — an Edit action in the header for a text file (markdown · json · yaml · text · code); absent, no Edit
624
627
  */
625
- export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true, onEdit }) {
628
+ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true, onEdit, hasUrl = true }) {
626
629
  const o = files[index]
627
630
  const [dims, setDims] = useState(null)
628
631
  /* the window's size survives paging — Finder keeps it — so it lives here, not in the frame,
@@ -666,17 +669,26 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
666
669
  <Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
667
670
  </Tooltip>
668
671
  )}
669
- <CopyAction label="Copy URL" text={mediaUrl(o.key)} />
670
- <Tooltip label="Download">
671
- <Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
672
- </Tooltip>
672
+ {hasUrl && (
673
+ <>
674
+ <CopyAction label="Copy URL" text={mediaUrl(o.key)} />
675
+ <Tooltip label="Download">
676
+ <Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
677
+ </Tooltip>
678
+ </>
679
+ )}
673
680
  </span>
674
681
  ),
675
682
  }
676
683
  return (
677
684
  /* QUICK LOOK DIMS, it does not black out — you keep the list you came from in view */
678
685
  <FullscreenOverlay open onClose={onClose} closeButton={false} scrim>
679
- {isImage(o.contentType) ? (
686
+ {!hasUrl ? (
687
+ /* nothing to fetch, so nothing to draw — said, not spun on (kol-fxr 2026-10-08) */
688
+ <QuickLookFrame {...frame}>
689
+ <p className="kol-helper-12 text-meta p-8">No preview</p>
690
+ </QuickLookFrame>
691
+ ) : isImage(o.contentType) ? (
680
692
  <QuickLookFrame {...frame}>
681
693
  {/* A VECTOR HAS NO PIXEL SIZE (user 2026-09-24: a 32px logo.svg opened as a 32px window). It
682
694
  * is drawn in a 640 box — the window's max if that is smaller — and contained in it. */}
@@ -1081,6 +1093,8 @@ export function MediaLibraryBrowse({
1081
1093
  * browse surface inside the modal card needs the selection for its Use button). Keys are
1082
1094
  * bucket-relative. Absent, nothing. */
1083
1095
  onPickFile,
1096
+ /* `searchPlaceholder` — the search field's words; a store that is not a bucket names itself (kol-fxr 2026-10-08) */
1097
+ searchPlaceholder = 'Search this bucket',
1084
1098
  /* `o.displayName` on a listed object is its LABEL in every view, Quick Look and search (kol-fxr
1085
1099
  * 2026-10-08: names not unique, so the key is an id). Absent, the label is the key, as before. */
1086
1100
  }) {
@@ -1091,7 +1105,11 @@ export function MediaLibraryBrowse({
1091
1105
  const [ownBucket, setOwnBucket] = useState(bucket)
1092
1106
  const bucketId = bucket ?? ownBucket
1093
1107
  const lib = useBucketLibrary({ client, bucket: bucketId, defaults, settings: settingsProp, onSettingsChange, refreshKey })
1094
- const { buckets, bucketMeta, settings, setSettings, objects, error, mediaUrl, downloadUrl, writable } = lib
1108
+ const { buckets, bucketMeta, settings, setSettings, objects, error, mediaUrl, downloadUrl, hasUrl } = lib
1109
+ /* `fileActions` GIVEN IS A WRITER (kol-fxr 2026-10-08): the consumer brings its own verbs, so the
1110
+ * client need not carry a `deleteObject` stub to be treated as writable. The bucket still has to
1111
+ * say it is writable. */
1112
+ const writable = lib.writable || (!!fileActions && !!bucketMeta.writable)
1095
1113
  const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
1096
1114
  const [settingsOpen, setSettingsOpen] = useState(false)
1097
1115
  const [trashOpen, setTrashOpen] = useState(false)
@@ -2008,6 +2026,7 @@ export function MediaLibraryBrowse({
2008
2026
  <ColumnBrowser
2009
2027
  autoFocus={autoFocus}
2010
2028
  className={atTitleRoot ? 'is-root' : ''}
2029
+ tapSelects={!!onPickFile}
2011
2030
  height={colH}
2012
2031
  onHeightChange={onColH}
2013
2032
  rowSize={rowSize}
@@ -2435,7 +2454,7 @@ export function MediaLibraryBrowse({
2435
2454
  value={query}
2436
2455
  onChange={(e) => setQuery(e.target.value ?? '')}
2437
2456
  onClear={() => setQuery('')}
2438
- placeholder="Search this bucket"
2457
+ placeholder={searchPlaceholder}
2439
2458
  size="sm"
2440
2459
  className="flex-1 min-w-0"
2441
2460
  />
@@ -2499,7 +2518,7 @@ export function MediaLibraryBrowse({
2499
2518
  onClose={() => { setSearchOpen(false); setSearchQuery('') }}
2500
2519
  query={searchQuery}
2501
2520
  onQueryChange={setSearchQuery}
2502
- placeholder="Search this bucket"
2521
+ placeholder={searchPlaceholder}
2503
2522
  results={searchResults}
2504
2523
  onExpand={() => searchResults[0] && jumpTo(searchResults[0].id)}
2505
2524
  onSelect={(item) => (item.run ? item.run() : jumpTo(item.id))}
@@ -2516,7 +2535,7 @@ export function MediaLibraryBrowse({
2516
2535
  </FullscreenOverlay>
2517
2536
  )}
2518
2537
  {quickLook && (
2519
- <MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
2538
+ <MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} hasUrl={hasUrl} keySet={keySet} showNav={rowSelection.size > 1}
2520
2539
  onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
2521
2540
  onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
2522
2541
  )}
@@ -2639,7 +2658,7 @@ export function MediaLibraryBrowse({
2639
2658
  {fa.move && (
2640
2659
  <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
2641
2660
  )}
2642
- {many.every((p) => !p.endsWith('/')) && (
2661
+ {hasUrl && many.every((p) => !p.endsWith('/')) && (
2643
2662
  <>
2644
2663
  <MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl(many)}>Copy {many.length} URLs</MenuDropdownItem>
2645
2664
  <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload(many)}>Download {many.length}</MenuDropdownItem>
@@ -2653,8 +2672,17 @@ export function MediaLibraryBrowse({
2653
2672
  </>
2654
2673
  )
2655
2674
  }
2675
+ const qlFile = onPickFile && target.type === 'file' ? objects.find((o) => o.key === target.path) : null
2656
2676
  return (
2657
2677
  <>
2678
+ {/* A PICKER'S TAP SELECTS (`tapSelects`), so Quick Look needs a door of its own on a
2679
+ * phone — the menu (kol-fxr 2026-10-08). Only with `onPickFile`; others are unchanged. */}
2680
+ {qlFile && (
2681
+ <>
2682
+ <MenuDropdownItem iconLeft={<Icon name="eye-on" size={14} />} onClick={() => openQuickLook({ files: [qlFile], index: 0 })}>Quick Look</MenuDropdownItem>
2683
+ <MenuDropdownDivider />
2684
+ </>
2685
+ )}
2658
2686
  {fa.createFolder && (
2659
2687
  <MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2660
2688
  New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
@@ -2687,10 +2715,10 @@ export function MediaLibraryBrowse({
2687
2715
  seam a file goes through, so what a folder URL means is the consumer's call
2688
2716
  (a CDN prefix, a listing, a signed path) and never this page's guess. No
2689
2717
  Download: a folder is not an object to fetch. */}
2690
- {!isLevel && (
2718
+ {hasUrl && !isLevel && (
2691
2719
  <MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl([target.path])}>Copy URL</MenuDropdownItem>
2692
2720
  )}
2693
- {target.type === 'file' && (
2721
+ {hasUrl && target.type === 'file' && (
2694
2722
  <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
2695
2723
  )}
2696
2724
  {!isLevel && canTag && (
@@ -127,7 +127,7 @@ export function groupVariants(files) {
127
127
  for (const [base, list] of groups) {
128
128
  if (list.length < 2) { out.push(list[0]); continue }
129
129
  list.sort((a, b) => a.width - b.width)
130
- out.push({ ...list[0], displayKey: base, variants: list, totalSize: list.reduce((n, f) => n + (f.size || 0), 0) })
130
+ out.push({ ...list[0], displayKey: list[0].displayName ?? base, variants: list, totalSize: list.reduce((n, f) => n + (f.size || 0), 0) })
131
131
  }
132
132
  return out
133
133
  }
@@ -140,7 +140,7 @@ export function partition(objects, prefix) {
140
140
  const rel = prefix ? o.key.slice(prefix.length) : o.key
141
141
  const slash = rel.indexOf('/')
142
142
  if (slash !== -1) folderSet.add(rel.slice(0, slash + 1))
143
- else files.push({ ...o, displayKey: rel })
143
+ else files.push({ ...o, displayKey: o.displayName ?? rel })
144
144
  }
145
145
  return { folders: [...folderSet].sort(), files }
146
146
  }