feat: checklist cell editor a11y improvement (#4784)

This commit is contained in:
Richard Shiue 2024-03-02 19:14:00 +08:00 committed by GitHub
parent f4170755fa
commit b38fc43300
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 169 additions and 125 deletions

View File

@ -11,7 +11,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'checklist_cell_bloc.freezed.dart'; part 'checklist_cell_bloc.freezed.dart';
class ChecklistSelectOption { class ChecklistSelectOption {
ChecklistSelectOption(this.isSelected, this.data); ChecklistSelectOption({required this.isSelected, required this.data});
final bool isSelected; final bool isSelected;
final SelectOptionPB data; final SelectOptionPB data;
@ -26,6 +26,7 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
), ),
super(ChecklistCellState.initial(cellController)) { super(ChecklistCellState.initial(cellController)) {
_dispatch(); _dispatch();
_startListening();
} }
final ChecklistCellController cellController; final ChecklistCellController cellController;
@ -46,9 +47,6 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
on<ChecklistCellEvent>( on<ChecklistCellEvent>(
(event, emit) async { (event, emit) async {
await event.when( await event.when(
initial: () {
_startListening();
},
didReceiveOptions: (data) { didReceiveOptions: (data) {
if (data == null) { if (data == null) {
emit( emit(
@ -71,8 +69,8 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
updateTaskName: (option, name) { updateTaskName: (option, name) {
_updateOption(option, name); _updateOption(option, name);
}, },
selectTask: (option) async { selectTask: (id) async {
await _checklistCellService.select(optionId: option.id); await _checklistCellService.select(optionId: id);
}, },
createNewTask: (name) async { createNewTask: (name) async {
final result = await _checklistCellService.create(name: name); final result = await _checklistCellService.create(name: name);
@ -81,8 +79,8 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
(err) => Log.error(err), (err) => Log.error(err),
); );
}, },
deleteTask: (option) async { deleteTask: (id) async {
await _deleteOption([option]); await _deleteOption([id]);
}, },
); );
}, },
@ -102,21 +100,17 @@ class ChecklistCellBloc extends Bloc<ChecklistCellEvent, ChecklistCellState> {
void _updateOption(SelectOptionPB option, String name) async { void _updateOption(SelectOptionPB option, String name) async {
final result = final result =
await _checklistCellService.updateName(option: option, name: name); await _checklistCellService.updateName(option: option, name: name);
result.fold((l) => null, (err) => Log.error(err)); result.fold((l) => null, (err) => Log.error(err));
} }
Future<void> _deleteOption(List<SelectOptionPB> options) async { Future<void> _deleteOption(List<String> options) async {
final result = await _checklistCellService.delete( final result = await _checklistCellService.delete(optionIds: options);
optionIds: options.map((e) => e.id).toList(),
);
result.fold((l) => null, (err) => Log.error(err)); result.fold((l) => null, (err) => Log.error(err));
} }
} }
@freezed @freezed
class ChecklistCellEvent with _$ChecklistCellEvent { class ChecklistCellEvent with _$ChecklistCellEvent {
const factory ChecklistCellEvent.initial() = _InitialCell;
const factory ChecklistCellEvent.didReceiveOptions( const factory ChecklistCellEvent.didReceiveOptions(
ChecklistCellDataPB? data, ChecklistCellDataPB? data,
) = _DidReceiveCellUpdate; ) = _DidReceiveCellUpdate;
@ -124,12 +118,10 @@ class ChecklistCellEvent with _$ChecklistCellEvent {
SelectOptionPB option, SelectOptionPB option,
String name, String name,
) = _UpdateTaskName; ) = _UpdateTaskName;
const factory ChecklistCellEvent.selectTask(SelectOptionPB task) = const factory ChecklistCellEvent.selectTask(String taskId) = _SelectTask;
_SelectTask;
const factory ChecklistCellEvent.createNewTask(String description) = const factory ChecklistCellEvent.createNewTask(String description) =
_CreateNewTask; _CreateNewTask;
const factory ChecklistCellEvent.deleteTask(SelectOptionPB option) = const factory ChecklistCellEvent.deleteTask(String taskId) = _DeleteTask;
_DeleteTask;
} }
@freezed @freezed
@ -157,16 +149,14 @@ List<ChecklistSelectOption> _makeChecklistSelectOptions(
if (data == null) { if (data == null) {
return []; return [];
} }
return data.options
final List<ChecklistSelectOption> options = []; .map(
final List<SelectOptionPB> allOptions = List.from(data.options); (option) => ChecklistSelectOption(
final selectedOptionIds = data.selectedOptions.map((e) => e.id).toList(); isSelected: data.selectedOptions.any(
(selected) => selected.id == option.id,
for (final option in allOptions) { ),
options.add( data: option,
ChecklistSelectOption(selectedOptionIds.contains(option.id), option), ),
); )
} .toList();
return options;
} }

View File

@ -42,7 +42,7 @@ class _ChecklistCellState extends State<ChecklistCardCell> {
widget.databaseController, widget.databaseController,
widget.cellContext, widget.cellContext,
).as(), ).as(),
)..add(const ChecklistCellEvent.initial()); );
}, },
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>( child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) { builder: (context, state) {

View File

@ -64,14 +64,17 @@ class _ChecklistItemsState extends State<ChecklistItems> {
} }
final children = tasks final children = tasks
.mapIndexed( .mapIndexed(
(index, task) => ChecklistItem( (index, task) => Padding(
task: task, padding: const EdgeInsets.symmetric(vertical: 2.0),
autofocus: widget.state.newTask && index == tasks.length - 1, child: ChecklistItem(
onSubmitted: () { task: task,
if (index == tasks.length - 1) { autofocus: widget.state.newTask && index == tasks.length - 1,
widget.bloc.add(const ChecklistCellEvent.createNewTask("")); onSubmitted: () {
} if (index == tasks.length - 1) {
}, widget.bloc.add(const ChecklistCellEvent.createNewTask(""));
}
},
),
), ),
) )
.toList(); .toList();
@ -111,7 +114,7 @@ class _ChecklistItemsState extends State<ChecklistItems> {
], ],
), ),
), ),
const VSpace(4), const VSpace(2.0),
...children, ...children,
ChecklistItemControl(cellNotifer: widget.cellContainerNotifier), ChecklistItemControl(cellNotifer: widget.cellContainerNotifier),
], ],
@ -136,7 +139,7 @@ class ChecklistItemControl extends StatelessWidget {
.read<ChecklistCellBloc>() .read<ChecklistCellBloc>()
.add(const ChecklistCellEvent.createNewTask("")), .add(const ChecklistCellEvent.createNewTask("")),
child: Container( child: Container(
margin: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), margin: const EdgeInsets.fromLTRB(8.0, 2.0, 8.0, 0),
height: 12, height: 12,
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),

View File

@ -58,7 +58,7 @@ class GridChecklistCellState extends GridCellState<EditableChecklistCell> {
widget.databaseController, widget.databaseController,
widget.cellContext, widget.cellContext,
).as(), ).as(),
)..add(const ChecklistCellEvent.initial()); );
@override @override
void dispose() { void dispose() {

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
@ -23,10 +24,10 @@ class ChecklistCellEditor extends StatefulWidget {
final ChecklistCellController cellController; final ChecklistCellController cellController;
@override @override
State<ChecklistCellEditor> createState() => _GridChecklistCellState(); State<ChecklistCellEditor> createState() => _ChecklistCellEditorState();
} }
class _GridChecklistCellState extends State<ChecklistCellEditor> { class _ChecklistCellEditorState extends State<ChecklistCellEditor> {
/// Focus node for the new task text field /// Focus node for the new task text field
late final FocusNode newTaskFocusNode; late final FocusNode newTaskFocusNode;
@ -56,18 +57,14 @@ class _GridChecklistCellState extends State<ChecklistCellEditor> {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
AnimatedSwitcher( if (state.tasks.isNotEmpty)
duration: const Duration(milliseconds: 300), Padding(
child: state.tasks.isEmpty padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
? const SizedBox.shrink() child: ChecklistProgressBar(
: Padding( tasks: state.tasks,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), percent: state.percent,
child: ChecklistProgressBar( ),
tasks: state.tasks, ),
percent: state.percent,
),
),
),
ChecklistItemList( ChecklistItemList(
options: state.tasks, options: state.tasks,
onUpdateTask: () => newTaskFocusNode.requestFocus(), onUpdateTask: () => newTaskFocusNode.requestFocus(),
@ -92,7 +89,7 @@ class _GridChecklistCellState extends State<ChecklistCellEditor> {
/// Displays the a list of all the exisiting tasks and an input field to create /// Displays the a list of all the exisiting tasks and an input field to create
/// a new task if `isAddingNewTask` is true /// a new task if `isAddingNewTask` is true
class ChecklistItemList extends StatefulWidget { class ChecklistItemList extends StatelessWidget {
const ChecklistItemList({ const ChecklistItemList({
super.key, super.key,
required this.options, required this.options,
@ -102,26 +99,19 @@ class ChecklistItemList extends StatefulWidget {
final List<ChecklistSelectOption> options; final List<ChecklistSelectOption> options;
final VoidCallback onUpdateTask; final VoidCallback onUpdateTask;
@override
State<ChecklistItemList> createState() => _ChecklistItemListState();
}
class _ChecklistItemListState extends State<ChecklistItemList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.options.isEmpty) { if (options.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final itemList = widget.options final itemList = options
.mapIndexed( .mapIndexed(
(index, option) => Padding( (index, option) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ChecklistItem( child: ChecklistItem(
task: option, task: option,
onSubmitted: index == widget.options.length - 1 onSubmitted: index == options.length - 1 ? onUpdateTask : null,
? widget.onUpdateTask
: null,
key: ValueKey(option.data.id), key: ValueKey(option.data.id),
), ),
), ),
@ -140,6 +130,22 @@ class _ChecklistItemListState extends State<ChecklistItemList> {
} }
} }
class _SelectTaskIntent extends Intent {
const _SelectTaskIntent();
}
class _DeleteTaskIntent extends Intent {
const _DeleteTaskIntent();
}
class _StartEditingTaskIntent extends Intent {
const _StartEditingTaskIntent();
}
class _EndEditingTaskIntent extends Intent {
const _EndEditingTaskIntent();
}
/// Represents an existing task /// Represents an existing task
@visibleForTesting @visibleForTesting
class ChecklistItem extends StatefulWidget { class ChecklistItem extends StatefulWidget {
@ -160,58 +166,80 @@ class ChecklistItem extends StatefulWidget {
class _ChecklistItemState extends State<ChecklistItem> { class _ChecklistItemState extends State<ChecklistItem> {
late final TextEditingController _textController; late final TextEditingController _textController;
late final FocusNode _focusNode; final FocusNode _focusNode = FocusNode();
bool _isHovered = false; bool _isHovered = false;
bool _isFocused = false;
Timer? _debounceOnChanged; Timer? _debounceOnChanged;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textController = TextEditingController(text: widget.task.data.name); _textController = TextEditingController(text: widget.task.data.name);
_focusNode = FocusNode(
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.escape) {
node.unfocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
if (widget.autofocus) {
_focusNode.requestFocus();
}
} }
@override @override
void dispose() { void dispose() {
_debounceOnChanged?.cancel();
_textController.dispose(); _textController.dispose();
_focusNode.dispose(); _focusNode.dispose();
_debounceOnChanged?.cancel();
super.dispose(); super.dispose();
} }
@override @override
void didUpdateWidget(ChecklistItem oldWidget) { void didUpdateWidget(ChecklistItem oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.task.data.name != oldWidget.task.data.name && if (widget.task.data.name != oldWidget.task.data.name) {
!_focusNode.hasFocus) {
_textController.text = widget.task.data.name; _textController.text = widget.task.data.name;
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final icon = FlowySvg( return FocusableActionDetector(
widget.task.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, onShowHoverHighlight: (isHovered) {
blendMode: BlendMode.dst, setState(() => _isHovered = isHovered);
); },
return MouseRegion( onFocusChange: (isFocused) {
onEnter: (event) => setState(() => _isHovered = true), setState(() => _isFocused = isFocused);
onExit: (event) => setState(() => _isHovered = false), },
actions: {
_SelectTaskIntent: CallbackAction<_SelectTaskIntent>(
onInvoke: (_SelectTaskIntent intent) => context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.selectTask(widget.task.data.id)),
),
_DeleteTaskIntent: CallbackAction<_DeleteTaskIntent>(
onInvoke: (_DeleteTaskIntent intent) => context
.read<ChecklistCellBloc>()
.add(ChecklistCellEvent.deleteTask(widget.task.data.id)),
),
_StartEditingTaskIntent: CallbackAction<_StartEditingTaskIntent>(
onInvoke: (_StartEditingTaskIntent intent) =>
_focusNode.requestFocus(),
),
_EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>(
onInvoke: (_EndEditingTaskIntent intent) => _focusNode.unfocus(),
),
},
shortcuts: {
const SingleActivator(LogicalKeyboardKey.space):
const _SelectTaskIntent(),
const SingleActivator(LogicalKeyboardKey.delete):
const _DeleteTaskIntent(),
const SingleActivator(LogicalKeyboardKey.enter):
const _StartEditingTaskIntent(),
if (Platform.isMacOS)
const SingleActivator(LogicalKeyboardKey.enter, meta: true):
const _SelectTaskIntent()
else
const SingleActivator(LogicalKeyboardKey.enter, control: true):
const _SelectTaskIntent(),
},
descendantsAreTraversable: false,
child: Container( child: Container(
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isHovered color: _isHovered || _isFocused || _focusNode.hasFocus
? AFThemeExtension.of(context).lightGreyHover ? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent, : Colors.transparent,
borderRadius: Corners.s6Border, borderRadius: Corners.s6Border,
@ -220,43 +248,65 @@ class _ChecklistItemState extends State<ChecklistItem> {
children: [ children: [
FlowyIconButton( FlowyIconButton(
width: 32, width: 32,
icon: icon, icon: FlowySvg(
widget.task.isSelected
? FlowySvgs.check_filled_s
: FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
),
hoverColor: Colors.transparent, hoverColor: Colors.transparent,
onPressed: () => context.read<ChecklistCellBloc>().add( onPressed: () => context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.selectTask(widget.task.data), ChecklistCellEvent.selectTask(widget.task.data.id),
), ),
), ),
Expanded( Expanded(
child: TextField( child: Shortcuts(
controller: _textController, shortcuts: const {
focusNode: _focusNode, SingleActivator(LogicalKeyboardKey.space):
style: Theme.of(context).textTheme.bodyMedium, DoNothingAndStopPropagationIntent(),
decoration: InputDecoration( SingleActivator(LogicalKeyboardKey.delete):
border: InputBorder.none, DoNothingAndStopPropagationIntent(),
isCollapsed: true, SingleActivator(LogicalKeyboardKey.enter):
contentPadding: EdgeInsets.only( DoNothingAndStopPropagationIntent(),
top: 8.0, SingleActivator(LogicalKeyboardKey.escape):
bottom: 8.0, _EndEditingTaskIntent(),
left: 2.0,
right: _isHovered ? 2.0 : 8.0,
),
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
onChanged: _debounceOnChangedText,
onSubmitted: (description) {
_submitUpdateTaskDescription(description);
widget.onSubmitted?.call();
}, },
child: TextField(
controller: _textController,
focusNode: _focusNode,
autofocus: widget.autofocus,
style: Theme.of(context).textTheme.bodyMedium,
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
contentPadding: EdgeInsets.only(
top: 8.0,
bottom: 8.0,
left: 2.0,
right: _isHovered ? 2.0 : 8.0,
),
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
onChanged: (text) {
if (_textController.value.composing.isCollapsed) {
_debounceOnChangedText(text);
}
},
onSubmitted: (description) {
_submitUpdateTaskDescription(description);
widget.onSubmitted?.call();
},
),
), ),
), ),
if (_isHovered) if (_isHovered || _isFocused || _focusNode.hasFocus)
FlowyIconButton( FlowyIconButton(
width: 32, width: 32,
icon: const FlowySvg(FlowySvgs.delete_s), icon: const FlowySvg(FlowySvgs.delete_s),
hoverColor: Colors.transparent, hoverColor: Colors.transparent,
iconColorOnHover: Theme.of(context).colorScheme.error, iconColorOnHover: Theme.of(context).colorScheme.error,
onPressed: () => context.read<ChecklistCellBloc>().add( onPressed: () => context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.deleteTask(widget.task.data), ChecklistCellEvent.deleteTask(widget.task.data.id),
), ),
), ),
], ],
@ -276,7 +326,7 @@ class _ChecklistItemState extends State<ChecklistItem> {
context.read<ChecklistCellBloc>().add( context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.updateTaskName( ChecklistCellEvent.updateTaskName(
widget.task.data, widget.task.data,
description.trim(), description,
), ),
); );
} }

View File

@ -159,7 +159,7 @@ class _ChecklistItemState extends State<_ChecklistItem> {
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
onTap: () => context onTap: () => context
.read<ChecklistCellBloc>() .read<ChecklistCellBloc>()
.add(ChecklistCellEvent.selectTask(widget.task.data)), .add(ChecklistCellEvent.selectTask(widget.task.data.id)),
child: SizedBox.square( child: SizedBox.square(
dimension: 44, dimension: 44,
child: Center( child: Center(
@ -239,7 +239,7 @@ class _ChecklistItemState extends State<_ChecklistItem> {
child: InkWell( child: InkWell(
onTap: () { onTap: () {
context.read<ChecklistCellBloc>().add( context.read<ChecklistCellBloc>().add(
ChecklistCellEvent.deleteTask(widget.task.data), ChecklistCellEvent.deleteTask(widget.task.data.id),
); );
context.pop(); context.pop();
}, },

View File

@ -161,15 +161,16 @@ class PopoverState extends State<Popover> {
), ),
); );
return FocusScope( return CallbackShortcuts(
onKey: (node, event) { bindings: {
if (event.logicalKey == LogicalKeyboardKey.escape) { const SingleActivator(LogicalKeyboardKey.escape): () =>
_removeRootOverlay(); _removeRootOverlay(),
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}, },
child: Stack(children: children), child: FocusScope(
child: Stack(
children: children,
),
),
); );
}); });
_rootEntry.addEntry(context, this, newEntry, widget.asBarrier); _rootEntry.addEntry(context, this, newEntry, widget.asBarrier);