feat: keyboard shortcuts for approvals (#869)

* wip

* cmd click shortcut to deny approval

* cleaner type

* show percentage in progress bar

* improve structure of PendingApprovalComponent

* enter to approve request

* disable kanban scope

* fix approval scope selection
This commit is contained in:
Gabriel Gordon-Hall
2025-09-30 11:40:34 +01:00
committed by GitHub
parent 23243dda7a
commit 6727e2dbb9
7 changed files with 420 additions and 202 deletions

View File

@@ -1,4 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { ApprovalStatus, ToolStatus } from 'shared/types'; import type { ApprovalStatus, ToolStatus } from 'shared/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -13,14 +20,20 @@ import { approvalsApi } from '@/lib/api';
import { Check, X } from 'lucide-react'; import { Check, X } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { TabNavContext } from '@/contexts/TabNavigationContext';
import { useKeyApproveRequest, useKeyDenyApproval, Scope } from '@/keyboard';
const DEFAULT_DENIAL_REASON = 'User denied this tool use request.'; const DEFAULT_DENIAL_REASON = 'User denied this tool use request.';
// ---------- Types ----------
interface PendingApprovalEntryProps { interface PendingApprovalEntryProps {
pendingStatus: Extract<ToolStatus, { status: 'pending_approval' }>; pendingStatus: Extract<ToolStatus, { status: 'pending_approval' }>;
executionProcessId?: string; executionProcessId?: string;
children: ReactNode; children: ReactNode;
} }
// ---------- Utils ----------
function formatSeconds(s: number) { function formatSeconds(s: number) {
if (s <= 0) return '0s'; if (s <= 0) return '0s';
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
@@ -28,113 +41,324 @@ function formatSeconds(s: number) {
return m > 0 ? `${m}m ${rem}s` : `${rem}s`; return m > 0 ? `${m}m ${rem}s` : `${rem}s`;
} }
// ---------- Hooks ----------
function useAbortController() {
const ref = useRef<AbortController | null>(null);
useEffect(() => () => ref.current?.abort(), []);
return ref;
}
function useApprovalCountdown(
requestedAt: string | number | Date,
timeoutAt: string | number | Date,
paused: boolean
) {
const totalSeconds = useMemo(() => {
const total = Math.floor(
(new Date(timeoutAt).getTime() - new Date(requestedAt).getTime()) / 1000
);
return Math.max(1, total);
}, [requestedAt, timeoutAt]);
const [timeLeft, setTimeLeft] = useState<number>(() => {
const remaining = new Date(timeoutAt).getTime() - Date.now();
return Math.max(0, Math.floor(remaining / 1000));
});
useEffect(() => {
if (paused) return;
const id = window.setInterval(() => {
const remaining = new Date(timeoutAt).getTime() - Date.now();
const next = Math.max(0, Math.floor(remaining / 1000));
setTimeLeft(next);
if (next <= 0) window.clearInterval(id);
}, 1000);
return () => window.clearInterval(id);
}, [timeoutAt, paused]);
const percent = useMemo(
() =>
Math.max(0, Math.min(100, Math.round((timeLeft / totalSeconds) * 100))),
[timeLeft, totalSeconds]
);
return { timeLeft, percent };
}
// ---------- Subcomponents ----------
function ProgressWithTooltip({
visible,
timeLeft,
percent,
}: {
visible: boolean;
timeLeft: number;
percent: number;
}) {
if (!visible) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center pr-8">
<CircularProgress percent={percent} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{formatSeconds(timeLeft)} remaining</p>
</TooltipContent>
</Tooltip>
);
}
function ActionButtons({
disabled,
isResponding,
onApprove,
onStartDeny,
}: {
disabled: boolean;
isResponding: boolean;
onApprove: () => void;
onStartDeny: () => void;
}) {
return (
<div className="flex items-center gap-1.5 pr-4">
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onApprove}
variant="ghost"
className="h-8 w-8 rounded-full p-0"
disabled={disabled}
aria-label={isResponding ? 'Submitting approval' : 'Approve'}
aria-busy={isResponding}
>
<Check className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isResponding ? 'Submitting…' : 'Approve request'}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onStartDeny}
variant="ghost"
className="h-8 w-8 rounded-full p-0"
disabled={disabled}
aria-label={isResponding ? 'Submitting denial' : 'Deny'}
aria-busy={isResponding}
>
<X className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isResponding ? 'Submitting…' : 'Provide denial reason'}</p>
</TooltipContent>
</Tooltip>
</div>
);
}
function DenyReasonForm({
isResponding,
timeLeft,
percent,
value,
onChange,
onCancel,
onSubmit,
inputRef,
}: {
isResponding: boolean;
timeLeft: number;
percent: number;
value: string;
onChange: (v: string) => void;
onCancel: () => void;
onSubmit: () => void;
inputRef: React.RefObject<HTMLTextAreaElement>;
}) {
return (
<div className="mt-3 bg-background px-3 py-3 text-sm">
<Textarea
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Let the agent know why this request was denied..."
disabled={isResponding}
className="text-sm"
/>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<ProgressWithTooltip
visible={timeLeft > 0}
timeLeft={timeLeft}
percent={percent}
/>
<div className="flex items-center gap-2 text-sm">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
disabled={isResponding}
>
Cancel
</Button>
<Button size="sm" onClick={onSubmit} disabled={isResponding}>
Deny
</Button>
</div>
</div>
</div>
);
}
// ---------- Main Component ----------
const PendingApprovalEntry = ({ const PendingApprovalEntry = ({
pendingStatus, pendingStatus,
executionProcessId, executionProcessId,
children, children,
}: PendingApprovalEntryProps) => { }: PendingApprovalEntryProps) => {
const [timeLeft, setTimeLeft] = useState<number>(() => {
const remaining = new Date(pendingStatus.timeout_at).getTime() - Date.now();
return Math.max(0, Math.floor(remaining / 1000));
});
const [isResponding, setIsResponding] = useState(false); const [isResponding, setIsResponding] = useState(false);
const [hasResponded, setHasResponded] = useState(false); const [hasResponded, setHasResponded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isEnteringReason, setIsEnteringReason] = useState(false); const [isEnteringReason, setIsEnteringReason] = useState(false);
const [denyReason, setDenyReason] = useState(''); const [denyReason, setDenyReason] = useState('');
const abortRef = useRef<AbortController | null>(null);
const abortRef = useAbortController();
const denyReasonRef = useRef<HTMLTextAreaElement | null>(null); const denyReasonRef = useRef<HTMLTextAreaElement | null>(null);
const percent = useMemo(() => { const { enableScope, disableScope, activeScopes } = useHotkeysContext();
const total = Math.max( const tabNav = useContext(TabNavContext);
1, const isLogsTabActive = tabNav ? tabNav.activeTab === 'logs' : true;
Math.floor( const dialogScopeActive = activeScopes.includes(Scope.DIALOG);
(new Date(pendingStatus.timeout_at).getTime() - const shouldControlScopes = isLogsTabActive && !dialogScopeActive;
new Date(pendingStatus.requested_at).getTime()) / const approvalsScopeEnabledRef = useRef(false);
1000 const dialogScopeActiveRef = useRef(dialogScopeActive);
)
);
return Math.max(0, Math.min(100, Math.round((timeLeft / total) * 100)));
}, [pendingStatus.requested_at, pendingStatus.timeout_at, timeLeft]);
useEffect(() => { useEffect(() => {
if (hasResponded) return; dialogScopeActiveRef.current = dialogScopeActive;
}, [dialogScopeActive]);
const id = window.setInterval(() => { const { timeLeft, percent } = useApprovalCountdown(
const remaining = pendingStatus.requested_at,
new Date(pendingStatus.timeout_at).getTime() - Date.now(); pendingStatus.timeout_at,
const next = Math.max(0, Math.floor(remaining / 1000)); hasResponded
setTimeLeft(next); );
if (next <= 0) {
window.clearInterval(id);
}
}, 1000);
return () => window.clearInterval(id);
}, [pendingStatus.timeout_at, hasResponded]);
useEffect(() => () => abortRef.current?.abort(), []);
const disabled = isResponding || hasResponded || timeLeft <= 0; const disabled = isResponding || hasResponded || timeLeft <= 0;
const respond = async (approved: boolean, reason?: string) => { const shouldEnableApprovalsScope = shouldControlScopes && !disabled;
if (disabled) return;
if (!executionProcessId) { useEffect(() => {
setError('Missing executionProcessId'); const shouldEnable = shouldEnableApprovalsScope;
return;
if (shouldEnable && !approvalsScopeEnabledRef.current) {
enableScope(Scope.APPROVALS);
disableScope(Scope.KANBAN);
approvalsScopeEnabledRef.current = true;
} else if (!shouldEnable && approvalsScopeEnabledRef.current) {
disableScope(Scope.APPROVALS);
if (!dialogScopeActive) {
enableScope(Scope.KANBAN);
}
approvalsScopeEnabledRef.current = false;
} }
setIsResponding(true); return () => {
setError(null); if (approvalsScopeEnabledRef.current) {
const controller = new AbortController(); disableScope(Scope.APPROVALS);
abortRef.current = controller; if (!dialogScopeActiveRef.current) {
enableScope(Scope.KANBAN);
}
approvalsScopeEnabledRef.current = false;
}
};
}, [
disableScope,
enableScope,
dialogScopeActive,
shouldEnableApprovalsScope,
]);
const status: ApprovalStatus = approved const respond = useCallback(
? { status: 'approved' } async (approved: boolean, reason?: string) => {
: { status: 'denied', reason }; if (disabled) return;
if (!executionProcessId) {
setError('Missing executionProcessId');
return;
}
try { setIsResponding(true);
await approvalsApi.respond( setError(null);
pendingStatus.approval_id, const controller = new AbortController();
{ abortRef.current = controller;
execution_process_id: executionProcessId,
status,
},
controller.signal
);
setHasResponded(true); const status: ApprovalStatus = approved
setIsEnteringReason(false); ? { status: 'approved' }
setDenyReason(''); : { status: 'denied', reason };
} catch (e: any) {
console.error('Approval respond failed:', e);
setError(e?.message || 'Failed to send response');
} finally {
setIsResponding(false);
}
};
const handleApprove = () => respond(true); try {
const handleStartDeny = () => { await approvalsApi.respond(
pendingStatus.approval_id,
{ execution_process_id: executionProcessId, status },
controller.signal
);
setHasResponded(true);
setIsEnteringReason(false);
setDenyReason('');
} catch (e: any) {
console.error('Approval respond failed:', e);
setError(e?.message || 'Failed to send response');
} finally {
setIsResponding(false);
}
},
[abortRef, disabled, executionProcessId, pendingStatus.approval_id]
);
const handleApprove = useCallback(() => respond(true), [respond]);
const handleStartDeny = useCallback(() => {
if (disabled) return; if (disabled) return;
setError(null); setError(null);
setIsEnteringReason(true); setIsEnteringReason(true);
}; }, [disabled]);
const handleCancelDeny = () => { const handleCancelDeny = useCallback(() => {
if (isResponding) return; if (isResponding) return;
setIsEnteringReason(false); setIsEnteringReason(false);
setDenyReason(''); setDenyReason('');
}; }, [isResponding]);
const handleSubmitDeny = () => { const handleSubmitDeny = useCallback(() => {
const trimmed = denyReason.trim(); const trimmed = denyReason.trim();
respond(false, trimmed || DEFAULT_DENIAL_REASON); respond(false, trimmed || DEFAULT_DENIAL_REASON);
}; }, [denyReason, respond]);
useEffect(() => { const triggerDeny = useCallback(
if (!hasResponded) return; (event?: KeyboardEvent) => {
}, [hasResponded]); if (!isEnteringReason || disabled || hasResponded) return;
event?.preventDefault();
handleSubmitDeny();
},
[isEnteringReason, disabled, hasResponded, handleSubmitDeny]
);
useKeyApproveRequest(handleApprove, {
scope: Scope.APPROVALS,
when: () => shouldEnableApprovalsScope && !isEnteringReason,
preventDefault: true,
});
useKeyDenyApproval(triggerDeny, {
scope: Scope.APPROVALS,
when: () => shouldEnableApprovalsScope && !hasResponded,
enableOnFormTags: ['textarea', 'TEXTAREA'],
preventDefault: true,
});
useEffect(() => { useEffect(() => {
if (!isEnteringReason) return; if (!isEnteringReason) return;
@@ -147,118 +371,52 @@ const PendingApprovalEntry = ({
<div className="absolute -top-3 left-4 rounded-full border bg-background px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide shadow-sm"> <div className="absolute -top-3 left-4 rounded-full border bg-background px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide shadow-sm">
Awaiting approval Awaiting approval
</div> </div>
<div className="overflow-hidden border"> <div className="overflow-hidden border">
{children} {children}
<div className="border-t bg-background px-2 py-1.5 text-xs sm:text-sm"> <div className="border-t bg-background px-2 py-1.5 text-xs sm:text-sm">
<TooltipProvider> <TooltipProvider>
<div className="flex items-center justify-between gap-1.5"> <div className="flex items-center justify-between gap-1.5 pl-4">
<div className="flex items-center gap-1.5 pl-4"> {!isEnteringReason && !hasResponded && (
{!isEnteringReason && ( <ProgressWithTooltip
<> visible={timeLeft > 0}
<Tooltip> timeLeft={timeLeft}
<TooltipTrigger asChild> percent={percent}
<Button />
onClick={handleApprove} )}
variant="ghost" {!isEnteringReason && (
className="h-8 w-8 rounded-full p-0" <ActionButtons
disabled={disabled} disabled={disabled}
aria-label={ isResponding={isResponding}
isResponding ? 'Submitting approval' : 'Approve' onApprove={handleApprove}
} onStartDeny={handleStartDeny}
> />
<Check className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{isResponding ? 'Submitting…' : 'Approve request'}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleStartDeny}
variant="ghost"
className="h-8 w-8 rounded-full p-0"
disabled={disabled}
aria-label={
isResponding ? 'Submitting denial' : 'Deny'
}
>
<X className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{isResponding
? 'Submitting…'
: 'Provide denial reason'}
</p>
</TooltipContent>
</Tooltip>
</>
)}
</div>
{!isEnteringReason && !hasResponded && timeLeft > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center pr-8">
<CircularProgress percent={percent} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{formatSeconds(timeLeft)} remaining</p>
</TooltipContent>
</Tooltip>
)} )}
</div> </div>
{error && <div className="mt-1 text-xs text-red-600">{error}</div>}
{isEnteringReason && !hasResponded && ( {error && (
<div className="mt-3 bg-background px-3 py-3 text-sm"> <div
<Textarea className="mt-1 text-xs text-red-600"
ref={denyReasonRef} role="alert"
value={denyReason} aria-live="polite"
onChange={(e) => { >
setDenyReason(e.target.value); {error}
}}
placeholder="Let the agent know why this request was denied..."
disabled={isResponding}
className="text-sm"
/>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCancelDeny}
disabled={isResponding}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSubmitDeny}
disabled={isResponding}
>
Submit denial
</Button>
</div>
{!hasResponded && timeLeft > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center pr-2">
<CircularProgress percent={percent} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{formatSeconds(timeLeft)} remaining</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div> </div>
)} )}
{isEnteringReason && !hasResponded && (
<DenyReasonForm
isResponding={isResponding}
timeLeft={timeLeft}
percent={percent}
value={denyReason}
onChange={setDenyReason}
onCancel={handleCancelDeny}
onSubmit={handleSubmitDeny}
inputRef={denyReasonRef}
/>
)}
</TooltipProvider> </TooltipProvider>
</div> </div>
</div> </div>

View File

@@ -14,28 +14,37 @@ export const CircularProgress: React.FC<CircularProgressProps> = ({
const strokeDashoffset = circumference - (percent / 100) * circumference; const strokeDashoffset = circumference - (percent / 100) * circumference;
return ( return (
<svg width={size} height={size} className="transform -rotate-90"> <div
<circle className="relative inline-flex items-center justify-center"
cx={size / 2} style={{ width: size, height: size }}
cy={size / 2} >
r={radius} <svg
stroke="currentColor" width={size}
strokeWidth={strokeWidth} height={size}
fill="none" className="absolute inset-0 transform -rotate-90"
className="text-muted-foreground/20" >
/> <circle
<circle cx={size / 2}
cx={size / 2} cy={size / 2}
cy={size / 2} r={radius}
r={radius} stroke="currentColor"
stroke="currentColor" strokeWidth={strokeWidth}
strokeWidth={strokeWidth} fill="none"
fill="none" className="text-muted-foreground/20"
strokeDasharray={circumference} />
strokeDashoffset={strokeDashoffset} <circle
className="text-muted-foreground transition-all duration-1000 ease-linear" cx={size / 2}
strokeLinecap="round" cy={size / 2}
/> r={radius}
</svg> stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="text-muted-foreground transition-all duration-1000 ease-linear"
strokeLinecap="round"
/>
</svg>
</div>
); );
}; };

View File

@@ -4,9 +4,11 @@ import {
useKeyboardShortcutsRegistry, useKeyboardShortcutsRegistry,
type ShortcutConfig, type ShortcutConfig,
} from '@/contexts/keyboard-shortcuts-context'; } from '@/contexts/keyboard-shortcuts-context';
import type { EnableOnFormTags } from '@/keyboard/types';
export interface KeyboardShortcutOptions { export interface KeyboardShortcutOptions {
enableOnContentEditable?: boolean; enableOnContentEditable?: boolean;
enableOnFormTags?: EnableOnFormTags;
preventDefault?: boolean; preventDefault?: boolean;
} }
@@ -18,7 +20,11 @@ export function useKeyboardShortcut(
const unregisterRef = useRef<(() => void) | null>(null); const unregisterRef = useRef<(() => void) | null>(null);
const { keys, callback, when = true, description, group, scope } = config; const { keys, callback, when = true, description, group, scope } = config;
const { enableOnContentEditable = false, preventDefault = false } = options; const {
enableOnContentEditable = false,
enableOnFormTags,
preventDefault = false,
} = options;
// Keep latest callback/when without forcing re-register // Keep latest callback/when without forcing re-register
const callbackRef = useRef(callback); const callbackRef = useRef(callback);
@@ -64,9 +70,10 @@ export function useKeyboardShortcut(
{ {
enabled: true, // we gate inside handler via whenRef enabled: true, // we gate inside handler via whenRef
enableOnContentEditable, enableOnContentEditable,
enableOnFormTags,
preventDefault, preventDefault,
scopes: scope ? [scope] : ['*'], scopes: scope ? [scope] : ['*'],
}, },
[keys, scope] // handler uses refs; only rebinding when identity changes [keys, scope, enableOnContentEditable, enableOnFormTags, preventDefault] // handler uses refs; only rebinding when identity changes
); );
} }

View File

@@ -92,3 +92,19 @@ export const useKeyToggleFullscreen = createSemanticHook(
* useKeyDeleteTask(() => handleDeleteTask(selectedTask), { scope: Scope.KANBAN }); * useKeyDeleteTask(() => handleDeleteTask(selectedTask), { scope: Scope.KANBAN });
*/ */
export const useKeyDeleteTask = createSemanticHook(Action.DELETE_TASK); export const useKeyDeleteTask = createSemanticHook(Action.DELETE_TASK);
/**
* Approve pending approval action - typically Enter key
*
* @example
* useKeyApproveRequest(() => approvePendingRequest(), { scope: Scope.APPROVALS });
*/
export const useKeyApproveRequest = createSemanticHook(Action.APPROVE_REQUEST);
/**
* Deny pending approval action - typically Cmd/Ctrl+Enter
*
* @example
* useKeyDenyApproval(() => denyPendingRequest(), { scope: Scope.GLOBAL });
*/
export const useKeyDenyApproval = createSemanticHook(Action.DENY_APPROVAL);

View File

@@ -4,6 +4,7 @@ export enum Scope {
KANBAN = 'kanban', KANBAN = 'kanban',
PROJECTS = 'projects', PROJECTS = 'projects',
EDIT_COMMENT = 'edit-comment', EDIT_COMMENT = 'edit-comment',
APPROVALS = 'approvals',
} }
export enum Action { export enum Action {
@@ -19,6 +20,8 @@ export enum Action {
SHOW_HELP = 'show_help', SHOW_HELP = 'show_help',
TOGGLE_FULLSCREEN = 'toggle_fullscreen', TOGGLE_FULLSCREEN = 'toggle_fullscreen',
DELETE_TASK = 'delete_task', DELETE_TASK = 'delete_task',
APPROVE_REQUEST = 'approve_request',
DENY_APPROVAL = 'deny_approval',
} }
export interface KeyBinding { export interface KeyBinding {
@@ -148,6 +151,22 @@ export const keyBindings: KeyBinding[] = [
description: 'Delete selected task', description: 'Delete selected task',
group: 'Task Details', group: 'Task Details',
}, },
// Approval actions
{
action: Action.APPROVE_REQUEST,
keys: 'enter',
scopes: [Scope.APPROVALS],
description: 'Approve pending approval request',
group: 'Approvals',
},
{
action: Action.DENY_APPROVAL,
keys: ['meta+enter', 'ctrl+enter'],
scopes: [Scope.APPROVALS],
description: 'Deny pending approval request',
group: 'Approvals',
},
]; ];
/** /**

View File

@@ -0,0 +1,4 @@
export type FormTag = 'input' | 'textarea' | 'select';
export type EnableOnFormTags =
| boolean
| readonly (FormTag | Uppercase<FormTag>)[];

View File

@@ -3,6 +3,7 @@ import {
useKeyboardShortcut, useKeyboardShortcut,
type KeyboardShortcutOptions, type KeyboardShortcutOptions,
} from '@/hooks/useKeyboardShortcut'; } from '@/hooks/useKeyboardShortcut';
import type { EnableOnFormTags } from './types';
import { Action, Scope, getKeysFor, getBindingFor } from './registry'; import { Action, Scope, getKeysFor, getBindingFor } from './registry';
export interface SemanticKeyOptions { export interface SemanticKeyOptions {
@@ -10,6 +11,7 @@ export interface SemanticKeyOptions {
enabled?: boolean | (() => boolean); enabled?: boolean | (() => boolean);
when?: boolean | (() => boolean); // Alias for enabled when?: boolean | (() => boolean); // Alias for enabled
enableOnContentEditable?: boolean; enableOnContentEditable?: boolean;
enableOnFormTags?: EnableOnFormTags;
preventDefault?: boolean; preventDefault?: boolean;
} }
@@ -28,6 +30,7 @@ export function createSemanticHook<A extends Action>(action: A) {
enabled = true, enabled = true,
when, when,
enableOnContentEditable, enableOnContentEditable,
enableOnFormTags,
preventDefault, preventDefault,
} = options; } = options;
@@ -45,6 +48,8 @@ export function createSemanticHook<A extends Action>(action: A) {
const keyboardShortcutOptions: KeyboardShortcutOptions = {}; const keyboardShortcutOptions: KeyboardShortcutOptions = {};
if (enableOnContentEditable !== undefined) if (enableOnContentEditable !== undefined)
keyboardShortcutOptions.enableOnContentEditable = enableOnContentEditable; keyboardShortcutOptions.enableOnContentEditable = enableOnContentEditable;
if (enableOnFormTags !== undefined)
keyboardShortcutOptions.enableOnFormTags = enableOnFormTags;
if (preventDefault !== undefined) if (preventDefault !== undefined)
keyboardShortcutOptions.preventDefault = preventDefault; keyboardShortcutOptions.preventDefault = preventDefault;