@kolkrabbi/kol-component 0.230.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 +2 -2
- package/src/atoms/Kbd.jsx +33 -0
- package/src/index.js +2 -0
- package/src/molecules/FieldRow.jsx +11 -10
- package/src/molecules/OptionRow.jsx +82 -0
- package/src/molecules/SearchInput.jsx +10 -9
- 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 +82 -38
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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
40
|
+
"@kolkrabbi/kol-icons": "^0.31.0"
|
|
41
41
|
},
|
|
42
42
|
"files": [
|
|
43
43
|
"src",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kbd — a key cap: one key or chord (↵, ⌘K, Esc) shown as an affordance.
|
|
3
|
+
* Replaces the two hand-rolled <kbd> chips (SearchInput's shortcut hint and
|
|
4
|
+
* the palette footer) that had drifted apart (2026-09-30). A plate, so it
|
|
5
|
+
* takes oq — never fg (icons-use-oq law).
|
|
6
|
+
*
|
|
7
|
+
* aria-hidden by default: a cap is a hint beside a label, not the label.
|
|
8
|
+
*
|
|
9
|
+
* @param {'sm'|'md'} size sm = inside a control (16px, helper-10) ·
|
|
10
|
+
* md = beside helper-12 text (20px)
|
|
11
|
+
* @param {string} icon kol-icons name drawn before the children — a key
|
|
12
|
+
* with a symbol (↵ `corner-down-left`, ⌘ `command`)
|
|
13
|
+
* is a GLYPH, never a typed character in the mono face
|
|
14
|
+
*/
|
|
15
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
16
|
+
|
|
17
|
+
const SIZE = {
|
|
18
|
+
sm: 'h-4 min-w-4 px-1 kol-helper-10',
|
|
19
|
+
md: 'h-5 min-w-5 px-1.5 kol-helper-12',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function Kbd({ size = 'md', icon, className = '', children, ...rest }) {
|
|
23
|
+
return (
|
|
24
|
+
<kbd
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
className={`inline-flex items-center justify-center gap-0.5 shrink-0 rounded-[var(--kol-radius-xs)] bg-oq-08 text-oq-64 ${SIZE[size] ?? SIZE.md} ${className}`.trim()}
|
|
27
|
+
{...rest}
|
|
28
|
+
>
|
|
29
|
+
{icon && <Icon name={icon} size={12} />}
|
|
30
|
+
{children}
|
|
31
|
+
</kbd>
|
|
32
|
+
)
|
|
33
|
+
}
|
package/src/index.js
CHANGED
|
@@ -59,6 +59,8 @@ export { default as SectionText } from './molecules/SectionText.jsx'
|
|
|
59
59
|
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
|
+
export { default as Kbd } from './atoms/Kbd.jsx'
|
|
63
|
+
export { default as OptionRow } from './molecules/OptionRow.jsx'
|
|
62
64
|
export { default as Tag } from './atoms/Tag.jsx'
|
|
63
65
|
export { default as Textarea } from './atoms/Textarea.jsx'
|
|
64
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
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import { toneClass } from '../utilities/tone.js'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
4
|
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
5
|
+
import Kbd from '../atoms/Kbd.jsx'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* SearchInput — controlled search field on the .kol-control shell. The
|
|
@@ -200,8 +201,10 @@ export default function SearchInput({
|
|
|
200
201
|
const shellCls = [
|
|
201
202
|
bare
|
|
202
203
|
/* kol-control--bare: zero-chrome marker so the theme's coarse-pointer
|
|
203
|
-
* 16px floor covers this body plan too (OverlaySearchFieldZoomsIOS)
|
|
204
|
-
|
|
204
|
+
* 16px floor covers this body plan too (OverlaySearchFieldZoomsIOS).
|
|
205
|
+
* Chrome off, geometry on: the size's padding + a transparent 1px ring
|
|
206
|
+
* land it on the 22 · 26 · 32 · 40 ladder like the shell (09-sizes) */
|
|
207
|
+
? `kol-control--bare kol-control-${size} flex w-full border border-transparent gap-2`
|
|
205
208
|
: `kol-control${variant ? ` kol-control--${variant}` : ''} kol-control-${size} gap-2${toneClass(tone) ? ` ${toneClass(tone)}` : ''}`,
|
|
206
209
|
'items-center cursor-text',
|
|
207
210
|
SIZE_TYPE[size],
|
|
@@ -225,7 +228,7 @@ export default function SearchInput({
|
|
|
225
228
|
<label className={shellCls}>
|
|
226
229
|
<span
|
|
227
230
|
aria-hidden="true"
|
|
228
|
-
className={`flex items-center shrink-0 ${bare ? 'text-
|
|
231
|
+
className={`flex items-center shrink-0 ${bare ? 'text-oq-48' : 'text-auto opacity-50'}`}
|
|
229
232
|
>
|
|
230
233
|
{/* ADJACENT — this glyph sits in the field's line box beside the query */}
|
|
231
234
|
<Icon name="search" size={iconSize ?? glyphSize(size)} />
|
|
@@ -254,12 +257,10 @@ export default function SearchInput({
|
|
|
254
257
|
</button>
|
|
255
258
|
) : shortcutHint ? (
|
|
256
259
|
/* aria-hidden — affordance, not a label (same stance as Input's prefix/suffix) */
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
{shortcutHint}
|
|
262
|
-
</kbd>
|
|
260
|
+
/* ⌘ is drawn, not typed — the mono face's ⌘ is the "drawn by a child" cap */
|
|
261
|
+
shortcutHint.startsWith('⌘')
|
|
262
|
+
? <Kbd size="sm" icon="command">{shortcutHint.slice(1)}</Kbd>
|
|
263
|
+
: <Kbd size="sm">{shortcutHint}</Kbd>
|
|
263
264
|
) : null}
|
|
264
265
|
</label>
|
|
265
266
|
)
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { useEffect, useId, useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
3
|
import SearchInput from '../molecules/SearchInput.jsx'
|
|
3
4
|
import Tag from '../atoms/Tag.jsx'
|
|
5
|
+
import Kbd from '../atoms/Kbd.jsx'
|
|
6
|
+
import OptionRow from '../molecules/OptionRow.jsx'
|
|
7
|
+
|
|
8
|
+
/* ONE COLUMN (2026-09-30): rows, headings and footer sit on the field's own
|
|
9
|
+
* geometry — the p-2 inset, then the md control's 1px ring + 16px pad — so
|
|
10
|
+
* every glyph and word lines up under the field's icon and caret. */
|
|
11
|
+
|
|
12
|
+
/* Rows bucketed by `group` in first-seen order, rank kept inside a group —
|
|
13
|
+
* one heading per group; arrows rove this order, not the engine's. */
|
|
14
|
+
function groupRows(rows) {
|
|
15
|
+
const order = []
|
|
16
|
+
const byGroup = new Map()
|
|
17
|
+
rows.forEach((r) => {
|
|
18
|
+
const g = r.group ?? ''
|
|
19
|
+
if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g) }
|
|
20
|
+
byGroup.get(g).push(r)
|
|
21
|
+
})
|
|
22
|
+
return order.flatMap((g) => byGroup.get(g))
|
|
23
|
+
}
|
|
4
24
|
|
|
5
25
|
/**
|
|
6
26
|
* HighlightMatch — default row renderer: underlines the first
|
|
@@ -56,7 +76,9 @@ export function HighlightMatch({ label, query, ranges }) {
|
|
|
56
76
|
*
|
|
57
77
|
* @param {boolean} open mount/unmount the overlay
|
|
58
78
|
* @param {Function} onClose () => void — backdrop click, Escape, post-select
|
|
59
|
-
* @param {Array} results pre-filtered rows: { id, label, group?, hint? }
|
|
79
|
+
* @param {Array} results pre-filtered rows: { id, label, group?, hint?, icon? }
|
|
80
|
+
* @param {Array} [suggestions] rows shown while the query is empty (same shape) —
|
|
81
|
+
* the palette opens on somewhere to go, not a blank box
|
|
60
82
|
* @param {string} query controlled query (drives the highlight slice)
|
|
61
83
|
* @param {Function} onQueryChange (string) => void — input change
|
|
62
84
|
* @param {Function} onSelect (item) => void — row click / Enter; consumer navigates
|
|
@@ -65,11 +87,15 @@ export function HighlightMatch({ label, query, ranges }) {
|
|
|
65
87
|
* the panel's last line, so Enter is never a surprise
|
|
66
88
|
* (e.g. `All results for “q”`). Rows may carry
|
|
67
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`)
|
|
68
93
|
*/
|
|
69
94
|
export default function ShellSearchOverlay({
|
|
70
95
|
open,
|
|
71
96
|
onClose,
|
|
72
|
-
results = [],
|
|
97
|
+
results: rawResults = [],
|
|
98
|
+
suggestions = [],
|
|
73
99
|
/* EXPANDED — the palette's second state (user ruling 2026-08-01). Enter
|
|
74
100
|
* commits the query and opens `children` as the results body; the palette
|
|
75
101
|
* and the old tag overlay are one surface with two states, not two
|
|
@@ -84,6 +110,8 @@ export default function ShellSearchOverlay({
|
|
|
84
110
|
onSelect,
|
|
85
111
|
placeholder = 'Search…',
|
|
86
112
|
enterLabel,
|
|
113
|
+
onOpenResults,
|
|
114
|
+
resultsLabel = 'All results',
|
|
87
115
|
}) {
|
|
88
116
|
const panelRef = useRef(null)
|
|
89
117
|
const listRef = useRef(null)
|
|
@@ -92,6 +120,7 @@ export default function ShellSearchOverlay({
|
|
|
92
120
|
/* Has the user actually chosen a row? See the Enter branch — without this,
|
|
93
121
|
* index 0 counts as a selection and Enter navigates somewhere unasked. */
|
|
94
122
|
const [navigated, setNavigated] = useState(false)
|
|
123
|
+
const results = groupRows(query ? rawResults : suggestions)
|
|
95
124
|
const active = results.length > 0 ? Math.min(activeIndex, results.length - 1) : -1
|
|
96
125
|
|
|
97
126
|
/* Focus in on open, restore the opener on close. querySelector instead of
|
|
@@ -110,7 +139,7 @@ export default function ShellSearchOverlay({
|
|
|
110
139
|
/* Keep the active row visible inside the scrolling list. */
|
|
111
140
|
useEffect(() => {
|
|
112
141
|
if (active < 0) return
|
|
113
|
-
listRef.current?.
|
|
142
|
+
listRef.current?.querySelectorAll('[role="option"]')[active]?.scrollIntoView({ block: 'nearest' })
|
|
114
143
|
}, [active])
|
|
115
144
|
|
|
116
145
|
if (!open) return null
|
|
@@ -136,12 +165,16 @@ export default function ShellSearchOverlay({
|
|
|
136
165
|
setActiveIndex((i) => Math.max(i - 1, 0))
|
|
137
166
|
} else if (e.key === 'Enter') {
|
|
138
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 }
|
|
139
171
|
/* Enter COMMITS the query and expands. It only selects when the user has
|
|
140
172
|
* actually arrowed to a row — `activeIndex` starts at 0, so "a row is
|
|
141
173
|
* highlighted" is true from the first keystroke and testing `active >= 0`
|
|
142
174
|
* made Enter navigate to whatever happened to be first. Committing a
|
|
143
175
|
* query must never be a navigation you didn't choose. */
|
|
144
|
-
|
|
176
|
+
/* An empty query has nothing to commit — Enter goes to the top suggestion. */
|
|
177
|
+
if (active >= 0 && (navigated || !query)) select(results[active])
|
|
145
178
|
else onExpand?.()
|
|
146
179
|
} else if (e.key === 'Tab') {
|
|
147
180
|
/* Focus trap — the input is the palette's only tab stop. */
|
|
@@ -182,8 +215,11 @@ export default function ShellSearchOverlay({
|
|
|
182
215
|
))}
|
|
183
216
|
</div>
|
|
184
217
|
)}
|
|
218
|
+
{/* A REAL FIELD, inset in the panel (2026-09-30, shadcn's palette as
|
|
219
|
+
* the aim) — the flush `bare` strip read as a hole, not a control. */}
|
|
220
|
+
<div className="kol-tone-grey p-2">
|
|
185
221
|
<SearchInput
|
|
186
|
-
|
|
222
|
+
className="w-full"
|
|
187
223
|
value={query}
|
|
188
224
|
onChange={(e) => onQueryChange?.(e.target.value)}
|
|
189
225
|
placeholder={chips.length > 0 ? 'Narrow these results…' : placeholder}
|
|
@@ -193,6 +229,7 @@ export default function ShellSearchOverlay({
|
|
|
193
229
|
aria-controls={listId}
|
|
194
230
|
aria-activedescendant={active >= 0 ? optionId(results[active]) : undefined}
|
|
195
231
|
/>
|
|
232
|
+
</div>
|
|
196
233
|
|
|
197
234
|
{/* WHY THIS IS NOT `molecules/Dropdown` (asked 2026-08-01). Dropdown is
|
|
198
235
|
* a SELECT: a trigger, a `value`, `onChange(value)`, and rows that are
|
|
@@ -204,7 +241,8 @@ export default function ShellSearchOverlay({
|
|
|
204
241
|
*
|
|
205
242
|
* THE ROW CONTRACT (was documented nowhere):
|
|
206
243
|
* label the row's text, match-highlighted against the query
|
|
207
|
-
* group
|
|
244
|
+
* group section heading the row files under — 'Atoms', 'Documentation', 'Tags'
|
|
245
|
+
* icon optional leading glyph (kol-icons name)
|
|
208
246
|
* hint subtext shown when the LABEL was not what matched
|
|
209
247
|
* href a destination; dismisses the palette
|
|
210
248
|
* action a closure; runs and KEEPS the palette open (tag rows)
|
|
@@ -212,46 +250,52 @@ export default function ShellSearchOverlay({
|
|
|
212
250
|
{expanded ? (
|
|
213
251
|
<div className="border-t border-fg-08 max-h-[70vh] overflow-y-auto">{children}</div>
|
|
214
252
|
) : results.length > 0 && (
|
|
253
|
+
/* FIXED BODY HEIGHT — the panel holds still while the list narrows. */
|
|
215
254
|
<ul
|
|
216
255
|
ref={listRef}
|
|
217
256
|
id={listId}
|
|
218
257
|
role="listbox"
|
|
219
|
-
className="
|
|
258
|
+
className="kol-tone-grey h-80 overflow-y-auto px-2 pb-2"
|
|
220
259
|
>
|
|
221
|
-
{results.map((item, i) =>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
/* preventDefault keeps focus in the input through the click */
|
|
228
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
229
|
-
onClick={() => select(item)}
|
|
230
|
-
onMouseEnter={() => { setActiveIndex(i); setNavigated(true) }}
|
|
231
|
-
className={`flex items-center gap-2 px-4 py-1.5 cursor-pointer kol-mono-14 transition-colors ${
|
|
232
|
-
i === active ? 'bg-fg-08 text-fg' : 'text-fg-80'
|
|
233
|
-
}`}
|
|
234
|
-
>
|
|
235
|
-
<span className="flex flex-col min-w-0">
|
|
236
|
-
<span className="truncate">
|
|
237
|
-
<HighlightMatch label={item.label} query={query} ranges={item.highlights} />
|
|
238
|
-
</span>
|
|
239
|
-
{item.hint && (
|
|
240
|
-
<span className="kol-mono-12 text-fg-48 truncate">{item.hint}</span>
|
|
260
|
+
{results.map((item, i) => {
|
|
261
|
+
const heading = (item.group ?? '') !== (results[i - 1]?.group ?? '') || i === 0
|
|
262
|
+
return (
|
|
263
|
+
<li key={item.id} role="presentation">
|
|
264
|
+
{heading && item.group && (
|
|
265
|
+
<p className="kol-helper-12 text-fg-48 px-4 border-x border-transparent pt-3 pb-2">{item.group}</p>
|
|
241
266
|
)}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
267
|
+
<OptionRow
|
|
268
|
+
id={optionId(item)}
|
|
269
|
+
role="option"
|
|
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}
|
|
275
|
+
/* preventDefault keeps focus in the input through the click */
|
|
276
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
277
|
+
onClick={() => select(item)}
|
|
278
|
+
onMouseEnter={() => { setActiveIndex(i); setNavigated(true) }}
|
|
279
|
+
/>
|
|
280
|
+
</li>
|
|
281
|
+
)
|
|
282
|
+
})}
|
|
248
283
|
</ul>
|
|
249
284
|
)}
|
|
250
|
-
{
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
285
|
+
{/* THE FOOTER SAYS WHAT ENTER DOES — always, not only once typing. */}
|
|
286
|
+
{!expanded && ((!query && results.length > 0) || (query && enterLabel)) && (
|
|
287
|
+
<div className="border-t border-fg-08 py-2">
|
|
288
|
+
<p className="flex items-center gap-2 kol-helper-12 text-fg-48 mx-2 px-4 border-x border-transparent">
|
|
289
|
+
<Kbd icon="corner-down-left" />
|
|
290
|
+
{query ? enterLabel : 'Go to page'}
|
|
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
|
+
</div>
|
|
255
299
|
)}
|
|
256
300
|
</div>
|
|
257
301
|
</div>
|