@kolkrabbi/kol-component 0.59.0 → 0.61.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.59.0",
3
+ "version": "0.61.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.17.0"
33
+ "@kolkrabbi/kol-icons": "^0.18.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "framer-motion": "^12.0.0",
@@ -43,8 +43,29 @@ import { glyphSize } from '../hooks/glyphLadders.js'
43
43
  * @param {string} className extra classes on the shell
44
44
  */
45
45
 
46
+ /* ONE type system and ONE glyph system for the whole component.
47
+ *
48
+ * It had two of each. The chromed path typed on `kol-mono-*` while the
49
+ * expanding path typed on `kol-helper-*`; the chromed path drew a FLAT 14px
50
+ * glyph at every size (`{ sm: 14, md: 14 }` — a size table that does not size)
51
+ * while the expanding path read the SOLO ladder. So the same component
52
+ * rendered two different fields depending on which branch you hit, and neither
53
+ * matched the IconFrame sitting next to it.
54
+ *
55
+ * Type is the mono ramp — a search field holds a query that can wrap, and
56
+ * `kol-helper-*` is line-height-1 chrome.
57
+ *
58
+ * The GLYPH READS BOTH LADDERS, because this component has both cases and they
59
+ * are the exact split `glyphLadders.js` exists for:
60
+ *
61
+ * SOLO the collapsed `expanding` trigger — a glyph ALONE in a pinned
62
+ * square, nothing beside it. 16 / 20 / 24.
63
+ * ADJACENT the leading glyph inside an open field, sitting in the input's
64
+ * line box beside the query text. 14 / 16 / 18.
65
+ *
66
+ * The flat `{ sm: 14, md: 14 }` it used to carry was the ADJACENT sm rung
67
+ * frozen for both sizes — right ladder, no size. */
46
68
  const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14' }
47
- const ICON_SIZE = { sm: 14, md: 14 }
48
69
 
49
70
  export default function SearchInput({
50
71
  value = '',
@@ -79,10 +100,32 @@ export default function SearchInput({
79
100
 
80
101
  /* the pinned squares, 05-control-chrome.md — sm 28 · md 32 · lg 36 */
81
102
  const square = { sm: 28, md: 32, lg: 36 }[size] ?? 32
103
+ /* THE OPEN FIELD IS SHORTER THAN THE TRIGGER'S HIT AREA. The square is the
104
+ * click target and it is INVISIBLE at rest (no fill), so the pill does not
105
+ * have to inherit its height — at 32 the field read as a chunky input beside
106
+ * two bare glyphs. It sits one rung down and centres inside the same box. */
107
+ const fieldH = square - 4
108
+
109
+ /* Escape closes, and so does clicking away — a field that can only be closed
110
+ * by emptying it and blurring is a trap, and this one had neither wired. The
111
+ * listener only exists while open. */
112
+ const shellRef = useRef(null)
113
+ useEffect(() => {
114
+ if (!expanding || !isOpen) return undefined
115
+ const away = (e) => { if (shellRef.current && !shellRef.current.contains(e.target)) setOpen(false) }
116
+ const esc = (e) => { if (e.key === 'Escape') setOpen(false) }
117
+ document.addEventListener('mousedown', away)
118
+ document.addEventListener('keydown', esc)
119
+ return () => {
120
+ document.removeEventListener('mousedown', away)
121
+ document.removeEventListener('keydown', esc)
122
+ }
123
+ }, [expanding, isOpen])
82
124
 
83
125
  if (expanding) {
84
126
  return (
85
127
  <div
128
+ ref={shellRef}
86
129
  /* THE FILL BELONGS TO THE FIELD, NOT THE TRIGGER. At rest this is a
87
130
  glyph you click, and it must read as one — bare, exactly like the
88
131
  filter icon it sits beside. `bg-fg-04` was unconditional, so the
@@ -93,19 +136,27 @@ export default function SearchInput({
93
136
  hardcoded 36 — the LG square — so an expanding search sat beside a
94
137
  `kol-btn-md` filter button at two different sizes. */
95
138
  className={`kol-expand flex items-center rounded-full ${isOpen ? 'bg-fg-04' : ''} ${className}`.trim()}
96
- style={{ height: square, width: isOpen ? expandedWidth : square }}
139
+ style={{ height: isOpen ? fieldH : square, width: isOpen ? expandedWidth : square }}
97
140
  >
98
- <button
99
- type="button"
100
- className={`flex items-center justify-center rounded-full text-auto flex-shrink-0 border border-transparent ${isOpen ? '' : 'transition-colors hover:border-oq-16'}`}
101
- style={{ width: square, height: square }}
102
- onClick={() => !isOpen && setOpen(true)}
103
- aria-label={triggerLabel}
104
- aria-expanded={isOpen}
105
- >
106
- {/* SOLO ladder — the glyph moves WITH the square, never independently */}
107
- <Icon name="search" size={glyphSize(size, true)} className="text-oq-80" />
108
- </button>
141
+ {/* THE GLYPH IS THE CLOSED STATE, and only that (user ruling
142
+ 2026-08-15). Once the field is open the caret is the affordance;
143
+ keeping the magnifier there spends the widest part of the pill
144
+ restating what the blinking cursor already says. */}
145
+ {!isOpen && (
146
+ <button
147
+ type="button"
148
+ className="flex items-center justify-center rounded-full text-auto flex-shrink-0 border border-transparent transition-colors hover:border-oq-16"
149
+ style={{ width: square, height: square }}
150
+ onClick={() => setOpen(true)}
151
+ aria-label={triggerLabel}
152
+ aria-expanded={false}
153
+ >
154
+ {/* SOLO ladder, and NO ink class — it inherits from its chrome
155
+ exactly as IconFrame's glyph does, so it cannot drift from the
156
+ icon beside it. */}
157
+ <Icon name="search" size={glyphSize(size, true)} />
158
+ </button>
159
+ )}
109
160
  {isOpen && (
110
161
  <input
111
162
  ref={inputRef}
@@ -115,7 +166,7 @@ export default function SearchInput({
115
166
  readOnly={!onChange || undefined}
116
167
  placeholder={placeholder}
117
168
  spellCheck={false}
118
- className="bg-transparent outline-none kol-helper-14 flex-1 text-fg-80 caret-current pr-4 min-w-0 appearance-none [&::-webkit-search-cancel-button]:hidden"
169
+ className={`bg-transparent outline-none ${SIZE_TYPE[size] ?? SIZE_TYPE.md} flex-1 text-oq-80 caret-current px-4 min-w-0 appearance-none [&::-webkit-search-cancel-button]:hidden`}
119
170
  onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) }}
120
171
  {...inputProps}
121
172
  />
@@ -149,7 +200,8 @@ export default function SearchInput({
149
200
  aria-hidden="true"
150
201
  className={`flex items-center shrink-0 ${bare ? 'text-fg-48' : 'text-auto opacity-50'}`}
151
202
  >
152
- <Icon name="search" size={ICON_SIZE[size] ?? 14} />
203
+ {/* ADJACENT this glyph sits in the field's line box beside the query */}
204
+ <Icon name="search" size={glyphSize(size)} />
153
205
  </span>
154
206
  <input
155
207
  type="search"
@@ -21,7 +21,7 @@ import IconFrame from '../atoms/IconFrame.jsx'
21
21
  * @param {Array} props.filterGroups — [{label, key, values}, ...]
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
- * @param {string} props.defaultViewMode — default view mode (default: 'list')
24
+ * @param {string} props.defaultViewMode — default view mode (falls back to the FIRST option)
25
25
  * @param {Function} props.onFilterChange — optional callback when filters change
26
26
  * @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
27
27
  * @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
@@ -52,9 +52,14 @@ const ContentFilters = ({
52
52
  viewModeOptions,
53
53
  viewMode: viewModeProp,
54
54
  onViewModeChange,
55
- defaultViewMode = 'list',
55
+ /* NO literal default. A strip always has exactly one active item, so the
56
+ * fallback must be the FIRST OPTION, not a guess — `'list'` matched nothing
57
+ * in a RECENT/SAVED strip, so any consumer that passed options without also
58
+ * passing a default rendered BOTH items in the rest state and the strip
59
+ * looked broken rather than unset. Same for `defaultLayout`. */
60
+ defaultViewMode,
56
61
  layoutOptions,
57
- defaultLayout = 'grid',
62
+ defaultLayout,
58
63
  /* WHERE the LIST/GRID strip sits — the two arrangements kol-monitor and
59
64
  * kol-website each settled on, made interchangeable (user ruling 2026-08-15).
60
65
  *
@@ -76,8 +81,12 @@ const ContentFilters = ({
76
81
  /* THE LOOK SEAMS. Every one defaults to what the component shipped, so
77
82
  * passing nothing renders exactly as before — these exist because the
78
83
  * hardcoded values were the wrong call for every consumer but one. */
84
+ /* The title is the SAME as RECENT/SAVED (user ruling 2026-08-15) — same
85
+ * rung, same casing, same tracking. It sits at the other end of the same
86
+ * row, so a different treatment reads as two unrelated things rather than
87
+ * one strip of chrome. */
79
88
  titleClassName = 'kol-helper-14',
80
- titleUppercase = false,
89
+ titleUppercase = true,
81
90
  labelClassName = 'kol-helper-12 text-fg-96',
82
91
  labelUppercase = true,
83
92
  tagVariant = 'primary',
@@ -86,8 +95,13 @@ const ContentFilters = ({
86
95
  tagRestClassName = 'text-fg-48',
87
96
  viewClassName = 'kol-helper-14',
88
97
  layoutClassName = 'kol-helper-12',
89
- stripActiveClassName = 'text-fg-96',
90
- stripRestClassName = 'text-fg-32 hover:text-fg-48',
98
+ /* REST WAS TOO DARK (user ruling 2026-08-15). `fg-32` is a third of the ink
99
+ * and read as DISABLED rather than unselected — the same complaint the
100
+ * IconFrame `nav` variant already settled ("the dimmed row read as disabled
101
+ * beside the ThemeToggle"). Rest is now the ghost rung, oq-48, and the whole
102
+ * pair moves to the opaque tier so it never washes out over a tinted surface. */
103
+ stripActiveClassName = 'text-oq-96',
104
+ stripRestClassName = 'text-oq-48 hover:text-oq-64',
91
105
  countClassName = 'kol-helper-12 text-fg-64',
92
106
  }) => {
93
107
  /* Icon seam — consumers on a local icon shelf pass their own component
@@ -95,9 +109,9 @@ const ContentFilters = ({
95
109
  const IconSeam = iconComponent || Icon
96
110
  const [activeFilters, setActiveFilters] = useState(new Set())
97
111
  const [isExpanded, setIsExpanded] = useState(false)
98
- const [internalViewMode, setInternalViewMode] = useState(defaultViewMode)
112
+ const [internalViewMode, setInternalViewMode] = useState(defaultViewMode ?? viewModeOptions?.[0]?.value)
99
113
  const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
100
- const [layout, setLayout] = useState(defaultLayout)
114
+ const [layout, setLayout] = useState(defaultLayout ?? layoutOptions?.[0]?.value ?? 'grid')
101
115
  const [searchOpen, setSearchOpen] = useState(false)
102
116
  const [searchText, setSearchText] = useState('')
103
117
 
@@ -256,7 +270,7 @@ const ContentFilters = ({
256
270
  * states"); the span here was the same defect that promoted it. */}
257
271
  <h2 className="flex items-center gap-2">
258
272
  {titleIcon && <IconFrame name={titleIcon} variant="secondary" size="md" />}
259
- <span className={titleClassName} style={titleUppercase ? { textTransform: 'uppercase' } : undefined}>{title}</span>
273
+ <span className={titleClassName} style={titleUppercase ? { textTransform: 'uppercase', letterSpacing: 1 } : undefined}>{title}</span>
260
274
  </h2>
261
275
  <Divider variant="vertical" className="self-stretch py-1" />
262
276
  <div className="flex items-center gap-1">
@@ -270,9 +284,16 @@ const ContentFilters = ({
270
284
  *
271
285
  * `nav` rests at oq-64 and lights to full ink when the panel is
272
286
  * open, so the toggle finally SHOWS that it is on. */}
287
+ {/* MD — 32px square, 20px glyph (user ruling 2026-08-15). The
288
+ * squares are 28 · 32 · 36, so 32 is the MIDDLE rung, not a large
289
+ * one; the search beside it takes the same. */}
273
290
  <IconFrame
274
291
  name="filter"
275
- variant={isExpanded ? 'primary' : 'nav'}
292
+ /* `nav` ALWAYS no container. The open state is the panel
293
+ * appearing below; painting a filled square behind the glyph as
294
+ * well says the same thing twice and puts a box in a row that
295
+ * has none. */
296
+ variant="nav"
276
297
  size="md"
277
298
  onClick={() => setIsExpanded(!isExpanded)}
278
299
  aria-label="Toggle filters"
@@ -294,6 +315,12 @@ const ContentFilters = ({
294
315
  onChange={(e) => setSearchText(e.target.value)}
295
316
  expandedWidth={200}
296
317
  triggerLabel="Search"
318
+ /* NO placeholder. The field opens from a glyph you just clicked —
319
+ * the caret is the affordance, and a greyed "Search…" sitting in
320
+ * a 200px pill is the widest thing in the header saying the least.
321
+ * The organism's own field never had one. */
322
+ placeholder=""
323
+ size="md"
297
324
  />
298
325
  {headerActions}
299
326
  </div>
@@ -375,7 +402,9 @@ const ContentFilters = ({
375
402
  * it always reads "N of N", a number that has never told anyone
376
403
  * anything. Ink stays `text-fg-64`: static information, not a
377
404
  * toggle, so it takes neither the 96 active nor the 32 rest. */}
378
- <div className="flex flex-shrink-0 flex-col items-end gap-4">
405
+ {/* ONE LINE the count sits BESIDE LIST/GRID, not stacked above it.
406
+ * They are the same strip of chrome reading left to right. */}
407
+ <div className="flex flex-shrink-0 items-center gap-4">
379
408
  {showCount && isExpanded && (
380
409
  <span className={countClassName} style={{ letterSpacing: 1 }}>
381
410
  {filteredItems.length} of {totalCount}