@kolkrabbi/kol-component 0.230.0 → 0.231.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 +2 -2
- package/src/atoms/Kbd.jsx +33 -0
- package/src/index.js +1 -0
- package/src/molecules/SearchInput.jsx +10 -9
- package/src/organisms/ShellSearchOverlay.jsx +82 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.231.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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
40
|
+
"@kolkrabbi/kol-icons": "^0.31.0"
|
|
41
41
|
},
|
|
42
42
|
"files": [
|
|
43
43
|
"src",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kbd — a key cap: one key or chord (↵, ⌘K, Esc) shown as an affordance.
|
|
3
|
+
* Replaces the two hand-rolled <kbd> chips (SearchInput's shortcut hint and
|
|
4
|
+
* the palette footer) that had drifted apart (2026-09-30). A plate, so it
|
|
5
|
+
* takes oq — never fg (icons-use-oq law).
|
|
6
|
+
*
|
|
7
|
+
* aria-hidden by default: a cap is a hint beside a label, not the label.
|
|
8
|
+
*
|
|
9
|
+
* @param {'sm'|'md'} size sm = inside a control (16px, helper-10) ·
|
|
10
|
+
* md = beside helper-12 text (20px)
|
|
11
|
+
* @param {string} icon kol-icons name drawn before the children — a key
|
|
12
|
+
* with a symbol (↵ `corner-down-left`, ⌘ `command`)
|
|
13
|
+
* is a GLYPH, never a typed character in the mono face
|
|
14
|
+
*/
|
|
15
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
16
|
+
|
|
17
|
+
const SIZE = {
|
|
18
|
+
sm: 'h-4 min-w-4 px-1 kol-helper-10',
|
|
19
|
+
md: 'h-5 min-w-5 px-1.5 kol-helper-12',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function Kbd({ size = 'md', icon, className = '', children, ...rest }) {
|
|
23
|
+
return (
|
|
24
|
+
<kbd
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
className={`inline-flex items-center justify-center gap-0.5 shrink-0 rounded-[var(--kol-radius-xs)] bg-oq-08 text-oq-64 ${SIZE[size] ?? SIZE.md} ${className}`.trim()}
|
|
27
|
+
{...rest}
|
|
28
|
+
>
|
|
29
|
+
{icon && <Icon name={icon} size={12} />}
|
|
30
|
+
{children}
|
|
31
|
+
</kbd>
|
|
32
|
+
)
|
|
33
|
+
}
|
package/src/index.js
CHANGED
|
@@ -59,6 +59,7 @@ export { default as SectionText } from './molecules/SectionText.jsx'
|
|
|
59
59
|
export { default as SectionLabel } from './atoms/SectionLabel.jsx'
|
|
60
60
|
export { default as SegmentedToggle } from './atoms/SegmentedToggle.jsx'
|
|
61
61
|
export { default as Stepper } from './molecules/Stepper.jsx'
|
|
62
|
+
export { default as Kbd } from './atoms/Kbd.jsx'
|
|
62
63
|
export { default as Tag } from './atoms/Tag.jsx'
|
|
63
64
|
export { default as Textarea } from './atoms/Textarea.jsx'
|
|
64
65
|
export { default as ToggleBracket } from './atoms/ToggleBracket.jsx'
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import { toneClass } from '../utilities/tone.js'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
4
|
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
5
|
+
import Kbd from '../atoms/Kbd.jsx'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* SearchInput — controlled search field on the .kol-control shell. The
|
|
@@ -200,8 +201,10 @@ export default function SearchInput({
|
|
|
200
201
|
const shellCls = [
|
|
201
202
|
bare
|
|
202
203
|
/* kol-control--bare: zero-chrome marker so the theme's coarse-pointer
|
|
203
|
-
* 16px floor covers this body plan too (OverlaySearchFieldZoomsIOS)
|
|
204
|
-
|
|
204
|
+
* 16px floor covers this body plan too (OverlaySearchFieldZoomsIOS).
|
|
205
|
+
* Chrome off, geometry on: the size's padding + a transparent 1px ring
|
|
206
|
+
* land it on the 22 · 26 · 32 · 40 ladder like the shell (09-sizes) */
|
|
207
|
+
? `kol-control--bare kol-control-${size} flex w-full border border-transparent gap-2`
|
|
205
208
|
: `kol-control${variant ? ` kol-control--${variant}` : ''} kol-control-${size} gap-2${toneClass(tone) ? ` ${toneClass(tone)}` : ''}`,
|
|
206
209
|
'items-center cursor-text',
|
|
207
210
|
SIZE_TYPE[size],
|
|
@@ -225,7 +228,7 @@ export default function SearchInput({
|
|
|
225
228
|
<label className={shellCls}>
|
|
226
229
|
<span
|
|
227
230
|
aria-hidden="true"
|
|
228
|
-
className={`flex items-center shrink-0 ${bare ? 'text-
|
|
231
|
+
className={`flex items-center shrink-0 ${bare ? 'text-oq-48' : 'text-auto opacity-50'}`}
|
|
229
232
|
>
|
|
230
233
|
{/* ADJACENT — this glyph sits in the field's line box beside the query */}
|
|
231
234
|
<Icon name="search" size={iconSize ?? glyphSize(size)} />
|
|
@@ -254,12 +257,10 @@ export default function SearchInput({
|
|
|
254
257
|
</button>
|
|
255
258
|
) : shortcutHint ? (
|
|
256
259
|
/* aria-hidden — affordance, not a label (same stance as Input's prefix/suffix) */
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
{shortcutHint}
|
|
262
|
-
</kbd>
|
|
260
|
+
/* ⌘ is drawn, not typed — the mono face's ⌘ is the "drawn by a child" cap */
|
|
261
|
+
shortcutHint.startsWith('⌘')
|
|
262
|
+
? <Kbd size="sm" icon="command">{shortcutHint.slice(1)}</Kbd>
|
|
263
|
+
: <Kbd size="sm">{shortcutHint}</Kbd>
|
|
263
264
|
) : null}
|
|
264
265
|
</label>
|
|
265
266
|
)
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { useEffect, useId, useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
3
|
import SearchInput from '../molecules/SearchInput.jsx'
|
|
3
4
|
import Tag from '../atoms/Tag.jsx'
|
|
5
|
+
import Kbd from '../atoms/Kbd.jsx'
|
|
6
|
+
|
|
7
|
+
/* ONE COLUMN (2026-09-30): rows, headings and footer sit on the field's own
|
|
8
|
+
* geometry — the p-2 inset, then the md control's 1px ring + 16px pad — so
|
|
9
|
+
* every glyph and word lines up under the field's icon and caret. */
|
|
10
|
+
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
11
|
+
|
|
12
|
+
/* Rows bucketed by `group` in first-seen order, rank kept inside a group —
|
|
13
|
+
* one heading per group; arrows rove this order, not the engine's. */
|
|
14
|
+
function groupRows(rows) {
|
|
15
|
+
const order = []
|
|
16
|
+
const byGroup = new Map()
|
|
17
|
+
rows.forEach((r) => {
|
|
18
|
+
const g = r.group ?? ''
|
|
19
|
+
if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g) }
|
|
20
|
+
byGroup.get(g).push(r)
|
|
21
|
+
})
|
|
22
|
+
return order.flatMap((g) => byGroup.get(g))
|
|
23
|
+
}
|
|
4
24
|
|
|
5
25
|
/**
|
|
6
26
|
* HighlightMatch — default row renderer: underlines the first
|
|
@@ -56,7 +76,9 @@ export function HighlightMatch({ label, query, ranges }) {
|
|
|
56
76
|
*
|
|
57
77
|
* @param {boolean} open mount/unmount the overlay
|
|
58
78
|
* @param {Function} onClose () => void — backdrop click, Escape, post-select
|
|
59
|
-
* @param {Array} results pre-filtered rows: { id, label, group?, hint? }
|
|
79
|
+
* @param {Array} results pre-filtered rows: { id, label, group?, hint?, icon? }
|
|
80
|
+
* @param {Array} [suggestions] rows shown while the query is empty (same shape) —
|
|
81
|
+
* the palette opens on somewhere to go, not a blank box
|
|
60
82
|
* @param {string} query controlled query (drives the highlight slice)
|
|
61
83
|
* @param {Function} onQueryChange (string) => void — input change
|
|
62
84
|
* @param {Function} onSelect (item) => void — row click / Enter; consumer navigates
|
|
@@ -69,7 +91,8 @@ export function HighlightMatch({ label, query, ranges }) {
|
|
|
69
91
|
export default function ShellSearchOverlay({
|
|
70
92
|
open,
|
|
71
93
|
onClose,
|
|
72
|
-
results = [],
|
|
94
|
+
results: rawResults = [],
|
|
95
|
+
suggestions = [],
|
|
73
96
|
/* EXPANDED — the palette's second state (user ruling 2026-08-01). Enter
|
|
74
97
|
* commits the query and opens `children` as the results body; the palette
|
|
75
98
|
* and the old tag overlay are one surface with two states, not two
|
|
@@ -92,6 +115,7 @@ export default function ShellSearchOverlay({
|
|
|
92
115
|
/* Has the user actually chosen a row? See the Enter branch — without this,
|
|
93
116
|
* index 0 counts as a selection and Enter navigates somewhere unasked. */
|
|
94
117
|
const [navigated, setNavigated] = useState(false)
|
|
118
|
+
const results = groupRows(query ? rawResults : suggestions)
|
|
95
119
|
const active = results.length > 0 ? Math.min(activeIndex, results.length - 1) : -1
|
|
96
120
|
|
|
97
121
|
/* Focus in on open, restore the opener on close. querySelector instead of
|
|
@@ -110,7 +134,7 @@ export default function ShellSearchOverlay({
|
|
|
110
134
|
/* Keep the active row visible inside the scrolling list. */
|
|
111
135
|
useEffect(() => {
|
|
112
136
|
if (active < 0) return
|
|
113
|
-
listRef.current?.
|
|
137
|
+
listRef.current?.querySelectorAll('[role="option"]')[active]?.scrollIntoView({ block: 'nearest' })
|
|
114
138
|
}, [active])
|
|
115
139
|
|
|
116
140
|
if (!open) return null
|
|
@@ -141,7 +165,8 @@ export default function ShellSearchOverlay({
|
|
|
141
165
|
* highlighted" is true from the first keystroke and testing `active >= 0`
|
|
142
166
|
* made Enter navigate to whatever happened to be first. Committing a
|
|
143
167
|
* query must never be a navigation you didn't choose. */
|
|
144
|
-
|
|
168
|
+
/* An empty query has nothing to commit — Enter goes to the top suggestion. */
|
|
169
|
+
if (active >= 0 && (navigated || !query)) select(results[active])
|
|
145
170
|
else onExpand?.()
|
|
146
171
|
} else if (e.key === 'Tab') {
|
|
147
172
|
/* Focus trap — the input is the palette's only tab stop. */
|
|
@@ -182,8 +207,11 @@ export default function ShellSearchOverlay({
|
|
|
182
207
|
))}
|
|
183
208
|
</div>
|
|
184
209
|
)}
|
|
210
|
+
{/* A REAL FIELD, inset in the panel (2026-09-30, shadcn's palette as
|
|
211
|
+
* the aim) — the flush `bare` strip read as a hole, not a control. */}
|
|
212
|
+
<div className="kol-tone-grey p-2">
|
|
185
213
|
<SearchInput
|
|
186
|
-
|
|
214
|
+
className="w-full"
|
|
187
215
|
value={query}
|
|
188
216
|
onChange={(e) => onQueryChange?.(e.target.value)}
|
|
189
217
|
placeholder={chips.length > 0 ? 'Narrow these results…' : placeholder}
|
|
@@ -193,6 +221,7 @@ export default function ShellSearchOverlay({
|
|
|
193
221
|
aria-controls={listId}
|
|
194
222
|
aria-activedescendant={active >= 0 ? optionId(results[active]) : undefined}
|
|
195
223
|
/>
|
|
224
|
+
</div>
|
|
196
225
|
|
|
197
226
|
{/* WHY THIS IS NOT `molecules/Dropdown` (asked 2026-08-01). Dropdown is
|
|
198
227
|
* a SELECT: a trigger, a `value`, `onChange(value)`, and rows that are
|
|
@@ -204,7 +233,8 @@ export default function ShellSearchOverlay({
|
|
|
204
233
|
*
|
|
205
234
|
* THE ROW CONTRACT (was documented nowhere):
|
|
206
235
|
* label the row's text, match-highlighted against the query
|
|
207
|
-
* group
|
|
236
|
+
* group section heading the row files under — 'Atoms', 'Documentation', 'Tags'
|
|
237
|
+
* icon optional leading glyph (kol-icons name)
|
|
208
238
|
* hint subtext shown when the LABEL was not what matched
|
|
209
239
|
* href a destination; dismisses the palette
|
|
210
240
|
* action a closure; runs and KEEPS the palette open (tag rows)
|
|
@@ -212,46 +242,60 @@ export default function ShellSearchOverlay({
|
|
|
212
242
|
{expanded ? (
|
|
213
243
|
<div className="border-t border-fg-08 max-h-[70vh] overflow-y-auto">{children}</div>
|
|
214
244
|
) : results.length > 0 && (
|
|
245
|
+
/* FIXED BODY HEIGHT — the panel holds still while the list narrows. */
|
|
215
246
|
<ul
|
|
216
247
|
ref={listRef}
|
|
217
248
|
id={listId}
|
|
218
249
|
role="listbox"
|
|
219
|
-
className="
|
|
250
|
+
className="kol-tone-grey h-80 overflow-y-auto px-2 pb-2"
|
|
220
251
|
>
|
|
221
|
-
{results.map((item, i) =>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
/* preventDefault keeps focus in the input through the click */
|
|
228
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
229
|
-
onClick={() => select(item)}
|
|
230
|
-
onMouseEnter={() => { setActiveIndex(i); setNavigated(true) }}
|
|
231
|
-
className={`flex items-center gap-2 px-4 py-1.5 cursor-pointer kol-mono-14 transition-colors ${
|
|
232
|
-
i === active ? 'bg-fg-08 text-fg' : 'text-fg-80'
|
|
233
|
-
}`}
|
|
234
|
-
>
|
|
235
|
-
<span className="flex flex-col min-w-0">
|
|
236
|
-
<span className="truncate">
|
|
237
|
-
<HighlightMatch label={item.label} query={query} ranges={item.highlights} />
|
|
238
|
-
</span>
|
|
239
|
-
{item.hint && (
|
|
240
|
-
<span className="kol-mono-12 text-fg-48 truncate">{item.hint}</span>
|
|
252
|
+
{results.map((item, i) => {
|
|
253
|
+
const heading = (item.group ?? '') !== (results[i - 1]?.group ?? '') || i === 0
|
|
254
|
+
return (
|
|
255
|
+
<li key={item.id} role="presentation">
|
|
256
|
+
{heading && item.group && (
|
|
257
|
+
<p className="kol-helper-12 text-fg-48 px-4 border-x border-transparent pt-3 pb-2">{item.group}</p>
|
|
241
258
|
)}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
259
|
+
<div
|
|
260
|
+
id={optionId(item)}
|
|
261
|
+
role="option"
|
|
262
|
+
aria-selected={i === active}
|
|
263
|
+
/* preventDefault keeps focus in the input through the click */
|
|
264
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
265
|
+
onClick={() => select(item)}
|
|
266
|
+
onMouseEnter={() => { setActiveIndex(i); setNavigated(true) }}
|
|
267
|
+
/* the md control's box: 1px ring + 6/16 pad + gap-2 → 32px, same as the field */
|
|
268
|
+
className={`flex items-center gap-2 px-4 py-1.5 border border-transparent min-h-[var(--kol-ctl-md)] rounded-[var(--kol-radius-sm)] cursor-pointer kol-mono-14 transition-colors ${
|
|
269
|
+
i === active ? 'bg-[var(--kol-tone-bg,var(--kol-surface-secondary))] text-fg' : 'text-fg-80'
|
|
270
|
+
}`}
|
|
271
|
+
>
|
|
272
|
+
{item.icon && (
|
|
273
|
+
<span aria-hidden="true" className="flex shrink-0 text-oq-48">
|
|
274
|
+
<Icon name={item.icon} size={glyphSize('md')} />
|
|
275
|
+
</span>
|
|
276
|
+
)}
|
|
277
|
+
<span className="flex flex-col min-w-0">
|
|
278
|
+
<span className="truncate">
|
|
279
|
+
<HighlightMatch label={item.label} query={query} ranges={item.highlights} />
|
|
280
|
+
</span>
|
|
281
|
+
{item.hint && (
|
|
282
|
+
<span className="kol-mono-12 text-fg-48 truncate">{item.hint}</span>
|
|
283
|
+
)}
|
|
284
|
+
</span>
|
|
285
|
+
</div>
|
|
286
|
+
</li>
|
|
287
|
+
)
|
|
288
|
+
})}
|
|
248
289
|
</ul>
|
|
249
290
|
)}
|
|
250
|
-
{
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
291
|
+
{/* THE FOOTER SAYS WHAT ENTER DOES — always, not only once typing. */}
|
|
292
|
+
{!expanded && ((!query && results.length > 0) || (query && enterLabel)) && (
|
|
293
|
+
<div className="border-t border-fg-08 py-2">
|
|
294
|
+
<p className="flex items-center gap-2 kol-helper-12 text-fg-48 mx-2 px-4 border-x border-transparent">
|
|
295
|
+
<Kbd icon="corner-down-left" />
|
|
296
|
+
{query ? enterLabel : 'Go to page'}
|
|
297
|
+
</p>
|
|
298
|
+
</div>
|
|
255
299
|
)}
|
|
256
300
|
</div>
|
|
257
301
|
</div>
|