@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-shell",
3
- "version": "0.1.3",
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-component": "^0.43.0"
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
- * `value` takes a **Set** for a multi-select strip (ContentFilters' filter
12
- * values, ruled 2026-08-15 to wear this exact idiom rather than Tag chips) or
13
- * a scalar for the single-select tabs. One ink recipe, both behaviours — the
14
- * alternative was a second copy of the active/rest classes, which is how the
15
- * two shipped shells drifted in the first place.
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 ${isOn(opt) ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
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
- export { default as ContentFilters } from './ContentFilters.jsx'
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'
@@ -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