170 lines
5.5 KiB
TypeScript
170 lines
5.5 KiB
TypeScript
/**
|
|
* Input Layer System — stack-based keyboard input capture for dialogs and overlays.
|
|
*
|
|
* Only "capturing" components (dialogs, overlays, import flows) register layers.
|
|
* When any layer exists on the stack, all non-capturing input handlers are blocked.
|
|
*
|
|
* Hooks:
|
|
* - `useInputLayer(id)` — push a capturing layer (dialogs/overlays).
|
|
* - `useLayeredInput(id, …)` — handle input for a specific capturing layer.
|
|
* - `useBlockableInput(…)` — handle input for screens / global keys; auto-blocked
|
|
* when any capturing layer is on the stack.
|
|
* - `useIsInputCaptured()` — returns true when a capturing layer is present
|
|
* (useful for disabling `focus` on child components).
|
|
*/
|
|
|
|
import React, {
|
|
createContext,
|
|
useContext,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { useInput } from 'ink';
|
|
|
|
// ── Context ──────────────────────────────────────────────────────────────────
|
|
|
|
interface InputLayerContextType {
|
|
/** Push a capturing layer. Returns a cleanup that pops it. */
|
|
push: (layerId: string) => () => void;
|
|
/** True when `layerId` is the topmost entry in the stack. */
|
|
isTop: (layerId: string) => boolean;
|
|
/** True when the stack has no entries (no dialog/overlay is capturing). */
|
|
isStackEmpty: () => boolean;
|
|
/** Monotonic counter — bumped on every push/pop so consumers re-render. */
|
|
version: number;
|
|
}
|
|
|
|
const InputLayerContext = createContext<InputLayerContextType | null>(null);
|
|
|
|
// ── Provider ─────────────────────────────────────────────────────────────────
|
|
|
|
interface InputLayerProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Wraps the component tree and provides the input-layer stack.
|
|
*
|
|
* Place this inside your outermost providers but above any component
|
|
* that calls the input-layer hooks.
|
|
*/
|
|
export function InputLayerProvider({ children }: InputLayerProviderProps): React.ReactElement {
|
|
const stackRef = useRef<string[]>([]);
|
|
const [version, setVersion] = useState(0);
|
|
|
|
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
|
|
const push = useCallback(
|
|
(layerId: string): (() => void) => {
|
|
stackRef.current = [...stackRef.current, layerId];
|
|
bump();
|
|
return () => {
|
|
stackRef.current = stackRef.current.filter((id) => id !== layerId);
|
|
bump();
|
|
};
|
|
},
|
|
[bump],
|
|
);
|
|
|
|
const isTop = useCallback(
|
|
(layerId: string): boolean => {
|
|
const s = stackRef.current;
|
|
return s.length > 0 && s[s.length - 1] === layerId;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const isStackEmpty = useCallback(
|
|
(): boolean => stackRef.current.length === 0,
|
|
[],
|
|
);
|
|
|
|
const value = useMemo<InputLayerContextType>(
|
|
() => ({ push, isTop, isStackEmpty, version }),
|
|
[push, isTop, isStackEmpty, version],
|
|
);
|
|
|
|
return (
|
|
<InputLayerContext.Provider value={value}>
|
|
{children}
|
|
</InputLayerContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Register a **capturing** layer (dialog / overlay / import flow).
|
|
*
|
|
* Pushes on mount, pops on unmount. While this layer is present every
|
|
* `useBlockableInput` handler in the tree is automatically disabled.
|
|
*
|
|
* @returns `{ isActive }` — true only when this layer is the topmost.
|
|
*/
|
|
export function useInputLayer(layerId: string): { isActive: boolean } {
|
|
const ctx = useContext(InputLayerContext);
|
|
if (!ctx) {
|
|
throw new Error('useInputLayer must be used within an InputLayerProvider');
|
|
}
|
|
|
|
const { push } = ctx;
|
|
useEffect(() => {
|
|
const pop = push(layerId);
|
|
return pop;
|
|
}, [push, layerId]);
|
|
|
|
return { isActive: ctx.isTop(layerId) };
|
|
}
|
|
|
|
/**
|
|
* Input handler for a **capturing** layer.
|
|
*
|
|
* Only fires when `layerId` is the topmost entry in the stack.
|
|
*/
|
|
export function useLayeredInput(
|
|
layerId: string,
|
|
handler: (input: string, key: any) => void,
|
|
options?: { isActive?: boolean },
|
|
): void {
|
|
const ctx = useContext(InputLayerContext);
|
|
if (!ctx) {
|
|
throw new Error('useLayeredInput must be used within an InputLayerProvider');
|
|
}
|
|
|
|
const isTopLayer = ctx.isTop(layerId);
|
|
const externalActive = options?.isActive !== false;
|
|
useInput(handler, { isActive: isTopLayer && externalActive });
|
|
}
|
|
|
|
/**
|
|
* Input handler for **non-capturing** components (screens, global keys).
|
|
*
|
|
* Fires only when the capture stack is empty (no dialog/overlay is open).
|
|
* This is the hook screens should use instead of raw `useInput`.
|
|
*/
|
|
export function useBlockableInput(
|
|
handler: (input: string, key: any) => void,
|
|
options?: { isActive?: boolean },
|
|
): void {
|
|
const ctx = useContext(InputLayerContext);
|
|
|
|
const nothingCapturing = ctx ? ctx.isStackEmpty() : true;
|
|
const externalActive = options?.isActive !== false;
|
|
useInput(handler, { isActive: nothingCapturing && externalActive });
|
|
}
|
|
|
|
/**
|
|
* Returns `true` when any capturing layer is on the stack.
|
|
*
|
|
* Use this to disable `focus` props on child components (e.g. ScrollableList)
|
|
* so their internal `useInput` handlers don't fire while a dialog is open.
|
|
*/
|
|
export function useIsInputCaptured(): boolean {
|
|
const ctx = useContext(InputLayerContext);
|
|
return ctx ? !ctx.isStackEmpty() : false;
|
|
}
|