@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.
- package/package.json +1 -1
- package/src/atoms/AssetPlaceholder.jsx +1 -1
- package/src/atoms/DocsToc.jsx +11 -6
- package/src/atoms/FullscreenOverlay.jsx +18 -5
- package/src/atoms/IconFrame.jsx +15 -3
- package/src/atoms/InteractiveImage.jsx +152 -0
- package/src/atoms/OverlayGlassPanel.jsx +1 -1
- package/src/atoms/Pill.jsx +10 -1
- package/src/atoms/SearchInput.jsx +1 -1
- package/src/atoms/Tag.jsx +49 -25
- package/src/atoms/TiltCard.jsx +1 -21
- package/src/hooks/useCoarsePointer.js +27 -0
- package/src/hooks/useScrollSpy.js +18 -9
- package/src/index.js +3 -0
- package/src/molecules/ColorInputRow.jsx +1 -1
- package/src/molecules/ColorSwatch.jsx +1 -1
- package/src/molecules/FramedMediaBand.jsx +2 -2
- package/src/molecules/ShellDrawer.jsx +1 -1
- package/src/molecules/ShellSearchOverlay.jsx +59 -8
- package/src/molecules/SpectrumControls.jsx +5 -5
- package/src/organisms/ContentFilters.jsx +18 -10
- package/src/organisms/FeatureSplit.jsx +1 -1
- package/src/organisms/GalleryCarousel.jsx +1 -1
- package/src/organisms/MediaLibrary.jsx +516 -0
- package/src/organisms/MediaViewer.jsx +9 -1
- package/src/organisms/Table.jsx +67 -4
|
@@ -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'
|
|
118
|
+
} else if (e.key === 'Enter') {
|
|
104
119
|
e.preventDefault()
|
|
105
|
-
|
|
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
|
|
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=
|
|
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
|
-
{
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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-[
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<span className="kol-
|
|
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="
|
|
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-[
|
|
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-[
|
|
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}
|