@westopp/windo 0.1.0 → 0.1.2
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/README.md +5 -2
- package/dist/cli.cjs +1 -1
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +64 -17
- package/dist/index.d.ts +64 -17
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/cli/index.ts +1 -1
- package/src/client/App.tsx +82 -3
- package/src/client/Canvas.tsx +8 -3
- package/src/client/Inspector.tsx +37 -0
- package/src/client/Sidebar.tsx +13 -3
- package/src/client/TagFilter.tsx +125 -0
- package/src/client/bridge.ts +24 -0
- package/src/client/chrome.css +317 -52
- package/src/client/internal-types.ts +24 -0
- package/src/define-config.ts +12 -8
- package/src/index.ts +2 -0
- package/src/preview/ctx.ts +8 -1
- package/src/preview/index.ts +155 -10
- package/src/preview/render.tsx +5 -3
- package/src/protocol.ts +6 -0
- package/src/types.ts +43 -12
- package/src/descriptor.test.ts +0 -59
package/src/client/Sidebar.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Sidebar / library nav. Groups come from config (ordered); entries are placed
|
|
2
2
|
// under the group whose slug matches entry.group. Collapsed groups persist to
|
|
3
3
|
// localStorage under windo:navgroups; an active search query forces all open.
|
|
4
|
+
// The tag filter (when tags are configured) narrows entries before grouping.
|
|
4
5
|
|
|
5
6
|
import { useState } from 'react'
|
|
6
7
|
import { Icons } from './icons'
|
|
7
8
|
import type { SidebarProps } from './internal-types'
|
|
9
|
+
import { TagFilter } from './TagFilter'
|
|
8
10
|
|
|
9
11
|
const STORAGE_KEY = 'windo:navgroups'
|
|
10
12
|
|
|
@@ -27,7 +29,7 @@ function GroupChevron() {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function Sidebar(props: SidebarProps) {
|
|
30
|
-
const { groups, manifest, selected, onSelect, query, setQuery, collapsed, onToggle } = props
|
|
32
|
+
const { groups, tags, manifest, selected, onSelect, query, setQuery, selectedTags, setSelectedTags, tagMatch, setTagMatch, collapsed, onToggle } = props
|
|
31
33
|
const [closedGroups, setClosedGroups] = useState(loadClosedGroups)
|
|
32
34
|
|
|
33
35
|
function toggleGroup(slug: string) {
|
|
@@ -60,7 +62,14 @@ export function Sidebar(props: SidebarProps) {
|
|
|
60
62
|
|
|
61
63
|
const q = query.trim().toLowerCase()
|
|
62
64
|
const groupName = (slug: string) => groups.find(g => g.slug === slug)?.name ?? slug
|
|
63
|
-
|
|
65
|
+
// Drop any persisted tags no longer declared in config so a stale selection
|
|
66
|
+
// can't silently filter everything out.
|
|
67
|
+
const activeTags = selectedTags.filter(t => tags.includes(t))
|
|
68
|
+
const matchesTags = (entryTags: string[]) => {
|
|
69
|
+
if (!activeTags.length) return true
|
|
70
|
+
return tagMatch === 'all' ? activeTags.every(t => entryTags.includes(t)) : activeTags.some(t => entryTags.includes(t))
|
|
71
|
+
}
|
|
72
|
+
const filtered = manifest.filter(e => matchesTags(e.tags) && (!q || e.title.toLowerCase().includes(q) || groupName(e.group).toLowerCase().includes(q)))
|
|
64
73
|
|
|
65
74
|
return (
|
|
66
75
|
<aside className="wb-sidebar">
|
|
@@ -73,6 +82,7 @@ export function Sidebar(props: SidebarProps) {
|
|
|
73
82
|
{Icons.chevsLeft}
|
|
74
83
|
</button>
|
|
75
84
|
</div>
|
|
85
|
+
<TagFilter tags={tags} selected={activeTags} setSelected={setSelectedTags} match={tagMatch} setMatch={setTagMatch} />
|
|
76
86
|
<nav className="wb-nav">
|
|
77
87
|
{groups.map(group => {
|
|
78
88
|
const items = filtered.filter(e => e.group === group.slug)
|
|
@@ -101,7 +111,7 @@ export function Sidebar(props: SidebarProps) {
|
|
|
101
111
|
</div>
|
|
102
112
|
)
|
|
103
113
|
})}
|
|
104
|
-
{!filtered.length && <div className="wb-nav-empty">No components match “{query}
|
|
114
|
+
{!filtered.length && <div className="wb-nav-empty">{q ? <>No components match “{query}”</> : 'No components match the selected tags'}</div>}
|
|
105
115
|
</nav>
|
|
106
116
|
</aside>
|
|
107
117
|
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Tag filter — a multi-select dropdown over the config-declared tag set. Checked
|
|
2
|
+
// tags narrow the sidebar to the components carrying them; the Any/All toggle
|
|
3
|
+
// switches between union (any) and intersection (all). No selection = no filter
|
|
4
|
+
// ("All components"). Renders nothing when the project declares no tags.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef, useState } from 'react'
|
|
7
|
+
import type { TagMatch } from './internal-types'
|
|
8
|
+
|
|
9
|
+
interface TagFilterProps {
|
|
10
|
+
/** Declared tag set — the available options. */
|
|
11
|
+
tags: string[]
|
|
12
|
+
/** Currently-checked tags. */
|
|
13
|
+
selected: string[]
|
|
14
|
+
setSelected: (next: string[]) => void
|
|
15
|
+
match: TagMatch
|
|
16
|
+
setMatch: (m: TagMatch) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Check() {
|
|
20
|
+
return (
|
|
21
|
+
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
|
22
|
+
<path d="M20 6 9 17l-5-5" />
|
|
23
|
+
</svg>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Chevron() {
|
|
28
|
+
return (
|
|
29
|
+
<svg aria-hidden="true" className="chev" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
30
|
+
<path d="m6 9 6 6 6-6" />
|
|
31
|
+
</svg>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function TagFilter({ tags, selected, setSelected, match, setMatch }: TagFilterProps) {
|
|
36
|
+
const [open, setOpen] = useState(false)
|
|
37
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
38
|
+
|
|
39
|
+
// Close on outside click / Escape while the menu is open.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!open) return
|
|
42
|
+
const onDown = (e: MouseEvent) => {
|
|
43
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
|
44
|
+
}
|
|
45
|
+
const onKey = (e: KeyboardEvent) => {
|
|
46
|
+
if (e.key === 'Escape') setOpen(false)
|
|
47
|
+
}
|
|
48
|
+
document.addEventListener('mousedown', onDown)
|
|
49
|
+
document.addEventListener('keydown', onKey)
|
|
50
|
+
return () => {
|
|
51
|
+
document.removeEventListener('mousedown', onDown)
|
|
52
|
+
document.removeEventListener('keydown', onKey)
|
|
53
|
+
}
|
|
54
|
+
}, [open])
|
|
55
|
+
|
|
56
|
+
if (!tags.length) return null
|
|
57
|
+
|
|
58
|
+
const has = selected.length > 0
|
|
59
|
+
|
|
60
|
+
function toggle(tag: string) {
|
|
61
|
+
setSelected(selected.includes(tag) ? selected.filter(t => t !== tag) : [...selected, tag])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="wb-tagfilter" ref={rootRef}>
|
|
66
|
+
<div className={`wb-tagfilter-control${open ? ' open' : ''}`}>
|
|
67
|
+
{has ? (
|
|
68
|
+
<>
|
|
69
|
+
<div className="wb-tagfilter-pills">
|
|
70
|
+
{selected.map(tag => (
|
|
71
|
+
<span key={tag} className="wb-tag-pill">
|
|
72
|
+
{tag}
|
|
73
|
+
<button type="button" className="x" aria-label={`Remove ${tag}`} title={`Remove ${tag}`} onClick={() => setSelected(selected.filter(t => t !== tag))}>
|
|
74
|
+
×
|
|
75
|
+
</button>
|
|
76
|
+
</span>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
<button type="button" className="wb-tagfilter-chev" aria-expanded={open} aria-label="Filter by tag" onClick={() => setOpen(o => !o)}>
|
|
80
|
+
<Chevron />
|
|
81
|
+
</button>
|
|
82
|
+
</>
|
|
83
|
+
) : (
|
|
84
|
+
<button type="button" className="wb-tagfilter-toggle" aria-expanded={open} aria-label="Filter by tag" onClick={() => setOpen(o => !o)}>
|
|
85
|
+
<span className="wb-tagfilter-placeholder">Filter by tag…</span>
|
|
86
|
+
<Chevron />
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{open && (
|
|
92
|
+
<div className="wb-tagfilter-menu">
|
|
93
|
+
<button type="button" className="wb-tag-option" aria-pressed={!has} onClick={() => setSelected([])}>
|
|
94
|
+
<span className={`wb-check${has ? '' : ' checked'}`}>{has ? null : <Check />}</span>
|
|
95
|
+
<span className="lbl">All components</span>
|
|
96
|
+
</button>
|
|
97
|
+
<div className="wb-tag-divider" />
|
|
98
|
+
{tags.map(tag => {
|
|
99
|
+
const on = selected.includes(tag)
|
|
100
|
+
return (
|
|
101
|
+
<button type="button" key={tag} className="wb-tag-option" aria-pressed={on} onClick={() => toggle(tag)}>
|
|
102
|
+
<span className={`wb-check${on ? ' checked' : ''}`}>{on && <Check />}</span>
|
|
103
|
+
<span className="lbl">{tag}</span>
|
|
104
|
+
</button>
|
|
105
|
+
)
|
|
106
|
+
})}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{has && (
|
|
111
|
+
<div className="wb-tagmatch">
|
|
112
|
+
<span className="wb-tagmatch-label">Match</span>
|
|
113
|
+
<div className="wb-tagmatch-seg">
|
|
114
|
+
<button type="button" aria-pressed={match === 'any'} className={match === 'any' ? 'on' : ''} onClick={() => setMatch('any')}>
|
|
115
|
+
Any
|
|
116
|
+
</button>
|
|
117
|
+
<button type="button" aria-pressed={match === 'all'} className={match === 'all' ? 'on' : ''} onClick={() => setMatch('all')}>
|
|
118
|
+
All
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
package/src/client/bridge.ts
CHANGED
|
@@ -16,6 +16,7 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
16
16
|
const [readyNonce, setReadyNonce] = useState(0)
|
|
17
17
|
const [title, setTitle] = useState('')
|
|
18
18
|
const [groups, setGroups] = useState<WindoGroup[]>([])
|
|
19
|
+
const [tags, setTags] = useState<string[]>([])
|
|
19
20
|
const [contexts, setContexts] = useState<WindoContextMeta[]>([])
|
|
20
21
|
const [manifest, setManifest] = useState<WindoManifestEntry[]>([])
|
|
21
22
|
const [describe, setDescribe] = useState<WindoDescribe | null>(null)
|
|
@@ -24,6 +25,14 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
24
25
|
const [logs, setLogs] = useState<WindoLogRow[]>([])
|
|
25
26
|
const [stateValues, setStateValues] = useState<Record<string, Record<string, unknown>>>({})
|
|
26
27
|
const [actionDisabled, setActionDisabled] = useState<Record<string, Record<string, boolean>>>({})
|
|
28
|
+
// Shared state: `ctxStateDefaults` is the config seed (from the manifest);
|
|
29
|
+
// `ctxState` is the latest snapshot echoed after a component writes it.
|
|
30
|
+
const [ctxStateDefaults, setCtxStateDefaults] = useState<Record<string, unknown>>({})
|
|
31
|
+
const [ctxState, setCtxStateEcho] = useState<Record<string, unknown>>({})
|
|
32
|
+
// Latest colour scheme a component pushed via `ctx.toggleTheme`/`setColorScheme`
|
|
33
|
+
// (null until one does). Wrapped in a fresh object each echo so consumers re-fire
|
|
34
|
+
// even when the same scheme is pushed twice (e.g. after a manual revert between).
|
|
35
|
+
const [colorScheme, setColorSchemeEcho] = useState<{ value: 'light' | 'dark' } | null>(null)
|
|
27
36
|
const seqRef = useRef(0)
|
|
28
37
|
|
|
29
38
|
const post = useCallback(
|
|
@@ -51,7 +60,9 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
51
60
|
setTitle(msg.title)
|
|
52
61
|
setManifest(msg.entries)
|
|
53
62
|
setGroups(msg.groups)
|
|
63
|
+
setTags(msg.tags)
|
|
54
64
|
setContexts(msg.contexts)
|
|
65
|
+
setCtxStateDefaults(msg.ctxState)
|
|
55
66
|
break
|
|
56
67
|
case 'describe':
|
|
57
68
|
setDescribe({
|
|
@@ -80,6 +91,12 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
80
91
|
setStateValues(prev => ({ ...prev, [msg.id]: msg.state }))
|
|
81
92
|
setActionDisabled(prev => ({ ...prev, [msg.id]: Object.fromEntries(msg.actions.map(a => [a.id, a.disabled])) }))
|
|
82
93
|
break
|
|
94
|
+
case 'ctx-state':
|
|
95
|
+
setCtxStateEcho(msg.state)
|
|
96
|
+
break
|
|
97
|
+
case 'color-scheme':
|
|
98
|
+
setColorSchemeEcho({ value: msg.colorScheme })
|
|
99
|
+
break
|
|
83
100
|
case 'render-error':
|
|
84
101
|
setRenderError({ message: msg.message, stack: msg.stack })
|
|
85
102
|
break
|
|
@@ -98,6 +115,8 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
98
115
|
|
|
99
116
|
const setEnv = useCallback((env: WindoEnvState) => post({ source: WINDO_MSG, dir: 'host', type: 'set-env', env }), [post])
|
|
100
117
|
|
|
118
|
+
const setCtxState = useCallback((state: Record<string, unknown>) => post({ source: WINDO_MSG, dir: 'host', type: 'set-ctx-state', state }), [post])
|
|
119
|
+
|
|
101
120
|
const invokeAction = useCallback((id: string, actionId: string) => post({ source: WINDO_MSG, dir: 'host', type: 'invoke-action', id, actionId }), [post])
|
|
102
121
|
|
|
103
122
|
const clearLogs = useCallback(() => setLogs([]), [])
|
|
@@ -107,6 +126,7 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
107
126
|
readyNonce,
|
|
108
127
|
title,
|
|
109
128
|
groups,
|
|
129
|
+
tags,
|
|
110
130
|
contexts,
|
|
111
131
|
manifest,
|
|
112
132
|
describe,
|
|
@@ -116,9 +136,13 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
116
136
|
clearLogs,
|
|
117
137
|
stateValues,
|
|
118
138
|
actionDisabled,
|
|
139
|
+
ctxStateDefaults,
|
|
140
|
+
ctxState,
|
|
141
|
+
colorScheme,
|
|
119
142
|
select,
|
|
120
143
|
setProps,
|
|
121
144
|
setEnv,
|
|
145
|
+
setCtxState,
|
|
122
146
|
invokeAction,
|
|
123
147
|
}
|
|
124
148
|
}
|