@@ -86,6 +86,26 @@ export function ChipModalSeparator({ className }: { className?: string }) {
8686 */
8787const 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+
89109export 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
403441interface ChipModalTextareaFieldBaseProps extends ChipModalFieldBaseProps {
@@ -536,6 +574,7 @@ export type ChipModalFieldProps =
536574 */
537575function 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