@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.
- package/README.md +26 -0
- package/dist/index.browser.js +3 -0
- package/dist/index.js +43 -0
- package/dist/packlets/ai-assist/index.js +6 -0
- package/dist/packlets/ai-assist/useAiAssist.js +219 -0
- package/dist/packlets/cascade/CascadeContainer.js +83 -0
- package/dist/packlets/cascade/ComparisonView.js +48 -0
- package/dist/packlets/cascade/EntityTabLayout.js +104 -0
- package/dist/packlets/cascade/MobileCascadeStack.js +63 -0
- package/dist/packlets/cascade/index.js +37 -0
- package/dist/packlets/cascade/model.js +30 -0
- package/dist/packlets/cascade/useCascadeOps.js +206 -0
- package/dist/packlets/cascade/useCascadeTransitions.js +58 -0
- package/dist/packlets/detail/DetailHelpers.js +103 -0
- package/dist/packlets/detail/index.js +6 -0
- package/dist/packlets/drop-zone/JsonDropZone.js +112 -0
- package/dist/packlets/drop-zone/index.js +6 -0
- package/dist/packlets/editing/EditFieldHelpers.js +130 -0
- package/dist/packlets/editing/MultiActionButton.js +73 -0
- package/dist/packlets/editing/NumericInput.js +119 -0
- package/dist/packlets/editing/TypeaheadInput.js +207 -0
- package/dist/packlets/editing/index.js +10 -0
- package/dist/packlets/editing/useTypeaheadMatch.js +102 -0
- package/dist/packlets/keyboard/index.js +7 -0
- package/dist/packlets/keyboard/registry.js +133 -0
- package/dist/packlets/keyboard/useKeyboardShortcuts.js +117 -0
- package/dist/packlets/messages/MessagesContext.js +76 -0
- package/dist/packlets/messages/MessagesLogger.js +103 -0
- package/dist/packlets/messages/StatusBar.js +154 -0
- package/dist/packlets/messages/Toast.js +68 -0
- package/dist/packlets/messages/index.js +11 -0
- package/dist/packlets/messages/model.js +56 -0
- package/dist/packlets/messages/useLogReporter.js +66 -0
- package/dist/packlets/modal/ConfirmDialog.js +78 -0
- package/dist/packlets/modal/Modal.js +55 -0
- package/dist/packlets/modal/index.js +7 -0
- package/dist/packlets/print/PrintEnclosure.js +60 -0
- package/dist/packlets/print/index.js +7 -0
- package/dist/packlets/print/openPrintWindow.js +112 -0
- package/dist/packlets/responsive/ResponsiveProvider.js +56 -0
- package/dist/packlets/responsive/index.js +7 -0
- package/dist/packlets/responsive/useResponsiveLayout.js +118 -0
- package/dist/packlets/selectors/EntityRow.js +276 -0
- package/dist/packlets/selectors/PreferredSelector.js +251 -0
- package/dist/packlets/selectors/index.js +24 -0
- package/dist/packlets/sidebar/CollectionSection.js +107 -0
- package/dist/packlets/sidebar/EntityList.js +164 -0
- package/dist/packlets/sidebar/FilterBar.js +42 -0
- package/dist/packlets/sidebar/FilterRow.js +182 -0
- package/dist/packlets/sidebar/GroupedEntityList.js +183 -0
- package/dist/packlets/sidebar/SearchBar.js +34 -0
- package/dist/packlets/sidebar/SidebarLayout.js +62 -0
- package/dist/packlets/sidebar/index.js +12 -0
- package/dist/packlets/theme/ThemeProvider.js +141 -0
- package/dist/packlets/theme/index.js +6 -0
- package/dist/packlets/top-bar/ModeSelector.js +46 -0
- package/dist/packlets/top-bar/TabBar.js +37 -0
- package/dist/packlets/top-bar/index.js +7 -0
- package/dist/packlets/url-sync/index.js +6 -0
- package/dist/packlets/url-sync/useUrlSync.js +157 -0
- package/eslint.config.js +22 -0
- package/lib/index.browser.d.ts +2 -0
- package/lib/index.browser.js +19 -0
- package/lib/index.d.ts +28 -0
- package/lib/index.js +59 -0
- package/lib/packlets/ai-assist/index.d.ts +6 -0
- package/lib/packlets/ai-assist/index.js +11 -0
- package/lib/packlets/ai-assist/useAiAssist.d.ts +77 -0
- package/lib/packlets/ai-assist/useAiAssist.js +223 -0
- package/lib/packlets/cascade/CascadeContainer.d.ts +44 -0
- package/lib/packlets/cascade/CascadeContainer.js +119 -0
- package/lib/packlets/cascade/ComparisonView.d.ts +35 -0
- package/lib/packlets/cascade/ComparisonView.js +54 -0
- package/lib/packlets/cascade/EntityTabLayout.d.ts +47 -0
- package/lib/packlets/cascade/EntityTabLayout.js +110 -0
- package/lib/packlets/cascade/MobileCascadeStack.d.ts +20 -0
- package/lib/packlets/cascade/MobileCascadeStack.js +99 -0
- package/lib/packlets/cascade/index.d.ts +12 -0
- package/lib/packlets/cascade/index.js +48 -0
- package/lib/packlets/cascade/model.d.ts +57 -0
- package/lib/packlets/cascade/model.js +33 -0
- package/lib/packlets/cascade/useCascadeOps.d.ts +111 -0
- package/lib/packlets/cascade/useCascadeOps.js +209 -0
- package/lib/packlets/cascade/useCascadeTransitions.d.ts +19 -0
- package/lib/packlets/cascade/useCascadeTransitions.js +62 -0
- package/lib/packlets/detail/DetailHelpers.d.ts +83 -0
- package/lib/packlets/detail/DetailHelpers.js +113 -0
- package/lib/packlets/detail/index.d.ts +6 -0
- package/lib/packlets/detail/index.js +14 -0
- package/lib/packlets/drop-zone/JsonDropZone.d.ts +40 -0
- package/lib/packlets/drop-zone/JsonDropZone.js +149 -0
- package/lib/packlets/drop-zone/index.d.ts +6 -0
- package/lib/packlets/drop-zone/index.js +10 -0
- package/lib/packlets/editing/EditFieldHelpers.d.ts +171 -0
- package/lib/packlets/editing/EditFieldHelpers.js +144 -0
- package/lib/packlets/editing/MultiActionButton.d.ts +45 -0
- package/lib/packlets/editing/MultiActionButton.js +109 -0
- package/lib/packlets/editing/NumericInput.d.ts +47 -0
- package/lib/packlets/editing/NumericInput.js +155 -0
- package/lib/packlets/editing/TypeaheadInput.d.ts +46 -0
- package/lib/packlets/editing/TypeaheadInput.js +243 -0
- package/lib/packlets/editing/index.d.ts +10 -0
- package/lib/packlets/editing/index.js +26 -0
- package/lib/packlets/editing/useTypeaheadMatch.d.ts +42 -0
- package/lib/packlets/editing/useTypeaheadMatch.js +105 -0
- package/lib/packlets/keyboard/index.d.ts +7 -0
- package/lib/packlets/keyboard/index.js +15 -0
- package/lib/packlets/keyboard/registry.d.ts +92 -0
- package/lib/packlets/keyboard/registry.js +138 -0
- package/lib/packlets/keyboard/useKeyboardShortcuts.d.ts +50 -0
- package/lib/packlets/keyboard/useKeyboardShortcuts.js +155 -0
- package/lib/packlets/messages/MessagesContext.d.ts +40 -0
- package/lib/packlets/messages/MessagesContext.js +113 -0
- package/lib/packlets/messages/MessagesLogger.d.ts +50 -0
- package/lib/packlets/messages/MessagesLogger.js +107 -0
- package/lib/packlets/messages/StatusBar.d.ts +22 -0
- package/lib/packlets/messages/StatusBar.js +190 -0
- package/lib/packlets/messages/Toast.d.ts +31 -0
- package/lib/packlets/messages/Toast.js +105 -0
- package/lib/packlets/messages/index.d.ts +11 -0
- package/lib/packlets/messages/index.js +24 -0
- package/lib/packlets/messages/model.d.ts +59 -0
- package/lib/packlets/messages/model.js +61 -0
- package/lib/packlets/messages/useLogReporter.d.ts +22 -0
- package/lib/packlets/messages/useLogReporter.js +69 -0
- package/lib/packlets/modal/ConfirmDialog.d.ts +39 -0
- package/lib/packlets/modal/ConfirmDialog.js +114 -0
- package/lib/packlets/modal/Modal.d.ts +22 -0
- package/lib/packlets/modal/Modal.js +91 -0
- package/lib/packlets/modal/index.d.ts +7 -0
- package/lib/packlets/modal/index.js +12 -0
- package/lib/packlets/print/PrintEnclosure.d.ts +33 -0
- package/lib/packlets/print/PrintEnclosure.js +96 -0
- package/lib/packlets/print/index.d.ts +7 -0
- package/lib/packlets/print/index.js +12 -0
- package/lib/packlets/print/openPrintWindow.d.ts +35 -0
- package/lib/packlets/print/openPrintWindow.js +118 -0
- package/lib/packlets/responsive/ResponsiveProvider.d.ts +35 -0
- package/lib/packlets/responsive/ResponsiveProvider.js +93 -0
- package/lib/packlets/responsive/index.d.ts +7 -0
- package/lib/packlets/responsive/index.js +13 -0
- package/lib/packlets/responsive/useResponsiveLayout.d.ts +48 -0
- package/lib/packlets/responsive/useResponsiveLayout.js +121 -0
- package/lib/packlets/selectors/EntityRow.d.ts +45 -0
- package/lib/packlets/selectors/EntityRow.js +315 -0
- package/lib/packlets/selectors/PreferredSelector.d.ts +50 -0
- package/lib/packlets/selectors/PreferredSelector.js +287 -0
- package/lib/packlets/selectors/index.d.ts +5 -0
- package/lib/packlets/selectors/index.js +29 -0
- package/lib/packlets/sidebar/CollectionSection.d.ts +82 -0
- package/lib/packlets/sidebar/CollectionSection.js +143 -0
- package/lib/packlets/sidebar/EntityList.d.ts +105 -0
- package/lib/packlets/sidebar/EntityList.js +200 -0
- package/lib/packlets/sidebar/FilterBar.d.ts +26 -0
- package/lib/packlets/sidebar/FilterBar.js +48 -0
- package/lib/packlets/sidebar/FilterRow.d.ts +42 -0
- package/lib/packlets/sidebar/FilterRow.js +218 -0
- package/lib/packlets/sidebar/GroupedEntityList.d.ts +59 -0
- package/lib/packlets/sidebar/GroupedEntityList.js +219 -0
- package/lib/packlets/sidebar/SearchBar.d.ts +19 -0
- package/lib/packlets/sidebar/SearchBar.js +40 -0
- package/lib/packlets/sidebar/SidebarLayout.d.ts +28 -0
- package/lib/packlets/sidebar/SidebarLayout.js +98 -0
- package/lib/packlets/sidebar/index.d.ts +12 -0
- package/lib/packlets/sidebar/index.js +22 -0
- package/lib/packlets/theme/ThemeProvider.d.ts +68 -0
- package/lib/packlets/theme/ThemeProvider.js +178 -0
- package/lib/packlets/theme/index.d.ts +6 -0
- package/lib/packlets/theme/index.js +11 -0
- package/lib/packlets/top-bar/ModeSelector.d.ts +38 -0
- package/lib/packlets/top-bar/ModeSelector.js +52 -0
- package/lib/packlets/top-bar/TabBar.d.ts +31 -0
- package/lib/packlets/top-bar/TabBar.js +43 -0
- package/lib/packlets/top-bar/index.d.ts +7 -0
- package/lib/packlets/top-bar/index.js +12 -0
- package/lib/packlets/url-sync/index.d.ts +6 -0
- package/lib/packlets/url-sync/index.js +12 -0
- package/lib/packlets/url-sync/useUrlSync.d.ts +75 -0
- package/lib/packlets/url-sync/useUrlSync.js +162 -0
- 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
|