@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.
@@ -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
- const filtered = manifest.filter(e => !q || e.title.toLowerCase().includes(q) || groupName(e.group).toLowerCase().includes(q))
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}”</div>}
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
+ }
@@ -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
  }