@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.230.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.29.1"
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
- ? 'kol-control--bare flex w-full gap-2.5 px-4 py-3'
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-fg-48' : 'text-auto opacity-50'}`}
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
- <kbd
258
- aria-hidden="true"
259
- className="inline-flex items-center justify-center shrink-0 h-4 min-w-4 px-1 rounded-[var(--kol-radius-xs)] bg-fg-08 kol-helper-10 text-fg-48"
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?.children[active]?.scrollIntoView({ block: 'nearest' })
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
- if (navigated) select(results[active])
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
- bare
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 right-aligned origin — 'Atoms', 'Documentation', 'Tags'
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="border-t border-fg-08 max-h-80 overflow-y-auto py-1"
250
+ className="kol-tone-grey h-80 overflow-y-auto px-2 pb-2"
220
251
  >
221
- {results.map((item, i) => (
222
- <li
223
- key={item.id}
224
- id={optionId(item)}
225
- role="option"
226
- aria-selected={i === active}
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
- </span>
243
- {item.group && (
244
- <span className="ml-auto shrink-0 kol-helper-10 text-fg-48">{item.group}</span>
245
- )}
246
- </li>
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
- {!expanded && enterLabel && query && (
251
- <p className="flex items-center gap-2 border-t border-fg-08 px-4 py-2 kol-helper-12 text-fg-64">
252
- <kbd className="kol-helper-12 rounded-[var(--kol-radius-sm)] bg-fg-08 px-1.5 py-0.5 text-fg">↵</kbd>
253
- {enterLabel}
254
- </p>
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>