Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,25 @@ jobs:
id: example_tests
run: flutter test packages/example/test

# A missing reference image does not create a file under failures/. Run
# the same pinned golden suite in update mode so reviewers still receive
# a candidate from the authoritative Ubuntu host. The original test step
# remains failed until the candidate is explicitly reviewed and checked
# in.
- name: Generate golden candidate images
# A missing baseline cannot produce a failure diff. Keep the original
# example-test step blocking, then generate review candidates only after
# that exact pinned-host failure.
- name: Generate golden diagnostic candidates
if: failure() && steps.example_tests.outcome == 'failure'
run: flutter test packages/example/test/goldens --update-goldens
run: >-
flutter test packages/example/test/goldens
--update-goldens

- name: Upload golden diagnostic candidates
if: failure() && steps.example_tests.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: golden-diagnostics-${{ github.sha }}
path: |
packages/example/test/goldens/components/baselines/
packages/example/test/goldens/components/failures/
if-no-files-found: error
retention-days: 14

- name: Upload golden failure images
if: failure()
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ jobs:
- name: Verify macOS screenshot evidence
run: |
test -s packages/example/build/integration_test_screenshots/dialog__open__macos__reference.png
test -s packages/example/build/integration_test_screenshots/link__default_inline__macos__reference.png
test -s packages/example/build/integration_test_screenshots/link__keyboard_focus__macos__reference.png
test -s packages/example/build/integration_test_screenshots/link__long_text_200__macos__reference.png
test -s packages/example/build/integration_test_screenshots/alert_dialog__open_safe_focus__macos__reference.png
test -s packages/example/build/integration_test_screenshots/alert_dialog__long_message_200_text__macos__reference.png
test -s packages/example/build/integration_test_screenshots/manifest.json
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/integration-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ jobs:
--chrome-binary="${{ steps.chrome.outputs.chrome-path }}" \
2>&1 | tee build/web-integration.log

flutter drive \
--driver=test_driver/link_browser_driver.dart \
--target=integration_test/link_browser_driver_app.dart \
-d web-server \
--browser-name=chrome \
--browser-dimension=800x600@1 \
--chrome-binary="${{ steps.chrome.outputs.chrome-path }}" \
2>&1 | tee build/web-link-browser.log

- name: Verify web integration evidence
run: |
log=packages/example/build/web-integration.log
Expand All @@ -77,6 +86,8 @@ jobs:
echo "The in-app test runner reported failures." >&2
exit 1
fi
grep -F "Trusted browser Link ownership checks passed." \
packages/example/build/web-link-browser.log

- name: Upload web integration evidence
if: always()
Expand All @@ -85,6 +96,7 @@ jobs:
name: integration-web-${{ github.sha }}
path: |
packages/example/build/web-integration.log
packages/example/build/web-link-browser.log
packages/example/build/integration_response_data.json
if-no-files-found: warn
retention-days: 14
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The complete documentation covers detailed component APIs and examples, guides a
## Supported Components

- NakedButton — button interactions (hover, press, focus)
- NakedLink — native Link navigation, semantics, and Enter-only activation
- NakedCheckbox — toggle behavior and semantics
- NakedRadio — single‑select radio with group management
- NakedSelect — dropdown/select with keyboard navigation
Expand Down Expand Up @@ -91,6 +92,54 @@ NakedButton(
)
```

### Custom Link

Use a Link for navigation rather than styling a Button like text. `linkUrl` is
required, while `enabled` is the only availability switch. Naked UI retains a
native anchor through Flutter's official `url_launcher.Link`; ordinary external
web navigation opens in the current tab, while internal/non-web defaults use
its `FollowLink` path. Enter and Numpad Enter activate, while
Space remains available to the page. Validate destinations before constructing
a Link—Naked UI accepts every `Uri` unchanged.

```dart
NakedLink(
linkUrl: Uri.parse('https://example.com/docs'),
onActivated: (url) => debugPrint('Activated $url'),
child: const Text('Documentation'),
builder: (context, state, child) => DecoratedBox(
decoration: BoxDecoration(
color: state.isHovered ? Colors.blue.shade50 : Colors.transparent,
border: Border.all(
color: state.isFocused ? Colors.blue : Colors.transparent,
),
),
child: child,
),
)
```

### Custom Link Resolution

Install a resolver around a subtree when the application, rather than the
platform, should route ordinary Link activations. Returning `handled` prevents
the default navigation; `onActivated` remains an observation hook and cannot
cancel it. Modified, middle, and secondary clicks stay browser-owned.

```dart
NakedLinkResolver(
resolve: (context, url) {
Navigator.of(context).pushNamed(url.toString());
return NakedLinkResolution.handled;
},
child: NakedLink(
linkUrl: Uri.parse('/account'),
onActivated: (url) => debugPrint('Activated $url'),
child: const Text('Account settings'),
),
)
```

### Custom Checkbox
Build a checkbox with custom visuals while maintaining proper state management.

Expand Down
2 changes: 2 additions & 0 deletions packages/example/integration_test/all_tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'components/naked_accordion_integration.dart' as accordion_tests;
import 'components/naked_button_integration.dart' as button_tests;
import 'components/naked_checkbox_integration.dart' as checkbox_tests;
import 'components/naked_dialog_integration.dart' as dialog_tests;
import 'components/naked_link_integration.dart' as link_tests;
import 'components/naked_menu_integration.dart' as menu_tests;
import 'components/naked_popover_integration.dart' as popover_tests;
import 'components/naked_radio_integration.dart' as radio_tests;
Expand All @@ -33,6 +34,7 @@ void main() {
group('Button Tests', button_tests.main);
group('Checkbox Tests', checkbox_tests.main);
group('Dialog Tests', dialog_tests.main);
group('Link Tests', link_tests.main);
group('Menu Tests', menu_tests.main);
group('Popover Tests', popover_tests.main);
group('Radio Tests', radio_tests.main);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import 'package:example/api/naked_link.0.dart' as link_example;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../helpers/keyboard_test_helpers.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('NakedLink Integration Tests', () {
testWidgets('Tab and Enter activate once and retain Link focus', (
tester,
) async {
await tester.pumpWidget(const link_example.MyApp());
await tester.pump();
final primary = find.byKey(const ValueKey('link.primary'));

FocusManager.instance.primaryFocus?.unfocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(tester.hasPrimaryFocusOn(primary), isTrue);

await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(find.text('Result: primary; activations: 1'), findsOneWidget);
expect(tester.hasPrimaryFocusOn(primary), isTrue);
});

testWidgets('Space does not activate and remains available to web scroll', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 320,
child: link_example.LinkExample(textScale: 2, longText: true),
),
),
),
),
);
await tester.pump();
final primary = find.byKey(const ValueKey('link.primary'));
final scrollable = Scrollable.of(tester.element(primary));

FocusManager.instance.primaryFocus?.unfocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(tester.hasPrimaryFocusOn(primary), isTrue);
final before = scrollable.position.pixels;

await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump();
expect(find.text('Result: none; activations: 0'), findsOneWidget);
expect(tester.hasPrimaryFocusOn(primary), isTrue);

if (kIsWeb) {
expect(scrollable.position.maxScrollExtent, greaterThan(0));
await tester.pumpUntil(
() => scrollable.position.pixels > before,
timeout: const Duration(seconds: 1),
);
}
});

testWidgets('pointer hover press and tap expose exact state and result', (
tester,
) async {
await tester.pumpWidget(const link_example.MyApp());
await tester.pump();
final primary = find.byKey(const ValueKey('link.primary'));
final center = tester.getCenter(primary);

final hover = await tester.createGesture(kind: PointerDeviceKind.mouse);
await hover.addPointer(location: Offset.zero);
addTearDown(hover.removePointer);
await hover.moveTo(center);
await tester.pump();
expect(
find.text('hovered:true focused:false pressed:false enabled:true'),
findsOneWidget,
);

final press = await tester.startGesture(
center,
kind: PointerDeviceKind.mouse,
);
var pressIsDown = true;
addTearDown(() async {
if (pressIsDown) await press.cancel();
});
await tester.pump();
expect(
find.text('hovered:true focused:false pressed:true enabled:true'),
findsOneWidget,
);
await press.up();
pressIsDown = false;
await tester.pump();
expect(find.text('Result: primary; activations: 1'), findsOneWidget);
expect(
find.text('hovered:true focused:false pressed:false enabled:true'),
findsOneWidget,
);
});

testWidgets('semantic tap follows the same resolver path', (tester) async {
final handle = tester.ensureSemantics();
try {
await tester.pumpWidget(const link_example.MyApp());
await tester.pump();
final node = tester.getSemantics(find.text('Read the documentation'));
expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);

node.owner!.performAction(node.id, SemanticsAction.tap);
await tester.pump();
expect(find.text('Result: primary; activations: 1'), findsOneWidget);
} finally {
handle.dispose();
}
});

testWidgets(
'disabled Link is skipped and has no pointer or semantic action',
(tester) async {
final handle = tester.ensureSemantics();
try {
await tester.pumpWidget(const link_example.MyApp());
await tester.pump();
final primary = find.byKey(const ValueKey('link.primary'));
final external = find.byKey(const ValueKey('link.external'));
final next = find.byKey(const ValueKey('link.next-focus'));

FocusManager.instance.primaryFocus?.unfocus();
await tester.pump();
for (final expected in [primary, external, next]) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(tester.hasPrimaryFocusOn(expected), isTrue);
}

await tester.tap(find.byKey(const ValueKey('link.disabled')));
await tester.pump();
expect(find.text('Result: none; activations: 0'), findsOneWidget);
final disabled = tester.getSemantics(
find.text('Unavailable documentation'),
);
expect(disabled.getSemanticsData().flagsCollection.isLink, isFalse);
expect(
disabled.getSemanticsData().hasAction(SemanticsAction.tap),
isFalse,
);
} finally {
handle.dispose();
}
},
);

testWidgets('disabling while focused blocks later activation', (
tester,
) async {
await tester.pumpWidget(const link_example.MyApp());
await tester.pump();
final primary = find.byKey(const ValueKey('link.primary'));

FocusManager.instance.primaryFocus?.unfocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
expect(tester.hasPrimaryFocusOn(primary), isTrue);

await tester.tap(find.byKey(const ValueKey('link.disable-primary')));
await tester.pump();
await tester.pump();
expect(
find.text('hovered:false focused:false pressed:false enabled:false'),
findsOneWidget,
);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(find.text('Result: none; activations: 0'), findsOneWidget);
});

testWidgets(
'secondary click remains free for later Context Menu composition',
(tester) async {
await tester.pumpWidget(const link_example.MyApp());
await tester.pump();

await tester.tapAt(
tester.getCenter(find.byKey(const ValueKey('link.primary'))),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
expect(find.text('Result: none; activations: 0'), findsOneWidget);
},
);

testWidgets('RTL and 200% long text remain usable without overflow', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: link_example.LinkExample(
textDirection: TextDirection.rtl,
textScale: 2,
longText: true,
),
),
),
);
await tester.pump();

expect(tester.takeException(), isNull);
expect(find.textContaining('دليل الوصول'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link.primary')));
await tester.pump();
expect(find.text('Result: primary; activations: 1'), findsOneWidget);
});
});
}
Loading
Loading