@ifc-lite/viewer 1.6.0 → 1.7.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,486 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * ListBuilder - Configure a list by selecting entity types, columns, and conditions
7
+ */
8
+
9
+ import React, { useCallback, useMemo, useState } from 'react';
10
+ import {
11
+ Play,
12
+ Plus,
13
+ Trash2,
14
+ ChevronDown,
15
+ ChevronRight,
16
+ ChevronUp,
17
+ Save,
18
+ } from 'lucide-react';
19
+ import { Button } from '@/components/ui/button';
20
+ import { Input } from '@/components/ui/input';
21
+ import { ScrollArea } from '@/components/ui/scroll-area';
22
+ import { Separator } from '@/components/ui/separator';
23
+ import { Badge } from '@/components/ui/badge';
24
+ import { IfcTypeEnum } from '@ifc-lite/data';
25
+ import type {
26
+ ListDataProvider,
27
+ ListDefinition,
28
+ ColumnDefinition,
29
+ DiscoveredColumns,
30
+ PropertyCondition,
31
+ } from '@ifc-lite/lists';
32
+ import { discoverColumns } from '@ifc-lite/lists';
33
+
34
+ // Building element types available for selection
35
+ const SELECTABLE_TYPES: { type: IfcTypeEnum; label: string }[] = [
36
+ { type: IfcTypeEnum.IfcWall, label: 'Walls' },
37
+ { type: IfcTypeEnum.IfcWallStandardCase, label: 'Walls (Standard)' },
38
+ { type: IfcTypeEnum.IfcDoor, label: 'Doors' },
39
+ { type: IfcTypeEnum.IfcWindow, label: 'Windows' },
40
+ { type: IfcTypeEnum.IfcSlab, label: 'Slabs' },
41
+ { type: IfcTypeEnum.IfcColumn, label: 'Columns' },
42
+ { type: IfcTypeEnum.IfcBeam, label: 'Beams' },
43
+ { type: IfcTypeEnum.IfcStair, label: 'Stairs' },
44
+ { type: IfcTypeEnum.IfcRamp, label: 'Ramps' },
45
+ { type: IfcTypeEnum.IfcRoof, label: 'Roofs' },
46
+ { type: IfcTypeEnum.IfcCovering, label: 'Coverings' },
47
+ { type: IfcTypeEnum.IfcCurtainWall, label: 'Curtain Walls' },
48
+ { type: IfcTypeEnum.IfcRailing, label: 'Railings' },
49
+ { type: IfcTypeEnum.IfcSpace, label: 'Spaces' },
50
+ { type: IfcTypeEnum.IfcBuildingStorey, label: 'Storeys' },
51
+ { type: IfcTypeEnum.IfcDistributionElement, label: 'MEP Distribution' },
52
+ { type: IfcTypeEnum.IfcFlowTerminal, label: 'MEP Terminals' },
53
+ { type: IfcTypeEnum.IfcFlowSegment, label: 'MEP Segments' },
54
+ { type: IfcTypeEnum.IfcFlowFitting, label: 'MEP Fittings' },
55
+ ];
56
+
57
+ interface ListBuilderProps {
58
+ providers: ListDataProvider[];
59
+ initial: ListDefinition | null;
60
+ onSave: (definition: ListDefinition) => void;
61
+ onCancel: () => void;
62
+ onExecute: (definition: ListDefinition) => void;
63
+ }
64
+
65
+ export function ListBuilder({ providers, initial, onSave, onCancel, onExecute }: ListBuilderProps) {
66
+ const [name, setName] = useState(initial?.name ?? '');
67
+ const [description, setDescription] = useState(initial?.description ?? '');
68
+ const [selectedTypes, setSelectedTypes] = useState<Set<IfcTypeEnum>>(
69
+ new Set(initial?.entityTypes ?? [])
70
+ );
71
+ const [columns, setColumns] = useState<ColumnDefinition[]>(initial?.columns ?? []);
72
+ const [conditions, setConditions] = useState<PropertyCondition[]>(initial?.conditions ?? []);
73
+ const [columnsExpanded, setColumnsExpanded] = useState(true);
74
+
75
+ // Count entities per type across all providers
76
+ const typeCounts = useMemo(() => {
77
+ const counts = new Map<IfcTypeEnum, number>();
78
+ for (const { type } of SELECTABLE_TYPES) {
79
+ let total = 0;
80
+ for (const p of providers) {
81
+ total += p.getEntitiesByType(type).length;
82
+ }
83
+ if (total > 0) counts.set(type, total);
84
+ }
85
+ return counts;
86
+ }, [providers]);
87
+
88
+ // Discover available columns whenever selected types change (across all providers)
89
+ const discovered = useMemo<DiscoveredColumns | null>(() => {
90
+ if (selectedTypes.size === 0) return null;
91
+ return discoverColumns(providers, Array.from(selectedTypes));
92
+ }, [providers, selectedTypes]);
93
+
94
+ const toggleType = useCallback((type: IfcTypeEnum) => {
95
+ setSelectedTypes(prev => {
96
+ const next = new Set(prev);
97
+ if (next.has(type)) {
98
+ next.delete(type);
99
+ } else {
100
+ next.add(type);
101
+ }
102
+ return next;
103
+ });
104
+ }, []);
105
+
106
+ const addColumn = useCallback((col: ColumnDefinition) => {
107
+ setColumns(prev => {
108
+ if (prev.some(c => c.id === col.id)) return prev;
109
+ return [...prev, col];
110
+ });
111
+ }, []);
112
+
113
+ const removeColumn = useCallback((id: string) => {
114
+ setColumns(prev => prev.filter(c => c.id !== id));
115
+ }, []);
116
+
117
+ const moveColumn = useCallback((idx: number, direction: -1 | 1) => {
118
+ setColumns(prev => {
119
+ const target = idx + direction;
120
+ if (target < 0 || target >= prev.length) return prev;
121
+ const next = [...prev];
122
+ const tmp = next[idx];
123
+ next[idx] = next[target];
124
+ next[target] = tmp;
125
+ return next;
126
+ });
127
+ }, []);
128
+
129
+ const buildDefinition = useCallback((): ListDefinition => {
130
+ return {
131
+ id: initial?.id ?? crypto.randomUUID(),
132
+ name: name || 'Untitled List',
133
+ description: description || undefined,
134
+ createdAt: initial?.createdAt ?? Date.now(),
135
+ updatedAt: Date.now(),
136
+ entityTypes: Array.from(selectedTypes),
137
+ conditions,
138
+ columns,
139
+ };
140
+ }, [initial, name, description, selectedTypes, conditions, columns]);
141
+
142
+ const handleSave = useCallback(() => {
143
+ onSave(buildDefinition());
144
+ }, [buildDefinition, onSave]);
145
+
146
+ const handleRun = useCallback(() => {
147
+ const def = buildDefinition();
148
+ onExecute(def);
149
+ }, [buildDefinition, onExecute]);
150
+
151
+ const selectedColumnIds = useMemo(() => new Set(columns.map(c => c.id)), [columns]);
152
+
153
+ const totalSelectedEntities = useMemo(() => {
154
+ let count = 0;
155
+ for (const type of selectedTypes) {
156
+ count += typeCounts.get(type) ?? 0;
157
+ }
158
+ return count;
159
+ }, [selectedTypes, typeCounts]);
160
+
161
+ return (
162
+ <div className="flex-1 flex flex-col min-h-0">
163
+ <ScrollArea className="flex-1">
164
+ <div className="p-3 space-y-4">
165
+ {/* Name & Description */}
166
+ <div className="space-y-2">
167
+ <Input
168
+ placeholder="List name..."
169
+ value={name}
170
+ onChange={e => setName(e.target.value)}
171
+ className="h-8 text-sm"
172
+ />
173
+ <Input
174
+ placeholder="Description (optional)"
175
+ value={description}
176
+ onChange={e => setDescription(e.target.value)}
177
+ className="h-8 text-sm"
178
+ />
179
+ </div>
180
+
181
+ <Separator />
182
+
183
+ {/* Entity Type Selection */}
184
+ <div>
185
+ <div className="flex items-center justify-between mb-2">
186
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
187
+ Entity Types
188
+ </span>
189
+ {selectedTypes.size > 0 && (
190
+ <Badge variant="secondary" className="text-xs h-5">
191
+ {totalSelectedEntities} entities
192
+ </Badge>
193
+ )}
194
+ </div>
195
+ <div className="flex flex-wrap gap-1">
196
+ {SELECTABLE_TYPES.map(({ type, label }) => {
197
+ const count = typeCounts.get(type);
198
+ if (!count) return null; // Don't show types not in model
199
+ const selected = selectedTypes.has(type);
200
+ return (
201
+ <button
202
+ key={type}
203
+ onClick={() => toggleType(type)}
204
+ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors ${
205
+ selected
206
+ ? 'bg-primary text-primary-foreground border-primary'
207
+ : 'bg-background border-border hover:bg-muted'
208
+ }`}
209
+ >
210
+ {label}
211
+ <span className={selected ? 'opacity-75' : 'text-muted-foreground'}>
212
+ {count}
213
+ </span>
214
+ </button>
215
+ );
216
+ })}
217
+ </div>
218
+ </div>
219
+
220
+ {selectedTypes.size > 0 && discovered && (
221
+ <>
222
+ <Separator />
223
+
224
+ {/* Column Selection */}
225
+ <div>
226
+ <button
227
+ className="flex items-center gap-1 text-xs font-medium text-muted-foreground uppercase tracking-wider w-full"
228
+ onClick={() => setColumnsExpanded(!columnsExpanded)}
229
+ >
230
+ {columnsExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
231
+ Columns ({columns.length} selected)
232
+ </button>
233
+
234
+ {columnsExpanded && (
235
+ <div className="mt-2 space-y-2">
236
+ {/* Selected columns (reorderable) */}
237
+ {columns.length > 0 && (
238
+ <div className="space-y-0.5 mb-2">
239
+ {columns.map((col, idx) => (
240
+ <div
241
+ key={col.id}
242
+ className="flex items-center gap-1 px-2 py-1 rounded bg-muted/50 text-xs"
243
+ >
244
+ <span className="text-muted-foreground w-4 text-right">{idx + 1}</span>
245
+ <span className="flex-1 truncate">
246
+ {col.label ?? col.propertyName}
247
+ {col.psetName && (
248
+ <span className="text-muted-foreground ml-1">({col.psetName})</span>
249
+ )}
250
+ </span>
251
+ <button
252
+ onClick={() => moveColumn(idx, -1)}
253
+ disabled={idx === 0}
254
+ className={`${idx === 0 ? 'text-muted-foreground/30' : 'text-muted-foreground hover:text-foreground'}`}
255
+ >
256
+ <ChevronUp className="h-3 w-3" />
257
+ </button>
258
+ <button
259
+ onClick={() => moveColumn(idx, 1)}
260
+ disabled={idx === columns.length - 1}
261
+ className={`${idx === columns.length - 1 ? 'text-muted-foreground/30' : 'text-muted-foreground hover:text-foreground'}`}
262
+ >
263
+ <ChevronDown className="h-3 w-3" />
264
+ </button>
265
+ <button
266
+ onClick={() => removeColumn(col.id)}
267
+ className="text-muted-foreground hover:text-destructive"
268
+ >
269
+ <Trash2 className="h-3 w-3" />
270
+ </button>
271
+ </div>
272
+ ))}
273
+ </div>
274
+ )}
275
+
276
+ {/* Available columns */}
277
+ <ColumnPicker
278
+ discovered={discovered}
279
+ selectedIds={selectedColumnIds}
280
+ onAdd={addColumn}
281
+ />
282
+ </div>
283
+ )}
284
+ </div>
285
+ </>
286
+ )}
287
+ </div>
288
+ </ScrollArea>
289
+
290
+ {/* Bottom Actions */}
291
+ <div className="flex items-center gap-2 px-3 py-2 border-t">
292
+ <Button
293
+ variant="default"
294
+ size="sm"
295
+ onClick={handleRun}
296
+ disabled={selectedTypes.size === 0 || columns.length === 0}
297
+ className="text-xs h-7"
298
+ >
299
+ <Play className="h-3 w-3 mr-1" />
300
+ Run
301
+ </Button>
302
+ <Button
303
+ variant="outline"
304
+ size="sm"
305
+ onClick={handleSave}
306
+ disabled={selectedTypes.size === 0 || columns.length === 0}
307
+ className="text-xs h-7"
308
+ >
309
+ <Save className="h-3 w-3 mr-1" />
310
+ Save
311
+ </Button>
312
+ <div className="flex-1" />
313
+ <Button variant="ghost" size="sm" onClick={onCancel} className="text-xs h-7">
314
+ Cancel
315
+ </Button>
316
+ </div>
317
+ </div>
318
+ );
319
+ }
320
+
321
+ // ============================================================================
322
+ // Column Picker
323
+ // ============================================================================
324
+
325
+ interface ColumnPickerProps {
326
+ discovered: DiscoveredColumns;
327
+ selectedIds: Set<string>;
328
+ onAdd: (col: ColumnDefinition) => void;
329
+ }
330
+
331
+ function ColumnPicker({ discovered, selectedIds, onAdd }: ColumnPickerProps) {
332
+ const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['attributes']));
333
+
334
+ const toggleSection = (section: string) => {
335
+ setExpandedSections(prev => {
336
+ const next = new Set(prev);
337
+ if (next.has(section)) next.delete(section);
338
+ else next.add(section);
339
+ return next;
340
+ });
341
+ };
342
+
343
+ return (
344
+ <div className="space-y-1 text-xs">
345
+ {/* Attributes */}
346
+ <CollapsibleSection
347
+ title="Attributes"
348
+ expanded={expandedSections.has('attributes')}
349
+ onToggle={() => toggleSection('attributes')}
350
+ >
351
+ {discovered.attributes.map(attr => {
352
+ const id = `attr-${attr.toLowerCase()}`;
353
+ const isSelected = selectedIds.has(id);
354
+ return (
355
+ <PickerItem
356
+ key={id}
357
+ label={attr}
358
+ selected={isSelected}
359
+ onClick={() => {
360
+ if (!isSelected) {
361
+ onAdd({ id, source: 'attribute', propertyName: attr });
362
+ }
363
+ }}
364
+ />
365
+ );
366
+ })}
367
+ </CollapsibleSection>
368
+
369
+ {/* Property Sets */}
370
+ {Array.from(discovered.properties.entries())
371
+ .sort(([a], [b]) => a.localeCompare(b))
372
+ .map(([psetName, propNames]) => (
373
+ <CollapsibleSection
374
+ key={`pset-${psetName}`}
375
+ title={psetName}
376
+ badge="P"
377
+ expanded={expandedSections.has(`pset-${psetName}`)}
378
+ onToggle={() => toggleSection(`pset-${psetName}`)}
379
+ >
380
+ {propNames.map(propName => {
381
+ const id = `prop-${psetName}-${propName}`.toLowerCase().replace(/\s+/g, '-');
382
+ const isSelected = selectedIds.has(id);
383
+ return (
384
+ <PickerItem
385
+ key={id}
386
+ label={propName}
387
+ selected={isSelected}
388
+ onClick={() => {
389
+ if (!isSelected) {
390
+ onAdd({ id, source: 'property', psetName, propertyName: propName, label: propName });
391
+ }
392
+ }}
393
+ />
394
+ );
395
+ })}
396
+ </CollapsibleSection>
397
+ ))}
398
+
399
+ {/* Quantity Sets */}
400
+ {Array.from(discovered.quantities.entries())
401
+ .sort(([a], [b]) => a.localeCompare(b))
402
+ .map(([qsetName, quantNames]) => (
403
+ <CollapsibleSection
404
+ key={`qset-${qsetName}`}
405
+ title={qsetName}
406
+ badge="Q"
407
+ expanded={expandedSections.has(`qset-${qsetName}`)}
408
+ onToggle={() => toggleSection(`qset-${qsetName}`)}
409
+ >
410
+ {quantNames.map(quantName => {
411
+ const id = `quant-${qsetName}-${quantName}`.toLowerCase().replace(/\s+/g, '-');
412
+ const isSelected = selectedIds.has(id);
413
+ return (
414
+ <PickerItem
415
+ key={id}
416
+ label={quantName}
417
+ selected={isSelected}
418
+ onClick={() => {
419
+ if (!isSelected) {
420
+ onAdd({ id, source: 'quantity', psetName: qsetName, propertyName: quantName, label: quantName });
421
+ }
422
+ }}
423
+ />
424
+ );
425
+ })}
426
+ </CollapsibleSection>
427
+ ))}
428
+ </div>
429
+ );
430
+ }
431
+
432
+ function CollapsibleSection({
433
+ title,
434
+ badge,
435
+ expanded,
436
+ onToggle,
437
+ children,
438
+ }: {
439
+ title: string;
440
+ badge?: string;
441
+ expanded: boolean;
442
+ onToggle: () => void;
443
+ children: React.ReactNode;
444
+ }) {
445
+ return (
446
+ <div>
447
+ <button
448
+ className="flex items-center gap-1 w-full px-1 py-0.5 rounded hover:bg-muted/50 text-xs"
449
+ onClick={onToggle}
450
+ >
451
+ {expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
452
+ <span className="font-medium truncate">{title}</span>
453
+ {badge && (
454
+ <span className="ml-auto text-[10px] bg-muted px-1 rounded">{badge}</span>
455
+ )}
456
+ </button>
457
+ {expanded && <div className="pl-4 space-y-0">{children}</div>}
458
+ </div>
459
+ );
460
+ }
461
+
462
+ function PickerItem({
463
+ label,
464
+ selected,
465
+ onClick,
466
+ }: {
467
+ label: string;
468
+ selected: boolean;
469
+ onClick: () => void;
470
+ }) {
471
+ return (
472
+ <button
473
+ className={`flex items-center gap-1 w-full px-1 py-0.5 rounded text-xs ${
474
+ selected
475
+ ? 'text-muted-foreground cursor-default'
476
+ : 'hover:bg-muted/50 cursor-pointer'
477
+ }`}
478
+ onClick={onClick}
479
+ disabled={selected}
480
+ >
481
+ <Plus className={`h-2.5 w-2.5 ${selected ? 'invisible' : ''}`} />
482
+ <span className="truncate">{label}</span>
483
+ {selected && <span className="ml-auto text-[10px] text-muted-foreground">added</span>}
484
+ </button>
485
+ );
486
+ }