@kolkrabbi/kol-component 0.42.0 → 0.44.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 +2 -2
- package/src/atoms/AudioPlayer.jsx +51 -0
- package/src/index.js +4 -0
- package/src/organisms/ContentFilters.jsx +74 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@floating-ui/react": "^0.27.19",
|
|
31
31
|
"embla-carousel-react": "^8.6.0",
|
|
32
32
|
"react-syntax-highlighter": "^16.1.1",
|
|
33
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
33
|
+
"@kolkrabbi/kol-icons": "^0.17.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"framer-motion": "^12.0.0",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioPlayer — the interactive audio atom: one native <audio> with the UA's
|
|
3
|
+
* own control strip, and an optional label line above it.
|
|
4
|
+
*
|
|
5
|
+
* WHY IT EXISTS. Audio was the one media kind the design system had nothing for,
|
|
6
|
+
* so every consumer hand-rolled a bare `<audio controls>` and inherited whatever
|
|
7
|
+
* the browser painted. The MediaLibrary widening (kol-component 0.39.0) made
|
|
8
|
+
* audio objects arrive — 116 sound files in `kol-vault-media` alone — and left
|
|
9
|
+
* minting this as the open taxonomy call; this is that call made: an ATOM,
|
|
10
|
+
* beside HlsVideo.
|
|
11
|
+
*
|
|
12
|
+
* THE CONTRAST WITH HlsVideo, which is the thing to read before touching either.
|
|
13
|
+
* Same tier, same shape — one native media element, all layout via `className`,
|
|
14
|
+
* no composition — and **inverted intent**. HlsVideo is deliberately inert:
|
|
15
|
+
* `pointer-events: none`, `controls={false}`, plus the hardening set (no PiP, no
|
|
16
|
+
* download, no fullscreen, no remote playback, no context menu). It is
|
|
17
|
+
* decorative. This atom exists **to be operated**, so none of that hardening
|
|
18
|
+
* crosses over, and `controls` is not a prop — an audio player without controls
|
|
19
|
+
* is not a variant, it is a different component.
|
|
20
|
+
*
|
|
21
|
+
* The native strip is UA-painted and not themeable past `color-scheme`. Do not
|
|
22
|
+
* reach for pseudo-element hacks; a branded transport would be a separate
|
|
23
|
+
* `AudioTransport` molecule built on this atom.
|
|
24
|
+
*
|
|
25
|
+
* Layout is the call site's: the padding, the centring and the width that lived
|
|
26
|
+
* in the consumer source are all call-site concerns and none of them are baked
|
|
27
|
+
* in here.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} src audio URL; renders nothing when absent (the guard
|
|
30
|
+
* style the other media atoms use)
|
|
31
|
+
* @param {string} [label] line above the player; the element is omitted entirely
|
|
32
|
+
* when absent. Authored casing — no text-transform
|
|
33
|
+
* @param {'none'|'metadata'|'auto'} [preload='metadata'] native preload hint
|
|
34
|
+
* @param {string} [className] all layout/sizing (consumer-supplied)
|
|
35
|
+
*/
|
|
36
|
+
export default function AudioPlayer({
|
|
37
|
+
src,
|
|
38
|
+
label,
|
|
39
|
+
preload = 'metadata',
|
|
40
|
+
className = '',
|
|
41
|
+
...props
|
|
42
|
+
}) {
|
|
43
|
+
if (!src) return null
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<figure className={`kol-audio-player flex flex-col gap-3 ${className}`.trim()}>
|
|
47
|
+
{label && <figcaption className="kol-mono-12 text-fg-48">{label}</figcaption>}
|
|
48
|
+
<audio src={src} preload={preload} controls className="w-full" {...props} />
|
|
49
|
+
</figure>
|
|
50
|
+
)
|
|
51
|
+
}
|
package/src/index.js
CHANGED
|
@@ -33,6 +33,10 @@ export { default as ExitPreview } from './utilities/ExitPreview.jsx'
|
|
|
33
33
|
export { default as Figure } from './atoms/Figure.jsx'
|
|
34
34
|
export { default as FullscreenOverlay } from './utilities/FullscreenOverlay.jsx'
|
|
35
35
|
export { default as HlsVideo } from './atoms/HlsVideo.jsx'
|
|
36
|
+
/* AudioPlayer — HlsVideo's opposite number: same tier and shape, inverted
|
|
37
|
+
* intent (built to be operated, not decorative). See its header before editing
|
|
38
|
+
* either one. */
|
|
39
|
+
export { default as AudioPlayer } from './atoms/AudioPlayer.jsx'
|
|
36
40
|
export { default as Input } from './atoms/Input.jsx'
|
|
37
41
|
export { default as Label } from './atoms/Label.jsx'
|
|
38
42
|
export { default as LabeledControl } from './molecules/LabeledControl.jsx'
|
|
@@ -24,6 +24,7 @@ import IconFrame from '../atoms/IconFrame.jsx'
|
|
|
24
24
|
* @param {Function} props.onFilterChange — optional callback when filters change
|
|
25
25
|
* @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
|
|
26
26
|
* @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
|
|
27
|
+
* @param {ElementType} props.iconComponent — icon seam (defaults to DS Icon; needs `filter` + `search`)
|
|
27
28
|
*/
|
|
28
29
|
const ContentFilters = ({
|
|
29
30
|
items,
|
|
@@ -44,8 +45,12 @@ const ContentFilters = ({
|
|
|
44
45
|
searchKeys = ['label', 'name', 'title', 'type'],
|
|
45
46
|
headerActions,
|
|
46
47
|
showCountOnlyWhenFiltering = false,
|
|
48
|
+
iconComponent,
|
|
47
49
|
className = '',
|
|
48
50
|
}) => {
|
|
51
|
+
/* Icon seam — consumers on a local icon shelf pass their own component
|
|
52
|
+
* rather than being forced onto the DS set. Needs `filter` + `search`. */
|
|
53
|
+
const IconSeam = iconComponent || Icon
|
|
49
54
|
const [activeFilters, setActiveFilters] = useState(new Set())
|
|
50
55
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
51
56
|
const [internalViewMode, setInternalViewMode] = useState(defaultViewMode)
|
|
@@ -115,21 +120,38 @@ const ContentFilters = ({
|
|
|
115
120
|
})
|
|
116
121
|
}, [items, activeFilters, customFilterKeys, searchText, searchKeys])
|
|
117
122
|
|
|
123
|
+
/* THE FILTER VALUE IS A TAG — the atom's whole reason to exist ("a Tag with
|
|
124
|
+
* no handler is a Pill wearing the wrong name"). Three defects lived here
|
|
125
|
+
* until 2026-08-15, all of them working around the atom instead of using it:
|
|
126
|
+
*
|
|
127
|
+
* variant="default" — not a declared variant; `VARIANTS[v] ?? primary`
|
|
128
|
+
* silently rendered the FILLED chip. The outlined
|
|
129
|
+
* chip these want is `secondary`.
|
|
130
|
+
* className border-* — hand-rolled active state beside the atom's own
|
|
131
|
+
* `active` prop, which is what drives `.is-active`.
|
|
132
|
+
* <div onClick> — the handler on a wrapper, so Tag rendered a <span>
|
|
133
|
+
* and the interactive chip was not interactive.
|
|
134
|
+
*
|
|
135
|
+
* Casing is the atom's too: `.kol-tag` carries `text-transform: uppercase`
|
|
136
|
+
* as the component tier's ONE documented exception to the no-casing law,
|
|
137
|
+
* because a filter value is data with no authoring site. Consumers must not
|
|
138
|
+
* uppercase these themselves. */
|
|
118
139
|
const renderFilterGroup = (group) => (
|
|
119
|
-
<div key={group.key}>
|
|
140
|
+
<div key={group.key} className="flex flex-col gap-3">
|
|
120
141
|
<h4 className="kol-helper-12 text-fg-48">{group.label}</h4>
|
|
121
|
-
<div className="flex flex-wrap gap-2
|
|
122
|
-
{group.values.map((value) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
142
|
+
<div className="flex flex-wrap gap-2">
|
|
143
|
+
{group.values.map((value) => (
|
|
144
|
+
<Tag
|
|
145
|
+
key={value}
|
|
146
|
+
size="sm"
|
|
147
|
+
variant="secondary"
|
|
148
|
+
hash={false}
|
|
149
|
+
active={activeFilters.has(`${group.key}:${value}`)}
|
|
150
|
+
onClick={() => toggleFilter(group.key, value)}
|
|
151
|
+
>
|
|
152
|
+
{value}
|
|
153
|
+
</Tag>
|
|
154
|
+
))}
|
|
133
155
|
</div>
|
|
134
156
|
</div>
|
|
135
157
|
)
|
|
@@ -158,7 +180,7 @@ const ContentFilters = ({
|
|
|
158
180
|
aria-label="Toggle filters"
|
|
159
181
|
style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'inherit' }}
|
|
160
182
|
>
|
|
161
|
-
<
|
|
183
|
+
<IconSeam name="filter" size={20} />
|
|
162
184
|
</button>
|
|
163
185
|
<div
|
|
164
186
|
className="flex items-center justify-center rounded-sm cursor-pointer hover:bg-fg-04 transition-colors"
|
|
@@ -186,7 +208,7 @@ const ContentFilters = ({
|
|
|
186
208
|
position: searchOpen ? 'absolute' : 'relative',
|
|
187
209
|
}}
|
|
188
210
|
>
|
|
189
|
-
<
|
|
211
|
+
<IconSeam name="search" size={20} />
|
|
190
212
|
</span>
|
|
191
213
|
{searchOpen && (
|
|
192
214
|
<input
|
|
@@ -237,37 +259,47 @@ const ContentFilters = ({
|
|
|
237
259
|
|
|
238
260
|
<Divider className="mb-4" />
|
|
239
261
|
|
|
240
|
-
{
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
262
|
+
{/* ONE ROW BELOW THE DIVIDER (user ruling 2026-08-15, four rounds of QA
|
|
263
|
+
* against a fork of this file). Filter groups are left-aligned COLUMNS —
|
|
264
|
+
* label above values — and appear only while the filter toggle is open.
|
|
265
|
+
* The layout strip is right-aligned and ALWAYS visible.
|
|
266
|
+
*
|
|
267
|
+
* `items-start` is load-bearing: it pins the strip to the label row so
|
|
268
|
+
* the values hang beneath it. The strip previously rendered in its own
|
|
269
|
+
* row AFTER this block, which is why expanding a filter group pushed it
|
|
270
|
+
* down the page — the defect that started the whole ticket. */}
|
|
271
|
+
{(layoutOptions || isExpanded) && (
|
|
272
|
+
<div className="flex items-start justify-between gap-16 pb-4">
|
|
273
|
+
<div className="flex items-start gap-16">
|
|
274
|
+
{isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
|
|
275
|
+
{isExpanded && activeFilters.size > 0 && (
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
onClick={clearAllFilters}
|
|
279
|
+
className="kol-helper-12 transition-colors underline text-fg-48"
|
|
280
|
+
style={{ background: 'transparent', border: 'none', cursor: 'pointer' }}
|
|
281
|
+
>
|
|
282
|
+
Clear all ({activeFilters.size})
|
|
283
|
+
</button>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
{layoutOptions && (
|
|
287
|
+
<div className="flex items-center gap-4 flex-shrink-0">
|
|
288
|
+
{layoutOptions.map((opt) => (
|
|
289
|
+
<span
|
|
290
|
+
key={opt.value}
|
|
291
|
+
onClick={() => setLayout(opt.value)}
|
|
292
|
+
className={`kol-helper-12 cursor-pointer select-none ${layout === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
|
|
293
|
+
style={{ letterSpacing: 1 }}
|
|
294
|
+
>
|
|
295
|
+
{opt.label}
|
|
296
|
+
</span>
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
252
299
|
)}
|
|
253
300
|
</div>
|
|
254
301
|
)}
|
|
255
302
|
|
|
256
|
-
{layoutOptions && (
|
|
257
|
-
<div className="flex items-center justify-end gap-4 mt-4">
|
|
258
|
-
{layoutOptions.map((opt) => (
|
|
259
|
-
<span
|
|
260
|
-
key={opt.value}
|
|
261
|
-
onClick={() => setLayout(opt.value)}
|
|
262
|
-
className={`kol-helper-12 cursor-pointer select-none ${layout === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
|
|
263
|
-
style={{ letterSpacing: 1 }}
|
|
264
|
-
>
|
|
265
|
-
{opt.label}
|
|
266
|
-
</span>
|
|
267
|
-
))}
|
|
268
|
-
</div>
|
|
269
|
-
)}
|
|
270
|
-
|
|
271
303
|
<div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
272
304
|
{renderItem(filteredItems, viewMode, layout)}
|
|
273
305
|
</div>
|