From 9ac301a03ec3420b22ada73f963b64c906204cbe Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 15:59:29 -0700 Subject: [PATCH 1/4] improvement(ux): submit on Enter in chip modals and advance table rows --- .../task-modal/recurrence-section.tsx | 1 + .../sub-block/components/table/table.tsx | 27 +++- .../src/components/chip-modal/chip-modal.tsx | 146 +++++++++++++----- 3 files changed, 135 insertions(+), 39 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx index 3453b88a958..e936999b586 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx @@ -290,6 +290,7 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren const count = Math.max(1, Math.floor(Number(value) || 1)) onChange({ ...recurrence, end: { type: 'after', count } }) }} + submitOnEnter={false} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx index 80996a1adae..dc009059ab3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx @@ -97,6 +97,31 @@ function TableCell({ } } + /** + * Enter commits the current cell (values already persist on change) and + * advances to the same column in the next row, spreadsheet-style. Skipped + * while a tag/env-var dropdown is open (Enter selects an option there) or + * during IME composition. The next row already exists — it auto-appends the + * moment the last row is typed into — so focus lands on a real input. + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + handlers.onKeyDown(e) + if ( + e.key !== 'Enter' || + e.nativeEvent.isComposing || + e.defaultPrevented || + fieldState.showEnvVars || + fieldState.showTags + ) { + return + } + const nextInput = inputRefs.current.get(`${rowIndex + 1}-${column}`) + if (nextInput) { + e.preventDefault() + nextInput.focus() + } + } + const syncScrollAfterUpdate = () => { requestAnimationFrame(() => { const input = inputRefs.current.get(cellKey) @@ -143,7 +168,7 @@ function TableCell({ value={cellValue} placeholder={column} onChange={handlers.onChange} - onKeyDown={handlers.onKeyDown} + onKeyDown={handleKeyDown} onScroll={handleScroll} onDrop={handlers.onDrop} onDragOver={handlers.onDragOver} diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index dbf0e8cdd0a..b92f20de699 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -86,6 +86,26 @@ export function ChipModalSeparator({ className }: { className?: string }) { */ const CHIP_MODAL_FIELD_ERROR_CLASS = 'text-[var(--text-error)] text-caption' +/** + * The modal's registered primary action, published by {@link ChipModalFooter} + * and consumed by {@link ChipModalField} so a single-line input can submit the + * modal on Enter without the consumer wiring `onSubmit` on every field. + */ +interface ChipModalSubmit { + /** Fires the footer's primary action. */ + trigger: () => void + /** Mirrors the primary action's disabled state so Enter never submits an invalid form. */ + disabled?: boolean +} + +/** + * Carries a mutable handle to the modal's primary action down to its fields. + * A ref (not state) so the footer can keep it current without re-rendering the + * body, and fields read it at Enter-time rather than at render-time. + */ +const ChipModalSubmitContext = + React.createContext | null>(null) + export interface ChipModalProps { /** Controlled open state. */ open: boolean @@ -120,21 +140,24 @@ function ChipModal({ className, children, }: ChipModalProps) { + const submitRef = React.useRef(null) return ( - - -
-
- {children} + + + +
+
+ {children} +
-
- - + + + ) } @@ -364,7 +387,32 @@ interface ChipModalFieldBaseProps { className?: string } -interface ChipModalInputFieldProps extends ChipModalFieldBaseProps { +/** + * Enter-submit behavior shared by the single-line field types (`input`, + * `email`). Both fire the modal's {@link ChipModalFooter} primary action on + * Enter by default; these props override or opt out of that. + */ +interface ChipModalSingleLineEnterProps { + /** + * Overrides the default Enter behavior. By default, pressing Enter in a + * single-line field fires the {@link ChipModalFooter} primary action (unless + * it's disabled), so a plain modal submits on Enter with no wiring. Pass + * `onSubmit` only when Enter should do something OTHER than the primary action + * (e.g. advance a multi-step flow). + */ + onSubmit?: () => void + /** + * Opts this field out of the automatic Enter-submits-the-primary-action + * behavior. Set `false` for a config knob that lives inside a larger form + * (e.g. a "number of runs" input in a scheduling modal) where Enter firing + * the modal's primary action would submit prematurely. Ignored when an + * explicit `onSubmit` is provided. + * @default true + */ + submitOnEnter?: boolean +} + +interface ChipModalInputFieldProps extends ChipModalFieldBaseProps, ChipModalSingleLineEnterProps { type: 'input' value: string onChange: (value: string) => void @@ -380,24 +428,14 @@ interface ChipModalInputFieldProps extends ChipModalFieldBaseProps { * @default false */ mono?: boolean - /** - * Called when the user presses Enter in the field. Wire this to the - * modal's primary action so the field behaves like a form submit. - */ - onSubmit?: () => void } -interface ChipModalEmailFieldProps extends ChipModalFieldBaseProps { +interface ChipModalEmailFieldProps extends ChipModalFieldBaseProps, ChipModalSingleLineEnterProps { type: 'email' value: string onChange: (value: string) => void placeholder?: string autoComplete?: string - /** - * Called when the user presses Enter in the field. Wire this to the - * modal's primary action so the field behaves like a form submit. - */ - onSubmit?: () => void } interface ChipModalTextareaFieldBaseProps extends ChipModalFieldBaseProps { @@ -536,6 +574,7 @@ export type ChipModalFieldProps = */ function ChipModalField(props: ChipModalFieldProps) { const id = React.useId() + const submitRef = React.useContext(ChipModalSubmitContext) const errorId = `${id}-error` const hintId = `${id}-hint` const { title, required, error, hint, flush = false, className } = props @@ -559,7 +598,7 @@ function ChipModalField(props: ChipModalFieldProps) { )} - {renderChipModalControl(props, id, errorId, hintId)} + {renderChipModalControl(props, id, errorId, hintId, submitRef)} {error && props.type !== 'emails' ? (

The "{props.toolName}" tool requires access to your account. diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx index 5825d796b87..2018525e3c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx @@ -74,17 +74,10 @@ export function RenameDocumentModal({ } } - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - void handleSubmit() - } - } - return ( onOpenChange(false)}>Rename Document - + { - if (e.key === 'Enter') { - e.preventDefault() - void handleSubmit() - } - } - const handleNameChange = (value: string) => { setName(value) setError(null) @@ -86,7 +79,7 @@ export function CreateWorkspaceModal({ return ( onOpenChange(false)}>{copy.title} - +

{copy.description}

{ + React.useLayoutEffect(() => { if (!submitRef) return if (primaryAction.variant === 'destructive') { submitRef.current = null From aa413a4abb42a5f76bc8d1c87571c88b8f53c753 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 16:18:20 -0700 Subject: [PATCH 3/4] fix(table): suppress auto-opened tag dropdown when Enter advances cells Focusing an empty cell auto-opens the tag dropdown; without closing it a follow-up Enter inserts a tag instead of navigating down the column. --- .../components/sub-block/components/table/table.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx index dc009059ab3..9a716746329 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx @@ -103,6 +103,11 @@ function TableCell({ * while a tag/env-var dropdown is open (Enter selects an option there) or * during IME composition. The next row already exists — it auto-appends the * moment the last row is typed into — so focus lands on a real input. + * + * Focusing an empty cell auto-opens the tag dropdown, so the destination's + * dropdown is closed right after focusing: otherwise a follow-up Enter would + * land on that dropdown and insert a tag instead of continuing down the + * column. Clicking or typing `<` in the cell still opens it deliberately. */ const handleKeyDown = (e: React.KeyboardEvent) => { handlers.onKeyDown(e) @@ -115,10 +120,12 @@ function TableCell({ ) { return } - const nextInput = inputRefs.current.get(`${rowIndex + 1}-${column}`) + const nextCellKey = `${rowIndex + 1}-${column}` + const nextInput = inputRefs.current.get(nextCellKey) if (nextInput) { e.preventDefault() nextInput.focus() + inputController.fieldHelpers.hideFieldDropdowns(nextCellKey) } } From 45d46d9984a00a1ec0c900c7b2875a18d4f46ef2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 16:24:33 -0700 Subject: [PATCH 4/4] fix(table): clean up cell refs on unmount and guard Enter advance - delete inputRefs/overlayRefs entries when a cell detaches so deleting a row can't leave stale position-keyed entries - guard Enter advance with isConnected so a stale ref can never steal focus into a detached node --- .../editor/components/sub-block/components/table/table.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx index 9a716746329..339c718591a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx @@ -122,7 +122,10 @@ function TableCell({ } const nextCellKey = `${rowIndex + 1}-${column}` const nextInput = inputRefs.current.get(nextCellKey) - if (nextInput) { + // `isConnected` guards against a stale ref: position-keyed entries can + // outlive a deleted row, and focusing a detached node would steal focus + // from the current cell. A real next row's input is always connected. + if (nextInput?.isConnected) { e.preventDefault() nextInput.focus() inputController.fieldHelpers.hideFieldDropdowns(nextCellKey) @@ -170,6 +173,7 @@ function TableCell({ { if (el) inputRefs.current.set(cellKey, el) + else inputRefs.current.delete(cellKey) }} type='text' value={cellValue} @@ -190,6 +194,7 @@ function TableCell({
{ if (el) overlayRefs.current.set(cellKey, el) + else overlayRefs.current.delete(cellKey) }} data-overlay={cellKey} className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'