@kolkrabbi/kol-component 0.164.0 → 0.166.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.166.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/hooks/useGrabEdge.js
CHANGED
|
@@ -25,11 +25,26 @@ import gsap from 'gsap'
|
|
|
25
25
|
* than that leaves the pill where it is, which is what stops it twitching.
|
|
26
26
|
*
|
|
27
27
|
* The element needs the `.kol-rail-grab` class for the drawing (kol-animation.css);
|
|
28
|
-
* this hook only supplies `is-near` and
|
|
28
|
+
* this hook only supplies `is-near` and the travel variable.
|
|
29
|
+
*
|
|
30
|
+
* BOTH AXES (BrowsePageRulingsAndSeams, kol-r2b2 2026-09-02). A rail has one
|
|
31
|
+
* vertical edge; `ColumnBrowser` has a vertical handle per column AND a
|
|
32
|
+
* horizontal one along its foot, and the user's ruling is that they wear the
|
|
33
|
+
* same gesture. `axis` says which way the pill TRAVELS, and the near test is
|
|
34
|
+
* always the other one — a vertical line is approached across x, a horizontal
|
|
35
|
+
* line across y:
|
|
36
|
+
*
|
|
37
|
+
* axis: 'y' (default) vertical edge · near on x · travels on y · --kol-rail-grab-y
|
|
38
|
+
* axis: 'x' horizontal edge · near on y · travels on x · --kol-rail-grab-x
|
|
39
|
+
*
|
|
40
|
+
* The `-y` name is kept as it shipped rather than renamed to something
|
|
41
|
+
* axis-neutral: it is a published token the rail's CSS reads, and the pair
|
|
42
|
+
* `-x`/`-y` names two real axes rather than one concept twice.
|
|
29
43
|
*
|
|
30
44
|
* @param {React.RefObject<HTMLElement>} ref the grab handle
|
|
45
|
+
* @param {{axis?: 'x'|'y'}} [options] which way the pill travels (default 'y')
|
|
31
46
|
*/
|
|
32
|
-
export default function useGrabEdge(ref) {
|
|
47
|
+
export default function useGrabEdge(ref, { axis = 'y' } = {}) {
|
|
33
48
|
useEffect(() => {
|
|
34
49
|
let raf = 0
|
|
35
50
|
const onMove = ({ clientX, clientY }) => {
|
|
@@ -39,16 +54,22 @@ export default function useGrabEdge(ref) {
|
|
|
39
54
|
const h = ref.current
|
|
40
55
|
if (!h) return
|
|
41
56
|
const r = h.getBoundingClientRect()
|
|
42
|
-
const
|
|
57
|
+
const vertical = axis === 'y'
|
|
58
|
+
/* the near test is ACROSS the line, the travel ALONG it */
|
|
59
|
+
const across = vertical ? clientX - (r.left + r.width / 2) : clientY - (r.top + r.height / 2)
|
|
60
|
+
const dist = Math.abs(across)
|
|
43
61
|
const near = dist <= GRAB.near || (h.classList.contains('is-near') && dist <= GRAB.sleep)
|
|
44
62
|
h.classList.toggle('is-near', near)
|
|
45
63
|
if (!near) return
|
|
46
|
-
/* the travel band: bipolar from the middle, GRAB.range of the
|
|
47
|
-
const
|
|
48
|
-
const
|
|
64
|
+
/* the travel band: bipolar from the middle, GRAB.range of the span */
|
|
65
|
+
const span = vertical ? r.height : r.width
|
|
66
|
+
const start = vertical ? r.top : r.left
|
|
67
|
+
const point = vertical ? clientY : clientX
|
|
68
|
+
const edge = (span * (1 - GRAB.range)) / 2
|
|
69
|
+
const along = Math.min(Math.max(point - start, edge), span - edge)
|
|
49
70
|
if (h.dataset.grabSeeded && Math.abs(along - Number(h.dataset.grabTarget)) < GRAB.stick) return
|
|
50
71
|
h.dataset.grabTarget = String(along)
|
|
51
|
-
const vars = { '--kol-rail-grab-y': `${along}px` }
|
|
72
|
+
const vars = { [vertical ? '--kol-rail-grab-y' : '--kol-rail-grab-x']: `${along}px` }
|
|
52
73
|
/* the CSS fallback is 50%, a percentage — nothing to tween from, so the
|
|
53
74
|
* first sighting sets and every move after tweens */
|
|
54
75
|
if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...GRAB.travel, overwrite: 'auto' })
|
|
@@ -57,5 +78,5 @@ export default function useGrabEdge(ref) {
|
|
|
57
78
|
}
|
|
58
79
|
window.addEventListener('pointermove', onMove)
|
|
59
80
|
return () => { window.removeEventListener('pointermove', onMove); cancelAnimationFrame(raf) }
|
|
60
|
-
}, [ref])
|
|
81
|
+
}, [ref, axis])
|
|
61
82
|
}
|
|
@@ -3,6 +3,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
3
3
|
import KindPreview from '../molecules/KindPreview.jsx'
|
|
4
4
|
import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
5
5
|
import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
6
|
+
import useGrabEdge from '../hooks/useGrabEdge.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* ColumnBrowser — Finder-style Miller columns over a flat key space (kol-r2b2's
|
|
@@ -24,17 +25,21 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
|
|
|
24
25
|
* `window` (the overlay holds focus) and space closes — the opening keystroke
|
|
25
26
|
* bubbles from here and is ignored.
|
|
26
27
|
*
|
|
27
|
-
* Rows wear the DS Table's cell metrics (12px 16px, mono 12
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Rows wear the DS Table's cell metrics (12px 16px, mono 12) with NO divider —
|
|
29
|
+
* the row is a rounded pill on a constant 4px inset, so selecting never shifts
|
|
30
|
+
* the layout (BrowsePageRulingsAndSeams, 2026-09-02). ONE fill on screen and it
|
|
31
|
+
* means selected: hover and the bare keyboard cursor paint nothing, the trail is
|
|
32
|
+
* `fg-02` and the deepest column holding a selection is `fg-04`.
|
|
30
33
|
* Height defaults to 528px = 12 rows × 44px (user ruling); columns scroll. Both
|
|
31
34
|
* the height and every column's width are DRAGGABLE, Finder-style
|
|
32
35
|
* (ColumnBrowserResize, kol-r2b2 2026-08-27 — user: "column height drag yes and
|
|
33
36
|
* individual column width drag" · "that should be a set in ds"): a strip along
|
|
34
37
|
* the browser's bottom edge (`row-resize`) and one on each column's right edge
|
|
35
38
|
* (`col-resize`, the border already there is the visual — the strip is the hit
|
|
36
|
-
* area
|
|
37
|
-
*
|
|
39
|
+
* area). Each strip carries the estate's GRAB PILL: hidden at rest, woken by
|
|
40
|
+
* pointer PROXIMITY (`useGrabEdge`, the rail's hook) and travelling along the
|
|
41
|
+
* line to meet the cursor. Pointer events with `setPointerCapture`, no library;
|
|
42
|
+
* native CSS `resize:` was rejected.
|
|
38
43
|
*
|
|
39
44
|
* The app-owned bits are SEAMS with working defaults: `urlOf(o)` (the preview
|
|
40
45
|
* image — no URL, no image), `kindOf(o)` (image / video / audio / file from
|
|
@@ -94,11 +99,15 @@ const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'vid
|
|
|
94
99
|
|
|
95
100
|
/* ColumnBrowserChromeCorrections (kol-r2b2 2026-08-28, four user rulings measured live on admin.):
|
|
96
101
|
* every column keeps its right edge, the last included — without it the browser reads as an
|
|
97
|
-
* open-sided container when the columns do not fill the box (Finder closes it);
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* `
|
|
102
|
+
* open-sided container when the columns do not fill the box (Finder closes it); and ONE ink for
|
|
103
|
+
* folders and files — the fill alone marks selection ("why is folder full opacity but not
|
|
104
|
+
* files?"). `muted` stays a prop for other callers; the file rows no longer pass it.
|
|
105
|
+
* NO ROW DIVIDERS (BrowsePageRulingsAndSeams, kol-r2b2 2026-09-02): the `border-b last:border-b-0
|
|
106
|
+
* only:border-b` utilities and their inline `borderColor` are gone. The row is a PILL now — a
|
|
107
|
+
* constant 4px inset on every row so selecting never shifts the layout, radius-sm on the fill —
|
|
108
|
+
* and a hairline under a rounded pill draws the box the pill is trying not to be. The shape lives
|
|
109
|
+
* in kol-components-molecules.css with the fill, not as utilities racing it (ARCHITECTURE §5);
|
|
110
|
+
* this supersedes the `only:` hairline half of the 08-28 corrections, by the same repo's ruling.
|
|
102
111
|
* STATE CLASSES (ColumnBrowserSeams, kol-r2b2 2026-08-28): `is-selected` / `is-cursor` on the row,
|
|
103
112
|
* and the fill LIVES IN THE THEME now (kol-components-molecules.css) instead of a `bg-fg-04`
|
|
104
113
|
* utility — the only hook a consumer had for "selected" was that utility, and a restyle that
|
|
@@ -111,11 +120,10 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
|
|
|
111
120
|
/* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
|
|
112
121
|
* 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last. */
|
|
113
122
|
/* `cursor` = the keyboard row, drawn with the hover fill so ↑/↓ always shows where you are. */
|
|
114
|
-
className={`kol-column-browser-row flex items-center gap-2 px-4 py-3
|
|
123
|
+
className={`kol-column-browser-row flex items-center gap-2 px-4 py-3 cursor-pointer transition-colors${
|
|
115
124
|
active ? ' is-selected' : ''}${cursor ? ' is-cursor' : ''} ${
|
|
116
125
|
active || cursor || !muted ? 'text-fg-default' : 'text-fg-48'
|
|
117
126
|
}`}
|
|
118
|
-
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
119
127
|
onClick={onClick}
|
|
120
128
|
>
|
|
121
129
|
<span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
|
|
@@ -190,12 +198,28 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width
|
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
/* one edge handle: captures the pointer, reports the delta along its axis;
|
|
193
|
-
* `is-dragging` keeps the
|
|
201
|
+
* `is-dragging` keeps the pill lit while the pointer is captured.
|
|
202
|
+
*
|
|
203
|
+
* THE PILL FOLLOWS THE POINTER (BrowsePageRulingsAndSeams, kol-r2b2 2026-09-02,
|
|
204
|
+
* user ruling). It used to sit dead centre and wake on :hover — the theme's own
|
|
205
|
+
* comment said pointer-following "was built and rejected", while the SAME
|
|
206
|
+
* gesture had shipped on the rail's grab edge the same day and has run in
|
|
207
|
+
* kol-r2b2 since. `useGrabEdge` is that implementation; this is the second
|
|
208
|
+
* consumer of it, which is why the hook grew an `axis` rather than a fork.
|
|
209
|
+
*
|
|
210
|
+
* The handle's axis is named for what it RESIZES — `x` drags the column's width
|
|
211
|
+
* — and the pill on it travels the other way, down the vertical edge. So the
|
|
212
|
+
* `x` handle takes `axis: 'y'` and the `y` handle `axis: 'x'`: the hook's axis
|
|
213
|
+
* is the pill's travel, not the drag. Getting this pair backwards is the one
|
|
214
|
+
* easy mistake here. */
|
|
194
215
|
function ResizeHandle({ axis, onDrag, onEnd }) {
|
|
195
216
|
const [dragging, setDragging] = useState(false)
|
|
196
217
|
const start = useRef(null)
|
|
218
|
+
const ref = useRef(null)
|
|
219
|
+
useGrabEdge(ref, { axis: axis === 'x' ? 'y' : 'x' })
|
|
197
220
|
return (
|
|
198
221
|
<div
|
|
222
|
+
ref={ref}
|
|
199
223
|
className={`kol-column-browser-resize-${axis} ${dragging ? 'is-dragging' : ''}`.trim()}
|
|
200
224
|
role="separator"
|
|
201
225
|
aria-orientation={axis === 'x' ? 'vertical' : 'horizontal'}
|
|
@@ -223,13 +223,13 @@ function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downl
|
|
|
223
223
|
/* Display settings for the ACTIVE bucket — kol-r2b2's drawer wiring on the DS
|
|
224
224
|
* organism (the composition the user locked 2026-08-27). Every control sets a
|
|
225
225
|
* default, never a gate. */
|
|
226
|
-
function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profile }) {
|
|
226
|
+
function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profile, settingsFooter }) {
|
|
227
227
|
const set = (patch) => onChange({ ...settings, ...patch })
|
|
228
228
|
const toggleKind = (k) => set({ kinds: settings.kinds.includes(k) ? settings.kinds.filter((x) => x !== k) : [...settings.kinds, k] })
|
|
229
229
|
const noVariants = profile.variantSets === 0
|
|
230
230
|
const noSegments = profile.segments === 0
|
|
231
231
|
return (
|
|
232
|
-
<SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset}
|
|
232
|
+
<SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset}>{settingsFooter}</SettingsFooter>}>
|
|
233
233
|
<LabeledControlSection label="Structure" rowGap={1} divided>
|
|
234
234
|
<SettingsRow label="Columns" hint="Finder-style columns instead of folder rows">
|
|
235
235
|
<SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} />
|
|
@@ -299,7 +299,7 @@ const profileOf = (objects, rawFiles, systemCount) => ({
|
|
|
299
299
|
/* ══ BROWSE — folder / files ═══════════════════════════════════════════════ */
|
|
300
300
|
export function MediaLibraryBrowse({
|
|
301
301
|
client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
|
|
302
|
-
folderTree, headerActions, refreshKey, onOpen, autoFocus = false, className = '',
|
|
302
|
+
folderTree, headerActions, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '',
|
|
303
303
|
}) {
|
|
304
304
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
305
305
|
const prefix = prefixProp ?? ownPrefix
|
|
@@ -399,7 +399,7 @@ export function MediaLibraryBrowse({
|
|
|
399
399
|
onNext={() => setQuickLook((q) => ({ ...q, index: (q.index + 1) % q.files.length }))} />
|
|
400
400
|
)}
|
|
401
401
|
{settingsOpen && (
|
|
402
|
-
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} />
|
|
402
|
+
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
403
403
|
)}
|
|
404
404
|
|
|
405
405
|
{folderView === 'columns' ? (
|
|
@@ -468,7 +468,7 @@ export function MediaLibraryBrowse({
|
|
|
468
468
|
/* ══ LIBRARY — the content-filters wall ═══════════════════════════════════ */
|
|
469
469
|
export function MediaLibraryLibrary({
|
|
470
470
|
client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
|
|
471
|
-
headerActions, refreshKey, header = true, stats = true, className = '',
|
|
471
|
+
headerActions, refreshKey, header = true, stats = true, settingsFooter, className = '',
|
|
472
472
|
}) {
|
|
473
473
|
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
474
474
|
const bucketId = bucket ?? ownBucket
|
|
@@ -633,7 +633,7 @@ export function MediaLibraryLibrary({
|
|
|
633
633
|
onBucket={(v) => { if (v === 'all') return; if (bucket == null) setOwnBucket(v); onBucketChange?.(v) }} onSettings={() => setSettingsOpen(true)} />
|
|
634
634
|
)}
|
|
635
635
|
{settingsOpen && (
|
|
636
|
-
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} />
|
|
636
|
+
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
637
637
|
)}
|
|
638
638
|
|
|
639
639
|
{/* `stats={false}` (ColumnBrowserSeams, kol-r2b2 2026-08-28) — beside `header={false}`: a browse
|
|
@@ -238,12 +238,23 @@ export function SettingsChipRow({ options = [], selected = [], onToggle, allChip
|
|
|
238
238
|
|
|
239
239
|
/** SettingsFooter — a Divider, then one `IconFrame refresh` (primary · sm) at the
|
|
240
240
|
* right = reset to defaults (SettingsPanelApproved, 2026-08-27). No status word,
|
|
241
|
-
* no text button. `customised` is accepted for compatibility and unused.
|
|
242
|
-
|
|
241
|
+
* no text button. `customised` is accepted for compatibility and unused.
|
|
242
|
+
*
|
|
243
|
+
* `children` ride the SAME row, before reset (BrowsePageRulingsAndSeams,
|
|
244
|
+
* kol-r2b2 2026-09-02): a consumer wanting one more control in that footer had
|
|
245
|
+
* no way in, so kol-r2b2 ran a `MutationObserver` on `document.body` watching
|
|
246
|
+
* for `[aria-label="Reset to defaults"]` and portalled a theme chip into its
|
|
247
|
+
* parent — a copy string in a `querySelector`, watching the whole document, to
|
|
248
|
+
* place one button. The slot is the seam that ends it.
|
|
249
|
+
*
|
|
250
|
+
* `gap-2` on the row (user 2026-08-28): `flex justify-end` with no gap is right
|
|
251
|
+
* for one control and wrong for two — the chip and the reset icon touched. */
|
|
252
|
+
export function SettingsFooter({ onReset, resetLabel = 'Reset to defaults', children }) {
|
|
243
253
|
return (
|
|
244
254
|
<div className="flex flex-col gap-4">
|
|
245
255
|
<Divider />
|
|
246
|
-
<div className="flex justify-end">
|
|
256
|
+
<div className="flex justify-end items-center gap-2">
|
|
257
|
+
{children}
|
|
247
258
|
{onReset && <IconFrame name="refresh" variant="primary" size="sm" onClick={onReset} title={resetLabel} aria-label={resetLabel} />}
|
|
248
259
|
</div>
|
|
249
260
|
</div>
|