@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,73 @@
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
+ * Multi-action button component with dropdown for alternative actions.
24
+ * @packageDocumentation
25
+ */
26
+ import React, { useState, useRef, useEffect } from 'react';
27
+ import { ChevronDownIcon } from '@heroicons/react/20/solid';
28
+ // ============================================================================
29
+ // Component
30
+ // ============================================================================
31
+ /**
32
+ * Multi-action button with dropdown for alternative actions.
33
+ * Similar to Git commit button with "Commit", "Commit & Push", etc.
34
+ *
35
+ * @public
36
+ */
37
+ export function MultiActionButton(props) {
38
+ const { primaryAction, alternativeActions, variant = 'primary', disabled = false, dropdownDisabled, className = '' } = props;
39
+ const isDropdownDisabled = dropdownDisabled !== null && dropdownDisabled !== void 0 ? dropdownDisabled : disabled;
40
+ const [isOpen, setIsOpen] = useState(false);
41
+ const dropdownRef = useRef(null);
42
+ // Close dropdown when clicking outside
43
+ useEffect(() => {
44
+ const handleClickOutside = (event) => {
45
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
46
+ setIsOpen(false);
47
+ }
48
+ };
49
+ if (isOpen) {
50
+ document.addEventListener('mousedown', handleClickOutside);
51
+ return () => document.removeEventListener('mousedown', handleClickOutside);
52
+ }
53
+ }, [isOpen]);
54
+ const variantClasses = {
55
+ primary: 'bg-brand-primary hover:bg-brand-primary/90 text-white',
56
+ default: 'bg-surface-raised hover:bg-surface-raised text-secondary',
57
+ danger: 'bg-status-error-btn hover:bg-status-error-btn-hover text-white'
58
+ };
59
+ const baseClasses = 'inline-flex items-center text-xs font-medium rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
60
+ return (React.createElement("div", { className: `relative inline-flex ${className}`, ref: dropdownRef },
61
+ React.createElement("button", { type: "button", onClick: primaryAction.onSelect, disabled: disabled, className: `${baseClasses} ${variantClasses[variant]} px-2.5 py-1 ${alternativeActions.length > 0 ? 'rounded-r-none' : 'rounded'}` },
62
+ primaryAction.icon,
63
+ React.createElement("span", null, primaryAction.label)),
64
+ alternativeActions.length > 0 && (React.createElement("button", { type: "button", onClick: () => setIsOpen(!isOpen), disabled: isDropdownDisabled, className: `${baseClasses} ${variantClasses[variant]} px-1 py-1 rounded-l-none border-l border-white/20` },
65
+ React.createElement(ChevronDownIcon, { className: "h-3.5 w-3.5" }))),
66
+ isOpen && alternativeActions.length > 0 && (React.createElement("div", { className: "absolute top-full right-0 mt-1 w-48 bg-surface rounded-md shadow-lg border border-border py-1 z-50" }, alternativeActions.map((action) => (React.createElement("button", { key: action.id, type: "button", onClick: () => {
67
+ action.onSelect();
68
+ setIsOpen(false);
69
+ }, className: "w-full text-left px-3 py-2 text-xs text-secondary hover:bg-surface-raised flex items-center gap-2" },
70
+ action.icon,
71
+ React.createElement("span", null, action.label))))))));
72
+ }
73
+ //# sourceMappingURL=MultiActionButton.js.map
@@ -0,0 +1,119 @@
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
+ * Numeric input with select-all-on-focus and empty-string support.
24
+ *
25
+ * Uses `type="text"` with `inputMode="decimal"` to avoid native number-input
26
+ * quirks (spinner buttons, empty-string rejection). Manages a string internally
27
+ * but exposes `number | undefined` to callers.
28
+ *
29
+ * @packageDocumentation
30
+ */
31
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
32
+ // ============================================================================
33
+ // Constants
34
+ // ============================================================================
35
+ const NUMERIC_PATTERN = /^-?\d*\.?\d*$/;
36
+ const DEFAULT_CLASS = 'text-sm border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-focus-ring focus:border-focus-ring';
37
+ // ============================================================================
38
+ // Helpers
39
+ // ============================================================================
40
+ function formatValue(value) {
41
+ return value !== undefined ? String(value) : '';
42
+ }
43
+ function clamp(value, min, max) {
44
+ let result = value;
45
+ if (min !== undefined && result < min) {
46
+ result = min;
47
+ }
48
+ if (max !== undefined && result > max) {
49
+ result = max;
50
+ }
51
+ return result;
52
+ }
53
+ // ============================================================================
54
+ // Component
55
+ // ============================================================================
56
+ /**
57
+ * Numeric input that selects all on focus and allows empty values.
58
+ *
59
+ * Internally manages a string so the user can freely type, backspace to empty,
60
+ * and replace the entire value. On blur, the string is parsed to a number
61
+ * (clamped to min/max) and reported to the parent via `onChange`.
62
+ *
63
+ * @public
64
+ */
65
+ export function NumericInput(props) {
66
+ const { value, onChange, label, min, max, step, className, placeholder, autoFocus, disabled } = props;
67
+ const [displayValue, setDisplayValue] = useState(() => formatValue(value));
68
+ const focusedRef = useRef(false);
69
+ // Sync display when value prop changes externally (but not while focused)
70
+ useEffect(() => {
71
+ if (!focusedRef.current) {
72
+ setDisplayValue(formatValue(value));
73
+ }
74
+ }, [value]);
75
+ const handleFocus = useCallback((e) => {
76
+ focusedRef.current = true;
77
+ e.target.select();
78
+ }, []);
79
+ const handleChange = useCallback((e) => {
80
+ const raw = e.target.value;
81
+ if (raw === '' || NUMERIC_PATTERN.test(raw)) {
82
+ setDisplayValue(raw);
83
+ }
84
+ }, []);
85
+ const handleBlur = useCallback(() => {
86
+ focusedRef.current = false;
87
+ const trimmed = displayValue.trim();
88
+ if (trimmed === '' || trimmed === '-' || trimmed === '.') {
89
+ setDisplayValue('');
90
+ onChange(undefined);
91
+ return;
92
+ }
93
+ const parsed = parseFloat(trimmed);
94
+ if (isNaN(parsed)) {
95
+ setDisplayValue('');
96
+ onChange(undefined);
97
+ return;
98
+ }
99
+ const clamped = clamp(parsed, min, max);
100
+ setDisplayValue(String(clamped));
101
+ onChange(clamped);
102
+ }, [displayValue, onChange, min, max]);
103
+ const handleKeyDown = useCallback((e) => {
104
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') {
105
+ return;
106
+ }
107
+ e.preventDefault();
108
+ const effectiveStep = step !== null && step !== void 0 ? step : 1;
109
+ const current = parseFloat(displayValue) || 0;
110
+ const next = e.key === 'ArrowUp' ? current + effectiveStep : current - effectiveStep;
111
+ const clamped = clamp(next, min, max);
112
+ // Round to avoid floating-point drift
113
+ const rounded = parseFloat(clamped.toFixed(10));
114
+ setDisplayValue(String(rounded));
115
+ onChange(rounded);
116
+ }, [displayValue, onChange, min, max, step]);
117
+ return (React.createElement("input", { type: "text", inputMode: "decimal", value: displayValue, onChange: handleChange, onFocus: handleFocus, onBlur: handleBlur, onKeyDown: handleKeyDown, className: className !== null && className !== void 0 ? className : DEFAULT_CLASS, "aria-label": label, placeholder: placeholder, autoFocus: autoFocus, disabled: disabled }));
118
+ }
119
+ //# sourceMappingURL=NumericInput.js.map
@@ -0,0 +1,207 @@
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
+ * TypeaheadInput — text input with custom autocomplete dropdown supporting
24
+ * tiered suggestions and built-in blur resolution.
25
+ * @packageDocumentation
26
+ */
27
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
28
+ import { useTypeaheadMatch } from './useTypeaheadMatch';
29
+ // ============================================================================
30
+ // Component
31
+ // ============================================================================
32
+ /**
33
+ * Text input with a custom autocomplete dropdown supporting tiered suggestions.
34
+ *
35
+ * Priority suggestions (e.g. recipe alternates) appear first, visually separated
36
+ * from the full catalog. On blur, applies resolution logic: exact match → auto-select,
37
+ * single partial match → auto-select, else fires `onUnresolved`.
38
+ *
39
+ * @public
40
+ */
41
+ export function TypeaheadInput(props) {
42
+ const { value, onChange, suggestions, prioritySuggestions, onSelect, onUnresolved, placeholder, className, disabled, maxHeight = 240, autoFocus } = props;
43
+ const [isOpen, setIsOpen] = useState(false);
44
+ const [focusIndex, setFocusIndex] = useState(-1);
45
+ const inputRef = useRef(null);
46
+ const dropdownRef = useRef(null);
47
+ const itemRefs = useRef([]);
48
+ const mouseDownOnDropdownRef = useRef(false);
49
+ const matcher = useTypeaheadMatch(suggestions, prioritySuggestions);
50
+ // ---- Filtered suggestions ----
51
+ const filtered = useMemo(() => matcher.filterSuggestions(value), [matcher, value]);
52
+ const flatItems = useMemo(() => [...filtered.priority, ...filtered.catalog], [filtered.priority, filtered.catalog]);
53
+ const hasPriorityItems = filtered.priority.length > 0;
54
+ const hasCatalogItems = filtered.catalog.length > 0;
55
+ const hasItems = flatItems.length > 0;
56
+ // Reset focus index when suggestions change
57
+ useEffect(() => {
58
+ setFocusIndex(-1);
59
+ }, [value]);
60
+ // Scroll focused item into view
61
+ useEffect(() => {
62
+ var _a;
63
+ if (isOpen && focusIndex >= 0 && itemRefs.current[focusIndex]) {
64
+ (_a = itemRefs.current[focusIndex]) === null || _a === void 0 ? void 0 : _a.scrollIntoView({ block: 'nearest' });
65
+ }
66
+ }, [isOpen, focusIndex]);
67
+ // Close dropdown on outside click
68
+ useEffect(() => {
69
+ if (!isOpen)
70
+ return;
71
+ const handler = (e) => {
72
+ if (dropdownRef.current &&
73
+ !dropdownRef.current.contains(e.target) &&
74
+ inputRef.current &&
75
+ !inputRef.current.contains(e.target)) {
76
+ setIsOpen(false);
77
+ }
78
+ };
79
+ document.addEventListener('mousedown', handler);
80
+ return () => {
81
+ document.removeEventListener('mousedown', handler);
82
+ };
83
+ }, [isOpen]);
84
+ // ---- Selection ----
85
+ const handleSelect = useCallback((item) => {
86
+ onSelect(item);
87
+ setIsOpen(false);
88
+ setFocusIndex(-1);
89
+ }, [onSelect]);
90
+ const handleResolve = useCallback((text) => {
91
+ const trimmed = text.trim();
92
+ if (!trimmed) {
93
+ setIsOpen(false);
94
+ return;
95
+ }
96
+ const match = matcher.resolveOnBlur(trimmed);
97
+ if (match) {
98
+ handleSelect(match);
99
+ }
100
+ else if (onUnresolved) {
101
+ onUnresolved(trimmed);
102
+ setIsOpen(false);
103
+ }
104
+ else {
105
+ setIsOpen(false);
106
+ }
107
+ }, [matcher, handleSelect, onUnresolved]);
108
+ // ---- Input handlers ----
109
+ const handleChange = useCallback((e) => {
110
+ onChange(e.target.value);
111
+ if (!isOpen) {
112
+ setIsOpen(true);
113
+ }
114
+ }, [onChange, isOpen]);
115
+ const handleFocus = useCallback(() => {
116
+ var _a;
117
+ (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.select();
118
+ if (hasItems) {
119
+ setIsOpen(true);
120
+ }
121
+ }, [hasItems]);
122
+ const handleBlur = useCallback(() => {
123
+ // Guard: don't resolve if user is clicking an item in the dropdown
124
+ if (mouseDownOnDropdownRef.current) {
125
+ mouseDownOnDropdownRef.current = false;
126
+ return;
127
+ }
128
+ handleResolve(value);
129
+ }, [value, handleResolve]);
130
+ const handleKeyDown = useCallback((e) => {
131
+ if (!isOpen) {
132
+ if (e.key === 'ArrowDown' && hasItems) {
133
+ e.preventDefault();
134
+ setIsOpen(true);
135
+ setFocusIndex(0);
136
+ }
137
+ return;
138
+ }
139
+ switch (e.key) {
140
+ case 'Escape': {
141
+ e.preventDefault();
142
+ e.stopPropagation();
143
+ setIsOpen(false);
144
+ setFocusIndex(-1);
145
+ break;
146
+ }
147
+ case 'ArrowDown': {
148
+ e.preventDefault();
149
+ setFocusIndex((prev) => (prev < flatItems.length - 1 ? prev + 1 : 0));
150
+ break;
151
+ }
152
+ case 'ArrowUp': {
153
+ e.preventDefault();
154
+ setFocusIndex((prev) => (prev > 0 ? prev - 1 : flatItems.length - 1));
155
+ break;
156
+ }
157
+ case 'Enter': {
158
+ e.preventDefault();
159
+ if (focusIndex >= 0 && focusIndex < flatItems.length) {
160
+ handleSelect(flatItems[focusIndex]);
161
+ }
162
+ else {
163
+ handleResolve(value);
164
+ }
165
+ break;
166
+ }
167
+ case 'Tab': {
168
+ // Allow default tab behavior but resolve first
169
+ handleResolve(value);
170
+ break;
171
+ }
172
+ default:
173
+ break;
174
+ }
175
+ }, [isOpen, hasItems, flatItems, focusIndex, handleSelect, handleResolve, value]);
176
+ // ---- Dropdown mouse guard ----
177
+ const handleDropdownMouseDown = useCallback(() => {
178
+ mouseDownOnDropdownRef.current = true;
179
+ }, []);
180
+ // ---- Render ----
181
+ const priorityEndIndex = filtered.priority.length;
182
+ return (React.createElement("div", { className: "relative" },
183
+ React.createElement("input", { ref: inputRef, type: "text", value: value, onChange: handleChange, onFocus: handleFocus, onBlur: handleBlur, onKeyDown: handleKeyDown, placeholder: placeholder, disabled: disabled, autoFocus: autoFocus, className: className !== null && className !== void 0 ? className : 'w-full px-2 py-1 text-sm border border-border rounded focus:outline-none focus:ring-1 focus:ring-focus-ring focus:border-focus-ring', role: "combobox", "aria-expanded": isOpen, "aria-autocomplete": "list", autoComplete: "off" }),
184
+ isOpen && hasItems && (React.createElement("div", { ref: dropdownRef, className: "absolute z-50 mt-1 w-full bg-surface border border-border rounded-lg shadow-lg overflow-hidden", style: { maxHeight }, role: "listbox", onMouseDown: handleDropdownMouseDown },
185
+ React.createElement("div", { className: "overflow-y-auto", style: { maxHeight } },
186
+ hasPriorityItems &&
187
+ filtered.priority.map((item, index) => {
188
+ const isFocused = index === focusIndex;
189
+ return (React.createElement("button", { key: item.id, ref: (el) => {
190
+ itemRefs.current[index] = el;
191
+ }, role: "option", "aria-selected": isFocused, onClick: () => handleSelect(item), className: `flex items-center w-full px-2.5 py-1.5 text-left text-sm transition-colors border-l-2 ${isFocused
192
+ ? 'bg-surface-alt text-primary border-brand-primary/50'
193
+ : 'text-secondary hover:bg-hover border-brand-primary/20'}` },
194
+ React.createElement("span", { className: "flex-1 min-w-0 truncate" }, item.name)));
195
+ }),
196
+ hasPriorityItems && hasCatalogItems && React.createElement("div", { className: "border-t border-border my-0.5" }),
197
+ hasCatalogItems &&
198
+ filtered.catalog.map((item, catalogIndex) => {
199
+ const flatIndex = priorityEndIndex + catalogIndex;
200
+ const isFocused = flatIndex === focusIndex;
201
+ return (React.createElement("button", { key: item.id, ref: (el) => {
202
+ itemRefs.current[flatIndex] = el;
203
+ }, role: "option", "aria-selected": isFocused, onClick: () => handleSelect(item), className: `flex items-center w-full px-2.5 py-1.5 text-left text-sm transition-colors ${isFocused ? 'bg-surface-alt text-primary' : 'text-secondary hover:bg-hover'}` },
204
+ React.createElement("span", { className: "flex-1 min-w-0 truncate" }, item.name)));
205
+ }))))));
206
+ }
207
+ //# sourceMappingURL=TypeaheadInput.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Editing packlet - generic form field primitives for entity editors.
3
+ * @packageDocumentation
4
+ */
5
+ export { EditField, EditSection, TextInput, OptionalTextInput, TextAreaInput, NumberInput, SelectInput, TagsInput, CheckboxInput } from './EditFieldHelpers';
6
+ export { MultiActionButton } from './MultiActionButton';
7
+ export { NumericInput } from './NumericInput';
8
+ export { TypeaheadInput } from './TypeaheadInput';
9
+ export { useTypeaheadMatch } from './useTypeaheadMatch';
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,102 @@
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
+ * Reusable typeahead matching hook with tiered priority support.
24
+ * @packageDocumentation
25
+ */
26
+ import { useCallback, useMemo } from 'react';
27
+ // ============================================================================
28
+ // Hook
29
+ // ============================================================================
30
+ /**
31
+ * Hook that provides typeahead matching and filtering with tiered priority support.
32
+ *
33
+ * `findExactMatch` matches by exact id or case-insensitive name (checks priority first).
34
+ * `resolveOnBlur` tries exact match, then single partial match (priority first).
35
+ * `filterSuggestions` returns suggestions split by tier, filtered by substring match.
36
+ *
37
+ * @param suggestions - The full catalog of suggestions
38
+ * @param prioritySuggestions - Optional priority suggestions shown first (e.g. recipe alternates)
39
+ * @returns Match and filter functions
40
+ * @public
41
+ */
42
+ export function useTypeaheadMatch(suggestions, prioritySuggestions) {
43
+ // Pre-compute priority IDs for efficient catalog deduplication
44
+ const priorityIds = useMemo(() => { var _a; return new Set((_a = prioritySuggestions === null || prioritySuggestions === void 0 ? void 0 : prioritySuggestions.map((s) => s.id)) !== null && _a !== void 0 ? _a : []); }, [prioritySuggestions]);
45
+ const findExactMatch = useCallback((input) => {
46
+ const trimmed = input.trim();
47
+ if (!trimmed)
48
+ return undefined;
49
+ const lower = trimmed.toLowerCase();
50
+ if (prioritySuggestions) {
51
+ const match = prioritySuggestions.find((s) => s.id === trimmed || s.name.toLowerCase() === lower);
52
+ if (match)
53
+ return match;
54
+ }
55
+ return suggestions.find((s) => s.id === trimmed || s.name.toLowerCase() === lower);
56
+ }, [suggestions, prioritySuggestions]);
57
+ const resolveOnBlur = useCallback((input) => {
58
+ const trimmed = input.trim();
59
+ if (!trimmed)
60
+ return undefined;
61
+ const lower = trimmed.toLowerCase();
62
+ // Try exact match (priority first, then all)
63
+ if (prioritySuggestions) {
64
+ const priorityExact = prioritySuggestions.find((s) => s.id === trimmed || s.name.toLowerCase() === lower);
65
+ if (priorityExact)
66
+ return priorityExact;
67
+ }
68
+ const exact = suggestions.find((s) => s.id === trimmed || s.name.toLowerCase() === lower);
69
+ if (exact)
70
+ return exact;
71
+ // Try partial match in priority suggestions first
72
+ if (prioritySuggestions) {
73
+ const priorityPartials = prioritySuggestions.filter((s) => s.name.toLowerCase().includes(lower) || s.id.toLowerCase().includes(lower));
74
+ if (priorityPartials.length === 1)
75
+ return priorityPartials[0];
76
+ }
77
+ // Fall back to partial match in all suggestions
78
+ const partials = suggestions.filter((s) => s.name.toLowerCase().includes(lower) || s.id.toLowerCase().includes(lower));
79
+ if (partials.length === 1)
80
+ return partials[0];
81
+ return undefined;
82
+ }, [suggestions, prioritySuggestions]);
83
+ const filterSuggestions = useCallback((input) => {
84
+ var _a;
85
+ const trimmed = input.trim();
86
+ const lower = trimmed.toLowerCase();
87
+ // No input — return all suggestions split by tier
88
+ if (!trimmed) {
89
+ return {
90
+ priority: prioritySuggestions !== null && prioritySuggestions !== void 0 ? prioritySuggestions : [],
91
+ catalog: suggestions.filter((s) => !priorityIds.has(s.id))
92
+ };
93
+ }
94
+ // Filter by substring match
95
+ const matchesSuggestion = (s) => s.name.toLowerCase().includes(lower) || s.id.toLowerCase().includes(lower);
96
+ const priority = (_a = prioritySuggestions === null || prioritySuggestions === void 0 ? void 0 : prioritySuggestions.filter(matchesSuggestion)) !== null && _a !== void 0 ? _a : [];
97
+ const catalog = suggestions.filter((s) => !priorityIds.has(s.id) && matchesSuggestion(s));
98
+ return { priority, catalog };
99
+ }, [suggestions, prioritySuggestions, priorityIds]);
100
+ return { findExactMatch, resolveOnBlur, filterSuggestions };
101
+ }
102
+ //# sourceMappingURL=useTypeaheadMatch.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Keyboard shortcut packlet - registry, context, and hooks.
3
+ * @packageDocumentation
4
+ */
5
+ export { KeyboardShortcutRegistry, matchesBinding } from './registry';
6
+ export { KeyboardShortcutProvider, useKeyboardRegistry, useKeyboardShortcuts } from './useKeyboardShortcuts';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,133 @@
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
+ // Key Matching
24
+ // ============================================================================
25
+ /**
26
+ * Tests whether a keyboard event matches a key binding.
27
+ * @param event - The keyboard event
28
+ * @param binding - The binding to match against
29
+ * @returns true if the event matches
30
+ * @public
31
+ */
32
+ export function matchesBinding(event, binding) {
33
+ var _a, _b, _c;
34
+ const keyMatch = event.key.toLowerCase() === binding.key.toLowerCase();
35
+ const metaMatch = ((_a = binding.meta) !== null && _a !== void 0 ? _a : false) === (event.metaKey || event.ctrlKey);
36
+ const shiftMatch = ((_b = binding.shift) !== null && _b !== void 0 ? _b : false) === event.shiftKey;
37
+ const altMatch = ((_c = binding.alt) !== null && _c !== void 0 ? _c : false) === event.altKey;
38
+ return keyMatch && metaMatch && shiftMatch && altMatch;
39
+ }
40
+ // ============================================================================
41
+ // Registry
42
+ // ============================================================================
43
+ /**
44
+ * Centralized keyboard shortcut registry.
45
+ *
46
+ * Components register shortcuts via `register()` and receive a handle
47
+ * to unregister when they unmount. The registry attaches a single global
48
+ * keydown listener that dispatches to the highest-priority matching handler.
49
+ *
50
+ * @public
51
+ */
52
+ export class KeyboardShortcutRegistry {
53
+ constructor() {
54
+ this._shortcuts = new Map();
55
+ this._nextId = 0;
56
+ }
57
+ /**
58
+ * Registers a keyboard shortcut.
59
+ * @param shortcut - The shortcut to register
60
+ * @returns A registration handle with an `unregister()` method
61
+ */
62
+ register(shortcut) {
63
+ const id = `ks-${++this._nextId}`;
64
+ this._shortcuts.set(id, shortcut);
65
+ this._ensureListener();
66
+ return {
67
+ unregister: () => {
68
+ this._shortcuts.delete(id);
69
+ if (this._shortcuts.size === 0) {
70
+ this._removeListener();
71
+ }
72
+ }
73
+ };
74
+ }
75
+ /**
76
+ * Returns all currently registered shortcuts (for command palette / help display).
77
+ */
78
+ getAll() {
79
+ return Array.from(this._shortcuts.values());
80
+ }
81
+ /**
82
+ * Dispatches a keyboard event to the highest-priority matching handler.
83
+ * @param event - The keyboard event
84
+ * @returns true if a handler consumed the event
85
+ */
86
+ dispatch(event) {
87
+ var _a;
88
+ // Skip if the event target is an input/textarea/contenteditable
89
+ const target = event.target;
90
+ if (target) {
91
+ const tagName = (_a = target.tagName) === null || _a === void 0 ? void 0 : _a.toLowerCase();
92
+ if (tagName === 'input' || tagName === 'textarea' || target.isContentEditable) {
93
+ return false;
94
+ }
95
+ }
96
+ // Find all matching shortcuts, sorted by priority (highest first)
97
+ const matches = Array.from(this._shortcuts.values())
98
+ .filter((s) => matchesBinding(event, s.binding))
99
+ .sort((a, b) => { var _a, _b; return ((_a = b.priority) !== null && _a !== void 0 ? _a : 0) - ((_b = a.priority) !== null && _b !== void 0 ? _b : 0); });
100
+ for (const shortcut of matches) {
101
+ const result = shortcut.handler();
102
+ if (result !== false) {
103
+ event.preventDefault();
104
+ event.stopPropagation();
105
+ return true;
106
+ }
107
+ }
108
+ return false;
109
+ }
110
+ /**
111
+ * Removes all shortcuts and the global listener.
112
+ */
113
+ dispose() {
114
+ this._shortcuts.clear();
115
+ this._removeListener();
116
+ }
117
+ _ensureListener() {
118
+ if (this._listener) {
119
+ return;
120
+ }
121
+ this._listener = (e) => {
122
+ this.dispatch(e);
123
+ };
124
+ document.addEventListener('keydown', this._listener);
125
+ }
126
+ _removeListener() {
127
+ if (this._listener) {
128
+ document.removeEventListener('keydown', this._listener);
129
+ this._listener = undefined;
130
+ }
131
+ }
132
+ }
133
+ //# sourceMappingURL=registry.js.map