@fgv/ts-app-shell 5.1.0-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +26 -0
  2. package/dist/index.browser.js +3 -0
  3. package/dist/index.js +43 -0
  4. package/dist/packlets/ai-assist/index.js +6 -0
  5. package/dist/packlets/ai-assist/useAiAssist.js +219 -0
  6. package/dist/packlets/cascade/CascadeContainer.js +83 -0
  7. package/dist/packlets/cascade/ComparisonView.js +48 -0
  8. package/dist/packlets/cascade/EntityTabLayout.js +104 -0
  9. package/dist/packlets/cascade/MobileCascadeStack.js +63 -0
  10. package/dist/packlets/cascade/index.js +37 -0
  11. package/dist/packlets/cascade/model.js +30 -0
  12. package/dist/packlets/cascade/useCascadeOps.js +206 -0
  13. package/dist/packlets/cascade/useCascadeTransitions.js +58 -0
  14. package/dist/packlets/detail/DetailHelpers.js +103 -0
  15. package/dist/packlets/detail/index.js +6 -0
  16. package/dist/packlets/drop-zone/JsonDropZone.js +112 -0
  17. package/dist/packlets/drop-zone/index.js +6 -0
  18. package/dist/packlets/editing/EditFieldHelpers.js +130 -0
  19. package/dist/packlets/editing/MultiActionButton.js +73 -0
  20. package/dist/packlets/editing/NumericInput.js +119 -0
  21. package/dist/packlets/editing/TypeaheadInput.js +207 -0
  22. package/dist/packlets/editing/index.js +10 -0
  23. package/dist/packlets/editing/useTypeaheadMatch.js +102 -0
  24. package/dist/packlets/keyboard/index.js +7 -0
  25. package/dist/packlets/keyboard/registry.js +133 -0
  26. package/dist/packlets/keyboard/useKeyboardShortcuts.js +117 -0
  27. package/dist/packlets/messages/MessagesContext.js +76 -0
  28. package/dist/packlets/messages/MessagesLogger.js +103 -0
  29. package/dist/packlets/messages/StatusBar.js +154 -0
  30. package/dist/packlets/messages/Toast.js +68 -0
  31. package/dist/packlets/messages/index.js +11 -0
  32. package/dist/packlets/messages/model.js +56 -0
  33. package/dist/packlets/messages/useLogReporter.js +66 -0
  34. package/dist/packlets/modal/ConfirmDialog.js +78 -0
  35. package/dist/packlets/modal/Modal.js +55 -0
  36. package/dist/packlets/modal/index.js +7 -0
  37. package/dist/packlets/print/PrintEnclosure.js +60 -0
  38. package/dist/packlets/print/index.js +7 -0
  39. package/dist/packlets/print/openPrintWindow.js +112 -0
  40. package/dist/packlets/responsive/ResponsiveProvider.js +56 -0
  41. package/dist/packlets/responsive/index.js +7 -0
  42. package/dist/packlets/responsive/useResponsiveLayout.js +118 -0
  43. package/dist/packlets/selectors/EntityRow.js +276 -0
  44. package/dist/packlets/selectors/PreferredSelector.js +251 -0
  45. package/dist/packlets/selectors/index.js +24 -0
  46. package/dist/packlets/sidebar/CollectionSection.js +107 -0
  47. package/dist/packlets/sidebar/EntityList.js +164 -0
  48. package/dist/packlets/sidebar/FilterBar.js +42 -0
  49. package/dist/packlets/sidebar/FilterRow.js +182 -0
  50. package/dist/packlets/sidebar/GroupedEntityList.js +183 -0
  51. package/dist/packlets/sidebar/SearchBar.js +34 -0
  52. package/dist/packlets/sidebar/SidebarLayout.js +62 -0
  53. package/dist/packlets/sidebar/index.js +12 -0
  54. package/dist/packlets/theme/ThemeProvider.js +141 -0
  55. package/dist/packlets/theme/index.js +6 -0
  56. package/dist/packlets/top-bar/ModeSelector.js +46 -0
  57. package/dist/packlets/top-bar/TabBar.js +37 -0
  58. package/dist/packlets/top-bar/index.js +7 -0
  59. package/dist/packlets/url-sync/index.js +6 -0
  60. package/dist/packlets/url-sync/useUrlSync.js +157 -0
  61. package/eslint.config.js +22 -0
  62. package/lib/index.browser.d.ts +2 -0
  63. package/lib/index.browser.js +19 -0
  64. package/lib/index.d.ts +28 -0
  65. package/lib/index.js +59 -0
  66. package/lib/packlets/ai-assist/index.d.ts +6 -0
  67. package/lib/packlets/ai-assist/index.js +11 -0
  68. package/lib/packlets/ai-assist/useAiAssist.d.ts +77 -0
  69. package/lib/packlets/ai-assist/useAiAssist.js +223 -0
  70. package/lib/packlets/cascade/CascadeContainer.d.ts +44 -0
  71. package/lib/packlets/cascade/CascadeContainer.js +119 -0
  72. package/lib/packlets/cascade/ComparisonView.d.ts +35 -0
  73. package/lib/packlets/cascade/ComparisonView.js +54 -0
  74. package/lib/packlets/cascade/EntityTabLayout.d.ts +47 -0
  75. package/lib/packlets/cascade/EntityTabLayout.js +110 -0
  76. package/lib/packlets/cascade/MobileCascadeStack.d.ts +20 -0
  77. package/lib/packlets/cascade/MobileCascadeStack.js +99 -0
  78. package/lib/packlets/cascade/index.d.ts +12 -0
  79. package/lib/packlets/cascade/index.js +48 -0
  80. package/lib/packlets/cascade/model.d.ts +57 -0
  81. package/lib/packlets/cascade/model.js +33 -0
  82. package/lib/packlets/cascade/useCascadeOps.d.ts +111 -0
  83. package/lib/packlets/cascade/useCascadeOps.js +209 -0
  84. package/lib/packlets/cascade/useCascadeTransitions.d.ts +19 -0
  85. package/lib/packlets/cascade/useCascadeTransitions.js +62 -0
  86. package/lib/packlets/detail/DetailHelpers.d.ts +83 -0
  87. package/lib/packlets/detail/DetailHelpers.js +113 -0
  88. package/lib/packlets/detail/index.d.ts +6 -0
  89. package/lib/packlets/detail/index.js +14 -0
  90. package/lib/packlets/drop-zone/JsonDropZone.d.ts +40 -0
  91. package/lib/packlets/drop-zone/JsonDropZone.js +149 -0
  92. package/lib/packlets/drop-zone/index.d.ts +6 -0
  93. package/lib/packlets/drop-zone/index.js +10 -0
  94. package/lib/packlets/editing/EditFieldHelpers.d.ts +171 -0
  95. package/lib/packlets/editing/EditFieldHelpers.js +144 -0
  96. package/lib/packlets/editing/MultiActionButton.d.ts +45 -0
  97. package/lib/packlets/editing/MultiActionButton.js +109 -0
  98. package/lib/packlets/editing/NumericInput.d.ts +47 -0
  99. package/lib/packlets/editing/NumericInput.js +155 -0
  100. package/lib/packlets/editing/TypeaheadInput.d.ts +46 -0
  101. package/lib/packlets/editing/TypeaheadInput.js +243 -0
  102. package/lib/packlets/editing/index.d.ts +10 -0
  103. package/lib/packlets/editing/index.js +26 -0
  104. package/lib/packlets/editing/useTypeaheadMatch.d.ts +42 -0
  105. package/lib/packlets/editing/useTypeaheadMatch.js +105 -0
  106. package/lib/packlets/keyboard/index.d.ts +7 -0
  107. package/lib/packlets/keyboard/index.js +15 -0
  108. package/lib/packlets/keyboard/registry.d.ts +92 -0
  109. package/lib/packlets/keyboard/registry.js +138 -0
  110. package/lib/packlets/keyboard/useKeyboardShortcuts.d.ts +50 -0
  111. package/lib/packlets/keyboard/useKeyboardShortcuts.js +155 -0
  112. package/lib/packlets/messages/MessagesContext.d.ts +40 -0
  113. package/lib/packlets/messages/MessagesContext.js +113 -0
  114. package/lib/packlets/messages/MessagesLogger.d.ts +50 -0
  115. package/lib/packlets/messages/MessagesLogger.js +107 -0
  116. package/lib/packlets/messages/StatusBar.d.ts +22 -0
  117. package/lib/packlets/messages/StatusBar.js +190 -0
  118. package/lib/packlets/messages/Toast.d.ts +31 -0
  119. package/lib/packlets/messages/Toast.js +105 -0
  120. package/lib/packlets/messages/index.d.ts +11 -0
  121. package/lib/packlets/messages/index.js +24 -0
  122. package/lib/packlets/messages/model.d.ts +59 -0
  123. package/lib/packlets/messages/model.js +61 -0
  124. package/lib/packlets/messages/useLogReporter.d.ts +22 -0
  125. package/lib/packlets/messages/useLogReporter.js +69 -0
  126. package/lib/packlets/modal/ConfirmDialog.d.ts +39 -0
  127. package/lib/packlets/modal/ConfirmDialog.js +114 -0
  128. package/lib/packlets/modal/Modal.d.ts +22 -0
  129. package/lib/packlets/modal/Modal.js +91 -0
  130. package/lib/packlets/modal/index.d.ts +7 -0
  131. package/lib/packlets/modal/index.js +12 -0
  132. package/lib/packlets/print/PrintEnclosure.d.ts +33 -0
  133. package/lib/packlets/print/PrintEnclosure.js +96 -0
  134. package/lib/packlets/print/index.d.ts +7 -0
  135. package/lib/packlets/print/index.js +12 -0
  136. package/lib/packlets/print/openPrintWindow.d.ts +35 -0
  137. package/lib/packlets/print/openPrintWindow.js +118 -0
  138. package/lib/packlets/responsive/ResponsiveProvider.d.ts +35 -0
  139. package/lib/packlets/responsive/ResponsiveProvider.js +93 -0
  140. package/lib/packlets/responsive/index.d.ts +7 -0
  141. package/lib/packlets/responsive/index.js +13 -0
  142. package/lib/packlets/responsive/useResponsiveLayout.d.ts +48 -0
  143. package/lib/packlets/responsive/useResponsiveLayout.js +121 -0
  144. package/lib/packlets/selectors/EntityRow.d.ts +45 -0
  145. package/lib/packlets/selectors/EntityRow.js +315 -0
  146. package/lib/packlets/selectors/PreferredSelector.d.ts +50 -0
  147. package/lib/packlets/selectors/PreferredSelector.js +287 -0
  148. package/lib/packlets/selectors/index.d.ts +5 -0
  149. package/lib/packlets/selectors/index.js +29 -0
  150. package/lib/packlets/sidebar/CollectionSection.d.ts +82 -0
  151. package/lib/packlets/sidebar/CollectionSection.js +143 -0
  152. package/lib/packlets/sidebar/EntityList.d.ts +105 -0
  153. package/lib/packlets/sidebar/EntityList.js +200 -0
  154. package/lib/packlets/sidebar/FilterBar.d.ts +26 -0
  155. package/lib/packlets/sidebar/FilterBar.js +48 -0
  156. package/lib/packlets/sidebar/FilterRow.d.ts +42 -0
  157. package/lib/packlets/sidebar/FilterRow.js +218 -0
  158. package/lib/packlets/sidebar/GroupedEntityList.d.ts +59 -0
  159. package/lib/packlets/sidebar/GroupedEntityList.js +219 -0
  160. package/lib/packlets/sidebar/SearchBar.d.ts +19 -0
  161. package/lib/packlets/sidebar/SearchBar.js +40 -0
  162. package/lib/packlets/sidebar/SidebarLayout.d.ts +28 -0
  163. package/lib/packlets/sidebar/SidebarLayout.js +98 -0
  164. package/lib/packlets/sidebar/index.d.ts +12 -0
  165. package/lib/packlets/sidebar/index.js +22 -0
  166. package/lib/packlets/theme/ThemeProvider.d.ts +68 -0
  167. package/lib/packlets/theme/ThemeProvider.js +178 -0
  168. package/lib/packlets/theme/index.d.ts +6 -0
  169. package/lib/packlets/theme/index.js +11 -0
  170. package/lib/packlets/top-bar/ModeSelector.d.ts +38 -0
  171. package/lib/packlets/top-bar/ModeSelector.js +52 -0
  172. package/lib/packlets/top-bar/TabBar.d.ts +31 -0
  173. package/lib/packlets/top-bar/TabBar.js +43 -0
  174. package/lib/packlets/top-bar/index.d.ts +7 -0
  175. package/lib/packlets/top-bar/index.js +12 -0
  176. package/lib/packlets/url-sync/index.d.ts +6 -0
  177. package/lib/packlets/url-sync/index.js +12 -0
  178. package/lib/packlets/url-sync/useUrlSync.d.ts +75 -0
  179. package/lib/packlets/url-sync/useUrlSync.js +162 -0
  180. package/package.json +82 -0
@@ -0,0 +1,107 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+ /**
23
+ * Generic collection management section for the sidebar.
24
+ *
25
+ * Renders a collapsible list of collections with visibility toggles,
26
+ * status indicators, and action buttons.
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+ import React, { useCallback, useState } from 'react';
31
+ // ============================================================================
32
+ // CollectionRow (internal)
33
+ // ============================================================================
34
+ function CollectionRow(props) {
35
+ var _a, _b, _c, _d;
36
+ const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge } = props;
37
+ 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}` }, '×'))));
55
+ }
56
+ // ============================================================================
57
+ // CollectionSection
58
+ // ============================================================================
59
+ /**
60
+ * Collapsible sidebar section for managing collections.
61
+ *
62
+ * Renders a header with action buttons and a list of collection rows
63
+ * with visibility toggles, status indicators, and delete actions.
64
+ *
65
+ * @public
66
+ */
67
+ export function CollectionSection(props) {
68
+ const { collections, onToggleVisibility, onToggleAllVisibility, onAddDirectory, onCreateCollection, onDeleteCollection, onSetDefaultCollection, onExportCollection, onExportAllAsZip, onImportCollection, onOpenCollectionFromFile, onUnlockCollection, onRenameCollection, onMergeCollection, defaultCollapsed = false } = props;
69
+ 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]);
82
+ const handleToggleCollapse = useCallback(() => {
83
+ setCollapsed((prev) => !prev);
84
+ }, []);
85
+ return (React.createElement("div", { className: "flex flex-col border-t border-border mt-1" },
86
+ React.createElement("div", { className: "flex items-center justify-between px-3 py-1.5" },
87
+ 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'),
89
+ "Collections",
90
+ React.createElement("span", { className: "text-muted normal-case font-normal" },
91
+ "(",
92
+ collections.length,
93
+ ")")),
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
+ React.createElement("div", { className: "flex items-center gap-1" },
96
+ 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')),
99
+ 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 }))))))));
106
+ }
107
+ //# sourceMappingURL=CollectionSection.js.map
@@ -0,0 +1,164 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+ import React, { useCallback, useEffect, useRef } from 'react';
23
+ import { useResponsive } from '../responsive';
24
+ // ============================================================================
25
+ // EntityList Component
26
+ // ============================================================================
27
+ /**
28
+ * Generic entity list component for the sidebar.
29
+ *
30
+ * Renders a scrollable list of entities with:
31
+ * - Primary label + optional sublabel
32
+ * - Optional status indicator
33
+ * - Selection highlighting
34
+ * - Empty state with optional CTA
35
+ *
36
+ * @public
37
+ */
38
+ export function EntityList(props) {
39
+ const { entities, descriptor, selectedId, onSelect, onDrill, compareMode, checkedIds, onCheckedChange, emptyState, header, onDelete, canDelete } = props;
40
+ const listRef = useRef(null);
41
+ const { layoutMode } = useResponsive();
42
+ const isMobile = layoutMode === 'mobile';
43
+ // Scroll the selected item into view when selection changes
44
+ useEffect(() => {
45
+ if (selectedId && listRef.current) {
46
+ const selectedButton = listRef.current.querySelector(`[data-entity-id="${selectedId}"]`);
47
+ if (selectedButton) {
48
+ selectedButton.scrollIntoView({ block: 'nearest' });
49
+ }
50
+ }
51
+ }, [selectedId]);
52
+ // Find the index of the currently selected entity
53
+ const selectedIndex = entities.findIndex((e) => descriptor.getId(e) === selectedId);
54
+ const handleKeyDown = useCallback((e) => {
55
+ switch (e.key) {
56
+ case 'ArrowDown': {
57
+ e.preventDefault();
58
+ const nextIndex = selectedIndex < entities.length - 1 ? selectedIndex + 1 : 0;
59
+ onSelect(descriptor.getId(entities[nextIndex]));
60
+ break;
61
+ }
62
+ case 'ArrowUp': {
63
+ e.preventDefault();
64
+ const prevIndex = selectedIndex > 0 ? selectedIndex - 1 : entities.length - 1;
65
+ onSelect(descriptor.getId(entities[prevIndex]));
66
+ break;
67
+ }
68
+ case 'Enter':
69
+ case 'ArrowRight': {
70
+ if (selectedId !== undefined && onDrill) {
71
+ e.preventDefault();
72
+ onDrill();
73
+ }
74
+ break;
75
+ }
76
+ default:
77
+ break;
78
+ }
79
+ }, [entities, descriptor, selectedId, selectedIndex, onSelect, onDrill]);
80
+ if (entities.length === 0 && emptyState) {
81
+ return React.createElement(EmptyState, { config: emptyState });
82
+ }
83
+ return (React.createElement("div", { className: "flex flex-col flex-1 overflow-hidden", onKeyDown: handleKeyDown },
84
+ header !== undefined ? (React.createElement("div", { className: "px-3 py-1.5 text-xs text-muted border-b border-border-subtle" }, header)) : entities.length > 0 ? (React.createElement("div", { className: "flex items-center justify-between px-3 py-1.5 border-b border-border-subtle" },
85
+ React.createElement("span", { className: "text-xs text-muted" },
86
+ entities.length,
87
+ " item",
88
+ entities.length !== 1 ? 's' : '',
89
+ compareMode && props.compareCount !== undefined && props.compareCount > 0 && (React.createElement("span", { className: "ml-1.5 text-brand-accent" },
90
+ "\u00B7 ",
91
+ props.compareCount,
92
+ " selected"))),
93
+ React.createElement("div", { className: "flex items-center gap-1" },
94
+ compareMode &&
95
+ props.onStartComparison &&
96
+ props.compareCount !== undefined &&
97
+ props.compareCount >= 2 && (React.createElement("button", { onClick: (e) => {
98
+ var _a;
99
+ e.stopPropagation();
100
+ (_a = props.onStartComparison) === null || _a === void 0 ? void 0 : _a.call(props);
101
+ }, className: "px-2 py-0.5 text-[11px] rounded border transition-colors bg-brand-primary text-white border-brand-primary hover:bg-brand-primary/90" }, "Compare Now")),
102
+ props.onToggleCompare && (React.createElement("button", { onClick: (e) => {
103
+ var _a;
104
+ e.stopPropagation();
105
+ (_a = props.onToggleCompare) === null || _a === void 0 ? void 0 : _a.call(props);
106
+ }, className: `px-2 py-0.5 text-[11px] rounded border transition-colors ${compareMode
107
+ ? 'bg-brand-accent text-white border-brand-accent'
108
+ : 'bg-surface text-muted border-border hover:border-brand-accent hover:text-brand-accent'}` }, compareMode ? 'Cancel' : 'Compare'))))) : null,
109
+ React.createElement("div", { ref: listRef, className: "flex-1 overflow-y-auto" }, entities.map((entity) => {
110
+ var _a, _b;
111
+ const id = descriptor.getId(entity);
112
+ const label = descriptor.getLabel(entity);
113
+ const sublabel = (_a = descriptor.getSublabel) === null || _a === void 0 ? void 0 : _a.call(descriptor, entity);
114
+ const status = (_b = descriptor.getStatus) === null || _b === void 0 ? void 0 : _b.call(descriptor, entity);
115
+ const isSelected = id === selectedId;
116
+ const isChecked = compareMode === true && checkedIds !== undefined && checkedIds.has(id);
117
+ return (React.createElement("div", { key: id, className: `group flex items-center gap-2 w-full border-b border-border-subtle transition-colors ${isChecked
118
+ ? 'bg-selected border-l-2 border-l-selected-border'
119
+ : isSelected && !compareMode
120
+ ? 'bg-selected border-l-2 border-l-selected-border'
121
+ : 'hover:bg-hover border-l-2 border-l-transparent'}` },
122
+ React.createElement("button", { "data-entity-id": id, onClick: () => {
123
+ if (compareMode && onCheckedChange) {
124
+ onCheckedChange(id);
125
+ }
126
+ else if (isSelected && onDrill) {
127
+ onDrill();
128
+ }
129
+ else {
130
+ onSelect(id);
131
+ }
132
+ }, className: `flex items-center gap-2 flex-1 min-w-0 px-3 text-left ${isMobile ? 'py-3' : 'py-2'}` },
133
+ compareMode && (React.createElement("span", { className: `flex items-center justify-center w-4 h-4 rounded border shrink-0 transition-colors ${isChecked
134
+ ? 'bg-brand-accent border-brand-accent text-white'
135
+ : 'border-border bg-surface'}` }, isChecked && (React.createElement("svg", { className: "w-3 h-3", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 3 },
136
+ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M5 13l4 4L19 7" }))))),
137
+ React.createElement("div", { className: "flex-1 min-w-0" },
138
+ React.createElement("div", { className: `text-sm truncate ${(isSelected && !compareMode) || isChecked
139
+ ? 'font-medium text-brand-primary'
140
+ : 'text-primary'}` }, label),
141
+ sublabel && React.createElement("div", { className: "text-xs text-muted truncate mt-0.5" }, sublabel)),
142
+ status && (React.createElement("span", { className: "flex items-center gap-1 shrink-0 mt-0.5" },
143
+ React.createElement("span", { className: `w-2 h-2 rounded-full ${status.colorClass}` }),
144
+ React.createElement("span", { className: "text-xs text-muted" }, status.label)))),
145
+ onDelete && !compareMode && (!canDelete || canDelete(id)) && (React.createElement("button", { onClick: (e) => {
146
+ e.stopPropagation();
147
+ onDelete(id);
148
+ }, className: `shrink-0 mr-1 flex items-center justify-center text-muted hover:text-status-error-text transition-opacity rounded ${isMobile
149
+ ? 'w-11 h-11 opacity-100'
150
+ : 'w-6 h-6 opacity-0 group-hover:opacity-100 focus:opacity-100'}`, title: `Delete ${label}`, "aria-label": `Delete ${label}` },
151
+ React.createElement("svg", { className: "w-3.5 h-3.5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2 },
152
+ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" }))))));
153
+ }))));
154
+ }
155
+ // ============================================================================
156
+ // Empty State
157
+ // ============================================================================
158
+ function EmptyState({ config }) {
159
+ return (React.createElement("div", { className: "flex flex-col items-center justify-center flex-1 p-6 text-center" },
160
+ React.createElement("h3", { className: "text-sm font-medium text-secondary mb-1" }, config.title),
161
+ React.createElement("p", { className: "text-xs text-muted mb-4" }, config.description),
162
+ config.action && (React.createElement("button", { onClick: config.action.onClick, className: "px-3 py-1.5 text-xs font-medium text-white bg-brand-accent rounded-md hover:bg-brand-primary transition-colors" }, config.action.label))));
163
+ }
164
+ //# sourceMappingURL=EntityList.js.map
@@ -0,0 +1,42 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+ import React from 'react';
23
+ import { SearchBar } from './SearchBar';
24
+ /**
25
+ * Composite sidebar filter bar: search input + filter rows + clear-all.
26
+ *
27
+ * Renders the search bar at the top, filter rows in the middle,
28
+ * and a "Clear all" button when any filters are active.
29
+ *
30
+ * @public
31
+ */
32
+ export function FilterBar(props) {
33
+ const { search, activeFilterCount, onClearAll, children } = props;
34
+ const hasActiveFilters = activeFilterCount > 0 || search.value.length > 0;
35
+ return (React.createElement("div", { className: "flex flex-col" },
36
+ React.createElement(SearchBar, Object.assign({}, search)),
37
+ React.createElement("div", { className: "flex items-center justify-between px-3 py-1" },
38
+ React.createElement("span", { className: "text-xs font-medium text-muted uppercase tracking-wider" }, "Filters"),
39
+ hasActiveFilters && (React.createElement("button", { onClick: onClearAll, className: "text-xs text-brand-accent hover:text-brand-primary" }, "Clear all"))),
40
+ React.createElement("div", { className: "flex flex-col" }, children)));
41
+ }
42
+ //# sourceMappingURL=FilterBar.js.map
@@ -0,0 +1,182 @@
1
+ /*
2
+ * Copyright (c) 2026 Erik Fortune
3
+ *
4
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ * of this software and associated documentation files (the "Software"), to deal
6
+ * in the Software without restriction, including without limitation the rights
7
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ * copies of the Software, and to permit persons to whom the Software is
9
+ * furnished to do so, subject to the following conditions:
10
+ *
11
+ * The above copyright notice and this permission notice shall be included in all
12
+ * copies or substantial portions of the Software.
13
+ *
14
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ * SOFTWARE.
21
+ */
22
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import { useResponsive } from '../responsive';
24
+ // ============================================================================
25
+ // FilterRow Component
26
+ // ============================================================================
27
+ /** Minimum number of options before showing the search-within-filter input */
28
+ const SEARCH_THRESHOLD = 8;
29
+ /**
30
+ * A compact filter row with a right-side flyout overlay for selecting filter values.
31
+ *
32
+ * Collapsed: shows the label, status summary text, and a `\u203A` chevron.
33
+ * Expanded: a flyout panel slides out to the right of the sidebar, overlaying the main pane.
34
+ *
35
+ * @public
36
+ */
37
+ export function FilterRow(props) {
38
+ const { label, options, selected, onSelectionChange, multiple = true, isEqual = defaultIsEqual } = props;
39
+ const { layoutMode } = useResponsive();
40
+ const isMobile = layoutMode === 'mobile';
41
+ const [open, setOpen] = useState(false);
42
+ const [flyoutSearch, setFlyoutSearch] = useState('');
43
+ const rowRef = useRef(null);
44
+ const flyoutRef = useRef(null);
45
+ // Close flyout on outside click (check both row and flyout)
46
+ useEffect(() => {
47
+ if (!open) {
48
+ return undefined;
49
+ }
50
+ const handleClick = (e) => {
51
+ const target = e.target;
52
+ if (rowRef.current &&
53
+ !rowRef.current.contains(target) &&
54
+ flyoutRef.current &&
55
+ !flyoutRef.current.contains(target)) {
56
+ setOpen(false);
57
+ setFlyoutSearch('');
58
+ }
59
+ };
60
+ document.addEventListener('mousedown', handleClick);
61
+ return () => document.removeEventListener('mousedown', handleClick);
62
+ }, [open]);
63
+ // Close flyout on Escape
64
+ useEffect(() => {
65
+ if (!open) {
66
+ return undefined;
67
+ }
68
+ const handleKey = (e) => {
69
+ if (e.key === 'Escape') {
70
+ setOpen(false);
71
+ setFlyoutSearch('');
72
+ }
73
+ };
74
+ document.addEventListener('keydown', handleKey);
75
+ return () => document.removeEventListener('keydown', handleKey);
76
+ }, [open]);
77
+ const isSelected = useCallback((value) => selected.some((s) => isEqual(s, value)), [selected, isEqual]);
78
+ const toggleValue = useCallback((value) => {
79
+ if (multiple) {
80
+ if (isSelected(value)) {
81
+ onSelectionChange(selected.filter((s) => !isEqual(s, value)));
82
+ }
83
+ else {
84
+ onSelectionChange([...selected, value]);
85
+ }
86
+ }
87
+ else {
88
+ // Single-select: toggle off if already selected, otherwise select
89
+ if (isSelected(value)) {
90
+ onSelectionChange([]);
91
+ }
92
+ else {
93
+ onSelectionChange([value]);
94
+ }
95
+ }
96
+ }, [multiple, selected, onSelectionChange, isSelected, isEqual]);
97
+ const clearSelection = useCallback((e) => {
98
+ e.stopPropagation();
99
+ onSelectionChange([]);
100
+ }, [onSelectionChange]);
101
+ const activeCount = selected.length;
102
+ // Status summary text for the collapsed row
103
+ const statusText = useMemo(() => {
104
+ if (activeCount === 0) {
105
+ return 'All';
106
+ }
107
+ if (activeCount === 1) {
108
+ // Show the label of the single selected option
109
+ const sel = selected[0];
110
+ const match = options.find((o) => isEqual(o.value, sel));
111
+ return match ? match.label : '1 selected';
112
+ }
113
+ return `${activeCount} selected`;
114
+ }, [activeCount, selected, options, isEqual]);
115
+ // Filter options by flyout search
116
+ const filteredOptions = useMemo(() => {
117
+ const q = flyoutSearch.trim().toLowerCase();
118
+ if (q.length === 0) {
119
+ return options;
120
+ }
121
+ return options.filter((o) => o.label.toLowerCase().includes(q));
122
+ }, [options, flyoutSearch]);
123
+ // Compute flyout position: anchored to the right edge of the sidebar row, aligned vertically
124
+ const [flyoutStyle, setFlyoutStyle] = useState({});
125
+ useEffect(() => {
126
+ if (!open || !rowRef.current) {
127
+ return;
128
+ }
129
+ const rect = rowRef.current.getBoundingClientRect();
130
+ // Find the sidebar container (the <aside> ancestor)
131
+ let sidebar = rowRef.current;
132
+ while (sidebar && sidebar.tagName !== 'ASIDE') {
133
+ sidebar = sidebar.parentElement;
134
+ }
135
+ const rightEdge = sidebar ? sidebar.getBoundingClientRect().right : rect.right;
136
+ // Flyout appears at the right edge of the sidebar, vertically aligned with the row
137
+ const maxHeight = Math.min(400, window.innerHeight - rect.top - 8);
138
+ setFlyoutStyle({
139
+ position: 'fixed',
140
+ top: rect.top,
141
+ left: rightEdge,
142
+ width: 280,
143
+ maxHeight,
144
+ zIndex: 50
145
+ });
146
+ }, [open]);
147
+ const handleToggle = useCallback(() => {
148
+ setOpen((prev) => {
149
+ if (prev) {
150
+ setFlyoutSearch('');
151
+ }
152
+ return !prev;
153
+ });
154
+ }, []);
155
+ return (React.createElement("div", { ref: rowRef },
156
+ React.createElement("button", { onClick: handleToggle, className: `flex items-center justify-between w-full px-3 text-sm hover:bg-hover transition-colors ${isMobile ? 'py-3' : 'py-1.5'} ${activeCount > 0 ? 'text-brand-primary font-medium' : 'text-secondary'}` },
157
+ React.createElement("span", { className: "truncate" }, label),
158
+ React.createElement("span", { className: "flex items-center gap-1.5 shrink-0 ml-2" },
159
+ activeCount > 0 && (React.createElement("span", { onClick: clearSelection, className: "text-muted hover:text-secondary cursor-pointer text-xs", role: "button", "aria-label": `Clear ${label} filter` }, '\u00D7')),
160
+ React.createElement("span", { className: `text-xs ${activeCount > 0 ? 'text-brand-accent' : 'text-muted'}` }, statusText),
161
+ React.createElement("span", { className: "text-muted text-xs" }, '\u203A'))),
162
+ open && (React.createElement("div", { ref: flyoutRef, className: "bg-surface border border-border rounded-r-lg shadow-xl overflow-hidden flex flex-col", style: flyoutStyle },
163
+ React.createElement("div", { className: "flex items-center justify-between px-3 py-2 border-b border-border-subtle bg-surface-alt" },
164
+ React.createElement("span", { className: "text-xs font-medium text-secondary" }, label),
165
+ activeCount > 0 && (React.createElement("button", { onClick: () => onSelectionChange([]), className: "text-xs text-brand-accent hover:text-brand-primary" }, "Clear"))),
166
+ options.length >= SEARCH_THRESHOLD && (React.createElement("div", { className: "px-3 py-1.5 border-b border-border-subtle" },
167
+ React.createElement("input", { type: "text", value: flyoutSearch, onChange: (e) => setFlyoutSearch(e.target.value), placeholder: `Search ${label.toLowerCase()}...`, className: "w-full px-2 py-1 text-xs border border-border rounded bg-surface text-primary focus:outline-none focus:border-focus-ring", autoFocus: true }))),
168
+ React.createElement("div", { className: "flex-1 overflow-y-auto" }, filteredOptions.length === 0 ? (React.createElement("div", { className: "px-3 py-2 text-xs text-muted" }, options.length === 0 ? 'No options available' : 'No matches')) : (filteredOptions.map((option, idx) => {
169
+ const checked = isSelected(option.value);
170
+ return (React.createElement("button", { key: idx, onClick: () => toggleValue(option.value), className: `flex items-center gap-2 w-full px-3 text-sm text-left hover:bg-hover transition-colors ${isMobile ? 'py-2.5' : 'py-1.5'} ${checked ? 'text-brand-primary font-medium' : 'text-secondary'}` },
171
+ React.createElement("span", { className: `inline-flex items-center justify-center w-4 h-4 border rounded ${multiple ? 'rounded' : 'rounded-full'} ${checked ? 'bg-brand-accent border-brand-accent text-white' : 'border-border'}` }, checked && React.createElement("span", { className: "text-[10px]" }, '\u2713')),
172
+ React.createElement("span", { className: "flex-1 truncate" }, option.label),
173
+ option.count !== undefined && React.createElement("span", { className: "text-xs text-muted" }, option.count)));
174
+ })))))));
175
+ }
176
+ // ============================================================================
177
+ // Helpers
178
+ // ============================================================================
179
+ function defaultIsEqual(a, b) {
180
+ return a === b;
181
+ }
182
+ //# sourceMappingURL=FilterRow.js.map