@kolkrabbi/kol-component 0.240.0 → 0.241.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.240.0",
3
+ "version": "0.241.0",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  "embla-carousel-react": "^8.6.0",
27
27
  "pdfjs-dist": "^6.3.289",
28
28
  "react-syntax-highlighter": "^16.1.1",
29
- "@kolkrabbi/kol-search": "^0.3.0",
30
- "@kolkrabbi/kol-markdown": "^0.1.3"
29
+ "@kolkrabbi/kol-markdown": "^0.1.3",
30
+ "@kolkrabbi/kol-search": "^0.3.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@kolkrabbi/kol-icons": ">=0.22.0",
@@ -3,8 +3,8 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import ActionButton from '../molecules/ActionButton.jsx'
4
4
  import Button from '../atoms/Button.jsx'
5
5
  import Divider from '../atoms/Divider.jsx'
6
+ import Dropdown from '../molecules/Dropdown.jsx'
6
7
  import Input from '../atoms/Input.jsx'
7
- 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'
@@ -12,6 +12,7 @@ import { Tooltip } from '../utilities/Popover.jsx'
12
12
  import ContentCard from '../molecules/ContentCard.jsx'
13
13
  import ContentRow from '../molecules/ContentRow.jsx'
14
14
  import ContentFilters from './ContentFilters.jsx'
15
+ import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
15
16
  import MediaViewer from './MediaViewer.jsx'
16
17
  import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
17
18
  import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
@@ -136,6 +137,8 @@ function kindOf(o) {
136
137
  * every stream it held. A picker asks for what it can pick: `['image','video']`. */
137
138
  function acceptsKind(accept) {
138
139
  if (!accept || accept === 'all') return () => true
140
+ /* a function reads the object itself — a vector-only picker wants `.svg`, which is `image` by kind */
141
+ if (typeof accept === 'function') return accept
139
142
  const wanted = new Set(Array.isArray(accept) ? accept : [accept])
140
143
  return (o) => wanted.has(o.kind)
141
144
  }
@@ -298,6 +301,14 @@ export function MediaLibraryProvider({
298
301
  * from the flat view you just left. Derived, not an effect. */
299
302
  const [paging, setPaging] = useState({ id: null, visible: 0 })
300
303
 
304
+ /* THE STORE (2026-10-07, design-editor's picker folded into this one): a client with
305
+ * `buckets()` lists one at a time and the modal's header switches it — the browse page's
306
+ * rule, "a client with none is one bucket and no dropdown". Switching lands at the root:
307
+ * a prefix is one bucket's path. */
308
+ const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
309
+ const [bucket, setBucketState] = useState(() => buckets[0]?.id ?? null)
310
+ const setBucket = (id) => { setBucketState(id); setPrefix('') }
311
+
301
312
  useEffect(() => {
302
313
  if (!client) return undefined
303
314
  let cancelled = false
@@ -305,12 +316,12 @@ export function MediaLibraryProvider({
305
316
  setLoading(true)
306
317
  setError(null)
307
318
  client
308
- .listMedia('', { signal: controller.signal })
319
+ .listMedia('', { signal: controller.signal, bucket: bucket ?? undefined })
309
320
  .then((objs) => { if (!cancelled) setObjects(objs) })
310
321
  .catch((e) => { if (!cancelled && e.name !== 'AbortError') setError(e.message) })
311
322
  .finally(() => { if (!cancelled) setLoading(false) })
312
323
  return () => { cancelled = true; controller.abort() }
313
- }, [client])
324
+ }, [client, bucket])
314
325
 
315
326
  /* click an inactive field → ascending; click the active one → flip */
316
327
  const sortBy = (by) =>
@@ -344,6 +355,10 @@ export function MediaLibraryProvider({
344
355
  const kindCounts = files.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {})
345
356
  const kindsPresent = Object.keys(kindCounts).sort().map((k) => ({ value: k, label: KIND_LABEL[k] ?? k, count: kindCounts[k] }))
346
357
 
358
+ /* `q` is the paging key's and the stats' view of the search — the engine swap (kol-search,
359
+ * 2026-09-29) dropped its definition and the modal threw on open; found 2026-10-07 when the
360
+ * editor's doors moved onto it, since no consumer had opened this variant in between. */
361
+ const q = search.trim().toLowerCase()
347
362
  const filtered = filterMedia(files.filter((o) => kinds.size === 0 || kinds.has(o.kind)), search, { nameOf: (o) => o.displayKey })
348
363
  const sorted = sortFiles(filtered, sort)
349
364
 
@@ -396,10 +411,13 @@ export function MediaLibraryProvider({
396
411
  viewable,
397
412
  loading,
398
413
  error,
399
- mediaUrl: client?.mediaUrl ?? ((key) => key),
414
+ bucket,
415
+ buckets,
416
+ setBucket,
417
+ mediaUrl: client?.mediaUrl ? (key) => client.mediaUrl(key, bucket ?? undefined) : (key) => key,
400
418
  proxied: client?.proxied ?? ((url) => url),
401
419
  }
402
- }, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client])
420
+ }, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client, bucket, buckets])
403
421
 
404
422
  return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>
405
423
  }
@@ -880,7 +898,7 @@ function PickerBody({ items, viewMode, onOpen, onPick }) {
880
898
  /* The picker's chrome — ContentFilters owns the animated search, the kind
881
899
  * filter, the view toggle and the N-of-M count; sort rides its header slot. */
882
900
  function LibraryChrome({ onOpen, onPick }) {
883
- const { folders, sorted, sort, sortBy } = useMediaLibrary()
901
+ const { folders, sorted, sort, sortBy, bucket, buckets, setBucket } = useMediaLibrary()
884
902
  const [viewMode, setViewMode] = useState('grid')
885
903
 
886
904
  const items = useMemo(
@@ -891,6 +909,11 @@ function LibraryChrome({ onOpen, onPick }) {
891
909
  [folders, sorted],
892
910
  )
893
911
  const kinds = useMemo(() => [...new Set(sorted.map((o) => o.kind))].sort(), [sorted])
912
+ /* THE STORE — the header at `md+`, a row above the list on a phone. `cls` on the control rather
913
+ * than a wrapper: a `display: contents` wrapper lost to the phone hide in one host. */
914
+ const store = (cls = '') => buckets.length > 1 && (
915
+ <Dropdown size="sm" className={`min-w-0 max-w-[45vw] md:w-48 md:max-w-none ${cls}`.trim()} options={buckets.map((b) => ({ value: b.id, label: b.label }))} value={bucket} onChange={setBucket} aria-label="Store" />
916
+ )
894
917
 
895
918
  return (
896
919
  <>
@@ -900,16 +923,40 @@ function LibraryChrome({ onOpen, onPick }) {
900
923
  titleIcon="folder"
901
924
  totalCount={items.length}
902
925
  searchKeys={['name']}
903
- viewMode={viewMode}
904
- onViewModeChange={setViewMode}
905
- viewModeOptions={VIEW_OPTIONS}
906
926
  mutuallyExclusiveFilters={['kind']}
907
927
  filterGroups={[{ label: 'Kind', key: 'kind', values: ['folder', ...kinds] }]}
908
- headerActions={
909
- <SegmentedToggle size="sm" value={sort.by} onChange={sortBy} options={SORT_OPTIONS} ariaLabel="Sort by" />
910
- }
911
- renderItem={(filtered, mode) => (
912
- <PickerBody items={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
928
+ /* THE WALL'S ROW (2026-10-08, one header for the family): the view pair as icons on the row's
929
+ * trailing slot, the sort as the row below at `md+` — what `MediaLibraryLibrary` draws — and
930
+ * below `md` both fold into one `···`, the browse page's fold. It wore GRID · LIST as text and
931
+ * four sort cells in the header. The store (when the client has more than one) rides the
932
+ * header at `md+` and is a row above the list on a phone. Decided on the recommendation. */
933
+ headerActions={store('max-md:hidden')}
934
+ trailingActions={(
935
+ <>
936
+ <div className="hidden md:flex items-center gap-6"><ViewToggle viewMode={viewMode} onViewChange={setViewMode} variant="icon" options={VIEW_OPTIONS} /></div>
937
+ <div className="md:hidden">
938
+ <MenuItem label={<Icon name="more" size={16} />} caret={false} align="end" size="sm" buttonClassName="shrink-0 px-2">
939
+ {({ close }) => (
940
+ <div className="py-1 w-[200px]">
941
+ {VIEW_OPTIONS.map((opt) => (
942
+ <MenuDropdownItem key={opt.value} onClick={() => { setViewMode(opt.value); close() }} shortcut={viewMode === opt.value ? <Icon name="check" size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
943
+ ))}
944
+ <MenuDropdownDivider />
945
+ {SORT_OPTIONS.map((opt) => (
946
+ <MenuDropdownItem key={opt.value} onClick={() => { sortBy(opt.value); close() }} shortcut={sort.by === opt.value ? <Icon name={sort.dir === 'desc' ? 'arrow-down' : 'arrow-up'} size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
947
+ ))}
948
+ </div>
949
+ )}
950
+ </MenuItem>
951
+ </div>
952
+ </>
953
+ )}
954
+ belowActions={<div className="h-8 hidden md:flex items-center"><SortControls /></div>}
955
+ renderItem={(filtered) => (
956
+ <>
957
+ {buckets.length > 1 && <div className="md:hidden flex items-center gap-3 flex-wrap" style={{ marginBottom: 'var(--kol-spacing-3)' }}>{store()}</div>}
958
+ <PickerBody items={filtered} viewMode={viewMode} onOpen={onOpen} onPick={onPick} />
959
+ </>
913
960
  )}
914
961
  />
915
962
  <PathBar />
@@ -931,7 +978,9 @@ function PickerShell({ onClose, onPick }) {
931
978
  {/* While the viewer is up it owns Escape — handing the picker a no-op
932
979
  * close means one keypress steps back one level instead of exiting the
933
980
  * whole picker, which is the behaviour the fxr fork hand-rolled. */}
934
- <FullscreenOverlay open onClose={viewerIndex === null ? onClose : () => {}}>
981
+ {/* `scrim` — a modal OVER the page it was opened from, not an opaque surface in its place
982
+ * (user, 2026-10-07, on labs: "its not overlay, its complete full screen") */}
983
+ <FullscreenOverlay open scrim onClose={viewerIndex === null ? onClose : () => {}}>
935
984
  <div className="kol-media-picker">
936
985
  <LibraryChrome onOpen={setViewerIndex} onPick={pick} />
937
986
  </div>
@@ -2600,8 +2600,12 @@ export function MediaLibraryBrowse({
2600
2600
 
2601
2601
  {/* ONE MENU FOR EVERY ROW. `useContextMenu` carries the payload from whichever row opened
2602
2602
  * it, so this is a single instance rather than one per row, and the entries are built from
2603
- * what `fileActions` actually supplies — a read-only bucket gets no menu at all. */}
2604
- {canWrite && (
2603
+ * what `fileActions` actually supplies. IT MOUNTS ON A READ-ONLY BUCKET TOO (kol-website,
2604
+ * 2026-10-08: on B2 "I cant right click to get context modal … cant download assets"): Copy URL
2605
+ * and Download are read verbs — the client's URLs — and gating the whole menu on `canWrite`
2606
+ * took them away with the write verbs. `fa` is the write seam: empty when the bucket cannot
2607
+ * be written, so every write verb below folds away and the read verbs stay. */}
2608
+ {(() => { const fa = canWrite ? fileActions : {}; return (
2605
2609
  <ContextMenu menu={menu}>
2606
2610
  {(target) => {
2607
2611
  if (!target) return null
@@ -2611,7 +2615,7 @@ export function MediaLibraryBrowse({
2611
2615
  * not one of them — renaming five things to one name is not a thing — so it stays
2612
2616
  * single and disappears from a multi-selection rather than lying about what it does. */
2613
2617
  const many = (target.targets ?? []).length > 1 ? target.targets : null
2614
- const custom = (fileActions.items ?? []).filter((it) => !it.when || it.when(target))
2618
+ const custom = (fa.items ?? []).filter((it) => !it.when || it.when(target))
2615
2619
  const customItems = custom.length > 0 && (
2616
2620
  <>
2617
2621
  <MenuDropdownDivider />
@@ -2625,7 +2629,7 @@ export function MediaLibraryBrowse({
2625
2629
  <>
2626
2630
  <MenuDropdownItem disabled>{many.length} selected</MenuDropdownItem>
2627
2631
  <MenuDropdownDivider />
2628
- {fileActions.move && (
2632
+ {fa.move && (
2629
2633
  <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
2630
2634
  )}
2631
2635
  {many.every((p) => !p.endsWith('/')) && (
@@ -2635,7 +2639,7 @@ export function MediaLibraryBrowse({
2635
2639
  {canTag && <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags(many)}>Add tags to {many.length}…</MenuDropdownItem>}
2636
2640
  </>
2637
2641
  )}
2638
- {fileActions.remove && (
2642
+ {fa.remove && (
2639
2643
  <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doBatch(many, 'remove', 'Delete')}>Delete {many.length}</MenuDropdownItem>
2640
2644
  )}
2641
2645
  {customItems}
@@ -2644,7 +2648,7 @@ export function MediaLibraryBrowse({
2644
2648
  }
2645
2649
  return (
2646
2650
  <>
2647
- {fileActions.createFolder && (
2651
+ {fa.createFolder && (
2648
2652
  <MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2649
2653
  New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
2650
2654
  </MenuDropdownItem>
@@ -2655,7 +2659,7 @@ export function MediaLibraryBrowse({
2655
2659
  <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => newDocument(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2656
2660
  New document…
2657
2661
  </MenuDropdownItem>
2658
- ) : fileActions.createFile && (
2662
+ ) : fa.createFile && (
2659
2663
  <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2660
2664
  New file
2661
2665
  </MenuDropdownItem>
@@ -2666,10 +2670,10 @@ export function MediaLibraryBrowse({
2666
2670
  </MenuDropdownItem>
2667
2671
  )}
2668
2672
  {!isLevel && <MenuDropdownDivider />}
2669
- {!isLevel && fileActions.rename && (
2673
+ {!isLevel && fa.rename && (
2670
2674
  <MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => doRename(target.path, isFolder)}>Rename</MenuDropdownItem>
2671
2675
  )}
2672
- {!isLevel && fileActions.move && (
2676
+ {!isLevel && fa.move && (
2673
2677
  <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doMove(target.path)}>Move to…</MenuDropdownItem>
2674
2678
  )}
2675
2679
  {/* A FOLDER GETS COPY URL TOO (user 2026-09-23) — through the SAME `mediaUrl`
@@ -2693,7 +2697,7 @@ export function MediaLibraryBrowse({
2693
2697
  {target.type === 'file' && canEdit && editKindOf(target.o ?? { key: target.path }) && (
2694
2698
  <MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => openEditor(objects.find((o) => o.key === target.path) ?? { key: target.path })}>Edit</MenuDropdownItem>
2695
2699
  )}
2696
- {!isLevel && fileActions.remove && (
2700
+ {!isLevel && fa.remove && (
2697
2701
  <>
2698
2702
  <MenuDropdownDivider />
2699
2703
  <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doDelete(target.path, isFolder)}>Delete</MenuDropdownItem>
@@ -2704,7 +2708,7 @@ export function MediaLibraryBrowse({
2704
2708
  )
2705
2709
  }}
2706
2710
  </ContextMenu>
2707
- )}
2711
+ ) })()}
2708
2712
  {busyAction && <span className="sr-only" role="status">Working…</span>}
2709
2713
 
2710
2714
  {/* THE TAB PILL (item 16) — floats over the list, so the list owes it
@@ -3112,8 +3116,31 @@ export function MediaLibraryLibrary({
3112
3116
  layoutOptions={layoutOptions}
3113
3117
  layout={layout}
3114
3118
  onLayoutChange={setLayout}
3115
- trailingActions={<div className="flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>}
3116
- belowActions={layout === 'off' ? null : <div className="h-8 flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
3119
+ /* BELOW `md` THE VIEWS AND THE SORT FOLD INTO ONE `···` (2026-10-08, apps/media/library at 390:
3120
+ * the FILES row, the three view icons and NAME · DATE · SIZE · KIND stacked three rows over the
3121
+ * list) — the browse page's fold, item 15, so the two pages fold the same way. Decided on the
3122
+ * recommendation, for review. */
3123
+ trailingActions={(
3124
+ <>
3125
+ <div className="hidden md:flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>
3126
+ <div className="md:hidden">
3127
+ <MenuItem label={<Icon name="more" size={16} />} caret={false} align="end" size="sm" buttonClassName="shrink-0 px-2">
3128
+ {({ close }) => (
3129
+ <div className="py-1 w-[200px]">
3130
+ {LAYOUT_OPTIONS.map((opt) => (
3131
+ <MenuDropdownItem key={opt.value} onClick={() => { setLayout(opt.value); close() }} shortcut={layout === opt.value ? <Icon name="check" size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
3132
+ ))}
3133
+ <MenuDropdownDivider />
3134
+ {SORT_OPTIONS.map((opt) => (
3135
+ <MenuDropdownItem key={opt.value} onClick={() => { handleSort(opt.value); close() }} shortcut={sortBy === opt.value ? <Icon name={sortDir === 'desc' ? 'arrow-down' : 'arrow-up'} size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
3136
+ ))}
3137
+ </div>
3138
+ )}
3139
+ </MenuItem>
3140
+ </div>
3141
+ </>
3142
+ )}
3143
+ belowActions={layout === 'off' ? null : <div className="h-8 hidden md:flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
3117
3144
  /* ONE WALL BODY, shared with the merged surface (2026-09-22) */
3118
3145
  renderItem={(filtered) => (
3119
3146
  <WallBody files={filtered} layout={layout} sortBy={sortBy} sortDir={sortDir} pageSize={pageSize} listId={listId}
@@ -25,9 +25,12 @@ export default function MediaTileGallery({ items = [], layout = 'stack', cols =
25
25
 
26
26
  return (
27
27
  <>
28
+ {/* `cols` is the count from `sm` up; below it the grid is two across (08-breakpoints rule 6 —
29
+ * grid collapse is canonical, first break sm). The count rides a variable so the theme's one
30
+ * phone rule can override it: an inline `gridTemplateColumns` could not be out-ranked. */}
28
31
  <div
29
- className={isGrid ? 'grid gap-4' : 'flex flex-col gap-4'}
30
- style={isGrid ? { gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` } : undefined}
32
+ className={isGrid ? 'kol-media-tiles grid gap-4' : 'flex flex-col gap-4'}
33
+ style={isGrid ? { '--kol-media-tiles-cols': cols } : undefined}
31
34
  >
32
35
  {items.map((item, i) => (
33
36
  <figure key={i} className="min-w-0">
@@ -66,8 +66,13 @@ function ViewerStage({ media, index, onIndexChange }) {
66
66
  return () => document.removeEventListener('keydown', onKey)
67
67
  }, [emblaApi])
68
68
 
69
+ /* THE STAGE SETS ITS OWN WIDTH (2026-10-08, apps/media/viewer): FullscreenOverlay's sheet hugs
70
+ * its content, and the overlay is a grid whose one track is sized by that content — so `w-full`
71
+ * here resolved against nothing, the embla track grew to every slide side by side (six slides,
72
+ * 3888px at 1440) and the stage showed blank with the first image 1.6k off the left edge. The
73
+ * viewport minus the overlay's 24px lane each side is the width a fullscreen stage means. */
69
74
  return (
70
- <div className="relative w-full">
75
+ <div className="relative w-[calc(100vw-3rem)]">
71
76
  <div className="overflow-hidden" ref={emblaRef}>
72
77
  <div className="flex items-center">
73
78
  {media.map((item, i) => (
@@ -122,8 +127,15 @@ export default function MediaViewer({ open, media = [], index = 0, onIndexChange
122
127
  const actionRow = typeof actions === 'function' ? actions(media[index], index) : actions
123
128
 
124
129
  return (
125
- <FullscreenOverlay open onClose={onClose}>
130
+ /* `scrim` (2026-10-08, apps/media/viewer): without it the overlay painted its opaque light
131
+ * surface, and the viewer's inverse ink — caption, the paging chips — drew light on light and
132
+ * read as "an image and nothing else". The chips (md and up) and the caption were always there. */
133
+ <FullscreenOverlay open scrim onClose={onClose}>
126
134
  <ViewerStage media={media} index={index} onIndexChange={onIndexChange} />
135
+ {/* on a phone the chips are off and a swipe pages — the position says there is more */}
136
+ {media.length > 1 && (
137
+ <div className="md:hidden mt-3 text-center kol-helper-12 text-fg-inverse-64" aria-live="polite">{index + 1} / {media.length}</div>
138
+ )}
127
139
  {actionRow && (
128
140
  <div className="mt-4 flex items-center justify-center gap-2">{actionRow}</div>
129
141
  )}