Fix dialog focus
This commit is contained in:
169
src/tui/hooks/useInputLayer.tsx
Normal file
169
src/tui/hooks/useInputLayer.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user