@kolkrabbi/kol-component 0.231.0 → 0.232.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 +1 -1
- package/src/index.js +1 -0
- package/src/molecules/FieldRow.jsx +11 -10
- package/src/molecules/OptionRow.jsx +82 -0
- package/src/organisms/ColumnBrowser.jsx +31 -1
- package/src/organisms/ContentFilters.jsx +15 -3
- package/src/organisms/MediaLibraryPages.jsx +75 -16
- package/src/organisms/ShellSearchOverlay.jsx +21 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.232.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",
|
package/src/index.js
CHANGED
|
@@ -60,6 +60,7 @@ export { default as SectionLabel } from './atoms/SectionLabel.jsx'
|
|
|
60
60
|
export { default as SegmentedToggle } from './atoms/SegmentedToggle.jsx'
|
|
61
61
|
export { default as Stepper } from './molecules/Stepper.jsx'
|
|
62
62
|
export { default as Kbd } from './atoms/Kbd.jsx'
|
|
63
|
+
export { default as OptionRow } from './molecules/OptionRow.jsx'
|
|
63
64
|
export { default as Tag } from './atoms/Tag.jsx'
|
|
64
65
|
export { default as Textarea } from './atoms/Textarea.jsx'
|
|
65
66
|
export { default as ToggleBracket } from './atoms/ToggleBracket.jsx'
|
|
@@ -3,6 +3,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
3
3
|
import Input from '../atoms/Input'
|
|
4
4
|
import { usePopover, PopoverPanel } from '../utilities/Popover'
|
|
5
5
|
import Dropdown from './Dropdown'
|
|
6
|
+
import OptionRow from './OptionRow.jsx'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* FieldRow — one labeled field row in a record surface (lobby: RecordManager).
|
|
@@ -93,23 +94,23 @@ export function StatusChip({ value, options = [], onChange, variant = 'neutral',
|
|
|
93
94
|
{norm.map(({ value: val, label }) => {
|
|
94
95
|
return (
|
|
95
96
|
<li key={val}>
|
|
96
|
-
<
|
|
97
|
+
<OptionRow
|
|
98
|
+
as="button"
|
|
97
99
|
type="button"
|
|
98
100
|
role="option"
|
|
99
101
|
aria-selected={val === value}
|
|
100
|
-
|
|
102
|
+
size="sm"
|
|
103
|
+
selected={val === value}
|
|
104
|
+
className="w-full"
|
|
101
105
|
onClick={() => {
|
|
102
106
|
onChange?.(val)
|
|
103
107
|
setOpen(false)
|
|
104
108
|
}}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
</span>
|
|
111
|
-
{label}
|
|
112
|
-
</button>
|
|
109
|
+
/* check column always reserved — labels align whether or
|
|
110
|
+
* not a row carries the mark (reference grammar) */
|
|
111
|
+
leading={<span className="inline-flex w-4 justify-center">{val === value && <Icon name="check" size={12} />}</span>}
|
|
112
|
+
label={label}
|
|
113
|
+
/>
|
|
113
114
|
</li>
|
|
114
115
|
)
|
|
115
116
|
})}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OptionRow — one row in a list you move through: the palette's results, a browser's rows, a
|
|
3
|
+
* dropdown's options. Lifted out of ShellSearchOverlay (2026-09-30), where it was hand-rolled next to
|
|
4
|
+
* DropdownTagFilter's, FieldRow's and ColumnBrowser's own — four rows for one job.
|
|
5
|
+
*
|
|
6
|
+
* The box is the control's: `min-h` on the --kol-ctl-* ramp (22 · 26 · 32 · 40) with a 1px
|
|
7
|
+
* transparent ring, so a row lines up with the field or button beside it at the same size.
|
|
8
|
+
*
|
|
9
|
+
* States, strongest first:
|
|
10
|
+
* active — the keyboard cursor / hover: the tone fill, full ink
|
|
11
|
+
* selected — the picked item: oq-08
|
|
12
|
+
* trail — on the path to the picked item (a browser's parent folders): oq-04
|
|
13
|
+
*
|
|
14
|
+
* Renders no role — the caller owns the list semantics (`role="option"`, `aria-selected`, ids)
|
|
15
|
+
* and passes them through `...rest`.
|
|
16
|
+
*
|
|
17
|
+
* @param {'xs'|'sm'|'md'|'lg'} size the control ramp step
|
|
18
|
+
* @param {string} icon kol-icons name, drawn in oq (icons-use-oq law)
|
|
19
|
+
* @param {ReactNode} leading anything else in the icon column (a check, a thumb) — wins over icon
|
|
20
|
+
* @param {ReactNode} label
|
|
21
|
+
* @param {ReactNode} hint a second line under the label
|
|
22
|
+
* @param {ReactNode} trailing right-aligned (a shortcut, a count, a menu button)
|
|
23
|
+
* @param {string} as element to render (`div` · `button` · `li`)
|
|
24
|
+
* @param {boolean} fill false = the caller paints the state fills (ColumnBrowser's trail vs
|
|
25
|
+
* deepest-selection is decided in CSS by column); the ink still follows
|
|
26
|
+
* @param {boolean} muted a row that is there but not the point (fg-48)
|
|
27
|
+
*
|
|
28
|
+
* `ROW_HEIGHT` / `ROW_TYPE` are exported for a row that cannot take this anatomy (the media rows
|
|
29
|
+
* view's table row: twisty, drag hit, columns) — it still sits on the same ramp.
|
|
30
|
+
*/
|
|
31
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
32
|
+
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
33
|
+
|
|
34
|
+
/* the box: min-height on the ramp, and a pad that lands one line of the step's type exactly on it —
|
|
35
|
+
* xs 2+1+16+1+2 = 22 · sm 4+1+16+1+4 = 26 · md 6+1+18+1+6 = 32 · lg 10+1+18+1+10 = 40 */
|
|
36
|
+
export const ROW_HEIGHT = {
|
|
37
|
+
xs: 'min-h-[var(--kol-ctl-xs)] py-0.5',
|
|
38
|
+
sm: 'min-h-[var(--kol-ctl-sm)] py-1',
|
|
39
|
+
md: 'min-h-[var(--kol-ctl-md)] py-1.5',
|
|
40
|
+
lg: 'min-h-[var(--kol-ctl-lg)] py-2.5',
|
|
41
|
+
}
|
|
42
|
+
export const ROW_TYPE = { xs: 'kol-mono-12', sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-14' }
|
|
43
|
+
|
|
44
|
+
export default function OptionRow({
|
|
45
|
+
as: Tag = 'div',
|
|
46
|
+
size = 'md',
|
|
47
|
+
active = false,
|
|
48
|
+
selected = false,
|
|
49
|
+
trail = false,
|
|
50
|
+
fill: paints = true,
|
|
51
|
+
muted = false,
|
|
52
|
+
icon,
|
|
53
|
+
leading,
|
|
54
|
+
label,
|
|
55
|
+
hint,
|
|
56
|
+
trailing,
|
|
57
|
+
className = '',
|
|
58
|
+
children,
|
|
59
|
+
...rest
|
|
60
|
+
}) {
|
|
61
|
+
const lit = active || selected || trail
|
|
62
|
+
/* hover takes the active fill — the palette already moves `active` on hover; a list without a
|
|
63
|
+
* keyboard cursor gets the same look for free. `fill={false}` rows paint nothing (the columns) */
|
|
64
|
+
const bg = !paints ? '' : active
|
|
65
|
+
? 'bg-[var(--kol-tone-bg,var(--kol-surface-secondary))]'
|
|
66
|
+
: `${selected ? 'bg-oq-08' : trail ? 'bg-oq-04' : ''} hover:bg-[var(--kol-tone-bg,var(--kol-surface-secondary))]`
|
|
67
|
+
const fill = `${bg} ${lit ? 'text-fg' : muted ? 'text-fg-48' : 'text-fg-80'}`
|
|
68
|
+
const lead = leading ?? (icon && <Icon name={icon} size={glyphSize(size)} />)
|
|
69
|
+
return (
|
|
70
|
+
<Tag
|
|
71
|
+
className={`flex items-center gap-2 px-4 border border-transparent ${ROW_HEIGHT[size] ?? ROW_HEIGHT.md} rounded-[var(--kol-radius-sm)] cursor-pointer text-left ${ROW_TYPE[size] ?? ROW_TYPE.md} transition-colors ${fill} ${className}`.trim()}
|
|
72
|
+
{...rest}
|
|
73
|
+
>
|
|
74
|
+
{lead && <span aria-hidden="true" className="flex shrink-0 text-oq-48">{lead}</span>}
|
|
75
|
+
<span className="flex flex-1 flex-col min-w-0">
|
|
76
|
+
<span className="truncate">{label ?? children}</span>
|
|
77
|
+
{hint && <span className="kol-mono-12 text-fg-48 truncate">{hint}</span>}
|
|
78
|
+
</span>
|
|
79
|
+
{trailing && <span className="flex shrink-0 items-center">{trailing}</span>}
|
|
80
|
+
</Tag>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
3
3
|
import KindPreview, { KIND_GLYPH } from '../molecules/KindPreview.jsx'
|
|
4
4
|
import FileIcon from '../atoms/FileIcon.jsx'
|
|
5
5
|
import RowMenuButton from '../molecules/RowMenuButton.jsx'
|
|
6
|
+
import OptionRow from '../molecules/OptionRow.jsx'
|
|
6
7
|
import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
7
8
|
import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
8
9
|
import useGrabEdge from '../hooks/useGrabEdge.js'
|
|
@@ -248,7 +249,7 @@ const ZONE_BOX = 44
|
|
|
248
249
|
* without the stack: the columns pass none, so a desktop row is byte-identical
|
|
249
250
|
* to what it was. */
|
|
250
251
|
function Row({
|
|
251
|
-
icon, label, active, cursor = false, trailing, onClick, muted = false,
|
|
252
|
+
icon, label, active, cursor = false, trailing, onClick, muted = false, size = 'md',
|
|
252
253
|
indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop, dropFiles, markKey,
|
|
253
254
|
}) {
|
|
254
255
|
const [over, setOver] = useState(false)
|
|
@@ -273,6 +274,30 @@ function Row({
|
|
|
273
274
|
drop.onDrop(drop.path)
|
|
274
275
|
},
|
|
275
276
|
} : {}
|
|
277
|
+
/* THE DESKTOP ROW IS AN OptionRow (2026-09-30) — the palette's row, on the control ramp, so a size
|
|
278
|
+
* setting scales height, glyph and type together. The fills stay this file's CSS (trail vs the
|
|
279
|
+
* deepest column's selection is decided by column there, and hover/cursor paint nothing — the
|
|
280
|
+
* 2026-09-02 ruling), so `fill={false}`. The `zones` row below is the phone's and keeps its own. */
|
|
281
|
+
if (!zones) return (
|
|
282
|
+
<OptionRow
|
|
283
|
+
as="li"
|
|
284
|
+
size={size}
|
|
285
|
+
fill={false}
|
|
286
|
+
selected={active}
|
|
287
|
+
muted={muted && !cursor}
|
|
288
|
+
icon={icon}
|
|
289
|
+
label={label}
|
|
290
|
+
hint={meta}
|
|
291
|
+
trailing={<>{trailing}<RowMenuButton onOpen={onContextMenu} className="-my-2 -mr-2" /></>}
|
|
292
|
+
onContextMenu={onContextMenu}
|
|
293
|
+
{...dropProps}
|
|
294
|
+
data-marquee-key={markKey}
|
|
295
|
+
data-drop-over={over || undefined}
|
|
296
|
+
className={`kol-column-browser-row${active ? ' is-selected' : ''}${cursor ? ' is-cursor' : ''}`}
|
|
297
|
+
onClick={onClick}
|
|
298
|
+
style={indent ? { paddingLeft: `calc(var(--kol-spacing-4) + ${indent} * var(--kol-spacing-5))` } : undefined}
|
|
299
|
+
/>
|
|
300
|
+
)
|
|
276
301
|
return (
|
|
277
302
|
<li
|
|
278
303
|
onContextMenu={onContextMenu}
|
|
@@ -521,6 +546,9 @@ export default function ColumnBrowser({
|
|
|
521
546
|
/* `folderIcon(path)` — the glyph a folder row wears (default `folder`). The media pages mark a
|
|
522
547
|
* bucket — a SOURCE, not a prefix — with `database` (user 2026-09-23). */
|
|
523
548
|
folderIcon,
|
|
549
|
+
/* `rowSize` — the desktop rows' step on the control ramp (xs · sm · md · lg = 22 · 26 · 32 · 40),
|
|
550
|
+
* height, glyph and type together (user 2026-09-30). md matches the search field. */
|
|
551
|
+
rowSize = 'md',
|
|
524
552
|
/* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
|
|
525
553
|
* kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
|
|
526
554
|
* consumer, so neither is computed here:
|
|
@@ -1006,6 +1034,7 @@ export default function ColumnBrowser({
|
|
|
1006
1034
|
{folders.map((f, i) => (
|
|
1007
1035
|
<Row
|
|
1008
1036
|
key={f}
|
|
1037
|
+
size={rowSize}
|
|
1009
1038
|
icon={folderIcon?.(level + f) ?? 'folder'}
|
|
1010
1039
|
label={f.replace(/\/$/, '')}
|
|
1011
1040
|
markKey={level + f}
|
|
@@ -1021,6 +1050,7 @@ export default function ColumnBrowser({
|
|
|
1021
1050
|
{files.map((o, i) => (
|
|
1022
1051
|
<Row
|
|
1023
1052
|
key={o.key}
|
|
1053
|
+
size={rowSize}
|
|
1024
1054
|
icon={COL_ICON[kindOf(o)] || 'file'}
|
|
1025
1055
|
label={o.displayKey ?? o.key}
|
|
1026
1056
|
markKey={o.key}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo } from 'react'
|
|
1
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
2
2
|
import Tag from '../atoms/Tag.jsx'
|
|
3
3
|
import Divider from '../atoms/Divider.jsx'
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
@@ -115,6 +115,11 @@ const ContentFilters = ({
|
|
|
115
115
|
mutuallyExclusiveFilters = [],
|
|
116
116
|
customFilterKeys = [],
|
|
117
117
|
searchKeys = ['label', 'name', 'title', 'type'],
|
|
118
|
+
/* CONTROLLED SEARCH (2026-09-30) — same seam as `filtersOpen`: a consumer that must know the
|
|
119
|
+
* query (media swaps the tree for a flat list while one is typed) or hand one in (⌘Enter from its
|
|
120
|
+
* palette) owns it. Absent, the text stays private, exactly as before. */
|
|
121
|
+
searchValue,
|
|
122
|
+
onSearchChange,
|
|
118
123
|
headerActions,
|
|
119
124
|
trailingActions,
|
|
120
125
|
trailingPlacement = 'auto',
|
|
@@ -182,8 +187,15 @@ const ContentFilters = ({
|
|
|
182
187
|
setInternalLayout(next)
|
|
183
188
|
onLayoutChange?.(next)
|
|
184
189
|
}
|
|
185
|
-
const [searchOpen, setSearchOpen] = useState(
|
|
186
|
-
const [
|
|
190
|
+
const [searchOpen, setSearchOpen] = useState(!!searchValue)
|
|
191
|
+
const [internalSearch, setInternalSearch] = useState('')
|
|
192
|
+
const searchText = searchValue !== undefined ? searchValue : internalSearch
|
|
193
|
+
const setSearchText = (next) => {
|
|
194
|
+
setInternalSearch(next)
|
|
195
|
+
onSearchChange?.(next)
|
|
196
|
+
}
|
|
197
|
+
/* a query handed in from outside opens the field it lives in */
|
|
198
|
+
useEffect(() => { if (searchValue) setSearchOpen(true) }, [searchValue])
|
|
187
199
|
|
|
188
200
|
|
|
189
201
|
const toggleFilter = (filterType, value) => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { Fragment, createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import formatSize from '../utilities/formatSize.js'
|
|
3
|
+
import { ROW_HEIGHT, ROW_TYPE } from '../molecules/OptionRow.jsx'
|
|
4
|
+
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
3
5
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
6
|
import Button from '../atoms/Button.jsx'
|
|
5
7
|
import Divider from '../atoms/Divider.jsx'
|
|
@@ -299,11 +301,17 @@ function TagEditor({ tags = [], onChange, suggestions = [] }) {
|
|
|
299
301
|
)
|
|
300
302
|
}
|
|
301
303
|
|
|
304
|
+
/* THE ROWS VIEW'S STEP ON THE CONTROL RAMP (user 2026-09-30) — the same `rowSize` the columns take,
|
|
305
|
+
* handed down by context rather than threaded through every subtree level. These rows keep their
|
|
306
|
+
* own anatomy (twisty, drag hit, table columns) and take only the ramp: box, type, glyph. */
|
|
307
|
+
const RowSize = createContext('md')
|
|
308
|
+
|
|
302
309
|
/* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
|
|
303
310
|
* disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
|
|
304
311
|
* that navigates — so nothing that already renders these moves. */
|
|
305
312
|
function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
|
|
306
313
|
const [over, setOver] = useState(false)
|
|
314
|
+
const size = useContext(RowSize)
|
|
307
315
|
/* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
|
|
308
316
|
* row highlights only while something is actually over it, so an accidental hover reads as
|
|
309
317
|
* nothing. Without `drag` every handler below is undefined and the row is exactly what it was. */
|
|
@@ -333,7 +341,7 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
|
|
|
333
341
|
return (
|
|
334
342
|
/* THE COLUMN BROWSER'S ROW (user 2026-09-22): `kol-column-browser-row` carries the 4px pill
|
|
335
343
|
* inset, the selected fill and the drop fill; no divider, as the columns have none. */
|
|
336
|
-
<li onContextMenu={onContextMenu} {...dropProps} data-marquee-key={markKey} data-hit-zone data-drop-over={over || undefined} className={`kol-column-browser-row flex items-center gap-3
|
|
344
|
+
<li onContextMenu={onContextMenu} {...dropProps} data-marquee-key={markKey} data-hit-zone data-drop-over={over || undefined} className={`kol-column-browser-row flex items-center gap-3 border border-transparent px-3 ${ROW_HEIGHT[size]} transition-colors${selected ? ' is-selected' : ''}${current ? ' is-current' : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
|
|
337
345
|
{onToggle ? (
|
|
338
346
|
<button type="button" aria-label={expanded ? `Collapse ${name}` : `Expand ${name}`} aria-expanded={!!expanded}
|
|
339
347
|
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
|
@@ -345,11 +353,11 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
|
|
|
345
353
|
{/* THE FOLDER GLYPH IS THE ONE ICON LEFT, so it fills its box (user 2026-09-23: *"only folder
|
|
346
354
|
should use icon, and it should be bigger"*). Every other kind now renders its own bytes. */}
|
|
347
355
|
<span data-hit {...hitDrag} className="kol-row-hit flex items-center gap-3 min-w-0">
|
|
348
|
-
<span className="
|
|
356
|
+
<span className="shrink-0 flex items-center justify-center text-oq-48"><Icon name={icon} size={glyphSize(size)} /></span>
|
|
349
357
|
{/* NO TRAILING SLASH (user 2026-09-22). `partition` hands folders back as `audio/` because a
|
|
350
358
|
slash is what marks one in a flat key space; that is storage's spelling, not a label, and
|
|
351
359
|
`ColumnBrowser` has always stripped it. The glyph says "folder". */}
|
|
352
|
-
<span className=
|
|
360
|
+
<span className={`${ROW_TYPE[size]} min-w-0 truncate text-fg-default`}>{name.replace(/\/$/, '')}</span>
|
|
353
361
|
</span>
|
|
354
362
|
<span className="flex-1" />
|
|
355
363
|
{/* THE FOLDER'S COUNT SITS IN THE DATE COLUMN and the size column takes a dash — the columns
|
|
@@ -419,17 +427,18 @@ function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder,
|
|
|
419
427
|
* `#img/` drew two rows where the column browser drew four — the two folders, the empty folder the
|
|
420
428
|
* columns knew about, and a file. A list view that cannot show a file is not a list view. */
|
|
421
429
|
function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols, onContextMenu, drag, selected, markKey }) {
|
|
430
|
+
const size = useContext(RowSize)
|
|
422
431
|
const dragProps = drag ? { draggable: true, onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, o.key) } } : {}
|
|
423
432
|
return (
|
|
424
|
-
<li onContextMenu={onContextMenu} data-marquee-key={markKey ?? o.key} data-hit-zone className={`kol-column-browser-row flex items-center gap-3
|
|
433
|
+
<li onContextMenu={onContextMenu} data-marquee-key={markKey ?? o.key} data-hit-zone className={`kol-column-browser-row flex items-center gap-3 border border-transparent px-3 ${ROW_HEIGHT[size]} transition-colors${selected ? ' is-selected' : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
|
|
425
434
|
<span className="w-4 shrink-0" />
|
|
426
435
|
{/* the hit — icon and name, as in the folder row above */}
|
|
427
436
|
<span data-hit {...dragProps} className="kol-row-hit flex items-center gap-3 min-w-0">
|
|
428
|
-
<span className="
|
|
437
|
+
<span className="shrink-0 flex items-center justify-center text-oq-48 overflow-hidden rounded" style={{ width: glyphSize(size), height: glyphSize(size) }}>
|
|
429
438
|
{/* a file with no thumbnail draws its glyph at the FOLDER's size — one icon column (user 2026-09-25) */}
|
|
430
|
-
{thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={
|
|
439
|
+
{thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={glyphSize(size)} />}
|
|
431
440
|
</span>
|
|
432
|
-
<span className=
|
|
441
|
+
<span className={`${ROW_TYPE[size]} min-w-0 truncate text-fg-default`}>{o.displayKey ?? o.key.split('/').pop()}</span>
|
|
433
442
|
</span>
|
|
434
443
|
<span className="flex-1" />
|
|
435
444
|
{cols && <>
|
|
@@ -1058,6 +1067,11 @@ export function MediaLibraryBrowse({
|
|
|
1058
1067
|
* media-hub, "two too many"). Given, the gear calls it and this page opens no drawer of its own —
|
|
1059
1068
|
* inside a Hub the Hub's Settings is the one place. Absent, the gear opens the drawer as always. */
|
|
1060
1069
|
onOpenSettings,
|
|
1070
|
+
/* `searchSuggestions` — what the palette lists before you type. Absent: favourites, smart
|
|
1071
|
+
* folders, then the bucket's top-level folders. An array replaces them; a function receives them
|
|
1072
|
+
* and returns the list. Rows are the palette's shape; a row with `run` calls it instead of
|
|
1073
|
+
* jumping to `id` as a file key. */
|
|
1074
|
+
searchSuggestions,
|
|
1061
1075
|
/* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
|
|
1062
1076
|
* Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
|
|
1063
1077
|
bucketLevel = false,
|
|
@@ -1593,6 +1607,9 @@ export function MediaLibraryBrowse({
|
|
|
1593
1607
|
* you, which is what the filter bar is for. */
|
|
1594
1608
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
1595
1609
|
const [searchQuery, setSearchQuery] = useState('')
|
|
1610
|
+
/* the filter bar's own query — lifted so a typed one swaps the tree for a flat list, and so the
|
|
1611
|
+
* palette's ⌘Enter can hand its query over */
|
|
1612
|
+
const [barQuery, setBarQuery] = useState('')
|
|
1596
1613
|
/* THE ARROWS IN THE GRID AND THE ROWS (user 2026-09-23: *"arrow navigation doesnt work in grid
|
|
1597
1614
|
* mode? nor in row mode"*). The columns have always had theirs (`ColumnBrowser`); these are the
|
|
1598
1615
|
* same keys for the other two views, and every one is listed in the shortcuts sheet.
|
|
@@ -1929,8 +1946,12 @@ export function MediaLibraryBrowse({
|
|
|
1929
1946
|
.map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet) }))
|
|
1930
1947
|
const filtersOn = !!settings.filters
|
|
1931
1948
|
/* the bar filters FILES, and a tree is drawn from the files under it — so one filtered list
|
|
1932
|
-
* serves both: the wall renders it, the tree is scoped to the keys in it
|
|
1933
|
-
|
|
1949
|
+
* serves both: the wall renders it, the tree is scoped to the keys in it.
|
|
1950
|
+
* THE TREE'S LIST IS THE WHOLE BUCKET, not the folder you stand in (user 2026-09-30: open the
|
|
1951
|
+
* bar at `brand/og/` and `logos`, `original`, `projects` vanished with no filter set). The
|
|
1952
|
+
* columns draw every level up to the root, so a list scoped to `prefix` cut every sibling path
|
|
1953
|
+
* the moment the bar mounted. */
|
|
1954
|
+
const filterItems = isWall ? wallFiles : sortedObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key }))
|
|
1934
1955
|
const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
|
|
1935
1956
|
const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
|
|
1936
1957
|
/* favourites as a one-chip group: ContentFilters matches a value, so a starred file carries one */
|
|
@@ -1980,6 +2001,7 @@ export function MediaLibraryBrowse({
|
|
|
1980
2001
|
className={atTitleRoot ? 'is-root' : ''}
|
|
1981
2002
|
height={colH}
|
|
1982
2003
|
onHeightChange={onColH}
|
|
2004
|
+
rowSize={rowSize}
|
|
1983
2005
|
columnWidths={settings.columnWidths}
|
|
1984
2006
|
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
1985
2007
|
objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
@@ -2166,13 +2188,23 @@ export function MediaLibraryBrowse({
|
|
|
2166
2188
|
* inside it, the same preview pane beside them, the same count line under it — with the size
|
|
2167
2189
|
* slider at its right end, Finder's. */
|
|
2168
2190
|
const tileSize = settings.tileSize ?? 180
|
|
2191
|
+
const rowSize = settings.rowSize ?? 'md'
|
|
2169
2192
|
/* the tile size is the GRID's, not a level's (user 2026-09-24: the slider vanished at the root
|
|
2170
2193
|
* levels) — one control, drawn wherever the grid is */
|
|
2194
|
+
/* THE TREE VIEWS SCALE TOO (user 2026-09-30: *"we might want to scale everything on slider like we
|
|
2195
|
+
* do in grid view"*) — in steps of the control ramp, not pixels, so a row is always a control height */
|
|
2196
|
+
const RAMP = ['xs', 'sm', 'md', 'lg']
|
|
2197
|
+
const rowSlider = (
|
|
2198
|
+
<input type="range" className="slider-black w-32 cursor-pointer" min={0} max={RAMP.length - 1} step={1} value={Math.max(0, RAMP.indexOf(rowSize))}
|
|
2199
|
+
onChange={(e) => setSettings({ ...settings, rowSize: RAMP[Number(e.target.value)] })} aria-label="Row size" />
|
|
2200
|
+
)
|
|
2171
2201
|
const sizeSlider = (
|
|
2172
2202
|
<input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
|
|
2173
2203
|
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
2174
2204
|
)
|
|
2175
|
-
|
|
2205
|
+
/* `hits` = a query's results: no folders, no root level, each file named by its whole path —
|
|
2206
|
+
* in the tree views as a list, the rows' shape; the wall keeps its tiles */
|
|
2207
|
+
const wallPane = (files, hits = false) => (
|
|
2176
2208
|
<div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
|
|
2177
2209
|
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
2178
2210
|
{/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
|
|
@@ -2185,7 +2217,7 @@ export function MediaLibraryBrowse({
|
|
|
2185
2217
|
onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
2186
2218
|
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}>
|
|
2187
2219
|
{gridMarquee.rect && <div className="kol-marquee" style={gridMarquee.rect} />}
|
|
2188
|
-
{titleLevel ? (
|
|
2220
|
+
{titleLevel && !hits ? (
|
|
2189
2221
|
<div className="grid gap-x-3 gap-y-5" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${tileSize}px, 1fr))` }}>
|
|
2190
2222
|
{rootItems.map((it) => (
|
|
2191
2223
|
<MediaTile key={it.key} preview={<Icon name={it.icon} size="100%" className="text-oq-48" />} name={it.name}
|
|
@@ -2193,11 +2225,12 @@ export function MediaLibraryBrowse({
|
|
|
2193
2225
|
onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
|
|
2194
2226
|
))}
|
|
2195
2227
|
</div>
|
|
2196
|
-
) : <WallBody files={files} layout=
|
|
2228
|
+
) : <WallBody files={files} layout={hits && !isWall ? 'list' : 'grid'} tiles="media" cardMin={tileSize} onSorted={(list) => { gridOrderRef.current = list }}
|
|
2197
2229
|
/* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
|
|
2198
2230
|
* 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
|
|
2199
2231
|
* and no folders, as it was. */
|
|
2200
|
-
folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
|
|
2232
|
+
folders={settings.flat || smartMatches || hits ? [] : folders.map((f) => prefix + f)}
|
|
2233
|
+
{...(hits && { renderName: (o) => o.key })}
|
|
2201
2234
|
onOpenFolder={goFolder}
|
|
2202
2235
|
onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
|
|
2203
2236
|
onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
@@ -2230,6 +2263,20 @@ export function MediaLibraryBrowse({
|
|
|
2230
2263
|
hint: o.key.slice(0, o.key.lastIndexOf('/') + 1) || 'the bucket root',
|
|
2231
2264
|
group: KIND_LABEL[kindOf(o)] || 'File',
|
|
2232
2265
|
}))
|
|
2266
|
+
/* THE PALETTE OPENS ON SOMEWHERE TO GO (user 2026-09-30: it opened blank in olina). */
|
|
2267
|
+
const openFolder = (path) => { setAppRoot(false); goFolder(path) }
|
|
2268
|
+
const leafOf = (path) => path.replace(/\/$/, '').split('/').pop()
|
|
2269
|
+
const hintOf = (path) => dirOf(path) || 'the bucket root'
|
|
2270
|
+
const defaultSuggestions = [
|
|
2271
|
+
...Object.keys(folderRows ?? {}).filter((p) => folderRows[p]?.favourite).sort()
|
|
2272
|
+
.map((p) => ({ id: p, label: leafOf(p), hint: hintOf(p), group: 'Favourites', icon: 'folder', run: () => openFolder(p) })),
|
|
2273
|
+
...objects.filter((o) => o.favourite && !isSystemFile(o.key))
|
|
2274
|
+
.map((o) => ({ id: o.key, label: leafOf(o.key), hint: hintOf(o.key), group: 'Favourites', icon: 'file' })),
|
|
2275
|
+
...smartFolders.map((sf) => ({ id: `smart:${sf.id}`, label: sf.name, group: 'Smart folders', icon: 'folder', run: () => setActiveSmart(sf.id) })),
|
|
2276
|
+
...[...new Set([...treeFolders(''), ...partition(objects.filter((o) => !isSystemFile(o.key)), '').folders])].sort()
|
|
2277
|
+
.map((f) => ({ id: f, label: leafOf(f), group: 'Folders', icon: 'folder', run: () => openFolder(f) })),
|
|
2278
|
+
]
|
|
2279
|
+
const suggestions = typeof searchSuggestions === 'function' ? searchSuggestions(defaultSuggestions) : (searchSuggestions ?? defaultSuggestions)
|
|
2233
2280
|
const jumpTo = (key) => {
|
|
2234
2281
|
const o = objects.find((x) => x.key === key)
|
|
2235
2282
|
setSearchOpen(false); setSearchQuery('')
|
|
@@ -2249,14 +2296,20 @@ export function MediaLibraryBrowse({
|
|
|
2249
2296
|
if (q.text && !filterMedia([o], q.text).length) return false
|
|
2250
2297
|
return true
|
|
2251
2298
|
}).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
|
|
2299
|
+
/* A TYPED QUERY IS A RESULTS LIST (user 2026-09-30, reversing "a flat results list nobody asked
|
|
2300
|
+
* for"): pruning the tree to matching paths hid the files one level down and read as "search only
|
|
2301
|
+
* finds folders". While the bar holds a query, the tree views show every hit flat, each with its
|
|
2302
|
+
* path; clearing it brings the tree back. The wall already was a flat list. */
|
|
2252
2303
|
const body = (filtered) => (smartMatches
|
|
2253
2304
|
? wallPane(filtered ?? smartMatches)
|
|
2305
|
+
: filtered && barQuery.trim() ? wallPane(filtered, true)
|
|
2254
2306
|
: isWall
|
|
2255
2307
|
? wallPane(filtered ?? wallFiles)
|
|
2256
2308
|
: treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
|
|
2257
2309
|
|
|
2258
2310
|
return (
|
|
2259
2311
|
<div ref={pageRef} {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
2312
|
+
<RowSize.Provider value={rowSize}>
|
|
2260
2313
|
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
2261
2314
|
onHome={goTitleRoot}
|
|
2262
2315
|
onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
|
|
@@ -2440,7 +2493,10 @@ export function MediaLibraryBrowse({
|
|
|
2440
2493
|
placeholder="Search this bucket"
|
|
2441
2494
|
results={searchResults}
|
|
2442
2495
|
onExpand={() => searchResults[0] && jumpTo(searchResults[0].id)}
|
|
2443
|
-
onSelect={(item) => jumpTo(item.id)}
|
|
2496
|
+
onSelect={(item) => (item.run ? item.run() : jumpTo(item.id))}
|
|
2497
|
+
suggestions={suggestions}
|
|
2498
|
+
enterLabel="Go to file"
|
|
2499
|
+
onOpenResults={(q) => { setSettings({ ...settings, filters: true }); setBarQuery(q) }}
|
|
2444
2500
|
/>
|
|
2445
2501
|
)}
|
|
2446
2502
|
{quickLookFolder && (
|
|
@@ -2490,6 +2546,8 @@ export function MediaLibraryBrowse({
|
|
|
2490
2546
|
title="Files"
|
|
2491
2547
|
totalCount={filterItems.length}
|
|
2492
2548
|
searchKeys={['displayKey']}
|
|
2549
|
+
searchValue={barQuery}
|
|
2550
|
+
onSearchChange={setBarQuery}
|
|
2493
2551
|
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
|
|
2494
2552
|
mutuallyExclusiveFilters={['kind']}
|
|
2495
2553
|
/* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
|
|
@@ -2513,7 +2571,7 @@ export function MediaLibraryBrowse({
|
|
|
2513
2571
|
{atTop ? '1 folder' : `${bucketList.length} ${bucketList.length === 1 ? 'bucket' : 'buckets'}`}
|
|
2514
2572
|
{folderTree && ` · ${Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · ${formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}`}
|
|
2515
2573
|
</p>
|
|
2516
|
-
{isWall
|
|
2574
|
+
{isWall ? sizeSlider : rowSlider}
|
|
2517
2575
|
</div>
|
|
2518
2576
|
) : isWall ? (
|
|
2519
2577
|
/* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
|
|
@@ -2665,6 +2723,7 @@ export function MediaLibraryBrowse({
|
|
|
2665
2723
|
</>
|
|
2666
2724
|
)}
|
|
2667
2725
|
</div>
|
|
2726
|
+
</RowSize.Provider>
|
|
2668
2727
|
</div>
|
|
2669
2728
|
)
|
|
2670
2729
|
}
|
|
@@ -3,11 +3,11 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
3
3
|
import SearchInput from '../molecules/SearchInput.jsx'
|
|
4
4
|
import Tag from '../atoms/Tag.jsx'
|
|
5
5
|
import Kbd from '../atoms/Kbd.jsx'
|
|
6
|
+
import OptionRow from '../molecules/OptionRow.jsx'
|
|
6
7
|
|
|
7
8
|
/* ONE COLUMN (2026-09-30): rows, headings and footer sit on the field's own
|
|
8
9
|
* geometry — the p-2 inset, then the md control's 1px ring + 16px pad — so
|
|
9
10
|
* every glyph and word lines up under the field's icon and caret. */
|
|
10
|
-
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
11
11
|
|
|
12
12
|
/* Rows bucketed by `group` in first-seen order, rank kept inside a group —
|
|
13
13
|
* one heading per group; arrows rove this order, not the engine's. */
|
|
@@ -87,6 +87,9 @@ export function HighlightMatch({ label, query, ranges }) {
|
|
|
87
87
|
* the panel's last line, so Enter is never a surprise
|
|
88
88
|
* (e.g. `All results for “q”`). Rows may carry
|
|
89
89
|
* `highlights` ([start, end] ranges) from the engine.
|
|
90
|
+
* @param {Function} [onOpenResults] (query) => void — ⌘/Ctrl+Enter: every hit, on the consumer's
|
|
91
|
+
* own results surface. Plain Enter keeps its meaning.
|
|
92
|
+
* @param {string} [resultsLabel] the footer's second line for ⌘Enter (e.g. `All results`)
|
|
90
93
|
*/
|
|
91
94
|
export default function ShellSearchOverlay({
|
|
92
95
|
open,
|
|
@@ -107,6 +110,8 @@ export default function ShellSearchOverlay({
|
|
|
107
110
|
onSelect,
|
|
108
111
|
placeholder = 'Search…',
|
|
109
112
|
enterLabel,
|
|
113
|
+
onOpenResults,
|
|
114
|
+
resultsLabel = 'All results',
|
|
110
115
|
}) {
|
|
111
116
|
const panelRef = useRef(null)
|
|
112
117
|
const listRef = useRef(null)
|
|
@@ -160,6 +165,9 @@ export default function ShellSearchOverlay({
|
|
|
160
165
|
setActiveIndex((i) => Math.max(i - 1, 0))
|
|
161
166
|
} else if (e.key === 'Enter') {
|
|
162
167
|
e.preventDefault()
|
|
168
|
+
/* ⌘ENTER OPENS EVERY HIT (user 2026-09-30: *"enter would focus searched item, and maybe
|
|
169
|
+
* command enter would open the results page"*) — only where the consumer has a place for them. */
|
|
170
|
+
if ((e.metaKey || e.ctrlKey) && onOpenResults && query) { onOpenResults(query); onClose?.(); return }
|
|
163
171
|
/* Enter COMMITS the query and expands. It only selects when the user has
|
|
164
172
|
* actually arrowed to a row — `activeIndex` starts at 0, so "a row is
|
|
165
173
|
* highlighted" is true from the first keystroke and testing `active >= 0`
|
|
@@ -256,33 +264,19 @@ export default function ShellSearchOverlay({
|
|
|
256
264
|
{heading && item.group && (
|
|
257
265
|
<p className="kol-helper-12 text-fg-48 px-4 border-x border-transparent pt-3 pb-2">{item.group}</p>
|
|
258
266
|
)}
|
|
259
|
-
<
|
|
267
|
+
<OptionRow
|
|
260
268
|
id={optionId(item)}
|
|
261
269
|
role="option"
|
|
262
270
|
aria-selected={i === active}
|
|
271
|
+
active={i === active}
|
|
272
|
+
icon={item.icon}
|
|
273
|
+
label={<HighlightMatch label={item.label} query={query} ranges={item.highlights} />}
|
|
274
|
+
hint={item.hint}
|
|
263
275
|
/* preventDefault keeps focus in the input through the click */
|
|
264
276
|
onMouseDown={(e) => e.preventDefault()}
|
|
265
277
|
onClick={() => select(item)}
|
|
266
278
|
onMouseEnter={() => { setActiveIndex(i); setNavigated(true) }}
|
|
267
|
-
|
|
268
|
-
className={`flex items-center gap-2 px-4 py-1.5 border border-transparent min-h-[var(--kol-ctl-md)] rounded-[var(--kol-radius-sm)] cursor-pointer kol-mono-14 transition-colors ${
|
|
269
|
-
i === active ? 'bg-[var(--kol-tone-bg,var(--kol-surface-secondary))] text-fg' : 'text-fg-80'
|
|
270
|
-
}`}
|
|
271
|
-
>
|
|
272
|
-
{item.icon && (
|
|
273
|
-
<span aria-hidden="true" className="flex shrink-0 text-oq-48">
|
|
274
|
-
<Icon name={item.icon} size={glyphSize('md')} />
|
|
275
|
-
</span>
|
|
276
|
-
)}
|
|
277
|
-
<span className="flex flex-col min-w-0">
|
|
278
|
-
<span className="truncate">
|
|
279
|
-
<HighlightMatch label={item.label} query={query} ranges={item.highlights} />
|
|
280
|
-
</span>
|
|
281
|
-
{item.hint && (
|
|
282
|
-
<span className="kol-mono-12 text-fg-48 truncate">{item.hint}</span>
|
|
283
|
-
)}
|
|
284
|
-
</span>
|
|
285
|
-
</div>
|
|
279
|
+
/>
|
|
286
280
|
</li>
|
|
287
281
|
)
|
|
288
282
|
})}
|
|
@@ -295,6 +289,12 @@ export default function ShellSearchOverlay({
|
|
|
295
289
|
<Kbd icon="corner-down-left" />
|
|
296
290
|
{query ? enterLabel : 'Go to page'}
|
|
297
291
|
</p>
|
|
292
|
+
{query && onOpenResults && (
|
|
293
|
+
<p className="flex items-center gap-2 kol-helper-12 text-fg-48 mx-2 px-4 border-x border-transparent pt-1">
|
|
294
|
+
<Kbd icon="command"><Icon name="corner-down-left" size={12} /></Kbd>
|
|
295
|
+
{resultsLabel}
|
|
296
|
+
</p>
|
|
297
|
+
)}
|
|
298
298
|
</div>
|
|
299
299
|
)}
|
|
300
300
|
</div>
|