@kolkrabbi/kol-component 0.193.0 → 0.195.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.193.0",
3
+ "version": "0.195.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * useMediaQuery — subscribe to a media query, SSR-safe.
5
+ *
6
+ * The general form of `useCoarsePointer` and `usePrefersReducedMotion`, which
7
+ * are each this hook with one query frozen in. It exists because a STRUCTURAL
8
+ * responsive fork cannot be a stylesheet: `ColumnBrowser` renders Miller
9
+ * columns on a desktop and a single inline-expanding list on a phone
10
+ * (`ColumnBrowserStackMode`, kol-r2b2 2026-09-03), and those are different
11
+ * TREES, not one tree with different padding. A component that only needs
12
+ * different sizes still uses Tailwind's breakpoints — reach for this when the
13
+ * markup itself has to change.
14
+ *
15
+ * Returns false during SSR and on the first client render if the query cannot
16
+ * be evaluated, so a server render and its hydration agree.
17
+ *
18
+ * @param {string} query a media query, e.g. '(max-width: 767px)'
19
+ * @returns {boolean} whether it currently matches
20
+ */
21
+ export default function useMediaQuery(query) {
22
+ const [matches, setMatches] = useState(
23
+ () => typeof window !== 'undefined' && window.matchMedia(query).matches,
24
+ )
25
+
26
+ useEffect(() => {
27
+ if (typeof window === 'undefined') return
28
+ const mq = window.matchMedia(query)
29
+ const onChange = () => setMatches(mq.matches)
30
+ setMatches(mq.matches)
31
+ mq.addEventListener('change', onChange)
32
+ return () => mq.removeEventListener('change', onChange)
33
+ }, [query])
34
+
35
+ return matches
36
+ }
package/src/index.js CHANGED
@@ -193,6 +193,10 @@ export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
193
193
  export { default as useInViewAttention } from './hooks/useInViewAttention.js'
194
194
  export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
195
195
  export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
196
+ /* The general form of `useCoarsePointer` / `usePrefersReducedMotion` — for a
197
+ * STRUCTURAL responsive fork, where the markup itself changes and a stylesheet
198
+ * cannot express it (ColumnBrowserStackMode, 2026-09-03). Sizes stay Tailwind's. */
199
+ export { default as useMediaQuery } from './hooks/useMediaQuery.js'
196
200
  /* The rail gesture's other half. It lived in kol-framework until 2026-09-03 and
197
201
  * moved here for the same reason `useGrabEdge` did: kol-component's own
198
202
  * `EditorShell` needs resizable rails and cannot import framework. framework
@@ -4,6 +4,7 @@ import KindPreview from '../molecules/KindPreview.jsx'
4
4
  import { formatLength } from '../molecules/AudioPreview.jsx'
5
5
  import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
6
6
  import useGrabEdge from '../hooks/useGrabEdge.js'
7
+ import useMediaQuery from '../hooks/useMediaQuery.js'
7
8
  import { GRAB_COLUMN } from '../utilities/motion.js'
8
9
 
9
10
  /**
@@ -69,6 +70,17 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
69
70
  * any column it does not name falls back to the drag state, then `columnWidth`
70
71
  * @param {Function} onColumnResize (index, px) => void — the column's index, or `'preview'`
71
72
  * @param {string} className extra classes on the browser
73
+ *
74
+ * BELOW `md` (768) THIS IS A DIFFERENT TREE — one full-width inline-expanding
75
+ * list, not columns (`ColumnBrowserStackMode`, kol-r2b2 2026-09-03, user-ruled
76
+ * inline expand over push). `height` / `defaultHeight` / `onHeightChange`,
77
+ * `columnWidth` / `columnWidths` / `onColumnResize` and the resize handles are
78
+ * all DESKTOP-ONLY and inert there: a stored height is a value someone dragged
79
+ * on a desktop, and the list takes the viewport instead. A file tap fires
80
+ * `onQuickLook` rather than opening the preview column, so the consumer's
81
+ * existing full-screen inspector is the phone's preview. Everything else —
82
+ * `prefix`/`onPrefix`, `onPick`, the seams, the keyboard on a device that has
83
+ * one — is unchanged.
72
84
  */
73
85
 
74
86
  /* the DS kinds (mediaKinds — kol-r2b2's classification, promoted 2026-08-27) */
@@ -115,7 +127,14 @@ const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'vid
115
127
  * changes it breaks every rule hanging off it silently (twice already in kol-r2b2). It also lets the
116
128
  * theme say the user's ruling — "ONLY one selected state can exist, not TWO": the selected row in
117
129
  * the deepest column that holds one is full strength, every column on the way there is the trail. */
118
- function Row({ icon, label, active, cursor = false, trailing, onClick, muted = false }) {
130
+ /* `indent` and `meta` serve the STACK mode and are inert without it: the
131
+ * columns pass neither, so a desktop row is byte-identical to what it was. */
132
+ /* Three levels of indent, then the list re-bases and the back control takes
133
+ * over — the user's cap (ColumnBrowserStackMode, 2026-09-03): a deep bucket
134
+ * path indents until the name has no room. */
135
+ const INDENT_CAP = 3
136
+
137
+ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = false, indent = 0, meta }) {
119
138
  return (
120
139
  <li
121
140
  /* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
@@ -126,11 +145,24 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
126
145
  active || cursor || !muted ? 'text-fg-default' : 'text-fg-48'
127
146
  }`}
128
147
  onClick={onClick}
148
+ /* the indent is a PADDING, not a nested list: one flat <ul> keeps the
149
+ * rows one scroll box and one keyboard sequence, which a tree of nested
150
+ * scrollers does not */
151
+ style={indent ? { paddingLeft: `calc(var(--kol-spacing-4) + ${indent} * var(--kol-spacing-5))` } : undefined}
129
152
  >
130
153
  <span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
131
154
  <Icon name={icon} size={14} />
132
155
  </span>
133
- <span className="kol-mono-12 flex-1 truncate">{label}</span>
156
+ {meta ? (
157
+ /* size · date under the name — there is no preview column at this
158
+ * width to carry the facts (ColumnBrowserStackMode item 5) */
159
+ <span className="flex-1 min-w-0 flex flex-col gap-0.5">
160
+ <span className="kol-mono-12 truncate">{label}</span>
161
+ <span className="kol-helper-10 text-fg-48 truncate">{meta}</span>
162
+ </span>
163
+ ) : (
164
+ <span className="kol-mono-12 flex-1 truncate">{label}</span>
165
+ )}
134
166
  {trailing}
135
167
  </li>
136
168
  )
@@ -413,6 +445,128 @@ export default function ColumnBrowser({
413
445
  // While Quick Look is open the overlay's index is the truth for the highlight.
414
446
  const shown = quickLook ? quickLook.files[quickLook.index] : picked
415
447
 
448
+ /* ── THE STACK MODE (ColumnBrowserStackMode, kol-r2b2 2026-09-03) ─────────
449
+ *
450
+ * Miller columns put hierarchy on the X AXIS, and a phone has no width to
451
+ * spend on it: at 390 two 260px columns plus a gutter overflow, the inner row
452
+ * scrolls with nothing to say so, and a horizontal scroll inside a vertical
453
+ * page is a gesture nobody goes looking for. Measured on a live site, in a
454
+ * different repo running these same organisms — the layout ported and carried
455
+ * the missing mobile story with it, which is what makes this the DS's.
456
+ *
457
+ * THE RULING IS INLINE EXPAND, not push-and-back (user, 2026-09-03). iOS
458
+ * Files and Dropbox arrived at one-level-at-a-time independently and differ
459
+ * only on the descent; push discards the very thing this organism exists for
460
+ * — a parent that stays put while you look at its child — and leaves a
461
+ * generic file list any component could render. Inline expand keeps the idea
462
+ * and moves it from two axes onto one.
463
+ *
464
+ * CAPPED AT THREE LEVELS, then it pushes: a deep bucket path indents until
465
+ * the name has no room. Past the cap the list re-bases on a deeper folder and
466
+ * the back control NAMES the parent it returns to — a bare chevron does not
467
+ * say where it goes.
468
+ *
469
+ * This is a different TREE, not the same tree restyled, which is why it forks
470
+ * in JS on a media query rather than in the stylesheet. The breakpoint is
471
+ * `md` (768) — the one `ContentFilters` already uses, so a page has one
472
+ * responsive story and not two. */
473
+ const stack = useMediaQuery('(max-width: 767px)')
474
+
475
+ if (stack) {
476
+ /* Re-base so the deepest open level is at most INDENT_CAP below the base:
477
+ * everything above scrolls out of the list and is reached by the back
478
+ * control instead. */
479
+ const baseIdx = Math.max(0, levels.length - 1 - INDENT_CAP)
480
+ const base = levels[baseIdx]
481
+ const parent = baseIdx > 0 ? levels[baseIdx - 1] : null
482
+ const openPath = levels.slice(baseIdx)
483
+
484
+ /* One flat row list, walked down the open path: each level's items, with
485
+ * the open folder's children spliced in directly under it. */
486
+ const rows = []
487
+ openPath.forEach((level, depth) => {
488
+ const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
489
+ const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
490
+ folders.forEach((f) => {
491
+ const isOpen = f === openFolder
492
+ rows.push({
493
+ kind: 'folder', key: level + f, level, name: f, depth, open: isOpen,
494
+ })
495
+ /* the open folder's own children are pushed by the next iteration —
496
+ * nothing recursive here, the loop IS the path */
497
+ })
498
+ files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
499
+ if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
500
+ })
501
+
502
+ const metaOf = (o) => [o.size != null && formatSize(o.size), o.uploaded].filter(Boolean).join(' · ')
503
+
504
+ return (
505
+ <div
506
+ className={`kol-column-browser kol-column-browser--stack flex flex-col border rounded ${className}`.trim()}
507
+ /* THE VIEWPORT IS THE HEIGHT (item 4). `height` is a value someone
508
+ * dragged on a desktop; applied literally to a phone it painted a
509
+ * black void the length of the viewport under two near-empty columns.
510
+ * A desktop drag is not a phone measurement, so below the breakpoint
511
+ * the stored one is ignored outright rather than clamped. */
512
+ style={{ borderColor: 'var(--kol-oq-08)' }}
513
+ >
514
+ {parent != null && (
515
+ <button
516
+ type="button"
517
+ onClick={() => onPrefix(parent)}
518
+ className="kol-column-browser-back flex items-center gap-2 px-4 py-3 border-b text-fg-default"
519
+ style={{ borderColor: 'var(--kol-oq-08)' }}
520
+ >
521
+ <Icon name="chevron-left" size={14} className="text-fg-48" />
522
+ {/* NAMES THE PARENT (item 2) — the folder it returns to, not '‹' */}
523
+ <span className="kol-mono-12 truncate">
524
+ {parent === '' ? 'All files' : parent.replace(/\/$/, '').split('/').pop()}
525
+ </span>
526
+ </button>
527
+ )}
528
+ <ul className="kol-column-browser-column flex-1 overflow-y-auto">
529
+ {rows.map((r) =>
530
+ r.kind === 'empty' ? (
531
+ <li key={r.key} className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
532
+ ) : r.kind === 'folder' ? (
533
+ <Row
534
+ key={r.key}
535
+ icon="folder"
536
+ label={r.name.replace(/\/$/, '')}
537
+ indent={r.depth}
538
+ active={r.open}
539
+ trailing={<Icon name={r.open ? 'chevron-down' : 'chevron-right'} size={12} className="text-fg-32" />}
540
+ /* the same chevron opens and collapses — tapping the open
541
+ * folder returns to its parent level, which is the whole
542
+ * inline-expand gesture */
543
+ onClick={() => onPrefix(r.open ? r.level : r.level + r.name)}
544
+ />
545
+ ) : (
546
+ <Row
547
+ key={r.key}
548
+ icon={COL_ICON[kindOf(r.o)] || 'file'}
549
+ label={r.o.displayKey ?? r.o.key}
550
+ indent={r.depth}
551
+ meta={metaOf(r.o)}
552
+ active={shown?.key === r.o.key}
553
+ /* A FILE IS A PUSH, NOT A PREVIEW PANE (item 6): there is no
554
+ * column for the preview to sit beside at this width, so the
555
+ * tap opens the full-screen inspector the consumer already
556
+ * renders for Quick Look. */
557
+ onClick={() => {
558
+ const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
559
+ pick(r.o)
560
+ onQuickLook?.({ files: files.length ? files : [r.o], index: Math.max(0, files.findIndex((f) => f.key === r.o.key)) })
561
+ }}
562
+ />
563
+ ),
564
+ )}
565
+ </ul>
566
+ </div>
567
+ )
568
+ }
569
+
416
570
  return (
417
571
  <div
418
572
  ref={rootRef}
@@ -357,9 +357,37 @@ export function MediaLibraryBrowse({
357
357
  onSettings={() => setSettingsOpen(true)} />
358
358
 
359
359
  <div className="flex flex-col gap-3">
360
- {/* Breadcrumb (uppercase, active segment at full ink) · folder-view toggle */}
360
+ {/* Breadcrumb (uppercase, active segment at full ink) · folder-view toggle
361
+ *
362
+ * AT 390 THIS ROW FAILED (ColumnBrowserStackMode items 3 + 7, kol-r2b2
363
+ * 2026-09-03, measured on a live site): breadcrumb, the folder-view
364
+ * toggle and the ROW·COLUMN pair share one `justify-between` line with
365
+ * no wrap or collapse rule, so the crumbs wrapped to two lines and
366
+ * `COLUMN` was cut off. Below `md` the crumb becomes ONE middle-elided
367
+ * path line and the toggles collapse into the settings drawer — where
368
+ * both already live, so nothing new was minted to hold them. Both
369
+ * references (iOS Files, Dropbox) put these behind a `···` too. */}
361
370
  <div className="flex items-center justify-between gap-4">
362
- <div className="flex items-center gap-2 kol-mono-12">
371
+ {/* the elided line — first segment, an ellipsis for anything between,
372
+ * and the current one. Below `md` only. */}
373
+ <div className="flex md:hidden items-center gap-2 kol-mono-12 min-w-0">
374
+ <button className={crumbCls(appRoot)} onClick={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}>{ROOT}</button>
375
+ {!appRoot && bucketMeta.id && crumbs.length > 1 && (
376
+ <><span className="text-oq-32">/</span><span className="text-oq-32">…</span></>
377
+ )}
378
+ {!appRoot && bucketMeta.id && (
379
+ <span className="flex items-center gap-2 min-w-0">
380
+ <span className="text-oq-32">/</span>
381
+ <button
382
+ className={`${crumbCls(true)} truncate`}
383
+ onClick={() => setPrefix(crumbs.length ? crumbs.join('/') + '/' : '')}
384
+ >
385
+ {(crumbs[crumbs.length - 1] ?? label).toUpperCase()}
386
+ </button>
387
+ </span>
388
+ )}
389
+ </div>
390
+ <div className="hidden md:flex items-center gap-2 kol-mono-12">
363
391
  <button className={crumbCls(appRoot)} onClick={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}>{ROOT}</button>
364
392
  {!appRoot && bucketMeta.id && (
365
393
  <span className="flex items-center gap-2">
@@ -381,7 +409,10 @@ export function MediaLibraryBrowse({
381
409
  <span className="flex items-center gap-2"><span className="text-oq-32">/</span><span className="text-oq-96">{pickedFile.key.slice(prefix.length).toUpperCase()}</span></span>
382
410
  )}
383
411
  </div>
384
- <div className="flex items-center gap-6">
412
+ {/* the control cluster is desktop-only — below `md` these two toggles
413
+ * are the same setting the drawer already carries, and nothing in
414
+ * this row survives 390 */}
415
+ <div className="hidden md:flex items-center gap-6">
385
416
  <ViewToggle viewMode={folderView} onViewChange={(v) => setSettings({ ...settings, folderView: v })} variant="icon" options={FOLDER_VIEW_OPTIONS} />
386
417
  <Divider variant="vertical" />
387
418
  <div className="flex items-center gap-4">