@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.
@@ -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={() => setWidth(maxWidth)} />
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={() => setWidth(maxWidth)} />
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">
@@ -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)
@@ -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,
@@ -138,13 +138,8 @@ button {
138
138
  .wb-logo-mark {
139
139
  width: 22px;
140
140
  height: 22px;
141
- border-radius: 6px;
142
- background: var(--text);
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
  }
@@ -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>>(factory: (w: WindoFactoryArg<Groups, Contexts>) => WindoDefinition<Props, State, Groups[number]['slug']>) => WindoModule<Props, State>
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>>(factory: (w: WindoFactoryArg<Groups, Contexts>) => WindoDefinition<Props, State, Groups[number]['slug']>): WindoModule<Props, State> {
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 }
@@ -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
@@ -25,6 +25,7 @@ export type WindoPreviewMessage =
25
25
  title: string
26
26
  entries: WindoManifestEntry[]
27
27
  groups: WindoGroup[]
28
+ tags: string[]
28
29
  contexts: WindoContextMeta[]
29
30
  }
30
31
  | {
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