Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
waleedlatif1 marked this conversation as resolved.
}
Comment thread
waleedlatif1 marked this conversation as resolved.

const syncScrollAfterUpdate = () => {
requestAnimationFrame(() => {
const input = inputRefs.current.get(cellKey)
Expand Down Expand Up @@ -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}
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'
Expand Down
155 changes: 117 additions & 38 deletions packages/emcn/src/components/chip-modal/chip-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>
)
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand All @@ -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,
Expand All @@ -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()
}
Comment thread
waleedlatif1 marked this conversation as resolved.
}}
placeholder={props.placeholder}
maxLength={props.type === 'input' ? props.maxLength : undefined}
autoComplete={props.autoComplete}
Expand All @@ -619,6 +668,7 @@ function renderChipModalControl(
{...aria}
/>
)
}
case 'textarea':
return (
<ChipTextarea
Expand Down Expand Up @@ -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])
Comment thread
waleedlatif1 marked this conversation as resolved.

const primaryChip = (
<Chip
variant={primaryAction.variant ?? 'primary'}
Expand Down
Loading