@kolkrabbi/kol-shell 0.2.0 → 0.4.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 +5 -5
- package/src/ShortcutsOverlay.jsx +56 -7
- package/src/TabStrip.jsx +6 -9
- package/src/index.js +3 -1
- package/src/ContentFilters.jsx +0 -320
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "KOL application shell — fixed 48px NavRail + AppShell layout root, PageShell/PageHeader scaffolds, ContentFilters catalog organism, GridCard, SettingsScaffold, WalkthroughPanel, ShortcutsOverlay. App chrome (kol-framework owns site chrome). Nav items, content, shortcuts and settings are consumer-injected. Sits above @kolkrabbi/kol-{theme,component,framework}.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@kolkrabbi/kol-
|
|
24
|
-
"@kolkrabbi/kol-
|
|
25
|
-
"@kolkrabbi/kol-theme": "^0.
|
|
26
|
-
"@kolkrabbi/kol-
|
|
23
|
+
"@kolkrabbi/kol-component": "^0.46.0",
|
|
24
|
+
"@kolkrabbi/kol-framework": "^0.22.0",
|
|
25
|
+
"@kolkrabbi/kol-theme": "^0.43.1",
|
|
26
|
+
"@kolkrabbi/kol-icons": "^0.17.0"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"src",
|
package/src/ShortcutsOverlay.jsx
CHANGED
|
@@ -2,15 +2,50 @@ import { useEffect } from 'react'
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ShortcutsOverlay — the keyboard-shortcut sheet: blurred scrim, centred
|
|
5
|
-
* panel,
|
|
5
|
+
* panel, a 2-col grid (label · keys), Esc / backdrop-click close.
|
|
6
6
|
* Ported from the shared cut (mirror's, "copied from kol-monitor").
|
|
7
7
|
*
|
|
8
8
|
* `shortcuts` is a prop — feed the SAME array your settings page renders
|
|
9
9
|
* (both repos hand-maintained the list twice and both pairs drifted).
|
|
10
10
|
* Stacks at `--kol-z-modal`, above the rail's sticky tier.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* TWO FORMS, DETECTED ON SHAPE (ShortcutsOverlaySections, kol-fxr 2026-08-15):
|
|
13
|
+
*
|
|
14
|
+
* flat `[{ label, keys }]` — one grid, as shipped
|
|
15
|
+
* sectioned `[{ section, items: [{label,keys}] }]` — headings between groups
|
|
16
|
+
*
|
|
17
|
+
* The flat form renders EXACTLY as it did before, so no existing caller moves.
|
|
18
|
+
* The sectioned form exists because a real keymap is grouped — kol-fxr's is
|
|
19
|
+
* Edit · Selection · Layer · Tools · View, and its `shortcutsBySection()`
|
|
20
|
+
* already emits this shape — and flattening it to adopt this component would
|
|
21
|
+
* have lost the grouping. That consumer kept a 99-line local overlay instead,
|
|
22
|
+
* which is the duplication this component exists to end.
|
|
23
|
+
*
|
|
24
|
+
* ONE GRID, NOT NESTED ONES. The headings span both columns
|
|
25
|
+
* (`gridColumn: 1 / -1`) so every `keys` cell in the sheet stays on the same
|
|
26
|
+
* axis — nesting a grid per section would let each group compute its own
|
|
27
|
+
* column width and the keys would stagger down the panel.
|
|
28
|
+
*
|
|
29
|
+
* `keys` is a DISPLAY STRING and is never bound — this component shows a
|
|
30
|
+
* keymap, it does not own one. Formatting a combo (⌘⇧Z) is the consumer's,
|
|
31
|
+
* next to wherever the binding actually lives.
|
|
32
|
+
*
|
|
33
|
+
* @param {Array} props.shortcuts - `[{ label, keys }]` or `[{ section, items }]`
|
|
34
|
+
* @param {Function} props.onClose
|
|
13
35
|
*/
|
|
36
|
+
|
|
37
|
+
const isSectioned = (list) => Array.isArray(list?.[0]?.items)
|
|
38
|
+
|
|
39
|
+
/* One row of the shared grid. `contents` keeps the pair on the parent grid
|
|
40
|
+
* rather than making the wrapper a cell of its own. */
|
|
41
|
+
function Row({ label, keys }) {
|
|
42
|
+
return (
|
|
43
|
+
<span className="contents">
|
|
44
|
+
<span>{label}</span><span className="text-fg-96">{keys}</span>
|
|
45
|
+
</span>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
14
49
|
export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
|
|
15
50
|
useEffect(() => {
|
|
16
51
|
const onKey = (e) => { if (e.key === 'Escape') onClose?.() }
|
|
@@ -18,6 +53,8 @@ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
|
|
|
18
53
|
return () => window.removeEventListener('keydown', onKey)
|
|
19
54
|
}, [onClose])
|
|
20
55
|
|
|
56
|
+
const sectioned = isSectioned(shortcuts)
|
|
57
|
+
|
|
21
58
|
return (
|
|
22
59
|
<div
|
|
23
60
|
onClick={onClose}
|
|
@@ -25,11 +62,23 @@ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
|
|
|
25
62
|
style={{ display: 'grid', placeItems: 'center', backdropFilter: 'blur(2px)', zIndex: 'var(--kol-z-modal)' }}
|
|
26
63
|
>
|
|
27
64
|
<div className="text-fg-64 kol-helper-12 bg-surface-primary border border-fg-16" style={{ display: 'grid', gridTemplateColumns: 'auto auto', gap: '10px 62px', padding: 24, borderRadius: 4 }}>
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
<span
|
|
31
|
-
|
|
32
|
-
|
|
65
|
+
{sectioned
|
|
66
|
+
? shortcuts.map(({ section, items = [] }, i) => (
|
|
67
|
+
<span key={section} className="contents">
|
|
68
|
+
<span
|
|
69
|
+
className="text-fg-32"
|
|
70
|
+
style={{ gridColumn: '1 / -1', marginTop: i === 0 ? 0 : 14 }}
|
|
71
|
+
>
|
|
72
|
+
{section}
|
|
73
|
+
</span>
|
|
74
|
+
{items.map(({ label, keys }) => (
|
|
75
|
+
<Row key={`${section}:${label}`} label={label} keys={keys} />
|
|
76
|
+
))}
|
|
77
|
+
</span>
|
|
78
|
+
))
|
|
79
|
+
: shortcuts.map(({ label, keys }) => (
|
|
80
|
+
<Row key={label} label={label} keys={keys} />
|
|
81
|
+
))}
|
|
33
82
|
</div>
|
|
34
83
|
</div>
|
|
35
84
|
)
|
package/src/TabStrip.jsx
CHANGED
|
@@ -8,25 +8,22 @@
|
|
|
8
8
|
* want GRID). `tracked` adds the 1px letter-spacing the view-mode strips
|
|
9
9
|
* carried.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* SINGLE-SELECT ONLY. A `Set` form for multi-select was added 2026-08-15 so the
|
|
12
|
+
* retired ContentFilters fork could render filter values through this — and
|
|
13
|
+
* removed the same day with it. A multi-select, handler-carrying, active/rest
|
|
14
|
+
* chip is a `Tag`; building a second one here under another name in another
|
|
15
|
+
* package is exactly the duplication kol-shell exists to end.
|
|
16
16
|
*
|
|
17
17
|
* @param {Array} props.options `[{ value, label }]`
|
|
18
|
-
* @param {Set|*} props.value Set → multi-select · scalar → single-select
|
|
19
18
|
*/
|
|
20
19
|
export default function TabStrip({ options = [], value, onChange, size = 14, tracked = false, className = 'gap-6', style }) {
|
|
21
|
-
const isOn = (opt) => (value instanceof Set ? value.has(opt.value) : value === opt.value)
|
|
22
|
-
|
|
23
20
|
return (
|
|
24
21
|
<div className={`flex items-center ${className}`.trim()} style={style}>
|
|
25
22
|
{options.map((opt) => (
|
|
26
23
|
<span
|
|
27
24
|
key={opt.value}
|
|
28
25
|
onClick={() => onChange?.(opt.value)}
|
|
29
|
-
className={`kol-helper-${size} cursor-pointer select-none ${
|
|
26
|
+
className={`kol-helper-${size} cursor-pointer select-none ${value === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
|
|
30
27
|
style={tracked ? { letterSpacing: 1 } : undefined}
|
|
31
28
|
>
|
|
32
29
|
{opt.label}
|
package/src/index.js
CHANGED
|
@@ -14,7 +14,9 @@ export { NavHiddenContext, useNavHidden } from './navHidden.js'
|
|
|
14
14
|
export { default as NavRail } from './NavRail.jsx'
|
|
15
15
|
export { default as PageShell, PageBleed } from './PageShell.jsx'
|
|
16
16
|
export { default as PageHeader } from './PageHeader.jsx'
|
|
17
|
-
|
|
17
|
+
/* ContentFilters is NOT exported here — it lives in @kolkrabbi/kol-component,
|
|
18
|
+
* where it always did. This package shipped a recreated duplicate 0.1.0–0.2.0;
|
|
19
|
+
* retired 2026-08-15, quarantined in _tmp/. Import it from kol-component. */
|
|
18
20
|
export { default as TabStrip } from './TabStrip.jsx'
|
|
19
21
|
export { default as GridCard } from './GridCard.jsx'
|
|
20
22
|
export { default as SettingsScaffold, SettingsSection, LabelRow } from './SettingsScaffold.jsx'
|
package/src/ContentFilters.jsx
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
import { Fragment, useState, useMemo, useRef, useEffect } from 'react'
|
|
2
|
-
import { Divider } from '@kolkrabbi/kol-component'
|
|
3
|
-
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
|
-
import TabStrip from './TabStrip.jsx'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* ContentFilters — the catalog organism: header row (title + count), a
|
|
8
|
-
* toggled filter-chip panel, the expanding pill search, view-mode/layout
|
|
9
|
-
* strips (TabStrip), and a render-prop for the filtered items.
|
|
10
|
-
*
|
|
11
|
-
* Recreated from the mirror cut (the newer of the two shipped copies) with
|
|
12
|
-
* the shipped defects fixed: the dead ViewToggle import dropped (both repos
|
|
13
|
-
* carried it), `text-transform: uppercase` on the strips dropped (no
|
|
14
|
-
* auto-casing law — author labels in the case they should render; the 1px
|
|
15
|
-
* tracking stays via TabStrip `tracked`), and mirror's `bg-container-secondary`
|
|
16
|
-
* hover (a class no theme CSS defines) → `bg-fg-04`.
|
|
17
|
-
*
|
|
18
|
-
* Grid geometry law (documented default, both source repos, both pages):
|
|
19
|
-
* grid = `repeat(6, 1fr)` gap 24 · list = `repeat(4, 1fr)` gap 8.
|
|
20
|
-
*
|
|
21
|
-
* @param {Array} props.items - Items to filter
|
|
22
|
-
* @param {string} props.title - Section title
|
|
23
|
-
* @param {number} props.totalCount - Count before filtering
|
|
24
|
-
* @param {Array} props.filterGroups - `[{ label, key, values }]`
|
|
25
|
-
* @param {Function} props.renderItem - `(filteredItems, viewMode, layout) => node`
|
|
26
|
-
* @param {Array} props.viewModeOptions - `[{ value, label }]` for the view strip
|
|
27
|
-
* @param {Array} props.mutuallyExclusiveFilters - filter keys that self-clear
|
|
28
|
-
* @param {Array} props.customFilterKeys - keys renderItem handles, not this organism
|
|
29
|
-
* @param {ElementType} props.iconComponent - icon seam (defaults to DS Icon; needs `filter` + `search`)
|
|
30
|
-
* @param {Function} props.renderFilterValue - `(value, isActive, toggle) => node`, one filter value
|
|
31
|
-
* @param {string} props.labelClassName - REPLACES the group label's class (never stacks)
|
|
32
|
-
*
|
|
33
|
-
* **The two rendering seams (0.2.0).** How a filter value and a group label look
|
|
34
|
-
* is the consumer's call, not this organism's. Three publishes in one day went
|
|
35
|
-
* into re-ruling that here — outlined pills vs bare strip items vs chips — none
|
|
36
|
-
* of which is a design-system question: it is what a given app's filter bar
|
|
37
|
-
* should look like. `renderFilterValue` and `labelClassName` end that class of
|
|
38
|
-
* change without a publish.
|
|
39
|
-
*
|
|
40
|
-
* `labelClassName` REPLACES rather than stacks, per the 2026-07-30 law: two
|
|
41
|
-
* equal-specificity type classes on one element are decided by sheet order, so
|
|
42
|
-
* a stacked seam produces different renders in different consumers with no
|
|
43
|
-
* version difference. Same contract as ListingCard's `titleClassName`.
|
|
44
|
-
*
|
|
45
|
-
* Defaults reproduce 0.1.3 exactly — a bump moves nothing.
|
|
46
|
-
*/
|
|
47
|
-
const ContentFilters = ({
|
|
48
|
-
items,
|
|
49
|
-
title,
|
|
50
|
-
totalCount,
|
|
51
|
-
filterGroups = [],
|
|
52
|
-
renderItem,
|
|
53
|
-
viewModeOptions,
|
|
54
|
-
viewMode: viewModeProp,
|
|
55
|
-
onViewModeChange,
|
|
56
|
-
defaultViewMode = 'list',
|
|
57
|
-
layoutOptions,
|
|
58
|
-
defaultLayout = 'grid',
|
|
59
|
-
onFilterChange,
|
|
60
|
-
mutuallyExclusiveFilters = [],
|
|
61
|
-
customFilterKeys = [],
|
|
62
|
-
searchKeys = ['label', 'name', 'title', 'type'],
|
|
63
|
-
headerActions,
|
|
64
|
-
showCountOnlyWhenFiltering = false,
|
|
65
|
-
iconComponent,
|
|
66
|
-
renderFilterValue,
|
|
67
|
-
labelClassName,
|
|
68
|
-
}) => {
|
|
69
|
-
const [activeFilters, setActiveFilters] = useState(new Set())
|
|
70
|
-
const [isExpanded, setIsExpanded] = useState(false)
|
|
71
|
-
const [internalViewMode, setInternalViewMode] = useState(defaultViewMode)
|
|
72
|
-
const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
|
|
73
|
-
const [layout, setLayout] = useState(defaultLayout)
|
|
74
|
-
const [searchOpen, setSearchOpen] = useState(false)
|
|
75
|
-
const [searchText, setSearchText] = useState('')
|
|
76
|
-
const searchRef = useRef(null)
|
|
77
|
-
const IconSeam = iconComponent || Icon
|
|
78
|
-
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
if (searchOpen && searchRef.current) searchRef.current.focus()
|
|
81
|
-
}, [searchOpen])
|
|
82
|
-
|
|
83
|
-
const toggleFilter = (filterType, value) => {
|
|
84
|
-
const newFilters = new Set(activeFilters)
|
|
85
|
-
const filterKey = `${filterType}:${value}`
|
|
86
|
-
|
|
87
|
-
if (newFilters.has(filterKey)) {
|
|
88
|
-
newFilters.delete(filterKey)
|
|
89
|
-
} else {
|
|
90
|
-
if (mutuallyExclusiveFilters.includes(filterType)) {
|
|
91
|
-
Array.from(newFilters).forEach(existingFilter => {
|
|
92
|
-
if (existingFilter.startsWith(`${filterType}:`)) {
|
|
93
|
-
newFilters.delete(existingFilter)
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
newFilters.add(filterKey)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
setActiveFilters(newFilters)
|
|
101
|
-
if (onFilterChange) {
|
|
102
|
-
onFilterChange(newFilters, viewMode)
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const clearAllFilters = () => {
|
|
107
|
-
setActiveFilters(new Set())
|
|
108
|
-
if (onFilterChange) {
|
|
109
|
-
onFilterChange(new Set(), viewMode)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const handleViewModeChange = (mode) => {
|
|
114
|
-
if (onViewModeChange) onViewModeChange(mode)
|
|
115
|
-
else setInternalViewMode(mode)
|
|
116
|
-
if (onFilterChange) {
|
|
117
|
-
onFilterChange(activeFilters, mode)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const filteredItems = useMemo(() => {
|
|
122
|
-
let result = items
|
|
123
|
-
|
|
124
|
-
if (searchText) {
|
|
125
|
-
const q = searchText.toLowerCase()
|
|
126
|
-
result = result.filter(item =>
|
|
127
|
-
searchKeys.some(key => {
|
|
128
|
-
const val = item[key]
|
|
129
|
-
return val && String(val).toLowerCase().includes(q)
|
|
130
|
-
})
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (activeFilters.size === 0) return result
|
|
135
|
-
|
|
136
|
-
return result.filter((item) => {
|
|
137
|
-
let matches = true
|
|
138
|
-
activeFilters.forEach((filter) => {
|
|
139
|
-
const [filterType, value] = filter.split(':')
|
|
140
|
-
if (customFilterKeys.includes(filterType)) return
|
|
141
|
-
|
|
142
|
-
const itemValue = item[filterType]
|
|
143
|
-
if (Array.isArray(itemValue)) {
|
|
144
|
-
if (!itemValue.includes(value)) matches = false
|
|
145
|
-
} else {
|
|
146
|
-
if (itemValue !== value) matches = false
|
|
147
|
-
}
|
|
148
|
-
})
|
|
149
|
-
return matches
|
|
150
|
-
})
|
|
151
|
-
}, [items, activeFilters, customFilterKeys, searchText, searchKeys])
|
|
152
|
-
|
|
153
|
-
// A group is a COLUMN: label on top, values beneath it (user ruling
|
|
154
|
-
// 2026-08-15). How a value and a label RENDER is the consumer's — three
|
|
155
|
-
// publishes in one day went into re-deciding that here, which is the defect
|
|
156
|
-
// the seams below end. Defaults are 0.1.3 exactly, so nothing moves on bump.
|
|
157
|
-
const renderFilterGroup = (group) => {
|
|
158
|
-
const prefix = `${group.key}:`
|
|
159
|
-
const selected = new Set(
|
|
160
|
-
Array.from(activeFilters)
|
|
161
|
-
.filter((f) => f.startsWith(prefix))
|
|
162
|
-
.map((f) => f.slice(prefix.length))
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
return (
|
|
166
|
-
<div key={group.key} className="flex flex-col gap-3">
|
|
167
|
-
<h4
|
|
168
|
-
className={labelClassName || 'kol-helper-12 text-fg-32'}
|
|
169
|
-
style={labelClassName ? undefined : { letterSpacing: 1 }}
|
|
170
|
-
>
|
|
171
|
-
{group.label}
|
|
172
|
-
</h4>
|
|
173
|
-
{renderFilterValue ? (
|
|
174
|
-
<div className="flex flex-wrap items-center gap-4">
|
|
175
|
-
{group.values.map((v) => (
|
|
176
|
-
<Fragment key={v}>
|
|
177
|
-
{renderFilterValue(v, selected.has(v), () => toggleFilter(group.key, v))}
|
|
178
|
-
</Fragment>
|
|
179
|
-
))}
|
|
180
|
-
</div>
|
|
181
|
-
) : (
|
|
182
|
-
<TabStrip
|
|
183
|
-
options={group.values.map((v) => ({ value: v, label: v }))}
|
|
184
|
-
value={selected}
|
|
185
|
-
onChange={(v) => toggleFilter(group.key, v)}
|
|
186
|
-
size={12}
|
|
187
|
-
tracked
|
|
188
|
-
className="gap-4 flex-wrap"
|
|
189
|
-
/>
|
|
190
|
-
)}
|
|
191
|
-
</div>
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return (
|
|
196
|
-
<div className="w-full" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
|
197
|
-
{/* Header with filter toggle + expanding search */}
|
|
198
|
-
<div className="flex items-center justify-between mb-4">
|
|
199
|
-
<div className="flex items-center gap-6">
|
|
200
|
-
<h2 className="kol-helper-14">{title}</h2>
|
|
201
|
-
<div className="flex items-center gap-1">
|
|
202
|
-
<button
|
|
203
|
-
onClick={() => setIsExpanded(!isExpanded)}
|
|
204
|
-
className="p-2 hover:bg-fg-04 rounded-sm transition-colors leading-none"
|
|
205
|
-
aria-label="Toggle filters"
|
|
206
|
-
>
|
|
207
|
-
<IconSeam name="filter" size={16} />
|
|
208
|
-
</button>
|
|
209
|
-
<div
|
|
210
|
-
className="flex items-center rounded-full cursor-pointer"
|
|
211
|
-
style={{
|
|
212
|
-
height: 28,
|
|
213
|
-
width: searchOpen ? 200 : 28,
|
|
214
|
-
background: searchOpen ? 'var(--kol-fg-04, rgba(255,255,255,0.04))' : 'transparent',
|
|
215
|
-
transition: 'width 600ms cubic-bezier(0.16, 1, 0.3, 1), background 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
|
216
|
-
overflow: 'hidden',
|
|
217
|
-
}}
|
|
218
|
-
onClick={() => {
|
|
219
|
-
if (searchOpen) { setSearchOpen(false); setSearchText('') }
|
|
220
|
-
else setSearchOpen(true)
|
|
221
|
-
}}
|
|
222
|
-
>
|
|
223
|
-
<span
|
|
224
|
-
className="flex items-center justify-center flex-shrink-0"
|
|
225
|
-
style={{
|
|
226
|
-
width: 28, height: 28,
|
|
227
|
-
opacity: searchOpen ? 0 : 1,
|
|
228
|
-
transition: 'opacity 300ms cubic-bezier(0.16, 1, 0.3, 1)',
|
|
229
|
-
position: searchOpen ? 'absolute' : 'relative',
|
|
230
|
-
}}
|
|
231
|
-
>
|
|
232
|
-
<IconSeam name="search" size={16} />
|
|
233
|
-
</span>
|
|
234
|
-
{searchOpen && (
|
|
235
|
-
<input
|
|
236
|
-
ref={searchRef}
|
|
237
|
-
type="text"
|
|
238
|
-
value={searchText}
|
|
239
|
-
onChange={e => setSearchText(e.target.value)}
|
|
240
|
-
onClick={e => e.stopPropagation()}
|
|
241
|
-
placeholder=""
|
|
242
|
-
className="bg-transparent outline-none kol-helper-12 flex-1 text-fg-80 caret-current px-4"
|
|
243
|
-
onBlur={() => { if (!searchText) { setSearchOpen(false) } }}
|
|
244
|
-
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchText('') } }}
|
|
245
|
-
/>
|
|
246
|
-
)}
|
|
247
|
-
</div>
|
|
248
|
-
{headerActions}
|
|
249
|
-
</div>
|
|
250
|
-
{activeFilters.size > 0 && (
|
|
251
|
-
<span
|
|
252
|
-
className="kol-helper-12 text-fg-48 cursor-pointer select-none group flex items-center gap-2"
|
|
253
|
-
onClick={(e) => { e.stopPropagation(); clearAllFilters() }}
|
|
254
|
-
>
|
|
255
|
-
<span className="underline">({activeFilters.size}) {activeFilters.size === 1 ? 'filter' : 'filters'} active</span>
|
|
256
|
-
<span className="hidden group-hover:inline text-fg-64">×</span>
|
|
257
|
-
</span>
|
|
258
|
-
)}
|
|
259
|
-
</div>
|
|
260
|
-
|
|
261
|
-
<div className="flex items-center gap-8">
|
|
262
|
-
{(!showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0) && (
|
|
263
|
-
<span className="kol-helper-14 text-fg-64">
|
|
264
|
-
{filteredItems.length} of {totalCount}
|
|
265
|
-
</span>
|
|
266
|
-
)}
|
|
267
|
-
{viewModeOptions && (
|
|
268
|
-
<TabStrip options={viewModeOptions} value={viewMode} onChange={handleViewModeChange} tracked />
|
|
269
|
-
)}
|
|
270
|
-
</div>
|
|
271
|
-
</div>
|
|
272
|
-
|
|
273
|
-
<Divider className="mb-4" />
|
|
274
|
-
|
|
275
|
-
{/* BELOW the divider: filter groups as left-aligned COLUMNS (only while
|
|
276
|
-
the filter toggle is open), layout strip RIGHT and ALWAYS visible.
|
|
277
|
-
`items-start` is load-bearing — it pins the strip to the label row, so
|
|
278
|
-
the strip and the group labels read as one line and the values hang
|
|
279
|
-
beneath. 0.1.1 read "at the divider level" as the header row above the
|
|
280
|
-
divider; the strip never belongs in the header
|
|
281
|
-
(ShellHeaderFilterRefinements, 2026-08-15). */}
|
|
282
|
-
{(layoutOptions || isExpanded) && (
|
|
283
|
-
<div className="flex items-start justify-between gap-16 mb-4">
|
|
284
|
-
<div className="flex items-start gap-16">
|
|
285
|
-
{isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
|
|
286
|
-
{isExpanded && activeFilters.size > 0 && (
|
|
287
|
-
// Strip rest recipe, not its own ink — this button shares the line
|
|
288
|
-
// with the labels and values, and that line carries exactly TWO
|
|
289
|
-
// ink states (user ruling 2026-08-15: "we are not maintaining 3
|
|
290
|
-
// opacity states for one line in a component").
|
|
291
|
-
<button
|
|
292
|
-
onClick={clearAllFilters}
|
|
293
|
-
className="kol-helper-12 transition-colors underline text-fg-32 hover:text-fg-48"
|
|
294
|
-
>
|
|
295
|
-
Clear all ({activeFilters.size})
|
|
296
|
-
</button>
|
|
297
|
-
)}
|
|
298
|
-
</div>
|
|
299
|
-
{layoutOptions && (
|
|
300
|
-
<TabStrip
|
|
301
|
-
options={layoutOptions}
|
|
302
|
-
value={layout}
|
|
303
|
-
onChange={setLayout}
|
|
304
|
-
size={12}
|
|
305
|
-
tracked
|
|
306
|
-
className="gap-4"
|
|
307
|
-
/>
|
|
308
|
-
)}
|
|
309
|
-
</div>
|
|
310
|
-
)}
|
|
311
|
-
|
|
312
|
-
{/* Render filtered items */}
|
|
313
|
-
<div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
|
314
|
-
{renderItem(filteredItems, viewMode, layout)}
|
|
315
|
-
</div>
|
|
316
|
-
</div>
|
|
317
|
-
)
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
export default ContentFilters
|