@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.92.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
- className={`${layoutClassName} cursor-pointer select-none ${layout === opt.value ? stripActiveClassName : stripRestClassName}`}
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-8">
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
  )}