@kolkrabbi/kol-component 0.90.2 → 0.91.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.
|
|
3
|
+
"version": "0.91.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",
|
|
@@ -91,6 +91,8 @@ const MEDIA = {
|
|
|
91
91
|
|
|
92
92
|
export default function ContentCard({
|
|
93
93
|
variant = 'default',
|
|
94
|
+
hero = false,
|
|
95
|
+
label,
|
|
94
96
|
pad,
|
|
95
97
|
media,
|
|
96
98
|
ratio,
|
|
@@ -110,10 +112,31 @@ export default function ContentCard({
|
|
|
110
112
|
...text
|
|
111
113
|
}) {
|
|
112
114
|
const box = BOX[variant] ?? BOX.default
|
|
115
|
+
/* HERO (2026-08-27 — the featured card riding a page's fold; ListingCard
|
|
116
|
+
* size="hero" had no ContentCard equivalent, which is why Stack still
|
|
117
|
+
* imported it): a header row above the media — `label` left, `meta` chips
|
|
118
|
+
* right — the media on the zoom's hero rung (1.02), the text on
|
|
119
|
+
* ContentText's `hero` form (display-03 title). article only; the row form
|
|
120
|
+
* has no hero. */
|
|
121
|
+
const isHero = hero && variant === 'article'
|
|
122
|
+
const { meta: heroMeta, ...textSlots } = text
|
|
123
|
+
if (!isHero) Object.assign(textSlots, { meta: text.meta })
|
|
124
|
+
if (isHero) delete textSlots.tags
|
|
125
|
+
const metaChips = isHero && heroMeta != null ? (Array.isArray(heroMeta) ? heroMeta : [heroMeta]) : null
|
|
126
|
+
const heroHeader = isHero && (label != null || metaChips?.length) ? (
|
|
127
|
+
<div className="kol-card-hero-header flex items-center justify-between" style={{ marginBottom: 'var(--kol-spacing-4)' }}>
|
|
128
|
+
{label != null && <div className="kol-helper-14 text-meta">{label}</div>}
|
|
129
|
+
{metaChips?.length > 0 && (
|
|
130
|
+
<div className="flex gap-3 kol-helper-12 text-meta">
|
|
131
|
+
{metaChips.map((item, i) => <span key={i}>{item}</span>)}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
) : null
|
|
113
136
|
const r = ratio ?? RATIOS[variant]
|
|
114
137
|
const padding = pad ? `var(--kol-pad-card-${pad})` : box.pad
|
|
115
138
|
/* image-only cards (print) pass no text slots — the empty plate must not render */
|
|
116
|
-
const hasText = ['title', 'body', 'kicker', 'detail', 'date', 'size', 'meta', 'tags'].some((k) =>
|
|
139
|
+
const hasText = ['title', 'body', 'kicker', 'detail', 'date', 'size', 'meta', 'tags'].some((k) => textSlots[k] != null)
|
|
117
140
|
/* `actions` sit IN THE TEXT PLATE, bottom-right (user ruling 2026-08-15:
|
|
118
141
|
* *"space in text bottom right"*). Not stacked under the copy in their own
|
|
119
142
|
* row — that grew the card — and not on the media, which was my call to make
|
|
@@ -138,7 +161,7 @@ export default function ContentCard({
|
|
|
138
161
|
zIndex: box.layout === 'canvas' ? 1 : undefined,
|
|
139
162
|
}}
|
|
140
163
|
>
|
|
141
|
-
{hasText && <ContentText variant={variant} form=
|
|
164
|
+
{hasText && <ContentText variant={variant} form={isHero ? 'hero' : 'card'} {...textSlots} />}
|
|
142
165
|
{/* ABSOLUTE, not a flex sibling: the plate's height moves with the title
|
|
143
166
|
* and the meta, so a laid-out stack would stretch or drift with it. The
|
|
144
167
|
* inset reads the SAME pad token the plate uses, so the icons sit the
|
|
@@ -166,7 +189,7 @@ export default function ContentCard({
|
|
|
166
189
|
frame: frame ?? MEDIA[variant]?.frame ?? false,
|
|
167
190
|
ring: ring ?? MEDIA[variant]?.ring ?? false,
|
|
168
191
|
borderHover: MEDIA[variant]?.borderHover ?? false,
|
|
169
|
-
zoom: zoom ?? MEDIA[variant]?.zoom ?? false,
|
|
192
|
+
zoom: zoom ?? (isHero ? 'hero' : MEDIA[variant]?.zoom ?? false),
|
|
170
193
|
}
|
|
171
194
|
|
|
172
195
|
/* `control` — the in-frame control slot (user ruling 2026-08-15). One node,
|
|
@@ -181,6 +204,7 @@ export default function ContentCard({
|
|
|
181
204
|
const body =
|
|
182
205
|
box.layout === 'stack' ? (
|
|
183
206
|
<>
|
|
207
|
+
{heroHeader}
|
|
184
208
|
<div className="relative">
|
|
185
209
|
<ContentMedia ratio={r} {...mediaProps}>{media}</ContentMedia>
|
|
186
210
|
{controlNode}
|
|
@@ -241,6 +265,7 @@ export default function ContentCard({
|
|
|
241
265
|
const hoverBg = HOVER[variant]
|
|
242
266
|
|
|
243
267
|
const common = {
|
|
268
|
+
'data-tags': isHero && Array.isArray(text.tags) && text.tags.length ? text.tags.join(' ') : undefined,
|
|
244
269
|
className: `kol-card group flex ${expanded ? 'flex-row-reverse' : 'flex-col'} ${box.layout === 'drawer' ? 'relative overflow-hidden rounded-[var(--kol-radius-sm)]' : ''} ${framed ? 'overflow-hidden rounded-[var(--kol-radius-sm)]' : ''} ${box.border ? 'border' : ''} ${box.layout === 'canvas' ? 'relative' : ''} ${interactive ? 'cursor-pointer select-none' : ''} ${hoverBg && interactive ? 'kol-content-hover' : ''} ${className}`.trim(),
|
|
245
270
|
style: {
|
|
246
271
|
/* same reason as ContentRow: rest colours are PROPERTIES, because an
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @param {ReactNode} kicker article only
|
|
21
21
|
* @param {ReactNode} detail catalog · print
|
|
22
22
|
* @param {ReactNode} date default · article · typeface
|
|
23
|
+
* @param {'card'|'row'|'hero'} form the block's form; `hero` is article's featured text (ContentCard `hero`)
|
|
23
24
|
* @param {ReactNode} size default · article (file size / read length)
|
|
24
25
|
* @param {ReactNode} meta work only
|
|
25
26
|
* @param {number} gap inner line gap in px (defaults per variant/form)
|
|
@@ -64,6 +65,12 @@ const RAMP = {
|
|
|
64
65
|
/* kol-content-title-dim: the title dims to 70% on card hover (StackCardHover, 2026-08-27 — ListingCard's move, kol-theme) */
|
|
65
66
|
card: { kicker: 'kol-mono-12 text-meta', title: 'kol-sans-heading-03 text-emphasis kol-content-title-dim', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
|
|
66
67
|
row: { kicker: 'kol-mono-12 text-meta', title: 'kol-sans-heading-04 text-emphasis kol-content-title-dim', body: 'kol-mono-12 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
|
|
68
|
+
/* HERO — the featured card riding a page's fold (ContentCard `hero`,
|
|
69
|
+
* 2026-08-27): ListingCard size="hero"'s text, read off Stack — kicker in
|
|
70
|
+
* the helper-16 voice, title on display-03 (Stack's ruled hero title),
|
|
71
|
+
* body mono-14. One emphasis (the title), everything else meta/body. */
|
|
72
|
+
/* UPPERCASE title, clamped 2, body clamped 2 — the shipped Stack hero, carried through (user 2026-08-27: "UPPERCASE THE FUCKING TITLE") */
|
|
73
|
+
hero: { kicker: 'kol-helper-16 text-meta', title: 'kol-sans-display-03 text-emphasis uppercase line-clamp-2 kol-content-title-dim', body: 'kol-mono-14 text-body line-clamp-2', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
|
|
67
74
|
},
|
|
68
75
|
work: {
|
|
69
76
|
/* INVERSE ink — the card's plate is the drawer, `surface-inverse`. Leaving
|
|
@@ -111,6 +118,8 @@ const ORDER = {
|
|
|
111
118
|
* form's own outer gap. A flat column gave every line the same gap, which
|
|
112
119
|
* read as unrelated lines rather than a heading with its standfirst. */
|
|
113
120
|
article: { card: ['tags', 'kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']], row: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']] },
|
|
121
|
+
/* the hero carries its tags as data (ListingCard hero: `data-tags` only) and its meta in the header row above the media — neither is a text line here */
|
|
122
|
+
articleHero: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']],
|
|
114
123
|
/* work CARD = the drawer's two lines: title, then one meta line.
|
|
115
124
|
*
|
|
116
125
|
* work ROW = WorkListItem, read off the live /work listing: a LEFT column of
|
|
@@ -155,7 +164,7 @@ const GAPS = {
|
|
|
155
164
|
default: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-2)' },
|
|
156
165
|
catalog: { card: 'var(--kol-spacing-1)', row: 'var(--kol-spacing-2)' },
|
|
157
166
|
print: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
|
|
158
|
-
article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)' },
|
|
167
|
+
article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)', hero: 'var(--kol-spacing-3)' },
|
|
159
168
|
work: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-4)' },
|
|
160
169
|
typeface: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-6)' },
|
|
161
170
|
}
|
|
@@ -169,7 +178,7 @@ export default function ContentText({
|
|
|
169
178
|
className = '',
|
|
170
179
|
}) {
|
|
171
180
|
const ramp = RAMP[variant]?.[form] ?? RAMP.default[form] ?? RAMP.default.card
|
|
172
|
-
const order = ORDER[variant]?.[form]
|
|
181
|
+
const order = (form === 'hero' && ORDER[`${variant}Hero`]) || ORDER[variant]?.[form] || ORDER.default.card
|
|
173
182
|
const values = { title, body, kicker, detail, date, size, meta, tags }
|
|
174
183
|
const overrides = { title: titleClass, body: bodyClass, kicker: kickerClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass, tags: tagsClass }
|
|
175
184
|
|
|
@@ -22,6 +22,9 @@ import IconFrame from '../atoms/IconFrame.jsx'
|
|
|
22
22
|
* @param {Function} props.renderItem — (filteredItems, viewMode, layout) => ReactNode
|
|
23
23
|
* @param {Array} props.viewModeOptions — optional view mode options for the view strip
|
|
24
24
|
* @param {string} props.defaultViewMode — default view mode (falls back to the FIRST option)
|
|
25
|
+
* @param {string} props.layout — controlled LIST/GRID value (kol-r2b2 2026-08-27: a consumer that persists
|
|
26
|
+
* layout per bucket needs the strip's value back; `defaultLayout` alone kept it internal)
|
|
27
|
+
* @param {Function} props.onLayoutChange — (layout) => void, fires on every strip click
|
|
25
28
|
* @param {Function} props.onFilterChange — optional callback when filters change
|
|
26
29
|
* @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
|
|
27
30
|
* @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
|
|
@@ -60,6 +63,8 @@ const ContentFilters = ({
|
|
|
60
63
|
defaultViewMode,
|
|
61
64
|
layoutOptions,
|
|
62
65
|
defaultLayout,
|
|
66
|
+
layout: layoutProp,
|
|
67
|
+
onLayoutChange,
|
|
63
68
|
/* WHERE the LIST/GRID strip sits — the two arrangements kol-monitor and
|
|
64
69
|
* kol-website each settled on, made interchangeable (user ruling 2026-08-15).
|
|
65
70
|
*
|
|
@@ -115,7 +120,13 @@ const ContentFilters = ({
|
|
|
115
120
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
116
121
|
const [internalViewMode, setInternalViewMode] = useState(defaultViewMode ?? viewModeOptions?.[0]?.value)
|
|
117
122
|
const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
|
|
118
|
-
|
|
123
|
+
/* Controlled when `layout` is passed, internal otherwise — the same pair viewMode uses. */
|
|
124
|
+
const [internalLayout, setInternalLayout] = useState(defaultLayout ?? layoutOptions?.[0]?.value ?? 'grid')
|
|
125
|
+
const layout = layoutProp !== undefined ? layoutProp : internalLayout
|
|
126
|
+
const setLayout = (next) => {
|
|
127
|
+
setInternalLayout(next)
|
|
128
|
+
onLayoutChange?.(next)
|
|
129
|
+
}
|
|
119
130
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
120
131
|
const [searchText, setSearchText] = useState('')
|
|
121
132
|
|