Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
improvement(ux): submit on Enter in chip modals and advance table rows#5781
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
9ac301a79828f4aa413a445d46d9File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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' | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }} | ||
| 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]) | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const primaryChip = ( | ||
| <Chip | ||
| variant={primaryAction.variant ?? 'primary'} | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.