@kolkrabbi/kol-component 0.56.0 → 0.57.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.56.0",
3
+ "version": "0.57.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",
@@ -1,9 +1,10 @@
1
- import { useState, useMemo, useRef, useEffect } from 'react'
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
- <div
244
- className="kol-expand flex items-center justify-center rounded-sm cursor-pointer hover:bg-fg-04"
245
- style={{
246
- height: 32,
247
- width: searchOpen ? 200 : 32,
248
- background: searchOpen ? 'var(--kol-surface-secondary)' : 'transparent',
249
- border: 'none',
250
- overflow: 'hidden',
251
- }}
252
- onClick={() => {
253
- if (searchOpen) { setSearchOpen(false); setSearchText('') }
254
- else setSearchOpen(true)
255
- }}
256
- >
257
- {/* Same fake-button defect as the title icon: a decorative glyph
258
- * wearing kol-btn chrome. The clickable thing is the wrapper
259
- * div, not this span. */}
260
- <span
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
- {/* The count and LIST/GRID ride the HEADER row, above the divider.
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`,
@@ -358,9 +331,14 @@ const ContentFilters = ({
358
331
  * below the divider" cut. It also settles the defect that ruling was
359
332
  * fighting for good: the strip cannot be pushed down the page by an
360
333
  * expanding filter group if it is not in the same row as one. */}
361
- {isExpanded && (
334
+ {/* The row renders whenever it has ANYTHING to show. LIST/GRID is always
335
+ * visible — it is how you change the view, not a detail of filtering —
336
+ * while the groups and the count appear only with the panel open. Gating
337
+ * the whole row on `isExpanded` hid the strip until you opened filters,
338
+ * which is not a state anyone would guess at. */}
339
+ {(isExpanded || (layoutPlacement === 'below' && layoutStrip)) && (
362
340
  <div className="flex items-start justify-between gap-16 pb-4">
363
- <div className="flex items-start gap-16">
341
+ <div className="flex min-w-0 flex-1 items-start gap-16">
364
342
  {isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
365
343
  {isExpanded && activeFilters.size > 0 && (
366
344
  <button
@@ -373,6 +351,21 @@ const ContentFilters = ({
373
351
  </button>
374
352
  )}
375
353
  </div>
354
+
355
+ {/* BELOW the divider: the count, and LIST/GRID when the arrangement
356
+ * puts it here. The count is a report of what the filters did, so it
357
+ * lives with them and appears only while they are open — unfiltered
358
+ * it always reads "N of N", a number that has never told anyone
359
+ * anything. Ink stays `text-fg-64`: static information, not a
360
+ * toggle, so it takes neither the 96 active nor the 32 rest. */}
361
+ <div className="flex flex-shrink-0 flex-col items-end gap-4">
362
+ {showCount && isExpanded && (
363
+ <span className={countClassName} style={{ letterSpacing: 1 }}>
364
+ {filteredItems.length} of {totalCount}
365
+ </span>
366
+ )}
367
+ {layoutPlacement === 'below' && layoutStrip}
368
+ </div>
376
369
  </div>
377
370
  )}
378
371