feat(chat): Improve keyboard shortcuts and accessibility in chat input

This commit is contained in:
cogwheel0
2025-12-05 18:23:00 +05:30
parent f676f50c85
commit e3b47ecf87

View File

@@ -1285,6 +1285,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}) { }) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
// Exclude from semantics so screen readers interact directly with the
// TextField, which provides its own accessibility via hintText.
excludeFromSemantics: true,
onTap: () { onTap: () {
if (!widget.enabled) return; if (!widget.enabled) return;
// Explicit user intent to focus: re-enable autofocus and focus // Explicit user intent to focus: re-enable autofocus and focus
@@ -1293,164 +1296,158 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} catch (_) {} } catch (_) {}
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
}, },
child: MergeSemantics( child: Shortcuts(
child: Semantics( shortcuts: () {
label: AppLocalizations.of(context)!.messageInputLabel, final map = <LogicalKeySet, Intent>{
hint: AppLocalizations.of(context)!.messageInputHint, LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter):
child: Shortcuts( const _SendMessageIntent(),
shortcuts: () { LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter):
final map = <LogicalKeySet, Intent>{ const _SendMessageIntent(),
LogicalKeySet( };
LogicalKeyboardKey.meta, if (sendOnEnter) {
map[LogicalKeySet(LogicalKeyboardKey.enter)] =
const _SendMessageIntent();
map[LogicalKeySet(
LogicalKeyboardKey.shift,
LogicalKeyboardKey.enter, LogicalKeyboardKey.enter,
): const _SendMessageIntent(), )] =
LogicalKeySet( const _InsertNewlineIntent();
LogicalKeyboardKey.control, }
LogicalKeyboardKey.enter, if (_showPromptOverlay) {
): const _SendMessageIntent(), map[LogicalKeySet(LogicalKeyboardKey.arrowDown)] =
}; const _SelectNextPromptIntent();
if (sendOnEnter) { map[LogicalKeySet(LogicalKeyboardKey.arrowUp)] =
map[LogicalKeySet(LogicalKeyboardKey.enter)] = const _SelectPreviousPromptIntent();
const _SendMessageIntent(); map[LogicalKeySet(LogicalKeyboardKey.escape)] =
map[LogicalKeySet( const _DismissPromptIntent();
LogicalKeyboardKey.shift, }
LogicalKeyboardKey.enter, return map;
)] = }(),
const _InsertNewlineIntent(); child: Actions(
} actions: <Type, Action<Intent>>{
if (_showPromptOverlay) { _SendMessageIntent: CallbackAction<_SendMessageIntent>(
map[LogicalKeySet(LogicalKeyboardKey.arrowDown)] = onInvoke: (intent) {
const _SelectNextPromptIntent(); if (_showPromptOverlay) {
map[LogicalKeySet(LogicalKeyboardKey.arrowUp)] = _confirmPromptSelection();
const _SelectPreviousPromptIntent(); return null;
map[LogicalKeySet(LogicalKeyboardKey.escape)] = }
const _DismissPromptIntent(); _sendMessage();
} return null;
return map;
}(),
child: Actions(
actions: <Type, Action<Intent>>{
_SendMessageIntent: CallbackAction<_SendMessageIntent>(
onInvoke: (intent) {
if (_showPromptOverlay) {
_confirmPromptSelection();
return null;
}
_sendMessage();
return null;
},
),
_InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(
onInvoke: (intent) {
_insertNewline();
return null;
},
),
_SelectNextPromptIntent:
CallbackAction<_SelectNextPromptIntent>(
onInvoke: (intent) {
_movePromptSelection(1);
return null;
},
),
_SelectPreviousPromptIntent:
CallbackAction<_SelectPreviousPromptIntent>(
onInvoke: (intent) {
_movePromptSelection(-1);
return null;
},
),
_DismissPromptIntent: CallbackAction<_DismissPromptIntent>(
onInvoke: (intent) {
_hidePromptOverlay();
return null;
},
),
}, },
child: Builder( ),
builder: (context) { _InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(
final double factor = isActive ? 1.0 : 0.0; onInvoke: (intent) {
final Color animatedPlaceholder = Color.lerp( _insertNewline();
placeholderBase, return null;
placeholderFocused, },
factor, ),
)!; _SelectNextPromptIntent: CallbackAction<_SelectNextPromptIntent>(
final Color animatedTextColor = Color.lerp( onInvoke: (intent) {
context.conduitTheme.inputText.withValues(alpha: 0.88), _movePromptSelection(1);
context.conduitTheme.inputText, return null;
factor, },
)!; ),
_SelectPreviousPromptIntent:
CallbackAction<_SelectPreviousPromptIntent>(
onInvoke: (intent) {
_movePromptSelection(-1);
return null;
},
),
_DismissPromptIntent: CallbackAction<_DismissPromptIntent>(
onInvoke: (intent) {
_hidePromptOverlay();
return null;
},
),
},
child: Builder(
builder: (context) {
final double factor = isActive ? 1.0 : 0.0;
final Color animatedPlaceholder = Color.lerp(
placeholderBase,
placeholderFocused,
factor,
)!;
final Color animatedTextColor = Color.lerp(
context.conduitTheme.inputText.withValues(alpha: 0.88),
context.conduitTheme.inputText,
factor,
)!;
final FontWeight recordingWeight = _isRecording final FontWeight recordingWeight = _isRecording
? FontWeight.w500 ? FontWeight.w500
: FontWeight.w400; : FontWeight.w400;
final TextStyle baseChatStyle = final TextStyle baseChatStyle = AppTypography.chatMessageStyle;
AppTypography.chatMessageStyle;
return TextField( // Wrap with Semantics to provide an accessible label for screen
controller: _controller, // readers. We avoid MergeSemantics which caused double-
focusNode: _focusNode, // announcements. The TextField provides its own text field
enabled: widget.enabled, // semantics; this just adds the descriptive label.
autofocus: false, return Semantics(
minLines: 1, label: AppLocalizations.of(context)!.messageInputLabel,
maxLines: null, child: TextField(
keyboardType: TextInputType.multiline, controller: _controller,
textCapitalization: TextCapitalization.sentences, focusNode: _focusNode,
textInputAction: sendOnEnter enabled: widget.enabled,
? TextInputAction.send autofocus: false,
: TextInputAction.newline, minLines: 1,
autofillHints: const <String>[], maxLines: null,
showCursor: true, keyboardType: TextInputType.multiline,
scrollPadding: const EdgeInsets.only(bottom: 80), textCapitalization: TextCapitalization.sentences,
keyboardAppearance: brightness, textInputAction: sendOnEnter
cursorColor: animatedTextColor, ? TextInputAction.send
style: baseChatStyle.copyWith( : TextInputAction.newline,
color: animatedTextColor, autofillHints: const <String>[],
showCursor: true,
scrollPadding: const EdgeInsets.only(bottom: 80),
keyboardAppearance: brightness,
cursorColor: animatedTextColor,
style: baseChatStyle.copyWith(
color: animatedTextColor,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
fontWeight: recordingWeight,
),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.messageHintText,
hintStyle: baseChatStyle.copyWith(
color: animatedPlaceholder,
fontWeight: recordingWeight,
fontStyle: _isRecording fontStyle: _isRecording
? FontStyle.italic ? FontStyle.italic
: FontStyle.normal, : FontStyle.normal,
fontWeight: recordingWeight,
), ),
decoration: InputDecoration( filled: false,
hintText: AppLocalizations.of(context)!.messageHintText, border: InputBorder.none,
hintStyle: baseChatStyle.copyWith( enabledBorder: InputBorder.none,
color: animatedPlaceholder, focusedBorder: InputBorder.none,
fontWeight: recordingWeight, errorBorder: InputBorder.none,
fontStyle: _isRecording disabledBorder: InputBorder.none,
? FontStyle.italic contentPadding: contentPadding,
: FontStyle.normal, isDense: true,
), alignLabelWithHint: true,
filled: false, ),
border: InputBorder.none, // Enable pasting images and files from clipboard
enabledBorder: InputBorder.none, contentInsertionConfiguration: ContentInsertionConfiguration(
focusedBorder: InputBorder.none, allowedMimeTypes: ClipboardAttachmentService
errorBorder: InputBorder.none, .supportedImageMimeTypes
disabledBorder: InputBorder.none, .toList(),
contentPadding: contentPadding, onContentInserted: _handleContentInserted,
isDense: true, ),
alignLabelWithHint: true, onSubmitted: (_) {
), if (sendOnEnter) {
// Enable pasting images and files from clipboard _sendMessage();
contentInsertionConfiguration: }
ContentInsertionConfiguration( },
allowedMimeTypes: ClipboardAttachmentService onTap: () {
.supportedImageMimeTypes if (!widget.enabled) return;
.toList(), _ensureFocusedIfEnabled();
onContentInserted: _handleContentInserted, },
), ),
onSubmitted: (_) { );
if (sendOnEnter) { },
_sendMessage();
}
},
onTap: () {
if (!widget.enabled) return;
_ensureFocusedIfEnabled();
},
);
},
),
),
), ),
), ),
), ),