make conversation card actions keyboard-reachable
The copy button and custom message actions only rendered on hover, so they were unreachable by keyboard or assistive tech. Keep them in the tree always — revealed via opacity on hover OR focus — and route each through ClideTappable (Tab traversal + Enter/Space activation) with a Semantics button label and onTap so AT can discover and invoke them. alwaysIncludeSemantics keeps them in the semantics tree while hidden. T-174. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,32 @@ void main() {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// Returns the Opacity wrapping the action bar (the Opacity that reveals/hides
|
||||
// the action buttons on hover or focus).
|
||||
Opacity actionBarOpacity(WidgetTester tester) {
|
||||
// The action bar Opacity is a direct child of the header Row — pick the
|
||||
// first Opacity that is an ancestor of the 'copy' Semantics (or any action
|
||||
// label), which is the one whose value toggles on hover/focus.
|
||||
return tester.widgetList<Opacity>(find.byType(Opacity)).first;
|
||||
}
|
||||
|
||||
testWidgets('copy button is always in the tree (Opacity 0 before hover)', (tester) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
const ConversationCard(
|
||||
accent: Color(0xFFFFFFFF),
|
||||
label: 'you',
|
||||
copyText: 'the raw message',
|
||||
body: Text('the raw message', textDirection: TextDirection.ltr),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
// The text widget is in the tree (always), but the Opacity hides it.
|
||||
expect(find.text('copy'), findsOneWidget);
|
||||
expect(actionBarOpacity(tester).opacity, 0.0);
|
||||
});
|
||||
|
||||
testWidgets('copy button (on hover) writes the copyText to the clipboard', (tester) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
@@ -40,10 +66,9 @@ void main() {
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(find.text('copy'), findsNothing); // hidden until hover
|
||||
|
||||
await hoverCard(tester);
|
||||
expect(find.text('copy'), findsOneWidget);
|
||||
expect(actionBarOpacity(tester).opacity, 1.0);
|
||||
|
||||
await tester.tap(find.text('copy'));
|
||||
// writePlain awaits the clipboard channel; let the real async settle.
|
||||
@@ -51,6 +76,63 @@ void main() {
|
||||
expect(f.services.clipboard.readAs<String>(), 'the raw message');
|
||||
});
|
||||
|
||||
testWidgets('copy button: keyboard focus + ActivateIntent writes to clipboard without hovering', (tester) async {
|
||||
// T-174: actions must be keyboard-reachable even when the card is not hovered.
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
const ConversationCard(
|
||||
accent: Color(0xFFFFFFFF),
|
||||
label: 'you',
|
||||
copyText: 'keyboard written',
|
||||
body: Text('keyboard written', textDirection: TextDirection.ltr),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
// Locate the ClideTappable whose FocusNode we want: the one wrapping the
|
||||
// 'copy' label. We drive focus programmatically via the node embedded in
|
||||
// the widget — find the Focus descendent nearest to the 'copy' text.
|
||||
final copyTextFinder = find.text('copy');
|
||||
expect(copyTextFinder, findsOneWidget);
|
||||
|
||||
// Walk up to find the Focus that ClideTappable installed, then request focus.
|
||||
final focusWidget = tester.widget<Focus>(
|
||||
find.ancestor(of: copyTextFinder, matching: find.byType(Focus)).first,
|
||||
);
|
||||
focusWidget.focusNode!.requestFocus();
|
||||
await tester.pump(); // focus resolves; the focus listener calls setState
|
||||
await tester.pump(); // the scheduled rebuild paints the lifted opacity
|
||||
|
||||
expect(focusWidget.focusNode!.hasFocus, isTrue, reason: 'copy action should accept keyboard focus');
|
||||
|
||||
// With focus, the action bar opacity should lift to 1.0.
|
||||
expect(actionBarOpacity(tester).opacity, 1.0);
|
||||
|
||||
// Invoke via ActivateIntent (what Enter/Space dispatches from the keymap).
|
||||
Actions.invoke(focusWidget.focusNode!.context!, const ActivateIntent());
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
|
||||
expect(f.services.clipboard.readAs<String>(), 'keyboard written');
|
||||
});
|
||||
|
||||
testWidgets('copy button carries a Semantics button label', (tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
const ConversationCard(
|
||||
accent: Color(0xFFFFFFFF),
|
||||
label: 'you',
|
||||
copyText: 'msg',
|
||||
body: Text('msg', textDirection: TextDirection.ltr),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
// Semantics label 'copy' must be present even before hover, so AT can
|
||||
// discover the button without the user mousing over first.
|
||||
expect(find.bySemanticsLabel('copy'), findsOneWidget);
|
||||
handle.dispose();
|
||||
});
|
||||
|
||||
testWidgets('collapsible card hides its body until expanded', (tester) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
@@ -87,6 +169,23 @@ void main() {
|
||||
expect(find.bySemanticsLabel('Collapse'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('custom actions are always in the tree (Opacity 0 before hover)', (tester) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
ConversationCard(
|
||||
accent: const Color(0xFFFFFFFF),
|
||||
label: 'claude',
|
||||
body: const Text('body', textDirection: TextDirection.ltr),
|
||||
actions: [MessageAction(label: 'fork', onInvoke: () {})],
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
// 'fork' is in the tree (always), but the Opacity hides it.
|
||||
expect(find.text('fork'), findsOneWidget);
|
||||
expect(actionBarOpacity(tester).opacity, 0.0);
|
||||
});
|
||||
|
||||
testWidgets('custom actions appear on hover and invoke', (tester) async {
|
||||
var forked = false;
|
||||
await tester.pumpWidget(harness(
|
||||
@@ -99,9 +198,9 @@ void main() {
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(find.text('fork'), findsNothing);
|
||||
|
||||
await hoverCard(tester);
|
||||
expect(actionBarOpacity(tester).opacity, 1.0);
|
||||
await tester.tap(find.text('fork'));
|
||||
await tester.pump();
|
||||
expect(forked, isTrue);
|
||||
|
||||
Reference in New Issue
Block a user