Fix dialog focus

This commit is contained in:
2026-03-23 03:51:51 +00:00
parent a28d43a68b
commit 7fd89c5663
18 changed files with 403 additions and 177 deletions

View File

@@ -11,3 +11,10 @@ export {
useCreateInvitation,
useInvitationIds,
} from './useInvitations.js';
export {
InputLayerProvider,
useInputLayer,
useLayeredInput,
useBlockableInput,
useIsInputCaptured,
} from './useInputLayer.js';

View 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;
}