lucent-ui 0.29.0 → 0.31.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.
- package/dist/index.cjs +102 -45
- package/dist/index.d.ts +143 -0
- package/dist/index.js +2578 -1885
- package/dist-cli/cli/entry.js +0 -0
- package/dist-cli/cli/index.js +0 -0
- package/dist-server/server/index.js +29 -29
- package/dist-server/server/pattern-registry.js +18 -0
- package/dist-server/server/registry.js +8 -0
- package/dist-server/src/components/molecules/Card/Card.manifest.js +2 -2
- package/dist-server/src/components/molecules/Collapsible/Collapsible.manifest.js +4 -4
- package/dist-server/src/components/molecules/FilterDateRange/FilterDateRange.manifest.js +46 -0
- package/dist-server/src/components/molecules/FilterMultiSelect/FilterMultiSelect.manifest.js +66 -0
- package/dist-server/src/components/molecules/FilterSearch/FilterSearch.manifest.js +37 -0
- package/dist-server/src/components/molecules/FilterSelect/FilterSelect.manifest.js +58 -0
- package/dist-server/src/components/molecules/Stepper/Stepper.manifest.js +115 -0
- package/dist-server/src/components/molecules/Timeline/Timeline.manifest.js +23 -9
- package/dist-server/src/manifest/{recipes/action-bar.recipe.js → patterns/action-bar.pattern.js} +1 -1
- package/dist-server/src/manifest/{recipes/collapsible-card.recipe.js → patterns/collapsible-card.pattern.js} +1 -1
- package/dist-server/src/manifest/patterns/dashboard-header.pattern.js +98 -0
- package/dist-server/src/manifest/{recipes/empty-state-card.recipe.js → patterns/empty-state-card.pattern.js} +1 -1
- package/dist-server/src/manifest/{recipes/form-layout.recipe.js → patterns/form-layout.pattern.js} +1 -1
- package/dist-server/src/manifest/patterns/index.js +12 -0
- package/dist-server/src/manifest/patterns/notification-feed.pattern.js +91 -0
- package/dist-server/src/manifest/patterns/onboarding-flow.pattern.js +107 -0
- package/dist-server/src/manifest/patterns/pricing-table.pattern.js +108 -0
- package/dist-server/src/manifest/{recipes/profile-card.recipe.js → patterns/profile-card.pattern.js} +1 -1
- package/dist-server/src/manifest/patterns/search-filter-bar.pattern.js +122 -0
- package/dist-server/src/manifest/{recipes/settings-panel.recipe.js → patterns/settings-panel.pattern.js} +1 -1
- package/dist-server/src/manifest/{recipes/stats-row.recipe.js → patterns/stats-row.pattern.js} +1 -1
- package/package.json +13 -15
- package/dist-server/server/recipe-registry.js +0 -18
- package/dist-server/src/manifest/recipes/index.js +0 -8
- package/dist-server/src/manifest/recipes/search-filter-bar.recipe.js +0 -197
- package/dist-server/src/manifest/validate.test.js +0 -28
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const PATTERN = {
|
|
2
|
+
id: 'search-filter-bar',
|
|
3
|
+
name: 'Search / Filter Bar',
|
|
4
|
+
description: 'Compact toolbar of filter molecules — FilterSearch, FilterSelect, FilterMultiSelect, FilterDateRange — with sort control and view toggle. Designed to sit above a DataTable or list.',
|
|
5
|
+
category: 'dashboard',
|
|
6
|
+
components: ['filter-search', 'filter-select', 'filter-multi-select', 'filter-date-range', 'segmented-control', 'row', 'text', 'button'],
|
|
7
|
+
structure: `
|
|
8
|
+
Row gap="2" align="center" wrap
|
|
9
|
+
├── FilterSearch ← icon button, expands to Input
|
|
10
|
+
├── FilterSelect (label="Availability") ← single-select dropdown
|
|
11
|
+
├── FilterMultiSelect (label="Status") ← multi-select, stays open, count badge
|
|
12
|
+
├── FilterMultiSelect (label="Tags") ← multi-select with swatch chips
|
|
13
|
+
├── FilterDateRange (label="Date range") ← button trigger → calendar popover
|
|
14
|
+
├── Text + Button (ghost, "Clear all") ← conditional result count row
|
|
15
|
+
├── flex spacer
|
|
16
|
+
└── Row gap="2" align="center"
|
|
17
|
+
├── FilterSelect (icon=sort, "Newest") ← sort control
|
|
18
|
+
└── SegmentedControl (grid/list icons) ← view toggle
|
|
19
|
+
`.trim(),
|
|
20
|
+
code: `const [search, setSearch] = useState('');
|
|
21
|
+
const [statuses, setStatuses] = useState<string[]>([]);
|
|
22
|
+
const [tags, setTags] = useState<string[]>([]);
|
|
23
|
+
const [dateRange, setDateRange] = useState<DateRange | undefined>();
|
|
24
|
+
|
|
25
|
+
const hasFilters = statuses.length > 0 || tags.length > 0 || dateRange !== undefined;
|
|
26
|
+
const clearAll = () => { setStatuses([]); setTags([]); setDateRange(undefined); setSearch(''); };
|
|
27
|
+
|
|
28
|
+
<Row gap="2" align="center" wrap>
|
|
29
|
+
<FilterSearch placeholder="Search…" value={search} onChange={setSearch} />
|
|
30
|
+
|
|
31
|
+
<FilterSelect label="Availability" options={[
|
|
32
|
+
{ value: 'available', label: 'Available' },
|
|
33
|
+
{ value: 'notice', label: 'On notice' },
|
|
34
|
+
{ value: 'unavailable', label: 'Unavailable' },
|
|
35
|
+
]} />
|
|
36
|
+
|
|
37
|
+
<FilterMultiSelect label="Status" options={[
|
|
38
|
+
{ value: 'active', label: 'Active' },
|
|
39
|
+
{ value: 'archived', label: 'Archived' },
|
|
40
|
+
{ value: 'on-hold', label: 'On hold' },
|
|
41
|
+
]} value={statuses} onChange={setStatuses} />
|
|
42
|
+
|
|
43
|
+
<FilterMultiSelect label="Tags" options={[
|
|
44
|
+
{ value: 'react', label: 'React', swatch: '#3b82f6' },
|
|
45
|
+
{ value: 'devops', label: 'DevOps', swatch: '#10b981' },
|
|
46
|
+
{ value: 'data', label: 'Data Science', swatch: '#6366f1' },
|
|
47
|
+
]} value={tags} onChange={setTags} />
|
|
48
|
+
|
|
49
|
+
<FilterDateRange label="Date range" value={dateRange} onChange={setDateRange} />
|
|
50
|
+
|
|
51
|
+
<div style={{ flex: 1 }} />
|
|
52
|
+
|
|
53
|
+
<Row gap="2" align="center">
|
|
54
|
+
<FilterSelect label="Newest first" icon={<SortIcon />} options={[
|
|
55
|
+
{ value: 'newest', label: 'Newest first' },
|
|
56
|
+
{ value: 'oldest', label: 'Oldest first' },
|
|
57
|
+
{ value: 'name', label: 'Name A–Z' },
|
|
58
|
+
]} />
|
|
59
|
+
<SegmentedControl size="sm" defaultValue="grid" options={[
|
|
60
|
+
{ value: 'grid', label: <GridIcon /> },
|
|
61
|
+
{ value: 'list', label: <ListIcon /> },
|
|
62
|
+
]} />
|
|
63
|
+
</Row>
|
|
64
|
+
</Row>
|
|
65
|
+
|
|
66
|
+
{/* Result count between filter bar and DataTable */}
|
|
67
|
+
{hasFilters && (
|
|
68
|
+
<Row gap="2" align="center">
|
|
69
|
+
<Text size="sm" color="secondary">
|
|
70
|
+
Showing {filteredRows.length} of {allRows.length} candidates
|
|
71
|
+
</Text>
|
|
72
|
+
<Button variant="ghost" size="xs" onClick={clearAll}>Clear all</Button>
|
|
73
|
+
</Row>
|
|
74
|
+
)}`,
|
|
75
|
+
variants: [
|
|
76
|
+
{
|
|
77
|
+
title: 'Minimal — search + sort only',
|
|
78
|
+
code: `<Row gap="2" align="center">
|
|
79
|
+
<FilterSearch placeholder="Search…" />
|
|
80
|
+
<div style={{ flex: 1 }} />
|
|
81
|
+
<FilterSelect label="Newest first" icon={<SortIcon />} options={sortOptions} />
|
|
82
|
+
</Row>`,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
title: 'With DataTable',
|
|
86
|
+
code: `const [search, setSearch] = useState('');
|
|
87
|
+
const [statuses, setStatuses] = useState<string[]>([]);
|
|
88
|
+
|
|
89
|
+
const filtered = candidates.filter(c => {
|
|
90
|
+
if (search && !c.name.toLowerCase().includes(search.toLowerCase())) return false;
|
|
91
|
+
if (statuses.length > 0 && !statuses.includes(c.status.toLowerCase())) return false;
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
<Stack gap="3">
|
|
96
|
+
<Row gap="2" align="center" wrap>
|
|
97
|
+
<FilterSearch placeholder="Search…" value={search} onChange={setSearch} />
|
|
98
|
+
<FilterMultiSelect label="Status" options={statusOptions}
|
|
99
|
+
value={statuses} onChange={setStatuses} />
|
|
100
|
+
</Row>
|
|
101
|
+
{statuses.length > 0 && (
|
|
102
|
+
<Row gap="2" align="center">
|
|
103
|
+
<Text size="sm" color="secondary">
|
|
104
|
+
Showing {filtered.length} of {candidates.length}
|
|
105
|
+
</Text>
|
|
106
|
+
<Button variant="ghost" size="xs" onClick={() => setStatuses([])}>Clear all</Button>
|
|
107
|
+
</Row>
|
|
108
|
+
)}
|
|
109
|
+
<DataTable columns={columns} rows={filtered} pageSize={5} />
|
|
110
|
+
</Stack>`,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
designNotes: 'Four dedicated filter molecules replace the previous boilerplate of manually wiring Menu open state, ' +
|
|
114
|
+
'keepOpen hacks, count badges, and clear footers. FilterSearch collapses to a square icon-only button ' +
|
|
115
|
+
'and expands on click. FilterSelect wraps Menu for single-select with a "Clear" footer. ' +
|
|
116
|
+
'FilterMultiSelect builds its own portal dropdown (not Menu) so items toggle without closing — ' +
|
|
117
|
+
'it renders Checkbox + optional Chip (with swatch) per option and an always-visible "Clear all" footer. ' +
|
|
118
|
+
'FilterDateRange wraps DateRangePicker with the trigger prop to render a secondary Button. ' +
|
|
119
|
+
'A flex spacer pushes sort and view controls to the right edge. ' +
|
|
120
|
+
'Between the filter bar and DataTable, a conditional result count row shows ' +
|
|
121
|
+
'"Showing X of Y" with a ghost "Clear all" button when filters are active.',
|
|
122
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lucent-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "An AI-first React component library with machine-readable manifests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -30,18 +30,6 @@
|
|
|
30
30
|
"dist-server",
|
|
31
31
|
"dist-cli"
|
|
32
32
|
],
|
|
33
|
-
"scripts": {
|
|
34
|
-
"dev": "vite --config vite.dev.config.ts",
|
|
35
|
-
"build": "vite build",
|
|
36
|
-
"build:server": "tsc -p server/tsconfig.json",
|
|
37
|
-
"build:cli": "tsc -p cli/tsconfig.json && cp cli/template.manifest.json dist-cli/cli/template.manifest.json",
|
|
38
|
-
"test": "vitest run",
|
|
39
|
-
"test:watch": "vitest",
|
|
40
|
-
"prepublishOnly": "tsc --noEmit && pnpm build && pnpm build:server && pnpm build:cli",
|
|
41
|
-
"changeset": "changeset",
|
|
42
|
-
"version-packages": "changeset version",
|
|
43
|
-
"release": "pnpm prepublishOnly && changeset publish"
|
|
44
|
-
},
|
|
45
33
|
"keywords": [
|
|
46
34
|
"react",
|
|
47
35
|
"component-library",
|
|
@@ -56,7 +44,6 @@
|
|
|
56
44
|
},
|
|
57
45
|
"author": "Rozina Szogyenyi",
|
|
58
46
|
"license": "MIT",
|
|
59
|
-
"packageManager": "pnpm@10.30.3",
|
|
60
47
|
"peerDependencies": {
|
|
61
48
|
"react": "^18.0.0 || ^19.0.0",
|
|
62
49
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
@@ -78,5 +65,16 @@
|
|
|
78
65
|
"dependencies": {
|
|
79
66
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
80
67
|
"zod": "^4.3.6"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"dev": "vite --config vite.dev.config.ts",
|
|
71
|
+
"build": "vite build",
|
|
72
|
+
"build:server": "tsc -p server/tsconfig.json",
|
|
73
|
+
"build:cli": "tsc -p cli/tsconfig.json && cp cli/template.manifest.json dist-cli/cli/template.manifest.json",
|
|
74
|
+
"test": "vitest run",
|
|
75
|
+
"test:watch": "vitest",
|
|
76
|
+
"changeset": "changeset",
|
|
77
|
+
"version-packages": "changeset version",
|
|
78
|
+
"release": "pnpm prepublishOnly && changeset publish"
|
|
81
79
|
}
|
|
82
|
-
}
|
|
80
|
+
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { RECIPE as ProfileCard } from '../src/manifest/recipes/profile-card.recipe.js';
|
|
2
|
-
import { RECIPE as SettingsPanel } from '../src/manifest/recipes/settings-panel.recipe.js';
|
|
3
|
-
import { RECIPE as StatsRow } from '../src/manifest/recipes/stats-row.recipe.js';
|
|
4
|
-
import { RECIPE as ActionBar } from '../src/manifest/recipes/action-bar.recipe.js';
|
|
5
|
-
import { RECIPE as FormLayout } from '../src/manifest/recipes/form-layout.recipe.js';
|
|
6
|
-
import { RECIPE as EmptyStateCard } from '../src/manifest/recipes/empty-state-card.recipe.js';
|
|
7
|
-
import { RECIPE as CollapsibleCard } from '../src/manifest/recipes/collapsible-card.recipe.js';
|
|
8
|
-
import { RECIPE as SearchFilterBar } from '../src/manifest/recipes/search-filter-bar.recipe.js';
|
|
9
|
-
export const ALL_RECIPES = [
|
|
10
|
-
ProfileCard,
|
|
11
|
-
SettingsPanel,
|
|
12
|
-
StatsRow,
|
|
13
|
-
ActionBar,
|
|
14
|
-
FormLayout,
|
|
15
|
-
EmptyStateCard,
|
|
16
|
-
CollapsibleCard,
|
|
17
|
-
SearchFilterBar,
|
|
18
|
-
];
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export { RECIPE as ProfileCard } from './profile-card.recipe.js';
|
|
2
|
-
export { RECIPE as SettingsPanel } from './settings-panel.recipe.js';
|
|
3
|
-
export { RECIPE as StatsRow } from './stats-row.recipe.js';
|
|
4
|
-
export { RECIPE as ActionBar } from './action-bar.recipe.js';
|
|
5
|
-
export { RECIPE as FormLayout } from './form-layout.recipe.js';
|
|
6
|
-
export { RECIPE as EmptyStateCard } from './empty-state-card.recipe.js';
|
|
7
|
-
export { RECIPE as CollapsibleCard } from './collapsible-card.recipe.js';
|
|
8
|
-
export { RECIPE as SearchFilterBar } from './search-filter-bar.recipe.js';
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
export const RECIPE = {
|
|
2
|
-
id: 'search-filter-bar',
|
|
3
|
-
name: 'Search / Filter Bar',
|
|
4
|
-
description: 'Compact toolbar of secondary filter buttons with Menu popovers, collapsible search, multi-select tags, date range picker, sort control, and view toggle. Designed to sit above a DataTable or list.',
|
|
5
|
-
category: 'dashboard',
|
|
6
|
-
components: ['button', 'menu', 'chip', 'checkbox', 'date-range-picker', 'segmented-control', 'input', 'row'],
|
|
7
|
-
structure: `
|
|
8
|
-
Row gap="2" align="center" wrap
|
|
9
|
-
├── Button (secondary, icon-only, search) ← expands to Input on click
|
|
10
|
-
├── Menu ← single-select filter
|
|
11
|
-
│ ├── trigger: Button (secondary, chevron, "Availability")
|
|
12
|
-
│ └── MenuItem[] (selected marks active)
|
|
13
|
-
├── Menu ← multi-select status filter
|
|
14
|
-
│ ├── trigger: Button (secondary, chevron, "Status" + Chip count)
|
|
15
|
-
│ └── MenuItem[] (controlled open, stays open on toggle)
|
|
16
|
-
├── Menu ← multi-select tags with chips
|
|
17
|
-
│ ├── trigger: Button (secondary, chevron, "Tags" + Chip count)
|
|
18
|
-
│ └── MenuItem[] > Row > Checkbox + Chip (swatch)
|
|
19
|
-
├── DateRangePicker ← trigger prop renders Button
|
|
20
|
-
│ └── trigger: Button (secondary, chevron, "Date range")
|
|
21
|
-
├── Button (ghost, "Clear all") ← conditional, shown when filters active
|
|
22
|
-
├── flex spacer ← pushes sort/view to right
|
|
23
|
-
└── Row gap="2" align="center" ← sort + view toggle
|
|
24
|
-
├── Menu
|
|
25
|
-
│ ├── trigger: Button (secondary, chevron, leftIcon=sort, "Newest first")
|
|
26
|
-
│ └── MenuItem[] (selected marks active sort)
|
|
27
|
-
└── SegmentedControl (sm, grid/list icons)
|
|
28
|
-
`.trim(),
|
|
29
|
-
code: `const [searchOpen, setSearchOpen] = useState(false);
|
|
30
|
-
const [statuses, setStatuses] = useState<Set<string>>(new Set());
|
|
31
|
-
const [statusOpen, setStatusOpen] = useState(false);
|
|
32
|
-
const [tags, setTags] = useState<Set<string>>(new Set());
|
|
33
|
-
const [tagsOpen, setTagsOpen] = useState(false);
|
|
34
|
-
const [dateRange, setDateRange] = useState<DateRange | undefined>();
|
|
35
|
-
|
|
36
|
-
const toggleStatus = (s: string) => setStatuses(prev => {
|
|
37
|
-
if (s === 'all') return new Set();
|
|
38
|
-
const next = new Set(prev);
|
|
39
|
-
if (next.has(s)) next.delete(s); else next.add(s);
|
|
40
|
-
return next;
|
|
41
|
-
});
|
|
42
|
-
const toggleTag = (t: string) => setTags(prev => {
|
|
43
|
-
const next = new Set(prev);
|
|
44
|
-
if (next.has(t)) next.delete(t); else next.add(t);
|
|
45
|
-
return next;
|
|
46
|
-
});
|
|
47
|
-
const hasFilters = statuses.size > 0 || tags.size > 0 || dateRange !== undefined;
|
|
48
|
-
const clearAll = () => { setStatuses(new Set()); setTags(new Set()); setDateRange(undefined); };
|
|
49
|
-
|
|
50
|
-
<Row gap="2" align="center" wrap>
|
|
51
|
-
{/* Collapsible search */}
|
|
52
|
-
{searchOpen ? (
|
|
53
|
-
<Input placeholder="Search…" size="sm" style={{ width: 260 }} autoFocus
|
|
54
|
-
onBlur={(e) => { if (!e.target.value) setSearchOpen(false); }} />
|
|
55
|
-
) : (
|
|
56
|
-
<Button variant="secondary" size="sm" aria-label="Search"
|
|
57
|
-
leftIcon={<SearchIcon />} />
|
|
58
|
-
)}
|
|
59
|
-
|
|
60
|
-
{/* Single-select filter */}
|
|
61
|
-
<Menu trigger={<Button variant="secondary" size="sm" chevron>Availability</Button>} size="sm">
|
|
62
|
-
<MenuItem selected onSelect={() => {}}>All</MenuItem>
|
|
63
|
-
<MenuItem onSelect={() => {}}>Available</MenuItem>
|
|
64
|
-
<MenuItem onSelect={() => {}}>On notice</MenuItem>
|
|
65
|
-
<MenuItem onSelect={() => {}}>Unavailable</MenuItem>
|
|
66
|
-
</Menu>
|
|
67
|
-
|
|
68
|
-
{/* Multi-select status */}
|
|
69
|
-
<Menu trigger={
|
|
70
|
-
<Button variant="secondary" size="sm" chevron>
|
|
71
|
-
Status{statuses.size > 0 && <Chip variant="accent" size="sm">{statuses.size}</Chip>}
|
|
72
|
-
</Button>
|
|
73
|
-
} size="sm" open={statusOpen} onOpenChange={setStatusOpen}>
|
|
74
|
-
<MenuItem selected={statuses.size === 0}
|
|
75
|
-
onSelect={() => { toggleStatus('all'); setStatusOpen(true); }}>All</MenuItem>
|
|
76
|
-
<MenuSeparator />
|
|
77
|
-
<MenuItem selected={statuses.has('active')}
|
|
78
|
-
onSelect={() => { toggleStatus('active'); setStatusOpen(true); }}>Active</MenuItem>
|
|
79
|
-
<MenuItem selected={statuses.has('archived')}
|
|
80
|
-
onSelect={() => { toggleStatus('archived'); setStatusOpen(true); }}>Archived</MenuItem>
|
|
81
|
-
<MenuItem selected={statuses.has('on-hold')}
|
|
82
|
-
onSelect={() => { toggleStatus('on-hold'); setStatusOpen(true); }}>On hold</MenuItem>
|
|
83
|
-
</Menu>
|
|
84
|
-
|
|
85
|
-
{/* Multi-select tags with checkbox + chip */}
|
|
86
|
-
<Menu trigger={
|
|
87
|
-
<Button variant="secondary" size="sm" chevron>
|
|
88
|
-
Tags{tags.size > 0 && <Chip variant="accent" size="sm">{tags.size}</Chip>}
|
|
89
|
-
</Button>
|
|
90
|
-
} size="sm" open={tagsOpen} onOpenChange={setTagsOpen}>
|
|
91
|
-
<MenuItem onSelect={() => { toggleTag('data-science'); setTagsOpen(true); }}>
|
|
92
|
-
<Row gap="2" align="center">
|
|
93
|
-
<Checkbox checked={tags.has('data-science')} onChange={() => {}} />
|
|
94
|
-
<Chip size="sm" swatch="#6366f1">Data Science</Chip>
|
|
95
|
-
</Row>
|
|
96
|
-
</MenuItem>
|
|
97
|
-
<MenuItem onSelect={() => { toggleTag('devops'); setTagsOpen(true); }}>
|
|
98
|
-
<Row gap="2" align="center">
|
|
99
|
-
<Checkbox checked={tags.has('devops')} onChange={() => {}} />
|
|
100
|
-
<Chip size="sm" swatch="#10b981">DevOps</Chip>
|
|
101
|
-
</Row>
|
|
102
|
-
</MenuItem>
|
|
103
|
-
</Menu>
|
|
104
|
-
|
|
105
|
-
{/* Date range with button trigger */}
|
|
106
|
-
<DateRangePicker placeholder="Date range" size="sm"
|
|
107
|
-
value={dateRange} onChange={setDateRange}
|
|
108
|
-
trigger={<Button variant="secondary" size="sm" chevron>
|
|
109
|
-
{dateRange ? formatRange(dateRange) : 'Date range'}
|
|
110
|
-
</Button>} />
|
|
111
|
-
|
|
112
|
-
{/* Clear all (conditional) */}
|
|
113
|
-
{hasFilters && <Button variant="ghost" size="sm" onClick={clearAll}>Clear all</Button>}
|
|
114
|
-
|
|
115
|
-
{/* Spacer */}
|
|
116
|
-
<div style={{ flex: 1 }} />
|
|
117
|
-
|
|
118
|
-
{/* Sort + view toggle */}
|
|
119
|
-
<Row gap="2" align="center">
|
|
120
|
-
<Menu trigger={
|
|
121
|
-
<Button variant="secondary" size="sm" chevron leftIcon={<SortIcon />}>
|
|
122
|
-
Newest first
|
|
123
|
-
</Button>
|
|
124
|
-
} size="sm">
|
|
125
|
-
<MenuItem selected onSelect={() => {}}>Newest first</MenuItem>
|
|
126
|
-
<MenuItem onSelect={() => {}}>Oldest first</MenuItem>
|
|
127
|
-
<MenuItem onSelect={() => {}}>Name A–Z</MenuItem>
|
|
128
|
-
</Menu>
|
|
129
|
-
<SegmentedControl size="sm" defaultValue="grid" options={[
|
|
130
|
-
{ value: 'grid', label: <GridIcon /> },
|
|
131
|
-
{ value: 'list', label: <ListIcon /> },
|
|
132
|
-
]} />
|
|
133
|
-
</Row>
|
|
134
|
-
</Row>`,
|
|
135
|
-
variants: [
|
|
136
|
-
{
|
|
137
|
-
title: 'Minimal — search + sort only',
|
|
138
|
-
code: `<Row gap="2" align="center">
|
|
139
|
-
<Button variant="secondary" size="sm" aria-label="Search" leftIcon={<SearchIcon />} />
|
|
140
|
-
<div style={{ flex: 1 }} />
|
|
141
|
-
<Menu trigger={
|
|
142
|
-
<Button variant="secondary" size="sm" chevron leftIcon={<SortIcon />}>
|
|
143
|
-
Newest first
|
|
144
|
-
</Button>
|
|
145
|
-
} size="sm">
|
|
146
|
-
<MenuItem selected onSelect={() => {}}>Newest first</MenuItem>
|
|
147
|
-
<MenuItem onSelect={() => {}}>Oldest first</MenuItem>
|
|
148
|
-
<MenuItem onSelect={() => {}}>Name A–Z</MenuItem>
|
|
149
|
-
</Menu>
|
|
150
|
-
</Row>`,
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
title: 'Pipeline — multi-select filters only',
|
|
154
|
-
code: `<Row gap="2" align="center" wrap>
|
|
155
|
-
<Menu trigger={
|
|
156
|
-
<Button variant="secondary" size="sm" chevron>
|
|
157
|
-
Opportunity{selectedOpp.size > 0 && <Chip variant="accent" size="sm">{selectedOpp.size}</Chip>}
|
|
158
|
-
</Button>
|
|
159
|
-
} size="sm" open={oppOpen} onOpenChange={setOppOpen}>
|
|
160
|
-
<MenuItem selected={selectedOpp.size === 0}
|
|
161
|
-
onSelect={() => { clearOpp(); setOppOpen(true); }}>All</MenuItem>
|
|
162
|
-
<MenuSeparator />
|
|
163
|
-
{opportunities.map(o => (
|
|
164
|
-
<MenuItem key={o} selected={selectedOpp.has(o)}
|
|
165
|
-
onSelect={() => { toggleOpp(o); setOppOpen(true); }}>{o}</MenuItem>
|
|
166
|
-
))}
|
|
167
|
-
</Menu>
|
|
168
|
-
<Menu trigger={
|
|
169
|
-
<Button variant="secondary" size="sm" chevron>
|
|
170
|
-
Stage{selectedStage.size > 0 && <Chip variant="accent" size="sm">{selectedStage.size}</Chip>}
|
|
171
|
-
</Button>
|
|
172
|
-
} size="sm" open={stageOpen} onOpenChange={setStageOpen}>
|
|
173
|
-
<MenuItem selected={selectedStage.size === 0}
|
|
174
|
-
onSelect={() => { clearStage(); setStageOpen(true); }}>All</MenuItem>
|
|
175
|
-
<MenuSeparator />
|
|
176
|
-
{stages.map(s => (
|
|
177
|
-
<MenuItem key={s} selected={selectedStage.has(s)}
|
|
178
|
-
onSelect={() => { toggleStage(s); setStageOpen(true); }}>{s}</MenuItem>
|
|
179
|
-
))}
|
|
180
|
-
</Menu>
|
|
181
|
-
{hasFilters && <Button variant="ghost" size="sm" onClick={clearAll}>Clear all</Button>}
|
|
182
|
-
</Row>`,
|
|
183
|
-
},
|
|
184
|
-
],
|
|
185
|
-
designNotes: 'All filters use secondary buttons with chevron to create a uniform, compact toolbar. ' +
|
|
186
|
-
'No visible input fields or select dropdowns — everything opens as a popover from the button trigger. ' +
|
|
187
|
-
'Search starts as a square icon-only button (leftIcon with no children) and expands to an Input on click, ' +
|
|
188
|
-
'collapsing back when blurred empty. Multi-select menus use controlled open state (open + onOpenChange) ' +
|
|
189
|
-
'and re-set open=true in onSelect handlers so the menu stays open while toggling items. ' +
|
|
190
|
-
'Active filter count is shown as an accent Chip inside the button label, not parenthesized text. ' +
|
|
191
|
-
'Tags menu items combine Checkbox + Chip (with swatch colors) in a Row for rich multi-select visuals. ' +
|
|
192
|
-
'DateRangePicker uses the trigger prop to render a Button instead of its default input-style trigger. ' +
|
|
193
|
-
'A ghost "Clear all" button appears conditionally when any filter is active. ' +
|
|
194
|
-
'A flex spacer pushes sort and view controls to the right edge. ' +
|
|
195
|
-
'Sort button uses leftIcon for the sort icon. ' +
|
|
196
|
-
'SegmentedControl with icon-only options (grid/list SVGs via label: ReactNode) provides view toggling.',
|
|
197
|
-
};
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from 'vitest';
|
|
2
|
-
import { validateManifest } from './validate.js';
|
|
3
|
-
// Auto-discover all component manifests
|
|
4
|
-
const manifestModules = import.meta.glob('../components/**/*.manifest.ts', { eager: true });
|
|
5
|
-
const manifests = Object.entries(manifestModules).map(([path, mod]) => {
|
|
6
|
-
const m = mod;
|
|
7
|
-
const manifest = m['COMPONENT_MANIFEST'];
|
|
8
|
-
return { path, manifest };
|
|
9
|
-
});
|
|
10
|
-
describe('Component manifests', () => {
|
|
11
|
-
test('at least one manifest was discovered', () => {
|
|
12
|
-
expect(manifests.length).toBeGreaterThan(0);
|
|
13
|
-
});
|
|
14
|
-
for (const { path, manifest } of manifests) {
|
|
15
|
-
const label = path.replace('../components/', '').replace('.manifest.ts', '');
|
|
16
|
-
test(`${label} — exports COMPONENT_MANIFEST`, () => {
|
|
17
|
-
expect(manifest).toBeDefined();
|
|
18
|
-
});
|
|
19
|
-
test(`${label} — passes schema validation`, () => {
|
|
20
|
-
const result = validateManifest(manifest);
|
|
21
|
-
if (!result.valid) {
|
|
22
|
-
const messages = result.errors.map(e => ` ${e.field}: ${e.message}`).join('\n');
|
|
23
|
-
throw new Error(`Invalid manifest:\n${messages}`);
|
|
24
|
-
}
|
|
25
|
-
expect(result.valid).toBe(true);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
});
|