@kolkrabbi/kol-component 0.192.0 → 0.194.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.194.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",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useMediaQuery — subscribe to a media query, SSR-safe.
|
|
5
|
+
*
|
|
6
|
+
* The general form of `useCoarsePointer` and `usePrefersReducedMotion`, which
|
|
7
|
+
* are each this hook with one query frozen in. It exists because a STRUCTURAL
|
|
8
|
+
* responsive fork cannot be a stylesheet: `ColumnBrowser` renders Miller
|
|
9
|
+
* columns on a desktop and a single inline-expanding list on a phone
|
|
10
|
+
* (`ColumnBrowserStackMode`, kol-r2b2 2026-09-03), and those are different
|
|
11
|
+
* TREES, not one tree with different padding. A component that only needs
|
|
12
|
+
* different sizes still uses Tailwind's breakpoints — reach for this when the
|
|
13
|
+
* markup itself has to change.
|
|
14
|
+
*
|
|
15
|
+
* Returns false during SSR and on the first client render if the query cannot
|
|
16
|
+
* be evaluated, so a server render and its hydration agree.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} query a media query, e.g. '(max-width: 767px)'
|
|
19
|
+
* @returns {boolean} whether it currently matches
|
|
20
|
+
*/
|
|
21
|
+
export default function useMediaQuery(query) {
|
|
22
|
+
const [matches, setMatches] = useState(
|
|
23
|
+
() => typeof window !== 'undefined' && window.matchMedia(query).matches,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (typeof window === 'undefined') return
|
|
28
|
+
const mq = window.matchMedia(query)
|
|
29
|
+
const onChange = () => setMatches(mq.matches)
|
|
30
|
+
setMatches(mq.matches)
|
|
31
|
+
mq.addEventListener('change', onChange)
|
|
32
|
+
return () => mq.removeEventListener('change', onChange)
|
|
33
|
+
}, [query])
|
|
34
|
+
|
|
35
|
+
return matches
|
|
36
|
+
}
|
package/src/index.js
CHANGED
|
@@ -193,6 +193,10 @@ export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
|
|
|
193
193
|
export { default as useInViewAttention } from './hooks/useInViewAttention.js'
|
|
194
194
|
export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
|
|
195
195
|
export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
196
|
+
/* The general form of `useCoarsePointer` / `usePrefersReducedMotion` — for a
|
|
197
|
+
* STRUCTURAL responsive fork, where the markup itself changes and a stylesheet
|
|
198
|
+
* cannot express it (ColumnBrowserStackMode, 2026-09-03). Sizes stay Tailwind's. */
|
|
199
|
+
export { default as useMediaQuery } from './hooks/useMediaQuery.js'
|
|
196
200
|
/* The rail gesture's other half. It lived in kol-framework until 2026-09-03 and
|
|
197
201
|
* moved here for the same reason `useGrabEdge` did: kol-component's own
|
|
198
202
|
* `EditorShell` needs resizable rails and cannot import framework. framework
|
|
@@ -77,6 +77,11 @@ const Dropdown = ({
|
|
|
77
77
|
* while a row is hovered would otherwise leave the consumer previewing
|
|
78
78
|
* forever. It never fires while closed — a closed dropdown has no rows. */
|
|
79
79
|
onOptionHover,
|
|
80
|
+
/* OPTIONS may carry more than `{ value, label }`: `icon` puts a glyph in the
|
|
81
|
+
* row's line box (ADJACENT ladder — it sits beside a label), and `shortcut`
|
|
82
|
+
* prints on every row that is NOT the current one, the tick taking that slot
|
|
83
|
+
* when it is. Both were added for the tool-palette idiom and are general —
|
|
84
|
+
* a menu of tools and a menu of anything else want the same two columns. */
|
|
80
85
|
/* ICON-ONLY TRIGGER — an icon name, or a pre-rendered node dropped in where
|
|
81
86
|
* the glyph goes. The trigger becomes the pinned square (`kol-btn-icon`) at
|
|
82
87
|
* the current size, with no label, no ghost widths and no caret; the panel
|
|
@@ -259,8 +264,25 @@ const Dropdown = ({
|
|
|
259
264
|
onPointerEnter={onOptionHover ? () => reportHover(option.value) : undefined}
|
|
260
265
|
onPointerLeave={onOptionHover ? () => reportHover(null) : undefined}
|
|
261
266
|
onClick={() => handleSelect(option)}
|
|
262
|
-
|
|
267
|
+
/* The active row's mark is the CHECK, always — it is what says
|
|
268
|
+
* which value is current, and nothing may take that slot from
|
|
269
|
+
* it. An option's own `shortcut` shows on the rows that are
|
|
270
|
+
* not current, which is the tool-palette idiom: the keystroke
|
|
271
|
+
* that would arm this variant, replaced by the tick once it
|
|
272
|
+
* is armed (editor-set-is-behind-its-source, 2026-09-03). */
|
|
273
|
+
shortcut={
|
|
274
|
+
isActive
|
|
275
|
+
? <Icon name="check" size={resolvedSize === 'xs' ? indicatorSize('xs') : 11} />
|
|
276
|
+
: option.shortcut
|
|
277
|
+
}
|
|
263
278
|
>
|
|
279
|
+
{/* an option's leading glyph — the ADJACENT ladder, because it
|
|
280
|
+
* sits in a line box beside a label, never the solo rung */}
|
|
281
|
+
{option.icon && (
|
|
282
|
+
<span className="shrink-0 inline-flex items-center" style={{ marginRight: 'var(--kol-spacing-2)' }}>
|
|
283
|
+
<Icon name={option.icon} size={glyphSize(resolvedSize)} />
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
264
286
|
{option.label}
|
|
265
287
|
</MenuDropdownItem>
|
|
266
288
|
)
|
|
@@ -30,6 +30,22 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
30
30
|
* For a text-trigger single-select use `Dropdown`; for the two-button
|
|
31
31
|
* action-half + chevron-half split use `ShapeDropdown`.
|
|
32
32
|
*
|
|
33
|
+
* WHY THIS IS STILL ITS OWN COMPONENT, after `Dropdown` grew the icon-only
|
|
34
|
+
* square trigger it was asked to (2026-09-03, `editor-set-is-behind-its-
|
|
35
|
+
* source`). The ticket's premise — *"it hand-rolls a `<button>` re-emitting
|
|
36
|
+
* Button's classes"* — is gone: since 0.182.0 this wears
|
|
37
|
+
* `kol-btn-icon kol-btn-{size}`, the same class output at the same rungs, so
|
|
38
|
+
* there is no second implementation of the box left to collapse. What remains
|
|
39
|
+
* is ONE behaviour Dropdown does not have and should not grow for one caller:
|
|
40
|
+
* a click on the trigger both ARMS the last-picked variant and opens the menu,
|
|
41
|
+
* so a tool is selected and re-pickable in one gesture. Dropdown is a
|
|
42
|
+
* controlled select — its trigger opens, it does not choose — and giving it an
|
|
43
|
+
* `onTriggerClick` seam to serve this would be a seam for exactly one consumer.
|
|
44
|
+
* Two components, one class output, one popover utility, one glyph ladder: the
|
|
45
|
+
* duplication the ticket named is closed, and the difference that is left is
|
|
46
|
+
* real. What DID move to Dropdown is the part that generalises — per-option
|
|
47
|
+
* `icon` and `shortcut` rows, which any menu wants.
|
|
48
|
+
*
|
|
33
49
|
* @param {Object} props
|
|
34
50
|
* @param {{id: string, label: string, icon: string, shortcut?: string}[]} props.variants - Variants: menu rows + trigger glyph
|
|
35
51
|
* @param {string} props.value - Active variant id (controlled)
|
|
@@ -4,6 +4,7 @@ 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
6
|
import useGrabEdge from '../hooks/useGrabEdge.js'
|
|
7
|
+
import useMediaQuery from '../hooks/useMediaQuery.js'
|
|
7
8
|
import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -69,6 +70,17 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
69
70
|
* any column it does not name falls back to the drag state, then `columnWidth`
|
|
70
71
|
* @param {Function} onColumnResize (index, px) => void — the column's index, or `'preview'`
|
|
71
72
|
* @param {string} className extra classes on the browser
|
|
73
|
+
*
|
|
74
|
+
* BELOW `md` (768) THIS IS A DIFFERENT TREE — one full-width inline-expanding
|
|
75
|
+
* list, not columns (`ColumnBrowserStackMode`, kol-r2b2 2026-09-03, user-ruled
|
|
76
|
+
* inline expand over push). `height` / `defaultHeight` / `onHeightChange`,
|
|
77
|
+
* `columnWidth` / `columnWidths` / `onColumnResize` and the resize handles are
|
|
78
|
+
* all DESKTOP-ONLY and inert there: a stored height is a value someone dragged
|
|
79
|
+
* on a desktop, and the list takes the viewport instead. A file tap fires
|
|
80
|
+
* `onQuickLook` rather than opening the preview column, so the consumer's
|
|
81
|
+
* existing full-screen inspector is the phone's preview. Everything else —
|
|
82
|
+
* `prefix`/`onPrefix`, `onPick`, the seams, the keyboard on a device that has
|
|
83
|
+
* one — is unchanged.
|
|
72
84
|
*/
|
|
73
85
|
|
|
74
86
|
/* the DS kinds (mediaKinds — kol-r2b2's classification, promoted 2026-08-27) */
|
|
@@ -115,7 +127,14 @@ const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'vid
|
|
|
115
127
|
* changes it breaks every rule hanging off it silently (twice already in kol-r2b2). It also lets the
|
|
116
128
|
* theme say the user's ruling — "ONLY one selected state can exist, not TWO": the selected row in
|
|
117
129
|
* the deepest column that holds one is full strength, every column on the way there is the trail. */
|
|
118
|
-
|
|
130
|
+
/* `indent` and `meta` serve the STACK mode and are inert without it: the
|
|
131
|
+
* columns pass neither, so a desktop row is byte-identical to what it was. */
|
|
132
|
+
/* Three levels of indent, then the list re-bases and the back control takes
|
|
133
|
+
* over — the user's cap (ColumnBrowserStackMode, 2026-09-03): a deep bucket
|
|
134
|
+
* path indents until the name has no room. */
|
|
135
|
+
const INDENT_CAP = 3
|
|
136
|
+
|
|
137
|
+
function Row({ icon, label, active, cursor = false, trailing, onClick, muted = false, indent = 0, meta }) {
|
|
119
138
|
return (
|
|
120
139
|
<li
|
|
121
140
|
/* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
|
|
@@ -126,11 +145,24 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
|
|
|
126
145
|
active || cursor || !muted ? 'text-fg-default' : 'text-fg-48'
|
|
127
146
|
}`}
|
|
128
147
|
onClick={onClick}
|
|
148
|
+
/* the indent is a PADDING, not a nested list: one flat <ul> keeps the
|
|
149
|
+
* rows one scroll box and one keyboard sequence, which a tree of nested
|
|
150
|
+
* scrollers does not */
|
|
151
|
+
style={indent ? { paddingLeft: `calc(var(--kol-spacing-4) + ${indent} * var(--kol-spacing-5))` } : undefined}
|
|
129
152
|
>
|
|
130
153
|
<span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
|
|
131
154
|
<Icon name={icon} size={14} />
|
|
132
155
|
</span>
|
|
133
|
-
|
|
156
|
+
{meta ? (
|
|
157
|
+
/* size · date under the name — there is no preview column at this
|
|
158
|
+
* width to carry the facts (ColumnBrowserStackMode item 5) */
|
|
159
|
+
<span className="flex-1 min-w-0 flex flex-col gap-0.5">
|
|
160
|
+
<span className="kol-mono-12 truncate">{label}</span>
|
|
161
|
+
<span className="kol-helper-10 text-fg-48 truncate">{meta}</span>
|
|
162
|
+
</span>
|
|
163
|
+
) : (
|
|
164
|
+
<span className="kol-mono-12 flex-1 truncate">{label}</span>
|
|
165
|
+
)}
|
|
134
166
|
{trailing}
|
|
135
167
|
</li>
|
|
136
168
|
)
|
|
@@ -413,6 +445,128 @@ export default function ColumnBrowser({
|
|
|
413
445
|
// While Quick Look is open the overlay's index is the truth for the highlight.
|
|
414
446
|
const shown = quickLook ? quickLook.files[quickLook.index] : picked
|
|
415
447
|
|
|
448
|
+
/* ── THE STACK MODE (ColumnBrowserStackMode, kol-r2b2 2026-09-03) ─────────
|
|
449
|
+
*
|
|
450
|
+
* Miller columns put hierarchy on the X AXIS, and a phone has no width to
|
|
451
|
+
* spend on it: at 390 two 260px columns plus a gutter overflow, the inner row
|
|
452
|
+
* scrolls with nothing to say so, and a horizontal scroll inside a vertical
|
|
453
|
+
* page is a gesture nobody goes looking for. Measured on a live site, in a
|
|
454
|
+
* different repo running these same organisms — the layout ported and carried
|
|
455
|
+
* the missing mobile story with it, which is what makes this the DS's.
|
|
456
|
+
*
|
|
457
|
+
* THE RULING IS INLINE EXPAND, not push-and-back (user, 2026-09-03). iOS
|
|
458
|
+
* Files and Dropbox arrived at one-level-at-a-time independently and differ
|
|
459
|
+
* only on the descent; push discards the very thing this organism exists for
|
|
460
|
+
* — a parent that stays put while you look at its child — and leaves a
|
|
461
|
+
* generic file list any component could render. Inline expand keeps the idea
|
|
462
|
+
* and moves it from two axes onto one.
|
|
463
|
+
*
|
|
464
|
+
* CAPPED AT THREE LEVELS, then it pushes: a deep bucket path indents until
|
|
465
|
+
* the name has no room. Past the cap the list re-bases on a deeper folder and
|
|
466
|
+
* the back control NAMES the parent it returns to — a bare chevron does not
|
|
467
|
+
* say where it goes.
|
|
468
|
+
*
|
|
469
|
+
* This is a different TREE, not the same tree restyled, which is why it forks
|
|
470
|
+
* in JS on a media query rather than in the stylesheet. The breakpoint is
|
|
471
|
+
* `md` (768) — the one `ContentFilters` already uses, so a page has one
|
|
472
|
+
* responsive story and not two. */
|
|
473
|
+
const stack = useMediaQuery('(max-width: 767px)')
|
|
474
|
+
|
|
475
|
+
if (stack) {
|
|
476
|
+
/* Re-base so the deepest open level is at most INDENT_CAP below the base:
|
|
477
|
+
* everything above scrolls out of the list and is reached by the back
|
|
478
|
+
* control instead. */
|
|
479
|
+
const baseIdx = Math.max(0, levels.length - 1 - INDENT_CAP)
|
|
480
|
+
const base = levels[baseIdx]
|
|
481
|
+
const parent = baseIdx > 0 ? levels[baseIdx - 1] : null
|
|
482
|
+
const openPath = levels.slice(baseIdx)
|
|
483
|
+
|
|
484
|
+
/* One flat row list, walked down the open path: each level's items, with
|
|
485
|
+
* the open folder's children spliced in directly under it. */
|
|
486
|
+
const rows = []
|
|
487
|
+
openPath.forEach((level, depth) => {
|
|
488
|
+
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
489
|
+
const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
|
|
490
|
+
folders.forEach((f) => {
|
|
491
|
+
const isOpen = f === openFolder
|
|
492
|
+
rows.push({
|
|
493
|
+
kind: 'folder', key: level + f, level, name: f, depth, open: isOpen,
|
|
494
|
+
})
|
|
495
|
+
/* the open folder's own children are pushed by the next iteration —
|
|
496
|
+
* nothing recursive here, the loop IS the path */
|
|
497
|
+
})
|
|
498
|
+
files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
|
|
499
|
+
if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
const metaOf = (o) => [o.size != null && formatSize(o.size), o.uploaded].filter(Boolean).join(' · ')
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<div
|
|
506
|
+
className={`kol-column-browser kol-column-browser--stack flex flex-col border rounded ${className}`.trim()}
|
|
507
|
+
/* THE VIEWPORT IS THE HEIGHT (item 4). `height` is a value someone
|
|
508
|
+
* dragged on a desktop; applied literally to a phone it painted a
|
|
509
|
+
* black void the length of the viewport under two near-empty columns.
|
|
510
|
+
* A desktop drag is not a phone measurement, so below the breakpoint
|
|
511
|
+
* the stored one is ignored outright rather than clamped. */
|
|
512
|
+
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
513
|
+
>
|
|
514
|
+
{parent != null && (
|
|
515
|
+
<button
|
|
516
|
+
type="button"
|
|
517
|
+
onClick={() => onPrefix(parent)}
|
|
518
|
+
className="kol-column-browser-back flex items-center gap-2 px-4 py-3 border-b text-fg-default"
|
|
519
|
+
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
520
|
+
>
|
|
521
|
+
<Icon name="chevron-left" size={14} className="text-fg-48" />
|
|
522
|
+
{/* NAMES THE PARENT (item 2) — the folder it returns to, not '‹' */}
|
|
523
|
+
<span className="kol-mono-12 truncate">
|
|
524
|
+
{parent === '' ? 'All files' : parent.replace(/\/$/, '').split('/').pop()}
|
|
525
|
+
</span>
|
|
526
|
+
</button>
|
|
527
|
+
)}
|
|
528
|
+
<ul className="kol-column-browser-column flex-1 overflow-y-auto">
|
|
529
|
+
{rows.map((r) =>
|
|
530
|
+
r.kind === 'empty' ? (
|
|
531
|
+
<li key={r.key} className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
|
|
532
|
+
) : r.kind === 'folder' ? (
|
|
533
|
+
<Row
|
|
534
|
+
key={r.key}
|
|
535
|
+
icon="folder"
|
|
536
|
+
label={r.name.replace(/\/$/, '')}
|
|
537
|
+
indent={r.depth}
|
|
538
|
+
active={r.open}
|
|
539
|
+
trailing={<Icon name={r.open ? 'chevron-down' : 'chevron-right'} size={12} className="text-fg-32" />}
|
|
540
|
+
/* the same chevron opens and collapses — tapping the open
|
|
541
|
+
* folder returns to its parent level, which is the whole
|
|
542
|
+
* inline-expand gesture */
|
|
543
|
+
onClick={() => onPrefix(r.open ? r.level : r.level + r.name)}
|
|
544
|
+
/>
|
|
545
|
+
) : (
|
|
546
|
+
<Row
|
|
547
|
+
key={r.key}
|
|
548
|
+
icon={COL_ICON[kindOf(r.o)] || 'file'}
|
|
549
|
+
label={r.o.displayKey ?? r.o.key}
|
|
550
|
+
indent={r.depth}
|
|
551
|
+
meta={metaOf(r.o)}
|
|
552
|
+
active={shown?.key === r.o.key}
|
|
553
|
+
/* A FILE IS A PUSH, NOT A PREVIEW PANE (item 6): there is no
|
|
554
|
+
* column for the preview to sit beside at this width, so the
|
|
555
|
+
* tap opens the full-screen inspector the consumer already
|
|
556
|
+
* renders for Quick Look. */
|
|
557
|
+
onClick={() => {
|
|
558
|
+
const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
|
|
559
|
+
pick(r.o)
|
|
560
|
+
onQuickLook?.({ files: files.length ? files : [r.o], index: Math.max(0, files.findIndex((f) => f.key === r.o.key)) })
|
|
561
|
+
}}
|
|
562
|
+
/>
|
|
563
|
+
),
|
|
564
|
+
)}
|
|
565
|
+
</ul>
|
|
566
|
+
</div>
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
416
570
|
return (
|
|
417
571
|
<div
|
|
418
572
|
ref={rootRef}
|