@kolkrabbi/kol-component 0.55.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.55.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, 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)
@@ -172,8 +179,28 @@ const ContentFilters = ({
172
179
  * as the component tier's ONE documented exception to the no-casing law,
173
180
  * because a filter value is data with no authoring site. Consumers must not
174
181
  * uppercase these themselves. */
182
+ /* A group either STACKS its values in a narrow column or WRAPS them across
183
+ * the room it is given (`group.stack`). Both shapes are live on kol-website's
184
+ * /work: a short closed set like Type reads as a column you scan down, while
185
+ * ~45 tags must wrap or they run off the page. One shape for both meant the
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
+
175
202
  const renderFilterGroup = (group) => (
176
- <div key={group.key} className="flex flex-col gap-3">
203
+ <div key={group.key} className={`flex flex-col gap-3 ${group.stack ? 'shrink-0' : 'min-w-0 flex-1'}`}>
177
204
  {/* THE CATEGORY LABEL IS THE ACTIVE INK — `kol-helper-12` at `text-fg-96`,
178
205
  * the same full opacity a SELECTED layout item wears (user ruling
179
206
  * 2026-08-15: "TAGS and other categories are ACTIVE state full opacity").
@@ -187,7 +214,7 @@ const ContentFilters = ({
187
214
  <h4 className={labelClassName} style={labelUppercase ? { textTransform: 'uppercase' } : undefined}>
188
215
  {group.label}
189
216
  </h4>
190
- <div className="flex flex-wrap gap-4">
217
+ <div className={group.stack ? 'flex flex-col items-start gap-2' : 'flex flex-wrap gap-2'}>
191
218
  {group.values.map((value) => (
192
219
  <Tag
193
220
  key={value}
@@ -235,46 +262,24 @@ const ContentFilters = ({
235
262
  * stop and the one ActionButton's docstring already names. */}
236
263
  <IconSeam name="filter" size={glyphSize('md', true)} />
237
264
  </button>
238
- <div
239
- className="kol-expand flex items-center justify-center rounded-sm cursor-pointer hover:bg-fg-04"
240
- style={{
241
- height: 32,
242
- width: searchOpen ? 200 : 32,
243
- background: searchOpen ? 'var(--kol-surface-secondary)' : 'transparent',
244
- border: 'none',
245
- overflow: 'hidden',
246
- }}
247
- onClick={() => {
248
- if (searchOpen) { setSearchOpen(false); setSearchText('') }
249
- else setSearchOpen(true)
250
- }}
251
- >
252
- {/* Same fake-button defect as the title icon: a decorative glyph
253
- * wearing kol-btn chrome. The clickable thing is the wrapper
254
- * div, not this span. */}
255
- <span
256
- className="kol-expand-content flex items-center justify-center flex-shrink-0"
257
- style={{
258
- opacity: searchOpen ? 0 : 1,
259
- position: searchOpen ? 'absolute' : 'relative',
260
- }}
261
- >
262
- <IconSeam name="search" size={glyphSize('md', true)} />
263
- </span>
264
- {searchOpen && (
265
- <input
266
- ref={searchRef}
267
- type="text"
268
- value={searchText}
269
- onChange={(e) => setSearchText(e.target.value)}
270
- onClick={(e) => e.stopPropagation()}
271
- placeholder=""
272
- className="bg-transparent outline-none kol-helper-12 flex-1 text-fg-80 caret-current px-4"
273
- onBlur={() => { if (!searchText) setSearchOpen(false) }}
274
- onKeyDown={(e) => { if (e.key === 'Escape') { setSearchOpen(false); setSearchText('') } }}
275
- />
276
- )}
277
- </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
+ />
278
283
  {headerActions}
279
284
  </div>
280
285
  {activeFilters.size > 0 && (
@@ -289,34 +294,7 @@ const ContentFilters = ({
289
294
  </div>
290
295
 
291
296
  <div className="flex items-center gap-8">
292
- {/* The count and LIST/GRID ride the HEADER row, above the divider.
293
- *
294
- * "N of N" appears ONLY while the filter panel is open (user ruling
295
- * 2026-08-15) — with nothing filtered it always reads "12 of 12",
296
- * which is a number that has never once told anyone anything. It
297
- * earns its place the moment a filter can change it.
298
- *
299
- * Ink stays `text-fg-64`: static information, not a toggle, so it
300
- * takes neither the 96 active nor the 32 rest. */}
301
- {showCount && isExpanded && (
302
- <span className={countClassName} style={{ letterSpacing: 1 }}>
303
- {filteredItems.length} of {totalCount}
304
- </span>
305
- )}
306
- {layoutOptions && (
307
- <div className="flex items-center gap-4">
308
- {layoutOptions.map((opt) => (
309
- <span
310
- key={opt.value}
311
- onClick={() => setLayout(opt.value)}
312
- className={`${layoutClassName} cursor-pointer select-none ${layout === opt.value ? stripActiveClassName : stripRestClassName}`}
313
- style={{ letterSpacing: 1 }}
314
- >
315
- {opt.label}
316
- </span>
317
- ))}
318
- </div>
319
- )}
297
+ {layoutPlacement === 'header' && layoutStrip}
320
298
  {/* RECENT / SAVED is the SAME STRIP as LIST / GRID, not a ViewToggle.
321
299
  * Read off kol-monitor's original (_tmp/2026-08-15-shell-adoption/
322
300
  * ContentFilters.jsx:225-232): inline spans, `kol-helper-14`,
@@ -355,9 +333,9 @@ const ContentFilters = ({
355
333
  * expanding filter group if it is not in the same row as one. */}
356
334
  {isExpanded && (
357
335
  <div className="flex items-start justify-between gap-16 pb-4">
358
- <div className="flex items-start gap-16">
359
- {isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
360
- {isExpanded && activeFilters.size > 0 && (
336
+ <div className="flex min-w-0 flex-1 items-start gap-16">
337
+ {filterGroups.map((group) => renderFilterGroup(group))}
338
+ {activeFilters.size > 0 && (
361
339
  <button
362
340
  type="button"
363
341
  onClick={clearAllFilters}
@@ -368,6 +346,21 @@ const ContentFilters = ({
368
346
  </button>
369
347
  )}
370
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>
371
364
  </div>
372
365
  )}
373
366