@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.
|
|
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.
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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=
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|