@kolkrabbi/kol-component 0.15.2 → 0.19.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.
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useId, useRef, useState } from 'react'
2
2
  import SearchInput from '../atoms/SearchInput.jsx'
3
+ import Tag from '../atoms/Tag.jsx'
3
4
 
4
5
  /**
5
6
  * HighlightMatch — default row renderer: underlines the first
@@ -51,6 +52,15 @@ export default function ShellSearchOverlay({
51
52
  open,
52
53
  onClose,
53
54
  results = [],
55
+ /* EXPANDED — the palette's second state (user ruling 2026-08-01). Enter
56
+ * commits the query and opens `children` as the results body; the palette
57
+ * and the old tag overlay are one surface with two states, not two
58
+ * components. `chips` are the committed tag facets of the same query. */
59
+ expanded = false,
60
+ onExpand,
61
+ chips = [],
62
+ onRemoveChip,
63
+ children,
54
64
  query = '',
55
65
  onQueryChange,
56
66
  onSelect,
@@ -60,6 +70,9 @@ export default function ShellSearchOverlay({
60
70
  const listRef = useRef(null)
61
71
  const listId = useId()
62
72
  const [activeIndex, setActiveIndex] = useState(0)
73
+ /* Has the user actually chosen a row? See the Enter branch — without this,
74
+ * index 0 counts as a selection and Enter navigates somewhere unasked. */
75
+ const [navigated, setNavigated] = useState(false)
63
76
  const active = results.length > 0 ? Math.min(activeIndex, results.length - 1) : -1
64
77
 
65
78
  /* Focus in on open, restore the opener on close. querySelector instead of
@@ -73,7 +86,7 @@ export default function ShellSearchOverlay({
73
86
  }, [open])
74
87
 
75
88
  /* Roving row resets to the top on every query change / reopen. */
76
- useEffect(() => { setActiveIndex(0) }, [query, open])
89
+ useEffect(() => { setActiveIndex(0); setNavigated(false) }, [query, open])
77
90
 
78
91
  /* Keep the active row visible inside the scrolling list. */
79
92
  useEffect(() => {
@@ -96,13 +109,21 @@ export default function ShellSearchOverlay({
96
109
  onClose?.()
97
110
  } else if (e.key === 'ArrowDown') {
98
111
  e.preventDefault()
112
+ setNavigated(true)
99
113
  setActiveIndex((i) => Math.min(i + 1, results.length - 1))
100
114
  } else if (e.key === 'ArrowUp') {
101
115
  e.preventDefault()
116
+ setNavigated(true)
102
117
  setActiveIndex((i) => Math.max(i - 1, 0))
103
- } else if (e.key === 'Enter' && active >= 0) {
118
+ } else if (e.key === 'Enter') {
104
119
  e.preventDefault()
105
- select(results[active])
120
+ /* Enter COMMITS the query and expands. It only selects when the user has
121
+ * actually arrowed to a row — `activeIndex` starts at 0, so "a row is
122
+ * highlighted" is true from the first keystroke and testing `active >= 0`
123
+ * made Enter navigate to whatever happened to be first. Committing a
124
+ * query must never be a navigation you didn't choose. */
125
+ if (navigated) select(results[active])
126
+ else onExpand?.()
106
127
  } else if (e.key === 'Tab') {
107
128
  /* Focus trap — the input is the palette's only tab stop. */
108
129
  e.preventDefault()
@@ -112,7 +133,7 @@ export default function ShellSearchOverlay({
112
133
  return (
113
134
  <div className="fixed inset-0 z-[300] flex items-start justify-center pt-[20vh]">
114
135
  <div
115
- className="absolute inset-0 bg-black/60 backdrop-blur-[1px]"
136
+ className="absolute inset-0 kol-overlay-scrim"
116
137
  onClick={onClose}
117
138
  aria-hidden="true"
118
139
  />
@@ -121,13 +142,26 @@ export default function ShellSearchOverlay({
121
142
  role="dialog"
122
143
  aria-modal="true"
123
144
  aria-label="Search"
124
- className="relative w-full max-w-lg mx-4 overflow-hidden bg-surface-primary border border-fg-08 rounded-[var(--kol-radius-2xl)] shadow-[0_20px_60px_rgba(0,0,0,0.4)]"
145
+ className={`kol-overlay-panel mx-4 ${expanded ? 'max-w-[var(--kol-content-panel)]' : 'max-w-lg'}`}
125
146
  >
147
+ {/* THE MODE, said out loud (user 2026-08-01: "how do you set search
148
+ * mode? theres no helper, message or mode clearly readble"). Two
149
+ * modes exist — FILTER (tags narrow a set) and FIND (a keyword jumps
150
+ * to a destination) — and the only signal was whether chips happened
151
+ * to be present. The chips ARE the mode, so they get a label. */}
152
+ {chips.length > 0 && (
153
+ <div className="flex flex-wrap items-center gap-1.5 px-4 pt-3">
154
+ <span className="kol-helper-10 text-fg-48 shrink-0">FILTERING BY</span>
155
+ {chips.map((t) => (
156
+ <Tag key={t} onRemove={() => onRemoveChip?.(t)}>{t}</Tag>
157
+ ))}
158
+ </div>
159
+ )}
126
160
  <SearchInput
127
161
  bare
128
162
  value={query}
129
163
  onChange={(e) => onQueryChange?.(e.target.value)}
130
- placeholder={placeholder}
164
+ placeholder={chips.length > 0 ? 'Narrow these results…' : placeholder}
131
165
  onKeyDown={handleKeyDown}
132
166
  role="combobox"
133
167
  aria-expanded={results.length > 0}
@@ -135,7 +169,24 @@ export default function ShellSearchOverlay({
135
169
  aria-activedescendant={active >= 0 ? optionId(results[active]) : undefined}
136
170
  />
137
171
 
138
- {results.length > 0 && (
172
+ {/* WHY THIS IS NOT `molecules/Dropdown` (asked 2026-08-01). Dropdown is
173
+ * a SELECT: a trigger, a `value`, `onChange(value)`, and rows that are
174
+ * options. This is a COMBOBOX — a text query filtering a live list
175
+ * whose rows carry a `group`, a `hint`, and may fire an `action`
176
+ * instead of selecting a value. Same ARIA family, different control.
177
+ * Folding one into the other would mean giving Dropdown a query, a
178
+ * hint slot and an action escape hatch, i.e. building this inside it.
179
+ *
180
+ * THE ROW CONTRACT (was documented nowhere):
181
+ * label the row's text, match-highlighted against the query
182
+ * group right-aligned origin — 'Atoms', 'Documentation', 'Tags'
183
+ * hint subtext shown when the LABEL was not what matched
184
+ * href a destination; dismisses the palette
185
+ * action a closure; runs and KEEPS the palette open (tag rows)
186
+ * Built by `buildShellSearchItems` (showcase/src/nav/shell-nav.js). */}
187
+ {expanded ? (
188
+ <div className="border-t border-fg-08 max-h-[70vh] overflow-y-auto">{children}</div>
189
+ ) : results.length > 0 && (
139
190
  <ul
140
191
  ref={listRef}
141
192
  id={listId}
@@ -151,7 +202,7 @@ export default function ShellSearchOverlay({
151
202
  /* preventDefault keeps focus in the input through the click */
152
203
  onMouseDown={(e) => e.preventDefault()}
153
204
  onClick={() => select(item)}
154
- onMouseEnter={() => setActiveIndex(i)}
205
+ onMouseEnter={() => { setActiveIndex(i); setNavigated(true) }}
155
206
  className={`flex items-center gap-2 px-4 py-1.5 cursor-pointer kol-mono-14 transition-colors ${
156
207
  i === active ? 'bg-fg-08 text-fg' : 'text-fg-64'
157
208
  }`}
@@ -103,7 +103,7 @@ export function HueStrip({ hue, onChange }) {
103
103
  aria-valuenow={Math.round(hue)}
104
104
  onPointerDown={onDown}
105
105
  onKeyDown={onKeyDown}
106
- className="relative rounded-[2px] cursor-pointer touch-none"
106
+ className="relative rounded-[var(--kol-radius-xs)] cursor-pointer touch-none"
107
107
  style={{ height: 12, background: HUE_GRADIENT }}
108
108
  >
109
109
  {/* Handle positions inside an inset region so 0..100% maps to the
@@ -118,7 +118,7 @@ export function HueStrip({ hue, onChange }) {
118
118
  /**
119
119
  * SBSquare — 2D saturation/value picker: white→hue horizontal gradient with
120
120
  * a black overlay fading upward, crosshair handle. Fills its container
121
- * (consumer owns the size — and the rounding: apply `rounded-[2px]
121
+ * (consumer owns the size — and the rounding: apply `rounded-[var(--kol-radius-xs)]
122
122
  * overflow-hidden` on the wrapper). Focus + arrow keys nudge: Left/Right =
123
123
  * saturation ±1, Up/Down = value ±1 (role="slider").
124
124
  *
@@ -174,7 +174,7 @@ export function SBSquare({ hue, sat, val, onChange }) {
174
174
 
175
175
  const sb = `linear-gradient(to bottom, transparent 0%, #000 100%), linear-gradient(to right, #fff 0%, hsl(${hue},100%,50%) 100%)`
176
176
  /* Gradient field is rectangular — rounding is the consumer's job (apply
177
- * `rounded-[2px] overflow-hidden` on the wrapper). Putting border-radius
177
+ * `rounded-[var(--kol-radius-xs)] overflow-hidden` on the wrapper). Putting border-radius
178
178
  * on the gradient itself produces tiny antialiasing artifacts at the
179
179
  * corners; clipping via overflow-hidden on the parent is cleaner. */
180
180
  return (
@@ -480,7 +480,7 @@ function SvgHandle({ cx, cy, r = 2.4 }) {
480
480
  /**
481
481
  * SpectrumControls — the composed classic square picker: HueStrip stacked
482
482
  * over a fill-height SBSquare, per the source color panel's Hue mode layout
483
- * (SBSquare wrapped in `rounded-[2px] overflow-hidden`, see its comment).
483
+ * (SBSquare wrapped in `rounded-[var(--kol-radius-xs)] overflow-hidden`, see its comment).
484
484
  * Fills its container — the consumer owns the outer size. For the ring
485
485
  * picker, use WheelTriangle directly; the primitives are also exported for
486
486
  * custom layouts.
@@ -496,7 +496,7 @@ export default function SpectrumControls({ value, onChange }) {
496
496
  return (
497
497
  <div className="flex flex-col gap-3 w-full h-full min-h-0">
498
498
  <HueStrip hue={hue} onChange={(h) => onChange({ hue: h, sat, val })} />
499
- <div className="flex-1 min-h-0 rounded-[2px] overflow-hidden">
499
+ <div className="flex-1 min-h-0 rounded-[var(--kol-radius-xs)] overflow-hidden">
500
500
  <SBSquare hue={hue} sat={sat} val={val} onChange={(s, v) => onChange({ hue, sat: s, val: v })} />
501
501
  </div>
502
502
  </div>
@@ -4,6 +4,7 @@ import Divider from '../atoms/Divider.jsx'
4
4
  import Button from '../atoms/Button.jsx'
5
5
  import { Icon } from '@kolkrabbi/kol-icons'
6
6
  import ViewToggle from '../atoms/ViewToggle'
7
+ import IconFrame from '../atoms/IconFrame.jsx'
7
8
 
8
9
  /**
9
10
  * ContentFilters — universal filter component for content grids.
@@ -43,6 +44,7 @@ const ContentFilters = ({
43
44
  searchKeys = ['label', 'name', 'title', 'type'],
44
45
  headerActions,
45
46
  showCountOnlyWhenFiltering = false,
47
+ className = '',
46
48
  }) => {
47
49
  const [activeFilters, setActiveFilters] = useState(new Set())
48
50
  const [isExpanded, setIsExpanded] = useState(false)
@@ -133,16 +135,19 @@ const ContentFilters = ({
133
135
  )
134
136
 
135
137
  return (
136
- <div className="w-full" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
138
+ /* minHeight 0 on both this root and the body below: without it a flex
139
+ * child refuses to shrink past its content, so a scrollable body pushed
140
+ * the whole card taller instead of scrolling inside it. */
141
+ <div className={`w-full ${className}`.trim()} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
137
142
  <div className="flex items-center justify-between mb-4">
138
143
  <div className="flex items-center gap-6">
139
- <h2 className="flex items-center gap-1">
140
- {titleIcon && (
141
- <span className="kol-btn kol-btn-secondary kol-btn-md kol-btn-icon">
142
- <Icon name={titleIcon} size={20} />
143
- </span>
144
- )}
145
- <span className="kol-btn kol-btn-md kol-helper-16">{title}</span>
144
+ {/* IconFrame, NOT a kol-btn span: this is decoration and clicks
145
+ * nothing, so it must not wear a button's chrome. The atom exists
146
+ * for exactly this (lobby ruling 2026-07-30 "icons only, NO
147
+ * states"); the span here was the same defect that promoted it. */}
148
+ <h2 className="flex items-center gap-2">
149
+ {titleIcon && <IconFrame name={titleIcon} variant="secondary" size="md" />}
150
+ <span className="kol-helper-16">{title}</span>
146
151
  </h2>
147
152
  <Divider variant="vertical" className="self-stretch py-1" />
148
153
  <div className="flex items-center gap-1">
@@ -170,8 +175,11 @@ const ContentFilters = ({
170
175
  else setSearchOpen(true)
171
176
  }}
172
177
  >
178
+ {/* Same fake-button defect as the title icon: a decorative glyph
179
+ * wearing kol-btn chrome. The clickable thing is the wrapper
180
+ * div, not this span. */}
173
181
  <span
174
- className="kol-btn kol-btn-md kol-btn-icon flex items-center justify-center flex-shrink-0"
182
+ className="flex items-center justify-center flex-shrink-0"
175
183
  style={{
176
184
  opacity: searchOpen ? 0 : 1,
177
185
  transition: 'opacity 300ms cubic-bezier(0.16, 1, 0.3, 1)',
@@ -260,7 +268,7 @@ const ContentFilters = ({
260
268
  </div>
261
269
  )}
262
270
 
263
- <div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
271
+ <div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
264
272
  {renderItem(filteredItems, viewMode, layout)}
265
273
  </div>
266
274
  </div>
@@ -70,7 +70,7 @@ export default function FeatureSplit({
70
70
  {ctas && <div className="flex flex-wrap gap-4 pt-2">{ctas}</div>}
71
71
  </div>
72
72
  {media && (
73
- <div className="kol-feature-split-visual relative aspect-[4/5] rounded-[4px] overflow-hidden">
73
+ <div className="kol-feature-split-visual relative aspect-[4/5] rounded-[var(--kol-radius-sm)] overflow-hidden">
74
74
  {media}
75
75
  {caption && <div className="kol-feature-split-visual-veil" aria-hidden="true" />}
76
76
  {caption && <span className="kol-feature-split-visual-caption">{caption}</span>}
@@ -56,7 +56,7 @@ export default function GalleryCarousel({ media = [], title = '', defaultAspect
56
56
  return (
57
57
  <div
58
58
  key={item.url || i}
59
- className="w-full overflow-hidden rounded-[2px] cursor-pointer"
59
+ className="w-full overflow-hidden rounded-[var(--kol-radius-xs)] cursor-pointer"
60
60
  style={{ aspectRatio: aspect }}
61
61
  onPointerDown={onPointerDown}
62
62
  onPointerMove={onPointerMove}