@fgv/ts-app-shell 5.1.0-1 → 5.1.0-11

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.
@@ -33,18 +33,18 @@ import React from 'react';
33
33
  * Horizontal label + field layout for a single edit field.
34
34
  * @public
35
35
  */
36
- export function EditField({ label, children }) {
36
+ export function EditField({ label, tooltip, children }) {
37
37
  return (React.createElement("div", { className: "flex items-baseline gap-2 py-1" },
38
- React.createElement("label", { className: "text-xs text-muted w-32 shrink-0" }, label),
38
+ React.createElement("label", { className: "text-xs text-muted w-32 shrink-0", title: tooltip }, label),
39
39
  React.createElement("div", { className: "flex-1" }, children)));
40
40
  }
41
41
  /**
42
42
  * Titled section wrapper for grouping related edit fields.
43
43
  * @public
44
44
  */
45
- export function EditSection({ title, children }) {
45
+ export function EditSection({ title, tooltip, children }) {
46
46
  return (React.createElement("div", { className: "mb-4" },
47
- React.createElement("h4", { className: "text-xs font-medium text-muted uppercase tracking-wider mb-1.5" }, title),
47
+ React.createElement("h4", { className: "text-xs font-medium text-muted uppercase tracking-wider mb-1.5", title: tooltip }, title),
48
48
  children));
49
49
  }
50
50
  /**
@@ -27,31 +27,139 @@
27
27
  *
28
28
  * @packageDocumentation
29
29
  */
30
- import React, { useCallback, useState } from 'react';
30
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
31
+ import { StarIcon as StarIconOutline } from '@heroicons/react/24/outline';
32
+ import { StarIcon as StarIconSolid, ExclamationTriangleIcon, BuildingLibraryIcon, ShieldCheckIcon, ShieldExclamationIcon, ArrowDownTrayIcon, PencilSquareIcon, ArrowsPointingInIcon, TrashIcon, FolderPlusIcon, ArchiveBoxArrowDownIcon, FolderOpenIcon, ArrowUpTrayIcon, PlusIcon, ChevronRightIcon } from '@heroicons/react/20/solid';
33
+ const BADGE_BASE_CLASSES = 'inline-flex shrink-0 items-center justify-center rounded-full font-medium leading-none';
34
+ const BADGE_TONE_CLASSES = {
35
+ info: 'bg-status-info-bg text-status-info-text',
36
+ warning: 'bg-status-warning-bg text-status-warning-text',
37
+ danger: 'bg-status-error-bg text-status-error-text'
38
+ };
39
+ function renderBadge(badge) {
40
+ var _a, _b;
41
+ const tone = (_a = badge.tone) !== null && _a !== void 0 ? _a : 'info';
42
+ const ariaLabel = badge.ariaLabel;
43
+ if (badge.kind === 'dot') {
44
+ return (React.createElement("span", { className: `ml-1.5 h-2 w-2 ${BADGE_BASE_CLASSES} ${BADGE_TONE_CLASSES[tone]}`, "aria-label": ariaLabel }));
45
+ }
46
+ return (React.createElement("span", { className: `ml-1.5 min-w-4 px-1.5 py-0.5 text-[0.6875rem] ${BADGE_BASE_CLASSES} ${BADGE_TONE_CLASSES[tone]}`, "aria-label": ariaLabel }, (_b = badge.count) !== null && _b !== void 0 ? _b : 0));
47
+ }
48
+ // ============================================================================
49
+ // Context Menu (internal)
50
+ // ============================================================================
51
+ const LONG_PRESS_MS = 500;
52
+ function CollectionContextMenu(props) {
53
+ const { menu, isHidden, onHide, onShow, onClose } = props;
54
+ const ref = useRef(null);
55
+ useEffect(() => {
56
+ function handleClickOutside(e) {
57
+ if (ref.current && !ref.current.contains(e.target)) {
58
+ onClose();
59
+ }
60
+ }
61
+ document.addEventListener('mousedown', handleClickOutside);
62
+ return () => document.removeEventListener('mousedown', handleClickOutside);
63
+ }, [onClose]);
64
+ const action = isHidden ? onShow : onHide;
65
+ if (!action) {
66
+ return React.createElement(React.Fragment, null);
67
+ }
68
+ return (React.createElement("div", { ref: ref, className: "fixed z-50 bg-surface-raised border border-border rounded shadow-lg py-1 min-w-[140px]", style: { left: menu.x, top: menu.y } },
69
+ React.createElement("button", { className: "w-full text-left px-3 py-1.5 text-sm text-secondary hover:bg-hover transition-colors", onClick: () => {
70
+ action(menu.collectionId);
71
+ onClose();
72
+ } }, isHidden ? 'Show collection' : 'Hide collection')));
73
+ }
74
+ // ============================================================================
75
+ // Long-press hook (internal)
76
+ // ============================================================================
77
+ function useLongPress(onLongPress) {
78
+ const timerRef = useRef(undefined);
79
+ const firedRef = useRef(false);
80
+ const onTouchStart = useCallback((e) => {
81
+ firedRef.current = false;
82
+ timerRef.current = setTimeout(() => {
83
+ firedRef.current = true;
84
+ onLongPress(e);
85
+ }, LONG_PRESS_MS);
86
+ }, [onLongPress]);
87
+ const cancel = useCallback(() => {
88
+ if (timerRef.current !== undefined) {
89
+ clearTimeout(timerRef.current);
90
+ timerRef.current = undefined;
91
+ }
92
+ }, []);
93
+ return { onTouchStart, onTouchEnd: cancel, onTouchMove: cancel };
94
+ }
31
95
  // ============================================================================
32
96
  // CollectionRow (internal)
33
97
  // ============================================================================
34
98
  function CollectionRow(props) {
35
- var _a, _b, _c, _d;
36
- const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge } = props;
99
+ var _a;
100
+ const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge, borderColorClass, onContextMenu, isHiddenRow } = props;
37
101
  const displayName = (_a = collection.name) !== null && _a !== void 0 ? _a : collection.id;
38
- return (React.createElement("div", { className: `flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors hover:bg-hover ${collection.isVisible ? 'text-secondary' : 'text-muted'}` },
39
- React.createElement("button", { onClick: () => onToggleVisibility(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs hover:text-brand-accent transition-colors", title: collection.isVisible ? 'Hide collection' : 'Show collection', "aria-label": `${collection.isVisible ? 'Hide' : 'Show'} ${displayName}` }, collection.isVisible ? '\u{1F441}' : '\u{1F441}\u{FE0F}\u{200D}\u{1F5E8}\u{FE0F}'),
40
- onSetDefault && collection.isMutable && (React.createElement("button", { onClick: () => onSetDefault(collection.id), className: `shrink-0 w-5 h-5 flex items-center justify-center transition-colors ${collection.isDefault ? 'text-star hover:text-star' : 'text-faint hover:text-star'}`, title: collection.isDefault
41
- ? 'Default collection for new items'
42
- : 'Set as default collection for new items', "aria-label": collection.isDefault
43
- ? `${(_b = collection.name) !== null && _b !== void 0 ? _b : collection.id} is default`
44
- : `Set ${(_c = collection.name) !== null && _c !== void 0 ? _c : collection.id} as default`, "aria-pressed": collection.isDefault }, collection.isDefault ? '★' : '☆')),
45
- collection.hasConflict && (React.createElement("span", { className: "shrink-0 text-xs text-status-warning-strong cursor-default", title: "An encrypted copy of this collection from another storage root has the same ID. Go to Settings \u2192 Storage to resolve the conflict.", "aria-label": `Conflict: encrypted shadow for ${displayName}` }, '⚠')),
46
- !collection.isMutable && (React.createElement("span", { className: "shrink-0 text-xs text-muted", title: "Built-in collection (read-only)" }, '\uD83D\uDD12')),
47
- collection.isProtected &&
48
- (collection.isUnlocked || !onUnlock ? (React.createElement("span", { className: `shrink-0 text-xs ${collection.isUnlocked ? 'text-status-success-icon' : 'text-muted'}`, title: collection.isUnlocked ? 'Protected (unlocked)' : 'Protected (locked)' }, '\uD83D\uDEE1')) : (React.createElement("button", { onClick: () => onUnlock(collection.id), className: "shrink-0 text-xs text-muted hover:text-star transition-colors", title: "Click to unlock", "aria-label": `Unlock ${(_d = collection.name) !== null && _d !== void 0 ? _d : collection.id}` }, '\uD83D\uDEE1'))),
49
- React.createElement("span", { className: "flex-1 truncate", title: displayName }, displayName),
50
- React.createElement("span", { className: "shrink-0 text-xs text-muted" }, collection.itemCount),
51
- collection.isMutable && onExport && (React.createElement("button", { onClick: () => onExport(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs text-faint hover:text-brand-accent transition-colors", title: `Export ${displayName}`, "aria-label": `Export ${displayName}` }, "\u2193")),
52
- collection.isMutable && onRename && (React.createElement("button", { onClick: () => onRename(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs text-faint hover:text-brand-accent transition-colors", title: `Rename ${displayName}`, "aria-label": `Rename ${displayName}` }, '✎')),
53
- collection.isMutable && onMerge && (React.createElement("button", { onClick: () => onMerge(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs text-faint hover:text-brand-accent transition-colors", title: `Merge ${displayName} into another collection`, "aria-label": `Merge ${displayName}` }, '⤵')),
54
- collection.isMutable && onDelete && (React.createElement("button", { onClick: () => onDelete(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs text-faint hover:text-status-error-icon transition-colors", title: `Remove ${displayName}`, "aria-label": `Remove ${displayName}` }, '×'))));
102
+ const handleContextMenu = useCallback((e) => {
103
+ if (onContextMenu) {
104
+ e.preventDefault();
105
+ onContextMenu(collection.id, e.clientX, e.clientY);
106
+ }
107
+ }, [onContextMenu, collection.id]);
108
+ const handleLongPress = useCallback((e) => {
109
+ if (onContextMenu && e.touches.length > 0) {
110
+ e.preventDefault();
111
+ const touch = e.touches[0];
112
+ onContextMenu(collection.id, touch.clientX, touch.clientY);
113
+ }
114
+ }, [onContextMenu, collection.id]);
115
+ const longPress = useLongPress(handleLongPress);
116
+ return (React.createElement("div", Object.assign({ onClick: () => onToggleVisibility(collection.id), onContextMenu: handleContextMenu }, longPress, { className: `flex flex-col px-3 py-2.5 text-sm transition-colors hover:bg-hover cursor-pointer ${isHiddenRow
117
+ ? 'text-muted opacity-30 line-through'
118
+ : collection.isVisible
119
+ ? 'text-secondary'
120
+ : 'text-muted opacity-50'} ${borderColorClass ? `border-l-4 ${borderColorClass}` : ''}`, role: "button", "aria-pressed": collection.isVisible, title: collection.sourceName ? `Source: ${collection.sourceName}` : displayName }),
121
+ React.createElement("div", { className: "flex items-center gap-1.5" },
122
+ onSetDefault && collection.isMutable && (React.createElement("button", { onClick: (e) => {
123
+ e.stopPropagation();
124
+ onSetDefault(collection.id);
125
+ }, className: `shrink-0 w-5 h-5 flex items-center justify-center transition-colors ${collection.isDefault ? 'text-star hover:text-star' : 'text-faint hover:text-star'}`, title: collection.isDefault
126
+ ? 'Default collection for new items'
127
+ : 'Set as default collection for new items', "aria-label": collection.isDefault ? `${displayName} is default` : `Set ${displayName} as default`, "aria-pressed": collection.isDefault }, collection.isDefault ? (React.createElement(StarIconSolid, { className: "w-4 h-4" })) : (React.createElement(StarIconOutline, { className: "w-4 h-4" })))),
128
+ collection.hasConflict && (React.createElement("span", { className: "shrink-0 text-status-warning-strong cursor-default", title: "An encrypted copy of this collection from another storage root has the same ID. Go to Settings \u2192 Storage to resolve the conflict.", "aria-label": `Conflict: encrypted shadow for ${displayName}` },
129
+ React.createElement(ExclamationTriangleIcon, { className: "w-4 h-4" }))),
130
+ !collection.isMutable && (React.createElement("span", { className: "shrink-0 text-muted", title: "Built-in collection (read-only)" },
131
+ React.createElement(BuildingLibraryIcon, { className: "w-4 h-4" }))),
132
+ collection.isProtected &&
133
+ (collection.isUnlocked || !onUnlock ? (React.createElement("span", { className: `shrink-0 ${collection.isUnlocked ? 'text-status-success-icon' : 'text-muted'}`, title: collection.isUnlocked ? 'Protected (unlocked)' : 'Protected (locked)' }, collection.isUnlocked ? (React.createElement(ShieldCheckIcon, { className: "w-4 h-4" })) : (React.createElement(ShieldExclamationIcon, { className: "w-4 h-4" })))) : (React.createElement("button", { onClick: (e) => {
134
+ e.stopPropagation();
135
+ onUnlock(collection.id);
136
+ }, className: "shrink-0 text-muted hover:text-star transition-colors", title: "Click to unlock", "aria-label": `Unlock ${displayName}` },
137
+ React.createElement(ShieldExclamationIcon, { className: "w-4 h-4" })))),
138
+ React.createElement("span", { className: "flex-1 truncate", title: displayName }, displayName),
139
+ collection.badge && renderBadge(collection.badge),
140
+ React.createElement("span", { className: "shrink-0 text-xs text-muted" }, collection.itemCount)),
141
+ React.createElement("div", { className: "flex items-center gap-1 mt-1 ml-[24px]" },
142
+ collection.isMutable && onExport && (React.createElement("button", { onClick: (e) => {
143
+ e.stopPropagation();
144
+ onExport(collection.id);
145
+ }, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Export ${displayName}`, "aria-label": `Export ${displayName}` },
146
+ React.createElement(ArrowDownTrayIcon, { className: "w-4 h-4" }))),
147
+ collection.isMutable && onRename && (React.createElement("button", { onClick: (e) => {
148
+ e.stopPropagation();
149
+ onRename(collection.id);
150
+ }, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Rename ${displayName}`, "aria-label": `Rename ${displayName}` },
151
+ React.createElement(PencilSquareIcon, { className: "w-4 h-4" }))),
152
+ collection.isMutable && onMerge && (React.createElement("button", { onClick: (e) => {
153
+ e.stopPropagation();
154
+ onMerge(collection.id);
155
+ }, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Merge ${displayName} into another collection`, "aria-label": `Merge ${displayName}` },
156
+ React.createElement(ArrowsPointingInIcon, { className: "w-4 h-4" }))),
157
+ collection.isMutable && onDelete && (React.createElement("button", { onClick: (e) => {
158
+ e.stopPropagation();
159
+ onDelete(collection.id);
160
+ }, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-status-error-icon transition-colors", title: `Remove ${displayName}`, "aria-label": `Remove ${displayName}` },
161
+ React.createElement(TrashIcon, { className: "w-4 h-4" }))),
162
+ !collection.isMutable && React.createElement("span", { className: "text-xs text-muted" }, "(built-in)"))));
55
163
  }
56
164
  // ============================================================================
57
165
  // CollectionSection
@@ -65,43 +173,65 @@ function CollectionRow(props) {
65
173
  * @public
66
174
  */
67
175
  export function CollectionSection(props) {
68
- const { collections, onToggleVisibility, onToggleAllVisibility, onAddDirectory, onCreateCollection, onDeleteCollection, onSetDefaultCollection, onExportCollection, onExportAllAsZip, onImportCollection, onOpenCollectionFromFile, onUnlockCollection, onRenameCollection, onMergeCollection, defaultCollapsed = false } = props;
176
+ var _a;
177
+ const { collections, onToggleVisibility, onAddDirectory, onCreateCollection, onDeleteCollection, onSetDefaultCollection, onExportCollection, onExportAllAsZip, onImportCollection, onOpenCollectionFromFile, onUnlockCollection, onRenameCollection, onMergeCollection, onHideCollection, onShowCollection, defaultCollapsed = false, sourceColorMap, sourceColorFallback } = props;
69
178
  const [collapsed, setCollapsed] = useState(defaultCollapsed);
70
- const allVisible = collections.length > 0 && collections.every((c) => c.isVisible);
71
- const handleToggleAllVisibility = useCallback(() => {
72
- if (onToggleAllVisibility) {
73
- onToggleAllVisibility(!allVisible);
74
- }
75
- else {
76
- const ids = allVisible
77
- ? collections.map((c) => c.id)
78
- : collections.filter((c) => !c.isVisible).map((c) => c.id);
79
- ids.forEach((id) => onToggleVisibility(id));
80
- }
81
- }, [onToggleAllVisibility, onToggleVisibility, allVisible, collections]);
179
+ const [hiddenExpanded, setHiddenExpanded] = useState(false);
180
+ const [contextMenu, setContextMenu] = useState(undefined);
181
+ const visibleCollections = collections.filter((c) => !c.isHidden);
182
+ const hiddenCollections = collections.filter((c) => c.isHidden);
82
183
  const handleToggleCollapse = useCallback(() => {
83
184
  setCollapsed((prev) => !prev);
84
185
  }, []);
186
+ const handleToggleHiddenExpanded = useCallback(() => {
187
+ setHiddenExpanded((prev) => !prev);
188
+ }, []);
189
+ const handleOpenContextMenu = useCallback((collectionId, x, y) => {
190
+ setContextMenu({ collectionId, x, y });
191
+ }, []);
192
+ const handleCloseContextMenu = useCallback(() => {
193
+ setContextMenu(undefined);
194
+ }, []);
195
+ const getBorderColor = useCallback((sourceName) => {
196
+ if (!sourceColorMap)
197
+ return undefined;
198
+ if (sourceName && sourceName in sourceColorMap) {
199
+ return sourceColorMap[sourceName];
200
+ }
201
+ return sourceColorFallback;
202
+ }, [sourceColorMap, sourceColorFallback]);
203
+ const contextMenuCollection = contextMenu
204
+ ? collections.find((c) => c.id === contextMenu.collectionId)
205
+ : undefined;
85
206
  return (React.createElement("div", { className: "flex flex-col border-t border-border mt-1" },
86
207
  React.createElement("div", { className: "flex items-center justify-between px-3 py-1.5" },
87
208
  React.createElement("button", { onClick: handleToggleCollapse, className: "flex items-center gap-1 text-xs font-medium text-muted uppercase tracking-wider hover:text-secondary transition-colors" },
88
- React.createElement("span", { className: `text-[10px] transition-transform ${collapsed ? '' : 'rotate-90'}` }, '\u203A'),
209
+ React.createElement(ChevronRightIcon, { className: `w-3 h-3 transition-transform ${collapsed ? '' : 'rotate-90'}` }),
89
210
  "Collections",
90
211
  React.createElement("span", { className: "text-muted normal-case font-normal" },
91
212
  "(",
92
213
  collections.length,
93
214
  ")")),
94
- collections.length > 1 && (React.createElement("button", { onClick: handleToggleAllVisibility, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: allVisible ? 'Hide all collections' : 'Show all collections', "aria-label": allVisible ? 'Hide all collections' : 'Show all collections' }, allVisible ? '\u{1F441}\u{FE0F}\u{200D}\u{1F5E8}\u{FE0F}' : '\u{1F441}')),
95
215
  React.createElement("div", { className: "flex items-center gap-1" },
96
216
  onAddDirectory && (React.createElement("button", { onClick: onAddDirectory, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Add directory", "aria-label": "Add directory" },
97
- "+",
98
- '\uD83D\uDCC1')),
217
+ React.createElement(FolderPlusIcon, { className: "w-4 h-4" }))),
99
218
  onExportAllAsZip && (React.createElement("button", { onClick: onExportAllAsZip, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Export all mutable collections as zip", "aria-label": "Export all as zip" },
100
- "\u2193",
101
- '🗂')),
102
- onOpenCollectionFromFile && (React.createElement("button", { onClick: onOpenCollectionFromFile, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Open collection file for in-place editing", "aria-label": "Open collection from file" }, '📂')),
103
- onImportCollection && (React.createElement("button", { onClick: onImportCollection, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Import collection from file (in-memory)", "aria-label": "Import collection from file" }, "\u2191")),
104
- onCreateCollection && (React.createElement("button", { onClick: onCreateCollection, "data-testid": "sidebar-new-collection-button", className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "New collection", "aria-label": "New collection" }, "+")))),
105
- !collapsed && (React.createElement("div", { className: "flex flex-col" }, collections.length === 0 ? (React.createElement("div", { className: "px-3 py-2 text-xs text-muted" }, "No collections")) : (collections.map((collection) => (React.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection }))))))));
219
+ React.createElement(ArchiveBoxArrowDownIcon, { className: "w-4 h-4" }))),
220
+ onOpenCollectionFromFile && (React.createElement("button", { onClick: onOpenCollectionFromFile, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Open collection file for in-place editing", "aria-label": "Open collection from file" },
221
+ React.createElement(FolderOpenIcon, { className: "w-4 h-4" }))),
222
+ onImportCollection && (React.createElement("button", { onClick: onImportCollection, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Import collection from file (in-memory)", "aria-label": "Import collection from file" },
223
+ React.createElement(ArrowUpTrayIcon, { className: "w-4 h-4" }))),
224
+ onCreateCollection && (React.createElement("button", { onClick: onCreateCollection, "data-testid": "sidebar-new-collection-button", className: "text-muted hover:text-brand-accent transition-colors px-1", title: "New collection", "aria-label": "New collection" },
225
+ React.createElement(PlusIcon, { className: "w-4 h-4" }))))),
226
+ !collapsed && (React.createElement("div", { className: "flex flex-col" },
227
+ visibleCollections.length === 0 && hiddenCollections.length === 0 ? (React.createElement("div", { className: "px-3 py-2 text-xs text-muted" }, "No collections")) : (visibleCollections.map((collection) => (React.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection, borderColorClass: getBorderColor(collection.sourceName), onContextMenu: onHideCollection ? handleOpenContextMenu : undefined })))),
228
+ hiddenCollections.length > 0 && (React.createElement(React.Fragment, null,
229
+ React.createElement("button", { onClick: handleToggleHiddenExpanded, className: "flex items-center gap-1 px-3 py-1 text-xs text-muted hover:text-secondary transition-colors" },
230
+ React.createElement(ChevronRightIcon, { className: `w-3 h-3 transition-transform ${hiddenExpanded ? 'rotate-90' : ''}` }),
231
+ hiddenCollections.length,
232
+ " hidden"),
233
+ hiddenExpanded &&
234
+ hiddenCollections.map((collection) => (React.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection, borderColorClass: getBorderColor(collection.sourceName), onContextMenu: onShowCollection ? handleOpenContextMenu : undefined, isHiddenRow: true }))))))),
235
+ contextMenu && contextMenuCollection && (React.createElement(CollectionContextMenu, { menu: contextMenu, isHidden: (_a = contextMenuCollection.isHidden) !== null && _a !== void 0 ? _a : false, onHide: onHideCollection, onShow: onShowCollection, onClose: handleCloseContextMenu }))));
106
236
  }
107
237
  //# sourceMappingURL=CollectionSection.js.map
@@ -20,6 +20,21 @@
20
20
  * SOFTWARE.
21
21
  */
22
22
  import React from 'react';
23
+ const BADGE_BASE_CLASSES = 'inline-flex shrink-0 items-center justify-center rounded-full font-medium leading-none';
24
+ const BADGE_TONE_CLASSES = {
25
+ info: 'bg-status-info-bg text-status-info-text',
26
+ warning: 'bg-status-warning-bg text-status-warning-text',
27
+ danger: 'bg-status-error-bg text-status-error-text'
28
+ };
29
+ function renderBadge(badge) {
30
+ var _a, _b;
31
+ const tone = (_a = badge.tone) !== null && _a !== void 0 ? _a : 'info';
32
+ const ariaLabel = badge.ariaLabel;
33
+ if (badge.kind === 'dot') {
34
+ return (React.createElement("span", { className: `ml-1.5 h-2 w-2 ${BADGE_BASE_CLASSES} ${BADGE_TONE_CLASSES[tone]}`, "aria-label": ariaLabel }));
35
+ }
36
+ return (React.createElement("span", { className: `ml-1.5 min-w-4 px-1.5 py-0.5 text-[0.6875rem] ${BADGE_BASE_CLASSES} ${BADGE_TONE_CLASSES[tone]}`, "aria-label": ariaLabel }, (_b = badge.count) !== null && _b !== void 0 ? _b : 0));
37
+ }
23
38
  /**
24
39
  * Second-level tab bar for switching views within a mode.
25
40
  * @public
@@ -29,7 +44,9 @@ export function TabBar(props) {
29
44
  return (React.createElement("div", { className: "flex items-center gap-1 px-4 py-1 bg-brand-secondary text-white border-t border-white/10 overflow-x-auto" },
30
45
  tabs.map((tab) => (React.createElement("button", { key: tab.id, onClick: () => onTabChange(tab.id), className: `px-3 py-1.5 rounded-md text-sm font-medium transition-colors shrink-0 ${activeTab === tab.id
31
46
  ? 'bg-white/20 text-white'
32
- : 'text-white/60 hover:text-white hover:bg-white/10'}`, "aria-current": activeTab === tab.id ? 'page' : undefined }, tab.label))),
47
+ : 'text-white/60 hover:text-white hover:bg-white/10'}`, "aria-current": activeTab === tab.id ? 'page' : undefined },
48
+ React.createElement("span", null, tab.label),
49
+ tab.badge && renderBadge(tab.badge)))),
33
50
  rightContent !== undefined && (React.createElement(React.Fragment, null,
34
51
  React.createElement("div", { className: "flex-1" }),
35
52
  rightContent))));
@@ -15,6 +15,8 @@ import React from 'react';
15
15
  export interface IEditFieldProps {
16
16
  /** Label text displayed to the left of the field */
17
17
  readonly label: string;
18
+ /** Optional tooltip shown on hover over the label */
19
+ readonly tooltip?: string;
18
20
  /** The input control(s) to render */
19
21
  readonly children: React.ReactNode;
20
22
  }
@@ -22,7 +24,7 @@ export interface IEditFieldProps {
22
24
  * Horizontal label + field layout for a single edit field.
23
25
  * @public
24
26
  */
25
- export declare function EditField({ label, children }: IEditFieldProps): React.ReactElement;
27
+ export declare function EditField({ label, tooltip, children }: IEditFieldProps): React.ReactElement;
26
28
  /**
27
29
  * Props for the EditSection component.
28
30
  * @public
@@ -30,6 +32,8 @@ export declare function EditField({ label, children }: IEditFieldProps): React.R
30
32
  export interface IEditSectionProps {
31
33
  /** Section heading text */
32
34
  readonly title: string;
35
+ /** Optional tooltip shown on hover over the section heading */
36
+ readonly tooltip?: string;
33
37
  /** Section content (typically EditField components) */
34
38
  readonly children: React.ReactNode;
35
39
  }
@@ -37,7 +41,7 @@ export interface IEditSectionProps {
37
41
  * Titled section wrapper for grouping related edit fields.
38
42
  * @public
39
43
  */
40
- export declare function EditSection({ title, children }: IEditSectionProps): React.ReactElement;
44
+ export declare function EditSection({ title, tooltip, children }: IEditSectionProps): React.ReactElement;
41
45
  /**
42
46
  * Props for the TextInput component.
43
47
  * @public
@@ -47,18 +47,18 @@ const react_1 = __importDefault(require("react"));
47
47
  * Horizontal label + field layout for a single edit field.
48
48
  * @public
49
49
  */
50
- function EditField({ label, children }) {
50
+ function EditField({ label, tooltip, children }) {
51
51
  return (react_1.default.createElement("div", { className: "flex items-baseline gap-2 py-1" },
52
- react_1.default.createElement("label", { className: "text-xs text-muted w-32 shrink-0" }, label),
52
+ react_1.default.createElement("label", { className: "text-xs text-muted w-32 shrink-0", title: tooltip }, label),
53
53
  react_1.default.createElement("div", { className: "flex-1" }, children)));
54
54
  }
55
55
  /**
56
56
  * Titled section wrapper for grouping related edit fields.
57
57
  * @public
58
58
  */
59
- function EditSection({ title, children }) {
59
+ function EditSection({ title, tooltip, children }) {
60
60
  return (react_1.default.createElement("div", { className: "mb-4" },
61
- react_1.default.createElement("h4", { className: "text-xs font-medium text-muted uppercase tracking-wider mb-1.5" }, title),
61
+ react_1.default.createElement("h4", { className: "text-xs font-medium text-muted uppercase tracking-wider mb-1.5", title: tooltip }, title),
62
62
  children));
63
63
  }
64
64
  /**
@@ -7,6 +7,16 @@
7
7
  * @packageDocumentation
8
8
  */
9
9
  import React from 'react';
10
+ /**
11
+ * Describes a single collection for rendering in the sidebar.
12
+ * @public
13
+ */
14
+ export interface ICollectionBadge {
15
+ readonly kind: 'dot' | 'count';
16
+ readonly tone?: 'info' | 'warning' | 'danger';
17
+ readonly count?: number;
18
+ readonly ariaLabel?: string;
19
+ }
10
20
  /**
11
21
  * Describes a single collection for rendering in the sidebar.
12
22
  * @public
@@ -33,18 +43,27 @@ export interface ICollectionRowItem {
33
43
  * in another storage root. When true, a repair action should be offered.
34
44
  */
35
45
  readonly hasConflict?: boolean;
46
+ /** Whether this collection is explicitly hidden by the user */
47
+ readonly isHidden?: boolean;
48
+ /** The name of the storage source this collection was loaded from */
49
+ readonly sourceName?: string;
50
+ /** Optional badge rendered alongside the collection name */
51
+ readonly badge?: ICollectionBadge;
36
52
  }
37
53
  /**
38
54
  * Props for the CollectionSection component.
39
55
  * @public
40
56
  */
57
+ /**
58
+ * Maps source names to Tailwind border color classes for the left-border indicator.
59
+ * @public
60
+ */
61
+ export type SourceColorMap = Readonly<Record<string, string>>;
41
62
  export interface ICollectionSectionProps {
42
63
  /** Collection items to display */
43
64
  readonly collections: ReadonlyArray<ICollectionRowItem>;
44
65
  /** Callback when visibility is toggled for a collection */
45
66
  readonly onToggleVisibility: (collectionId: string) => void;
46
- /** Callback when all-visible toggle is clicked; receives true to show all, false to hide all */
47
- readonly onToggleAllVisibility?: (showAll: boolean) => void;
48
67
  /** Callback when "Add Directory" is clicked */
49
68
  readonly onAddDirectory?: () => void;
50
69
  /** Callback when "New Collection" is clicked */
@@ -67,8 +86,16 @@ export interface ICollectionSectionProps {
67
86
  readonly onRenameCollection?: (collectionId: string) => void;
68
87
  /** Callback when merge is clicked for a mutable collection */
69
88
  readonly onMergeCollection?: (collectionId: string) => void;
89
+ /** Callback when hide is selected from the context menu */
90
+ readonly onHideCollection?: (collectionId: string) => void;
91
+ /** Callback when show (unhide) is selected from the context menu */
92
+ readonly onShowCollection?: (collectionId: string) => void;
70
93
  /** Whether the section starts collapsed */
71
94
  readonly defaultCollapsed?: boolean;
95
+ /** Maps sourceName values to Tailwind border-l color classes */
96
+ readonly sourceColorMap?: SourceColorMap;
97
+ /** Fallback border-l color class when sourceName is not in the map */
98
+ readonly sourceColorFallback?: string;
72
99
  }
73
100
  /**
74
101
  * Collapsible sidebar section for managing collections.
@@ -64,30 +64,138 @@ exports.CollectionSection = CollectionSection;
64
64
  * @packageDocumentation
65
65
  */
66
66
  const react_1 = __importStar(require("react"));
67
+ const outline_1 = require("@heroicons/react/24/outline");
68
+ const solid_1 = require("@heroicons/react/20/solid");
69
+ const BADGE_BASE_CLASSES = 'inline-flex shrink-0 items-center justify-center rounded-full font-medium leading-none';
70
+ const BADGE_TONE_CLASSES = {
71
+ info: 'bg-status-info-bg text-status-info-text',
72
+ warning: 'bg-status-warning-bg text-status-warning-text',
73
+ danger: 'bg-status-error-bg text-status-error-text'
74
+ };
75
+ function renderBadge(badge) {
76
+ var _a, _b;
77
+ const tone = (_a = badge.tone) !== null && _a !== void 0 ? _a : 'info';
78
+ const ariaLabel = badge.ariaLabel;
79
+ if (badge.kind === 'dot') {
80
+ return (react_1.default.createElement("span", { className: `ml-1.5 h-2 w-2 ${BADGE_BASE_CLASSES} ${BADGE_TONE_CLASSES[tone]}`, "aria-label": ariaLabel }));
81
+ }
82
+ return (react_1.default.createElement("span", { className: `ml-1.5 min-w-4 px-1.5 py-0.5 text-[0.6875rem] ${BADGE_BASE_CLASSES} ${BADGE_TONE_CLASSES[tone]}`, "aria-label": ariaLabel }, (_b = badge.count) !== null && _b !== void 0 ? _b : 0));
83
+ }
84
+ // ============================================================================
85
+ // Context Menu (internal)
86
+ // ============================================================================
87
+ const LONG_PRESS_MS = 500;
88
+ function CollectionContextMenu(props) {
89
+ const { menu, isHidden, onHide, onShow, onClose } = props;
90
+ const ref = (0, react_1.useRef)(null);
91
+ (0, react_1.useEffect)(() => {
92
+ function handleClickOutside(e) {
93
+ if (ref.current && !ref.current.contains(e.target)) {
94
+ onClose();
95
+ }
96
+ }
97
+ document.addEventListener('mousedown', handleClickOutside);
98
+ return () => document.removeEventListener('mousedown', handleClickOutside);
99
+ }, [onClose]);
100
+ const action = isHidden ? onShow : onHide;
101
+ if (!action) {
102
+ return react_1.default.createElement(react_1.default.Fragment, null);
103
+ }
104
+ return (react_1.default.createElement("div", { ref: ref, className: "fixed z-50 bg-surface-raised border border-border rounded shadow-lg py-1 min-w-[140px]", style: { left: menu.x, top: menu.y } },
105
+ react_1.default.createElement("button", { className: "w-full text-left px-3 py-1.5 text-sm text-secondary hover:bg-hover transition-colors", onClick: () => {
106
+ action(menu.collectionId);
107
+ onClose();
108
+ } }, isHidden ? 'Show collection' : 'Hide collection')));
109
+ }
110
+ // ============================================================================
111
+ // Long-press hook (internal)
112
+ // ============================================================================
113
+ function useLongPress(onLongPress) {
114
+ const timerRef = (0, react_1.useRef)(undefined);
115
+ const firedRef = (0, react_1.useRef)(false);
116
+ const onTouchStart = (0, react_1.useCallback)((e) => {
117
+ firedRef.current = false;
118
+ timerRef.current = setTimeout(() => {
119
+ firedRef.current = true;
120
+ onLongPress(e);
121
+ }, LONG_PRESS_MS);
122
+ }, [onLongPress]);
123
+ const cancel = (0, react_1.useCallback)(() => {
124
+ if (timerRef.current !== undefined) {
125
+ clearTimeout(timerRef.current);
126
+ timerRef.current = undefined;
127
+ }
128
+ }, []);
129
+ return { onTouchStart, onTouchEnd: cancel, onTouchMove: cancel };
130
+ }
67
131
  // ============================================================================
68
132
  // CollectionRow (internal)
69
133
  // ============================================================================
70
134
  function CollectionRow(props) {
71
- var _a, _b, _c, _d;
72
- const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge } = props;
135
+ var _a;
136
+ const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge, borderColorClass, onContextMenu, isHiddenRow } = props;
73
137
  const displayName = (_a = collection.name) !== null && _a !== void 0 ? _a : collection.id;
74
- return (react_1.default.createElement("div", { className: `flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors hover:bg-hover ${collection.isVisible ? 'text-secondary' : 'text-muted'}` },
75
- react_1.default.createElement("button", { onClick: () => onToggleVisibility(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs hover:text-brand-accent transition-colors", title: collection.isVisible ? 'Hide collection' : 'Show collection', "aria-label": `${collection.isVisible ? 'Hide' : 'Show'} ${displayName}` }, collection.isVisible ? '\u{1F441}' : '\u{1F441}\u{FE0F}\u{200D}\u{1F5E8}\u{FE0F}'),
76
- onSetDefault && collection.isMutable && (react_1.default.createElement("button", { onClick: () => onSetDefault(collection.id), className: `shrink-0 w-5 h-5 flex items-center justify-center transition-colors ${collection.isDefault ? 'text-star hover:text-star' : 'text-faint hover:text-star'}`, title: collection.isDefault
77
- ? 'Default collection for new items'
78
- : 'Set as default collection for new items', "aria-label": collection.isDefault
79
- ? `${(_b = collection.name) !== null && _b !== void 0 ? _b : collection.id} is default`
80
- : `Set ${(_c = collection.name) !== null && _c !== void 0 ? _c : collection.id} as default`, "aria-pressed": collection.isDefault }, collection.isDefault ? '★' : '☆')),
81
- collection.hasConflict && (react_1.default.createElement("span", { className: "shrink-0 text-xs text-status-warning-strong cursor-default", title: "An encrypted copy of this collection from another storage root has the same ID. Go to Settings \u2192 Storage to resolve the conflict.", "aria-label": `Conflict: encrypted shadow for ${displayName}` }, '⚠')),
82
- !collection.isMutable && (react_1.default.createElement("span", { className: "shrink-0 text-xs text-muted", title: "Built-in collection (read-only)" }, '\uD83D\uDD12')),
83
- collection.isProtected &&
84
- (collection.isUnlocked || !onUnlock ? (react_1.default.createElement("span", { className: `shrink-0 text-xs ${collection.isUnlocked ? 'text-status-success-icon' : 'text-muted'}`, title: collection.isUnlocked ? 'Protected (unlocked)' : 'Protected (locked)' }, '\uD83D\uDEE1')) : (react_1.default.createElement("button", { onClick: () => onUnlock(collection.id), className: "shrink-0 text-xs text-muted hover:text-star transition-colors", title: "Click to unlock", "aria-label": `Unlock ${(_d = collection.name) !== null && _d !== void 0 ? _d : collection.id}` }, '\uD83D\uDEE1'))),
85
- react_1.default.createElement("span", { className: "flex-1 truncate", title: displayName }, displayName),
86
- react_1.default.createElement("span", { className: "shrink-0 text-xs text-muted" }, collection.itemCount),
87
- collection.isMutable && onExport && (react_1.default.createElement("button", { onClick: () => onExport(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs text-faint hover:text-brand-accent transition-colors", title: `Export ${displayName}`, "aria-label": `Export ${displayName}` }, "\u2193")),
88
- collection.isMutable && onRename && (react_1.default.createElement("button", { onClick: () => onRename(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs text-faint hover:text-brand-accent transition-colors", title: `Rename ${displayName}`, "aria-label": `Rename ${displayName}` }, '✎')),
89
- collection.isMutable && onMerge && (react_1.default.createElement("button", { onClick: () => onMerge(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs text-faint hover:text-brand-accent transition-colors", title: `Merge ${displayName} into another collection`, "aria-label": `Merge ${displayName}` }, '⤵')),
90
- collection.isMutable && onDelete && (react_1.default.createElement("button", { onClick: () => onDelete(collection.id), className: "shrink-0 w-5 h-5 flex items-center justify-center text-xs text-faint hover:text-status-error-icon transition-colors", title: `Remove ${displayName}`, "aria-label": `Remove ${displayName}` }, '×'))));
138
+ const handleContextMenu = (0, react_1.useCallback)((e) => {
139
+ if (onContextMenu) {
140
+ e.preventDefault();
141
+ onContextMenu(collection.id, e.clientX, e.clientY);
142
+ }
143
+ }, [onContextMenu, collection.id]);
144
+ const handleLongPress = (0, react_1.useCallback)((e) => {
145
+ if (onContextMenu && e.touches.length > 0) {
146
+ e.preventDefault();
147
+ const touch = e.touches[0];
148
+ onContextMenu(collection.id, touch.clientX, touch.clientY);
149
+ }
150
+ }, [onContextMenu, collection.id]);
151
+ const longPress = useLongPress(handleLongPress);
152
+ return (react_1.default.createElement("div", Object.assign({ onClick: () => onToggleVisibility(collection.id), onContextMenu: handleContextMenu }, longPress, { className: `flex flex-col px-3 py-2.5 text-sm transition-colors hover:bg-hover cursor-pointer ${isHiddenRow
153
+ ? 'text-muted opacity-30 line-through'
154
+ : collection.isVisible
155
+ ? 'text-secondary'
156
+ : 'text-muted opacity-50'} ${borderColorClass ? `border-l-4 ${borderColorClass}` : ''}`, role: "button", "aria-pressed": collection.isVisible, title: collection.sourceName ? `Source: ${collection.sourceName}` : displayName }),
157
+ react_1.default.createElement("div", { className: "flex items-center gap-1.5" },
158
+ onSetDefault && collection.isMutable && (react_1.default.createElement("button", { onClick: (e) => {
159
+ e.stopPropagation();
160
+ onSetDefault(collection.id);
161
+ }, className: `shrink-0 w-5 h-5 flex items-center justify-center transition-colors ${collection.isDefault ? 'text-star hover:text-star' : 'text-faint hover:text-star'}`, title: collection.isDefault
162
+ ? 'Default collection for new items'
163
+ : 'Set as default collection for new items', "aria-label": collection.isDefault ? `${displayName} is default` : `Set ${displayName} as default`, "aria-pressed": collection.isDefault }, collection.isDefault ? (react_1.default.createElement(solid_1.StarIcon, { className: "w-4 h-4" })) : (react_1.default.createElement(outline_1.StarIcon, { className: "w-4 h-4" })))),
164
+ collection.hasConflict && (react_1.default.createElement("span", { className: "shrink-0 text-status-warning-strong cursor-default", title: "An encrypted copy of this collection from another storage root has the same ID. Go to Settings \u2192 Storage to resolve the conflict.", "aria-label": `Conflict: encrypted shadow for ${displayName}` },
165
+ react_1.default.createElement(solid_1.ExclamationTriangleIcon, { className: "w-4 h-4" }))),
166
+ !collection.isMutable && (react_1.default.createElement("span", { className: "shrink-0 text-muted", title: "Built-in collection (read-only)" },
167
+ react_1.default.createElement(solid_1.BuildingLibraryIcon, { className: "w-4 h-4" }))),
168
+ collection.isProtected &&
169
+ (collection.isUnlocked || !onUnlock ? (react_1.default.createElement("span", { className: `shrink-0 ${collection.isUnlocked ? 'text-status-success-icon' : 'text-muted'}`, title: collection.isUnlocked ? 'Protected (unlocked)' : 'Protected (locked)' }, collection.isUnlocked ? (react_1.default.createElement(solid_1.ShieldCheckIcon, { className: "w-4 h-4" })) : (react_1.default.createElement(solid_1.ShieldExclamationIcon, { className: "w-4 h-4" })))) : (react_1.default.createElement("button", { onClick: (e) => {
170
+ e.stopPropagation();
171
+ onUnlock(collection.id);
172
+ }, className: "shrink-0 text-muted hover:text-star transition-colors", title: "Click to unlock", "aria-label": `Unlock ${displayName}` },
173
+ react_1.default.createElement(solid_1.ShieldExclamationIcon, { className: "w-4 h-4" })))),
174
+ react_1.default.createElement("span", { className: "flex-1 truncate", title: displayName }, displayName),
175
+ collection.badge && renderBadge(collection.badge),
176
+ react_1.default.createElement("span", { className: "shrink-0 text-xs text-muted" }, collection.itemCount)),
177
+ react_1.default.createElement("div", { className: "flex items-center gap-1 mt-1 ml-[24px]" },
178
+ collection.isMutable && onExport && (react_1.default.createElement("button", { onClick: (e) => {
179
+ e.stopPropagation();
180
+ onExport(collection.id);
181
+ }, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Export ${displayName}`, "aria-label": `Export ${displayName}` },
182
+ react_1.default.createElement(solid_1.ArrowDownTrayIcon, { className: "w-4 h-4" }))),
183
+ collection.isMutable && onRename && (react_1.default.createElement("button", { onClick: (e) => {
184
+ e.stopPropagation();
185
+ onRename(collection.id);
186
+ }, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Rename ${displayName}`, "aria-label": `Rename ${displayName}` },
187
+ react_1.default.createElement(solid_1.PencilSquareIcon, { className: "w-4 h-4" }))),
188
+ collection.isMutable && onMerge && (react_1.default.createElement("button", { onClick: (e) => {
189
+ e.stopPropagation();
190
+ onMerge(collection.id);
191
+ }, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Merge ${displayName} into another collection`, "aria-label": `Merge ${displayName}` },
192
+ react_1.default.createElement(solid_1.ArrowsPointingInIcon, { className: "w-4 h-4" }))),
193
+ collection.isMutable && onDelete && (react_1.default.createElement("button", { onClick: (e) => {
194
+ e.stopPropagation();
195
+ onDelete(collection.id);
196
+ }, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-status-error-icon transition-colors", title: `Remove ${displayName}`, "aria-label": `Remove ${displayName}` },
197
+ react_1.default.createElement(solid_1.TrashIcon, { className: "w-4 h-4" }))),
198
+ !collection.isMutable && react_1.default.createElement("span", { className: "text-xs text-muted" }, "(built-in)"))));
91
199
  }
92
200
  // ============================================================================
93
201
  // CollectionSection
@@ -101,43 +209,65 @@ function CollectionRow(props) {
101
209
  * @public
102
210
  */
103
211
  function CollectionSection(props) {
104
- const { collections, onToggleVisibility, onToggleAllVisibility, onAddDirectory, onCreateCollection, onDeleteCollection, onSetDefaultCollection, onExportCollection, onExportAllAsZip, onImportCollection, onOpenCollectionFromFile, onUnlockCollection, onRenameCollection, onMergeCollection, defaultCollapsed = false } = props;
212
+ var _a;
213
+ const { collections, onToggleVisibility, onAddDirectory, onCreateCollection, onDeleteCollection, onSetDefaultCollection, onExportCollection, onExportAllAsZip, onImportCollection, onOpenCollectionFromFile, onUnlockCollection, onRenameCollection, onMergeCollection, onHideCollection, onShowCollection, defaultCollapsed = false, sourceColorMap, sourceColorFallback } = props;
105
214
  const [collapsed, setCollapsed] = (0, react_1.useState)(defaultCollapsed);
106
- const allVisible = collections.length > 0 && collections.every((c) => c.isVisible);
107
- const handleToggleAllVisibility = (0, react_1.useCallback)(() => {
108
- if (onToggleAllVisibility) {
109
- onToggleAllVisibility(!allVisible);
110
- }
111
- else {
112
- const ids = allVisible
113
- ? collections.map((c) => c.id)
114
- : collections.filter((c) => !c.isVisible).map((c) => c.id);
115
- ids.forEach((id) => onToggleVisibility(id));
116
- }
117
- }, [onToggleAllVisibility, onToggleVisibility, allVisible, collections]);
215
+ const [hiddenExpanded, setHiddenExpanded] = (0, react_1.useState)(false);
216
+ const [contextMenu, setContextMenu] = (0, react_1.useState)(undefined);
217
+ const visibleCollections = collections.filter((c) => !c.isHidden);
218
+ const hiddenCollections = collections.filter((c) => c.isHidden);
118
219
  const handleToggleCollapse = (0, react_1.useCallback)(() => {
119
220
  setCollapsed((prev) => !prev);
120
221
  }, []);
222
+ const handleToggleHiddenExpanded = (0, react_1.useCallback)(() => {
223
+ setHiddenExpanded((prev) => !prev);
224
+ }, []);
225
+ const handleOpenContextMenu = (0, react_1.useCallback)((collectionId, x, y) => {
226
+ setContextMenu({ collectionId, x, y });
227
+ }, []);
228
+ const handleCloseContextMenu = (0, react_1.useCallback)(() => {
229
+ setContextMenu(undefined);
230
+ }, []);
231
+ const getBorderColor = (0, react_1.useCallback)((sourceName) => {
232
+ if (!sourceColorMap)
233
+ return undefined;
234
+ if (sourceName && sourceName in sourceColorMap) {
235
+ return sourceColorMap[sourceName];
236
+ }
237
+ return sourceColorFallback;
238
+ }, [sourceColorMap, sourceColorFallback]);
239
+ const contextMenuCollection = contextMenu
240
+ ? collections.find((c) => c.id === contextMenu.collectionId)
241
+ : undefined;
121
242
  return (react_1.default.createElement("div", { className: "flex flex-col border-t border-border mt-1" },
122
243
  react_1.default.createElement("div", { className: "flex items-center justify-between px-3 py-1.5" },
123
244
  react_1.default.createElement("button", { onClick: handleToggleCollapse, className: "flex items-center gap-1 text-xs font-medium text-muted uppercase tracking-wider hover:text-secondary transition-colors" },
124
- react_1.default.createElement("span", { className: `text-[10px] transition-transform ${collapsed ? '' : 'rotate-90'}` }, '\u203A'),
245
+ react_1.default.createElement(solid_1.ChevronRightIcon, { className: `w-3 h-3 transition-transform ${collapsed ? '' : 'rotate-90'}` }),
125
246
  "Collections",
126
247
  react_1.default.createElement("span", { className: "text-muted normal-case font-normal" },
127
248
  "(",
128
249
  collections.length,
129
250
  ")")),
130
- collections.length > 1 && (react_1.default.createElement("button", { onClick: handleToggleAllVisibility, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: allVisible ? 'Hide all collections' : 'Show all collections', "aria-label": allVisible ? 'Hide all collections' : 'Show all collections' }, allVisible ? '\u{1F441}\u{FE0F}\u{200D}\u{1F5E8}\u{FE0F}' : '\u{1F441}')),
131
251
  react_1.default.createElement("div", { className: "flex items-center gap-1" },
132
252
  onAddDirectory && (react_1.default.createElement("button", { onClick: onAddDirectory, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Add directory", "aria-label": "Add directory" },
133
- "+",
134
- '\uD83D\uDCC1')),
253
+ react_1.default.createElement(solid_1.FolderPlusIcon, { className: "w-4 h-4" }))),
135
254
  onExportAllAsZip && (react_1.default.createElement("button", { onClick: onExportAllAsZip, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Export all mutable collections as zip", "aria-label": "Export all as zip" },
136
- "\u2193",
137
- '🗂')),
138
- onOpenCollectionFromFile && (react_1.default.createElement("button", { onClick: onOpenCollectionFromFile, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Open collection file for in-place editing", "aria-label": "Open collection from file" }, '📂')),
139
- onImportCollection && (react_1.default.createElement("button", { onClick: onImportCollection, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Import collection from file (in-memory)", "aria-label": "Import collection from file" }, "\u2191")),
140
- onCreateCollection && (react_1.default.createElement("button", { onClick: onCreateCollection, "data-testid": "sidebar-new-collection-button", className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "New collection", "aria-label": "New collection" }, "+")))),
141
- !collapsed && (react_1.default.createElement("div", { className: "flex flex-col" }, collections.length === 0 ? (react_1.default.createElement("div", { className: "px-3 py-2 text-xs text-muted" }, "No collections")) : (collections.map((collection) => (react_1.default.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection }))))))));
255
+ react_1.default.createElement(solid_1.ArchiveBoxArrowDownIcon, { className: "w-4 h-4" }))),
256
+ onOpenCollectionFromFile && (react_1.default.createElement("button", { onClick: onOpenCollectionFromFile, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Open collection file for in-place editing", "aria-label": "Open collection from file" },
257
+ react_1.default.createElement(solid_1.FolderOpenIcon, { className: "w-4 h-4" }))),
258
+ onImportCollection && (react_1.default.createElement("button", { onClick: onImportCollection, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Import collection from file (in-memory)", "aria-label": "Import collection from file" },
259
+ react_1.default.createElement(solid_1.ArrowUpTrayIcon, { className: "w-4 h-4" }))),
260
+ onCreateCollection && (react_1.default.createElement("button", { onClick: onCreateCollection, "data-testid": "sidebar-new-collection-button", className: "text-muted hover:text-brand-accent transition-colors px-1", title: "New collection", "aria-label": "New collection" },
261
+ react_1.default.createElement(solid_1.PlusIcon, { className: "w-4 h-4" }))))),
262
+ !collapsed && (react_1.default.createElement("div", { className: "flex flex-col" },
263
+ visibleCollections.length === 0 && hiddenCollections.length === 0 ? (react_1.default.createElement("div", { className: "px-3 py-2 text-xs text-muted" }, "No collections")) : (visibleCollections.map((collection) => (react_1.default.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection, borderColorClass: getBorderColor(collection.sourceName), onContextMenu: onHideCollection ? handleOpenContextMenu : undefined })))),
264
+ hiddenCollections.length > 0 && (react_1.default.createElement(react_1.default.Fragment, null,
265
+ react_1.default.createElement("button", { onClick: handleToggleHiddenExpanded, className: "flex items-center gap-1 px-3 py-1 text-xs text-muted hover:text-secondary transition-colors" },
266
+ react_1.default.createElement(solid_1.ChevronRightIcon, { className: `w-3 h-3 transition-transform ${hiddenExpanded ? 'rotate-90' : ''}` }),
267
+ hiddenCollections.length,
268
+ " hidden"),
269
+ hiddenExpanded &&
270
+ hiddenCollections.map((collection) => (react_1.default.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection, borderColorClass: getBorderColor(collection.sourceName), onContextMenu: onShowCollection ? handleOpenContextMenu : undefined, isHiddenRow: true }))))))),
271
+ contextMenu && contextMenuCollection && (react_1.default.createElement(CollectionContextMenu, { menu: contextMenu, isHidden: (_a = contextMenuCollection.isHidden) !== null && _a !== void 0 ? _a : false, onHide: onHideCollection, onShow: onShowCollection, onClose: handleCloseContextMenu }))));
142
272
  }
143
273
  //# sourceMappingURL=CollectionSection.js.map
@@ -8,5 +8,5 @@ export { FilterRow, type IFilterRowProps, type IFilterOption } from './FilterRow
8
8
  export { FilterBar, type IFilterBarProps } from './FilterBar';
9
9
  export { EntityList, type IEntityListProps, type IEntityDescriptor, type IEntityStatus, type IEmptyStateConfig, type IEmptyStateAction } from './EntityList';
10
10
  export { GroupedEntityList, type IGroupedEntityListProps, type IEntityGroupDescriptor } from './GroupedEntityList';
11
- export { CollectionSection, type ICollectionSectionProps, type ICollectionRowItem } from './CollectionSection';
11
+ export { CollectionSection, type ICollectionBadge, type ICollectionSectionProps, type ICollectionRowItem, type SourceColorMap } from './CollectionSection';
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -1,4 +1,14 @@
1
1
  import React from 'react';
2
+ /**
3
+ * Optional badge shown alongside a tab label.
4
+ * @public
5
+ */
6
+ export interface ITabBadge {
7
+ readonly kind: 'dot' | 'count';
8
+ readonly tone?: 'info' | 'warning' | 'danger';
9
+ readonly count?: number;
10
+ readonly ariaLabel?: string;
11
+ }
2
12
  /**
3
13
  * Configuration for a single tab.
4
14
  * @public
@@ -8,6 +18,8 @@ export interface ITabConfig<TTab extends string> {
8
18
  readonly id: TTab;
9
19
  /** Display label */
10
20
  readonly label: string;
21
+ /** Optional badge rendered next to the tab label */
22
+ readonly badge?: ITabBadge;
11
23
  }
12
24
  /**
13
25
  * Props for the TabBar component.
@@ -26,6 +26,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.TabBar = TabBar;
28
28
  const react_1 = __importDefault(require("react"));
29
+ const BADGE_BASE_CLASSES = 'inline-flex shrink-0 items-center justify-center rounded-full font-medium leading-none';
30
+ const BADGE_TONE_CLASSES = {
31
+ info: 'bg-status-info-bg text-status-info-text',
32
+ warning: 'bg-status-warning-bg text-status-warning-text',
33
+ danger: 'bg-status-error-bg text-status-error-text'
34
+ };
35
+ function renderBadge(badge) {
36
+ var _a, _b;
37
+ const tone = (_a = badge.tone) !== null && _a !== void 0 ? _a : 'info';
38
+ const ariaLabel = badge.ariaLabel;
39
+ if (badge.kind === 'dot') {
40
+ return (react_1.default.createElement("span", { className: `ml-1.5 h-2 w-2 ${BADGE_BASE_CLASSES} ${BADGE_TONE_CLASSES[tone]}`, "aria-label": ariaLabel }));
41
+ }
42
+ return (react_1.default.createElement("span", { className: `ml-1.5 min-w-4 px-1.5 py-0.5 text-[0.6875rem] ${BADGE_BASE_CLASSES} ${BADGE_TONE_CLASSES[tone]}`, "aria-label": ariaLabel }, (_b = badge.count) !== null && _b !== void 0 ? _b : 0));
43
+ }
29
44
  /**
30
45
  * Second-level tab bar for switching views within a mode.
31
46
  * @public
@@ -35,7 +50,9 @@ function TabBar(props) {
35
50
  return (react_1.default.createElement("div", { className: "flex items-center gap-1 px-4 py-1 bg-brand-secondary text-white border-t border-white/10 overflow-x-auto" },
36
51
  tabs.map((tab) => (react_1.default.createElement("button", { key: tab.id, onClick: () => onTabChange(tab.id), className: `px-3 py-1.5 rounded-md text-sm font-medium transition-colors shrink-0 ${activeTab === tab.id
37
52
  ? 'bg-white/20 text-white'
38
- : 'text-white/60 hover:text-white hover:bg-white/10'}`, "aria-current": activeTab === tab.id ? 'page' : undefined }, tab.label))),
53
+ : 'text-white/60 hover:text-white hover:bg-white/10'}`, "aria-current": activeTab === tab.id ? 'page' : undefined },
54
+ react_1.default.createElement("span", null, tab.label),
55
+ tab.badge && renderBadge(tab.badge)))),
39
56
  rightContent !== undefined && (react_1.default.createElement(react_1.default.Fragment, null,
40
57
  react_1.default.createElement("div", { className: "flex-1" }),
41
58
  rightContent))));
@@ -3,5 +3,5 @@
3
3
  * @packageDocumentation
4
4
  */
5
5
  export { ModeSelector, type IModeConfig, type IModeSelectorProps } from './ModeSelector';
6
- export { TabBar, type ITabConfig, type ITabBarProps } from './TabBar';
6
+ export { TabBar, type ITabBadge, type ITabConfig, type ITabBarProps } from './TabBar';
7
7
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,17 +1,9 @@
1
1
  {
2
2
  "name": "@fgv/ts-app-shell",
3
- "version": "5.1.0-1",
3
+ "version": "5.1.0-11",
4
4
  "description": "Shared React UI primitives for application shells: column cascade, sidebar, toast/log messages, command palette, keybinding registry",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
- "scripts": {
8
- "build": "heft build --clean",
9
- "clean": "heft clean",
10
- "test": "heft test --clean",
11
- "lint": "eslint src --ext .ts,.tsx",
12
- "fixlint": "eslint src --ext .ts,.tsx --fix",
13
- "coverage": "jest --coverage"
14
- },
15
7
  "sideEffects": false,
16
8
  "keywords": [
17
9
  "typescript",
@@ -23,18 +15,16 @@
23
15
  "author": "Erik Fortune",
24
16
  "license": "MIT",
25
17
  "dependencies": {
26
- "@fgv/ts-utils": "workspace:*",
27
18
  "@heroicons/react": "~2.2.0",
28
19
  "tslib": "^2.8.1",
29
- "@fgv/ts-extras": "workspace:*"
20
+ "@fgv/ts-utils": "5.1.0-11",
21
+ "@fgv/ts-extras": "5.1.0-11"
30
22
  },
31
23
  "peerDependencies": {
32
24
  "react": ">=18.0.0",
33
25
  "react-dom": ">=18.0.0"
34
26
  },
35
27
  "devDependencies": {
36
- "@fgv/heft-dual-rig": "workspace:*",
37
- "@fgv/ts-utils-jest": "workspace:*",
38
28
  "@types/react": "~19.2.8",
39
29
  "@types/react-dom": "~19.2.3",
40
30
  "react": "~19.2.3",
@@ -56,11 +46,13 @@
56
46
  "rimraf": "^6.1.2",
57
47
  "ts-jest": "^29.4.6",
58
48
  "typescript": "5.9.3",
59
- "@rushstack/heft": "1.2.6",
49
+ "@rushstack/heft": "1.2.7",
60
50
  "@rushstack/eslint-config": "4.6.4",
61
51
  "@rushstack/heft-jest-plugin": "1.2.6",
62
52
  "@testing-library/dom": "^10.4.0",
63
- "@rushstack/heft-node-rig": "2.11.26"
53
+ "@rushstack/heft-node-rig": "2.11.27",
54
+ "@fgv/heft-dual-rig": "5.1.0-11",
55
+ "@fgv/ts-utils-jest": "5.1.0-11"
64
56
  },
65
57
  "exports": {
66
58
  ".": {
@@ -78,5 +70,13 @@
78
70
  "repository": {
79
71
  "type": "git",
80
72
  "url": "https://github.com/ErikFortune/fgv.git"
73
+ },
74
+ "scripts": {
75
+ "build": "heft build --clean",
76
+ "clean": "heft clean",
77
+ "test": "heft test --clean",
78
+ "lint": "eslint src --ext .ts,.tsx",
79
+ "fixlint": "eslint src --ext .ts,.tsx --fix",
80
+ "coverage": "jest --coverage"
81
81
  }
82
- }
82
+ }