@kolkrabbi/kol-component 0.221.0 → 0.223.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.
|
|
3
|
+
"version": "0.223.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
|
@@ -120,6 +120,7 @@ export { default as Slider } from './molecules/Slider.jsx'
|
|
|
120
120
|
export { default as SpecList } from './molecules/SpecList.jsx'
|
|
121
121
|
export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './organisms/SpectrumControls.jsx'
|
|
122
122
|
export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
|
|
123
|
+
export { default as ToolPalette } from './organisms/ToolPalette.jsx'
|
|
123
124
|
export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
|
|
124
125
|
export { default as TabsRow } from './molecules/TabsRow.jsx'
|
|
125
126
|
|
|
@@ -54,7 +54,14 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
54
54
|
* @param {boolean} props.active - Whether this tool group is the active tool — lit trigger, `aria-pressed`
|
|
55
55
|
* @param {'xs'|'sm'|'md'|'lg'} props.size - Trigger box on the pinned-square ladder — 22 · 26 · 32 · 40 (default: 'md'). The glyph follows the SOLO ladder; it is never set at the call site
|
|
56
56
|
* @param {boolean} props.blurOnClick - Blur the trigger after click so a canvas can reclaim focus and refresh its cursor (default: false)
|
|
57
|
+
* @param {Function} props.onTrigger - Replaces the trigger's arm: fires with the trigger variant's id on every click. For a fold of one-shot ACTIONS (Boolean: the trigger re-runs the last-picked op) — no pressed state is implied (default: arm via `onChange`)
|
|
58
|
+
* @param {boolean} props.disabled - Disables the trigger — `.kol-btn:disabled` dims it (default: false)
|
|
59
|
+
* @param {ElementType} props.iconComponent - Glyph renderer receiving `{ name, size, className, style }` — Button's seam (default: DS `Icon`)
|
|
57
60
|
* @param {string} props.aria-label - Trigger label fallback when no variant resolves
|
|
61
|
+
*
|
|
62
|
+
* A variant may carry its own `onSelect()` — that row does its own thing instead of `onChange`
|
|
63
|
+
* (Text: "Text" arms the tool, "Kinetic type" inserts a layer). Without `lastPicked` the trigger
|
|
64
|
+
* remembers the last row picked from the menu (kol-fxr ToolPalette, editor-panels-the-held-specs A3).
|
|
58
65
|
* @param {string} props.className - Additional classes on the trigger
|
|
59
66
|
*/
|
|
60
67
|
|
|
@@ -77,6 +84,9 @@ const SplitToolButton = ({
|
|
|
77
84
|
active = false,
|
|
78
85
|
size = 'md',
|
|
79
86
|
blurOnClick = false,
|
|
87
|
+
onTrigger,
|
|
88
|
+
disabled = false,
|
|
89
|
+
iconComponent: IconC = Icon,
|
|
80
90
|
className = '',
|
|
81
91
|
'aria-label': ariaLabel,
|
|
82
92
|
}) => {
|
|
@@ -89,20 +99,23 @@ const SplitToolButton = ({
|
|
|
89
99
|
role: 'menu',
|
|
90
100
|
})
|
|
91
101
|
|
|
102
|
+
const [ownLast, setOwnLast] = useState(null)
|
|
92
103
|
const byId = (id) => variants.find((v) => v.id === id)
|
|
93
|
-
const triggerVariant = (active && byId(value)) || byId(lastPicked) || variants[0]
|
|
104
|
+
const triggerVariant = (active && byId(value)) || byId(lastPicked ?? ownLast) || variants[0]
|
|
94
105
|
const label = triggerVariant?.label ?? ariaLabel
|
|
95
106
|
const title = triggerVariant?.shortcut ? `${label} (${triggerVariant.shortcut})` : label
|
|
96
107
|
|
|
97
108
|
/* Arm the last-picked variant on the same click that toggles the menu —
|
|
98
109
|
* floating-ui's useClick (on the reference) handles the open/close half. */
|
|
99
110
|
const handleTriggerClick = (event) => {
|
|
100
|
-
if (
|
|
111
|
+
if (onTrigger) { if (triggerVariant) onTrigger(triggerVariant.id) }
|
|
112
|
+
else if (!active && triggerVariant) onChange?.(triggerVariant.id)
|
|
101
113
|
if (blurOnClick) event.currentTarget.blur()
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
const handleSelect = (variant) => {
|
|
105
|
-
|
|
117
|
+
if (variant.onSelect) variant.onSelect()
|
|
118
|
+
else { onChange?.(variant.id); setOwnLast(variant.id) }
|
|
106
119
|
setOpen(false)
|
|
107
120
|
}
|
|
108
121
|
|
|
@@ -113,11 +126,12 @@ const SplitToolButton = ({
|
|
|
113
126
|
{...popover.getReferenceProps({ onClick: handleTriggerClick })}
|
|
114
127
|
type="button"
|
|
115
128
|
className={`relative kol-btn kol-btn-ghost kol-btn-icon kol-btn-${size} ${active ? 'kol-btn-pressed' : 'kol-btn-quiet'} ${className}`.trim()}
|
|
116
|
-
|
|
129
|
+
disabled={disabled}
|
|
130
|
+
aria-pressed={onTrigger ? undefined : active}
|
|
117
131
|
aria-label={label}
|
|
118
132
|
title={title}
|
|
119
133
|
>
|
|
120
|
-
{triggerVariant && <
|
|
134
|
+
{triggerVariant && <IconC name={triggerVariant.icon} size={glyphSize(size, true)} />}
|
|
121
135
|
<FoldIndicator />
|
|
122
136
|
</button>
|
|
123
137
|
{/* w-max — floats size to content, the menu-family law (2026-08-09). */}
|
|
@@ -137,7 +151,7 @@ const SplitToolButton = ({
|
|
|
137
151
|
className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-body hover:text-emphasis text-left"
|
|
138
152
|
>
|
|
139
153
|
<span className="shrink-0 w-4 inline-flex items-center justify-center">
|
|
140
|
-
<
|
|
154
|
+
<IconC name={variant.icon} size={14} />
|
|
141
155
|
</span>
|
|
142
156
|
<span className="flex-1 truncate">{variant.label}</span>
|
|
143
157
|
<span className="kol-helper-10 text-emphasis shrink-0">
|
|
@@ -63,6 +63,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
63
63
|
* @param {Function} formatDate (isoString) => string — the row/preview date, ISO date-only by default
|
|
64
64
|
* @param {Function} partition (objects, level) => { folders: string[], files: object[] }
|
|
65
65
|
* @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
|
|
66
|
+
* @param {Function} renderDetails (file) => ReactNode — rows under the preview column's facts (tags, an Edit action); absent, nothing is added
|
|
66
67
|
* @param {number|string} height controlled height — px as a number, or ANY CSS length (`'100%'`, `'60vh'`,
|
|
67
68
|
* `'calc(100dvh - 240px)'`) so the browser can fill the space it is given
|
|
68
69
|
* (kol-client-olina 2026-09-22); a CSS length draws no height grabber;
|
|
@@ -260,10 +261,16 @@ function Row({
|
|
|
260
261
|
onDragStart: drop ? (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) } : undefined,
|
|
261
262
|
onDragOver: (e) => { if (takesFiles(e) || (drop && !isFileDrag(e) && drop.canDrop(drop.path))) { e.preventDefault(); setOver(true) } },
|
|
262
263
|
onDragLeave: () => setOver(false),
|
|
264
|
+
/* A ROW THAT CANNOT TAKE THE DROP LETS IT BUBBLE (the file-onto-file bug, 2026-09-25): the
|
|
265
|
+
* enclosing column / list allows the drag, so the drop still fires HERE, and moving into a
|
|
266
|
+
* FILE's key renamed the dragged file. Unguarded, it skipped `canDrop`; now the container
|
|
267
|
+
* takes it — dropped on a file, it lands in that file's folder, as Finder does. */
|
|
263
268
|
onDrop: (e) => {
|
|
264
|
-
|
|
265
|
-
if (takesFiles(e)) dropFiles(e.dataTransfer.files)
|
|
266
|
-
|
|
269
|
+
setOver(false)
|
|
270
|
+
if (takesFiles(e)) { e.preventDefault(); e.stopPropagation(); dropFiles(e.dataTransfer.files); return }
|
|
271
|
+
if (!drop || isFileDrag(e) || !drop.canDrop(drop.path)) return
|
|
272
|
+
e.preventDefault(); e.stopPropagation()
|
|
273
|
+
drop.onDrop(drop.path)
|
|
267
274
|
},
|
|
268
275
|
} : {}
|
|
269
276
|
return (
|
|
@@ -350,7 +357,7 @@ function Row({
|
|
|
350
357
|
)
|
|
351
358
|
}
|
|
352
359
|
|
|
353
|
-
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, width }) {
|
|
360
|
+
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, renderDetails, width }) {
|
|
354
361
|
// Pixel size and length come from the loaded media itself — the bucket stores
|
|
355
362
|
// none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
|
|
356
363
|
// `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
|
|
@@ -408,6 +415,14 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
|
|
|
408
415
|
</div>
|
|
409
416
|
))}
|
|
410
417
|
</dl>
|
|
418
|
+
{/* `renderDetails(o)` — the consumer's rows under the facts (the media D1 pass: tags, Edit) */}
|
|
419
|
+
{/* its own island: a click or a drag in here is not a click on the browser's background,
|
|
420
|
+
which deselects (a tag field would unpick the file it edits) */}
|
|
421
|
+
{renderDetails && (
|
|
422
|
+
<div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
423
|
+
{renderDetails(o)}
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
411
426
|
</div>
|
|
412
427
|
)
|
|
413
428
|
}
|
|
@@ -474,6 +489,9 @@ export default function ColumnBrowser({
|
|
|
474
489
|
formatDate = defaultFormatDate,
|
|
475
490
|
partition = defaultPartition,
|
|
476
491
|
renderPreview,
|
|
492
|
+
/* `renderDetails(file)` — extra rows under the preview column's facts (tags, an Edit action);
|
|
493
|
+
* absent, the column is exactly what it was */
|
|
494
|
+
renderDetails,
|
|
477
495
|
height,
|
|
478
496
|
defaultHeight = 528,
|
|
479
497
|
onHeightChange,
|
|
@@ -648,6 +666,9 @@ export default function ColumnBrowser({
|
|
|
648
666
|
|
|
649
667
|
const shiftAnchor = useRef(null)
|
|
650
668
|
const onKeyDown = (e) => {
|
|
669
|
+
/* typing in a field inside the browser (the preview's tag field) is not navigation */
|
|
670
|
+
const el = e.target
|
|
671
|
+
if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
|
|
651
672
|
/* ⌘↑ / ⌘↓ (user 2026-09-23: *"all modes should 'command up down' to move up and down parent
|
|
652
673
|
* child"*) — Finder's: ⌘↑ is ←, back to the enclosing folder; ⌘↓ opens what the cursor is on,
|
|
653
674
|
* → for a folder and Quick Look for a file. */
|
|
@@ -1029,7 +1050,7 @@ export default function ColumnBrowser({
|
|
|
1029
1050
|
</>
|
|
1030
1051
|
) : shown && (
|
|
1031
1052
|
<>
|
|
1032
|
-
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
1053
|
+
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} renderDetails={renderDetails} width={widthOf('preview')} />
|
|
1033
1054
|
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
1034
1055
|
</>
|
|
1035
1056
|
)}
|
|
@@ -4,6 +4,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
5
5
|
import Divider from '../atoms/Divider.jsx'
|
|
6
6
|
import Input from '../atoms/Input.jsx'
|
|
7
|
+
import Tag from '../atoms/Tag.jsx'
|
|
7
8
|
import IconFrame from '../atoms/IconFrame.jsx'
|
|
8
9
|
import ActionButton from '../atoms/ActionButton.jsx'
|
|
9
10
|
import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
|
|
@@ -66,6 +67,18 @@ import { nearestRatio } from '../utilities/ratios.js'
|
|
|
66
67
|
* `client.buckets()` (kol-media-client ≥0.2.0); a client with none is one bucket
|
|
67
68
|
* and no dropdown. Settings are kol-r2b2's per-bucket model (`SETTINGS_BASE`),
|
|
68
69
|
* controlled through `settings` / `onSettingsChange` so a consumer persists them.
|
|
70
|
+
*
|
|
71
|
+
* WHAT A DATABASE BESIDE THE BUCKET ADDS (the media D1 pass, 2026-09-25 — kol-olina keeps these
|
|
72
|
+
* in Cloudflare D1). Every verb is optional; a client without one hides that feature and nothing
|
|
73
|
+
* else changes. Keys are the page's own (bucket keys), `bucket` is the bucket id.
|
|
74
|
+
* tags listed objects carry `tags: string[]`; `setTags(key, tags, bucket)` writes them —
|
|
75
|
+
* preview-pane chips, "Tags…" / "Add tags to N…" in the menu, a Tags filter
|
|
76
|
+
* group, a Tags column under Fields
|
|
77
|
+
* text editing `readText(key, bucket) → { text, draft }` · `writeText(key, text, bucket)` ·
|
|
78
|
+
* `saveDraft(key, text | null, bucket)`; listed objects carry `hasDraft` — Edit in
|
|
79
|
+
* the pane and in Quick Look for markdown · json · yaml · text · code
|
|
80
|
+
* settings `loadSettings(bucket)` · `saveSettings(bucket, settings | null)` (null = reset) —
|
|
81
|
+
* used when the page is UNCONTROLLED; a `settings` prop still wins
|
|
69
82
|
*/
|
|
70
83
|
|
|
71
84
|
/* THE ROW VIEW'S COLUMNS (user 2026-09-22). OFF by default and `rowColumns` turns them on: with
|
|
@@ -165,11 +178,32 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
|
|
|
165
178
|
const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
|
|
166
179
|
const bucketMeta = buckets.find((b) => b.id === bucket) ?? buckets[0] ?? { id: null, label: '', writable: false }
|
|
167
180
|
const bucketId = bucketMeta.id
|
|
168
|
-
const
|
|
181
|
+
const base = () => ({ ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) })
|
|
182
|
+
const [own, setOwn] = useState(base)
|
|
183
|
+
/* PERSONALISATION (the media D1 pass, 2026-09-25): an uncontrolled page whose client offers
|
|
184
|
+
* `loadSettings(bucket)` / `saveSettings(bucket, settings | null)` keeps each person's view
|
|
185
|
+
* settings THERE — kol-olina's D1 — instead of for this tab only. `null` is reset. A `settings`
|
|
186
|
+
* prop still wins: a consumer that owns them is left alone, exactly as before. */
|
|
187
|
+
const remote = !controlled && typeof client?.loadSettings === 'function'
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!remote) return undefined
|
|
190
|
+
let live = true
|
|
191
|
+
Promise.resolve(client.loadSettings(bucketId ?? undefined))
|
|
192
|
+
.then((got) => { if (live) setOwn({ ...base(), ...(got ?? {}) }) })
|
|
193
|
+
.catch(() => {})
|
|
194
|
+
return () => { live = false }
|
|
195
|
+
}, [remote, client, bucketId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
169
196
|
const settings = controlled ?? own
|
|
170
197
|
const setSettings = (next) => {
|
|
171
|
-
if (next === null) {
|
|
198
|
+
if (next === null) {
|
|
199
|
+
const back = base()
|
|
200
|
+
if (!controlled) setOwn(back)
|
|
201
|
+
if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, null)).then((got) => got && setOwn({ ...back, ...got })).catch(() => {})
|
|
202
|
+
onSettingsChange?.(back)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
172
205
|
if (!controlled) setOwn(next)
|
|
206
|
+
if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, next)).catch(() => {})
|
|
173
207
|
onSettingsChange?.(next)
|
|
174
208
|
}
|
|
175
209
|
const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
|
|
@@ -213,6 +247,30 @@ function ImageFrame({ src, vector }) {
|
|
|
213
247
|
}
|
|
214
248
|
|
|
215
249
|
|
|
250
|
+
/* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
|
|
251
|
+
* one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
|
|
252
|
+
* without it, or on a read-only bucket, the chips show and nothing edits them. */
|
|
253
|
+
function TagEditor({ tags = [], onChange }) {
|
|
254
|
+
const add = (raw) => {
|
|
255
|
+
const next = raw.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
256
|
+
if (next.length) onChange([...new Set([...tags, ...next])])
|
|
257
|
+
}
|
|
258
|
+
if (!onChange && !tags.length) return null
|
|
259
|
+
return (
|
|
260
|
+
<div className="flex flex-col gap-2">
|
|
261
|
+
<span className="kol-mono-12 text-fg-48">Tags</span>
|
|
262
|
+
{tags.length > 0 && (
|
|
263
|
+
<div className="flex flex-wrap gap-1">
|
|
264
|
+
{tags.map((t) => (
|
|
265
|
+
<Tag key={t} variant="secondary" size="sm" onRemove={onChange ? () => onChange(tags.filter((x) => x !== t)) : undefined}>{t}</Tag>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
{onChange && <Input size="xs" value="" placeholder="Add a tag" aria-label="Add a tag" onCommit={add} />}
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
216
274
|
/* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
|
|
217
275
|
* disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
|
|
218
276
|
* that navigates — so nothing that already renders these moves. */
|
|
@@ -232,10 +290,16 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
|
|
|
232
290
|
const dropProps = drag || dropFiles ? {
|
|
233
291
|
onDragOver: (e) => { if (takesFiles(e) || (drag && !isFileDrag(e) && drag.canDrop(drag.path))) { e.preventDefault(); setOver(true) } },
|
|
234
292
|
onDragLeave: () => setOver(false),
|
|
293
|
+
/* A ROW THAT CANNOT TAKE THE DROP LETS IT BUBBLE (the file-onto-file bug, 2026-09-25): the
|
|
294
|
+
* enclosing column / list allows the drag, so the drop still fires HERE, and moving into a
|
|
295
|
+
* FILE's key renamed the dragged file. Unguarded, it skipped `canDrop`; now the container
|
|
296
|
+
* takes it — dropped on a file, it lands in that file's folder, as Finder does. */
|
|
235
297
|
onDrop: (e) => {
|
|
236
|
-
|
|
237
|
-
if (takesFiles(e)) dropFiles(e.dataTransfer.files)
|
|
238
|
-
|
|
298
|
+
setOver(false)
|
|
299
|
+
if (takesFiles(e)) { e.preventDefault(); e.stopPropagation(); dropFiles(e.dataTransfer.files); return }
|
|
300
|
+
if (!drag || isFileDrag(e) || !drag.canDrop(drag.path)) return
|
|
301
|
+
e.preventDefault(); e.stopPropagation()
|
|
302
|
+
drag.onDrop(drag.path)
|
|
239
303
|
},
|
|
240
304
|
} : {}
|
|
241
305
|
return (
|
|
@@ -264,6 +328,7 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
|
|
|
264
328
|
line up or the header above them is a lie. The trailing chevron went with them: it
|
|
265
329
|
promised a click that opens, and a click now SELECTS (opening is a double-click). */}
|
|
266
330
|
{cols && <>
|
|
331
|
+
{cols.tags != null && <span className="shrink-0" style={{ width: cols.tags }} />}
|
|
267
332
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{meta}</span>
|
|
268
333
|
<span className="kol-mono-12 text-fg-24 shrink-0 truncate" style={{ width: cols.size }}>—</span>
|
|
269
334
|
</>}
|
|
@@ -340,6 +405,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
340
405
|
</span>
|
|
341
406
|
<span className="flex-1" />
|
|
342
407
|
{cols && <>
|
|
408
|
+
{cols.tags != null && <span className="kol-mono-12 text-fg-48 shrink-0 truncate" style={{ width: cols.tags }}>{(o.tags ?? []).map((t) => `#${t}`).join(' ')}</span>}
|
|
343
409
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
|
|
344
410
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
|
|
345
411
|
</>}
|
|
@@ -451,12 +517,112 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
|
|
|
451
517
|
<span className="w-4 shrink-0" aria-hidden="true" />
|
|
452
518
|
<span className="w-8 shrink-0" aria-hidden="true" />
|
|
453
519
|
<span className="flex-1 min-w-0">{label('name', 'Name')}</span>
|
|
520
|
+
{/* Tags does not sort — a file has several — so its header is a plain label */}
|
|
521
|
+
{cols.tags != null && (
|
|
522
|
+
<div className="relative shrink-0 border-l" style={{ width: cols.tags, borderColor: 'var(--kol-oq-08)' }}>
|
|
523
|
+
{handle('tags')}
|
|
524
|
+
<span className="kol-helper-12 text-fg-48">Tags</span>
|
|
525
|
+
</div>
|
|
526
|
+
)}
|
|
454
527
|
{column('date', 'Date')}
|
|
455
528
|
{column('size', 'Size')}
|
|
456
529
|
</div>
|
|
457
530
|
)
|
|
458
531
|
}
|
|
459
532
|
|
|
533
|
+
/* THE KINDS THAT OPEN AS TEXT — what `FileEditor` takes (the media D1 pass, 2026-09-25) */
|
|
534
|
+
const TEXT_KINDS = new Set(['markdown', 'json', 'yaml', 'text', 'code'])
|
|
535
|
+
|
|
536
|
+
/* FILE EDITING (the media D1 pass, 2026-09-25) — Quick Look's window with the text in it. Every
|
|
537
|
+
* pause writes a DRAFT through `client.saveDraft` (kol-olina keeps it in D1, beside the file), so
|
|
538
|
+
* closing the window or the tab loses nothing; ⌘S or Save writes the FILE (`client.writeText`) and
|
|
539
|
+
* clears the draft; Revert drops the draft and goes back to the file. The file itself changes only
|
|
540
|
+
* on Save — a draft is never what anyone else sees. */
|
|
541
|
+
const DRAFT_PAUSE = 800
|
|
542
|
+
function FileEditor({ o, client, bucket, onClose, onSaved, onDraft }) {
|
|
543
|
+
const [base, setBase] = useState(null) // the saved file's text; null while loading
|
|
544
|
+
const [text, setText] = useState('')
|
|
545
|
+
const [status, setStatus] = useState('loading') // loading · saved · draft · editing · saving · error
|
|
546
|
+
const timer = useRef(null)
|
|
547
|
+
const latest = useRef('')
|
|
548
|
+
const baseRef = useRef(null) // `base` for the unmount flush, which would otherwise see the first render's
|
|
549
|
+
const keep = (b) => { baseRef.current = b; setBase(b) }
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
let live = true
|
|
552
|
+
client.readText(o.key, bucket).then(({ text: file, draft }) => {
|
|
553
|
+
if (!live) return
|
|
554
|
+
keep(file ?? '')
|
|
555
|
+
setText(draft ?? file ?? '')
|
|
556
|
+
latest.current = draft ?? file ?? ''
|
|
557
|
+
setStatus(draft != null ? 'draft' : 'saved')
|
|
558
|
+
}).catch((e) => live && setStatus(`error: ${e.message}`))
|
|
559
|
+
return () => { live = false }
|
|
560
|
+
}, [o.key]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
561
|
+
const flushDraft = () => {
|
|
562
|
+
if (timer.current == null) return undefined
|
|
563
|
+
clearTimeout(timer.current); timer.current = null
|
|
564
|
+
const t = latest.current
|
|
565
|
+
const clean = t === baseRef.current
|
|
566
|
+
return Promise.resolve(client.saveDraft?.(o.key, clean ? null : t, bucket))
|
|
567
|
+
.then(() => { setStatus(clean ? 'saved' : 'draft'); onDraft?.(o.key, !clean) })
|
|
568
|
+
.catch(() => {})
|
|
569
|
+
}
|
|
570
|
+
useEffect(() => () => { flushDraft() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
571
|
+
const edit = (t) => {
|
|
572
|
+
setText(t); latest.current = t; setStatus('editing')
|
|
573
|
+
clearTimeout(timer.current)
|
|
574
|
+
timer.current = setTimeout(flushDraft, DRAFT_PAUSE)
|
|
575
|
+
}
|
|
576
|
+
const save = async () => {
|
|
577
|
+
clearTimeout(timer.current); timer.current = null
|
|
578
|
+
setStatus('saving')
|
|
579
|
+
try {
|
|
580
|
+
const res = await client.writeText(o.key, latest.current, bucket)
|
|
581
|
+
keep(latest.current); setStatus('saved')
|
|
582
|
+
onSaved?.(o.key, res)
|
|
583
|
+
} catch (e) { setStatus(`error: ${e.message}`) }
|
|
584
|
+
}
|
|
585
|
+
const revert = async () => {
|
|
586
|
+
clearTimeout(timer.current); timer.current = null
|
|
587
|
+
await client.saveDraft?.(o.key, null, bucket)
|
|
588
|
+
setText(baseRef.current ?? ''); latest.current = baseRef.current ?? ''; setStatus('saved')
|
|
589
|
+
onSaved?.(o.key, null)
|
|
590
|
+
}
|
|
591
|
+
const dirty = base != null && text !== base
|
|
592
|
+
/* ⌘S anywhere while the editor is open — not only with the caret in the text */
|
|
593
|
+
const saveRef = useRef(null)
|
|
594
|
+
saveRef.current = () => { if (baseRef.current != null && latest.current !== baseRef.current) save() }
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveRef.current() } }
|
|
597
|
+
window.addEventListener('keydown', onKey)
|
|
598
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
599
|
+
}, [])
|
|
600
|
+
const label = { loading: 'Loading…', saved: 'Saved', draft: 'Draft — not saved to the file', editing: 'Editing…', saving: 'Saving…' }[status] ?? status
|
|
601
|
+
return (
|
|
602
|
+
<FullscreenOverlay open onClose={() => { flushDraft(); onClose() }} closeButton={false} scrim>
|
|
603
|
+
<QuickLookFrame title={o.displayKey ?? o.key.split('/').pop()} meta={label} onClose={() => { flushDraft(); onClose() }}
|
|
604
|
+
actions={(
|
|
605
|
+
<span className="flex items-center gap-2">
|
|
606
|
+
{dirty && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
|
|
607
|
+
<Button variant="secondary" size="sm" onClick={save} disabled={!dirty || status === 'saving'}>Save</Button>
|
|
608
|
+
</span>
|
|
609
|
+
)}>
|
|
610
|
+
<textarea
|
|
611
|
+
value={text}
|
|
612
|
+
onChange={(e) => edit(e.target.value)}
|
|
613
|
+
disabled={base == null}
|
|
614
|
+
spellCheck={false}
|
|
615
|
+
aria-label={`Edit ${o.key.split('/').pop()}`}
|
|
616
|
+
className="kol-mono-12 block bg-transparent text-fg-default p-4 resize-none outline-none"
|
|
617
|
+
/* inline: Tailwind does not generate arbitrary values from package source (see the SVG box
|
|
618
|
+
in MediaInspector) — the window's own caps */
|
|
619
|
+
style={{ width: 'min(900px, var(--kol-ql-max-w))', height: 'min(640px, var(--kol-ql-media-h))' }}
|
|
620
|
+
/>
|
|
621
|
+
</QuickLookFrame>
|
|
622
|
+
</FullscreenOverlay>
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
|
|
460
626
|
/**
|
|
461
627
|
* MediaInspector — the full-screen viewer for one file in a set: image, video
|
|
462
628
|
* (scrubbable, audio on), audio, or a document page, in one `QuickLookFrame`, with
|
|
@@ -482,8 +648,9 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
|
|
|
482
648
|
* @param {Function} downloadUrl - `(key) => url` for the download control
|
|
483
649
|
* @param {Set|Object} keySet - The bucket's key set, used to find a video's poster
|
|
484
650
|
* @param {boolean} showNav - ‹ › and n / N in the header (default on); off when not paging a selection
|
|
651
|
+
* @param {Function} onEdit - `(file) => void` — an Edit action in the header for a text file (markdown · json · yaml · text · code); absent, no Edit
|
|
485
652
|
*/
|
|
486
|
-
export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true }) {
|
|
653
|
+
export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true, onEdit }) {
|
|
487
654
|
const o = files[index]
|
|
488
655
|
const [dims, setDims] = useState(null)
|
|
489
656
|
/* the window's size survives paging — Finder keeps it — so it lives here, not in the frame,
|
|
@@ -522,6 +689,11 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
522
689
|
* the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
|
|
523
690
|
actions: (
|
|
524
691
|
<span className="flex items-center gap-1">
|
|
692
|
+
{onEdit && TEXT_KINDS.has(kind) && (
|
|
693
|
+
<Tooltip label="Edit">
|
|
694
|
+
<Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
|
|
695
|
+
</Tooltip>
|
|
696
|
+
)}
|
|
525
697
|
<CopyAction label="Copy URL" text={mediaUrl(o.key)} />
|
|
526
698
|
<Tooltip label="Download">
|
|
527
699
|
<Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
|
|
@@ -976,6 +1148,49 @@ export function MediaLibraryBrowse({
|
|
|
976
1148
|
const press = useLongPress()
|
|
977
1149
|
const modal = useModal()
|
|
978
1150
|
const canWrite = !!fileActions && writable
|
|
1151
|
+
/* TAGS ARE THE CLIENT'S (the media D1 pass, 2026-09-25) — `client.setTags(key, tags, bucket)`
|
|
1152
|
+
* writes them, the listing carries them back as `o.tags`. The list is patched in place so a chip
|
|
1153
|
+
* lands at once; the next listing confirms it. */
|
|
1154
|
+
const canTag = writable && typeof client?.setTags === 'function'
|
|
1155
|
+
const saveTags = (key, tags) => runAction(async () => {
|
|
1156
|
+
await client.setTags(key, tags, bucketMeta.id ?? undefined)
|
|
1157
|
+
lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, tags } : o)))
|
|
1158
|
+
setPickedFile((f) => (f?.key === key ? { ...f, tags } : f))
|
|
1159
|
+
})
|
|
1160
|
+
const tagsOf = (key) => objects.find((o) => o.key === key)?.tags ?? []
|
|
1161
|
+
/* EDITING IS THE CLIENT'S TOO — `readText` / `writeText`, with `saveDraft` for the pauses */
|
|
1162
|
+
const canEdit = writable && typeof client?.readText === 'function' && typeof client?.writeText === 'function'
|
|
1163
|
+
const [editing, setEditing] = useState(null)
|
|
1164
|
+
const afterEdit = (key, res) => {
|
|
1165
|
+
lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, hasDraft: false, ...(res?.size != null && { size: res.size, uploaded: new Date().toISOString() }) } : o)))
|
|
1166
|
+
}
|
|
1167
|
+
const hasTags = canTag || objects.some((o) => o.tags?.length)
|
|
1168
|
+
/* what the preview pane adds under a file's facts, in every view */
|
|
1169
|
+
const detailsFor = (o) => {
|
|
1170
|
+
const tags = tagsOf(o.key)
|
|
1171
|
+
const editable = canEdit && TEXT_KINDS.has(kindOf(o))
|
|
1172
|
+
const hasDraft = objects.find((x) => x.key === o.key)?.hasDraft
|
|
1173
|
+
if (!editable && !canTag && !tags.length) return null
|
|
1174
|
+
return (
|
|
1175
|
+
<div className="flex flex-col gap-4">
|
|
1176
|
+
{editable && (
|
|
1177
|
+
<div className="flex items-center gap-3">
|
|
1178
|
+
<Button variant="secondary" size="sm" iconLeft="edit" onClick={() => setEditing(o)}>Edit</Button>
|
|
1179
|
+
{hasDraft && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
|
|
1180
|
+
</div>
|
|
1181
|
+
)}
|
|
1182
|
+
{(canTag || tags.length > 0) && <TagEditor tags={tags} onChange={canTag ? (next) => saveTags(o.key, next) : undefined} />}
|
|
1183
|
+
</div>
|
|
1184
|
+
)
|
|
1185
|
+
}
|
|
1186
|
+
const doTags = async (keys) => {
|
|
1187
|
+
const one = keys.length === 1
|
|
1188
|
+
const current = one ? tagsOf(keys[0]).join(', ') : ''
|
|
1189
|
+
const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} files, separated by commas:`, current, { okLabel: 'Save' })
|
|
1190
|
+
if (answer == null) return
|
|
1191
|
+
const typed = answer.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
1192
|
+
for (const key of keys) await saveTags(key, one ? typed : [...new Set([...tagsOf(key), ...typed])])
|
|
1193
|
+
}
|
|
979
1194
|
const [busyAction, setBusyAction] = useState(false)
|
|
980
1195
|
|
|
981
1196
|
const runAction = async (fn) => {
|
|
@@ -1437,7 +1652,7 @@ export function MediaLibraryBrowse({
|
|
|
1437
1652
|
const showRowPreview = folderView === 'rows' && (settings.rowPreview ?? true)
|
|
1438
1653
|
const showGridPreview = view === 'grid' && (settings.rowPreview ?? true)
|
|
1439
1654
|
/* OFF by default: a row is a name, and the facts live in the preview (user 2026-09-22) */
|
|
1440
|
-
const rowCols = settings.rowColumns ? rowColWidths(settings) : null
|
|
1655
|
+
const rowCols = settings.rowColumns ? { ...rowColWidths(settings), ...(hasTags && { tags: settings.rowColumnWidths?.tags ?? 140 }) } : null
|
|
1441
1656
|
const previewWidth = settings.columnWidths?.preview ?? 320
|
|
1442
1657
|
const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
|
|
1443
1658
|
const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
|
|
@@ -1459,6 +1674,7 @@ export function MediaLibraryBrowse({
|
|
|
1459
1674
|
) : previewFile ? (
|
|
1460
1675
|
<ColumnPreview key={previewFile.key} o={previewFile} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} kindLabel={KIND_LABEL}
|
|
1461
1676
|
formatSize={formatSize} formatDate={formatDate} width={previewWidth}
|
|
1677
|
+
renderDetails={detailsFor}
|
|
1462
1678
|
renderPreview={(o) => {
|
|
1463
1679
|
if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} />
|
|
1464
1680
|
const poster = posterFor(o.key, keySet)
|
|
@@ -1580,6 +1796,7 @@ export function MediaLibraryBrowse({
|
|
|
1580
1796
|
* serves both: the wall renders it, the tree is scoped to the keys in it */
|
|
1581
1797
|
const filterItems = isWall ? wallFiles : scoped.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o), displayKey: prefix ? o.key.slice(prefix.length) : o.key }))
|
|
1582
1798
|
const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
|
|
1799
|
+
const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
|
|
1583
1800
|
|
|
1584
1801
|
/* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
|
|
1585
1802
|
* `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
|
|
@@ -1652,6 +1869,7 @@ export function MediaLibraryBrowse({
|
|
|
1652
1869
|
/* the PROP wins when a consumer passes one; otherwise the `···`
|
|
1653
1870
|
owns it through settings (item 15) */
|
|
1654
1871
|
stackView={stackView ?? settings.stackView ?? 'list'}
|
|
1872
|
+
renderDetails={(o) => detailsFor({ ...o, key: o.key.slice(VROOT.length) })}
|
|
1655
1873
|
renderPreview={(o) => {
|
|
1656
1874
|
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
1657
1875
|
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} />
|
|
@@ -2079,8 +2297,13 @@ export function MediaLibraryBrowse({
|
|
|
2079
2297
|
)}
|
|
2080
2298
|
{quickLook && (
|
|
2081
2299
|
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
|
|
2300
|
+
onEdit={canEdit ? (f) => { closeQuickLook(); setEditing(f) } : undefined}
|
|
2082
2301
|
onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
|
|
2083
2302
|
)}
|
|
2303
|
+
{editing && (
|
|
2304
|
+
<FileEditor o={editing} client={client} bucket={bucketMeta.id ?? undefined} onClose={() => setEditing(null)} onSaved={afterEdit}
|
|
2305
|
+
onDraft={(key, has) => lib.setObjects((list) => list.map((x) => (x.key === key ? { ...x, hasDraft: has } : x)))} />
|
|
2306
|
+
)}
|
|
2084
2307
|
{settingsOpen && (
|
|
2085
2308
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
2086
2309
|
)}
|
|
@@ -2097,7 +2320,7 @@ export function MediaLibraryBrowse({
|
|
|
2097
2320
|
title="Files"
|
|
2098
2321
|
totalCount={filterItems.length}
|
|
2099
2322
|
searchKeys={['displayKey']}
|
|
2100
|
-
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }]}
|
|
2323
|
+
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : [])]}
|
|
2101
2324
|
mutuallyExclusiveFilters={['kind']}
|
|
2102
2325
|
/* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
|
|
2103
2326
|
* its panel, and the bar's own funnel closes the bar — the same switch from either
|
|
@@ -2179,6 +2402,7 @@ export function MediaLibraryBrowse({
|
|
|
2179
2402
|
<>
|
|
2180
2403
|
<MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl(many)}>Copy {many.length} URLs</MenuDropdownItem>
|
|
2181
2404
|
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload(many)}>Download {many.length}</MenuDropdownItem>
|
|
2405
|
+
{canTag && <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags(many)}>Add tags to {many.length}…</MenuDropdownItem>}
|
|
2182
2406
|
</>
|
|
2183
2407
|
)}
|
|
2184
2408
|
{fileActions.remove && (
|
|
@@ -2222,6 +2446,9 @@ export function MediaLibraryBrowse({
|
|
|
2222
2446
|
{target.type === 'file' && (
|
|
2223
2447
|
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
|
|
2224
2448
|
)}
|
|
2449
|
+
{target.type === 'file' && canTag && (
|
|
2450
|
+
<MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags([target.path])}>Tags…</MenuDropdownItem>
|
|
2451
|
+
)}
|
|
2225
2452
|
{!isLevel && fileActions.remove && (
|
|
2226
2453
|
<>
|
|
2227
2454
|
<MenuDropdownDivider />
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
+
import Button from '../atoms/Button.jsx'
|
|
3
|
+
import Divider from '../atoms/Divider.jsx'
|
|
4
|
+
import SplitToolButton from '../molecules/SplitToolButton.jsx'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ToolPalette — the editor's tool bar as one row (kol-fxr `shell/panels/ToolPalette.jsx`,
|
|
8
|
+
* editor-panels-the-held-specs A3, 2026-09-25). Four kinds of cell, all on one pinned-square rung:
|
|
9
|
+
*
|
|
10
|
+
* - `tool` — arms a mode; lit while `activeId` is its id
|
|
11
|
+
* - `action` — a one-shot operation (flip, rotate, duplicate)
|
|
12
|
+
* - `split` — a fold of variants: `SplitToolButton`. A fold of TOOLS arms (Shape); a fold with
|
|
13
|
+
* `action: true` runs its variants instead, the trigger re-running the last-picked one
|
|
14
|
+
* (Boolean); a variant with `action: true` inside a tool fold is a one-shot row (Text →
|
|
15
|
+
* Kinetic type)
|
|
16
|
+
* - `divider` — a hairline between groups
|
|
17
|
+
*
|
|
18
|
+
* The store is the consumer's (the source read `useTool` + `useComposeState` directly): what
|
|
19
|
+
* is armed, what each action does and **when an item is disabled** — needs a selection, needs
|
|
20
|
+
* two booleanable layers — all arrive in `items` and are never inferred here.
|
|
21
|
+
*
|
|
22
|
+
* `min-w-0 overflow-x-auto`: at a narrow width the row scrolls in its own box instead of
|
|
23
|
+
* painting over the rail beside it, as the source learned.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} props
|
|
26
|
+
* @param {Array<{kind: 'tool'|'action'|'split'|'divider', id?: string, icon?: string, label?: string, shortcut?: string, disabled?: boolean, action?: boolean, variants?: Array<{id: string, label: string, icon: string, shortcut?: string, action?: boolean}>}>} props.items - The row, left to right
|
|
27
|
+
* @param {string} props.activeId - The armed tool's id — a `tool` item's, or any variant's of a tool fold
|
|
28
|
+
* @param {Function} props.onSelect - (id) => void — a tool (or a tool variant) was armed
|
|
29
|
+
* @param {Function} props.onAction - (id) => void — an action (or an action variant) was run
|
|
30
|
+
* @param {'xs'|'sm'|'md'|'lg'} props.size - The rung — 22 · 26 · 32 · 40 (default: 'md', the source's 32)
|
|
31
|
+
* @param {ElementType} props.iconComponent - Glyph renderer receiving `{ name, size, className, style }` (default: DS `Icon`)
|
|
32
|
+
* @param {string} props.className - Additional classes on the row
|
|
33
|
+
*/
|
|
34
|
+
const blur = (e) => e.currentTarget.blur()
|
|
35
|
+
|
|
36
|
+
const ToolPalette = ({
|
|
37
|
+
items = [],
|
|
38
|
+
activeId,
|
|
39
|
+
onSelect,
|
|
40
|
+
onAction,
|
|
41
|
+
size = 'md',
|
|
42
|
+
iconComponent = Icon,
|
|
43
|
+
className = '',
|
|
44
|
+
}) => (
|
|
45
|
+
<div className={`flex items-center gap-1 min-w-0 overflow-x-auto ${className}`.trim()}>
|
|
46
|
+
{items.map((it, i) => {
|
|
47
|
+
if (it.kind === 'divider') return <Divider key={`d${i}`} variant="vertical" height={20} className="mx-1.5 shrink-0" />
|
|
48
|
+
const tip = it.shortcut ? `${it.label} (${it.shortcut})` : it.label
|
|
49
|
+
if (it.kind === 'split') {
|
|
50
|
+
const variants = (it.variants ?? []).map((v) => (v.action && !it.action ? { ...v, onSelect: () => onAction?.(v.id) } : v))
|
|
51
|
+
const run = (id) => (it.action ? onAction : onSelect)?.(id)
|
|
52
|
+
return (
|
|
53
|
+
<SplitToolButton key={it.id} variants={variants} value={activeId} size={size} iconComponent={iconComponent}
|
|
54
|
+
active={!it.action && variants.some((v) => v.id === activeId && !v.action)}
|
|
55
|
+
onChange={run} onTrigger={it.action ? run : undefined}
|
|
56
|
+
disabled={it.disabled} blurOnClick aria-label={it.label} />
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
const isTool = it.kind === 'tool'
|
|
60
|
+
return (
|
|
61
|
+
<Button key={it.id} variant="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
|
|
62
|
+
pressed={isTool ? activeId === it.id : undefined} disabled={it.disabled}
|
|
63
|
+
aria-label={it.label} title={tip}
|
|
64
|
+
onClick={(e) => { (isTool ? onSelect : onAction)?.(it.id); blur(e) }} />
|
|
65
|
+
)
|
|
66
|
+
})}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
export default ToolPalette
|