@kolkrabbi/kol-component 0.58.0 → 0.60.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.58.0",
3
+ "version": "0.60.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,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
+ import { glyphSize } from '../hooks/glyphLadders.js'
3
4
 
4
5
  /**
5
6
  * SearchInput — controlled search field on the .kol-control shell. The
@@ -42,8 +43,29 @@ import { Icon } from '@kolkrabbi/kol-icons'
42
43
  * @param {string} className extra classes on the shell
43
44
  */
44
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. */
45
68
  const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14' }
46
- const ICON_SIZE = { sm: 14, md: 14 }
47
69
 
48
70
  export default function SearchInput({
49
71
  value = '',
@@ -76,6 +98,9 @@ export default function SearchInput({
76
98
  if (expanding && isOpen) inputRef.current?.focus()
77
99
  }, [expanding, isOpen])
78
100
 
101
+ /* the pinned squares, 05-control-chrome.md — sm 28 · md 32 · lg 36 */
102
+ const square = { sm: 28, md: 32, lg: 36 }[size] ?? 32
103
+
79
104
  if (expanding) {
80
105
  return (
81
106
  <div
@@ -84,20 +109,23 @@ export default function SearchInput({
84
109
  filter icon it sits beside. `bg-fg-04` was unconditional, so the
85
110
  collapsed pill rendered as a filled circle next to a bare glyph and
86
111
  the pair looked like two different kinds of control. */
87
- className={`kol-expand flex items-center rounded-full h-9 ${isOpen ? 'bg-fg-04' : ''} ${className}`.trim()}
88
- /* motion is `.kol-expand` in kol-theme; only the WIDTH is per-instance */
89
- style={{ width: isOpen ? expandedWidth : 36 }}
112
+ /* THE SQUARE FOLLOWS THE LADDER, and it follows `size` like every other
113
+ control: sm 28 · md 32 · lg 36 (hooks/glyphLadders.js). This was a
114
+ hardcoded 36 the LG square so an expanding search sat beside a
115
+ `kol-btn-md` filter button at two different sizes. */
116
+ className={`kol-expand flex items-center rounded-full ${isOpen ? 'bg-fg-04' : ''} ${className}`.trim()}
117
+ style={{ height: square, width: isOpen ? expandedWidth : square }}
90
118
  >
91
119
  <button
92
120
  type="button"
93
- className={`flex items-center justify-center w-9 h-9 rounded-full text-auto flex-shrink-0 border border-transparent ${isOpen ? '' : 'transition-colors hover:border-fg-12'}`}
121
+ className={`flex items-center justify-center rounded-full text-auto flex-shrink-0 border border-transparent ${isOpen ? '' : 'transition-colors hover:border-oq-16'}`}
122
+ style={{ width: square, height: square }}
94
123
  onClick={() => !isOpen && setOpen(true)}
95
124
  aria-label={triggerLabel}
96
125
  aria-expanded={isOpen}
97
126
  >
98
- {/* SOLO ladder, md rung a 36px trigger is the md square and takes
99
- a 20px glyph, not the sm rung's 16 */}
100
- <Icon name="search" size={20} className="text-fg-80" />
127
+ {/* SOLO ladder — the glyph moves WITH the square, never independently */}
128
+ <Icon name="search" size={glyphSize(size, true)} className="text-oq-80" />
101
129
  </button>
102
130
  {isOpen && (
103
131
  <input
@@ -108,7 +136,7 @@ export default function SearchInput({
108
136
  readOnly={!onChange || undefined}
109
137
  placeholder={placeholder}
110
138
  spellCheck={false}
111
- 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"
139
+ className={`bg-transparent outline-none ${SIZE_TYPE[size] ?? SIZE_TYPE.md} flex-1 text-oq-80 caret-current pr-4 min-w-0 appearance-none [&::-webkit-search-cancel-button]:hidden`}
112
140
  onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) }}
113
141
  {...inputProps}
114
142
  />
@@ -142,7 +170,8 @@ export default function SearchInput({
142
170
  aria-hidden="true"
143
171
  className={`flex items-center shrink-0 ${bare ? 'text-fg-48' : 'text-auto opacity-50'}`}
144
172
  >
145
- <Icon name="search" size={ICON_SIZE[size] ?? 14} />
173
+ {/* ADJACENT this glyph sits in the field's line box beside the query */}
174
+ <Icon name="search" size={glyphSize(size)} />
146
175
  </span>
147
176
  <input
148
177
  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
  *
@@ -95,9 +100,9 @@ const ContentFilters = ({
95
100
  const IconSeam = iconComponent || Icon
96
101
  const [activeFilters, setActiveFilters] = useState(new Set())
97
102
  const [isExpanded, setIsExpanded] = useState(false)
98
- const [internalViewMode, setInternalViewMode] = useState(defaultViewMode)
103
+ const [internalViewMode, setInternalViewMode] = useState(defaultViewMode ?? viewModeOptions?.[0]?.value)
99
104
  const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
100
- const [layout, setLayout] = useState(defaultLayout)
105
+ const [layout, setLayout] = useState(defaultLayout ?? layoutOptions?.[0]?.value ?? 'grid')
101
106
  const [searchOpen, setSearchOpen] = useState(false)
102
107
  const [searchText, setSearchText] = useState('')
103
108
 
@@ -237,7 +242,18 @@ const ContentFilters = ({
237
242
  * child refuses to shrink past its content, so a scrollable body pushed
238
243
  * the whole card taller instead of scrolling inside it. */
239
244
  <div className={`w-full ${className}`.trim()} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
240
- <div className="flex items-center justify-between mb-4">
245
+ {/* ONE VERTICAL RHYTHM for the whole block, chosen together instead of
246
+ * four independent literals (header mb-4, divider mb-4, strip row pb-4,
247
+ * body mt-8 — none of which knew about the others):
248
+ *
249
+ * header → divider 12 the divider belongs TO the header, close
250
+ * divider → strip 12 same, it belongs to what follows
251
+ * strip → content 24 the one real break, wall starts here
252
+ *
253
+ * 12/12/24 on the --kol-spacing-* rungs. The old 16/16/32 spaced the
254
+ * divider equally from both sides, so it read as a free-floating line
255
+ * rather than the header's own baseline. */}
256
+ <div className="flex items-center justify-between" style={{ marginBottom: 'var(--kol-spacing-3)' }}>
241
257
  <div className="flex items-center gap-6">
242
258
  {/* IconFrame, NOT a kol-btn span: this is decoration and clicks
243
259
  * nothing, so it must not wear a button's chrome. The atom exists
@@ -249,19 +265,23 @@ const ContentFilters = ({
249
265
  </h2>
250
266
  <Divider variant="vertical" className="self-stretch py-1" />
251
267
  <div className="flex items-center gap-1">
252
- <button
253
- type="button"
268
+ {/* IconFrame, not a kol-btn with its chrome cancelled inline.
269
+ * 05-control-chrome.md:109 — "any icon-only control in chrome is
270
+ * IconFrame; nothing hand-writes the square". This wore
271
+ * `kol-btn-md kol-btn-icon` and then removed the background, the
272
+ * border and the colour by inline style, which is the whole button
273
+ * paid for and thrown away — and it left the control with no
274
+ * states at all while the search beside it had hover.
275
+ *
276
+ * `nav` rests at oq-64 and lights to full ink when the panel is
277
+ * open, so the toggle finally SHOWS that it is on. */}
278
+ <IconFrame
279
+ name="filter"
280
+ variant={isExpanded ? 'primary' : 'nav'}
281
+ size="md"
254
282
  onClick={() => setIsExpanded(!isExpanded)}
255
- className="kol-btn kol-btn-md kol-btn-icon"
256
283
  aria-label="Toggle filters"
257
- style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'inherit' }}
258
- >
259
- {/* SOLO ladder, md rung — a 32px square takes a 20px glyph.
260
- * Both header glyphs were 16 (the sm rung) in md boxes, which is
261
- * the exact hand-transcription hooks/glyphLadders.js exists to
262
- * stop and the one ActionButton's docstring already names. */}
263
- <IconSeam name="filter" size={glyphSize('md', true)} />
264
- </button>
284
+ />
265
285
  {/* THE SEARCH IS `SearchInput expanding` — the DS component, fully
266
286
  * round, the same pill the nav shelf uses (user ruling
267
287
  * 2026-08-15). This organism hand-rolled its own: a rounded-sm box
@@ -279,6 +299,12 @@ const ContentFilters = ({
279
299
  onChange={(e) => setSearchText(e.target.value)}
280
300
  expandedWidth={200}
281
301
  triggerLabel="Search"
302
+ /* NO placeholder. The field opens from a glyph you just clicked —
303
+ * the caret is the affordance, and a greyed "Search…" sitting in
304
+ * a 200px pill is the widest thing in the header saying the least.
305
+ * The organism's own field never had one. */
306
+ placeholder=""
307
+ size="md"
282
308
  />
283
309
  {headerActions}
284
310
  </div>
@@ -321,7 +347,9 @@ const ContentFilters = ({
321
347
  </div>
322
348
  </div>
323
349
 
324
- <Divider className="mb-4" />
350
+ {/* Divider takes className, NOT style — a style prop here is silently
351
+ * dropped, which is how this margin nearly went missing. */}
352
+ <Divider className="mb-3" />
325
353
 
326
354
  {/* BELOW the divider: the filter GROUPS only. Left-aligned columns —
327
355
  * label above values — visible only while the filter toggle is open.
@@ -337,7 +365,7 @@ const ContentFilters = ({
337
365
  * the whole row on `isExpanded` hid the strip until you opened filters,
338
366
  * which is not a state anyone would guess at. */}
339
367
  {(isExpanded || (layoutPlacement === 'below' && layoutStrip)) && (
340
- <div className="flex items-start justify-between gap-16 pb-4">
368
+ <div className="flex items-start justify-between gap-16">
341
369
  <div className="flex min-w-0 flex-1 items-start gap-16">
342
370
  {isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
343
371
  {isExpanded && activeFilters.size > 0 && (
@@ -369,7 +397,7 @@ const ContentFilters = ({
369
397
  </div>
370
398
  )}
371
399
 
372
- <div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
400
+ <div style={{ marginTop: 'var(--kol-spacing-6)', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
373
401
  {renderItem(filteredItems, viewMode, layout)}
374
402
  </div>
375
403
  </div>