@kolkrabbi/kol-component 0.56.0 → 0.57.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/organisms/ContentFilters.jsx +64 -76
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.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",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { useState, useMemo
|
|
1
|
+
import { useState, useMemo } from 'react'
|
|
2
2
|
import Tag from '../atoms/Tag.jsx'
|
|
3
3
|
import Divider from '../atoms/Divider.jsx'
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
5
5
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
6
6
|
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
7
|
+
import SearchInput from '../molecules/SearchInput.jsx'
|
|
7
8
|
import IconFrame from '../atoms/IconFrame.jsx'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -54,6 +55,16 @@ const ContentFilters = ({
|
|
|
54
55
|
defaultViewMode = 'list',
|
|
55
56
|
layoutOptions,
|
|
56
57
|
defaultLayout = 'grid',
|
|
58
|
+
/* WHERE the LIST/GRID strip sits — the two arrangements kol-monitor and
|
|
59
|
+
* kol-website each settled on, made interchangeable (user ruling 2026-08-15).
|
|
60
|
+
*
|
|
61
|
+
* 'below' header carries RECENT/SAVED; LIST/GRID sits below the divider
|
|
62
|
+
* beside the count. monitor's shape, and the default.
|
|
63
|
+
* 'header' LIST/GRID rides the header row instead.
|
|
64
|
+
*
|
|
65
|
+
* "N of N" is below the divider in BOTH — it is a count of what the filters
|
|
66
|
+
* did, so it belongs with them, and it only renders while they are open. */
|
|
67
|
+
layoutPlacement = 'below',
|
|
57
68
|
onFilterChange,
|
|
58
69
|
mutuallyExclusiveFilters = [],
|
|
59
70
|
customFilterKeys = [],
|
|
@@ -89,11 +100,7 @@ const ContentFilters = ({
|
|
|
89
100
|
const [layout, setLayout] = useState(defaultLayout)
|
|
90
101
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
91
102
|
const [searchText, setSearchText] = useState('')
|
|
92
|
-
const searchRef = useRef(null)
|
|
93
103
|
|
|
94
|
-
useEffect(() => {
|
|
95
|
-
if (searchOpen && searchRef.current) searchRef.current.focus()
|
|
96
|
-
}, [searchOpen])
|
|
97
104
|
|
|
98
105
|
const toggleFilter = (filterType, value) => {
|
|
99
106
|
const newFilters = new Set(activeFilters)
|
|
@@ -177,6 +184,21 @@ const ContentFilters = ({
|
|
|
177
184
|
* /work: a short closed set like Type reads as a column you scan down, while
|
|
178
185
|
* ~45 tags must wrap or they run off the page. One shape for both meant the
|
|
179
186
|
* short group ate a full row it did not need. */
|
|
187
|
+
const layoutStrip = layoutOptions ? (
|
|
188
|
+
<div className="flex items-center gap-4">
|
|
189
|
+
{layoutOptions.map((opt) => (
|
|
190
|
+
<span
|
|
191
|
+
key={opt.value}
|
|
192
|
+
onClick={() => setLayout(opt.value)}
|
|
193
|
+
className={`${layoutClassName} cursor-pointer select-none ${layout === opt.value ? stripActiveClassName : stripRestClassName}`}
|
|
194
|
+
style={{ letterSpacing: 1 }}
|
|
195
|
+
>
|
|
196
|
+
{opt.label}
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
) : null
|
|
201
|
+
|
|
180
202
|
const renderFilterGroup = (group) => (
|
|
181
203
|
<div key={group.key} className={`flex flex-col gap-3 ${group.stack ? 'shrink-0' : 'min-w-0 flex-1'}`}>
|
|
182
204
|
{/* THE CATEGORY LABEL IS THE ACTIVE INK — `kol-helper-12` at `text-fg-96`,
|
|
@@ -240,46 +262,24 @@ const ContentFilters = ({
|
|
|
240
262
|
* stop and the one ActionButton's docstring already names. */}
|
|
241
263
|
<IconSeam name="filter" size={glyphSize('md', true)} />
|
|
242
264
|
</button>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}}
|
|
256
|
-
|
|
257
|
-
{
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
className="kol-expand-content flex items-center justify-center flex-shrink-0"
|
|
262
|
-
style={{
|
|
263
|
-
opacity: searchOpen ? 0 : 1,
|
|
264
|
-
position: searchOpen ? 'absolute' : 'relative',
|
|
265
|
-
}}
|
|
266
|
-
>
|
|
267
|
-
<IconSeam name="search" size={glyphSize('md', true)} />
|
|
268
|
-
</span>
|
|
269
|
-
{searchOpen && (
|
|
270
|
-
<input
|
|
271
|
-
ref={searchRef}
|
|
272
|
-
type="text"
|
|
273
|
-
value={searchText}
|
|
274
|
-
onChange={(e) => setSearchText(e.target.value)}
|
|
275
|
-
onClick={(e) => e.stopPropagation()}
|
|
276
|
-
placeholder=""
|
|
277
|
-
className="bg-transparent outline-none kol-helper-12 flex-1 text-fg-80 caret-current px-4"
|
|
278
|
-
onBlur={() => { if (!searchText) setSearchOpen(false) }}
|
|
279
|
-
onKeyDown={(e) => { if (e.key === 'Escape') { setSearchOpen(false); setSearchText('') } }}
|
|
280
|
-
/>
|
|
281
|
-
)}
|
|
282
|
-
</div>
|
|
265
|
+
{/* THE SEARCH IS `SearchInput expanding` — the DS component, fully
|
|
266
|
+
* round, the same pill the nav shelf uses (user ruling
|
|
267
|
+
* 2026-08-15). This organism hand-rolled its own: a rounded-sm box
|
|
268
|
+
* with its own width animation, its own input, its own Escape and
|
|
269
|
+
* blur handling — a second search field the DS could not see, in
|
|
270
|
+
* a different shape from the one every other surface serves.
|
|
271
|
+
*
|
|
272
|
+
* Open state stays HERE because the filter row reads it (the count
|
|
273
|
+
* shows while searching); SearchInput takes it controlled. */}
|
|
274
|
+
<SearchInput
|
|
275
|
+
expanding
|
|
276
|
+
open={searchOpen}
|
|
277
|
+
onOpenChange={(next) => { setSearchOpen(next); if (!next) setSearchText('') }}
|
|
278
|
+
value={searchText}
|
|
279
|
+
onChange={(e) => setSearchText(e.target.value)}
|
|
280
|
+
expandedWidth={200}
|
|
281
|
+
triggerLabel="Search"
|
|
282
|
+
/>
|
|
283
283
|
{headerActions}
|
|
284
284
|
</div>
|
|
285
285
|
{activeFilters.size > 0 && (
|
|
@@ -294,34 +294,7 @@ const ContentFilters = ({
|
|
|
294
294
|
</div>
|
|
295
295
|
|
|
296
296
|
<div className="flex items-center gap-8">
|
|
297
|
-
{
|
|
298
|
-
*
|
|
299
|
-
* "N of N" appears ONLY while the filter panel is open (user ruling
|
|
300
|
-
* 2026-08-15) — with nothing filtered it always reads "12 of 12",
|
|
301
|
-
* which is a number that has never once told anyone anything. It
|
|
302
|
-
* earns its place the moment a filter can change it.
|
|
303
|
-
*
|
|
304
|
-
* Ink stays `text-fg-64`: static information, not a toggle, so it
|
|
305
|
-
* takes neither the 96 active nor the 32 rest. */}
|
|
306
|
-
{showCount && isExpanded && (
|
|
307
|
-
<span className={countClassName} style={{ letterSpacing: 1 }}>
|
|
308
|
-
{filteredItems.length} of {totalCount}
|
|
309
|
-
</span>
|
|
310
|
-
)}
|
|
311
|
-
{layoutOptions && (
|
|
312
|
-
<div className="flex items-center gap-4">
|
|
313
|
-
{layoutOptions.map((opt) => (
|
|
314
|
-
<span
|
|
315
|
-
key={opt.value}
|
|
316
|
-
onClick={() => setLayout(opt.value)}
|
|
317
|
-
className={`${layoutClassName} cursor-pointer select-none ${layout === opt.value ? stripActiveClassName : stripRestClassName}`}
|
|
318
|
-
style={{ letterSpacing: 1 }}
|
|
319
|
-
>
|
|
320
|
-
{opt.label}
|
|
321
|
-
</span>
|
|
322
|
-
))}
|
|
323
|
-
</div>
|
|
324
|
-
)}
|
|
297
|
+
{layoutPlacement === 'header' && layoutStrip}
|
|
325
298
|
{/* RECENT / SAVED is the SAME STRIP as LIST / GRID, not a ViewToggle.
|
|
326
299
|
* Read off kol-monitor's original (_tmp/2026-08-15-shell-adoption/
|
|
327
300
|
* ContentFilters.jsx:225-232): inline spans, `kol-helper-14`,
|
|
@@ -360,9 +333,9 @@ const ContentFilters = ({
|
|
|
360
333
|
* expanding filter group if it is not in the same row as one. */}
|
|
361
334
|
{isExpanded && (
|
|
362
335
|
<div className="flex items-start justify-between gap-16 pb-4">
|
|
363
|
-
<div className="flex items-start gap-16">
|
|
364
|
-
{
|
|
365
|
-
{
|
|
336
|
+
<div className="flex min-w-0 flex-1 items-start gap-16">
|
|
337
|
+
{filterGroups.map((group) => renderFilterGroup(group))}
|
|
338
|
+
{activeFilters.size > 0 && (
|
|
366
339
|
<button
|
|
367
340
|
type="button"
|
|
368
341
|
onClick={clearAllFilters}
|
|
@@ -373,6 +346,21 @@ const ContentFilters = ({
|
|
|
373
346
|
</button>
|
|
374
347
|
)}
|
|
375
348
|
</div>
|
|
349
|
+
|
|
350
|
+
{/* BELOW the divider: the count, and LIST/GRID when the arrangement
|
|
351
|
+
* puts it here. The count is a report of what the filters did, so it
|
|
352
|
+
* lives with them and appears only while they are open — unfiltered
|
|
353
|
+
* it always reads "N of N", a number that has never told anyone
|
|
354
|
+
* anything. Ink stays `text-fg-64`: static information, not a
|
|
355
|
+
* toggle, so it takes neither the 96 active nor the 32 rest. */}
|
|
356
|
+
<div className="flex flex-shrink-0 flex-col items-end gap-4">
|
|
357
|
+
{showCount && (
|
|
358
|
+
<span className={countClassName} style={{ letterSpacing: 1 }}>
|
|
359
|
+
{filteredItems.length} of {totalCount}
|
|
360
|
+
</span>
|
|
361
|
+
)}
|
|
362
|
+
{layoutPlacement === 'below' && layoutStrip}
|
|
363
|
+
</div>
|
|
376
364
|
</div>
|
|
377
365
|
)}
|
|
378
366
|
|