Skip to content

Commit ae6a363

Browse files
authored
improvement(ux): submit on Enter in chip modals and advance table rows (#5781)
* improvement(ux): submit on Enter in chip modals and advance table rows * fix(emcn): stop Enter double-submit and close registration gap - stopPropagation on field Enter-submit so a parent onKeyDown can't re-fire the primary (double OAuth connect, etc.) - register primary via useLayoutEffect so it's set before paint (no null-Enter window) - drop now-redundant parent Enter handlers in connect-oauth/create-workspace/rename-document modals * 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. * 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
1 parent a1ea3bd commit ae6a363

6 files changed

Lines changed: 160 additions & 69 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type ComponentType, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
3+
import { type ComponentType, useEffect, useMemo, useRef, useState } from 'react'
44
import {
55
Badge,
66
ChipModal,
@@ -309,18 +309,6 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
309309
? !displayName.trim() || isPending || Boolean(existingCredential)
310310
: isPending
311311

312-
/**
313-
* Submits the connect form on Enter, mirroring the Connect button's enabled
314-
* state and excluding the multi-line description. Restores the keyboard
315-
* affordance the pre-consolidation workflow modal provided.
316-
*/
317-
const handleBodyKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
318-
if (event.key !== 'Enter' || !isConnect || isDisabled) return
319-
if (event.target instanceof HTMLTextAreaElement) return
320-
event.preventDefault()
321-
void handleConnect()
322-
}
323-
324312
const displayNameError =
325313
validationError ??
326314
(existingCredential
@@ -334,7 +322,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
334322
<ChipModalHeader icon={ProviderIcon} onClose={handleClose}>
335323
{title}
336324
</ChipModalHeader>
337-
<ChipModalBody onKeyDown={handleBodyKeyDown}>
325+
<ChipModalBody>
338326
{!isConnect && (
339327
<p className='text-[var(--text-tertiary)] text-caption'>
340328
The "{props.toolName}" tool requires access to your account.

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,10 @@ export function RenameDocumentModal({
7474
}
7575
}
7676

77-
const handleKeyDown = (e: React.KeyboardEvent) => {
78-
if (e.key === 'Enter') {
79-
e.preventDefault()
80-
void handleSubmit()
81-
}
82-
}
83-
8477
return (
8578
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Rename Document'>
8679
<ChipModalHeader onClose={() => onOpenChange(false)}>Rename Document</ChipModalHeader>
87-
<ChipModalBody onKeyDown={handleKeyDown}>
80+
<ChipModalBody>
8881
<ChipModalField
8982
type='input'
9083
title='Name'

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export function RecurrenceSection({ recurrence, onChange, launchDate }: Recurren
290290
const count = Math.max(1, Math.floor(Number(value) || 1))
291291
onChange({ ...recurrence, end: { type: 'after', count } })
292292
}}
293+
submitOnEnter={false}
293294
/>
294295
)}
295296
</>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,41 @@ function TableCell({
9797
}
9898
}
9999

100+
/**
101+
* Enter commits the current cell (values already persist on change) and
102+
* advances to the same column in the next row, spreadsheet-style. Skipped
103+
* while a tag/env-var dropdown is open (Enter selects an option there) or
104+
* during IME composition. The next row already exists — it auto-appends the
105+
* moment the last row is typed into — so focus lands on a real input.
106+
*
107+
* Focusing an empty cell auto-opens the tag dropdown, so the destination's
108+
* dropdown is closed right after focusing: otherwise a follow-up Enter would
109+
* land on that dropdown and insert a tag instead of continuing down the
110+
* column. Clicking or typing `<` in the cell still opens it deliberately.
111+
*/
112+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
113+
handlers.onKeyDown(e)
114+
if (
115+
e.key !== 'Enter' ||
116+
e.nativeEvent.isComposing ||
117+
e.defaultPrevented ||
118+
fieldState.showEnvVars ||
119+
fieldState.showTags
120+
) {
121+
return
122+
}
123+
const nextCellKey = `${rowIndex + 1}-${column}`
124+
const nextInput = inputRefs.current.get(nextCellKey)
125+
// `isConnected` guards against a stale ref: position-keyed entries can
126+
// outlive a deleted row, and focusing a detached node would steal focus
127+
// from the current cell. A real next row's input is always connected.
128+
if (nextInput?.isConnected) {
129+
e.preventDefault()
130+
nextInput.focus()
131+
inputController.fieldHelpers.hideFieldDropdowns(nextCellKey)
132+
}
133+
}
134+
100135
const syncScrollAfterUpdate = () => {
101136
requestAnimationFrame(() => {
102137
const input = inputRefs.current.get(cellKey)
@@ -138,12 +173,13 @@ function TableCell({
138173
<input
139174
ref={(el) => {
140175
if (el) inputRefs.current.set(cellKey, el)
176+
else inputRefs.current.delete(cellKey)
141177
}}
142178
type='text'
143179
value={cellValue}
144180
placeholder={column}
145181
onChange={handlers.onChange}
146-
onKeyDown={handlers.onKeyDown}
182+
onKeyDown={handleKeyDown}
147183
onScroll={handleScroll}
148184
onDrop={handlers.onDrop}
149185
onDragOver={handlers.onDragOver}
@@ -158,6 +194,7 @@ function TableCell({
158194
<div
159195
ref={(el) => {
160196
if (el) overlayRefs.current.set(cellKey, el)
197+
else overlayRefs.current.delete(cellKey)
161198
}}
162199
data-overlay={cellKey}
163200
className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,6 @@ export function CreateWorkspaceModal({
6969
}
7070
}
7171

72-
const handleKeyDown = (e: React.KeyboardEvent) => {
73-
if (e.key === 'Enter') {
74-
e.preventDefault()
75-
void handleSubmit()
76-
}
77-
}
78-
7972
const handleNameChange = (value: string) => {
8073
setName(value)
8174
setError(null)
@@ -86,7 +79,7 @@ export function CreateWorkspaceModal({
8679
return (
8780
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={copy.title}>
8881
<ChipModalHeader onClose={() => onOpenChange(false)}>{copy.title}</ChipModalHeader>
89-
<ChipModalBody onKeyDown={handleKeyDown}>
82+
<ChipModalBody>
9083
<p className='px-2 text-[var(--text-muted)] text-sm'>{copy.description}</p>
9184
<ChipModalField
9285
type='input'

packages/emcn/src/components/chip-modal/chip-modal.tsx

Lines changed: 117 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,26 @@ export function ChipModalSeparator({ className }: { className?: string }) {
8686
*/
8787
const CHIP_MODAL_FIELD_ERROR_CLASS = 'text-[var(--text-error)] text-caption'
8888

89+
/**
90+
* The modal's registered primary action, published by {@link ChipModalFooter}
91+
* and consumed by {@link ChipModalField} so a single-line input can submit the
92+
* modal on Enter without the consumer wiring `onSubmit` on every field.
93+
*/
94+
interface ChipModalSubmit {
95+
/** Fires the footer's primary action. */
96+
trigger: () => void
97+
/** Mirrors the primary action's disabled state so Enter never submits an invalid form. */
98+
disabled?: boolean
99+
}
100+
101+
/**
102+
* Carries a mutable handle to the modal's primary action down to its fields.
103+
* A ref (not state) so the footer can keep it current without re-rendering the
104+
* body, and fields read it at Enter-time rather than at render-time.
105+
*/
106+
const ChipModalSubmitContext =
107+
React.createContext<React.MutableRefObject<ChipModalSubmit | null> | null>(null)
108+
89109
export interface ChipModalProps {
90110
/** Controlled open state. */
91111
open: boolean
@@ -120,21 +140,24 @@ function ChipModal({
120140
className,
121141
children,
122142
}: ChipModalProps) {
143+
const submitRef = React.useRef<ChipModalSubmit | null>(null)
123144
return (
124-
<Modal open={open} onOpenChange={onOpenChange}>
125-
<ModalContent bare showClose={false} srTitle={srTitle} size={size}>
126-
<div
127-
className={cn(
128-
'flex min-h-0 w-full flex-col rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)] dark:bg-[var(--surface-5)]',
129-
className
130-
)}
131-
>
132-
<div className='flex min-h-0 flex-col overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]'>
133-
{children}
145+
<ChipModalSubmitContext.Provider value={submitRef}>
146+
<Modal open={open} onOpenChange={onOpenChange}>
147+
<ModalContent bare showClose={false} srTitle={srTitle} size={size}>
148+
<div
149+
className={cn(
150+
'flex min-h-0 w-full flex-col rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)] dark:bg-[var(--surface-5)]',
151+
className
152+
)}
153+
>
154+
<div className='flex min-h-0 flex-col overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]'>
155+
{children}
156+
</div>
134157
</div>
135-
</div>
136-
</ModalContent>
137-
</Modal>
158+
</ModalContent>
159+
</Modal>
160+
</ChipModalSubmitContext.Provider>
138161
)
139162
}
140163

@@ -364,7 +387,32 @@ interface ChipModalFieldBaseProps {
364387
className?: string
365388
}
366389

367-
interface ChipModalInputFieldProps extends ChipModalFieldBaseProps {
390+
/**
391+
* Enter-submit behavior shared by the single-line field types (`input`,
392+
* `email`). Both fire the modal's {@link ChipModalFooter} primary action on
393+
* Enter by default; these props override or opt out of that.
394+
*/
395+
interface ChipModalSingleLineEnterProps {
396+
/**
397+
* Overrides the default Enter behavior. By default, pressing Enter in a
398+
* single-line field fires the {@link ChipModalFooter} primary action (unless
399+
* it's disabled), so a plain modal submits on Enter with no wiring. Pass
400+
* `onSubmit` only when Enter should do something OTHER than the primary action
401+
* (e.g. advance a multi-step flow).
402+
*/
403+
onSubmit?: () => void
404+
/**
405+
* Opts this field out of the automatic Enter-submits-the-primary-action
406+
* behavior. Set `false` for a config knob that lives inside a larger form
407+
* (e.g. a "number of runs" input in a scheduling modal) where Enter firing
408+
* the modal's primary action would submit prematurely. Ignored when an
409+
* explicit `onSubmit` is provided.
410+
* @default true
411+
*/
412+
submitOnEnter?: boolean
413+
}
414+
415+
interface ChipModalInputFieldProps extends ChipModalFieldBaseProps, ChipModalSingleLineEnterProps {
368416
type: 'input'
369417
value: string
370418
onChange: (value: string) => void
@@ -380,24 +428,14 @@ interface ChipModalInputFieldProps extends ChipModalFieldBaseProps {
380428
* @default false
381429
*/
382430
mono?: boolean
383-
/**
384-
* Called when the user presses Enter in the field. Wire this to the
385-
* modal's primary action so the field behaves like a form submit.
386-
*/
387-
onSubmit?: () => void
388431
}
389432

390-
interface ChipModalEmailFieldProps extends ChipModalFieldBaseProps {
433+
interface ChipModalEmailFieldProps extends ChipModalFieldBaseProps, ChipModalSingleLineEnterProps {
391434
type: 'email'
392435
value: string
393436
onChange: (value: string) => void
394437
placeholder?: string
395438
autoComplete?: string
396-
/**
397-
* Called when the user presses Enter in the field. Wire this to the
398-
* modal's primary action so the field behaves like a form submit.
399-
*/
400-
onSubmit?: () => void
401439
}
402440

403441
interface ChipModalTextareaFieldBaseProps extends ChipModalFieldBaseProps {
@@ -536,6 +574,7 @@ export type ChipModalFieldProps =
536574
*/
537575
function ChipModalField(props: ChipModalFieldProps) {
538576
const id = React.useId()
577+
const submitRef = React.useContext(ChipModalSubmitContext)
539578
const errorId = `${id}-error`
540579
const hintId = `${id}-hint`
541580
const { title, required, error, hint, flush = false, className } = props
@@ -559,7 +598,7 @@ function ChipModalField(props: ChipModalFieldProps) {
559598
</span>
560599
)}
561600
</Label>
562-
{renderChipModalControl(props, id, errorId, hintId)}
601+
{renderChipModalControl(props, id, errorId, hintId, submitRef)}
563602
{error && props.type !== 'emails' ? (
564603
<p id={errorId} role='alert' className={CHIP_MODAL_FIELD_ERROR_CLASS}>
565604
{error}
@@ -584,7 +623,8 @@ function renderChipModalControl(
584623
props: ChipModalFieldProps,
585624
id: string,
586625
errorId: string,
587-
hintId: string
626+
hintId: string,
627+
submitRef: React.MutableRefObject<ChipModalSubmit | null> | null
588628
): React.ReactNode {
589629
const aria = {
590630
'aria-required': props.required || undefined,
@@ -594,23 +634,32 @@ function renderChipModalControl(
594634

595635
switch (props.type) {
596636
case 'input':
597-
case 'email':
637+
case 'email': {
638+
const onSubmit = props.onSubmit
598639
return (
599640
<ChipInput
600641
id={id}
601642
type={props.type === 'email' ? 'email' : (props.inputType ?? 'text')}
602643
value={props.value}
603644
onChange={(event) => props.onChange(event.target.value)}
604-
onKeyDown={
605-
props.onSubmit
606-
? (event) => {
607-
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
608-
event.preventDefault()
609-
props.onSubmit?.()
610-
}
611-
}
612-
: undefined
613-
}
645+
onKeyDown={(event) => {
646+
if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
647+
if (onSubmit) {
648+
event.preventDefault()
649+
event.stopPropagation()
650+
onSubmit()
651+
return
652+
}
653+
if (props.submitOnEnter === false) return
654+
const submit = submitRef?.current
655+
if (submit && !submit.disabled) {
656+
event.preventDefault()
657+
// Stop bubbling so a parent Enter handler (e.g. a modal body that
658+
// also submits) can't fire the same primary action a second time.
659+
event.stopPropagation()
660+
submit.trigger()
661+
}
662+
}}
614663
placeholder={props.placeholder}
615664
maxLength={props.type === 'input' ? props.maxLength : undefined}
616665
autoComplete={props.autoComplete}
@@ -619,6 +668,7 @@ function renderChipModalControl(
619668
{...aria}
620669
/>
621670
)
671+
}
622672
case 'textarea':
623673
return (
624674
<ChipTextarea
@@ -1043,6 +1093,35 @@ function ChipModalFooter({
10431093
secondaryActions,
10441094
}: ChipModalFooterProps) {
10451095
const showsDisabledTooltip = Boolean(primaryAction.disabled && primaryAction.disabledTooltip)
1096+
1097+
/**
1098+
* Publish the primary action so single-line {@link ChipModalField}s can submit
1099+
* the modal on Enter without per-field wiring. Kept in a ref (updated each
1100+
* render) rather than state so fields read the latest handler at Enter-time.
1101+
*
1102+
* A layout effect (not a passive effect) so the ref is populated before the
1103+
* browser paints the modal — otherwise there is a window between first paint
1104+
* and effect commit where an enabled primary is visible but Enter does
1105+
* nothing because `submitRef.current` is still `null`.
1106+
*
1107+
* A `destructive` primary is deliberately NOT published: Enter must never
1108+
* trigger a destructive action from a text field. Destructive flows that DO
1109+
* want Enter (e.g. a guarded "change address") wire an explicit field-level
1110+
* `onSubmit`, which takes precedence over this fallback.
1111+
*/
1112+
const submitRef = React.useContext(ChipModalSubmitContext)
1113+
React.useLayoutEffect(() => {
1114+
if (!submitRef) return
1115+
if (primaryAction.variant === 'destructive') {
1116+
submitRef.current = null
1117+
return
1118+
}
1119+
submitRef.current = { trigger: primaryAction.onClick, disabled: primaryAction.disabled }
1120+
return () => {
1121+
submitRef.current = null
1122+
}
1123+
}, [submitRef, primaryAction.onClick, primaryAction.disabled, primaryAction.variant])
1124+
10461125
const primaryChip = (
10471126
<Chip
10481127
variant={primaryAction.variant ?? 'primary'}

0 commit comments

Comments
 (0)