Skip to content

Add Shift+Enter shortcut example for TextField.#167952

Merged
auto-submit[bot] merged 3 commits intoflutter:masterfrom
ksokolovskyi:add-shift-enter-support-to-web
Aug 19, 2025
Merged

Add Shift+Enter shortcut example for TextField.#167952
auto-submit[bot] merged 3 commits intoflutter:masterfrom
ksokolovskyi:add-shift-enter-support-to-web

Conversation

@ksokolovskyi
Copy link
Contributor

@ksokolovskyi ksokolovskyi commented Apr 28, 2025

Closes #167902

This PR adds a new TextField example which shows how to use Shortcuts and Actions widgets to create a custom Shift+Enter keyboard shortcut for inserting a new line.

example.mov

@github-actions github-actions bot added a: text input Entering text in a text field or keyboard related problems engine flutter/engine related. See also e: labels. platform-web Web applications specifically labels Apr 28, 2025
@ksokolovskyi ksokolovskyi changed the title [web] Add support to add a new line in multiline field with non-default action. [web] Add support for new line adding in a multiline field with non-default action. Apr 29, 2025
@kevmoo
Copy link
Contributor

kevmoo commented Apr 29, 2025

I wonder if this option should be made opt-in. I'll leave it to the framework folks...

@kevmoo
Copy link
Contributor

kevmoo commented Apr 29, 2025

We should be careful about creating behavior that differs from other (desktop) platforms

@justinmc
Copy link
Contributor

justinmc commented May 1, 2025

I think it makes sense to make this opt-in. I've seen both behaviors in native web apps (cmd+enter submits or not in a multiline field). So I'm thinking Flutter should be configurable as well if I'm understanding correctly.

CC @mdebbar

@ksokolovskyi
Copy link
Contributor Author

@kevmoo @justinmc thanks a lot for taking a look at this PR!
I am wondering how we could make this behavior opt-in from the framework side 🤔. I would appreciate it if you could suggest any options.

@mdebbar
Copy link
Contributor

mdebbar commented May 15, 2025

Let's figure out the framework side of the story first. That will dictate how the web side will be implemented.

@justinmc or @Renzo-Olivares do you wanna chime in with some ideas on how to provide this opt-in in the framework?

I suggest that we think about this holistically to create a cohesive story for handling cmd+enter, shift+enter, etc.

@justinmc
Copy link
Contributor

I agree that we should have a solution that covers all platforms here. It sounds like this might not be possible to solve in a straightforward way exclusively in the framework though (say with Shortcuts), because the enter key action is caught by the embedder first.

@justinmc
Copy link
Contributor

Could we solve this by, in each embedder, when a non-newline input action and a enter+shift keystroke is received, sending that key even to the framework instead of handling it as a submit? Then in the framework, the app developer could handle it via Shortcuts.

But what about the default case, where the app developer just wants the field to submit? We would probably need to map shift+enter to a DoNothingAndStopPropagation intent by default.

Just some vague notes on ideas thrown out in triage. We'd have to try this and/or think about this more.

@Renzo-Olivares
Copy link
Contributor

Renzo-Olivares commented May 22, 2025

Currently you can do something like this and the field won't be submitted on enter + shift. I used SelectAllTextIntent for examples sake.

Shortcuts(
  shortcuts: <ShortcutActivator, Intent>{
    SingleActivator(LogicalKeyboardKey.enter, shift: true): const SelectAllTextIntent(
      SelectionChangedCause.keyboard,
    ),
  },
  child: TextField(
    maxLines: null,
    textInputAction: TextInputAction.done,
    onSubmitted: (String? value) {
      debugPrint('Submitted: $value');
    },
  ),
),

Maybe in the web default text editing shortcuts mapping we could add:

SingleActivator(LogicalKeyboardKey.enter, shift: true): DoNothingAndStopPropagationEnterKeyTextIntent(),

and make the action it maps to overridable.

Then someone could override this with:

Actions(
  actions: <Type, Action<Intent>> {
    DoNothingAndStopPropagationEnterKeyTextIntent : _insertNewLineAction,
  },
  child: TextField(
    controller: _controller,
    maxLines: null,
    textInputAction: TextInputAction.done,
    onSubmitted: (String? value) {
      debugPrint('Submitted: $value');
    },
  ),
),

Though currently this is already possible at the moment since we don't define default shortcuts for enter + shift.

Shortcuts(
  shortcuts: <ShortcutActivator, Intent>{
    if (kIsWeb)
      SingleActivator(LogicalKeyboardKey.enter, shift: true):
          InsertNewLineTextIntent(),
  },
  child: Actions(
    actions: <Type, Action<Intent>>{
      if (kIsWeb)
        InsertNewLineTextIntent:
            CallbackAction<InsertNewLineTextIntent>(
              onInvoke: (InsertNewLineTextIntent intent) {
                final TextEditingValue value =
                    _controller.value;
                final String newText = value.text.replaceRange(
                  value.selection.start,
                  value.selection.end,
                  '\n',
                );
                _controller.value = value.copyWith(
                  text: newText,
                  selection: TextSelection.collapsed(
                    offset: value.selection.start + 1,
                  ),
                );
              },
            ),
    },
    child: TextField(
      controller: _controller,
      maxLines: null,
      textInputAction: TextInputAction.done,
      onSubmitted: (String? value) {
        debugPrint('Submitted: $value');
      },
    ),
  ),
),

class InsertNewLineTextIntent extends Intent {}

@justinmc
Copy link
Contributor

@ksokolovskyi What do you think about changing this PR based on the suggestions given by @Renzo-Olivares?

@ksokolovskyi
Copy link
Contributor Author

@justinmc @Renzo-Olivares @mdebbar, sorry for the silence from my side. Thanks a lot for your thoughts around this issue!
Let me read through the comments and wrap my mind around the proposed solution.

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. and removed engine flutter/engine related. See also e: labels. platform-web Web applications specifically labels Jul 1, 2025
@ksokolovskyi
Copy link
Contributor Author

Hi @justinmc @Renzo-Olivares, I reverted my changes to the web engine and added DoNothingAndStopEnterKeyPropagationIntent with the default overridable ignoring action.

Without the override, the behavior remains the same as in the stable, but now users can override the default action for DoNothingAndStopEnterKeyPropagationIntent to, for example, add a newline.

Demo Source Code
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Padding(
          padding: const EdgeInsets.all(20),
          child: Center(
            child: Input(),
          ),
        ),
      ),
    );
  }
}

class Input extends StatefulWidget {
  const Input({super.key});

  @override
  State<Input> createState() => _InputState();
}

class _InputState extends State<Input> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Actions(
      actions: <Type, Action<Intent>>{
        if (kIsWeb)
          DoNothingAndStopEnterKeyPropagationIntent:
              CallbackAction<DoNothingAndStopEnterKeyPropagationIntent>(
                onInvoke: (DoNothingAndStopEnterKeyPropagationIntent intent) {
                  final TextEditingValue value = _controller.value;
                  final String newText = value.text.replaceRange(
                    value.selection.end,
                    null,
                    '\n',
                  );
                  _controller.value = value.copyWith(
                    text: newText,
                    selection: TextSelection.collapsed(
                      offset: value.selection.start + 1,
                    ),
                  );
                  return null;
                },
              ),
      },
      child: TextField(
        controller: _controller,
        decoration: InputDecoration(border: OutlineInputBorder()),
        maxLines: null,
        textInputAction: TextInputAction.done,
        onSubmitted: (value) {
          print('ON SUBMITTED: "$value"');
        },
      ),
    );
  }
}
Demo

In the demo, I first type text, then press Enter key, and then press Shift + Enter.

demo.mov

I am not sure whether this is what you expected, so I would greatly appreciate feedback from your side.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think ideally we should get the default behavior correct on web out-of-the-box. @ksokolovskyi can you take a look at exactly what that default behavior should be if you haven't already? So what should happen on the native web in each of these cases in a multiline field:

  • enter
  • shift + enter
  • cmd/ctrl + enter
  • option + enter
  • alt + enter

With the current state of the PR, I think app developers still have some work to do to get this behavior correct, and reimplementing the insertion of a newline character is a bit rough for something that could be fairly common.

///
/// See also:
///
/// * [DefaultTextEditingShortcuts], which triggers this [Intent].
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Maybe link to DoNothingAndStopPropagationIntent too?

@ksokolovskyi
Copy link
Contributor Author

@justinmc on the web, the multiline field (textarea) is not getting submitted by default when the user presses Enter. I prepared a JSFiddle that shows how to achieve the submission on Enter press: https://jsfiddle.net/knevercode/e4v9pamw/4/. You can see in the source code that I have a keydown listener which checks whether the Enter key was pressed and ignores the event if Shift was pressed as well.

Since there is no default submission of the textarea on the web, I am unsure how to determine the default behavior in this case.

As the issue's OP works on an AI chat toolkit, I decided to take a look at how popular AI chat clients' fields behave.

Gemini ChatGPT Claude Perplexity
enter Submit Submit Submit Submit
shift + enter New line New line New line New line
cmd + enter Submit Submit Submit Submit
ctrl + enter Submit Submit Submit Submit
option + enter Submit Submit New line Submit

@justinmc
Copy link
Contributor

Ah you're right, this is not a built-in behavior on web, sorry. I guess Flutter shouldn't try to make this work by default either. So then we need to decide if it's worth it to make this change, or if we expect everyone to use the workaround.

Option 1: No change

We make no code change and expect users to do the workaround in order to handle this, where they catch shift+enter with a Shortcuts widget and then define what happens in an Actions.

If we choose this option, maybe we should add this as an example in the examples directory in order to help users discover this.

Taking @Renzo-Olivares's code from #167952 (comment):

Must use Shortcuts and Actions
Shortcuts(
  shortcuts: <ShortcutActivator, Intent>{
    if (kIsWeb)
      SingleActivator(LogicalKeyboardKey.enter, shift: true):
          InsertNewLineTextIntent(),
  },
  child: Actions(
    actions: <Type, Action<Intent>>{
      if (kIsWeb)
        InsertNewLineTextIntent:
            CallbackAction<InsertNewLineTextIntent>(
              onInvoke: (InsertNewLineTextIntent intent) {
                final TextEditingValue value =
                    _controller.value;
                final String newText = value.text.replaceRange(
                  value.selection.start,
                  value.selection.end,
                  '\n',
                );
                _controller.value = value.copyWith(
                  text: newText,
                  selection: TextSelection.collapsed(
                    offset: value.selection.start + 1,
                  ),
                );
              },
            ),
    },
    child: TextField(
      controller: _controller,
      maxLines: null,
      textInputAction: TextInputAction.done,
      onSubmitted: (String? value) {
        debugPrint('Submitted: $value');
      },
    ),
  ),
),

class InsertNewLineTextIntent extends Intent {}

Option 2: Add DoNothingAndStopPropagationEnterKeyTextIntent

In this case we add DoNothingAndStopPropagationEnterKeyTextIntent to DefaultTextEditingShortcuts in order to make this a little bit easier on users:

Only need to use Actions
Actions(
  actions: <Type, Action<Intent>> {
    DoNothingAndStopPropagationEnterKeyTextIntent : CallbackAction<InsertNewLineTextIntent>(
              onInvoke: (InsertNewLineTextIntent intent) {
                final TextEditingValue value =
                    _controller.value;
                final String newText = value.text.replaceRange(
                  value.selection.start,
                  value.selection.end,
                  '\n',
                );
                _controller.value = value.copyWith(
                  text: newText,
                  selection: TextSelection.collapsed(
                    offset: value.selection.start + 1,
                  ),
                );
              },
            ),
  },
  child: TextField(
    controller: _controller,
    maxLines: null,
    textInputAction: TextInputAction.done,
    onSubmitted: (String? value) {
      debugPrint('Submitted: $value');
    },
  ),
),

My thoughts

I like option 1. Adding the intent DoNothingAndStopPropagationEnterKeyTextIntent seems out of the ordinary for this one specific case when users can already do this themselves. It looks like on the web users also need to set up this behavior themselves. I think adding an example will help Flutter developers figure this out.

@ksokolovskyi
Copy link
Contributor Author

@justinmc thanks a lot for your thoughts on this issue and detailed options description!
I would prefer option 1 over 2 as well.

@Renzo-Olivares @kevmoo @mdebbar, what do you think?

@Renzo-Olivares
Copy link
Contributor

I like option 1 as well, and definitely agree that we should add an example in the examples directory and link it in the EditableText docs.

@justinmc
Copy link
Contributor

justinmc commented Aug 8, 2025

@ksokolovskyi Sounds good then, if you want to edit this PR to just be an example of how to do this, I'd be happy to rereview!

@ksokolovskyi
Copy link
Contributor Author

@justinmc @Renzo-Olivares Thanks! I'll be away next week, so I'll proceed with the update upon my return.

@github-actions github-actions bot removed c: contributor-productivity Team-specific productivity, code health, technical debt. platform-android Android applications specifically platform-ios iOS applications specifically tool Affects the "flutter" command-line tool. See also t: labels. engine flutter/engine related. See also e: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) a: internationalization Supporting other languages or locales. (aka i18n) platform-fuchsia Fuchsia code specifically f: scrolling Viewports, list views, slivers, etc. f: cupertino flutter/packages/flutter/cupertino repository platform-windows Building on or for Windows specifically f: routes Navigator, Router, and related APIs. f: gestures flutter/packages/flutter/gestures repository. platform-web Web applications specifically platform-linux Building on or for Linux specifically package flutter/packages repository. See also p: labels. a: desktop Running on desktop team-infra Owned by Infrastructure team f: focus Focus traversal, gaining or losing focus f: integration_test The flutter/packages/integration_test plugin labels Aug 19, 2025
Copy link
Contributor

@Renzo-Olivares Renzo-Olivares left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you for the contribution!

Copy link
Member

@loic-sharma loic-sharma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the wonderful contribution, and thanks everyone for the excellent discussion! :)

@ksokolovskyi
Copy link
Contributor Author

Thanks a lot, everyone, for the review and suggestions!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[web] There seems to be no way for a user to manually enter a newline into a TextField on the web

6 participants