ink-cartridge 3.8.0 → 3.8.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 +12 -3
- package/dist/components/multi-select/MultiSelectInput.d.ts +1 -82
- package/dist/dev/dev-screen.d.ts +1 -1
- package/dist/dev/dev-screen.js +7 -3
- package/dist/dev/entrance.d.ts +27 -22
- package/dist/dev/entrance.js +32 -32
- package/dist/dev/globalKey-display.js +2 -1
- package/dist/dev/globalSeq-display.js +2 -1
- package/dist/dev/layerKey-display.js +2 -1
- package/dist/dev/types.d.ts +4 -0
- package/dist/keyboard/context.d.ts +7 -3
- package/dist/keyboard/hook.js +56 -17
- package/dist/screen/ModalContext.d.ts +12 -3
- package/dist/screen/ModalContext.js +6 -2
- package/dist/screen/OverlayContext.d.ts +14 -3
- package/dist/screen/OverlayContext.js +8 -2
- package/dist/screen/current-screen.js +6 -3
- package/dist/screen/provider.d.ts +13 -4
- package/dist/screen/provider.js +87 -26
- package/dist/screen/types.d.ts +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<br>
|
|
3
|
+
<br>
|
|
4
|
+
<img width="440" alt="cartridge" src="static/cartridge.png">
|
|
5
|
+
<br>
|
|
6
|
+
<br>
|
|
7
|
+
<br>
|
|
8
|
+
</div>
|
|
2
9
|
|
|
3
|
-
>
|
|
10
|
+
<h1 align="center">Cartridge</h1>
|
|
11
|
+
|
|
12
|
+
>A component kit for rapidly building complex, multi-page, interaction-heavy terminal applications — filling the critical gaps Ink leaves open.
|
|
4
13
|
|
|
5
14
|
[](https://github.com/BAIGAOa/ink-trc/actions/workflows/ci.yml)
|
|
6
15
|
[](https://www.npmjs.com/package/ink-cartridge)
|
|
@@ -161,4 +170,4 @@ The method `blockedKey` is poorly named — it means *pass-through*, not "block.
|
|
|
161
170
|
|
|
162
171
|
## License
|
|
163
172
|
|
|
164
|
-
[MIT](LICENSE)
|
|
173
|
+
[MIT](LICENSE)
|
|
@@ -41,85 +41,4 @@ import type { MultiSelectInputProps, Item } from './types.js';
|
|
|
41
41
|
* />
|
|
42
42
|
* ```
|
|
43
43
|
*/
|
|
44
|
-
export declare function MultiSelectInput<T, I extends Item<T> = Item<T>>({ items, selected: controlledSelected, defaultSelected, onChange, onSubmit, onSelect, onUnselect, onHighlight, indicatorComponent, checkboxComponent, itemComponent, focusId, limit: limitProp, initialIndex, storage, storageKey, }: MultiSelectInputProps<T, I>): React.
|
|
45
|
-
readonly position?: "absolute" | "relative" | "static" | undefined;
|
|
46
|
-
readonly top?: number | string | undefined;
|
|
47
|
-
readonly right?: number | string | undefined;
|
|
48
|
-
readonly bottom?: number | string | undefined;
|
|
49
|
-
readonly left?: number | string | undefined;
|
|
50
|
-
readonly columnGap?: number | undefined;
|
|
51
|
-
readonly rowGap?: number | undefined;
|
|
52
|
-
readonly gap?: number | undefined;
|
|
53
|
-
readonly margin?: number | undefined;
|
|
54
|
-
readonly marginX?: number | undefined;
|
|
55
|
-
readonly marginY?: number | undefined;
|
|
56
|
-
readonly marginTop?: number | undefined;
|
|
57
|
-
readonly marginBottom?: number | undefined;
|
|
58
|
-
readonly marginLeft?: number | undefined;
|
|
59
|
-
readonly marginRight?: number | undefined;
|
|
60
|
-
readonly padding?: number | undefined;
|
|
61
|
-
readonly paddingX?: number | undefined;
|
|
62
|
-
readonly paddingY?: number | undefined;
|
|
63
|
-
readonly paddingTop?: number | undefined;
|
|
64
|
-
readonly paddingBottom?: number | undefined;
|
|
65
|
-
readonly paddingLeft?: number | undefined;
|
|
66
|
-
readonly paddingRight?: number | undefined;
|
|
67
|
-
readonly flexGrow?: number | undefined;
|
|
68
|
-
readonly flexShrink?: number | undefined;
|
|
69
|
-
readonly flexDirection?: "row" | "column" | "row-reverse" | "column-reverse" | undefined;
|
|
70
|
-
readonly flexBasis?: number | string | undefined;
|
|
71
|
-
readonly flexWrap?: "nowrap" | "wrap" | "wrap-reverse" | undefined;
|
|
72
|
-
readonly alignItems?: "flex-start" | "center" | "flex-end" | "stretch" | "baseline" | undefined;
|
|
73
|
-
readonly alignSelf?: "flex-start" | "center" | "flex-end" | "auto" | "stretch" | "baseline" | undefined;
|
|
74
|
-
readonly alignContent?: "flex-start" | "flex-end" | "center" | "stretch" | "space-between" | "space-around" | "space-evenly" | undefined;
|
|
75
|
-
readonly justifyContent?: "flex-start" | "flex-end" | "space-between" | "space-around" | "space-evenly" | "center" | undefined;
|
|
76
|
-
readonly width?: number | string | undefined;
|
|
77
|
-
readonly height?: number | string | undefined;
|
|
78
|
-
readonly minWidth?: number | string | undefined;
|
|
79
|
-
readonly minHeight?: number | string | undefined;
|
|
80
|
-
readonly maxWidth?: number | string | undefined;
|
|
81
|
-
readonly maxHeight?: number | string | undefined;
|
|
82
|
-
readonly aspectRatio?: number | undefined;
|
|
83
|
-
readonly display?: "flex" | "none" | undefined;
|
|
84
|
-
readonly borderStyle?: (keyof import("cli-boxes").Boxes | import("cli-boxes").BoxStyle) | undefined;
|
|
85
|
-
readonly borderTop?: boolean | undefined;
|
|
86
|
-
readonly borderBottom?: boolean | undefined;
|
|
87
|
-
readonly borderLeft?: boolean | undefined;
|
|
88
|
-
readonly borderRight?: boolean | undefined;
|
|
89
|
-
readonly borderColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
90
|
-
readonly borderTopColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
91
|
-
readonly borderBottomColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
92
|
-
readonly borderLeftColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
93
|
-
readonly borderRightColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
94
|
-
readonly borderDimColor?: boolean | undefined;
|
|
95
|
-
readonly borderTopDimColor?: boolean | undefined;
|
|
96
|
-
readonly borderBottomDimColor?: boolean | undefined;
|
|
97
|
-
readonly borderLeftDimColor?: boolean | undefined;
|
|
98
|
-
readonly borderRightDimColor?: boolean | undefined;
|
|
99
|
-
readonly borderBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
100
|
-
readonly borderTopBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
101
|
-
readonly borderBottomBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
102
|
-
readonly borderLeftBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
103
|
-
readonly borderRightBackgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
104
|
-
readonly overflow?: "visible" | "hidden" | undefined;
|
|
105
|
-
readonly overflowX?: "visible" | "hidden" | undefined;
|
|
106
|
-
readonly overflowY?: "visible" | "hidden" | undefined;
|
|
107
|
-
readonly backgroundColor?: import("type-fest").LiteralUnion<import("ansi-styles").ForegroundColorName, string> | undefined;
|
|
108
|
-
} & {
|
|
109
|
-
readonly 'aria-label'?: string;
|
|
110
|
-
readonly 'aria-hidden'?: boolean;
|
|
111
|
-
readonly 'aria-role'?: "button" | "checkbox" | "combobox" | "list" | "listbox" | "listitem" | "menu" | "menuitem" | "option" | "progressbar" | "radio" | "radiogroup" | "tab" | "tablist" | "table" | "textbox" | "timer" | "toolbar";
|
|
112
|
-
readonly 'aria-state'?: {
|
|
113
|
-
readonly busy?: boolean;
|
|
114
|
-
readonly checked?: boolean;
|
|
115
|
-
readonly disabled?: boolean;
|
|
116
|
-
readonly expanded?: boolean;
|
|
117
|
-
readonly multiline?: boolean;
|
|
118
|
-
readonly multiselectable?: boolean;
|
|
119
|
-
readonly readonly?: boolean;
|
|
120
|
-
readonly required?: boolean;
|
|
121
|
-
readonly selected?: boolean;
|
|
122
|
-
};
|
|
123
|
-
} & {
|
|
124
|
-
children?: React.ReactNode | undefined;
|
|
125
|
-
} & React.RefAttributes<import("ink").DOMElement>>;
|
|
44
|
+
export declare function MultiSelectInput<T, I extends Item<T> = Item<T>>({ items, selected: controlledSelected, defaultSelected, onChange, onSubmit, onSelect, onUnselect, onHighlight, indicatorComponent, checkboxComponent, itemComponent, focusId, limit: limitProp, initialIndex, storage, storageKey, }: MultiSelectInputProps<T, I>): React.JSX.Element;
|
package/dist/dev/dev-screen.d.ts
CHANGED
|
@@ -33,4 +33,4 @@ import { DevProps } from "./types.js";
|
|
|
33
33
|
*
|
|
34
34
|
* @2026-06-23 3.6.1
|
|
35
35
|
*/
|
|
36
|
-
export declare function DevScreen({ top: initialTop, left }: DevProps): React.JSX.Element;
|
|
36
|
+
export declare function DevScreen({ top: initialTop, left, allowKeys }: DevProps): React.JSX.Element;
|
package/dist/dev/dev-screen.js
CHANGED
|
@@ -165,10 +165,11 @@ function LayerSummary({ topComponent, readLayer }) {
|
|
|
165
165
|
*
|
|
166
166
|
* @2026-06-23 3.6.1
|
|
167
167
|
*/
|
|
168
|
-
export function DevScreen({ top: initialTop, left }) {
|
|
168
|
+
export function DevScreen({ top: initialTop, left, allowKeys }) {
|
|
169
169
|
const { currentPath, displayedOverlays, activeOverlayIds, modalQueue, activeModalId, } = useScreenSystem();
|
|
170
|
-
const { boundKeyboard, readLayer } = useKeyboard();
|
|
171
|
-
const
|
|
170
|
+
const { boundKeyboard, readLayer, allowModal } = useKeyboard();
|
|
171
|
+
const modalCtx = useContext(ModalContext);
|
|
172
|
+
const modalId = modalCtx?.id ?? null;
|
|
172
173
|
const { rows } = useWindowSize();
|
|
173
174
|
const { openModal } = useScreenSystem();
|
|
174
175
|
const [offsetTop, setOffsetTop] = useState(initialTop);
|
|
@@ -200,6 +201,9 @@ export function DevScreen({ top: initialTop, left }) {
|
|
|
200
201
|
clearTimeout(flashTimerRef.current);
|
|
201
202
|
};
|
|
202
203
|
}, []);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
return allowModal(allowKeys ?? []);
|
|
206
|
+
}, [allowModal]);
|
|
203
207
|
useEffect(() => {
|
|
204
208
|
const u1 = boundKeyboard(['up'], () => setOffsetTop(prev => clampTopRef.current(prev - 1)));
|
|
205
209
|
const u2 = boundKeyboard(['down'], () => setOffsetTop(prev => clampTopRef.current(prev + 1)));
|
package/dist/dev/entrance.d.ts
CHANGED
|
@@ -3,43 +3,48 @@ import { DevProps } from "./types.js";
|
|
|
3
3
|
* Open the developer debugging modal.
|
|
4
4
|
*
|
|
5
5
|
* Calls {@link openModal} with the fixed modal ID `_Dev-Tool_` and
|
|
6
|
-
* the {@link DevScreen} component
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* the {@link DevScreen} component, using `persistent: true` by default
|
|
7
|
+
* so the panel survives screen navigation. Keyboard focus is
|
|
8
|
+
* automatically suspended when navigating away and restored when
|
|
9
|
+
* returning to the originating screen.
|
|
10
|
+
*
|
|
11
|
+
* Safe to call when the modal is already open — the underlying
|
|
12
|
+
* {@link openModal} treats duplicate IDs as a no-op, so a simple key
|
|
13
|
+
* binding without a toggle ref suffices:
|
|
14
|
+
*
|
|
15
|
+
* {@link closeDevTool} is the inverse.
|
|
9
16
|
*
|
|
10
17
|
* When open, DevScreen blocks all keyboard events from reaching
|
|
11
|
-
* overlays and screens.
|
|
12
|
-
*
|
|
18
|
+
* overlays and screens. Pass `allowKeys` to let specific keys through
|
|
19
|
+
* (e.g. `allowKeys: ['ctrl+d']` to toggle the tool itself).
|
|
13
20
|
*
|
|
14
|
-
* @param top
|
|
15
|
-
* @param left
|
|
21
|
+
* @param top - Initial vertical position in rows (0 = top of terminal).
|
|
22
|
+
* @param left - Horizontal position in columns.
|
|
23
|
+
* @param zindex - Optional modal z-index for stacking order.
|
|
24
|
+
* @param allowKeys - Keys allowed to pass through the modal to layers below.
|
|
25
|
+
* @param persistent - Whether the dev tool survives screen navigation. Defaults to `true`.
|
|
16
26
|
*
|
|
17
|
-
* @throws If `ScenarioManagementProvider` is not mounted
|
|
18
|
-
* ID `_Dev-Tool_` is already open.
|
|
27
|
+
* @throws If `ScenarioManagementProvider` is not mounted.
|
|
19
28
|
*
|
|
20
29
|
* @example
|
|
21
30
|
* ```ts
|
|
22
|
-
* //
|
|
23
|
-
*
|
|
31
|
+
* // Press Ctrl+D to open the dev tool (no-op if already open):
|
|
32
|
+
* boundKeyboard(['ctrl+d'], () => openDevTool({ top: 0, left: 0 }));
|
|
33
|
+
*
|
|
34
|
+
* // Toggle requires allowKeys so the second press can pass through:
|
|
24
35
|
* boundKeyboard(['ctrl+d'], () => {
|
|
25
|
-
*
|
|
26
|
-
* closeDevTool();
|
|
27
|
-
* devOpenRef.current = false;
|
|
28
|
-
* } else {
|
|
29
|
-
* openDevTool({ top: 0, left: 0 });
|
|
30
|
-
* devOpenRef.current = true;
|
|
31
|
-
* }
|
|
36
|
+
* try { openDevTool({ top: 0, left: 0, allowKeys: ['ctrl+d'] }); } catch {}
|
|
32
37
|
* });
|
|
33
38
|
* ```
|
|
39
|
+
* @2026-07-04 v3.8.0
|
|
34
40
|
*/
|
|
35
|
-
export declare function openDevTool({ top, left, zindex }: DevProps): void;
|
|
41
|
+
export declare function openDevTool({ top, left, zindex, allowKeys, persistent }: DevProps): void;
|
|
36
42
|
/**
|
|
37
43
|
* Close the developer debugging modal.
|
|
38
44
|
*
|
|
39
45
|
* Calls {@link closeModal} for the fixed modal ID `_Dev-Tool_`.
|
|
40
|
-
* Safe to call even if the modal is not currently open —
|
|
41
|
-
*
|
|
42
|
-
* may have been closed by its own Escape binding.
|
|
46
|
+
* Safe to call even if the modal is not currently open — the underlying
|
|
47
|
+
* {@link closeModal} treats non-existent IDs as a no-op.
|
|
43
48
|
*
|
|
44
49
|
* @example
|
|
45
50
|
* ```ts
|
package/dist/dev/entrance.js
CHANGED
|
@@ -4,51 +4,58 @@ import { DevScreen } from "./dev-screen.js";
|
|
|
4
4
|
* Open the developer debugging modal.
|
|
5
5
|
*
|
|
6
6
|
* Calls {@link openModal} with the fixed modal ID `_Dev-Tool_` and
|
|
7
|
-
* the {@link DevScreen} component
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* the {@link DevScreen} component, using `persistent: true` by default
|
|
8
|
+
* so the panel survives screen navigation. Keyboard focus is
|
|
9
|
+
* automatically suspended when navigating away and restored when
|
|
10
|
+
* returning to the originating screen.
|
|
11
|
+
*
|
|
12
|
+
* Safe to call when the modal is already open — the underlying
|
|
13
|
+
* {@link openModal} treats duplicate IDs as a no-op, so a simple key
|
|
14
|
+
* binding without a toggle ref suffices:
|
|
15
|
+
*
|
|
16
|
+
* {@link closeDevTool} is the inverse.
|
|
10
17
|
*
|
|
11
18
|
* When open, DevScreen blocks all keyboard events from reaching
|
|
12
|
-
* overlays and screens.
|
|
13
|
-
*
|
|
19
|
+
* overlays and screens. Pass `allowKeys` to let specific keys through
|
|
20
|
+
* (e.g. `allowKeys: ['ctrl+d']` to toggle the tool itself).
|
|
14
21
|
*
|
|
15
|
-
* @param top
|
|
16
|
-
* @param left
|
|
22
|
+
* @param top - Initial vertical position in rows (0 = top of terminal).
|
|
23
|
+
* @param left - Horizontal position in columns.
|
|
24
|
+
* @param zindex - Optional modal z-index for stacking order.
|
|
25
|
+
* @param allowKeys - Keys allowed to pass through the modal to layers below.
|
|
26
|
+
* @param persistent - Whether the dev tool survives screen navigation. Defaults to `true`.
|
|
17
27
|
*
|
|
18
|
-
* @throws If `ScenarioManagementProvider` is not mounted
|
|
19
|
-
* ID `_Dev-Tool_` is already open.
|
|
28
|
+
* @throws If `ScenarioManagementProvider` is not mounted.
|
|
20
29
|
*
|
|
21
30
|
* @example
|
|
22
31
|
* ```ts
|
|
23
|
-
* //
|
|
24
|
-
*
|
|
32
|
+
* // Press Ctrl+D to open the dev tool (no-op if already open):
|
|
33
|
+
* boundKeyboard(['ctrl+d'], () => openDevTool({ top: 0, left: 0 }));
|
|
34
|
+
*
|
|
35
|
+
* // Toggle requires allowKeys so the second press can pass through:
|
|
25
36
|
* boundKeyboard(['ctrl+d'], () => {
|
|
26
|
-
*
|
|
27
|
-
* closeDevTool();
|
|
28
|
-
* devOpenRef.current = false;
|
|
29
|
-
* } else {
|
|
30
|
-
* openDevTool({ top: 0, left: 0 });
|
|
31
|
-
* devOpenRef.current = true;
|
|
32
|
-
* }
|
|
37
|
+
* try { openDevTool({ top: 0, left: 0, allowKeys: ['ctrl+d'] }); } catch {}
|
|
33
38
|
* });
|
|
34
39
|
* ```
|
|
40
|
+
* @2026-07-04 v3.8.0
|
|
35
41
|
*/
|
|
36
|
-
export function openDevTool({ top, left, zindex }) {
|
|
42
|
+
export function openDevTool({ top, left, zindex, allowKeys, persistent = true }) {
|
|
37
43
|
openModal('_Dev-Tool_', DevScreen, {
|
|
38
44
|
top: top,
|
|
39
|
-
left: left
|
|
45
|
+
left: left,
|
|
46
|
+
allowKeys: allowKeys,
|
|
40
47
|
}, {
|
|
41
48
|
zIndex: zindex,
|
|
42
|
-
renderNow: true
|
|
49
|
+
renderNow: true,
|
|
50
|
+
persistent,
|
|
43
51
|
});
|
|
44
52
|
}
|
|
45
53
|
/**
|
|
46
54
|
* Close the developer debugging modal.
|
|
47
55
|
*
|
|
48
56
|
* Calls {@link closeModal} for the fixed modal ID `_Dev-Tool_`.
|
|
49
|
-
* Safe to call even if the modal is not currently open —
|
|
50
|
-
*
|
|
51
|
-
* may have been closed by its own Escape binding.
|
|
57
|
+
* Safe to call even if the modal is not currently open — the underlying
|
|
58
|
+
* {@link closeModal} treats non-existent IDs as a no-op.
|
|
52
59
|
*
|
|
53
60
|
* @example
|
|
54
61
|
* ```ts
|
|
@@ -56,12 +63,5 @@ export function openDevTool({ top, left, zindex }) {
|
|
|
56
63
|
* ```
|
|
57
64
|
*/
|
|
58
65
|
export function closeDevTool() {
|
|
59
|
-
|
|
60
|
-
closeModal('_Dev-Tool_');
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// Modal already closed — no-op. This handles the case where
|
|
64
|
-
// the Escape key inside DevScreen closed the modal, leaving
|
|
65
|
-
// an external toggle ref stale.
|
|
66
|
-
}
|
|
66
|
+
closeModal('_Dev-Tool_');
|
|
67
67
|
}
|
|
@@ -51,7 +51,8 @@ const Sep = () => (React.createElement(Box, null,
|
|
|
51
51
|
export default function GlobalKeyDisplayBox({ top: initialTop, left }) {
|
|
52
52
|
const { getGlobalKeys, boundKeyboard } = useKeyboard();
|
|
53
53
|
const { closeModal } = useScreenSystem();
|
|
54
|
-
const
|
|
54
|
+
const modalCtx = useContext(ModalContext);
|
|
55
|
+
const modalId = modalCtx?.id ?? null;
|
|
55
56
|
const { rows } = useWindowSize();
|
|
56
57
|
const [offsetTop, setOffsetTop] = useState(initialTop);
|
|
57
58
|
const [expandedEntry, setExpandedEntry] = useState(null);
|
|
@@ -56,7 +56,8 @@ const Sep = () => (React.createElement(Box, null,
|
|
|
56
56
|
export default function GlobalSequenceDisplayBox({ top: initialTop, left }) {
|
|
57
57
|
const { getGlobalSequences, getGlobalPendingSequence, boundKeyboard } = useKeyboard();
|
|
58
58
|
const { closeModal } = useScreenSystem();
|
|
59
|
-
const
|
|
59
|
+
const modalCtx = useContext(ModalContext);
|
|
60
|
+
const modalId = modalCtx?.id ?? null;
|
|
60
61
|
const { rows } = useWindowSize();
|
|
61
62
|
const [offsetTop, setOffsetTop] = useState(initialTop);
|
|
62
63
|
const [expandedEntry, setExpandedEntry] = useState(null);
|
|
@@ -140,7 +140,8 @@ function FocusTargetDetail({ focusId, target }) {
|
|
|
140
140
|
export default function LayerKeyDisplayBox({ top: initialTop, left, screenComponent }) {
|
|
141
141
|
const { readLayer, boundKeyboard } = useKeyboard();
|
|
142
142
|
const { closeModal } = useScreenSystem();
|
|
143
|
-
const
|
|
143
|
+
const modalCtx = useContext(ModalContext);
|
|
144
|
+
const modalId = modalCtx?.id ?? null;
|
|
144
145
|
const { rows } = useWindowSize();
|
|
145
146
|
const [offsetTop, setOffsetTop] = useState(initialTop);
|
|
146
147
|
const [expandedEntry, setExpandedEntry] = useState(null);
|
package/dist/dev/types.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ export interface DevProps {
|
|
|
7
7
|
/** Horizontal position in columns — 0 is the left edge. */
|
|
8
8
|
left: number;
|
|
9
9
|
zindex?: number;
|
|
10
|
+
/** Keys allowed to pass through the dev tool modal to layers below. */
|
|
11
|
+
allowKeys?: string[];
|
|
12
|
+
/** Whether the dev tool survives screen navigation. Defaults to `true`. */
|
|
13
|
+
persistent?: boolean;
|
|
10
14
|
}
|
|
11
15
|
/**
|
|
12
16
|
* Props accepted by the {@link GlobalKeyDisplayBox} global-keys inspector modal.
|
|
@@ -18,17 +18,21 @@ export interface KeyboardContextValue {
|
|
|
18
18
|
* Overloads:
|
|
19
19
|
* 1. `(keys: string | string[], handler: KeyHandler, options?: BoundKeyboardOptions)` —
|
|
20
20
|
* explicit keys and handler. A single string is normalized to `[string]`.
|
|
21
|
-
* 2. `(actionId: string, options
|
|
21
|
+
* 2. `(keys: string | string[], actionId: string, options?: BoundKeyboardOptions)` —
|
|
22
|
+
* explicit keys referencing a registered shortcut action by id.
|
|
23
|
+
* 3. `(actionId: string, options?: BoundKeyboardOptions)` —
|
|
22
24
|
* uses a registered shortcut action's predefined keys and callback.
|
|
23
25
|
*
|
|
24
26
|
* @param keys Key name(s) to bind (e.g. `"s"`, `["s", "ctrl+q", "return"]`).
|
|
25
|
-
* @param handler Callback receiving the raw `input` and `key` from Ink
|
|
27
|
+
* @param handler Callback receiving the raw `input` and `key` from Ink,
|
|
28
|
+
* or a registered shortcut action id string.
|
|
26
29
|
* @param options Optional binding behavior (`onlyThis`, `focusId`, `once`, `times`, `when`).
|
|
27
30
|
* @returns An unbind function that removes this binding when called.
|
|
28
31
|
*/
|
|
29
32
|
boundKeyboard: {
|
|
30
33
|
(keys: string | string[], handler: KeyHandler, options?: BoundKeyboardOptions): () => void;
|
|
31
|
-
(actionId: string, options
|
|
34
|
+
(keys: string | string[], actionId: string, options?: BoundKeyboardOptions): () => void;
|
|
35
|
+
(actionId: string, options?: BoundKeyboardOptions): () => void;
|
|
32
36
|
};
|
|
33
37
|
/**
|
|
34
38
|
* Mark one or more keys as "transparent" on the current layer.
|
package/dist/keyboard/hook.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useContext, useEffect, useState } from "react";
|
|
1
|
+
import { useContext, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { KeyboardContext } from "./context.js";
|
|
3
3
|
import { OverlayContext } from "../screen/OverlayContext.js";
|
|
4
4
|
import { ModalContext } from "../screen/ModalContext.js";
|
|
5
|
+
import { useScreenSystem } from "../screen/hook.js";
|
|
5
6
|
/**
|
|
6
7
|
* Access the keyboard API from within a React component.
|
|
7
8
|
*
|
|
@@ -23,41 +24,78 @@ import { ModalContext } from "../screen/ModalContext.js";
|
|
|
23
24
|
*/
|
|
24
25
|
export function useKeyboard() {
|
|
25
26
|
const ctx = useContext(KeyboardContext);
|
|
26
|
-
const
|
|
27
|
-
const
|
|
27
|
+
const overlayCtx = useContext(OverlayContext);
|
|
28
|
+
const modalCtx = useContext(ModalContext);
|
|
29
|
+
const overlayId = overlayCtx?.id ?? null;
|
|
30
|
+
const modalId = modalCtx?.id ?? null;
|
|
31
|
+
const { currentPath } = useScreenSystem();
|
|
28
32
|
if (!ctx) {
|
|
29
33
|
throw new Error("[Ink-Cartridge] useKeyboard() must be called inside a <KeyboardProvider>.");
|
|
30
34
|
}
|
|
31
|
-
// Manage the owner stack for overlay isolation.
|
|
32
|
-
// When inside an overlay, push the overlay ID as the current owner so
|
|
33
|
-
// that boundKeyboard, blockedKey, stop, and focus functions operate on
|
|
34
|
-
// the overlay's own keyboard layer instead of the screen's layer.
|
|
35
|
-
//
|
|
36
|
-
// Destructuring _pushOwner / _popOwner to local variables gives ESLint a
|
|
37
|
-
// simple identifier to include in the dependency array (avoiding the need
|
|
38
|
-
// to list `ctx` as a whole, which would broaden the effect trigger).
|
|
39
35
|
const { _pushOwner, _popOwner } = ctx;
|
|
36
|
+
const ownerPushedRef = useRef(false);
|
|
37
|
+
// Lifecycle: overlay mount → push, unmount → pop.
|
|
40
38
|
useEffect(() => {
|
|
41
39
|
if (overlayId) {
|
|
42
|
-
|
|
40
|
+
if (!ownerPushedRef.current) {
|
|
41
|
+
_pushOwner(overlayId);
|
|
42
|
+
ownerPushedRef.current = true;
|
|
43
|
+
}
|
|
43
44
|
return () => {
|
|
44
45
|
_popOwner(overlayId);
|
|
46
|
+
ownerPushedRef.current = false;
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
return;
|
|
48
50
|
}, [overlayId, _pushOwner, _popOwner]);
|
|
49
|
-
//
|
|
50
|
-
// When inside a modal, push the modal ID as the current owner so that
|
|
51
|
-
// keyboard functions operate on the modal's own layer.
|
|
51
|
+
// Lifecycle: modal mount → push, unmount → pop.
|
|
52
52
|
useEffect(() => {
|
|
53
53
|
if (modalId) {
|
|
54
|
-
|
|
54
|
+
if (!ownerPushedRef.current) {
|
|
55
|
+
_pushOwner(modalId);
|
|
56
|
+
ownerPushedRef.current = true;
|
|
57
|
+
}
|
|
55
58
|
return () => {
|
|
56
59
|
_popOwner(modalId);
|
|
60
|
+
ownerPushedRef.current = false;
|
|
57
61
|
};
|
|
58
62
|
}
|
|
59
63
|
return;
|
|
60
64
|
}, [modalId, _pushOwner, _popOwner]);
|
|
65
|
+
/**
|
|
66
|
+
* Render-time owner stack sync for persistent layers.
|
|
67
|
+
*
|
|
68
|
+
* When a persistent overlay/modal survives navigation but the user has
|
|
69
|
+
* navigated to a different screen, the owner must be popped from the
|
|
70
|
+
* stack IMMEDIATELY (during render) so that sibling screen components'
|
|
71
|
+
* mount effects see the correct owner when they call boundKeyboard.
|
|
72
|
+
* Re-push happens when the user navigates back to the origin screen.
|
|
73
|
+
*
|
|
74
|
+
* This replaces the previous useEffect-based management which was
|
|
75
|
+
* subject to sibling ordering: screen components' effects fire before
|
|
76
|
+
* the persisting layer's effect, causing bindings to leak into the
|
|
77
|
+
* wrong keyboard layer.
|
|
78
|
+
*
|
|
79
|
+
* @2026-07-04 v3.8.0
|
|
80
|
+
*/
|
|
81
|
+
const ownerCtx = modalCtx ?? overlayCtx;
|
|
82
|
+
const ownerId = ownerCtx?.id ?? null;
|
|
83
|
+
const originComponent = ownerCtx?.originComponent;
|
|
84
|
+
if (originComponent && ownerId) {
|
|
85
|
+
const currentTop = currentPath[currentPath.length - 1];
|
|
86
|
+
if (currentTop === originComponent) {
|
|
87
|
+
if (!ownerPushedRef.current) {
|
|
88
|
+
_pushOwner(ownerId);
|
|
89
|
+
ownerPushedRef.current = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
if (ownerPushedRef.current) {
|
|
94
|
+
_popOwner(ownerId);
|
|
95
|
+
ownerPushedRef.current = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
61
99
|
return ctx;
|
|
62
100
|
}
|
|
63
101
|
/**
|
|
@@ -99,7 +137,8 @@ export function useFocusState(focusId) {
|
|
|
99
137
|
*/
|
|
100
138
|
export function useModalMissListener(cb, options) {
|
|
101
139
|
const ctx = useContext(KeyboardContext);
|
|
102
|
-
const
|
|
140
|
+
const modalCtx = useContext(ModalContext);
|
|
141
|
+
const modalId = modalCtx?.id ?? null;
|
|
103
142
|
useEffect(() => {
|
|
104
143
|
if (!ctx || !modalId)
|
|
105
144
|
return;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Context that provides the modal ID when rendering
|
|
3
|
-
* a modal component. Null when rendering a regular screen.
|
|
2
|
+
* Context that provides the modal ID and origin component when rendering
|
|
3
|
+
* inside a modal component. Null when rendering a regular screen.
|
|
4
|
+
*
|
|
5
|
+
* The {@link originComponent} field is set only for persistent modals —
|
|
6
|
+
* it tracks which screen opened the modal and enables automatic keyboard
|
|
7
|
+
* layer push/pop as the user navigates between screens.
|
|
4
8
|
*
|
|
5
9
|
* Used by the keyboard system to isolate per-modal keyboard layers
|
|
6
10
|
* by modal ID instead of component type (enabling multiple instances
|
|
@@ -8,4 +12,9 @@
|
|
|
8
12
|
*
|
|
9
13
|
* Architecturally symmetric to {@link OverlayContext}.
|
|
10
14
|
*/
|
|
11
|
-
export declare const ModalContext: import("react").Context<
|
|
15
|
+
export declare const ModalContext: import("react").Context<{
|
|
16
|
+
/** Unique modal identifier. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** Screen that opened this modal (set only when persistent). */
|
|
19
|
+
originComponent?: React.ComponentType<any>;
|
|
20
|
+
} | null>;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createContext } from 'react';
|
|
2
2
|
/**
|
|
3
|
-
* Context that provides the modal ID when rendering
|
|
4
|
-
* a modal component. Null when rendering a regular screen.
|
|
3
|
+
* Context that provides the modal ID and origin component when rendering
|
|
4
|
+
* inside a modal component. Null when rendering a regular screen.
|
|
5
|
+
*
|
|
6
|
+
* The {@link originComponent} field is set only for persistent modals —
|
|
7
|
+
* it tracks which screen opened the modal and enables automatic keyboard
|
|
8
|
+
* layer push/pop as the user navigates between screens.
|
|
5
9
|
*
|
|
6
10
|
* Used by the keyboard system to isolate per-modal keyboard layers
|
|
7
11
|
* by modal ID instead of component type (enabling multiple instances
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Context that provides the overlay ID when rendering
|
|
3
|
-
* an overlay component. Null when rendering a regular screen.
|
|
2
|
+
* Context that provides the overlay ID and origin component when rendering
|
|
3
|
+
* inside an overlay component. Null when rendering a regular screen.
|
|
4
|
+
*
|
|
5
|
+
* The {@link originComponent} field is set only for persistent overlays —
|
|
6
|
+
* it tracks which screen opened the overlay and enables automatic keyboard
|
|
7
|
+
* layer push/pop as the user navigates between screens.
|
|
4
8
|
*
|
|
5
9
|
* Used by the keyboard system to isolate per-overlay keyboard layers
|
|
6
10
|
* by overlay ID instead of component type (enabling multiple instances
|
|
7
11
|
* of the same component as different overlays).
|
|
12
|
+
*
|
|
13
|
+
* Architecturally symmetric to {@link ModalContext}.
|
|
8
14
|
*/
|
|
9
|
-
export declare const OverlayContext: import("react").Context<
|
|
15
|
+
export declare const OverlayContext: import("react").Context<{
|
|
16
|
+
/** Unique overlay identifier. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** Screen that opened this overlay (set only when persistent). */
|
|
19
|
+
originComponent?: React.ComponentType<any>;
|
|
20
|
+
} | null>;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { createContext } from 'react';
|
|
2
2
|
/**
|
|
3
|
-
* Context that provides the overlay ID when rendering
|
|
4
|
-
* an overlay component. Null when rendering a regular screen.
|
|
3
|
+
* Context that provides the overlay ID and origin component when rendering
|
|
4
|
+
* inside an overlay component. Null when rendering a regular screen.
|
|
5
|
+
*
|
|
6
|
+
* The {@link originComponent} field is set only for persistent overlays —
|
|
7
|
+
* it tracks which screen opened the overlay and enables automatic keyboard
|
|
8
|
+
* layer push/pop as the user navigates between screens.
|
|
5
9
|
*
|
|
6
10
|
* Used by the keyboard system to isolate per-overlay keyboard layers
|
|
7
11
|
* by overlay ID instead of component type (enabling multiple instances
|
|
8
12
|
* of the same component as different overlays).
|
|
13
|
+
*
|
|
14
|
+
* Architecturally symmetric to {@link ModalContext}.
|
|
9
15
|
*/
|
|
10
16
|
export const OverlayContext = createContext(null);
|
|
@@ -24,7 +24,7 @@ export function CurrentScreen() {
|
|
|
24
24
|
const entry = displayedOverlays[i];
|
|
25
25
|
if (!entry)
|
|
26
26
|
return overlayNode;
|
|
27
|
-
return React.createElement(OverlayContext.Provider, { value: entry.id, key: `ovl-ctx-${entry.id}` }, overlayNode);
|
|
27
|
+
return React.createElement(OverlayContext.Provider, { value: { id: entry.id, originComponent: entry.originComponent }, key: `ovl-ctx-${entry.id}` }, overlayNode);
|
|
28
28
|
});
|
|
29
29
|
// Build modal elements with ModalContext wrappers (symmetric to overlays).
|
|
30
30
|
// Uses renderedModalEntries (parallel to currentModals) for correct ID matching.
|
|
@@ -32,7 +32,10 @@ export function CurrentScreen() {
|
|
|
32
32
|
const entry = renderedModalEntries[i];
|
|
33
33
|
if (!entry)
|
|
34
34
|
return modalNode;
|
|
35
|
-
return React.createElement(ModalContext.Provider, { value: entry.id, key: `mdl-ctx-${entry.id}` }, modalNode);
|
|
35
|
+
return React.createElement(ModalContext.Provider, { value: { id: entry.id, originComponent: entry.originComponent }, key: `mdl-ctx-${entry.id}` }, modalNode);
|
|
36
36
|
});
|
|
37
|
-
return React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
|
|
37
|
+
return (React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
|
|
38
|
+
currentScreen,
|
|
39
|
+
wrappedOverlays.map((w) => w),
|
|
40
|
+
wrappedModals.map((w) => w)));
|
|
38
41
|
}
|
|
@@ -23,19 +23,23 @@ export declare function gotoScreen<C extends React.ComponentType<any>>(component
|
|
|
23
23
|
* Open a floating overlay on top of the current screen stack.
|
|
24
24
|
*
|
|
25
25
|
* Multiple overlays can be open simultaneously, distinguished by unique IDs.
|
|
26
|
+
* Calling with an ID that already exists is a no-op — the existing overlay is
|
|
27
|
+
* left unchanged.
|
|
26
28
|
*
|
|
27
29
|
* @param id Unique identifier for this overlay.
|
|
28
30
|
* @param component The overlay component (must be registered).
|
|
29
31
|
* @param params Props to pass to the overlay component.
|
|
30
32
|
* @param options Optional activation and zIndex settings.
|
|
31
33
|
*
|
|
32
|
-
* @throws If the provider is not mounted
|
|
33
|
-
* or the ID is already in use.
|
|
34
|
+
* @throws If the provider is not mounted or the component is not registered.
|
|
34
35
|
*/
|
|
35
36
|
export declare function openOverlay<C extends React.ComponentType<any>>(id: string, component: C, params: React.ComponentProps<C>, options?: OpenOverlayOptions): void;
|
|
36
37
|
/**
|
|
37
38
|
* Close a specific overlay by its ID.
|
|
38
39
|
*
|
|
40
|
+
* If no overlay with the given ID exists, this is a no-op — safe to call
|
|
41
|
+
* even when the overlay may have already been closed.
|
|
42
|
+
*
|
|
39
43
|
* @param id The ID of the overlay to close.
|
|
40
44
|
*
|
|
41
45
|
* @throws If the provider is not mounted.
|
|
@@ -70,13 +74,15 @@ export declare function deactivateOverlay(id: string): void;
|
|
|
70
74
|
* The active modal receives absolute keyboard priority, consuming all
|
|
71
75
|
* keyboard events before they reach overlays or screens.
|
|
72
76
|
*
|
|
77
|
+
* Calling with an ID that already exists is a no-op — the existing modal
|
|
78
|
+
* is left unchanged. This supports simple toggle bindings without guard refs.
|
|
79
|
+
*
|
|
73
80
|
* @param id Unique identifier for this modal.
|
|
74
81
|
* @param component The modal component (must be registered).
|
|
75
82
|
* @param params Props to pass to the modal component.
|
|
76
83
|
* @param options Optional zIndex and renderNow settings.
|
|
77
84
|
*
|
|
78
|
-
* @throws If the provider is not mounted
|
|
79
|
-
* or the ID is already in use.
|
|
85
|
+
* @throws If the provider is not mounted or the component is not registered.
|
|
80
86
|
*/
|
|
81
87
|
export declare function openModal<C extends React.ComponentType<any>>(id: string, component: C, params: React.ComponentProps<C>, options?: OpenModalOptions): void;
|
|
82
88
|
/**
|
|
@@ -85,6 +91,9 @@ export declare function openModal<C extends React.ComponentType<any>>(id: string
|
|
|
85
91
|
* When the active modal is closed, the modal with the next highest zIndex
|
|
86
92
|
* automatically becomes active.
|
|
87
93
|
*
|
|
94
|
+
* If no modal with the given ID exists, this is a no-op — safe to call
|
|
95
|
+
* even when the modal may have already been closed.
|
|
96
|
+
*
|
|
88
97
|
* @param id The ID of the modal to close.
|
|
89
98
|
*
|
|
90
99
|
* @throws If the provider is not mounted.
|
package/dist/screen/provider.js
CHANGED
|
@@ -80,14 +80,15 @@ export function gotoScreen(component, params) {
|
|
|
80
80
|
* Open a floating overlay on top of the current screen stack.
|
|
81
81
|
*
|
|
82
82
|
* Multiple overlays can be open simultaneously, distinguished by unique IDs.
|
|
83
|
+
* Calling with an ID that already exists is a no-op — the existing overlay is
|
|
84
|
+
* left unchanged.
|
|
83
85
|
*
|
|
84
86
|
* @param id Unique identifier for this overlay.
|
|
85
87
|
* @param component The overlay component (must be registered).
|
|
86
88
|
* @param params Props to pass to the overlay component.
|
|
87
89
|
* @param options Optional activation and zIndex settings.
|
|
88
90
|
*
|
|
89
|
-
* @throws If the provider is not mounted
|
|
90
|
-
* or the ID is already in use.
|
|
91
|
+
* @throws If the provider is not mounted or the component is not registered.
|
|
91
92
|
*/
|
|
92
93
|
export function openOverlay(id, component, params, options) {
|
|
93
94
|
if (!hasComponent(component)) {
|
|
@@ -100,11 +101,15 @@ export function openOverlay(id, component, params, options) {
|
|
|
100
101
|
params: params,
|
|
101
102
|
activate: options?.activate ?? true,
|
|
102
103
|
zIndex: options?.zIndex,
|
|
104
|
+
persistent: options?.persistent,
|
|
103
105
|
});
|
|
104
106
|
}
|
|
105
107
|
/**
|
|
106
108
|
* Close a specific overlay by its ID.
|
|
107
109
|
*
|
|
110
|
+
* If no overlay with the given ID exists, this is a no-op — safe to call
|
|
111
|
+
* even when the overlay may have already been closed.
|
|
112
|
+
*
|
|
108
113
|
* @param id The ID of the overlay to close.
|
|
109
114
|
*
|
|
110
115
|
* @throws If the provider is not mounted.
|
|
@@ -147,13 +152,15 @@ export function deactivateOverlay(id) {
|
|
|
147
152
|
* The active modal receives absolute keyboard priority, consuming all
|
|
148
153
|
* keyboard events before they reach overlays or screens.
|
|
149
154
|
*
|
|
155
|
+
* Calling with an ID that already exists is a no-op — the existing modal
|
|
156
|
+
* is left unchanged. This supports simple toggle bindings without guard refs.
|
|
157
|
+
*
|
|
150
158
|
* @param id Unique identifier for this modal.
|
|
151
159
|
* @param component The modal component (must be registered).
|
|
152
160
|
* @param params Props to pass to the modal component.
|
|
153
161
|
* @param options Optional zIndex and renderNow settings.
|
|
154
162
|
*
|
|
155
|
-
* @throws If the provider is not mounted
|
|
156
|
-
* or the ID is already in use.
|
|
163
|
+
* @throws If the provider is not mounted or the component is not registered.
|
|
157
164
|
*/
|
|
158
165
|
export function openModal(id, component, params, options) {
|
|
159
166
|
if (!hasComponent(component)) {
|
|
@@ -165,7 +172,8 @@ export function openModal(id, component, params, options) {
|
|
|
165
172
|
component,
|
|
166
173
|
params: params,
|
|
167
174
|
zIndex: options?.zIndex,
|
|
168
|
-
renderNow: options?.renderNow,
|
|
175
|
+
renderNow: options?.renderNow ?? false,
|
|
176
|
+
persistent: options?.persistent,
|
|
169
177
|
});
|
|
170
178
|
}
|
|
171
179
|
/**
|
|
@@ -174,6 +182,9 @@ export function openModal(id, component, params, options) {
|
|
|
174
182
|
* When the active modal is closed, the modal with the next highest zIndex
|
|
175
183
|
* automatically becomes active.
|
|
176
184
|
*
|
|
185
|
+
* If no modal with the given ID exists, this is a no-op — safe to call
|
|
186
|
+
* even when the modal may have already been closed.
|
|
187
|
+
*
|
|
177
188
|
* @param id The ID of the modal to close.
|
|
178
189
|
*
|
|
179
190
|
* @throws If the provider is not mounted.
|
|
@@ -223,6 +234,30 @@ function buildPathFrom(ancestor, target) {
|
|
|
223
234
|
path.reverse();
|
|
224
235
|
return path;
|
|
225
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Rebuild active IDs for overlays and modals after navigation.
|
|
239
|
+
*
|
|
240
|
+
* Only persistent entries survive navigation. Among them, only those
|
|
241
|
+
* whose originComponent matches the current top-of-path screen are
|
|
242
|
+
* re-activated.
|
|
243
|
+
*
|
|
244
|
+
* @2026-07-04 v3.8.0
|
|
245
|
+
*/
|
|
246
|
+
function recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTopScreen) {
|
|
247
|
+
const activeOverlayIds = new Set();
|
|
248
|
+
for (const o of persistentOverlays) {
|
|
249
|
+
if (o.originComponent === newTopScreen) {
|
|
250
|
+
activeOverlayIds.add(o.id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
let activeModalId = null;
|
|
254
|
+
for (const m of persistentModals) {
|
|
255
|
+
if (m.originComponent === newTopScreen) {
|
|
256
|
+
activeModalId = m.id;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { activeOverlayIds, activeModalId };
|
|
260
|
+
}
|
|
226
261
|
/**
|
|
227
262
|
* Pure reducer for {@link ScreenState}.
|
|
228
263
|
*
|
|
@@ -230,7 +265,9 @@ function buildPathFrom(ancestor, target) {
|
|
|
230
265
|
* (cross-branch), openOverlay, closeOverlay, closeAllOverlays,
|
|
231
266
|
* activateOverlay, and deactivateOverlay.
|
|
232
267
|
*
|
|
233
|
-
* Navigation actions
|
|
268
|
+
* Navigation actions filter out non-persistent overlays/modals and
|
|
269
|
+
* recalculate active IDs based on whether the origin screen of each
|
|
270
|
+
* persistent entry is at the top of the new path.
|
|
234
271
|
*/
|
|
235
272
|
function screenReducer(state, action) {
|
|
236
273
|
switch (action.type) {
|
|
@@ -245,13 +282,18 @@ function screenReducer(state, action) {
|
|
|
245
282
|
: state.counter + 1;
|
|
246
283
|
const template = getTemplate(action.component) ?? {};
|
|
247
284
|
const mergedParams = { ...template, ...action.params };
|
|
285
|
+
const newPath = [...state.path, action.component];
|
|
286
|
+
const persistentOverlays = state.overlays.filter(o => o.persistent);
|
|
287
|
+
const persistentModals = state.modals.filter(m => m.persistent);
|
|
288
|
+
const newTop = newPath[newPath.length - 1];
|
|
289
|
+
const { activeOverlayIds, activeModalId } = recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTop);
|
|
248
290
|
return {
|
|
249
|
-
path:
|
|
291
|
+
path: newPath,
|
|
250
292
|
pathParams: [...state.pathParams, mergedParams],
|
|
251
|
-
overlays:
|
|
252
|
-
activeOverlayIds
|
|
253
|
-
modals:
|
|
254
|
-
activeModalId
|
|
293
|
+
overlays: persistentOverlays,
|
|
294
|
+
activeOverlayIds,
|
|
295
|
+
modals: persistentModals,
|
|
296
|
+
activeModalId,
|
|
255
297
|
counter,
|
|
256
298
|
};
|
|
257
299
|
}
|
|
@@ -262,13 +304,18 @@ function screenReducer(state, action) {
|
|
|
262
304
|
? '[Ink-Cartridge] back() failed: already at the root node, cannot go back.'
|
|
263
305
|
: `[Ink-Cartridge] back(${levels}) failed: current depth is ${state.path.length}, cannot go back ${levels} levels.`);
|
|
264
306
|
}
|
|
307
|
+
const newPath = state.path.slice(0, -levels);
|
|
308
|
+
const persistentOverlays = state.overlays.filter(o => o.persistent);
|
|
309
|
+
const persistentModals = state.modals.filter(m => m.persistent);
|
|
310
|
+
const newTop = newPath[newPath.length - 1];
|
|
311
|
+
const { activeOverlayIds, activeModalId } = recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTop);
|
|
265
312
|
return {
|
|
266
|
-
path:
|
|
313
|
+
path: newPath,
|
|
267
314
|
pathParams: state.pathParams.slice(0, -levels),
|
|
268
|
-
overlays:
|
|
269
|
-
activeOverlayIds
|
|
270
|
-
modals:
|
|
271
|
-
activeModalId
|
|
315
|
+
overlays: persistentOverlays,
|
|
316
|
+
activeOverlayIds,
|
|
317
|
+
modals: persistentModals,
|
|
318
|
+
activeModalId,
|
|
272
319
|
counter: state.counter + 1,
|
|
273
320
|
};
|
|
274
321
|
}
|
|
@@ -292,22 +339,26 @@ function screenReducer(state, action) {
|
|
|
292
339
|
return comp === action.component ? mergedParams : tpl;
|
|
293
340
|
}),
|
|
294
341
|
];
|
|
342
|
+
const persistentOverlays = state.overlays.filter(o => o.persistent);
|
|
343
|
+
const persistentModals = state.modals.filter(m => m.persistent);
|
|
344
|
+
const newTop = newPath[newPath.length - 1];
|
|
345
|
+
const { activeOverlayIds, activeModalId } = recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTop);
|
|
295
346
|
return {
|
|
296
347
|
path: newPath,
|
|
297
348
|
pathParams: newPathParams,
|
|
298
|
-
overlays:
|
|
299
|
-
activeOverlayIds
|
|
300
|
-
modals:
|
|
301
|
-
activeModalId
|
|
349
|
+
overlays: persistentOverlays,
|
|
350
|
+
activeOverlayIds,
|
|
351
|
+
modals: persistentModals,
|
|
352
|
+
activeModalId,
|
|
302
353
|
counter: state.counter + 1,
|
|
303
354
|
};
|
|
304
355
|
}
|
|
305
356
|
case 'openOverlay': {
|
|
306
357
|
if (state.overlays.some(o => o.id === action.id)) {
|
|
307
|
-
|
|
358
|
+
return state;
|
|
308
359
|
}
|
|
309
360
|
if (state.modals.some(m => m.id === action.id)) {
|
|
310
|
-
|
|
361
|
+
return state;
|
|
311
362
|
}
|
|
312
363
|
const newEntry = {
|
|
313
364
|
id: action.id,
|
|
@@ -315,6 +366,10 @@ function screenReducer(state, action) {
|
|
|
315
366
|
props: action.params,
|
|
316
367
|
zIndex: action.zIndex ?? state.overlays.length,
|
|
317
368
|
createdAt: Date.now(),
|
|
369
|
+
persistent: action.persistent,
|
|
370
|
+
originComponent: action.persistent
|
|
371
|
+
? state.path[state.path.length - 1]
|
|
372
|
+
: undefined,
|
|
318
373
|
};
|
|
319
374
|
const newOverlays = sortOverlays([...state.overlays, newEntry]);
|
|
320
375
|
const newActiveIds = new Set(state.activeOverlayIds);
|
|
@@ -329,7 +384,7 @@ function screenReducer(state, action) {
|
|
|
329
384
|
}
|
|
330
385
|
case 'closeOverlay': {
|
|
331
386
|
if (!state.overlays.some(o => o.id === action.id)) {
|
|
332
|
-
|
|
387
|
+
return state;
|
|
333
388
|
}
|
|
334
389
|
const newOverlays = state.overlays.filter(o => o.id !== action.id);
|
|
335
390
|
const newActiveIds = new Set(state.activeOverlayIds);
|
|
@@ -371,10 +426,10 @@ function screenReducer(state, action) {
|
|
|
371
426
|
}
|
|
372
427
|
case 'openModal': {
|
|
373
428
|
if (state.modals.some(m => m.id === action.id)) {
|
|
374
|
-
|
|
429
|
+
return state;
|
|
375
430
|
}
|
|
376
431
|
if (state.overlays.some(o => o.id === action.id)) {
|
|
377
|
-
|
|
432
|
+
return state;
|
|
378
433
|
}
|
|
379
434
|
const newEntry = {
|
|
380
435
|
id: action.id,
|
|
@@ -383,6 +438,10 @@ function screenReducer(state, action) {
|
|
|
383
438
|
zIndex: action.zIndex ?? state.modals.length,
|
|
384
439
|
createdAt: Date.now(),
|
|
385
440
|
renderNow: action.renderNow ?? false,
|
|
441
|
+
persistent: action.persistent,
|
|
442
|
+
originComponent: action.persistent
|
|
443
|
+
? state.path[state.path.length - 1]
|
|
444
|
+
: undefined,
|
|
386
445
|
};
|
|
387
446
|
const newModals = sortModals([...state.modals, newEntry]);
|
|
388
447
|
// The last element (highest zIndex) is the active modal
|
|
@@ -395,7 +454,7 @@ function screenReducer(state, action) {
|
|
|
395
454
|
}
|
|
396
455
|
case 'closeModal': {
|
|
397
456
|
if (!state.modals.some(m => m.id === action.id)) {
|
|
398
|
-
|
|
457
|
+
return state;
|
|
399
458
|
}
|
|
400
459
|
const newModals = state.modals.filter(m => m.id !== action.id);
|
|
401
460
|
const activeId = newModals.length > 0 ? newModals[newModals.length - 1].id : null;
|
|
@@ -502,6 +561,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
|
|
|
502
561
|
params: params,
|
|
503
562
|
activate: options?.activate ?? true,
|
|
504
563
|
zIndex: options?.zIndex,
|
|
564
|
+
persistent: options?.persistent,
|
|
505
565
|
});
|
|
506
566
|
}, []);
|
|
507
567
|
const closeOverlayInContext = useMemo(() => (id) => dispatch({ type: 'closeOverlay', id }), []);
|
|
@@ -519,6 +579,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
|
|
|
519
579
|
params: params,
|
|
520
580
|
zIndex: options?.zIndex,
|
|
521
581
|
renderNow: options?.renderNow,
|
|
582
|
+
persistent: options?.persistent,
|
|
522
583
|
});
|
|
523
584
|
}, []);
|
|
524
585
|
const closeModalInContext = useMemo(() => (id) => dispatch({ type: 'closeModal', id }), []);
|
package/dist/screen/types.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface OpenOverlayOptions {
|
|
|
21
21
|
activate?: boolean;
|
|
22
22
|
/** Visual stacking order. Smaller values render behind larger values. */
|
|
23
23
|
zIndex?: number;
|
|
24
|
+
/** Whether this overlay survives screen navigation (skip/back/gotoScreen). */
|
|
25
|
+
persistent?: boolean;
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
26
28
|
* Options for {@link openModal}.
|
|
@@ -30,6 +32,8 @@ export interface OpenModalOptions {
|
|
|
30
32
|
zIndex?: number;
|
|
31
33
|
/** Whether to render the modal even when it is not the active modal (zIndex not highest). Defaults to false. */
|
|
32
34
|
renderNow?: boolean;
|
|
35
|
+
/** Whether this modal survives screen navigation (skip/back/gotoScreen). */
|
|
36
|
+
persistent?: boolean;
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
39
|
* A single overlay entry in the multi-overlay system.
|
|
@@ -45,6 +49,15 @@ export interface OverlayEntry {
|
|
|
45
49
|
zIndex: number;
|
|
46
50
|
/** Timestamp for tie-breaking when zIndex values are equal. */
|
|
47
51
|
createdAt: number;
|
|
52
|
+
/**
|
|
53
|
+
* When true, this overlay survives screen navigation (skip/back/gotoScreen).
|
|
54
|
+
* Non-persistent overlays are cleared on navigation. A persistent overlay
|
|
55
|
+
* is automatically re-activated when navigation returns to its originating
|
|
56
|
+
* screen and deactivated when navigating away.
|
|
57
|
+
*/
|
|
58
|
+
persistent?: boolean;
|
|
59
|
+
/** When persistent, the screen component that opened this overlay — used to restore keyboard activation when returning to that screen. */
|
|
60
|
+
originComponent?: React.ComponentType<any>;
|
|
48
61
|
}
|
|
49
62
|
/**
|
|
50
63
|
* A single modal entry in the modal system.
|
|
@@ -66,6 +79,15 @@ export interface ModalEntry {
|
|
|
66
79
|
createdAt: number;
|
|
67
80
|
/** Whether to render even when not the active modal. Defaults to false. */
|
|
68
81
|
renderNow: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* When true, this modal survives screen navigation (skip/back/gotoScreen).
|
|
84
|
+
* Non-persistent modals are cleared on navigation. A persistent modal is
|
|
85
|
+
* automatically re-activated when navigation returns to its originating
|
|
86
|
+
* screen and deactivated when navigating away.
|
|
87
|
+
*/
|
|
88
|
+
persistent?: boolean;
|
|
89
|
+
/** When persistent, the screen component that opened this modal — used to restore activation when returning to that screen. */
|
|
90
|
+
originComponent?: React.ComponentType<any>;
|
|
69
91
|
}
|
|
70
92
|
/**
|
|
71
93
|
* Internal state of the screen management provider.
|
|
@@ -125,6 +147,8 @@ export interface OpenOverlayAction {
|
|
|
125
147
|
activate: boolean;
|
|
126
148
|
/** Optional zIndex for visual stacking. */
|
|
127
149
|
zIndex?: number;
|
|
150
|
+
/** Whether this overlay survives screen navigation. */
|
|
151
|
+
persistent?: boolean;
|
|
128
152
|
}
|
|
129
153
|
/** Action dispatched when closing a specific overlay by ID. */
|
|
130
154
|
export interface CloseOverlayAction {
|
|
@@ -161,6 +185,8 @@ export interface OpenModalAction {
|
|
|
161
185
|
zIndex?: number;
|
|
162
186
|
/** Whether to render even when not active. Defaults to false. */
|
|
163
187
|
renderNow?: boolean;
|
|
188
|
+
/** Whether this modal survives screen navigation. */
|
|
189
|
+
persistent?: boolean;
|
|
164
190
|
}
|
|
165
191
|
/** Action dispatched when closing a specific modal by ID. */
|
|
166
192
|
export interface CloseModalAction {
|