@kolkrabbi/kol-component 0.165.0 → 0.167.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/atoms/ActionButton.jsx +1 -1
- package/src/atoms/IconFrame.jsx +1 -1
- package/src/atoms/SegmentedToggle.jsx +1 -1
- package/src/hooks/glyphLadders.js +3 -3
- package/src/hooks/useGrabEdge.js +55 -11
- package/src/molecules/SearchInput.jsx +2 -2
- package/src/molecules/ShellDrawer.jsx +6 -7
- package/src/organisms/ColumnBrowser.jsx +33 -6
- package/src/utilities/motion.js +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.167.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",
|
|
@@ -47,7 +47,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
47
47
|
* confirm only fires once it resolves
|
|
48
48
|
* @param {string} href renders an <a> instead of a <button>
|
|
49
49
|
* @param {number} hold ms to hold the confirm state (default 2000)
|
|
50
|
-
* @param {string} size sm | md | lg — the pinned square (
|
|
50
|
+
* @param {string} size xs | sm | md | lg — the pinned square (22/26/32/40)
|
|
51
51
|
* and its SOLO glyph (16/20/24) move together,
|
|
52
52
|
* resolved from hooks/glyphLadders.js. This was
|
|
53
53
|
* a raw px number and defaulted to 16 in a 32px
|
package/src/atoms/IconFrame.jsx
CHANGED
|
@@ -28,7 +28,7 @@ import { toneClass } from '../utilities/tone.js'
|
|
|
28
28
|
* no per-theme props.
|
|
29
29
|
*
|
|
30
30
|
* `size` moves the square and the glyph together on the solo-glyph ladder
|
|
31
|
-
* (16/20/24 against the pinned squares
|
|
31
|
+
* (16/20/24 against the pinned squares 22/26/32/40) — that pairing is the DEFAULT,
|
|
32
32
|
* and it is what every call site should take. `iconSize` unbinds the glyph for
|
|
33
33
|
* the cases the ladder cannot serve, exactly as it does on `Button` and `Input`:
|
|
34
34
|
* a `radius="full"` frame reads heavier than the square it was tuned against (a
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* 'tonal' (filled tiles, but the clicked cell marks itself by
|
|
38
38
|
* TONE — surface-tertiary fill, no ring; 2026-08-12).
|
|
39
39
|
* Every variant renders at the SAME pinned button-ladder
|
|
40
|
-
* height (26/32/40) — icon or text, the box never moves.
|
|
40
|
+
* height (22/26/32/40) — icon or text, the box never moves.
|
|
41
41
|
* size — mirrors Button exactly: 'sm' (26px, mono-12, 4/12 pad) |
|
|
42
42
|
* 'md' (default, 32px, mono-14, 6/16 pad) | 'lg' (40px,
|
|
43
43
|
* mono-16, 8/20 pad). Same cell padding + mono type as the
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The ladders split on ONE question: is there a label beside the glyph?
|
|
9
9
|
*
|
|
10
|
-
* SOLO an icon alone in a pinned square (
|
|
10
|
+
* SOLO an icon alone in a pinned square (22/26/32/40) — takes the room
|
|
11
11
|
* ADJACENT an icon inside a rung's line box, beside a label
|
|
12
12
|
*
|
|
13
13
|
* Stated in the DS before this file existed — ThemeToggle.jsx:40 ("solo
|
|
14
14
|
* 16/20/24, the pinned-square pairing") and IconFrame.jsx ("the solo-glyph
|
|
15
|
-
* ladder against the pinned squares
|
|
15
|
+
* ladder against the pinned squares"). This file is where they stop
|
|
16
16
|
* being transcribed.
|
|
17
17
|
*
|
|
18
18
|
* Why it exists: the same two ladders were re-typed in four places
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* carrying a label) and nothing connected the two.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
/** Icon alone in a pinned square. Pairs with squares
|
|
26
|
+
/** Icon alone in a pinned square. Pairs with squares 22 · 26 · 32 · 40. */
|
|
27
27
|
export const SOLO = { xs: 12, sm: 16, md: 20, lg: 24 }
|
|
28
28
|
|
|
29
29
|
/** Icon beside a label, inside the rung's line box. */
|
package/src/hooks/useGrabEdge.js
CHANGED
|
@@ -25,11 +25,49 @@ 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.
|
|
43
|
+
*
|
|
44
|
+
* TUNING IS PER-EDGE (BrowsePageRulingsAndSeams, 2026-09-02 — user: *"its a bit
|
|
45
|
+
* different, its not like the sidenav"*). The rail's dwell-and-throw is its own
|
|
46
|
+
* ruling, not a house constant every edge inherits: `ColumnBrowser`'s handles
|
|
47
|
+
* were ruled on their own feel in kol-r2b2 — a longer chase and a much smaller
|
|
48
|
+
* retarget threshold, so the pill tracks the pointer closely instead of landing
|
|
49
|
+
* and holding. `GRAB` stays the default, so the rail does not move.
|
|
29
50
|
*
|
|
30
51
|
* @param {React.RefObject<HTMLElement>} ref the grab handle
|
|
52
|
+
* EVERY tuning key the constants carry is an option, so a whole set spreads in
|
|
53
|
+
* (`useGrabEdge(ref, { axis, ...GRAB_COLUMN })`) with nothing silently dropped
|
|
54
|
+
* — a key the hook did not read would be a seam wired to nothing.
|
|
55
|
+
*
|
|
56
|
+
* @param {{axis?: 'x'|'y', near?: number, sleep?: number, travel?: object, stick?: number, range?: number}} [options]
|
|
57
|
+
* `axis` — which way the pill travels (default 'y').
|
|
58
|
+
* `near` / `sleep` — wake and sleep distance from the line, the hysteresis pair.
|
|
59
|
+
* `travel` — the gsap chase (default `GRAB.travel`).
|
|
60
|
+
* `stick` — px the pointer must move before the pill re-targets (default `GRAB.stick`).
|
|
61
|
+
* `range` — the travel band as a fraction of the edge (default `GRAB.range`).
|
|
31
62
|
*/
|
|
32
|
-
export default function useGrabEdge(ref
|
|
63
|
+
export default function useGrabEdge(ref, {
|
|
64
|
+
axis = 'y',
|
|
65
|
+
near: nearAt = GRAB.near,
|
|
66
|
+
sleep = GRAB.sleep,
|
|
67
|
+
travel = GRAB.travel,
|
|
68
|
+
stick = GRAB.stick,
|
|
69
|
+
range = GRAB.range,
|
|
70
|
+
} = {}) {
|
|
33
71
|
useEffect(() => {
|
|
34
72
|
let raf = 0
|
|
35
73
|
const onMove = ({ clientX, clientY }) => {
|
|
@@ -39,23 +77,29 @@ export default function useGrabEdge(ref) {
|
|
|
39
77
|
const h = ref.current
|
|
40
78
|
if (!h) return
|
|
41
79
|
const r = h.getBoundingClientRect()
|
|
42
|
-
const
|
|
43
|
-
|
|
80
|
+
const vertical = axis === 'y'
|
|
81
|
+
/* the near test is ACROSS the line, the travel ALONG it */
|
|
82
|
+
const across = vertical ? clientX - (r.left + r.width / 2) : clientY - (r.top + r.height / 2)
|
|
83
|
+
const dist = Math.abs(across)
|
|
84
|
+
const near = dist <= nearAt || (h.classList.contains('is-near') && dist <= sleep)
|
|
44
85
|
h.classList.toggle('is-near', near)
|
|
45
86
|
if (!near) return
|
|
46
|
-
/* the travel band: bipolar from the middle,
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
87
|
+
/* the travel band: bipolar from the middle, `range` of the span */
|
|
88
|
+
const span = vertical ? r.height : r.width
|
|
89
|
+
const start = vertical ? r.top : r.left
|
|
90
|
+
const point = vertical ? clientY : clientX
|
|
91
|
+
const edge = (span * (1 - range)) / 2
|
|
92
|
+
const along = Math.min(Math.max(point - start, edge), span - edge)
|
|
93
|
+
if (h.dataset.grabSeeded && Math.abs(along - Number(h.dataset.grabTarget)) < stick) return
|
|
50
94
|
h.dataset.grabTarget = String(along)
|
|
51
|
-
const vars = { '--kol-rail-grab-y': `${along}px` }
|
|
95
|
+
const vars = { [vertical ? '--kol-rail-grab-y' : '--kol-rail-grab-x']: `${along}px` }
|
|
52
96
|
/* the CSS fallback is 50%, a percentage — nothing to tween from, so the
|
|
53
97
|
* first sighting sets and every move after tweens */
|
|
54
|
-
if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...
|
|
98
|
+
if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...travel, overwrite: 'auto' })
|
|
55
99
|
else { gsap.set(h, vars); h.dataset.grabSeeded = '1' }
|
|
56
100
|
})
|
|
57
101
|
}
|
|
58
102
|
window.addEventListener('pointermove', onMove)
|
|
59
103
|
return () => { window.removeEventListener('pointermove', onMove); cancelAnimationFrame(raf) }
|
|
60
|
-
}, [ref])
|
|
104
|
+
}, [ref, axis, nearAt, sleep, travel, stick, range])
|
|
61
105
|
}
|
|
@@ -103,7 +103,7 @@ export default function SearchInput({
|
|
|
103
103
|
if (expanding && isOpen) inputRef.current?.focus()
|
|
104
104
|
}, [expanding, isOpen])
|
|
105
105
|
|
|
106
|
-
/* the pinned squares,
|
|
106
|
+
/* the pinned squares, 01-foundations/09-sizes.md — 22 · 26 · 32 · 40 */
|
|
107
107
|
const square = { sm: 28, md: 32, lg: 36 }[size] ?? 32
|
|
108
108
|
/* THE OPEN FIELD'S HEIGHT IS ITS OWN KNOB, defaulting to the square.
|
|
109
109
|
*
|
|
@@ -154,7 +154,7 @@ export default function SearchInput({
|
|
|
154
154
|
collapsed pill rendered as a filled circle next to a bare glyph and
|
|
155
155
|
the pair looked like two different kinds of control. */
|
|
156
156
|
/* THE SQUARE FOLLOWS THE LADDER, and it follows `size` like every other
|
|
157
|
-
control:
|
|
157
|
+
control: 22 · 26 · 32 · 40 (01-foundations/09-sizes.md). This was a
|
|
158
158
|
hardcoded 36 — the LG square — so an expanding search sat beside a
|
|
159
159
|
`kol-btn-md` filter button at two different sizes. */
|
|
160
160
|
className={`kol-expand flex items-center rounded-full ${isOpen ? (toneClass(tone) ? 'kol-tone-sunken' : 'bg-fg-04') : ''} ${className}`.trim()}
|
|
@@ -198,18 +198,17 @@ export default function ShellDrawer({
|
|
|
198
198
|
</button>
|
|
199
199
|
)}
|
|
200
200
|
{header != null && <div className="min-w-0 flex-1">{header}</div>}
|
|
201
|
-
{/*
|
|
202
|
-
*
|
|
203
|
-
* `iconSize`
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
201
|
+
{/* A NORMAL ICON BUTTON (user 2026-09-02: *"it should just be like a
|
|
202
|
+
* normal button with a close icon, its not new?"*). It carried
|
|
203
|
+
* `iconSize={14}` — kept in 2026-08-01 to preserve the glyph size the
|
|
204
|
+
* hand-rolled button before it happened to have — so the box sat at
|
|
205
|
+
* the md rung's 32px around a glyph six under it, and it read as an
|
|
206
|
+
* oversized empty square. No override: the rung sets both. */}
|
|
207
207
|
{closeSide !== 'start' && (
|
|
208
208
|
<Button
|
|
209
209
|
variant="nav"
|
|
210
210
|
size="md"
|
|
211
211
|
iconOnly="x"
|
|
212
|
-
iconSize={14}
|
|
213
212
|
onClick={onClose}
|
|
214
213
|
aria-label="Close"
|
|
215
214
|
className="ml-auto shrink-0"
|
|
@@ -3,6 +3,8 @@ 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'
|
|
7
|
+
import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* ColumnBrowser — Finder-style Miller columns over a flat key space (kol-r2b2's
|
|
@@ -24,17 +26,21 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
|
|
|
24
26
|
* `window` (the overlay holds focus) and space closes — the opening keystroke
|
|
25
27
|
* bubbles from here and is ignored.
|
|
26
28
|
*
|
|
27
|
-
* Rows wear the DS Table's cell metrics (12px 16px, mono 12
|
|
28
|
-
*
|
|
29
|
-
*
|
|
29
|
+
* Rows wear the DS Table's cell metrics (12px 16px, mono 12) with NO divider —
|
|
30
|
+
* the row is a rounded pill on a constant 4px inset, so selecting never shifts
|
|
31
|
+
* the layout (BrowsePageRulingsAndSeams, 2026-09-02). ONE fill on screen and it
|
|
32
|
+
* means selected: hover and the bare keyboard cursor paint nothing, the trail is
|
|
33
|
+
* `fg-02` and the deepest column holding a selection is `fg-04`.
|
|
30
34
|
* Height defaults to 528px = 12 rows × 44px (user ruling); columns scroll. Both
|
|
31
35
|
* the height and every column's width are DRAGGABLE, Finder-style
|
|
32
36
|
* (ColumnBrowserResize, kol-r2b2 2026-08-27 — user: "column height drag yes and
|
|
33
37
|
* individual column width drag" · "that should be a set in ds"): a strip along
|
|
34
38
|
* the browser's bottom edge (`row-resize`) and one on each column's right edge
|
|
35
39
|
* (`col-resize`, the border already there is the visual — the strip is the hit
|
|
36
|
-
* area
|
|
37
|
-
*
|
|
40
|
+
* area). Each strip carries the estate's GRAB PILL: hidden at rest, woken by
|
|
41
|
+
* pointer PROXIMITY (`useGrabEdge`, the rail's hook) and travelling along the
|
|
42
|
+
* line to meet the cursor. Pointer events with `setPointerCapture`, no library;
|
|
43
|
+
* native CSS `resize:` was rejected.
|
|
38
44
|
*
|
|
39
45
|
* The app-owned bits are SEAMS with working defaults: `urlOf(o)` (the preview
|
|
40
46
|
* image — no URL, no image), `kindOf(o)` (image / video / audio / file from
|
|
@@ -193,12 +199,33 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width
|
|
|
193
199
|
}
|
|
194
200
|
|
|
195
201
|
/* one edge handle: captures the pointer, reports the delta along its axis;
|
|
196
|
-
* `is-dragging` keeps the
|
|
202
|
+
* `is-dragging` keeps the pill lit while the pointer is captured.
|
|
203
|
+
*
|
|
204
|
+
* THE PILL FOLLOWS THE POINTER (BrowsePageRulingsAndSeams, kol-r2b2 2026-09-02,
|
|
205
|
+
* user ruling). It used to sit dead centre and wake on :hover — the theme's own
|
|
206
|
+
* comment said pointer-following "was built and rejected", while the SAME
|
|
207
|
+
* gesture had shipped on the rail's grab edge the same day and has run in
|
|
208
|
+
* kol-r2b2 since. `useGrabEdge` is that implementation; this is the second
|
|
209
|
+
* consumer of it, which is why the hook grew an `axis` rather than a fork.
|
|
210
|
+
*
|
|
211
|
+
* The handle's axis is named for what it RESIZES — `x` drags the column's width
|
|
212
|
+
* — and the pill on it travels the other way, down the vertical edge. So the
|
|
213
|
+
* `x` handle takes `axis: 'y'` and the `y` handle `axis: 'x'`: the hook's axis
|
|
214
|
+
* is the pill's travel, not the drag. Getting this pair backwards is the one
|
|
215
|
+
* easy mistake here.
|
|
216
|
+
*
|
|
217
|
+
* `GRAB_COLUMN`, not the rail's `GRAB` (user 2026-09-02: *"its not like the
|
|
218
|
+
* sidenav. the handles are different from the sidenav"*) — a 2.8s chase and a
|
|
219
|
+
* 30px retarget, so the pill tracks the pointer instead of landing and holding
|
|
220
|
+
* on the rail's 90px dwell. */
|
|
197
221
|
function ResizeHandle({ axis, onDrag, onEnd }) {
|
|
198
222
|
const [dragging, setDragging] = useState(false)
|
|
199
223
|
const start = useRef(null)
|
|
224
|
+
const ref = useRef(null)
|
|
225
|
+
useGrabEdge(ref, { axis: axis === 'x' ? 'y' : 'x', ...GRAB_COLUMN })
|
|
200
226
|
return (
|
|
201
227
|
<div
|
|
228
|
+
ref={ref}
|
|
202
229
|
className={`kol-column-browser-resize-${axis} ${dragging ? 'is-dragging' : ''}`.trim()}
|
|
203
230
|
role="separator"
|
|
204
231
|
aria-orientation={axis === 'x' ? 'vertical' : 'horizontal'}
|
package/src/utilities/motion.js
CHANGED
|
@@ -86,3 +86,29 @@ export const GRAB = {
|
|
|
86
86
|
snap: { duration: 0.5, ease: EASE.outGsap },
|
|
87
87
|
slop: 3,
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
/* THE COLUMN BROWSER'S HANDLES ARE NOT THE RAIL'S (BrowsePageRulingsAndSeams,
|
|
91
|
+
* kol-r2b2 2026-09-02 — user: *"its a bit different, its not like the sidenav.
|
|
92
|
+
* the handles are different from the sidenav"*). Same gesture, its own feel,
|
|
93
|
+
* ruled in kol-r2b2 and running there since 2026-08-28:
|
|
94
|
+
*
|
|
95
|
+
* travel 2.8s, not 1.1 — a long, unhurried chase
|
|
96
|
+
* stick 30px, not 90 — a much smaller retarget threshold, so the pill
|
|
97
|
+
* TRACKS the pointer down the edge instead of landing and holding.
|
|
98
|
+
* The dwell that fixed the rail's four-mark grid is not what these
|
|
99
|
+
* want; the deadband exists only so a slow drag does not nudge the
|
|
100
|
+
* target every frame — and it is measured against the last TARGET,
|
|
101
|
+
* not the animated value, because mid-flight the live value is still
|
|
102
|
+
* travelling.
|
|
103
|
+
* range 1 — no bipolar band. The rail chokes its travel so the pill never
|
|
104
|
+
* rides up beside the logomark; there is nothing at these edges to
|
|
105
|
+
* avoid, and the CSS `clamp()` at 2.25rem already stops it flush.
|
|
106
|
+
*
|
|
107
|
+
* `near` / `sleep` are shared — proximity is proximity. The FADE curve differs
|
|
108
|
+
* too and lives with the rule (kol-theme's kol-components-molecules.css). */
|
|
109
|
+
export const GRAB_COLUMN = {
|
|
110
|
+
...GRAB,
|
|
111
|
+
stick: 30,
|
|
112
|
+
range: 1,
|
|
113
|
+
travel: { duration: 2.8, ease: EASE.outGsap },
|
|
114
|
+
}
|