@kolkrabbi/kol-component 0.45.0 → 0.47.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/ActionButton.jsx +193 -0
- package/src/hooks/usePlaceholders.js +72 -0
- package/src/index.js +12 -1
- package/src/molecules/CodeBlock.jsx +2 -2
- package/src/molecules/ContentCard.jsx +250 -0
- package/src/molecules/ContentItem.jsx +14 -0
- package/src/molecules/ContentMedia.jsx +91 -0
- package/src/molecules/ContentRow.jsx +145 -0
- package/src/molecules/ContentText.jsx +223 -0
- package/src/molecules/CopyButton.jsx +41 -0
- package/src/molecules/EmptyState.jsx +14 -6
- package/src/molecules/MediaCard.jsx +2 -2
- package/src/molecules/SearchInput.jsx +1 -1
- package/src/molecules/Section.jsx +13 -2
- package/src/organisms/ContentCollection.jsx +56 -0
- package/src/organisms/ContentFilters.jsx +72 -30
- package/src/utilities/AssetPlaceholder.jsx +6 -1
- package/src/atoms/CopyButton.jsx +0 -42
|
@@ -24,6 +24,21 @@ import IconFrame from '../atoms/IconFrame.jsx'
|
|
|
24
24
|
* @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
|
|
25
25
|
* @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
|
|
26
26
|
* @param {ElementType} props.iconComponent — icon seam (defaults to DS Icon; needs `filter` + `search`)
|
|
27
|
+
*
|
|
28
|
+
* Look seams — all default to the shipped values, pass nothing and nothing changes:
|
|
29
|
+
* @param {string} props.titleClassName — header title type/ink
|
|
30
|
+
* @param {boolean} props.titleUppercase — cases the title (default false)
|
|
31
|
+
* @param {string} props.labelClassName — filter-group label type/ink
|
|
32
|
+
* @param {boolean} props.labelUppercase — cases the group label (default true)
|
|
33
|
+
* @param {string} props.tagVariant — filter chip variant ('primary' grey fill)
|
|
34
|
+
* @param {string} props.tagSize — filter chip size
|
|
35
|
+
* @param {string} props.tagActiveClassName — chip ink, selected
|
|
36
|
+
* @param {string} props.tagRestClassName — chip ink, unselected
|
|
37
|
+
* @param {string} props.viewClassName — RECENT/SAVED strip type
|
|
38
|
+
* @param {string} props.layoutClassName — LIST/GRID strip type
|
|
39
|
+
* @param {string} props.stripActiveClassName — strip ink, selected (both strips)
|
|
40
|
+
* @param {string} props.stripRestClassName — strip ink, unselected (both strips)
|
|
41
|
+
* @param {string} props.countClassName — the "N of N" type/ink
|
|
27
42
|
*/
|
|
28
43
|
const ContentFilters = ({
|
|
29
44
|
items,
|
|
@@ -46,6 +61,22 @@ const ContentFilters = ({
|
|
|
46
61
|
showCountOnlyWhenFiltering = false,
|
|
47
62
|
iconComponent,
|
|
48
63
|
className = '',
|
|
64
|
+
/* THE LOOK SEAMS. Every one defaults to what the component shipped, so
|
|
65
|
+
* passing nothing renders exactly as before — these exist because the
|
|
66
|
+
* hardcoded values were the wrong call for every consumer but one. */
|
|
67
|
+
titleClassName = 'kol-helper-14',
|
|
68
|
+
titleUppercase = false,
|
|
69
|
+
labelClassName = 'kol-helper-12 text-fg-96',
|
|
70
|
+
labelUppercase = true,
|
|
71
|
+
tagVariant = 'primary',
|
|
72
|
+
tagSize = 'sm',
|
|
73
|
+
tagActiveClassName = 'text-fg-96',
|
|
74
|
+
tagRestClassName = 'text-fg-48',
|
|
75
|
+
viewClassName = 'kol-helper-14',
|
|
76
|
+
layoutClassName = 'kol-helper-12',
|
|
77
|
+
stripActiveClassName = 'text-fg-96',
|
|
78
|
+
stripRestClassName = 'text-fg-32 hover:text-fg-48',
|
|
79
|
+
countClassName = 'kol-helper-12 text-fg-64',
|
|
49
80
|
}) => {
|
|
50
81
|
/* Icon seam — consumers on a local icon shelf pass their own component
|
|
51
82
|
* rather than being forced onto the DS set. Needs `filter` + `search`. */
|
|
@@ -119,13 +150,18 @@ const ContentFilters = ({
|
|
|
119
150
|
})
|
|
120
151
|
}, [items, activeFilters, customFilterKeys, searchText, searchKeys])
|
|
121
152
|
|
|
153
|
+
const showCount = !showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0
|
|
154
|
+
|
|
122
155
|
/* THE FILTER VALUE IS A TAG — the atom's whole reason to exist ("a Tag with
|
|
123
156
|
* no handler is a Pill wearing the wrong name"). Three defects lived here
|
|
124
157
|
* until 2026-08-15, all of them working around the atom instead of using it:
|
|
125
158
|
*
|
|
126
159
|
* variant="default" — not a declared variant; `VARIANTS[v] ?? primary`
|
|
127
|
-
* silently rendered the FILLED chip
|
|
128
|
-
* chip
|
|
160
|
+
* silently rendered the FILLED chip — which is what
|
|
161
|
+
* the chip should be. `primary` is now declared, not
|
|
162
|
+
* fallen through to. (An outlined `secondary` shipped
|
|
163
|
+
* 2026-08-15 and was ruled wrong the same day: the
|
|
164
|
+
* grey filled chip is the look.)
|
|
129
165
|
* className border-* — hand-rolled active state beside the atom's own
|
|
130
166
|
* `active` prop, which is what drives `.is-active`.
|
|
131
167
|
* <div onClick> — the handler on a wrapper, so Tag rendered a <span>
|
|
@@ -147,19 +183,19 @@ const ContentFilters = ({
|
|
|
147
183
|
* never what anyone looked at. No inline letter-spacing: the helper ramp
|
|
148
184
|
* carries its own, and the override the fork added was not in the
|
|
149
185
|
* rendered path either. */}
|
|
150
|
-
<h4 className=
|
|
186
|
+
<h4 className={labelClassName} style={labelUppercase ? { textTransform: 'uppercase' } : undefined}>
|
|
151
187
|
{group.label}
|
|
152
188
|
</h4>
|
|
153
189
|
<div className="flex flex-wrap gap-4">
|
|
154
190
|
{group.values.map((value) => (
|
|
155
191
|
<Tag
|
|
156
192
|
key={value}
|
|
157
|
-
size=
|
|
158
|
-
variant=
|
|
193
|
+
size={tagSize}
|
|
194
|
+
variant={tagVariant}
|
|
159
195
|
hash={false}
|
|
160
196
|
active={activeFilters.has(`${group.key}:${value}`)}
|
|
161
197
|
onClick={() => toggleFilter(group.key, value)}
|
|
162
|
-
className={activeFilters.has(`${group.key}:${value}`) ?
|
|
198
|
+
className={activeFilters.has(`${group.key}:${value}`) ? tagActiveClassName : tagRestClassName}
|
|
163
199
|
>
|
|
164
200
|
{value}
|
|
165
201
|
</Tag>
|
|
@@ -181,7 +217,7 @@ const ContentFilters = ({
|
|
|
181
217
|
* states"); the span here was the same defect that promoted it. */}
|
|
182
218
|
<h2 className="flex items-center gap-2">
|
|
183
219
|
{titleIcon && <IconFrame name={titleIcon} variant="secondary" size="md" />}
|
|
184
|
-
<span className=
|
|
220
|
+
<span className={titleClassName} style={titleUppercase ? { textTransform: 'uppercase' } : undefined}>{title}</span>
|
|
185
221
|
</h2>
|
|
186
222
|
<Divider variant="vertical" className="self-stretch py-1" />
|
|
187
223
|
<div className="flex items-center gap-1">
|
|
@@ -201,7 +237,7 @@ const ContentFilters = ({
|
|
|
201
237
|
width: searchOpen ? 200 : 32,
|
|
202
238
|
background: searchOpen ? 'var(--kol-surface-secondary)' : 'transparent',
|
|
203
239
|
border: 'none',
|
|
204
|
-
transition: 'width 600ms
|
|
240
|
+
transition: 'width 600ms var(--kol-ease-house), background 400ms var(--kol-ease-house)',
|
|
205
241
|
overflow: 'hidden',
|
|
206
242
|
}}
|
|
207
243
|
onClick={() => {
|
|
@@ -216,7 +252,7 @@ const ContentFilters = ({
|
|
|
216
252
|
className="flex items-center justify-center flex-shrink-0"
|
|
217
253
|
style={{
|
|
218
254
|
opacity: searchOpen ? 0 : 1,
|
|
219
|
-
transition: 'opacity 300ms
|
|
255
|
+
transition: 'opacity 300ms var(--kol-ease-house)',
|
|
220
256
|
position: searchOpen ? 'absolute' : 'relative',
|
|
221
257
|
}}
|
|
222
258
|
>
|
|
@@ -250,11 +286,6 @@ const ContentFilters = ({
|
|
|
250
286
|
</div>
|
|
251
287
|
|
|
252
288
|
<div className="flex items-center gap-8">
|
|
253
|
-
{(!showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0) && (
|
|
254
|
-
<span className="kol-helper-14 text-fg-64">
|
|
255
|
-
{filteredItems.length} of {totalCount}
|
|
256
|
-
</span>
|
|
257
|
-
)}
|
|
258
289
|
{/* RECENT / SAVED is the SAME STRIP as LIST / GRID, not a ViewToggle.
|
|
259
290
|
* Read off kol-monitor's original (_tmp/2026-08-15-shell-adoption/
|
|
260
291
|
* ContentFilters.jsx:225-232): inline spans, `kol-helper-14`,
|
|
@@ -270,7 +301,7 @@ const ContentFilters = ({
|
|
|
270
301
|
<span
|
|
271
302
|
key={opt.value}
|
|
272
303
|
onClick={() => handleViewModeChange(opt.value)}
|
|
273
|
-
className={
|
|
304
|
+
className={`${viewClassName} cursor-pointer select-none ${viewMode === opt.value ? stripActiveClassName : stripRestClassName}`}
|
|
274
305
|
style={{ textTransform: 'uppercase', letterSpacing: 1 }}
|
|
275
306
|
>
|
|
276
307
|
{opt.label}
|
|
@@ -292,7 +323,7 @@ const ContentFilters = ({
|
|
|
292
323
|
* the values hang beneath it. The strip previously rendered in its own
|
|
293
324
|
* row AFTER this block, which is why expanding a filter group pushed it
|
|
294
325
|
* down the page — the defect that started the whole ticket. */}
|
|
295
|
-
{(layoutOptions || isExpanded) && (
|
|
326
|
+
{(layoutOptions || isExpanded || showCount) && (
|
|
296
327
|
<div className="flex items-start justify-between gap-16 pb-4">
|
|
297
328
|
<div className="flex items-start gap-16">
|
|
298
329
|
{isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
|
|
@@ -307,20 +338,31 @@ const ContentFilters = ({
|
|
|
307
338
|
</button>
|
|
308
339
|
)}
|
|
309
340
|
</div>
|
|
310
|
-
{
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
341
|
+
{/* The count rides the LAYOUT row, not the header (user ruling
|
|
342
|
+
* 2026-08-15) — same `kol-helper-12` + 1px tracking as LIST/GRID
|
|
343
|
+
* beside it. Ink stays `text-fg-64`: it is static information, not
|
|
344
|
+
* a toggle, so it takes neither the 96 active nor the 32 rest. */}
|
|
345
|
+
<div className="flex items-center gap-4 flex-shrink-0">
|
|
346
|
+
{showCount && (
|
|
347
|
+
<span className={countClassName} style={{ letterSpacing: 1 }}>
|
|
348
|
+
{filteredItems.length} of {totalCount}
|
|
349
|
+
</span>
|
|
350
|
+
)}
|
|
351
|
+
{layoutOptions && (
|
|
352
|
+
<div className="flex items-center gap-4">
|
|
353
|
+
{layoutOptions.map((opt) => (
|
|
354
|
+
<span
|
|
355
|
+
key={opt.value}
|
|
356
|
+
onClick={() => setLayout(opt.value)}
|
|
357
|
+
className={`${layoutClassName} cursor-pointer select-none ${layout === opt.value ? stripActiveClassName : stripRestClassName}`}
|
|
358
|
+
style={{ letterSpacing: 1 }}
|
|
359
|
+
>
|
|
360
|
+
{opt.label}
|
|
361
|
+
</span>
|
|
362
|
+
))}
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
324
366
|
</div>
|
|
325
367
|
)}
|
|
326
368
|
|
|
@@ -6,17 +6,22 @@
|
|
|
6
6
|
* are visibly flagged rather than showing the browser's broken-image icon.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
/* `radius` (2026-08-15 user ruling): a placeholder inside a host that already
|
|
10
|
+
* CLIPS its own corners rounds twice — the visible double-round on the card
|
|
11
|
+
* form. The host turns it off; it stays on everywhere else, so nothing that
|
|
12
|
+
* renders a bare placeholder changes. */
|
|
9
13
|
export default function AssetPlaceholder({
|
|
10
14
|
category,
|
|
11
15
|
name,
|
|
12
16
|
aspectRatio = '16 / 9',
|
|
13
17
|
note = 'MISSING',
|
|
18
|
+
radius = true,
|
|
14
19
|
className = '',
|
|
15
20
|
}) {
|
|
16
21
|
const label = [category, name].filter(Boolean).join(' · ')
|
|
17
22
|
return (
|
|
18
23
|
<div
|
|
19
|
-
className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] rounded-[var(--kol-radius-sm)] bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
|
|
24
|
+
className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] ${radius ? 'rounded-[var(--kol-radius-sm)]' : ''} bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
|
|
20
25
|
style={{ aspectRatio }}
|
|
21
26
|
role="img"
|
|
22
27
|
aria-label={`${label || 'asset'} — ${note}`}
|
package/src/atoms/CopyButton.jsx
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* CopyButton — THE copy-to-clipboard control (2026-08-09 user ruling): the
|
|
6
|
-
* 32×32 icon button — `copy` glyph flipping to `check` for 2s on copied,
|
|
7
|
-
* no text label. This is the button CodeBlock carried privately since the
|
|
8
|
-
* 2026-07-28 elder replication, promoted to the one shared atom; the old
|
|
9
|
-
* Copy/Copied label chip (one-off SVGs outside the icon set) is retired.
|
|
10
|
-
* Chrome comes from .kol-copy-btn (kol-theme); parents add their own
|
|
11
|
-
* positioning class (e.g. CodeBlock's .kol-codeblock-copy).
|
|
12
|
-
*
|
|
13
|
-
* Props:
|
|
14
|
-
* text — string (or () => string) written to the clipboard
|
|
15
|
-
* className — extra classes (positioning etc.)
|
|
16
|
-
*/
|
|
17
|
-
export default function CopyButton({ text, className = '', ...props }) {
|
|
18
|
-
const [copied, setCopied] = useState(false)
|
|
19
|
-
|
|
20
|
-
const onCopy = async () => {
|
|
21
|
-
try {
|
|
22
|
-
await navigator.clipboard.writeText(typeof text === 'function' ? text() : String(text ?? ''))
|
|
23
|
-
setCopied(true)
|
|
24
|
-
setTimeout(() => setCopied(false), 2000)
|
|
25
|
-
} catch {
|
|
26
|
-
/* clipboard blocked — silent */
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<button
|
|
32
|
-
type="button"
|
|
33
|
-
className={`kol-copy-btn ${className}`.trim()}
|
|
34
|
-
onClick={onCopy}
|
|
35
|
-
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
|
|
36
|
-
title={copied ? 'Copied' : 'Copy'}
|
|
37
|
-
{...props}
|
|
38
|
-
>
|
|
39
|
-
<Icon name={copied ? 'check' : 'copy'} size={16} />
|
|
40
|
-
</button>
|
|
41
|
-
)
|
|
42
|
-
}
|