@kolkrabbi/kol-shell 0.1.3 → 0.3.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 +3 -3
- package/src/TabStrip.jsx +6 -9
- package/src/index.js +3 -1
- package/src/ContentFilters.jsx +0 -291
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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-framework": "^0.20.1",
|
|
24
23
|
"@kolkrabbi/kol-icons": "^0.17.0",
|
|
24
|
+
"@kolkrabbi/kol-component": "^0.44.0",
|
|
25
25
|
"@kolkrabbi/kol-theme": "^0.42.2",
|
|
26
|
-
"@kolkrabbi/kol-
|
|
26
|
+
"@kolkrabbi/kol-framework": "^0.20.1"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"src",
|
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,291 +0,0 @@
|
|
|
1
|
-
import { 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
|
-
*/
|
|
31
|
-
const ContentFilters = ({
|
|
32
|
-
items,
|
|
33
|
-
title,
|
|
34
|
-
totalCount,
|
|
35
|
-
filterGroups = [],
|
|
36
|
-
renderItem,
|
|
37
|
-
viewModeOptions,
|
|
38
|
-
viewMode: viewModeProp,
|
|
39
|
-
onViewModeChange,
|
|
40
|
-
defaultViewMode = 'list',
|
|
41
|
-
layoutOptions,
|
|
42
|
-
defaultLayout = 'grid',
|
|
43
|
-
onFilterChange,
|
|
44
|
-
mutuallyExclusiveFilters = [],
|
|
45
|
-
customFilterKeys = [],
|
|
46
|
-
searchKeys = ['label', 'name', 'title', 'type'],
|
|
47
|
-
headerActions,
|
|
48
|
-
showCountOnlyWhenFiltering = false,
|
|
49
|
-
iconComponent,
|
|
50
|
-
}) => {
|
|
51
|
-
const [activeFilters, setActiveFilters] = useState(new Set())
|
|
52
|
-
const [isExpanded, setIsExpanded] = useState(false)
|
|
53
|
-
const [internalViewMode, setInternalViewMode] = useState(defaultViewMode)
|
|
54
|
-
const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
|
|
55
|
-
const [layout, setLayout] = useState(defaultLayout)
|
|
56
|
-
const [searchOpen, setSearchOpen] = useState(false)
|
|
57
|
-
const [searchText, setSearchText] = useState('')
|
|
58
|
-
const searchRef = useRef(null)
|
|
59
|
-
const IconSeam = iconComponent || Icon
|
|
60
|
-
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
if (searchOpen && searchRef.current) searchRef.current.focus()
|
|
63
|
-
}, [searchOpen])
|
|
64
|
-
|
|
65
|
-
const toggleFilter = (filterType, value) => {
|
|
66
|
-
const newFilters = new Set(activeFilters)
|
|
67
|
-
const filterKey = `${filterType}:${value}`
|
|
68
|
-
|
|
69
|
-
if (newFilters.has(filterKey)) {
|
|
70
|
-
newFilters.delete(filterKey)
|
|
71
|
-
} else {
|
|
72
|
-
if (mutuallyExclusiveFilters.includes(filterType)) {
|
|
73
|
-
Array.from(newFilters).forEach(existingFilter => {
|
|
74
|
-
if (existingFilter.startsWith(`${filterType}:`)) {
|
|
75
|
-
newFilters.delete(existingFilter)
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
newFilters.add(filterKey)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
setActiveFilters(newFilters)
|
|
83
|
-
if (onFilterChange) {
|
|
84
|
-
onFilterChange(newFilters, viewMode)
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const clearAllFilters = () => {
|
|
89
|
-
setActiveFilters(new Set())
|
|
90
|
-
if (onFilterChange) {
|
|
91
|
-
onFilterChange(new Set(), viewMode)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const handleViewModeChange = (mode) => {
|
|
96
|
-
if (onViewModeChange) onViewModeChange(mode)
|
|
97
|
-
else setInternalViewMode(mode)
|
|
98
|
-
if (onFilterChange) {
|
|
99
|
-
onFilterChange(activeFilters, mode)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const filteredItems = useMemo(() => {
|
|
104
|
-
let result = items
|
|
105
|
-
|
|
106
|
-
if (searchText) {
|
|
107
|
-
const q = searchText.toLowerCase()
|
|
108
|
-
result = result.filter(item =>
|
|
109
|
-
searchKeys.some(key => {
|
|
110
|
-
const val = item[key]
|
|
111
|
-
return val && String(val).toLowerCase().includes(q)
|
|
112
|
-
})
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (activeFilters.size === 0) return result
|
|
117
|
-
|
|
118
|
-
return result.filter((item) => {
|
|
119
|
-
let matches = true
|
|
120
|
-
activeFilters.forEach((filter) => {
|
|
121
|
-
const [filterType, value] = filter.split(':')
|
|
122
|
-
if (customFilterKeys.includes(filterType)) return
|
|
123
|
-
|
|
124
|
-
const itemValue = item[filterType]
|
|
125
|
-
if (Array.isArray(itemValue)) {
|
|
126
|
-
if (!itemValue.includes(value)) matches = false
|
|
127
|
-
} else {
|
|
128
|
-
if (itemValue !== value) matches = false
|
|
129
|
-
}
|
|
130
|
-
})
|
|
131
|
-
return matches
|
|
132
|
-
})
|
|
133
|
-
}, [items, activeFilters, customFilterKeys, searchText, searchKeys])
|
|
134
|
-
|
|
135
|
-
// A group is a COLUMN: label on top, values beneath it — never label-left /
|
|
136
|
-
// values-right on one line (user ruling 2026-08-15, second QA round). The
|
|
137
|
-
// label wears the full strip idiom too — tracked `kol-helper-12` at the
|
|
138
|
-
// strip's rest ink — so a category header and the LIST/GRID items read as one
|
|
139
|
-
// family ("LIST GRID text has style, that style also on TAGS and any other
|
|
140
|
-
// filter category"). Rest ink, not active: the label is not interactive.
|
|
141
|
-
const renderFilterGroup = (group) => {
|
|
142
|
-
const prefix = `${group.key}:`
|
|
143
|
-
const selected = new Set(
|
|
144
|
-
Array.from(activeFilters)
|
|
145
|
-
.filter((f) => f.startsWith(prefix))
|
|
146
|
-
.map((f) => f.slice(prefix.length))
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
return (
|
|
150
|
-
<div key={group.key} className="flex flex-col gap-3">
|
|
151
|
-
<h4 className="kol-helper-12 text-fg-32" style={{ letterSpacing: 1 }}>
|
|
152
|
-
{group.label}
|
|
153
|
-
</h4>
|
|
154
|
-
<TabStrip
|
|
155
|
-
options={group.values.map((v) => ({ value: v, label: v }))}
|
|
156
|
-
value={selected}
|
|
157
|
-
onChange={(v) => toggleFilter(group.key, v)}
|
|
158
|
-
size={12}
|
|
159
|
-
tracked
|
|
160
|
-
className="gap-4 flex-wrap"
|
|
161
|
-
/>
|
|
162
|
-
</div>
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return (
|
|
167
|
-
<div className="w-full" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
|
168
|
-
{/* Header with filter toggle + expanding search */}
|
|
169
|
-
<div className="flex items-center justify-between mb-4">
|
|
170
|
-
<div className="flex items-center gap-6">
|
|
171
|
-
<h2 className="kol-helper-14">{title}</h2>
|
|
172
|
-
<div className="flex items-center gap-1">
|
|
173
|
-
<button
|
|
174
|
-
onClick={() => setIsExpanded(!isExpanded)}
|
|
175
|
-
className="p-2 hover:bg-fg-04 rounded-sm transition-colors leading-none"
|
|
176
|
-
aria-label="Toggle filters"
|
|
177
|
-
>
|
|
178
|
-
<IconSeam name="filter" size={16} />
|
|
179
|
-
</button>
|
|
180
|
-
<div
|
|
181
|
-
className="flex items-center rounded-full cursor-pointer"
|
|
182
|
-
style={{
|
|
183
|
-
height: 28,
|
|
184
|
-
width: searchOpen ? 200 : 28,
|
|
185
|
-
background: searchOpen ? 'var(--kol-fg-04, rgba(255,255,255,0.04))' : 'transparent',
|
|
186
|
-
transition: 'width 600ms cubic-bezier(0.16, 1, 0.3, 1), background 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
|
187
|
-
overflow: 'hidden',
|
|
188
|
-
}}
|
|
189
|
-
onClick={() => {
|
|
190
|
-
if (searchOpen) { setSearchOpen(false); setSearchText('') }
|
|
191
|
-
else setSearchOpen(true)
|
|
192
|
-
}}
|
|
193
|
-
>
|
|
194
|
-
<span
|
|
195
|
-
className="flex items-center justify-center flex-shrink-0"
|
|
196
|
-
style={{
|
|
197
|
-
width: 28, height: 28,
|
|
198
|
-
opacity: searchOpen ? 0 : 1,
|
|
199
|
-
transition: 'opacity 300ms cubic-bezier(0.16, 1, 0.3, 1)',
|
|
200
|
-
position: searchOpen ? 'absolute' : 'relative',
|
|
201
|
-
}}
|
|
202
|
-
>
|
|
203
|
-
<IconSeam name="search" size={16} />
|
|
204
|
-
</span>
|
|
205
|
-
{searchOpen && (
|
|
206
|
-
<input
|
|
207
|
-
ref={searchRef}
|
|
208
|
-
type="text"
|
|
209
|
-
value={searchText}
|
|
210
|
-
onChange={e => setSearchText(e.target.value)}
|
|
211
|
-
onClick={e => e.stopPropagation()}
|
|
212
|
-
placeholder=""
|
|
213
|
-
className="bg-transparent outline-none kol-helper-12 flex-1 text-fg-80 caret-current px-4"
|
|
214
|
-
onBlur={() => { if (!searchText) { setSearchOpen(false) } }}
|
|
215
|
-
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchText('') } }}
|
|
216
|
-
/>
|
|
217
|
-
)}
|
|
218
|
-
</div>
|
|
219
|
-
{headerActions}
|
|
220
|
-
</div>
|
|
221
|
-
{activeFilters.size > 0 && (
|
|
222
|
-
<span
|
|
223
|
-
className="kol-helper-12 text-fg-48 cursor-pointer select-none group flex items-center gap-2"
|
|
224
|
-
onClick={(e) => { e.stopPropagation(); clearAllFilters() }}
|
|
225
|
-
>
|
|
226
|
-
<span className="underline">({activeFilters.size}) {activeFilters.size === 1 ? 'filter' : 'filters'} active</span>
|
|
227
|
-
<span className="hidden group-hover:inline text-fg-64">×</span>
|
|
228
|
-
</span>
|
|
229
|
-
)}
|
|
230
|
-
</div>
|
|
231
|
-
|
|
232
|
-
<div className="flex items-center gap-8">
|
|
233
|
-
{(!showCountOnlyWhenFiltering || isExpanded || searchOpen || activeFilters.size > 0) && (
|
|
234
|
-
<span className="kol-helper-14 text-fg-64">
|
|
235
|
-
{filteredItems.length} of {totalCount}
|
|
236
|
-
</span>
|
|
237
|
-
)}
|
|
238
|
-
{viewModeOptions && (
|
|
239
|
-
<TabStrip options={viewModeOptions} value={viewMode} onChange={handleViewModeChange} tracked />
|
|
240
|
-
)}
|
|
241
|
-
</div>
|
|
242
|
-
</div>
|
|
243
|
-
|
|
244
|
-
<Divider className="mb-4" />
|
|
245
|
-
|
|
246
|
-
{/* BELOW the divider: filter groups as left-aligned COLUMNS (only while
|
|
247
|
-
the filter toggle is open), layout strip RIGHT and ALWAYS visible.
|
|
248
|
-
`items-start` is load-bearing — it pins the strip to the label row, so
|
|
249
|
-
the strip and the group labels read as one line and the values hang
|
|
250
|
-
beneath. 0.1.1 read "at the divider level" as the header row above the
|
|
251
|
-
divider; the strip never belongs in the header
|
|
252
|
-
(ShellHeaderFilterRefinements, 2026-08-15). */}
|
|
253
|
-
{(layoutOptions || isExpanded) && (
|
|
254
|
-
<div className="flex items-start justify-between gap-16 mb-4">
|
|
255
|
-
<div className="flex items-start gap-16">
|
|
256
|
-
{isExpanded && filterGroups.map((group) => renderFilterGroup(group))}
|
|
257
|
-
{isExpanded && activeFilters.size > 0 && (
|
|
258
|
-
// Strip rest recipe, not its own ink — this button shares the line
|
|
259
|
-
// with the labels and values, and that line carries exactly TWO
|
|
260
|
-
// ink states (user ruling 2026-08-15: "we are not maintaining 3
|
|
261
|
-
// opacity states for one line in a component").
|
|
262
|
-
<button
|
|
263
|
-
onClick={clearAllFilters}
|
|
264
|
-
className="kol-helper-12 transition-colors underline text-fg-32 hover:text-fg-48"
|
|
265
|
-
>
|
|
266
|
-
Clear all ({activeFilters.size})
|
|
267
|
-
</button>
|
|
268
|
-
)}
|
|
269
|
-
</div>
|
|
270
|
-
{layoutOptions && (
|
|
271
|
-
<TabStrip
|
|
272
|
-
options={layoutOptions}
|
|
273
|
-
value={layout}
|
|
274
|
-
onChange={setLayout}
|
|
275
|
-
size={12}
|
|
276
|
-
tracked
|
|
277
|
-
className="gap-4"
|
|
278
|
-
/>
|
|
279
|
-
)}
|
|
280
|
-
</div>
|
|
281
|
-
)}
|
|
282
|
-
|
|
283
|
-
{/* Render filtered items */}
|
|
284
|
-
<div className="mt-8" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
|
285
|
-
{renderItem(filteredItems, viewMode, layout)}
|
|
286
|
-
</div>
|
|
287
|
-
</div>
|
|
288
|
-
)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export default ContentFilters
|