feat(chat): Improve keyboard shortcuts and accessibility in chat input
This commit is contained in:
@@ -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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user