@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.92.1",
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
- <span className={titleClassName} style={titleUppercase ? { textTransform: 'uppercase', letterSpacing: 1 } : undefined}>{title}</span>
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-8">
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
  )}