@kolkrabbi/kol-component 0.92.1 → 0.93.1
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/SizeOrDownload.jsx +79 -0
- package/src/atoms/SortHeader.jsx +30 -0
- package/src/atoms/ToggleCheckbox.jsx +6 -1
- package/src/index.js +3 -0
- package/src/molecules/ContentCard.jsx +11 -0
- package/src/molecules/SortControls.jsx +31 -0
- package/src/organisms/ContentFilters.jsx +19 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.1",
|
|
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,79 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
|
|
4
|
+
/* Promoted verbatim from showcase/src/sets/content-card-comparison.jsx
|
|
5
|
+
* (ContentFiltersCollection, kol-r2b2 2026-08-27) — the card's `size` slot:
|
|
6
|
+
* the size at rest, a download link on hover. `href` is the download. */
|
|
7
|
+
/* The size string IS the download affordance: "2.4 MB" at rest, a download
|
|
8
|
+
* icon + "Download" on hover, no container of any kind.
|
|
9
|
+
*
|
|
10
|
+
* Both states sit in ONE grid cell so the meta row never reflows. The icon is
|
|
11
|
+
* CLIPPED at zero width at rest and opens to its box on hover, sliding left to
|
|
12
|
+
* right — so it reads as coming out from behind the word rather than fading in
|
|
13
|
+
* beside it. ONE duration and curve for every part and both directions — 500ms
|
|
14
|
+
* on the house curve `--kol-ease-house`, plus a 200ms delay on the icon so the
|
|
15
|
+
* word starts before the glyph. That delay is on the `group-hover:` variant,
|
|
16
|
+
* NOT the base — ENTRY only. With it on the base the exit inherited it too, so
|
|
17
|
+
* the glyph finished retracting at t=700 while the fade ended at t=500: it went
|
|
18
|
+
* invisible at full extension and never appeared to slide back.
|
|
19
|
+
*
|
|
20
|
+
* The glyph is NOT on `.kol-inline-control` — that chrome is for a control you
|
|
21
|
+
* click, and this one is inside the link rather than being it.
|
|
22
|
+
*
|
|
23
|
+
* Both hover parts ink on the OPACITY scale (`text-oq-80`), not an `fg-*` role:
|
|
24
|
+
* a stroke glyph on a flat fg colour reads wrong against the plate, and oq is
|
|
25
|
+
* what the rest of the chrome uses.
|
|
26
|
+
*
|
|
27
|
+
* ONE type class throughout — kol-mono-12. helper-12 is line-height 1 against
|
|
28
|
+
* mono's 16px, so swapping classes moved the line. Only the ink changes.
|
|
29
|
+
*/
|
|
30
|
+
export default function SizeOrDownload({
|
|
31
|
+
children,
|
|
32
|
+
href,
|
|
33
|
+
icon = 'download',
|
|
34
|
+
confirmIcon = 'check',
|
|
35
|
+
label = 'Download',
|
|
36
|
+
confirmLabel = 'Downloaded',
|
|
37
|
+
}) {
|
|
38
|
+
/* The confirm is LOCAL, not ActionButton's — this affordance is the link
|
|
39
|
+
* itself, not an icon control sitting inside one. */
|
|
40
|
+
const [done, setDone] = useState(false)
|
|
41
|
+
const timer = useRef(null)
|
|
42
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
43
|
+
|
|
44
|
+
const click = (event) => {
|
|
45
|
+
/* a real `href` downloads; without one the click is the confirm alone */
|
|
46
|
+
if (!href) event.preventDefault()
|
|
47
|
+
clearTimeout(timer.current)
|
|
48
|
+
setDone(true)
|
|
49
|
+
timer.current = setTimeout(() => setDone(false), 2000)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<a
|
|
54
|
+
href={href ?? '#'}
|
|
55
|
+
onClick={click}
|
|
56
|
+
aria-label={done ? confirmLabel : label}
|
|
57
|
+
className="group/size -mx-1 inline-grid items-end justify-items-start rounded-[var(--kol-radius-sm)] px-1 transition-colors duration-500 ease-[var(--kol-ease-house)] hover:bg-oq-04 active:bg-oq-08"
|
|
58
|
+
>
|
|
59
|
+
<span
|
|
60
|
+
className="kol-mono-12 text-oq-80 group-hover/size:opacity-0"
|
|
61
|
+
style={{ gridArea: '1 / 1', transition: 'opacity 500ms var(--kol-ease-house)' }}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</span>
|
|
65
|
+
|
|
66
|
+
<span
|
|
67
|
+
className="inline-flex items-center opacity-0 group-hover/size:opacity-100"
|
|
68
|
+
style={{ gridArea: '1 / 1', transition: 'opacity 500ms var(--kol-ease-house)' }}
|
|
69
|
+
>
|
|
70
|
+
<span className="inline-flex w-0 overflow-hidden transition-[width] duration-500 ease-[var(--kol-ease-house)] group-hover/size:w-[20px] group-hover/size:delay-200">
|
|
71
|
+
<span className="inline-flex h-4 w-4 -translate-x-2 items-center justify-center text-oq-80 transition-transform duration-500 ease-[var(--kol-ease-house)] group-hover/size:translate-x-0 group-hover/size:delay-200">
|
|
72
|
+
<Icon name={done ? confirmIcon : icon} size={16} />
|
|
73
|
+
</span>
|
|
74
|
+
</span>
|
|
75
|
+
<span className="kol-mono-12 text-oq-80">{label}</span>
|
|
76
|
+
</span>
|
|
77
|
+
</a>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SortHeader — one sortable field: label + direction arrow. Strip chrome, verbatim
|
|
5
|
+
* from kol-r2b2's FileList (ContentFiltersCollection, 2026-08-27): `kol-helper-12`,
|
|
6
|
+
* uppercase label, `letterSpacing: 1`, `select-none`; active `text-oq-96`, rest
|
|
7
|
+
* `text-oq-48 hover:text-oq-64`. The arrow renders ONLY on the active field —
|
|
8
|
+
* arrow-down = ascending (1→N, A→Z, oldest→newest), arrow-up = descending.
|
|
9
|
+
*
|
|
10
|
+
* @param {ReactNode} label the field's label (rendered uppercase)
|
|
11
|
+
* @param {boolean} active this field is the current sort
|
|
12
|
+
* @param {'asc'|'desc'} dir direction, read only when active
|
|
13
|
+
* @param {Function} onClick () => void
|
|
14
|
+
* @param {string} className extra classes on the button
|
|
15
|
+
*/
|
|
16
|
+
export default function SortHeader({ label, active = false, dir = 'asc', onClick, className = '', ...props }) {
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
onClick={onClick}
|
|
21
|
+
aria-pressed={active}
|
|
22
|
+
className={`kol-helper-12 flex items-center gap-1 select-none transition-colors ${active ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'} ${className}`.replace(/\s+/g, ' ').trim()}
|
|
23
|
+
style={{ letterSpacing: 1 }}
|
|
24
|
+
{...props}
|
|
25
|
+
>
|
|
26
|
+
{typeof label === 'string' ? label.toUpperCase() : label}
|
|
27
|
+
{active && <Icon name={dir === 'asc' ? 'arrow-down' : 'arrow-up'} size={10} />}
|
|
28
|
+
</button>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
|
|
3
|
+
/* `variant="media"` (ContentFiltersCollection, kol-r2b2 2026-08-27): inside a
|
|
4
|
+
* media frame the unchecked hairline vanished over a photo; this variant's
|
|
5
|
+
* UNCHECKED box carries the media control's solid plate (`--kol-oq-12`) so it
|
|
6
|
+
* reads over any image. Checked stays the white plate + black check. */
|
|
3
7
|
const ToggleCheckbox = ({
|
|
4
8
|
label,
|
|
5
9
|
checked = false,
|
|
6
10
|
onChange,
|
|
11
|
+
variant = 'default',
|
|
7
12
|
className = '',
|
|
8
13
|
hint,
|
|
9
14
|
...props
|
|
@@ -15,7 +20,7 @@ const ToggleCheckbox = ({
|
|
|
15
20
|
|
|
16
21
|
return (
|
|
17
22
|
<label
|
|
18
|
-
className={`toggle-checkbox ${checked ? 'is-active' : ''} ${className}`.trim()}
|
|
23
|
+
className={`toggle-checkbox ${variant === 'media' ? 'toggle-checkbox--media' : ''} ${checked ? 'is-active' : ''} ${className}`.replace(/\s+/g, ' ').trim()}
|
|
19
24
|
{...props}
|
|
20
25
|
>
|
|
21
26
|
<input
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,9 @@ export { default as Avatar } from './atoms/Avatar.jsx'
|
|
|
23
23
|
export { default as Badge } from './atoms/Badge.jsx'
|
|
24
24
|
export { default as Button } from './atoms/Button.jsx'
|
|
25
25
|
export { default as ActionButton } from './atoms/ActionButton.jsx'
|
|
26
|
+
export { default as SizeOrDownload } from './atoms/SizeOrDownload.jsx'
|
|
27
|
+
export { default as SortHeader } from './atoms/SortHeader.jsx'
|
|
28
|
+
export { default as SortControls } from './molecules/SortControls.jsx'
|
|
26
29
|
export { default as CopyButton } from './molecules/CopyButton.jsx'
|
|
27
30
|
export { default as CurveOverlay } from './atoms/CurveOverlay.jsx'
|
|
28
31
|
export { default as Divider } from './atoms/Divider.jsx'
|
|
@@ -101,6 +101,7 @@ export default function ContentCard({
|
|
|
101
101
|
ring,
|
|
102
102
|
zoom,
|
|
103
103
|
control,
|
|
104
|
+
controlStart,
|
|
104
105
|
actions,
|
|
105
106
|
expanded = false,
|
|
106
107
|
expandedContent,
|
|
@@ -199,6 +200,12 @@ export default function ContentCard({
|
|
|
199
200
|
* what MediaCard hardcodes as a download link plus a select checkbox, and
|
|
200
201
|
* the reason its media library could not migrate onto ContentCard. */
|
|
201
202
|
const controlNode = control ? <div className="kol-frame-control">{control}</div> : null
|
|
203
|
+
/* The frame's OTHER corner — top-left — for a second control (kol-r2b2
|
|
204
|
+
* 2026-08-27: the select indicator beside the download chip). Port note:
|
|
205
|
+
* becomes `.kol-frame-control--start` in kol-theme; inline until then. */
|
|
206
|
+
const controlStartNode = controlStart
|
|
207
|
+
? <div className="kol-frame-control kol-frame-control--top-left">{controlStart}</div>
|
|
208
|
+
: null
|
|
202
209
|
|
|
203
210
|
|
|
204
211
|
const body =
|
|
@@ -208,6 +215,7 @@ export default function ContentCard({
|
|
|
208
215
|
<div className="relative">
|
|
209
216
|
<ContentMedia ratio={r} {...mediaProps}>{media}</ContentMedia>
|
|
210
217
|
{controlNode}
|
|
218
|
+
{controlStartNode}
|
|
211
219
|
</div>
|
|
212
220
|
{textNode}
|
|
213
221
|
</>
|
|
@@ -216,6 +224,7 @@ export default function ContentCard({
|
|
|
216
224
|
<div className="flex-1 min-w-0 min-h-0 relative overflow-hidden" style={expanded ? { flex: '0 0 50%' } : undefined}>
|
|
217
225
|
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
218
226
|
{controlNode}
|
|
227
|
+
{controlStartNode}
|
|
219
228
|
</div>
|
|
220
229
|
{expanded ? (
|
|
221
230
|
<div
|
|
@@ -231,6 +240,7 @@ export default function ContentCard({
|
|
|
231
240
|
<div className="relative h-full">
|
|
232
241
|
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
233
242
|
{controlNode}
|
|
243
|
+
{controlStartNode}
|
|
234
244
|
</div>
|
|
235
245
|
{/* the plate is INVERSE and hidden until hover — `kol-card-drawer` owns
|
|
236
246
|
* the reveal so the transition sits with the rest of the chrome */}
|
|
@@ -242,6 +252,7 @@ export default function ContentCard({
|
|
|
242
252
|
<div className="absolute" style={{ inset: 0 }}>
|
|
243
253
|
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
244
254
|
{controlNode}
|
|
255
|
+
{controlStartNode}
|
|
245
256
|
</div>
|
|
246
257
|
{textNode}
|
|
247
258
|
</>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import SortHeader from '../atoms/SortHeader.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SortControls — the sortable-header group (ContentFiltersCollection, kol-r2b2
|
|
5
|
+
* 2026-08-27): `flex items-center gap-4` of SortHeaders. Click an inactive field
|
|
6
|
+
* → it becomes the sort, ASCENDING; click the active field → the direction
|
|
7
|
+
* flips. `onSort(field)` fires once per click — the consumer writes ONE state
|
|
8
|
+
* (two setter calls spreading the same stale state clobbered each other in the
|
|
9
|
+
* original; the fixed reference is in the ticket).
|
|
10
|
+
*
|
|
11
|
+
* @param {{value: string, label: ReactNode}[]} options the fields
|
|
12
|
+
* @param {string} sortBy the active field's value
|
|
13
|
+
* @param {'asc'|'desc'} sortDir
|
|
14
|
+
* @param {Function} onSort (field) => void
|
|
15
|
+
* @param {string} className extra classes on the group
|
|
16
|
+
*/
|
|
17
|
+
export default function SortControls({ options = [], sortBy, sortDir = 'asc', onSort = () => {}, className = '' }) {
|
|
18
|
+
return (
|
|
19
|
+
<div className={`flex items-center gap-4 ${className}`.trim()}>
|
|
20
|
+
{options.map((opt) => (
|
|
21
|
+
<SortHeader
|
|
22
|
+
key={opt.value}
|
|
23
|
+
label={opt.label}
|
|
24
|
+
active={sortBy === opt.value}
|
|
25
|
+
dir={sortDir}
|
|
26
|
+
onClick={() => onSort(opt.value)}
|
|
27
|
+
/>
|
|
28
|
+
))}
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -25,6 +25,10 @@ import IconFrame from '../atoms/IconFrame.jsx'
|
|
|
25
25
|
* @param {string} props.layout — controlled LIST/GRID value (kol-r2b2 2026-08-27: a consumer that persists
|
|
26
26
|
* layout per bucket needs the strip's value back; `defaultLayout` alone kept it internal)
|
|
27
27
|
* @param {Function} props.onLayoutChange — (layout) => void, fires on every strip click
|
|
28
|
+
* @param {ReactNode} props.leadingActions — the LEFT half of the below-divider row, beside the
|
|
29
|
+
* LIST/GRID strip (kol-r2b2 2026-08-27: the selection bar lives there, not on a row of its own)
|
|
30
|
+
* @param {ReactNode} props.belowActions — the RIGHT half of the below-divider row, beside the count
|
|
31
|
+
* (kol-r2b2 2026-08-27: the sort group, once SELECT/FLAT moved up into the header strip)
|
|
28
32
|
* @param {ReactNode} props.trailingActions — the header's RIGHT slot, where the view strip sits
|
|
29
33
|
* (kol-r2b2 2026-08-27: a consumer's own controls — sort, flat, select — belong there;
|
|
30
34
|
* `headerActions` is the left group beside search and was never that)
|
|
@@ -84,6 +88,8 @@ const ContentFilters = ({
|
|
|
84
88
|
searchKeys = ['label', 'name', 'title', 'type'],
|
|
85
89
|
headerActions,
|
|
86
90
|
trailingActions,
|
|
91
|
+
leadingActions,
|
|
92
|
+
belowActions,
|
|
87
93
|
showCountOnlyWhenFiltering = false,
|
|
88
94
|
iconComponent,
|
|
89
95
|
className = '',
|
|
@@ -291,7 +297,12 @@ const ContentFilters = ({
|
|
|
291
297
|
* states"); the span here was the same defect that promoted it. */}
|
|
292
298
|
<h2 className="flex items-center gap-2">
|
|
293
299
|
{titleIcon && <IconFrame name={titleIcon} variant="secondary" size="md" />}
|
|
294
|
-
|
|
300
|
+
{/* `pr-4` by rule (ContentFiltersTitleGap, kol-website 2026-08-27): the
|
|
301
|
+
* divider sat 24px from the title's text edge but 32 from the
|
|
302
|
+
* icon glyphs (the frames carry 8px of air a side) and read pushed
|
|
303
|
+
* toward the title; 16px on the title side balances it. The seam
|
|
304
|
+
* `titleClassName` stays what it was. */}
|
|
305
|
+
<span className={`${titleClassName} pr-4`} style={titleUppercase ? { textTransform: 'uppercase', letterSpacing: 1 } : undefined}>{title}</span>
|
|
295
306
|
</h2>
|
|
296
307
|
<Divider variant="vertical" />
|
|
297
308
|
<div className="flex items-center gap-1">
|
|
@@ -365,8 +376,11 @@ const ContentFilters = ({
|
|
|
365
376
|
)}
|
|
366
377
|
</div>
|
|
367
378
|
|
|
368
|
-
<div className="flex items-center gap-
|
|
379
|
+
<div className="flex items-center gap-6">
|
|
369
380
|
{trailingActions}
|
|
381
|
+
{/* The divider between a consumer's trailing controls and the strip is
|
|
382
|
+
* the organism's, as it is on the left between title and icons. */}
|
|
383
|
+
{trailingActions && layoutPlacement === 'header' && layoutStrip && <Divider variant="vertical" />}
|
|
370
384
|
{layoutPlacement === 'header' && layoutStrip}
|
|
371
385
|
{/* RECENT / SAVED is the SAME STRIP as LIST / GRID, not a ViewToggle.
|
|
372
386
|
* Read off kol-monitor's original (_tmp/2026-08-15-shell-adoption/
|
|
@@ -411,9 +425,10 @@ const ContentFilters = ({
|
|
|
411
425
|
* while the groups and the count appear only with the panel open. Gating
|
|
412
426
|
* the whole row on `isExpanded` hid the strip until you opened filters,
|
|
413
427
|
* which is not a state anyone would guess at. */}
|
|
414
|
-
{(isExpanded || (layoutPlacement === 'below' && layoutStrip)) && (
|
|
428
|
+
{(isExpanded || (layoutPlacement === 'below' && layoutStrip) || leadingActions || belowActions) && (
|
|
415
429
|
<div className="flex items-start justify-between gap-16">
|
|
416
430
|
<div className="flex min-w-0 flex-1 items-start gap-16">
|
|
431
|
+
{leadingActions}
|
|
417
432
|
{isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
|
|
418
433
|
{isExpanded && activeFilters.size > 0 && (
|
|
419
434
|
<button
|
|
@@ -444,6 +459,7 @@ const ContentFilters = ({
|
|
|
444
459
|
</span>
|
|
445
460
|
)}
|
|
446
461
|
{layoutPlacement === 'below' && layoutStrip}
|
|
462
|
+
{belowActions}
|
|
447
463
|
</div>
|
|
448
464
|
</div>
|
|
449
465
|
)}
|