mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+2
-14
@@ -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.
|
||||
|
||||
+1
-8
@@ -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'
|
||||
|
||||
+1
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
+38
-1
@@ -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'
|
||||
|
||||
+1
-8
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user