@kolkrabbi/kol-component 0.92.0 → 0.93.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/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/ContentText.jsx +7 -1
- package/src/molecules/SortControls.jsx +31 -0
- package/src/organisms/ContentFilters.jsx +22 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.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,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
|
</>
|
|
@@ -189,9 +189,15 @@ export default function ContentText({
|
|
|
189
189
|
* so a card that wants the whole excerpt simply does not pass it. */
|
|
190
190
|
const extra = (slot) => (slot === 'body' && clamp ? ` line-clamp-${clamp}` : '')
|
|
191
191
|
|
|
192
|
+
/* the title dim is BEHAVIOUR, not voice: a `titleClass` override replaces the
|
|
193
|
+
* ramp string whole (that is the seam's contract), and the hook was riding in
|
|
194
|
+
* that string — so Stack's uppercase display title lost its hover the moment
|
|
195
|
+
* it overrode the class (user 2026-08-27: "I had put A HOVER OPACITY drop,
|
|
196
|
+
* WHERE IS IT"). The hook is re-attached after the override. */
|
|
197
|
+
const hook = (slot) => (overrides[slot] && /\bkol-content-title-dim\b/.test(ramp[slot] ?? '') ? ' kol-content-title-dim' : '')
|
|
192
198
|
const line = (slot) =>
|
|
193
199
|
values[slot] == null ? null : (
|
|
194
|
-
<div key={slot} className={`${overrides[slot] ?? ramp[slot] ?? ''}${extra(slot)}`.trim()}>{values[slot]}</div>
|
|
200
|
+
<div key={slot} className={`${overrides[slot] ?? ramp[slot] ?? ''}${extra(slot)}${hook(slot)}`.trim()}>{values[slot]}</div>
|
|
195
201
|
)
|
|
196
202
|
|
|
197
203
|
/* RECURSIVE (2026-08-15) — an entry inside a line/between/group may itself be
|
|
@@ -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,13 @@ 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)
|
|
32
|
+
* @param {ReactNode} props.trailingActions — the header's RIGHT slot, where the view strip sits
|
|
33
|
+
* (kol-r2b2 2026-08-27: a consumer's own controls — sort, flat, select — belong there;
|
|
34
|
+
* `headerActions` is the left group beside search and was never that)
|
|
28
35
|
* @param {Function} props.onFilterChange — optional callback when filters change
|
|
29
36
|
* @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
|
|
30
37
|
* @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
|
|
@@ -80,6 +87,9 @@ const ContentFilters = ({
|
|
|
80
87
|
customFilterKeys = [],
|
|
81
88
|
searchKeys = ['label', 'name', 'title', 'type'],
|
|
82
89
|
headerActions,
|
|
90
|
+
trailingActions,
|
|
91
|
+
leadingActions,
|
|
92
|
+
belowActions,
|
|
83
93
|
showCountOnlyWhenFiltering = false,
|
|
84
94
|
iconComponent,
|
|
85
95
|
className = '',
|
|
@@ -218,8 +228,10 @@ const ContentFilters = ({
|
|
|
218
228
|
{layoutOptions.map((opt) => (
|
|
219
229
|
<span
|
|
220
230
|
key={opt.value}
|
|
221
|
-
onClick={() => setLayout(opt.value)}
|
|
222
|
-
|
|
231
|
+
onClick={opt.onClick ?? (() => setLayout(opt.value))}
|
|
232
|
+
aria-pressed={opt.active !== undefined ? !!opt.active : undefined}
|
|
233
|
+
title={opt.title}
|
|
234
|
+
className={`${layoutClassName} cursor-pointer select-none ${(opt.active ?? layout === opt.value) ? stripActiveClassName : stripRestClassName}`}
|
|
223
235
|
style={{ letterSpacing: 1 }}
|
|
224
236
|
>
|
|
225
237
|
{opt.label}
|
|
@@ -359,7 +371,11 @@ const ContentFilters = ({
|
|
|
359
371
|
)}
|
|
360
372
|
</div>
|
|
361
373
|
|
|
362
|
-
<div className="flex items-center gap-
|
|
374
|
+
<div className="flex items-center gap-6">
|
|
375
|
+
{trailingActions}
|
|
376
|
+
{/* The divider between a consumer's trailing controls and the strip is
|
|
377
|
+
* the organism's, as it is on the left between title and icons. */}
|
|
378
|
+
{trailingActions && layoutPlacement === 'header' && layoutStrip && <Divider variant="vertical" />}
|
|
363
379
|
{layoutPlacement === 'header' && layoutStrip}
|
|
364
380
|
{/* RECENT / SAVED is the SAME STRIP as LIST / GRID, not a ViewToggle.
|
|
365
381
|
* Read off kol-monitor's original (_tmp/2026-08-15-shell-adoption/
|
|
@@ -404,9 +420,10 @@ const ContentFilters = ({
|
|
|
404
420
|
* while the groups and the count appear only with the panel open. Gating
|
|
405
421
|
* the whole row on `isExpanded` hid the strip until you opened filters,
|
|
406
422
|
* which is not a state anyone would guess at. */}
|
|
407
|
-
{(isExpanded || (layoutPlacement === 'below' && layoutStrip)) && (
|
|
423
|
+
{(isExpanded || (layoutPlacement === 'below' && layoutStrip) || leadingActions || belowActions) && (
|
|
408
424
|
<div className="flex items-start justify-between gap-16">
|
|
409
425
|
<div className="flex min-w-0 flex-1 items-start gap-16">
|
|
426
|
+
{leadingActions}
|
|
410
427
|
{isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
|
|
411
428
|
{isExpanded && activeFilters.size > 0 && (
|
|
412
429
|
<button
|
|
@@ -437,6 +454,7 @@ const ContentFilters = ({
|
|
|
437
454
|
</span>
|
|
438
455
|
)}
|
|
439
456
|
{layoutPlacement === 'below' && layoutStrip}
|
|
457
|
+
{belowActions}
|
|
440
458
|
</div>
|
|
441
459
|
</div>
|
|
442
460
|
)}
|