@kolkrabbi/kol-component 0.212.0 → 0.214.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.212.0",
3
+ "version": "0.214.0",
4
4
  "description": "KOL design-system components \u2014 atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -143,6 +143,7 @@ export { default as Carousel } from './molecules/Carousel.jsx'
143
143
  * stage reaches for it instead of re-typing the button markup, which is how the
144
144
  * three in-package copies (and kol-website's CarouselNavigation) happened. */
145
145
  export { default as EmblaNav } from './molecules/EmblaNav.jsx'
146
+ export { default as MobileTabBar, TABBAR_H } from './molecules/MobileTabBar.jsx'
146
147
  export { default as ContentFilters } from './organisms/ContentFilters.jsx'
147
148
  export { default as PageHeader } from './molecules/PageHeader.jsx'
148
149
  export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
@@ -27,6 +27,12 @@ export function MenuItem({
27
27
  panelStyle,
28
28
  buttonClassName = '',
29
29
  defaultOpen = false,
30
+ /* THE CARET IS NOT ALWAYS RIGHT. The trigger draws `label ▾`, which is the
31
+ * shape of a named menu — File, Sort. An ICON trigger (`···`, a gear) is
32
+ * already complete and a caret beside it reads as a second glyph rather than
33
+ * an affordance; neither reference draws one (ColumnBrowserMobileViews item
34
+ * 15, kol-r2b2 2026-09-04). Default keeps every existing call-site. */
35
+ caret = true,
30
36
  }) {
31
37
  const [open, setOpen] = useState(defaultOpen)
32
38
  const popover = usePopover({
@@ -48,11 +54,13 @@ export function MenuItem({
48
54
  className={`kol-helper-12 px-3 h-8 inline-flex items-center gap-2 rounded text-body hover:text-emphasis transition-colors ${buttonClassName}`}
49
55
  >
50
56
  <span>{label}</span>
51
- <Icon
52
- name="chevron-down"
53
- size={10}
54
- style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 200ms' }}
55
- />
57
+ {caret && (
58
+ <Icon
59
+ name="chevron-down"
60
+ size={10}
61
+ style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 200ms' }}
62
+ />
63
+ )}
56
64
  </button>
57
65
  {/* w-max: floats size to CONTENT, never to the containing block —
58
66
  * same family law as ShapeDropdown's panel (2026-08-09 review). */}
@@ -0,0 +1,67 @@
1
+ import { Icon } from '@kolkrabbi/kol-icons'
2
+
3
+ /**
4
+ * MobileTabBar — the floating bottom tab pill, below `md` only.
5
+ *
6
+ * Both references end the same way: neither iOS Files nor Dropbox stacks two
7
+ * full-height surfaces on a phone. Each floats a pill at the bottom and gives
8
+ * every surface a tab — Files `Recents · Shared · Browse`, Dropbox `Home ·
9
+ * Files · Photos · Account` (ColumnBrowserMobileViews §5, kol-r2b2, user-ruled
10
+ * 2026-09-03). It is what answers the question `ColumnBrowserStackMode` left
11
+ * open: the library wall does NOT stay stacked under the browser.
12
+ *
13
+ * WHAT A TAB MEANS IS THE CONSUMER'S. This ships the pill — the shape, the
14
+ * float, the states, the breakpoint — and takes a list. A repo whose surfaces
15
+ * are routes wires it to its router; one whose surfaces are variants swaps a
16
+ * variant. The DS deciding that a media library has exactly three surfaces
17
+ * called Browse, Files and Kinds is the kind of guess that makes an organism
18
+ * un-reusable.
19
+ *
20
+ * FLOATS, not a layout row: it sits over the list on `position: fixed` so the
21
+ * list scrolls under it, which is what both references do. The consumer owes
22
+ * the list bottom padding — `--kol-tabbar-h` is published for exactly that, so
23
+ * nobody hardcodes 56.
24
+ *
25
+ * @param {Array<{value: string, label: string, icon?: string}>} tabs
26
+ * @param {string} value the active tab
27
+ * @param {Function} onChange (value) => void
28
+ * @param {string} className extra classes on the pill
29
+ */
30
+
31
+ export const TABBAR_H = 56
32
+
33
+ export default function MobileTabBar({ tabs = [], value, onChange, className = '' }) {
34
+ if (tabs.length === 0) return null
35
+ return (
36
+ <nav
37
+ className={`kol-mobile-tabbar md:hidden fixed inset-x-0 bottom-0 z-[var(--kol-z-sticky)] flex items-stretch ${className}`.trim()}
38
+ style={{
39
+ height: TABBAR_H,
40
+ /* the pill's own ground, not the page's — it floats OVER content, so a
41
+ * transparent bar would show rows sliding through the labels */
42
+ background: 'var(--kol-surface-primary)',
43
+ borderTop: '1px solid var(--kol-oq-08)',
44
+ paddingBottom: 'env(safe-area-inset-bottom, 0px)',
45
+ }}
46
+ aria-label="Sections"
47
+ >
48
+ {tabs.map((t) => {
49
+ const active = t.value === value
50
+ return (
51
+ <button
52
+ key={t.value}
53
+ type="button"
54
+ onClick={() => onChange?.(t.value)}
55
+ aria-current={active ? 'page' : undefined}
56
+ className="flex-1 min-w-0 flex flex-col items-center justify-center gap-1 cursor-pointer"
57
+ style={{ background: 'transparent', border: 'none', color: active ? 'var(--kol-fg-default)' : 'var(--kol-fg-48)' }}
58
+ >
59
+ {t.icon && <Icon name={t.icon} size={18} />}
60
+ {/* helper is single-line chrome, which a tab label always is */}
61
+ <span className="kol-helper-10 truncate max-w-full px-1">{t.label}</span>
62
+ </button>
63
+ )
64
+ })}
65
+ </nav>
66
+ )
67
+ }
@@ -13,6 +13,9 @@ import Dropdown from '../molecules/Dropdown.jsx'
13
13
  import ContentCard from '../molecules/ContentCard.jsx'
14
14
  import ContentRow from '../molecules/ContentRow.jsx'
15
15
  import SortControls from '../molecules/SortControls.jsx'
16
+ import SearchInput from '../molecules/SearchInput.jsx'
17
+ import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
18
+ import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
16
19
  import KindPreview from '../molecules/KindPreview.jsx'
17
20
  import AudioSheet from '../molecules/AudioSheet.jsx'
18
21
  import VideoSheet from '../molecules/VideoSheet.jsx'
@@ -81,6 +84,13 @@ const FOLDER_VIEW_OPTIONS = [
81
84
  { value: 'rows', label: 'Rows', icon: 'view-list' },
82
85
  { value: 'columns', label: 'Columns', icon: 'columns' },
83
86
  ]
87
+ /* the stack view the `···` switches (item 15) — the same two ColumnBrowser
88
+ * carries below `md`, named as the references name them */
89
+ const STACK_VIEW_OPTIONS = [
90
+ { value: 'list', label: 'List' },
91
+ { value: 'grid', label: 'Icons' },
92
+ ]
93
+
84
94
  const SORT_OPTIONS = [
85
95
  { value: 'name', label: 'Name' },
86
96
  { value: 'date', label: 'Date' },
@@ -328,6 +338,33 @@ const profileOf = (objects, rawFiles, systemCount) => ({
328
338
  })
329
339
 
330
340
  /* ══ BROWSE — folder / files ═══════════════════════════════════════════════ */
341
+ /* THE `···`'s SORT, pure and module-level so it is reachable by a check
342
+ * (ColumnBrowserMobileViews item 15). Sorting was desktop-only chrome until
343
+ * now — item 7 of the previous ticket hid the control cluster below `md` and
344
+ * put nothing in its place, so on a phone sort could not be reached at all.
345
+ *
346
+ * NAME IS THE TIEBREAK IN EVERY MODE. Two files of equal size, or a bucket
347
+ * whose objects carry no `uploaded`, otherwise come back in whatever order the
348
+ * comparator happened to walk — a list that reshuffles between renders for no
349
+ * reason the user did anything to cause. The tiebreak is deliberately NOT
350
+ * reversed by `sortDir`: descending by size still reads A before B inside a
351
+ * tie, which is what every file manager does. */
352
+ function sortObjects(objects, sortBy = 'name', sortDir = 'asc') {
353
+ const nameOf = (o) => o.key.slice(o.key.lastIndexOf('/') + 1).toLowerCase()
354
+ const val = {
355
+ name: nameOf,
356
+ date: (o) => o.uploaded ?? '',
357
+ size: (o) => o.size ?? 0,
358
+ kind: (o) => String(kindOf(o)),
359
+ }[sortBy] ?? nameOf
360
+ const dir = sortDir === 'desc' ? -1 : 1
361
+ return [...objects].sort((m, n) => {
362
+ const x = val(m), y = val(n)
363
+ if (x === y) return nameOf(m) < nameOf(n) ? -1 : 1
364
+ return (x < y ? -1 : 1) * dir
365
+ })
366
+ }
367
+
331
368
  export function MediaLibraryBrowse({
332
369
  client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
333
370
  folderTree, headerActions, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '',
@@ -342,6 +379,10 @@ export function MediaLibraryBrowse({
342
379
  * real opinion about it (it owns `height`, the widths and `partition`, so
343
380
  * those are deliberately absent from this list). */
344
381
  thumbnailFor, folderMeta, formatDate, stackView,
382
+ /* THE TAB PILL'S LIST (ColumnBrowserMobileViews item 16). What a tab MEANS is
383
+ * the consumer's — a repo whose surfaces are routes wires its router here.
384
+ * No tabs, no pill, and the page is exactly what it was. */
385
+ tabs, activeTab, onTabChange,
345
386
  }) {
346
387
  const [ownPrefix, setOwnPrefix] = useState('')
347
388
  const prefix = prefixProp ?? ownPrefix
@@ -357,8 +398,25 @@ export function MediaLibraryBrowse({
357
398
  const [quickLook, setQuickLook] = useState(null)
358
399
  const columnsRef = useRef(null)
359
400
  const { folderView = 'columns', flat } = settings
401
+ /* PINNED SEARCH + `···` (item 15), below `md` only. Item 7 of the previous
402
+ * ticket hid the desktop control cluster and put nothing in its place, so
403
+ * SORT became unreachable on a phone; both references carry exactly these two
404
+ * controls above the list. Search is page state, not a setting — a query is
405
+ * something you are doing, not something you have configured. */
406
+ const [query, setQuery] = useState('')
407
+
408
+ /* Search filters the KEY SPACE, so the tree still navigates: a path survives
409
+ * when a file under it matches and ColumnBrowser's own partition does the
410
+ * rest. One view, rather than a flat results list nobody asked for. Sort
411
+ * orders the files the way the wall already sorts its own. */
412
+ const q = query.trim().toLowerCase()
413
+ const searched = useMemo(() => (q ? objects.filter((o) => o.key.toLowerCase().includes(q)) : objects), [objects, q])
414
+ const sortedObjects = useMemo(
415
+ () => sortObjects(searched, settings.sortBy, settings.sortDir),
416
+ [searched, settings.sortBy, settings.sortDir],
417
+ )
360
418
 
361
- const scoped = prefix ? objects.filter((o) => o.key.startsWith(prefix)) : objects
419
+ const scoped = prefix ? sortedObjects.filter((o) => o.key.startsWith(prefix)) : sortedObjects
362
420
  const { folders, files: dirFiles } = partition(scoped, prefix)
363
421
  const keySet = new Set(objects.map((o) => o.key))
364
422
  const levelFiles = flat ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key })) : dirFiles
@@ -474,6 +532,66 @@ export function MediaLibraryBrowse({
474
532
  </div>
475
533
  </div>
476
534
 
535
+ {/* PINNED ABOVE THE LIST, below `md` (item 15). Search sits here and not
536
+ inside the ContentFilters wall, because the wall is a DIFFERENT
537
+ SURFACE — on a phone it is a tab away, so a control living in it is
538
+ a control you cannot reach from the thing you are searching. */}
539
+ <div className="flex md:hidden items-center gap-2">
540
+ <SearchInput
541
+ value={query}
542
+ onChange={(e) => setQuery(e.target.value ?? '')}
543
+ onClear={() => setQuery('')}
544
+ placeholder="Search this bucket"
545
+ size="sm"
546
+ className="flex-1 min-w-0"
547
+ />
548
+ {/* `···` — view mode and sort, the two the desktop cluster carries and
549
+ item 7 left with nowhere to go. `caret={false}`: an icon trigger is
550
+ already complete, and neither reference draws a chevron on it. */}
551
+ <MenuItem
552
+ label={<Icon name="more" size={16} />}
553
+ caret={false}
554
+ align="end"
555
+ buttonClassName="shrink-0 px-2"
556
+ >
557
+ {({ close }) => (
558
+ <div className="py-1 w-[200px]">
559
+ {STACK_VIEW_OPTIONS.map((opt) => (
560
+ <MenuDropdownItem
561
+ key={opt.value}
562
+ onClick={() => { setSettings({ ...settings, stackView: opt.value }); close() }}
563
+ shortcut={(settings.stackView ?? 'list') === opt.value ? <Icon name="check" size={11} /> : undefined}
564
+ >
565
+ {opt.label}
566
+ </MenuDropdownItem>
567
+ ))}
568
+ <MenuDropdownDivider />
569
+ {SORT_OPTIONS.map((opt) => (
570
+ <MenuDropdownItem
571
+ key={opt.value}
572
+ /* tapping the ACTIVE key flips the direction, which is how
573
+ both references let you reverse without a second control */
574
+ onClick={() => {
575
+ const same = (settings.sortBy ?? 'name') === opt.value
576
+ setSettings({
577
+ ...settings,
578
+ sortBy: opt.value,
579
+ sortDir: same && settings.sortDir !== 'desc' ? 'desc' : 'asc',
580
+ })
581
+ close()
582
+ }}
583
+ shortcut={(settings.sortBy ?? 'name') === opt.value
584
+ ? <Icon name={settings.sortDir === 'desc' ? 'arrow-up' : 'arrow-down'} size={11} />
585
+ : undefined}
586
+ >
587
+ {opt.label}
588
+ </MenuDropdownItem>
589
+ ))}
590
+ </div>
591
+ )}
592
+ </MenuItem>
593
+ </div>
594
+
477
595
  {quickLook && (
478
596
  <MediaInspector files={quickLook.files} index={quickLook.index} onClose={() => setQuickLook(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
479
597
  onPrev={() => setQuickLook((q) => ({ ...q, index: (q.index - 1 + q.files.length) % q.files.length }))}
@@ -492,7 +610,7 @@ export function MediaLibraryBrowse({
492
610
  onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
493
611
  columnWidths={settings.columnWidths}
494
612
  onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
495
- objects={objects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
613
+ objects={sortedObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
496
614
  prefix={single ? prefix : (appRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
497
615
  onPrefix={(v) => {
498
616
  /* single: the browser's path IS the bucket path, no segment to strip */
@@ -514,7 +632,9 @@ export function MediaLibraryBrowse({
514
632
  formatDate={formatDate}
515
633
  thumbnailFor={thumbnailFor}
516
634
  folderMeta={folderMeta}
517
- stackView={stackView}
635
+ /* the PROP wins when a consumer passes one; otherwise the `···`
636
+ owns it through settings (item 15) */
637
+ stackView={stackView ?? settings.stackView ?? 'list'}
518
638
  renderPreview={(o) => {
519
639
  const real = { ...o, key: o.key.slice(VROOT.length) }
520
640
  if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
@@ -549,6 +669,24 @@ export function MediaLibraryBrowse({
549
669
  </p>
550
670
  )}
551
671
  {void onOpen}
672
+
673
+ {/* THE TAB PILL (item 16) — floats over the list, so the list owes it
674
+ room or its last row sits under the bar forever.
675
+ THE SPACER GOES LAST, AND THAT IS THE WHOLE POINT
676
+ (TabBarSpacerAboveTheList, kol-r2b2 2026-09-04). `MobileTabBar` is
677
+ `fixed`, so where it sits in the tree is irrelevant — the SPACER is
678
+ in NORMAL FLOW, so its position is everything. Rendered before the
679
+ list it failed twice at once: a 56px hole punched into the gap under
680
+ the pinned search, and the last row still running 99px under the
681
+ bar. The comment above named the failure it was meant to prevent and
682
+ the block sat in the wrong place anyway. Below `md` only; above it
683
+ the 2026-08-26 one-view ruling stands. */}
684
+ {tabs?.length > 0 && (
685
+ <>
686
+ <div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
687
+ <MobileTabBar tabs={tabs} value={activeTab} onChange={onTabChange} />
688
+ </>
689
+ )}
552
690
  </div>
553
691
  </div>
554
692
  )
@@ -558,6 +696,9 @@ export function MediaLibraryBrowse({
558
696
  export function MediaLibraryLibrary({
559
697
  client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
560
698
  headerActions, refreshKey, header = true, stats = true, settingsFooter, className = '',
699
+ /* the same pill the browse page takes — the wall is one of the surfaces it
700
+ switches between, so it has to carry it too (item 16) */
701
+ tabs, activeTab, onTabChange,
561
702
  }) {
562
703
  const [ownBucket, setOwnBucket] = useState(bucket)
563
704
  const bucketId = bucket ?? ownBucket
@@ -798,6 +939,15 @@ export function MediaLibraryLibrary({
798
939
  )
799
940
  }}
800
941
  />
942
+
943
+ {/* the pill again — the wall is one of the surfaces it switches, so it
944
+ carries the same bar and owes it the same room (item 16) */}
945
+ {tabs?.length > 0 && (
946
+ <>
947
+ <div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
948
+ <MobileTabBar tabs={tabs} value={activeTab} onChange={onTabChange} />
949
+ </>
950
+ )}
801
951
  </div>
802
952
  )
803
953
  }
@@ -18,7 +18,26 @@ import CloseButton from './CloseButton.jsx'
18
18
  const FOCUSABLE =
19
19
  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
20
20
 
21
- export default function FullscreenOverlay({ open, onClose, closeButton = true, children }) {
21
+ export default function FullscreenOverlay({
22
+ open, onClose, closeButton = true,
23
+ /* WHERE FOCUS LANDS ON OPEN. The sheet takes it by default, which is right
24
+ * for a browser and wrong for a sheet opened to be TYPED IN: a child's
25
+ * `autoFocus` cannot win, because child effects run BEFORE the parent's and
26
+ * this one moves focus afterwards — so the field focuses and is immediately
27
+ * robbed, with nothing in either file looking wrong (kol-fxr measured it on
28
+ * design-editor 0.10.0: Save As routed into the dialog correctly and focus
29
+ * sat on `.kol-overlay-sheet`). Pass a ref to the node that should hold it.
30
+ * A ref that is empty on mount falls back to the sheet, so a conditional
31
+ * field cannot leave the overlay unfocused.
32
+ *
33
+ * The ref may point at the control ITSELF or at a WRAPPER around it — the
34
+ * first focusable descendant is taken. That is deliberate: a DS input is a
35
+ * component, not a DOM node, and whether it forwards a ref is a detail no
36
+ * caller should have to know to put focus in it. A plain `<div ref>` always
37
+ * works. */
38
+ initialFocus,
39
+ children,
40
+ }) {
22
41
  const sheetRef = useRef(null)
23
42
 
24
43
  /* Escape closes; Tab is TRAPPED in the sheet (SettingsPanel, 2026-08-26 —
@@ -45,13 +64,19 @@ export default function FullscreenOverlay({ open, onClose, closeButton = true, c
45
64
  const prev = document.body.style.overflow
46
65
  document.body.style.overflow = 'hidden'
47
66
  const prevFocus = document.activeElement
48
- sheetRef.current?.focus()
67
+ const wanted = initialFocus?.current
68
+ const target = wanted
69
+ ? (typeof wanted.focus === 'function' && wanted.matches?.(FOCUSABLE)
70
+ ? wanted
71
+ : wanted.querySelector?.(FOCUSABLE) ?? wanted)
72
+ : sheetRef.current
73
+ target?.focus?.()
49
74
  return () => {
50
75
  document.removeEventListener('keydown', onKey)
51
76
  document.body.style.overflow = prev
52
77
  if (prevFocus instanceof HTMLElement) prevFocus.focus()
53
78
  }
54
- }, [open, onClose])
79
+ }, [open, onClose, initialFocus])
55
80
 
56
81
  if (!open) return null
57
82