@kolkrabbi/kol-workshop 0.1.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.
@@ -0,0 +1,105 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
2
+ import { useLocation } from 'react-router-dom'
3
+
4
+ const TagModeContext = createContext(null)
5
+
6
+ /**
7
+ * Default route helpers. These are the ONLY place the old hardcoded workshop
8
+ * routes survive — pass `docHref` / `tagHref` to the provider to point the tag
9
+ * system at your app's routes. Consumers also inject their docs `inventory`
10
+ * here so the overlay + graph stay decoupled from any Vite-glob singleton.
11
+ */
12
+ const defaultDocHref = (id) => `/workshop/docs/${id}`
13
+ const defaultTagHref = (tag) => `/workshop/design-system/documentation?tag=${encodeURIComponent(tag)}`
14
+
15
+ export const TagModeProvider = ({
16
+ children,
17
+ inventory = [],
18
+ docHref = defaultDocHref,
19
+ tagHref = defaultTagHref
20
+ }) => {
21
+ const [state, setState] = useState({ isOpen: false, activeTags: [] })
22
+
23
+ const openTagMode = useCallback((tag = null) => {
24
+ setState({ isOpen: true, activeTags: tag ? [tag] : [] })
25
+ }, [])
26
+
27
+ const closeTagMode = useCallback(() => {
28
+ setState({ isOpen: false, activeTags: [] })
29
+ }, [])
30
+
31
+ const toggleTag = useCallback((tag) => {
32
+ setState((prev) => ({
33
+ ...prev,
34
+ activeTags: prev.activeTags.includes(tag)
35
+ ? prev.activeTags.filter((t) => t !== tag)
36
+ : [...prev.activeTags, tag]
37
+ }))
38
+ }, [])
39
+
40
+ const removeTag = useCallback((tag) => {
41
+ setState((prev) => ({
42
+ ...prev,
43
+ activeTags: prev.activeTags.filter((t) => t !== tag)
44
+ }))
45
+ }, [])
46
+
47
+ const clearTags = useCallback(() => {
48
+ setState((prev) => ({ ...prev, activeTags: [] }))
49
+ }, [])
50
+
51
+ // Backward compat: activeTag = first tag (used by TagGraph)
52
+ const activeTag = state.activeTags[0] || null
53
+
54
+ // Backward compat: setActiveTag sets single tag
55
+ const setActiveTag = useCallback((tag) => {
56
+ setState((prev) => ({ ...prev, activeTags: tag ? [tag] : [] }))
57
+ }, [])
58
+
59
+ // Close on any route change
60
+ const location = useLocation()
61
+ useEffect(() => {
62
+ if (state.isOpen) setState({ isOpen: false, activeTags: [] })
63
+ }, [location.pathname])
64
+
65
+ useEffect(() => {
66
+ if (!state.isOpen) return
67
+ const handleKeyDown = (e) => {
68
+ if (e.key === 'Escape') {
69
+ e.preventDefault()
70
+ closeTagMode()
71
+ }
72
+ }
73
+ window.addEventListener('keydown', handleKeyDown)
74
+ return () => window.removeEventListener('keydown', handleKeyDown)
75
+ }, [state.isOpen, closeTagMode])
76
+
77
+ const value = useMemo(() => ({
78
+ isOpen: state.isOpen,
79
+ activeTags: state.activeTags,
80
+ activeTag,
81
+ openTagMode,
82
+ closeTagMode,
83
+ toggleTag,
84
+ removeTag,
85
+ clearTags,
86
+ setActiveTag,
87
+ inventory,
88
+ docHref,
89
+ tagHref
90
+ }), [state.isOpen, state.activeTags, activeTag, openTagMode, closeTagMode, toggleTag, removeTag, clearTags, setActiveTag, inventory, docHref, tagHref])
91
+
92
+ return (
93
+ <TagModeContext.Provider value={value}>
94
+ {children}
95
+ </TagModeContext.Provider>
96
+ )
97
+ }
98
+
99
+ export const useTagMode = () => {
100
+ const ctx = useContext(TagModeContext)
101
+ if (!ctx) throw new Error('useTagMode must be used within TagModeProvider')
102
+ return ctx
103
+ }
104
+
105
+ export default TagModeContext
@@ -0,0 +1,17 @@
1
+ import { Outlet } from 'react-router-dom'
2
+ import { useTagMode } from './TagModeContext.jsx'
3
+ import TagModeOverlay from './TagModeOverlay.jsx'
4
+
5
+ const TagModeGate = () => {
6
+ const { isOpen } = useTagMode()
7
+ return (
8
+ <>
9
+ <div style={isOpen ? { display: 'none' } : undefined}>
10
+ <Outlet />
11
+ </div>
12
+ {isOpen && <TagModeOverlay />}
13
+ </>
14
+ )
15
+ }
16
+
17
+ export default TagModeGate
@@ -0,0 +1,172 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { Link } from 'react-router-dom'
3
+ import { Input, Tag, Button } from '@kolkrabbi/kol-component'
4
+ import { useTagMode } from './TagModeContext.jsx'
5
+ import TagGraph from './TagGraph.jsx'
6
+ import { extractDocNumber, cleanTitle, getTagColor } from '../engine/index.js'
7
+ import { buildTagCounts } from '../engine/tags.js'
8
+
9
+ const TagModeOverlay = () => {
10
+ const { activeTags, activeTag, toggleTag, removeTag, clearTags, closeTagMode, inventory, docHref, tagHref } = useTagMode()
11
+ const [viewMode, setViewMode] = useState('list')
12
+ const [search, setSearch] = useState('')
13
+
14
+ const allTagsWithCount = useMemo(() => buildTagCounts(inventory), [inventory])
15
+
16
+ const visibleTags = useMemo(() => {
17
+ let tags = allTagsWithCount.filter(({ tag }) => !activeTags.includes(tag))
18
+ if (search.trim()) {
19
+ const q = search.toLowerCase()
20
+ tags = tags.filter(({ tag }) => tag.toLowerCase().includes(q))
21
+ }
22
+ return tags
23
+ }, [allTagsWithCount, search, activeTags])
24
+
25
+ const filteredDocs = useMemo(() => {
26
+ if (activeTags.length === 0) return []
27
+ return inventory.filter((d) => {
28
+ if (!Array.isArray(d.metadata?.tags)) return false
29
+ return activeTags.every((t) => d.metadata.tags.includes(t))
30
+ })
31
+ }, [inventory, activeTags])
32
+
33
+ const hasFilters = activeTags.length > 0
34
+
35
+ return (
36
+ <article className="docs-article">
37
+ <div className="max-w-[864px] mx-auto">
38
+ <div className="flex items-center justify-start gap-1 mb-3">
39
+ {hasFilters && (
40
+ <Button
41
+ variant="outline"
42
+ quiet
43
+ size="sm"
44
+ iconOnly={viewMode === 'graph' ? 'view-list' : 'polygon'}
45
+ onClick={() => setViewMode(viewMode === 'graph' ? 'list' : 'graph')}
46
+ aria-label={viewMode === 'graph' ? 'List view' : 'Graph view'}
47
+ />
48
+ )}
49
+ <Button
50
+ variant="outline"
51
+ quiet
52
+ size="sm"
53
+ iconOnly="x"
54
+ onClick={closeTagMode}
55
+ aria-label="Close tag mode"
56
+ />
57
+ </div>
58
+
59
+ <div className="dash-card flex flex-col gap-8 -mt-4">
60
+ <div className="flex items-center gap-2" style={{ width: '100%', alignSelf: 'stretch' }}>
61
+ <Input
62
+ type="text"
63
+ placeholder="Search tags…"
64
+ value={search}
65
+ onChange={(e) => setSearch(e.target.value)}
66
+ onKeyDown={(e) => {
67
+ if (e.key === 'Enter' && visibleTags.length > 0) {
68
+ toggleTag(visibleTags[0].tag)
69
+ setSearch('')
70
+ }
71
+ }}
72
+ size="md"
73
+ iconLeft="search"
74
+ className="w-full"
75
+ autoFocus
76
+ />
77
+ {search && (
78
+ <Button
79
+ variant="outline"
80
+ quiet
81
+ size="sm"
82
+ iconOnly="x"
83
+ onClick={() => setSearch('')}
84
+ aria-label="Clear search"
85
+ />
86
+ )}
87
+ </div>
88
+
89
+ <div className="flex flex-col gap-4 px-10">
90
+ {hasFilters && (
91
+ <>
92
+ <div className="flex items-center justify-between">
93
+ <Button
94
+ variant="outline"
95
+ quiet
96
+ size="sm"
97
+ onClick={clearTags}
98
+ >
99
+ clear filters
100
+ </Button>
101
+ <div className="flex flex-wrap items-center gap-1.5">
102
+ {activeTags.map((tag) => (
103
+ <Tag
104
+ key={tag}
105
+ variant="solid"
106
+ color={getTagColor(tag)}
107
+ size="md"
108
+ onRemove={() => removeTag(tag)}
109
+ >
110
+ {tag}
111
+ </Tag>
112
+ ))}
113
+ </div>
114
+ </div>
115
+ <div className="border-t border-fg-08 my-4" />
116
+ </>
117
+ )}
118
+
119
+ {hasFilters && viewMode === 'graph' ? (
120
+ <div>
121
+ <TagGraph
122
+ docs={filteredDocs}
123
+ allDocs={inventory}
124
+ activeTag={activeTag}
125
+ onTagClick={(tag) => toggleTag(tag)}
126
+ tagHref={tagHref}
127
+ />
128
+ </div>
129
+ ) : (
130
+ <>
131
+ <div className="flex flex-col">
132
+ {visibleTags.map(({ tag, count }) => (
133
+ <button
134
+ key={tag}
135
+ type="button"
136
+ className={`tag-list-item${activeTags.includes(tag) ? ' active' : ''}`}
137
+ onClick={() => toggleTag(tag)}
138
+ >
139
+ <span>{tag}</span>
140
+ <span className="tag-list-count">{count}</span>
141
+ </button>
142
+ ))}
143
+ {visibleTags.length === 0 && (
144
+ <p className="text-fg-48 kol-mono-xs py-4">No tags matching "{search}"</p>
145
+ )}
146
+ </div>
147
+
148
+ {hasFilters && filteredDocs.length > 0 && (
149
+ <div className="flex flex-col pt-4 border-t border-fg-08">
150
+ {filteredDocs.map((d) => (
151
+ <Link
152
+ key={d.id}
153
+ to={docHref(d.id)}
154
+ className="tag-list-item"
155
+ onClick={closeTagMode}
156
+ >
157
+ <span>{cleanTitle(d.title, d.id)}</span>
158
+ <span className="tag-list-count">{extractDocNumber(d.id)}</span>
159
+ </Link>
160
+ ))}
161
+ </div>
162
+ )}
163
+ </>
164
+ )}
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </article>
169
+ )
170
+ }
171
+
172
+ export default TagModeOverlay
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @kolkrabbi/kol-workshop — tag system.
3
+ *
4
+ * Route- and inventory-decoupled tag mode lifted from the monorepo. Wrap the
5
+ * routed tree in <TagModeProvider inventory={...} docHref={...} tagHref={...}>,
6
+ * mount <TagModeGate> as a layout route, and open with useTagMode().openTagMode.
7
+ */
8
+ export { TagModeProvider, useTagMode } from './TagModeContext.jsx'
9
+ export { default as TagModeGate } from './TagModeGate.jsx'
10
+ export { default as TagModeOverlay } from './TagModeOverlay.jsx'
11
+ export { default as TagGraph } from './TagGraph.jsx'