@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.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
99
|
-
|
|
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=
|
|
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
|
-
|
|
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 (
|
|
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
|
*
|
|
@@ -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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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>
|