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
This commit is contained in:
Waleed
2026-07-20 16:38:56 -07:00
committed by GitHub
parent a1ea3bd59c
commit ae6a363289
6 changed files with 160 additions and 69 deletions
@@ -1,6 +1,6 @@
'use client'
import { type ComponentType, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
import { type ComponentType, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
ChipModal,
@@ -309,18 +309,6 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
? !displayName.trim() || isPending || Boolean(existingCredential)
: isPending
/**
* Submits the connect form on Enter, mirroring the Connect button's enabled
* state and excluding the multi-line description. Restores the keyboard
* affordance the pre-consolidation workflow modal provided.
*/
const handleBodyKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' || !isConnect || isDisabled) return
if (event.target instanceof HTMLTextAreaElement) return
event.preventDefault()
void handleConnect()
}
const displayNameError =
validationError ??
(existingCredential
@@ -334,7 +322,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
<ChipModalHeader icon={ProviderIcon} onClose={handleClose}>
{title}
</ChipModalHeader>
<ChipModalBody onKeyDown={handleBodyKeyDown}>
<ChipModalBody>
{!isConnect && (
<p className='text-[var(--text-tertiary)] text-caption'>
The "{props.toolName}" tool requires access to your account.
@@ -74,17 +74,10 @@ export function RenameDocumentModal({
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSubmit()
}
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Rename Document'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Rename Document</ChipModalHeader>
<ChipModalBody onKeyDown={handleKeyDown}>
<ChipModalBody>
<ChipModalField
type='input'
title='Name'
@@ -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}
/>
)}
</>
@@ -97,6 +97,41 @@ 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.
*
* 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<HTMLInputElement>) => {
handlers.onKeyDown(e)
if (
e.key !== 'Enter' ||
e.nativeEvent.isComposing ||
e.defaultPrevented ||
fieldState.showEnvVars ||
fieldState.showTags
) {
return
}
const nextCellKey = `${rowIndex + 1}-${column}`
const nextInput = inputRefs.current.get(nextCellKey)
// `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)
}
}
const syncScrollAfterUpdate = () => {
requestAnimationFrame(() => {
const input = inputRefs.current.get(cellKey)
@@ -138,12 +173,13 @@ function TableCell({
<input
ref={(el) => {
if (el) inputRefs.current.set(cellKey, el)
else inputRefs.current.delete(cellKey)
}}
type='text'
value={cellValue}
placeholder={column}
onChange={handlers.onChange}
onKeyDown={handlers.onKeyDown}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
@@ -158,6 +194,7 @@ function TableCell({
<div
ref={(el) => {
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'
@@ -69,13 +69,6 @@ export function CreateWorkspaceModal({
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSubmit()
}
}
const handleNameChange = (value: string) => {
setName(value)
setError(null)
@@ -86,7 +79,7 @@ export function CreateWorkspaceModal({
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={copy.title}>
<ChipModalHeader onClose={() => onOpenChange(false)}>{copy.title}</ChipModalHeader>
<ChipModalBody onKeyDown={handleKeyDown}>
<ChipModalBody>
<p className='px-2 text-[var(--text-muted)] text-sm'>{copy.description}</p>
<ChipModalField
type='input'
@@ -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<React.MutableRefObject<ChipModalSubmit | null> | null>(null)
export interface ChipModalProps {
/** Controlled open state. */
open: boolean
@@ -120,21 +140,24 @@ function ChipModal({
className,
children,
}: ChipModalProps) {
const submitRef = React.useRef<ChipModalSubmit | null>(null)
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent bare showClose={false} srTitle={srTitle} size={size}>
<div
className={cn(
'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)]',
className
)}
>
<div className='flex min-h-0 flex-col overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]'>
{children}
<ChipModalSubmitContext.Provider value={submitRef}>
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent bare showClose={false} srTitle={srTitle} size={size}>
<div
className={cn(
'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)]',
className
)}
>
<div className='flex min-h-0 flex-col overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]'>
{children}
</div>
</div>
</div>
</ModalContent>
</Modal>
</ModalContent>
</Modal>
</ChipModalSubmitContext.Provider>
)
}
@@ -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) {
</span>
)}
</Label>
{renderChipModalControl(props, id, errorId, hintId)}
{renderChipModalControl(props, id, errorId, hintId, submitRef)}
{error && props.type !== 'emails' ? (
<p id={errorId} role='alert' className={CHIP_MODAL_FIELD_ERROR_CLASS}>
{error}
@@ -584,7 +623,8 @@ function renderChipModalControl(
props: ChipModalFieldProps,
id: string,
errorId: string,
hintId: string
hintId: string,
submitRef: React.MutableRefObject<ChipModalSubmit | null> | null
): React.ReactNode {
const aria = {
'aria-required': props.required || undefined,
@@ -594,23 +634,32 @@ function renderChipModalControl(
switch (props.type) {
case 'input':
case 'email':
case 'email': {
const onSubmit = props.onSubmit
return (
<ChipInput
id={id}
type={props.type === 'email' ? 'email' : (props.inputType ?? 'text')}
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
onKeyDown={
props.onSubmit
? (event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
props.onSubmit?.()
}
}
: undefined
}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
if (onSubmit) {
event.preventDefault()
event.stopPropagation()
onSubmit()
return
}
if (props.submitOnEnter === false) return
const submit = submitRef?.current
if (submit && !submit.disabled) {
event.preventDefault()
// Stop bubbling so a parent Enter handler (e.g. a modal body that
// also submits) can't fire the same primary action a second time.
event.stopPropagation()
submit.trigger()
}
}}
placeholder={props.placeholder}
maxLength={props.type === 'input' ? props.maxLength : undefined}
autoComplete={props.autoComplete}
@@ -619,6 +668,7 @@ function renderChipModalControl(
{...aria}
/>
)
}
case 'textarea':
return (
<ChipTextarea
@@ -1043,6 +1093,35 @@ function ChipModalFooter({
secondaryActions,
}: ChipModalFooterProps) {
const showsDisabledTooltip = Boolean(primaryAction.disabled && primaryAction.disabledTooltip)
/**
* Publish the primary action so single-line {@link ChipModalField}s can submit
* the modal on Enter without per-field wiring. Kept in a ref (updated each
* render) rather than state so fields read the latest handler at Enter-time.
*
* A layout effect (not a passive effect) so the ref is populated before the
* browser paints the modal — otherwise there is a window between first paint
* and effect commit where an enabled primary is visible but Enter does
* nothing because `submitRef.current` is still `null`.
*
* A `destructive` primary is deliberately NOT published: Enter must never
* trigger a destructive action from a text field. Destructive flows that DO
* want Enter (e.g. a guarded "change address") wire an explicit field-level
* `onSubmit`, which takes precedence over this fallback.
*/
const submitRef = React.useContext(ChipModalSubmitContext)
React.useLayoutEffect(() => {
if (!submitRef) return
if (primaryAction.variant === 'destructive') {
submitRef.current = null
return
}
submitRef.current = { trigger: primaryAction.onClick, disabled: primaryAction.disabled }
return () => {
submitRef.current = null
}
}, [submitRef, primaryAction.onClick, primaryAction.disabled, primaryAction.variant])
const primaryChip = (
<Chip
variant={primaryAction.variant ?? 'primary'}