Skip to content

Commit 0b3ed5d

Browse files
committed
improvement(ux): submit on Enter in chip modals and advance table rows
1 parent 922515f commit 0b3ed5d

3 files changed

Lines changed: 135 additions & 39 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]
    • scheduled-tasks/components/task-modal
    • w/[workflowId]/components/panel/components/editor/components/sub-block/components/table
  • packages/emcn/src/components/chip-modal

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: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,31 @@ 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+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
108+
handlers.onKeyDown(e)
109+
if (
110+
e.key !== 'Enter' ||
111+
e.nativeEvent.isComposing ||
112+
e.defaultPrevented ||
113+
fieldState.showEnvVars ||
114+
fieldState.showTags
115+
) {
116+
return
117+
}
118+
const nextInput = inputRefs.current.get(`${rowIndex + 1}-${column}`)
119+
if (nextInput) {
120+
e.preventDefault()
121+
nextInput.focus()
122+
}
123+
}
124+
100125
const syncScrollAfterUpdate = () => {
101126
requestAnimationFrame(() => {
102127
const input = inputRefs.current.get(cellKey)
@@ -143,7 +168,7 @@ function TableCell({
143168
value={cellValue}
144169
placeholder={column}
145170
onChange={handlers.onChange}
146-
onKeyDown={handlers.onKeyDown}
171+
onKeyDown={handleKeyDown}
147172
onScroll={handleScroll}
148173
onDrop={handlers.onDrop}
149174
onDragOver={handlers.onDragOver}

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

Lines changed: 108 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,28 @@ 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+
onSubmit()
650+
return
651+
}
652+
if (props.submitOnEnter === false) return
653+
const submit = submitRef?.current
654+
if (submit && !submit.disabled) {
655+
event.preventDefault()
656+
submit.trigger()
657+
}
658+
}}
614659
placeholder={props.placeholder}
615660
maxLength={props.type === 'input' ? props.maxLength : undefined}
616661
autoComplete={props.autoComplete}
@@ -619,6 +664,7 @@ function renderChipModalControl(
619664
{...aria}
620665
/>
621666
)
667+
}
622668
case 'textarea':
623669
return (
624670
<ChipTextarea
@@ -1043,6 +1089,30 @@ function ChipModalFooter({
10431089
secondaryActions,
10441090
}: ChipModalFooterProps) {
10451091
const showsDisabledTooltip = Boolean(primaryAction.disabled && primaryAction.disabledTooltip)
1092+
1093+
/**
1094+
* Publish the primary action so single-line {@link ChipModalField}s can submit
1095+
* the modal on Enter without per-field wiring. Kept in a ref (updated each
1096+
* render) rather than state so fields read the latest handler at Enter-time.
1097+
*
1098+
* A `destructive` primary is deliberately NOT published: Enter must never
1099+
* trigger a destructive action from a text field. Destructive flows that DO
1100+
* want Enter (e.g. a guarded "change address") wire an explicit field-level
1101+
* `onSubmit`, which takes precedence over this fallback.
1102+
*/
1103+
const submitRef = React.useContext(ChipModalSubmitContext)
1104+
React.useEffect(() => {
1105+
if (!submitRef) return
1106+
if (primaryAction.variant === 'destructive') {
1107+
submitRef.current = null
1108+
return
1109+
}
1110+
submitRef.current = { trigger: primaryAction.onClick, disabled: primaryAction.disabled }
1111+
return () => {
1112+
submitRef.current = null
1113+
}
1114+
}, [submitRef, primaryAction.onClick, primaryAction.disabled, primaryAction.variant])
1115+
10461116
const primaryChip = (
10471117
<Chip
10481118
variant={primaryAction.variant ?? 'primary'}

0 commit comments

Comments
 (0)