@westopp/windo 0.1.0 → 0.1.1
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/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -7
- package/dist/index.d.ts +15 -7
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/client/App.tsx +36 -3
- package/src/client/Canvas.tsx +8 -3
- package/src/client/Sidebar.tsx +13 -3
- package/src/client/TagFilter.tsx +125 -0
- package/src/client/bridge.ts +3 -0
- package/src/client/chrome.css +247 -7
- package/src/client/internal-types.ts +12 -0
- package/src/define-config.ts +12 -8
- package/src/preview/index.ts +3 -0
- package/src/protocol.ts +1 -0
- package/src/types.ts +10 -3
- package/src/descriptor.test.ts +0 -59
package/src/client/Canvas.tsx
CHANGED
|
@@ -156,7 +156,7 @@ function ResizeHandle({
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
// biome-ignore lint/a11y/noStaticElementInteractions: drag handle is pointer-only by design
|
|
159
|
-
return <div className={`wb-handle${dragging ? ' dragging' : ''}`} title="Drag to resize width — double-click to fill" onDoubleClick={onReset} onPointerDown={onPointerDown} />
|
|
159
|
+
return <div className={`wb-handle${dragging ? ' dragging' : ''}`} title="Drag to resize width — double-click to toggle fill / mobile" onDoubleClick={onReset} onPointerDown={onPointerDown} />
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
function HeightHandle({ height, setHeight, zoom, onReset }: { height: number; setHeight: (h: number) => void; zoom: number; onReset: () => void }) {
|
|
@@ -296,6 +296,11 @@ export function Canvas(props: CanvasProps) {
|
|
|
296
296
|
const mode = theme === 'dark' ? grid.dark : grid.light
|
|
297
297
|
const bp = breakpointFor(clamped)
|
|
298
298
|
|
|
299
|
+
// Double-clicking a width handle fills the stage; if already filled, it drops
|
|
300
|
+
// to the mobile preset so the gesture toggles between the two extremes.
|
|
301
|
+
const mobileWidth = PRESETS.find(p => p.id === 'mobile')?.width ?? MIN_WIDTH
|
|
302
|
+
const toggleFillWidth = () => setWidth(clamped >= maxWidth - 2 ? Math.min(mobileWidth, maxWidth) : maxWidth)
|
|
303
|
+
|
|
299
304
|
const gridClass = grid.on ? (grid.style === 'lines' ? ' grid-lines' : ' grid-dots') : ''
|
|
300
305
|
|
|
301
306
|
const frameStyle = {
|
|
@@ -327,7 +332,7 @@ export function Canvas(props: CanvasProps) {
|
|
|
327
332
|
<div className="wb-zoomwrap" style={{ transform: `scale(${zoom})` }}>
|
|
328
333
|
<div className="wb-frame-col">
|
|
329
334
|
<div className="wb-frame-row">
|
|
330
|
-
<ResizeHandle side="left" width={clamped} setWidth={setWidth} maxWidth={maxWidth} zoom={zoom} onReset={
|
|
335
|
+
<ResizeHandle side="left" width={clamped} setWidth={setWidth} maxWidth={maxWidth} zoom={zoom} onReset={toggleFillWidth} />
|
|
331
336
|
<div className="wb-framewrap">
|
|
332
337
|
<span className="wb-corner tl" />
|
|
333
338
|
<span className="wb-corner tr" />
|
|
@@ -344,7 +349,7 @@ export function Canvas(props: CanvasProps) {
|
|
|
344
349
|
</div>
|
|
345
350
|
</div>
|
|
346
351
|
</div>
|
|
347
|
-
<ResizeHandle side="right" width={clamped} setWidth={setWidth} maxWidth={maxWidth} zoom={zoom} onReset={
|
|
352
|
+
<ResizeHandle side="right" width={clamped} setWidth={setWidth} maxWidth={maxWidth} zoom={zoom} onReset={toggleFillWidth} />
|
|
348
353
|
</div>
|
|
349
354
|
<HeightHandle height={frameH} setHeight={setHeight} zoom={zoom} onReset={() => setHeight(null)} />
|
|
350
355
|
<div className="wb-dim-readout">
|
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)
|
|
@@ -51,6 +52,7 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
51
52
|
setTitle(msg.title)
|
|
52
53
|
setManifest(msg.entries)
|
|
53
54
|
setGroups(msg.groups)
|
|
55
|
+
setTags(msg.tags)
|
|
54
56
|
setContexts(msg.contexts)
|
|
55
57
|
break
|
|
56
58
|
case 'describe':
|
|
@@ -107,6 +109,7 @@ export function useWindoBridge(iframeRef: RefObject<HTMLIFrameElement | null>):
|
|
|
107
109
|
readyNonce,
|
|
108
110
|
title,
|
|
109
111
|
groups,
|
|
112
|
+
tags,
|
|
110
113
|
contexts,
|
|
111
114
|
manifest,
|
|
112
115
|
describe,
|
package/src/client/chrome.css
CHANGED
|
@@ -138,13 +138,8 @@ button {
|
|
|
138
138
|
.wb-logo-mark {
|
|
139
139
|
width: 22px;
|
|
140
140
|
height: 22px;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
color: var(--panel);
|
|
144
|
-
display: grid;
|
|
145
|
-
place-items: center;
|
|
146
|
-
font-size: 10.5px;
|
|
147
|
-
font-weight: 700;
|
|
141
|
+
flex-shrink: 0;
|
|
142
|
+
display: block;
|
|
148
143
|
}
|
|
149
144
|
|
|
150
145
|
.wb-logo-sub {
|
|
@@ -289,6 +284,251 @@ button {
|
|
|
289
284
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
290
285
|
}
|
|
291
286
|
|
|
287
|
+
/* ---------- Tag filter ---------- */
|
|
288
|
+
|
|
289
|
+
.wb-tagfilter {
|
|
290
|
+
position: relative;
|
|
291
|
+
margin: 0 12px 2px 12px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.wb-tagfilter-control {
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
gap: 5px;
|
|
298
|
+
width: 100%;
|
|
299
|
+
min-height: 32px;
|
|
300
|
+
padding: 4px 8px 4px 10px;
|
|
301
|
+
border: 1px solid var(--border);
|
|
302
|
+
border-radius: var(--r);
|
|
303
|
+
background: var(--panel2);
|
|
304
|
+
transition:
|
|
305
|
+
border-color 0.12s,
|
|
306
|
+
box-shadow 0.12s;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.wb-tagfilter-control:hover {
|
|
310
|
+
border-color: var(--border-strong);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.wb-tagfilter-control.open {
|
|
314
|
+
border-color: var(--border-strong);
|
|
315
|
+
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.wb-tagfilter-toggle {
|
|
319
|
+
flex: 1;
|
|
320
|
+
display: flex;
|
|
321
|
+
align-items: center;
|
|
322
|
+
justify-content: space-between;
|
|
323
|
+
gap: 6px;
|
|
324
|
+
min-width: 0;
|
|
325
|
+
align-self: stretch;
|
|
326
|
+
border: none;
|
|
327
|
+
background: transparent;
|
|
328
|
+
color: var(--text);
|
|
329
|
+
font: inherit;
|
|
330
|
+
font-size: 12.5px;
|
|
331
|
+
text-align: left;
|
|
332
|
+
cursor: pointer;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.wb-tagfilter-placeholder {
|
|
336
|
+
flex: 1;
|
|
337
|
+
color: var(--faint);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.wb-tagfilter-pills {
|
|
341
|
+
flex: 1 1 auto;
|
|
342
|
+
min-width: 0;
|
|
343
|
+
display: flex;
|
|
344
|
+
flex-wrap: wrap;
|
|
345
|
+
gap: 5px;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.wb-tagfilter-chev {
|
|
349
|
+
flex-shrink: 0;
|
|
350
|
+
display: inline-flex;
|
|
351
|
+
align-items: center;
|
|
352
|
+
justify-content: center;
|
|
353
|
+
align-self: stretch;
|
|
354
|
+
padding: 0 2px;
|
|
355
|
+
border: none;
|
|
356
|
+
background: transparent;
|
|
357
|
+
color: var(--faint);
|
|
358
|
+
cursor: pointer;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.wb-tag-pill {
|
|
362
|
+
display: inline-flex;
|
|
363
|
+
align-items: center;
|
|
364
|
+
gap: 4px;
|
|
365
|
+
padding: 3px 4px 3px 9px;
|
|
366
|
+
border-radius: 999px;
|
|
367
|
+
background: var(--accent);
|
|
368
|
+
color: var(--accent-text);
|
|
369
|
+
font-size: 11.5px;
|
|
370
|
+
font-weight: 500;
|
|
371
|
+
line-height: 1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
[data-theme="dark"] .wb-tag-pill {
|
|
375
|
+
background: var(--accent-ui);
|
|
376
|
+
color: #111113;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.wb-tag-pill .x {
|
|
380
|
+
display: inline-flex;
|
|
381
|
+
align-items: center;
|
|
382
|
+
justify-content: center;
|
|
383
|
+
width: 15px;
|
|
384
|
+
height: 15px;
|
|
385
|
+
padding: 0;
|
|
386
|
+
border: none;
|
|
387
|
+
border-radius: 50%;
|
|
388
|
+
background: transparent;
|
|
389
|
+
color: inherit;
|
|
390
|
+
font: inherit;
|
|
391
|
+
font-size: 13px;
|
|
392
|
+
line-height: 1;
|
|
393
|
+
opacity: 0.6;
|
|
394
|
+
cursor: pointer;
|
|
395
|
+
transition:
|
|
396
|
+
opacity 0.1s,
|
|
397
|
+
background 0.1s;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.wb-tag-pill .x:hover {
|
|
401
|
+
opacity: 1;
|
|
402
|
+
background: color-mix(in srgb, currentColor 22%, transparent);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.wb-tagfilter-control .chev {
|
|
406
|
+
flex-shrink: 0;
|
|
407
|
+
color: var(--faint);
|
|
408
|
+
transition: transform 0.15s;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.wb-tagfilter-control.open .chev {
|
|
412
|
+
transform: rotate(180deg);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.wb-tagfilter-menu {
|
|
416
|
+
position: absolute;
|
|
417
|
+
z-index: 20;
|
|
418
|
+
top: calc(100% + 4px);
|
|
419
|
+
left: 0;
|
|
420
|
+
right: 0;
|
|
421
|
+
padding: 5px;
|
|
422
|
+
border: 1px solid var(--border);
|
|
423
|
+
border-radius: var(--r);
|
|
424
|
+
background: var(--panel);
|
|
425
|
+
box-shadow: var(--shadow);
|
|
426
|
+
max-height: 280px;
|
|
427
|
+
overflow-y: auto;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.wb-tag-option {
|
|
431
|
+
display: flex;
|
|
432
|
+
align-items: center;
|
|
433
|
+
gap: 9px;
|
|
434
|
+
width: 100%;
|
|
435
|
+
padding: 7px 8px;
|
|
436
|
+
border: none;
|
|
437
|
+
border-radius: var(--r);
|
|
438
|
+
background: transparent;
|
|
439
|
+
color: var(--text);
|
|
440
|
+
font: inherit;
|
|
441
|
+
font-size: 13px;
|
|
442
|
+
text-align: left;
|
|
443
|
+
cursor: pointer;
|
|
444
|
+
transition: background 0.1s;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.wb-tag-option:hover {
|
|
448
|
+
background: var(--inset);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.wb-tag-option .lbl {
|
|
452
|
+
flex: 1;
|
|
453
|
+
overflow: hidden;
|
|
454
|
+
text-overflow: ellipsis;
|
|
455
|
+
white-space: nowrap;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.wb-check {
|
|
459
|
+
display: inline-flex;
|
|
460
|
+
align-items: center;
|
|
461
|
+
justify-content: center;
|
|
462
|
+
width: 17px;
|
|
463
|
+
height: 17px;
|
|
464
|
+
flex-shrink: 0;
|
|
465
|
+
border: 1.5px solid var(--border-strong);
|
|
466
|
+
border-radius: 5px;
|
|
467
|
+
background: var(--panel2);
|
|
468
|
+
color: var(--accent-text);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.wb-check.checked {
|
|
472
|
+
background: var(--accent);
|
|
473
|
+
border-color: var(--accent);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
[data-theme="dark"] .wb-check.checked {
|
|
477
|
+
background: var(--accent-ui);
|
|
478
|
+
border-color: var(--accent-ui);
|
|
479
|
+
color: #111113;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.wb-tag-divider {
|
|
483
|
+
height: 1px;
|
|
484
|
+
margin: 5px 4px;
|
|
485
|
+
background: var(--border);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.wb-tagmatch {
|
|
489
|
+
display: flex;
|
|
490
|
+
align-items: center;
|
|
491
|
+
gap: 10px;
|
|
492
|
+
margin: 8px 2px 0 2px;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.wb-tagmatch-label {
|
|
496
|
+
font-size: 10.5px;
|
|
497
|
+
font-weight: 600;
|
|
498
|
+
letter-spacing: 0.07em;
|
|
499
|
+
text-transform: uppercase;
|
|
500
|
+
color: var(--faint);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.wb-tagmatch-seg {
|
|
504
|
+
display: inline-flex;
|
|
505
|
+
padding: 2px;
|
|
506
|
+
border-radius: 999px;
|
|
507
|
+
background: var(--inset);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.wb-tagmatch-seg button {
|
|
511
|
+
border: none;
|
|
512
|
+
background: transparent;
|
|
513
|
+
color: var(--muted);
|
|
514
|
+
font: inherit;
|
|
515
|
+
font-size: 12px;
|
|
516
|
+
font-weight: 500;
|
|
517
|
+
padding: 4px 14px;
|
|
518
|
+
border-radius: 999px;
|
|
519
|
+
cursor: pointer;
|
|
520
|
+
transition:
|
|
521
|
+
background 0.12s,
|
|
522
|
+
color 0.12s,
|
|
523
|
+
box-shadow 0.12s;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.wb-tagmatch-seg button.on {
|
|
527
|
+
background: var(--panel);
|
|
528
|
+
color: var(--text);
|
|
529
|
+
box-shadow: 0 1px 2px rgba(16, 16, 18, 0.08);
|
|
530
|
+
}
|
|
531
|
+
|
|
292
532
|
.wb-nav {
|
|
293
533
|
flex: 1;
|
|
294
534
|
overflow-y: auto;
|
|
@@ -31,6 +31,8 @@ export interface BridgeApi {
|
|
|
31
31
|
readyNonce: number
|
|
32
32
|
title: string
|
|
33
33
|
groups: WindoGroup[]
|
|
34
|
+
/** The config's declared tag set (filter options for the sidebar). */
|
|
35
|
+
tags: string[]
|
|
34
36
|
contexts: WindoContextMeta[]
|
|
35
37
|
manifest: WindoManifestEntry[]
|
|
36
38
|
/** describe payload for the most recently selected id (null until it arrives). */
|
|
@@ -52,6 +54,9 @@ export interface BridgeApi {
|
|
|
52
54
|
|
|
53
55
|
export type InspectorPosition = 'right' | 'bottom'
|
|
54
56
|
|
|
57
|
+
/** How the sidebar's tag filter combines multiple selected tags. */
|
|
58
|
+
export type TagMatch = 'any' | 'all'
|
|
59
|
+
|
|
55
60
|
export type ThemeMode = 'light' | 'dark'
|
|
56
61
|
|
|
57
62
|
/** Cosmetic canvas chrome (drawn behind a transparent iframe), persisted to localStorage. */
|
|
@@ -85,11 +90,18 @@ export interface ChromeEnv {
|
|
|
85
90
|
|
|
86
91
|
export interface SidebarProps {
|
|
87
92
|
groups: WindoGroup[]
|
|
93
|
+
/** Declared tag set — the filter's options. */
|
|
94
|
+
tags: string[]
|
|
88
95
|
manifest: WindoManifestEntry[]
|
|
89
96
|
selected: string | null
|
|
90
97
|
onSelect: (id: string) => void
|
|
91
98
|
query: string
|
|
92
99
|
setQuery: (q: string) => void
|
|
100
|
+
/** Currently-checked tag filters. Empty = no filter. */
|
|
101
|
+
selectedTags: string[]
|
|
102
|
+
setSelectedTags: (next: string[]) => void
|
|
103
|
+
tagMatch: TagMatch
|
|
104
|
+
setTagMatch: (m: TagMatch) => void
|
|
93
105
|
collapsed: boolean
|
|
94
106
|
onToggle: () => void
|
|
95
107
|
}
|
package/src/define-config.ts
CHANGED
|
@@ -6,18 +6,22 @@ import type { ComponentType, ReactNode } from 'react'
|
|
|
6
6
|
import type { z } from 'zod'
|
|
7
7
|
import type { WindoConfig, WindoContextDefinition, WindoContextMap, WindoControlMap, WindoControlValues, WindoDefinition, WindoFactoryArg, WindoGroup, WindoModule, WindoRenderContext } from './types'
|
|
8
8
|
|
|
9
|
-
export interface DefineWindoConfigResult<Groups extends readonly WindoGroup[], Contexts extends WindoContextMap> {
|
|
10
|
-
config: WindoConfig<Groups, Contexts>
|
|
11
|
-
windo: <Props, State = Record<string, never>>(
|
|
9
|
+
export interface DefineWindoConfigResult<Groups extends readonly WindoGroup[], Tags extends readonly string[], Contexts extends WindoContextMap> {
|
|
10
|
+
config: WindoConfig<Groups, Contexts, Tags>
|
|
11
|
+
windo: <Props, State = Record<string, never>>(
|
|
12
|
+
factory: (w: WindoFactoryArg<Groups, Contexts, Tags>) => WindoDefinition<Props, State, Groups[number]['slug'], Tags[number]>
|
|
13
|
+
) => WindoModule<Props, State>
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
export function defineWindoConfig<const Groups extends readonly WindoGroup[], Contexts extends WindoContextMap = Record<string, never>>(
|
|
15
|
-
config: WindoConfig<Groups, Contexts>
|
|
16
|
-
): DefineWindoConfigResult<Groups, Contexts> {
|
|
17
|
-
function windo<Props, State = Record<string, never>>(
|
|
16
|
+
export function defineWindoConfig<const Groups extends readonly WindoGroup[], const Tags extends readonly string[] = readonly [], Contexts extends WindoContextMap = Record<string, never>>(
|
|
17
|
+
config: WindoConfig<Groups, Contexts, Tags>
|
|
18
|
+
): DefineWindoConfigResult<Groups, Tags, Contexts> {
|
|
19
|
+
function windo<Props, State = Record<string, never>>(
|
|
20
|
+
factory: (w: WindoFactoryArg<Groups, Contexts, Tags>) => WindoDefinition<Props, State, Groups[number]['slug'], Tags[number]>
|
|
21
|
+
): WindoModule<Props, State> {
|
|
18
22
|
return {
|
|
19
23
|
__windo: true,
|
|
20
|
-
resolve: w => factory(w as unknown as WindoFactoryArg<Groups, Contexts>),
|
|
24
|
+
resolve: w => factory(w as unknown as WindoFactoryArg<Groups, Contexts, Tags>),
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
return { config, windo }
|
package/src/preview/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ const contexts = config.contexts ?? {}
|
|
|
21
21
|
const w: WindoFactoryArg<readonly WindoGroup[], WindoContextMap> = {
|
|
22
22
|
groups: Object.fromEntries(config.groups.map(g => [g.slug, g])) as Record<string, WindoGroup>,
|
|
23
23
|
contexts,
|
|
24
|
+
tags: config.tags ? [...config.tags] : [],
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
interface ResolvedWindo {
|
|
@@ -110,6 +111,7 @@ function manifestEntry(id: string, def: WindoDefinition): WindoManifestEntry {
|
|
|
110
111
|
id,
|
|
111
112
|
title: def.title,
|
|
112
113
|
group: def.group,
|
|
114
|
+
tags: def.tags ?? [],
|
|
113
115
|
status: def.status ?? 'stable',
|
|
114
116
|
placement: def.placement ?? 'center',
|
|
115
117
|
uses: def.uses ?? [],
|
|
@@ -145,6 +147,7 @@ function postManifest() {
|
|
|
145
147
|
title: config.title ?? 'windo',
|
|
146
148
|
entries,
|
|
147
149
|
groups: [...config.groups],
|
|
150
|
+
tags: config.tags ? [...config.tags] : [],
|
|
148
151
|
contexts: buildContextMeta(contexts),
|
|
149
152
|
})
|
|
150
153
|
}
|
package/src/protocol.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -161,9 +161,11 @@ export type WindoDefaultProps<Props, State = unknown> = Props | ((ctx: WindoRend
|
|
|
161
161
|
* `actions`, `providers`, `component` — runs at render-time with the live `ctx`.
|
|
162
162
|
* Never close over live values in the static factory body.
|
|
163
163
|
*/
|
|
164
|
-
export interface WindoDefinition<Props = unknown, State = unknown, GroupSlug extends string = string> {
|
|
164
|
+
export interface WindoDefinition<Props = unknown, State = unknown, GroupSlug extends string = string, Tag extends string = string> {
|
|
165
165
|
title: string
|
|
166
166
|
group: GroupSlug
|
|
167
|
+
/** Tags this component carries. Each must be one of the config's declared `tags`. Drives the sidebar's tag filter. */
|
|
168
|
+
tags?: Tag[]
|
|
167
169
|
status?: WindoStatus
|
|
168
170
|
description?: string
|
|
169
171
|
deprecation?: string
|
|
@@ -189,10 +191,12 @@ export interface WindoDefinition<Props = unknown, State = unknown, GroupSlug ext
|
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
/** Argument handed to the `windo(w => ...)` factory. */
|
|
192
|
-
export interface WindoFactoryArg<Groups extends readonly WindoGroup[], Contexts extends WindoContextMap> {
|
|
194
|
+
export interface WindoFactoryArg<Groups extends readonly WindoGroup[], Contexts extends WindoContextMap, Tags extends readonly string[] = readonly string[]> {
|
|
193
195
|
/** Configured groups keyed by slug. */
|
|
194
196
|
groups: Record<Groups[number]['slug'], WindoGroup>
|
|
195
197
|
contexts: Contexts
|
|
198
|
+
/** The config's declared tags, in declaration order. */
|
|
199
|
+
tags: Tags
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
/**
|
|
@@ -209,11 +213,13 @@ export interface WindoModule<Props = any, State = any> {
|
|
|
209
213
|
* Config
|
|
210
214
|
* ------------------------------------------------------------------ */
|
|
211
215
|
|
|
212
|
-
export interface WindoConfig<Groups extends readonly WindoGroup[] = readonly WindoGroup[], Contexts extends WindoContextMap = WindoContextMap> {
|
|
216
|
+
export interface WindoConfig<Groups extends readonly WindoGroup[] = readonly WindoGroup[], Contexts extends WindoContextMap = WindoContextMap, Tags extends readonly string[] = readonly string[]> {
|
|
213
217
|
/** Configured groups. A component's `group` must be one of these slugs. */
|
|
214
218
|
groups: Groups
|
|
215
219
|
/** Named contexts available to components. */
|
|
216
220
|
contexts?: Contexts
|
|
221
|
+
/** The set of tags components may be assigned. A component's `tags` must be drawn from this list; the sidebar filters by them. */
|
|
222
|
+
tags?: Tags
|
|
217
223
|
/** Glob(s) for discovery, relative to project root. Default `**\/*.windo.tsx`. */
|
|
218
224
|
include?: string | string[]
|
|
219
225
|
/** Title shown in the workbench chrome. */
|
|
@@ -256,6 +262,7 @@ export interface WindoManifestEntry {
|
|
|
256
262
|
id: string
|
|
257
263
|
title: string
|
|
258
264
|
group: string
|
|
265
|
+
tags: string[]
|
|
259
266
|
status: WindoStatus
|
|
260
267
|
description?: string
|
|
261
268
|
deprecation?: string
|