diff --git a/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart b/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart index 20588ea2..b5bb3927 100644 --- a/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart +++ b/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart @@ -164,8 +164,10 @@ class GalleryTypographyPage extends StatelessWidget { onPressed: () => showToast(context, message: 'High contrast activated'), ), + // Two spellings of the same state: a null callback disables the + // link exactly as `enabled: false` does. FortalLink('Disabled', enabled: false, onPressed: () {}), - const FortalLink('Inert'), + const FortalLink('Disabled (no callback)'), FortalLink( 'Documentation', linkUrl: Uri.parse('https://docs.page/btwld/remix/fortal'), diff --git a/apps/dashboard/test/app_smoke_test.dart b/apps/dashboard/test/app_smoke_test.dart index b0755d9d..b57d4cda 100644 --- a/apps/dashboard/test/app_smoke_test.dart +++ b/apps/dashboard/test/app_smoke_test.dart @@ -643,12 +643,12 @@ void main() { for (final underline in FortalLinkUnderline.values) { expect(find.text(enumLabel(underline)), findsOneWidget); } - expect(find.text('Inert'), findsOneWidget); expect(find.text('Disabled'), findsOneWidget); + expect(find.text('Disabled (no callback)'), findsOneWidget); expect(tester.takeException(), isNull); }); - testWidgets('an actionable gallery link activates, an inert one does not', ( + testWidgets('an actionable gallery link activates, a disabled one does not', ( tester, ) async { tester.view.physicalSize = const Size(1440, 900); @@ -668,9 +668,9 @@ void main() { await tester.pump(); expect(find.text('Always link activated'), findsOneWidget); - final inert = find.text('Inert'); - await tester.ensureVisible(inert); - await tester.tap(inert, warnIfMissed: false); + final disabled = find.text('Disabled (no callback)'); + await tester.ensureVisible(disabled); + await tester.tap(disabled, warnIfMissed: false); await tester.pump(); expect(tester.takeException(), isNull); diff --git a/apps/playground/lib/registry/entries/typography_entry.dart b/apps/playground/lib/registry/entries/typography_entry.dart index a2a1e2c6..94293978 100644 --- a/apps/playground/lib/registry/entries/typography_entry.dart +++ b/apps/playground/lib/registry/entries/typography_entry.dart @@ -77,26 +77,30 @@ Widget buildTypographyExample() { underline: FortalLinkUnderline.none, onPressed: () {}, ), + // Two spellings of the same state: a null callback disables the + // link exactly as `enabled: false` does. FortalLink('Disabled', enabled: false, onPressed: () {}), - const FortalLink('Inert'), + const FortalLink('Disabled (no callback)'), ], ), const SizedBox(height: 24), const _SectionLabel('Accent and high contrast'), const SizedBox(height: 12), - const Wrap( + Wrap( spacing: 18, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ - FortalText('Accent text', accent: true), - FortalText( + const FortalText('Accent text', accent: true), + const FortalText( 'Accent high contrast', accent: true, highContrast: true, ), - FortalCode.soft('accent code', highContrast: true), - FortalLink('Accent link', highContrast: true), + const FortalCode.soft('accent code', highContrast: true), + // Actionable on purpose: this row is about accent colour, and a + // callback-less link would show the disabled treatment instead. + FortalLink('Accent link', highContrast: true, onPressed: () {}), ], ), const SizedBox(height: 24), diff --git a/docs.json b/docs.json index fab3b061..e1d83b63 100644 --- a/docs.json +++ b/docs.json @@ -129,6 +129,10 @@ "title": "IconButton", "href": "/components/icon_button" }, + { + "title": "Link", + "href": "/components/link" + }, { "title": "Menu", "href": "/components/menu" diff --git a/docs/components/link.mdx b/docs/components/link.mdx new file mode 100644 index 00000000..74fb88a8 --- /dev/null +++ b/docs/components/link.mdx @@ -0,0 +1,330 @@ +--- +title: Link +description: Styled text that navigates when activated +keywords: [flutter, remix, link, anchor, navigation, href, hyperlink] +--- + +Text that takes the user somewhere else. + +## When to use this + +- **Navigation**: Move to another route, document, or external destination +- **Inline references**: Link a word or phrase inside a paragraph +- **Secondary actions that navigate**: "View all", "Read the docs", "Learn more" + +Reach for `RemixButton` instead when activation changes state in place — submitting, +toggling, opening a dialog. The distinction is not cosmetic: a link publishes the +Link role and activates on Enter only, while a button publishes the Button role and +also activates on Space. + +## Basic implementation + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class LinkExample extends StatelessWidget { + const LinkExample({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + RemixLink( + label: 'Read the docs', + style: style, + onPressed: () => debugPrint('navigate'), + ), + // No callback: a disabled link, same as `enabled: false`. + const RemixLink(label: 'Coming soon'), + ], + ); + } + + LinkStyler get style { + return LinkStyler() + .labelColor(Colors.indigo) + .onHovered( + LinkStyler().label( + TextStyler() + .decoration(TextDecoration.underline) + .decorationColor(Colors.indigo), + ), + ); + } +} +``` + + +## Navigation stays yours + +`RemixLink` never launches anything. `onPressed` performs the navigation, so the +component works the same with a router, a URL launcher, or a scroll controller. + +`linkUrl` is assistive metadata that rides along on the semantics node. On Flutter +web it also becomes an anchor `href`, so omit it when `onPressed` already navigates — +otherwise a single click has two navigation owners. + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class LinkUrlExample extends StatelessWidget { + const LinkUrlExample({super.key}); + + @override + Widget build(BuildContext context) { + return RemixLink( + label: 'Remix on GitHub', + // Safe here: the callback hands off to the platform rather than routing + // in-app, so the web anchor and the callback agree on the destination. + linkUrl: Uri.parse('https://github.com/btwld/remix'), + semanticHint: 'Opens in a new window', + onPressed: () => debugPrint('launch'), + ); + } +} +``` + + +## Two ways to disable, one behaviour + +`onPressed: null` and `enabled: false` both disable the link, exactly as a null +callback disables any other Flutter control. A disabled link announces itself as +unavailable and gives up its Link role, destination, focus stop, and tap action. + +For text that was never meant to be followed, use ordinary text rather than a +link with no callback. + +## Fortal widgets + +Remix includes a Fortal-themed widget for this component: + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix_fortal/remix_fortal.dart'; + +class FortalLinkExample extends StatelessWidget { + const FortalLinkExample({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + spacing: 16, + children: [ + FortalLink('Auto', onPressed: () {}), + FortalLink( + 'Always', + underline: FortalLinkUnderline.always, + onPressed: () {}, + ), + FortalLink( + 'On hover', + underline: FortalLinkUnderline.hover, + onPressed: () {}, + ), + FortalLink( + 'None', + underline: FortalLinkUnderline.none, + onPressed: () {}, + ), + ], + ); + } +} +``` + + + + See the [fortalLinkStyle source code](https://github.com/btwld/remix/blob/main/packages/remix_fortal/lib/src/recipes/link.dart) for all available options. + + +## Constructor + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +RemixLink remixLinkConstructor({ + Key? key, + String? label, + Widget? child, + VoidCallback? onPressed, + bool enabled = true, + Uri? linkUrl, + FocusNode? focusNode, + bool autofocus = false, + bool enableFeedback = true, + MouseCursor mouseCursor = SystemMouseCursors.click, + String? semanticLabel, + String? semanticHint, + bool excludeSemantics = false, + LinkStyler style = const LinkStyler.create(), + LinkSpec? styleSpec, +}) => throw UnimplementedError(); +``` + + +## Properties +### Widget Properties + +#### `label` → `String?` + +Optional. The link text, rendered with the resolved label style. Either `label` or +`child` must be provided. + +#### `child` → `Widget?` + +Optional. Arbitrary link content used instead of `label`. It inherits the resolved +text and icon themes, so an icon beside the text picks up the link colour. + +#### `onPressed` → `VoidCallback?` + +Optional. Performs the navigation. A null callback disables the link. + +#### `enabled` → `bool` + +Optional. Whether an otherwise actionable link may activate. Defaults to `true`. + +#### `linkUrl` → `Uri?` + +Optional. Destination exposed through Link semantics. Never launched. + +#### `focusNode` → `FocusNode?` + +Optional. Caller-owned focus node. + +#### `autofocus` → `bool` + +Optional. Whether the link requests focus when first built. Defaults to `false`. + +#### `enableFeedback` → `bool` + +Optional. Whether accepted activations provide platform feedback. Defaults to `true`. + +#### `mouseCursor` → `MouseCursor` + +Optional. Cursor shown while the link is actionable. Defaults to `SystemMouseCursors.click`. + +#### `semanticLabel` → `String?` + +Optional. Accessible name that replaces the visible text for screen readers. + +#### `semanticHint` → `String?` + +Optional. Describes a non-obvious result of following the link. + +#### `excludeSemantics` → `bool` + +Optional. Hides the link and its subtree from semantics. Defaults to `false`. + +#### `style` → `LinkStyler` + +Optional. The style configuration for the link. + +#### `styleSpec` → `LinkSpec?` + +Optional. A pre-resolved style spec that bypasses style resolution. + +#### `key` → `Key?` + +Optional. Controls how one widget replaces another widget in the tree. + +### Style Methods + +#### `label(TextStyler value)` + +Configures the label text style using a TextStyler. + +#### `labelColor(Color value)` + +Sets label/text color. + +#### `labelFontSize(double value)` + +Sets label/text font size. + +#### `labelFontWeight(FontWeight value)` + +Sets label/text font weight. + +#### `labelDecoration(TextDecoration value)` + +Sets label/text decoration (underline, strikethrough, etc.). + +#### `labelDecorationColor(Color value)` + +Sets label/text decoration color. + +#### `labelStyle(TextStyleMix value)` + +Sets label/text style using TextStyleMix directly. + +#### `labelFontStyle(FontStyle value)` + +Sets label/text font style (italic/normal). + +#### `labelFontFamily(String value)` + +Sets label/text font family. + +#### `labelLetterSpacing(double value)` + +Sets label/text letter spacing. + +#### `labelWordSpacing(double value)` + +Sets label/text word spacing. + +#### `labelHeight(double value)` + +Sets label/text line height. + +#### `padding(EdgeInsetsGeometryMix value)` + +Sets padding. + +#### `margin(EdgeInsetsGeometryMix value)` + +Sets margin. + +#### `borderRadius(BorderRadiusGeometryMix radius)` + +Sets border radius, used by the focus ring. + +#### `decoration(DecorationMix value)` + +Sets decoration. + +#### `foregroundDecoration(DecorationMix value)` + +Sets a foreground decoration painted on top of the component. + +#### `alignment(Alignment value)` + +Sets container alignment. + +#### `constraints(BoxConstraintsMix value)` + +Sets constraints. + +#### `transform(Matrix4 value, AlignmentGeometry alignment = Alignment.center)` + +Applies a matrix transformation to the component. + +#### `animate(AnimationConfig value)` + +Sets animation. + +#### `wrap(WidgetModifierConfig value)` + +Applies widget modifiers such as clipping, opacity, or scaling. + +#### `call({Key? key, String? label, Widget? child, VoidCallback? onPressed, ...})` + +Creates a `RemixLink` widget with this style applied. diff --git a/docs/fortal/typography.mdx b/docs/fortal/typography.mdx index afa88bd2..cd8c9b70 100644 --- a/docs/fortal/typography.mdx +++ b/docs/fortal/typography.mdx @@ -30,7 +30,7 @@ tokens is the equivalent there. | `FortalHeading` | Page, section, and card titles | `header` with an explicit `headingLevel` | | `FortalCode` | Identifiers, snippets, tokens shown inline | None; Flutter has no code role | | `FortalKbd` | One keyboard key or shortcut | `keyboardKey`, inert | -| `FortalLink` | Text that navigates | `link` **only** when `onPressed` is set | +| `FortalLink` | Text that navigates | `link` **only** while enabled and given an `onPressed` | `FortalText` and `FortalCode` leave `size` and `weight` null by default so an omitted size inherits the ambient `DefaultTextStyle`, matching a Radix `Text` @@ -192,13 +192,20 @@ the requested level, and excludes the child so the label is not announced twice. Pass `semanticLabel` when the announced text should differ from the rendered text, or `excludeSemantics: true` to publish nothing at all. -## Actionable versus inert links +## Actionable versus disabled links + +A `FortalLink` without `onPressed` is **disabled**, the same as one with +`enabled: false` — a null callback disables a Flutter control. It keeps the +accent colour but gives up its focus stop, link role, and activation. Give it +`onPressed` and it becomes a real link: focusable, activatable with pointer and +Enter, and underlined according to `underline`. Every upstream underline rule is gated behind `:where(:any-link, button)`, so a -`FortalLink` without `onPressed` is **inert**: styled accent text with no focus -stop, no link role, and no activation. Give it `onPressed` and it becomes a real -link — focusable, activatable with pointer, Enter, and Space, and underlined -according to `underline`. +disabled link never underlines regardless of `underline`. For accent-coloured +text that was never meant to be followed, use `FortalText(accent: true)`. + +Space does **not** activate a link. That is the Button role's key; a link takes +Enter, matching an anchor on the web. | `underline` | Behaviour when actionable | |-------------|---------------------------| @@ -233,7 +240,7 @@ class LinkExample extends StatelessWidget { semanticHint: 'Opens the Fortal documentation', onPressed: onOpenDocs, ), - const FortalLink('Not a link yet'), + const FortalLink('Not available yet'), ], ); } @@ -247,14 +254,15 @@ launcher dependency. Passing `linkUrl` without `onPressed` asserts, because an announced destination with no activation is a broken promise to assistive technology. -Set `enabled: false` to keep the callback and still refuse focus and activation. +`enabled: false` keeps the callback and still refuses focus and activation, so +a link that is only temporarily unavailable can hold on to its destination. ## Customizing the recipes Each widget calls a `fortal*Style` recipe you can use directly. `FortalText` and -`FortalHeading` return a `TextStyler`; `FortalCode`, `FortalKbd`, and -`FortalLink` return a `BadgeStyler` and take a `BuildContext`, because their -geometry is em-relative to the resolved font size. +`FortalHeading` return a `TextStyler`; `FortalCode` and `FortalKbd` return a +`BadgeStyler` and `FortalLink` returns a `LinkStyler`. All three take a +`BuildContext`, because their geometry is em-relative to the resolved font size. ```dart diff --git a/packages/remix/README.md b/packages/remix/README.md index b2662129..0c4c441b 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -243,6 +243,7 @@ Remix provides a comprehensive set of production-ready components: ### Layout & Navigation - **Tabs** - Tabbed navigation - **Accordion** - Collapsible content sections +- **Link** - Styled text that navigates, with the Link role and Enter activation - **Menu** - Context menus and dropdowns - **SegmentedControl** - Equal-segment controlled single selection diff --git a/packages/remix/lib/remix.dart b/packages/remix/lib/remix.dart index a82e733c..a674792c 100644 --- a/packages/remix/lib/remix.dart +++ b/packages/remix/lib/remix.dart @@ -17,6 +17,7 @@ export 'src/components/checkbox/checkbox.dart'; export 'src/components/data_list/data_list.dart'; export 'src/components/data_table/data_table.dart'; export 'src/components/divider/divider.dart'; +export 'src/components/link/link.dart'; export 'src/components/menu/menu.dart'; export 'src/components/popover/popover.dart'; export 'src/components/progress/progress.dart'; diff --git a/packages/remix/lib/src/components/link/link.dart b/packages/remix/lib/src/components/link/link.dart new file mode 100644 index 00000000..c78d4805 --- /dev/null +++ b/packages/remix/lib/src/components/link/link.dart @@ -0,0 +1,15 @@ +library remix_link; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:naked_ui/naked_ui.dart'; + +import '../../rendering/remix_box_effects.dart'; +import '../../style/style.dart'; +import '../../utilities/remix_style.dart'; + +part 'link_spec.dart'; +part 'link_widget.dart'; +part 'link.g.dart'; diff --git a/packages/remix/lib/src/components/link/link.g.dart b/packages/remix/lib/src/components/link/link.g.dart new file mode 100644 index 00000000..43a1df85 --- /dev/null +++ b/packages/remix/lib/src/components/link/link.g.dart @@ -0,0 +1,719 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'link.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$LinkSpec implements Spec, Diagnosticable { + StyleSpec get container; + StyleSpec get label; + RemixBoxEffectsSpec? get containerEffects; + + @override + Type get type => LinkSpec; + + @override + LinkSpec copyWith({ + StyleSpec? container, + StyleSpec? label, + RemixBoxEffectsSpec? containerEffects, + }) { + return LinkSpec( + container: container ?? this.container, + label: label ?? this.label, + containerEffects: containerEffects ?? this.containerEffects, + ); + } + + @override + LinkSpec lerp(LinkSpec? other, double t) { + return LinkSpec( + container: container.lerp(other?.container, t), + label: label.lerp(other?.label, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), + ); + } + + @override + List get props => [container, label, containerEffects]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is LinkSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('label', label)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); + } +} + +@Deprecated( + 'Rename to `_\$LinkSpec` and migrate the class declaration to `class LinkSpec with _\$LinkSpec`. The `_\$LinkSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$LinkSpecMethods = _$LinkSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class LinkStyler extends MixStyler + with RemixBoxStylerMixin, LabelStyleMixin { + final Prop>? $container; + final Prop>? $label; + final Prop? $containerEffects; + + const LinkStyler.create({ + Prop>? container, + Prop>? label, + Prop? containerEffects, + super.variants, + super.modifier, + super.animation, + }) : $container = container, + $label = label, + $containerEffects = containerEffects; + + LinkStyler({ + BoxStyler? container, + TextStyler? label, + RemixBoxEffectsMix? containerEffects, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + container: Prop.maybeMix(container), + label: Prop.maybeMix(label), + containerEffects: Prop.maybeMix(containerEffects), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory LinkStyler.container(BoxStyler value) => + LinkStyler().container(value); + factory LinkStyler.label(TextStyler value) => LinkStyler().label(value); + factory LinkStyler.containerEffects(RemixBoxEffectsMix value) => + LinkStyler().containerEffects(value); + factory LinkStyler.alignment(AlignmentGeometry value) => + LinkStyler().alignment(value); + factory LinkStyler.padding(EdgeInsetsGeometryMix value) => + LinkStyler().padding(value); + factory LinkStyler.margin(EdgeInsetsGeometryMix value) => + LinkStyler().margin(value); + factory LinkStyler.constraints(BoxConstraintsMix value) => + LinkStyler().constraints(value); + factory LinkStyler.decoration(DecorationMix value) => + LinkStyler().decoration(value); + factory LinkStyler.foregroundDecoration(DecorationMix value) => + LinkStyler().foregroundDecoration(value); + factory LinkStyler.clipBehavior(Clip value) => + LinkStyler().clipBehavior(value); + factory LinkStyler.color(Color value) => LinkStyler().color(value); + factory LinkStyler.gradient(GradientMix value) => + LinkStyler().gradient(value); + factory LinkStyler.border(BoxBorderMix value) => LinkStyler().border(value); + factory LinkStyler.borderRadius(BorderRadiusGeometryMix value) => + LinkStyler().borderRadius(value); + factory LinkStyler.elevation(ElevationShadow value) => + LinkStyler().elevation(value); + factory LinkStyler.shadow(BoxShadowMix value) => LinkStyler().shadow(value); + factory LinkStyler.shadows(List value) => + LinkStyler().shadows(value); + factory LinkStyler.width(double value) => LinkStyler().width(value); + factory LinkStyler.height(double value) => LinkStyler().height(value); + factory LinkStyler.size(double width, double height) => + LinkStyler().size(width, height); + factory LinkStyler.minWidth(double value) => LinkStyler().minWidth(value); + factory LinkStyler.maxWidth(double value) => LinkStyler().maxWidth(value); + factory LinkStyler.minHeight(double value) => LinkStyler().minHeight(value); + factory LinkStyler.maxHeight(double value) => LinkStyler().maxHeight(value); + factory LinkStyler.scale(double scale, {Alignment alignment = .center}) => + LinkStyler().scale(scale, alignment: alignment); + factory LinkStyler.rotate(double radians, {Alignment alignment = .center}) => + LinkStyler().rotate(radians, alignment: alignment); + factory LinkStyler.translate(double x, double y, [double z = 0.0]) => + LinkStyler().translate(x, y, z); + factory LinkStyler.skew(double skewX, double skewY) => + LinkStyler().skew(skewX, skewY); + factory LinkStyler.textStyle(TextStyler value) => + LinkStyler().textStyle(value); + factory LinkStyler.image(DecorationImageMix value) => + LinkStyler().image(value); + factory LinkStyler.shape(ShapeBorderMix value) => LinkStyler().shape(value); + factory LinkStyler.backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => LinkStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory LinkStyler.backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => LinkStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory LinkStyler.backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => LinkStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory LinkStyler.linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => LinkStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory LinkStyler.radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => LinkStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory LinkStyler.sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => LinkStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory LinkStyler.foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => LinkStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory LinkStyler.foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => LinkStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory LinkStyler.foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => LinkStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory LinkStyler.transform( + Matrix4 value, { + Alignment alignment = .center, + }) => LinkStyler().transform(value, alignment: alignment); + + LinkStyler alignment(AlignmentGeometry value) { + return container(BoxStyler().alignment(value)); + } + + LinkStyler padding(EdgeInsetsGeometryMix value) { + return container(BoxStyler().padding(value)); + } + + LinkStyler margin(EdgeInsetsGeometryMix value) { + return container(BoxStyler().margin(value)); + } + + LinkStyler constraints(BoxConstraintsMix value) { + return container(BoxStyler().constraints(value)); + } + + LinkStyler decoration(DecorationMix value) { + return container(BoxStyler().decoration(value)); + } + + LinkStyler foregroundDecoration(DecorationMix value) { + return container(BoxStyler().foregroundDecoration(value)); + } + + LinkStyler clipBehavior(Clip value) { + return container(BoxStyler().clipBehavior(value)); + } + + LinkStyler color(Color value) { + return container(BoxStyler().color(value)); + } + + LinkStyler gradient(GradientMix value) { + return container(BoxStyler().gradient(value)); + } + + LinkStyler border(BoxBorderMix value) { + return container(BoxStyler().border(value)); + } + + LinkStyler borderRadius(BorderRadiusGeometryMix value) { + return container(BoxStyler().borderRadius(value)); + } + + LinkStyler elevation(ElevationShadow value) { + return container(BoxStyler().elevation(value)); + } + + LinkStyler shadow(BoxShadowMix value) { + return container(BoxStyler().shadow(value)); + } + + LinkStyler shadows(List value) { + return container(BoxStyler().shadows(value)); + } + + LinkStyler width(double value) { + return container(BoxStyler().width(value)); + } + + LinkStyler height(double value) { + return container(BoxStyler().height(value)); + } + + LinkStyler size(double width, double height) { + return container(BoxStyler().size(width, height)); + } + + LinkStyler minWidth(double value) { + return container(BoxStyler().minWidth(value)); + } + + LinkStyler maxWidth(double value) { + return container(BoxStyler().maxWidth(value)); + } + + LinkStyler minHeight(double value) { + return container(BoxStyler().minHeight(value)); + } + + LinkStyler maxHeight(double value) { + return container(BoxStyler().maxHeight(value)); + } + + LinkStyler scale(double scale, {Alignment alignment = .center}) { + return container(BoxStyler().scale(scale, alignment: alignment)); + } + + LinkStyler rotate(double radians, {Alignment alignment = .center}) { + return container(BoxStyler().rotate(radians, alignment: alignment)); + } + + LinkStyler translate(double x, double y, [double z = 0.0]) { + return container(BoxStyler().translate(x, y, z)); + } + + LinkStyler skew(double skewX, double skewY) { + return container(BoxStyler().skew(skewX, skewY)); + } + + LinkStyler textStyle(TextStyler value) { + return container(BoxStyler().textStyle(value)); + } + + LinkStyler image(DecorationImageMix value) { + return container(BoxStyler().image(value)); + } + + LinkStyler shape(ShapeBorderMix value) { + return container(BoxStyler().shape(value)); + } + + LinkStyler backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + LinkStyler backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + LinkStyler backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + LinkStyler linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + LinkStyler radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + LinkStyler sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + LinkStyler foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + LinkStyler foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + LinkStyler foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + LinkStyler transform(Matrix4 value, {Alignment alignment = .center}) { + return container(BoxStyler().transform(value, alignment: alignment)); + } + + /// Sets the container. + LinkStyler container(BoxStyler value) { + return merge(LinkStyler(container: value)); + } + + /// Sets the label. + @override + LinkStyler label(TextStyler value) { + return merge(LinkStyler(label: value)); + } + + /// Sets the containerEffects. + LinkStyler containerEffects(RemixBoxEffectsMix value) { + return merge(LinkStyler(containerEffects: value)); + } + + /// Sets the animation configuration. + @override + LinkStyler animate(AnimationConfig value) { + return merge(LinkStyler(animation: value)); + } + + /// Sets the style variants. + @override + LinkStyler variants(List> value) { + return merge(LinkStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + LinkStyler wrap(WidgetModifierConfig value) { + return merge(LinkStyler(modifier: value)); + } + + /// Sets the widget modifier. + LinkStyler modifier(WidgetModifierConfig value) { + return merge(LinkStyler(modifier: value)); + } + + RemixLink call({ + Key? key, + String? label, + Widget? child, + VoidCallback? onPressed, + bool enabled = true, + Uri? linkUrl, + FocusNode? focusNode, + bool autofocus = false, + bool enableFeedback = true, + MouseCursor mouseCursor = SystemMouseCursors.click, + String? semanticLabel, + String? semanticHint, + bool excludeSemantics = false, + }) { + return RemixLink( + key: key, + style: this, + label: label, + child: child, + onPressed: onPressed, + enabled: enabled, + linkUrl: linkUrl, + focusNode: focusNode, + autofocus: autofocus, + enableFeedback: enableFeedback, + mouseCursor: mouseCursor, + semanticLabel: semanticLabel, + semanticHint: semanticHint, + excludeSemantics: excludeSemantics, + ); + } + + /// Merges with another [LinkStyler]. + @override + LinkStyler merge(LinkStyler? other) { + return LinkStyler.create( + container: MixOps.merge($container, other?.$container), + label: MixOps.merge($label, other?.$label), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = LinkSpec( + container: MixOps.resolve(context, $container), + label: MixOps.resolve(context, $label), + containerEffects: MixOps.resolve(context, $containerEffects), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('label', $label)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); + } + + @override + List get props => [ + $container, + $label, + $containerEffects, + $animation, + $modifier, + $variants, + ]; +} diff --git a/packages/remix/lib/src/components/link/link_spec.dart b/packages/remix/lib/src/components/link/link_spec.dart new file mode 100644 index 00000000..eb3ce7cc --- /dev/null +++ b/packages/remix/lib/src/components/link/link_spec.dart @@ -0,0 +1,44 @@ +part of 'link.dart'; + +/// Resolved visual properties for a [RemixLink]. +/// +/// A link is a text run, not a control surface, so the spec carries only a +/// [container] box and a [label] text style. Focus rings and other decoration +/// layers arrive through [containerEffects] rather than extra slots. +@MixableSpec( + target: RemixLink.new, + extraStylerMixins: [RemixBoxStylerMixin, LabelStyleMixin], +) +class LinkSpec with _$LinkSpec { + @override + @MixableField(forwardStyler: true) + final StyleSpec container; + @override + final StyleSpec label; + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + + const LinkSpec({ + StyleSpec? container, + StyleSpec? label, + this.containerEffects, + }) : container = container ?? const StyleSpec(spec: BoxSpec()), + label = label ?? const StyleSpec(spec: TextSpec()); + + // Deliberate: route effects through lerpNullable so shadows/blends animate; + // the generator's default snap-lerps unrecognized spec types. + @override + LinkSpec lerp(LinkSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } +} diff --git a/packages/remix/lib/src/components/link/link_widget.dart b/packages/remix/lib/src/components/link/link_widget.dart new file mode 100644 index 00000000..1ef88191 --- /dev/null +++ b/packages/remix/lib/src/components/link/link_widget.dart @@ -0,0 +1,154 @@ +part of 'link.dart'; + +/// Styled text that navigates when activated. +/// +/// A link is the navigation counterpart to [RemixButton]: it activates on tap +/// and on Enter, but not on Space, and it publishes the Link role instead of +/// the Button role. Reach for [RemixButton] when activation changes state in +/// place rather than moving the user somewhere. +/// +/// Navigation itself stays caller-owned. [onPressed] performs it; [linkUrl] is +/// assistive metadata that is never launched. +/// +/// ## Example +/// +/// ```dart +/// RemixLink( +/// label: 'Documentation', +/// linkUrl: Uri.parse('https://example.com/docs'), +/// onPressed: () => router.go('/docs'), +/// ) +/// ``` +class RemixLink extends StatelessWidget { + /// Creates a text link with [label] or an arbitrary-content link with + /// [child]. Arbitrary content inherits the resolved text and icon themes. + const RemixLink({ + super.key, + this.label, + this.child, + this.onPressed, + this.enabled = true, + this.linkUrl, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.style = const LinkStyler.create(), + this.styleSpec, + }) : // An empty label renders a zero-width link that assistive tech cannot + // name, so it fails the same invariant a null label does. + assert( + child != null || (label != null && label != ''), + 'Either a non-empty label or a child must be provided so the link ' + 'has a name.', + ), + // A link publishes its destination only while it can be followed. A + // link disabled through `enabled` may be re-enabled and is therefore + // fine to give a destination; one with no callback at all never can be, + // so a linkUrl there is silently dead. + assert( + linkUrl == null || onPressed != null, + 'linkUrl needs an onPressed: a link with no callback never publishes ' + 'a destination.', + ); + + static final styleFrom = LinkStyler.new; + + /// The link text. Ignored when [child] is provided. + final String? label; + + /// Arbitrary link content, used instead of [label]. + final Widget? child; + + /// Performs the navigation when the link activates. + /// + /// A null callback disables the link, as it does on every other Flutter + /// control: no Link role, no destination, no focus stop, and no activation. + /// Use ordinary text for prose that was never meant to be followed. + final VoidCallback? onPressed; + + /// Whether an otherwise actionable link may activate. + final bool enabled; + + /// Optional destination exposed through Link semantics. + /// + /// This is never launched. On Flutter web it becomes an anchor `href`, so + /// omit it when [onPressed] already performs the navigation. + final Uri? linkUrl; + + /// Optional focus node controlling the link's focus behavior. + final FocusNode? focusNode; + + /// Whether the link requests focus when first built. + final bool autofocus; + + /// Whether accepted activations provide platform feedback. + final bool enableFeedback; + + /// Cursor shown while the link is actionable. + final MouseCursor mouseCursor; + + /// Accessible name that replaces the visible text for screen readers. + final String? semanticLabel; + + /// Additional description of what activating the link does. + final String? semanticHint; + + /// Whether to hide the link and its subtree from semantics. + final bool excludeSemantics; + + /// The style configuration for the link. + final LinkStyler style; + + /// Optional raw style spec that bypasses fluent style resolution. + final LinkSpec? styleSpec; + + Widget _buildContent(BuildContext context, LinkSpec spec) { + final content = child == null + ? StyledText(label!, styleSpec: spec.label) + : RemixDefaultContentStyle( + text: spec.label, + icon: StyleSpec( + spec: IconSpec(color: spec.label.spec.style?.color), + ), + child: child!, + ); + + return RemixBoxWithEffects( + styleSpec: spec.container, + containerEffects: spec.containerEffects, + child: content, + ); + } + + @override + Widget build(BuildContext context) { + // One path on purpose. `NakedLink` derives the role, destination, focus + // stop, and activation from `enabled && onPressed != null`, so a + // callback-less link is a disabled link — the same meaning `onPressed: + // null` carries on every other Flutter control. Rendering that case + // separately to dodge the disabled state would fork the state scope and + // let one LinkStyler resolve differently on each path. + return NakedLink( + onPressed: onPressed, + linkUrl: linkUrl, + enabled: enabled, + focusNode: focusNode, + autofocus: autofocus, + mouseCursor: mouseCursor, + enableFeedback: enableFeedback, + semanticLabel: semanticLabel, + semanticHint: semanticHint, + excludeSemantics: excludeSemantics, + builder: (context, _, _) => RemixStyleSpecBuilder( + style: style, + styleSpec: styleSpec, + controller: NakedLinkState.controllerOf(context), + builder: _buildContent, + ), + ); + } +} diff --git a/packages/remix/pubspec.yaml b/packages/remix/pubspec.yaml index 385094d6..dcfd5078 100644 --- a/packages/remix/pubspec.yaml +++ b/packages/remix/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: sdk: flutter mix: ^2.2.0-beta.4 mix_annotations: ^2.2.0-beta.1 - naked_ui: ^1.0.0-beta.10 + naked_ui: ^1.0.0-beta.11 dev_dependencies: flutter_test: diff --git a/packages/remix/test/components/generated_styler_call_test.dart b/packages/remix/test/components/generated_styler_call_test.dart index 8d8a9b65..f38b55fd 100644 --- a/packages/remix/test/components/generated_styler_call_test.dart +++ b/packages/remix/test/components/generated_styler_call_test.dart @@ -10,6 +10,7 @@ void main() { final calloutStyle = CalloutStyler(); final cardStyle = CardStyler(); final dividerStyle = DividerStyler(); + final linkStyle = LinkStyler(); final popoverStyle = PopoverStyler(); expect(avatarStyle.call().style, same(avatarStyle)); @@ -17,6 +18,7 @@ void main() { expect(calloutStyle.call(text: 'Notice').style, same(calloutStyle)); expect(cardStyle.call().style, same(cardStyle)); expect(dividerStyle.call().style, same(dividerStyle)); + expect(linkStyle.call(label: 'Docs').style, same(linkStyle)); expect( popoverStyle .call( diff --git a/packages/remix/test/components/link/link_focus_ring_test.dart b/packages/remix/test/components/link/link_focus_ring_test.dart new file mode 100644 index 00000000..d4ca6d9d --- /dev/null +++ b/packages/remix/test/components/link/link_focus_ring_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; +// The painter is internal, so this reaches past the barrel on purpose: the +// point of the test is that the resolved effects arrive at the widget that +// actually draws them. +import 'package:remix/src/rendering/remix_box_effects.dart'; + +import '../../helpers/test_helpers.dart'; + +void main() { + group('RemixLink focus ring', () { + // A link carries no fill, border, or padding, so its container slot exists + // for exactly one reason: hosting the focus-visible ring. Resolving the + // effects is not enough — dropping the box would still resolve them and + // silently paint nothing, which spec-level assertions cannot see. + testWidgets('resolved container effects reach the painter', (tester) async { + final previousStrategy = FocusManager.instance.highlightStrategy; + addTearDown(() { + FocusManager.instance.highlightStrategy = previousStrategy; + }); + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpRemixApp( + RemixLink( + label: 'Ringed', + focusNode: focusNode, + onPressed: () {}, + style: LinkStyler() + .borderRadius(BorderRadiusGeometryMix.circular(4)) + .onFocusVisible( + LinkStyler().containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix(color: Colors.blue, width: 2), + outlineOffset: 2, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + RemixBoxEffectsSpec? paintedEffects() => tester + .widget(find.byType(RemixBoxWithEffects)) + .containerEffects; + + expect(paintedEffects()?.outline.width ?? 0, 0, reason: 'idle'); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + final focused = paintedEffects(); + expect(focused, isNotNull); + expect(focused!.outline.width, 2); + expect(focused.outline.color, Colors.blue); + expect(focused.outlineOffset, 2); + }); + }); +} diff --git a/packages/remix/test/components/link/link_widget_test.dart b/packages/remix/test/components/link/link_widget_test.dart new file mode 100644 index 00000000..16d9017e --- /dev/null +++ b/packages/remix/test/components/link/link_widget_test.dart @@ -0,0 +1,389 @@ +import 'dart:ui' show PointerDeviceKind, Tristate; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:naked_ui/naked_ui.dart'; +import 'package:remix/remix.dart'; + +import '../../helpers/test_helpers.dart'; + +void main() { + group('RemixLink', () { + group('rendering', () { + testWidgets('renders a label', (tester) async { + await tester.pumpRemixApp(const RemixLink(label: 'Docs')); + await tester.pumpAndSettle(); + + expect(find.byType(StyledText), findsOneWidget); + expect(find.text('Docs'), findsOneWidget); + }); + + testWidgets('renders a child instead of a label', (tester) async { + await tester.pumpRemixApp( + const RemixLink( + label: 'ignored', + child: Icon(Icons.open_in_new, key: ValueKey('icon')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('icon')), findsOneWidget); + expect(find.text('ignored'), findsNothing); + }); + + testWidgets('applies the supplied style to the label', (tester) async { + await tester.pumpRemixApp( + RemixLink( + label: 'Styled', + style: LinkStyler().label(TextStyler().color(Colors.teal)), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.text('Styled')).style?.color, + Colors.teal, + ); + }); + }); + + group('activation', () { + testWidgets('tap, Enter, and Numpad Enter each activate once', ( + tester, + ) async { + var activations = 0; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpRemixApp( + RemixLink( + label: 'Go', + focusNode: focusNode, + enableFeedback: false, + onPressed: () => activations++, + ), + ); + + await tester.tap(find.text('Go')); + expect(activations, 1); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + expect(activations, 2); + await tester.sendKeyEvent(LogicalKeyboardKey.numpadEnter); + expect(activations, 3); + }); + + testWidgets('Space does not activate a link', (tester) async { + var activations = 0; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpRemixApp( + RemixLink( + label: 'Go', + focusNode: focusNode, + enableFeedback: false, + onPressed: () => activations++, + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + expect(activations, 0); + }); + + testWidgets('a disabled link neither focuses nor activates', ( + tester, + ) async { + var activations = 0; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpRemixApp( + RemixLink( + label: 'Go', + enabled: false, + focusNode: focusNode, + onPressed: () => activations++, + ), + ); + + await tester.tap(find.text('Go')); + focusNode.requestFocus(); + await tester.pump(); + + expect(activations, 0); + expect(focusNode.hasFocus, isFalse); + }); + }); + + group('semantics', () { + testWidgets('an actionable link publishes role, URL, and tap', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + final url = Uri.parse('https://example.com/docs'); + + await tester.pumpRemixApp( + RemixLink(label: 'Docs', linkUrl: url, onPressed: () {}), + ); + + final node = tester.getSemantics(find.bySemanticsLabel('Docs')); + final data = node.getSemanticsData(); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isTrue); + expect(data.hasAction(SemanticsAction.tap), isTrue); + expect(data.linkUrl, url); + handle.dispose(); + }); + + testWidgets('semanticLabel replaces the visible name', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpRemixApp( + RemixLink( + label: 'Docs', + semanticLabel: 'Read the documentation', + semanticHint: 'Opens in a new window', + onPressed: () {}, + ), + ); + + final data = tester + .getSemantics(find.bySemanticsLabel('Read the documentation')) + .getSemanticsData(); + expect(data.label, 'Read the documentation'); + expect(data.hint, 'Opens in a new window'); + handle.dispose(); + }); + + // `onPressed: null` and `enabled: false` mean the same thing here, the + // way they do on every Flutter control. NakedLink gates the Link role on + // effective-enabled, so both announce as unavailable text rather than as + // an unavailable link. + testWidgets('both ways of disabling a link announce identically', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var activations = 0; + + for (final link in [ + RemixLink(label: 'Go', focusNode: focusNode), + RemixLink( + label: 'Go', + enabled: false, + focusNode: focusNode, + onPressed: () => activations++, + ), + ]) { + await tester.pumpRemixApp(link); + + // Both go through the primitive. A special-cased render path for + // the callback-less case is what this guards against: it would fork + // the state scope and let one LinkStyler resolve two ways. + expect(find.byType(NakedLink), findsOneWidget); + + final data = tester + .getSemantics(find.bySemanticsLabel('Go')) + .getSemanticsData(); + expect(data.flagsCollection.isEnabled, Tristate.isFalse); + expect(data.flagsCollection.isLink, isFalse); + expect(data.hasAction(SemanticsAction.tap), isFalse); + expect(data.linkUrl, isNull); + + // Behaviour, not just semantics: neither spelling takes focus or + // fires a callback. + await tester.tap(find.text('Go')); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isFalse); + expect(activations, 0); + } + handle.dispose(); + }); + + testWidgets('excludeSemantics drops the node on both paths', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + for (final onPressed in [null, () {}]) { + await tester.pumpRemixApp( + RemixLink( + label: 'Hidden', + excludeSemantics: true, + onPressed: onPressed, + ), + ); + + expect(find.bySemanticsLabel('Hidden'), findsNothing); + } + handle.dispose(); + }); + + testWidgets('multi-child content still publishes one link node', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpRemixApp( + RemixLink( + onPressed: () {}, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [Text('Read more'), Icon(Icons.arrow_forward)], + ), + ), + ); + + // getSemantics throws if the subtree resolves to more than one node, + // so this also proves the role and the name landed together rather + // than splitting across a link node and a separate text node. + final data = tester + .getSemantics(find.byType(RemixLink)) + .getSemanticsData(); + expect(data.flagsCollection.isLink, isTrue); + expect(data.label, 'Read more'); + handle.dispose(); + }); + }); + + group('interaction state', () { + testWidgets('an ancestor widget state does not reach the link style', ( + tester, + ) async { + // The link resolves against its own Naked state controller, so a + // hovered ancestor must not paint it hovered. Enabled and disabled + // both go through that controller, so one LinkStyler stays honest + // either way. + final style = LinkStyler() + .label(TextStyler().color(Colors.black)) + .onHovered(LinkStyler().label(TextStyler().color(Colors.red))); + + for (final onPressed in [null, () {}]) { + await tester.pumpRemixApp( + WidgetStateProvider( + states: const {WidgetState.hovered}, + child: RemixLink( + label: 'Nested', + onPressed: onPressed, + style: style, + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.text('Nested')).style?.color, + Colors.black, + reason: 'onPressed: $onPressed', + ); + } + }); + + testWidgets('hover and focus reach the resolved style', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpRemixApp( + RemixLink( + label: 'Stateful', + focusNode: focusNode, + onPressed: () {}, + style: LinkStyler() + .label(TextStyler().color(Colors.black)) + .onHovered(LinkStyler().label(TextStyler().color(Colors.red))) + .onFocused(LinkStyler().label(TextStyler().color(Colors.blue))), + ), + ); + + expect( + tester.widget(find.text('Stateful')).style?.color, + Colors.black, + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.text('Stateful'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.text('Stateful')).style?.color, + Colors.red, + ); + + await mouse.moveTo(Offset.zero); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + tester.widget(find.text('Stateful')).style?.color, + Colors.blue, + ); + }); + }); + + group('styler helper', () { + testWidgets('calling a LinkStyler builds a RemixLink', (tester) async { + var pressed = false; + await tester.pumpRemixApp( + LinkStyler()(label: 'Called', onPressed: () => pressed = true), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Called')); + expect(pressed, isTrue); + }); + + test('LinkStyler.call forwards every widget input', () { + final style = LinkStyler(); + final url = Uri.parse('https://example.com'); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + final defaults = style(label: 'Default'); + final configured = style( + label: 'Configured', + onPressed: () {}, + enabled: false, + linkUrl: url, + focusNode: focusNode, + autofocus: true, + enableFeedback: false, + mouseCursor: SystemMouseCursors.basic, + semanticLabel: 'Name', + semanticHint: 'Hint', + excludeSemantics: true, + ); + + expect(defaults.enabled, isTrue); + expect(defaults.onPressed, isNull); + expect(defaults.linkUrl, isNull); + expect(defaults.autofocus, isFalse); + expect(defaults.enableFeedback, isTrue); + expect(defaults.mouseCursor, SystemMouseCursors.click); + expect(defaults.excludeSemantics, isFalse); + + expect(configured.enabled, isFalse); + expect(configured.onPressed, isNotNull); + expect(configured.linkUrl, url); + expect(configured.focusNode, same(focusNode)); + expect(configured.autofocus, isTrue); + expect(configured.enableFeedback, isFalse); + expect(configured.mouseCursor, SystemMouseCursors.basic); + expect(configured.semanticLabel, 'Name'); + expect(configured.semanticHint, 'Hint'); + expect(configured.excludeSemantics, isTrue); + expect(configured.style, same(style)); + }); + }); + }); +} diff --git a/packages/remix_fortal/lib/src/recipes/link.dart b/packages/remix_fortal/lib/src/recipes/link.dart index 9a684ff5..b40305c8 100644 --- a/packages/remix_fortal/lib/src/recipes/link.dart +++ b/packages/remix_fortal/lib/src/recipes/link.dart @@ -1,7 +1,6 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; -import 'package:naked_ui/naked_ui.dart'; import 'package:remix/remix.dart'; import '../fortal/fortal.dart'; @@ -14,7 +13,12 @@ enum FortalLinkUnderline { auto, always, hover, none } /// /// Takes a [context] because the focus outline's radius is em-relative to the /// resolved font size. -BadgeStyler fortalLinkStyle( +/// +/// [actionable] gates every state-dependent rule, matching upstream's +/// `:where(:any-link, button)`. A non-actionable link carries no hover or +/// focus-visible variant at all, so it stays plain accent text no matter what +/// widget states are resolved around it. +LinkStyler fortalLinkStyle( BuildContext context, { FortalTextSize? size, FortalTextWeight? weight, @@ -23,8 +27,50 @@ BadgeStyler fortalLinkStyle( bool truncate = false, bool highContrast = false, required bool actionable, - bool hovered = false, - bool focused = false, +}) { + LinkStyler styleFor({bool hovered = false, bool focused = false}) => + _fortalLinkStateStyle( + context, + size: size, + weight: weight, + underline: underline, + softWrap: softWrap, + truncate: truncate, + highContrast: highContrast, + actionable: actionable, + hovered: hovered, + focused: focused, + ); + + if (!actionable) return styleFor(); + + // The focus-visible snapshot already drops the underline via `focused`; the + // explicit `none` also clears any decoration inherited through the merge. + final focusVisible = styleFor( + focused: true, + ).label(.decoration(TextDecoration.none)); + + return styleFor() + .onHovered(styleFor(hovered: true)) + .onFocusVisible(focusVisible); +} + +/// Resolves one point in the link's state space. +/// +/// Separate from [fortalLinkStyle] because the public recipe returns a style +/// carrying Mix variants, and building those variants needs the flat snapshots +/// they are built from. +LinkStyler _fortalLinkStateStyle( + BuildContext context, { + required FortalTextSize? size, + required FortalTextWeight? weight, + required FortalLinkUnderline underline, + required bool softWrap, + required bool truncate, + required bool highContrast, + required bool actionable, + required bool hovered, + required bool focused, }) { var textStyle = fortalAccentForeground( TextStyler(), @@ -85,7 +131,7 @@ BadgeStyler fortalLinkStyle( truncate: truncate, ); - var style = BadgeStyler() + var style = LinkStyler() .label(textStyle) .borderRadius( BorderRadiusGeometryMix.circular( @@ -104,44 +150,19 @@ BadgeStyler fortalLinkStyle( return style; } -BadgeStyler _fortalInteractiveLinkStyle( - BuildContext context, { - FortalTextSize? size, - FortalTextWeight? weight, - required FortalLinkUnderline underline, - required bool softWrap, - required bool truncate, - required bool highContrast, -}) { - BadgeStyler styleFor({bool hovered = false, bool focused = false}) { - return fortalLinkStyle( - context, - size: size, - weight: weight, - underline: underline, - softWrap: softWrap, - truncate: truncate, - highContrast: highContrast, - actionable: true, - hovered: hovered, - focused: focused, - ); - } - - final focusVisible = styleFor( - focused: true, - ).label(.decoration(TextDecoration.none)); - return styleFor() - .onHovered(styleFor(hovered: true)) - .onFocusVisible(focusVisible); -} - /// Token-backed text that becomes an accessible link only when actionable. /// -/// With no [onPressed] this renders inert styled text: no focus stop, link -/// role, or activation. `linkUrl` is assistive metadata only and is never -/// launched; navigation stays the caller's responsibility in [onPressed]. -class FortalLink extends StatefulWidget { +/// A null [onPressed] disables the link just as [enabled] `false` does: accent +/// text with no focus stop, link role, or activation, and never underlined. +/// Reach for `FortalText(accent: true)` when the text was never meant to +/// navigate. +/// +/// `linkUrl` is assistive metadata only and is never launched; navigation stays +/// the caller's responsibility in [onPressed]. +/// +/// An actionable link activates on pointer and Enter. Space belongs to the +/// Button role and is deliberately left unclaimed. +class FortalLink extends StatelessWidget { const FortalLink( this.text, { super.key, @@ -184,77 +205,30 @@ class FortalLink extends StatefulWidget { final String? semanticHint; final bool excludeSemantics; - @override - State createState() => _FortalLinkState(); -} - -class _FortalLinkState extends State { - // Mirrored from NakedButton because the link Semantics node must sit *outside* - // the button: NakedButton implements `excludeSemantics: true` as an - // ExcludeSemantics wrapper, which would suppress a node placed inside it. - bool _focused = false; - @override Widget build(BuildContext context) { - if (widget.onPressed == null) { - final content = fortalLinkStyle( + return RemixLink( + label: text, + onPressed: onPressed, + enabled: enabled, + linkUrl: linkUrl, + focusNode: focusNode, + autofocus: autofocus, + enableFeedback: enableFeedback, + mouseCursor: mouseCursor, + semanticLabel: semanticLabel, + semanticHint: semanticHint, + excludeSemantics: excludeSemantics, + style: fortalLinkStyle( context, - size: widget.size, - weight: widget.weight, - underline: widget.underline, - softWrap: widget.softWrap, - truncate: widget.truncate, - highContrast: widget.highContrast, - actionable: false, - )(label: widget.text); - - if (widget.excludeSemantics) return ExcludeSemantics(child: content); - if (widget.semanticLabel == null) return content; - - return Semantics( - label: widget.semanticLabel, - excludeSemantics: true, - child: content, - ); - } - - final button = NakedButton( - onPressed: widget.enabled ? widget.onPressed : null, - enabled: widget.enabled, - mouseCursor: widget.mouseCursor, - enableFeedback: widget.enableFeedback, - focusNode: widget.focusNode, - autofocus: widget.autofocus, - onFocusChange: (focused) { - if (_focused != focused) setState(() => _focused = focused); - }, - excludeSemantics: true, - builder: (context, _, _) => StyleBuilder( - style: _fortalInteractiveLinkStyle( - context, - size: widget.size, - weight: widget.weight, - underline: widget.underline, - softWrap: widget.softWrap, - truncate: widget.truncate, - highContrast: widget.highContrast, - ), - controller: NakedButtonState.controllerOf(context), - builder: (_, spec) => RemixBadge(label: widget.text, styleSpec: spec), + size: size, + weight: weight, + underline: underline, + softWrap: softWrap, + truncate: truncate, + highContrast: highContrast, + actionable: onPressed != null, ), ); - - final link = Semantics( - link: true, - enabled: widget.enabled, - focused: _focused, - linkUrl: widget.linkUrl, - label: widget.semanticLabel ?? widget.text, - hint: widget.semanticHint, - onTap: widget.enabled ? widget.onPressed : null, - child: button, - ); - - return widget.excludeSemantics ? ExcludeSemantics(child: link) : link; } } diff --git a/packages/remix_fortal/pubspec.yaml b/packages/remix_fortal/pubspec.yaml index 2c32ab85..380b7c6c 100644 --- a/packages/remix_fortal/pubspec.yaml +++ b/packages/remix_fortal/pubspec.yaml @@ -41,7 +41,7 @@ dependencies: mix: ^2.2.0-beta.4 mix_annotations: ^2.2.0-beta.1 mix_chart: ^0.0.1-beta.1 - naked_ui: ^1.0.0-beta.10 + naked_ui: ^1.0.0-beta.11 dev_dependencies: flutter_test: diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/coverage_evidence.json b/packages/remix_fortal/reference/radix_themes_3_3_0/coverage_evidence.json index 3afdfe0e..ea5d4b6c 100644 --- a/packages/remix_fortal/reference/radix_themes_3_3_0/coverage_evidence.json +++ b/packages/remix_fortal/reference/radix_themes_3_3_0/coverage_evidence.json @@ -1208,7 +1208,7 @@ }, { "test": "test/components/typography/typography_test.dart", - "case": "inert link exposes no interactive metadata", + "case": "a link with no callback is a disabled link", "covers": ["state:inert"] }, { diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/manifest.json b/packages/remix_fortal/reference/radix_themes_3_3_0/manifest.json index 1d756624..b12c7eb6 100644 --- a/packages/remix_fortal/reference/radix_themes_3_3_0/manifest.json +++ b/packages/remix_fortal/reference/radix_themes_3_3_0/manifest.json @@ -4601,7 +4601,7 @@ ".rt-high-contrast" ], "flutterExceptions": [ - "Every upstream underline rule is gated behind `:where(:any-link, button)`, so a FortalLink with no onPressed stays inert accent text with no focus stop, link role, or activation.", + "Every upstream underline rule is gated behind `:where(:any-link, button)`, so a FortalLink with no onPressed is disabled exactly as `enabled: false` is: it keeps the accent colour but gives up its underline, focus stop, link role, and activation.", "`text-underline-offset` has no Flutter TextStyle equivalent, so the underline sits at the font's own offset.", "Flutter reads decorationThickness as a multiple of the font underline thickness rather than a CSS length, so the pinned `min(2px, max(1px, 0.05em))` ramp lands as a 1x\u20132x stroke.", "The decoration colour approximates Radix `color-mix(in oklab, \u2026)` with an sRGB Color.lerp.", diff --git a/packages/remix_fortal/test/components/typography/typography_test.dart b/packages/remix_fortal/test/components/typography/typography_test.dart index ff0f6530..542a622e 100644 --- a/packages/remix_fortal/test/components/typography/typography_test.dart +++ b/packages/remix_fortal/test/components/typography/typography_test.dart @@ -1,4 +1,4 @@ -import 'dart:ui' show PointerDeviceKind; +import 'dart:ui' show PointerDeviceKind, Tristate; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,6 +7,8 @@ import 'package:naked_ui/naked_ui.dart'; import 'package:remix/remix.dart'; import 'package:remix_fortal/remix_fortal.dart'; +import '../../helpers/test_helpers.dart'; + void main() { group('metrics', () { testWidgets('all nine sizes resolve the pinned scale across scaling', ( @@ -646,7 +648,10 @@ void main() { await tester.pumpAndSettle(); expect(focusNode.hasFocus, isTrue); - expect(_surface(tester).containerEffects?.outline.width ?? 0, 0); + expect( + _linkSurface(tester, 'focus me').containerEffects?.outline.width ?? 0, + 0, + ); expect(_underlined(tester, 'focus me'), isTrue); expect(tester.getSize(find.text('focus me')), idleSize); @@ -654,7 +659,7 @@ void main() { FocusHighlightStrategy.alwaysTraditional; await tester.pump(); - final effects = _surface(tester).containerEffects!; + final effects = _linkSurface(tester, 'focus me').containerEffects!; expect(effects.outline.width, 2); expect(effects.outlineOffset, 2); expect(_underlined(tester, 'focus me'), isFalse); @@ -664,7 +669,10 @@ void main() { FocusHighlightStrategy.alwaysTouch; await tester.pump(); - expect(_surface(tester).containerEffects?.outline.width ?? 0, 0); + expect( + _linkSurface(tester, 'focus me').containerEffects?.outline.width ?? 0, + 0, + ); expect(_underlined(tester, 'focus me'), isTrue); }); @@ -700,13 +708,19 @@ void main() { focusNode.requestFocus(); await tester.pumpAndSettle(); - expect(_surface(tester).containerEffects?.outline.width, 2); + expect( + _linkSurface(tester, 'hover me').containerEffects?.outline.width, + 2, + ); expect(_underlined(tester, 'hover me'), isFalse); FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; await tester.pump(); - expect(_surface(tester).containerEffects?.outline.width ?? 0, 0); + expect( + _linkSurface(tester, 'hover me').containerEffects?.outline.width ?? 0, + 0, + ); expect(_underlined(tester, 'hover me'), isTrue); }); }); @@ -824,30 +838,36 @@ void main() { hasEnabledState: true, isEnabled: true, hasTapAction: true, + // NakedLink publishes the focus stop through the same node, so + // assistive tech can move focus here without a separate target. + hasFocusAction: true, ), ); expect(node.getSemanticsData().linkUrl, url); + expect(find.byType(NakedButton), findsNothing); handle.dispose(); }); - testWidgets('inert link exposes no interactive metadata', (tester) async { + testWidgets('a link with no callback is a disabled link', (tester) async { final handle = tester.ensureSemantics(); await _pump(tester, const FortalLink('Read more')); - expect(find.byType(NakedButton), findsNothing); - expect( - tester - .getSemantics(find.bySemanticsLabel('Read more')) - .getSemanticsData() - .linkUrl, - isNull, - ); + // `onPressed: null` disables the link the way it disables any Flutter + // control: still one node, but no Link role, destination, or tap. + final data = tester + .getSemantics(find.bySemanticsLabel('Read more')) + .getSemanticsData(); + expect(data.flagsCollection.isEnabled, Tristate.isFalse); + expect(data.flagsCollection.isLink, isFalse); + expect(data.linkUrl, isNull); handle.dispose(); }); }); group('interaction', () { - testWidgets('pointer, Enter, and Space each activate once', (tester) async { + testWidgets('pointer and Enter activate once each; Space does not', ( + tester, + ) async { var activations = 0; final focusNode = FocusNode(); addTearDown(focusNode.dispose); @@ -867,6 +887,10 @@ void main() { await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.enter); expect(activations, 2); + await tester.sendKeyEvent(LogicalKeyboardKey.numpadEnter); + expect(activations, 3); + // A Link takes Enter, not Space. Space scrolls the page for a real + // anchor; the previous Button-backed recipe activated on it by mistake. await tester.sendKeyEvent(LogicalKeyboardKey.space); expect(activations, 3); }); @@ -1067,6 +1091,14 @@ BadgeSpec _surface(WidgetTester tester) { widget.style.resolve(tester.element(find.byType(RemixBadge))).spec; } +/// Reads a link's *resolved* spec. +/// +/// [RemixLink] resolves its style beneath the Naked state scope, so reading the +/// widget's own `style` field would report the idle snapshot; the published +/// provider is the only place the hover and focus-visible variants have landed. +LinkSpec _linkSurface(WidgetTester tester, String text) => + tester.resolvedSpecOf(find.text(text)); + Color? _boxColor(BadgeSpec spec) => (spec.container.spec.decoration as BoxDecoration?)?.color; diff --git a/packages/remix_fortal/test/public_api_test.dart b/packages/remix_fortal/test/public_api_test.dart index c5efad76..543ab172 100644 --- a/packages/remix_fortal/test/public_api_test.dart +++ b/packages/remix_fortal/test/public_api_test.dart @@ -115,7 +115,7 @@ void main() { expect(recipes.code, isA()); expect(recipes.kbd, isA()); - expect(recipes.link, isA()); + expect(recipes.link, isA()); }); test('Fortal chart wrappers are constructible from the public API', () { diff --git a/packages/remix_fortal/tool/fortal_parity/check.dart b/packages/remix_fortal/tool/fortal_parity/check.dart index b73e722e..0852937a 100644 --- a/packages/remix_fortal/tool/fortal_parity/check.dart +++ b/packages/remix_fortal/tool/fortal_parity/check.dart @@ -4,8 +4,8 @@ import 'dart:typed_data'; const _expectedIntegrity = 'sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw=='; -const _expectedNakedUiVersion = '1.0.0-beta.10'; -const _expectedNakedUiConstraint = '^1.0.0-beta.10'; +const _expectedNakedUiVersion = '1.0.0-beta.11'; +const _expectedNakedUiConstraint = '^1.0.0-beta.11'; const _expectedMappedFamilies = { 'avatar', 'badge', diff --git a/pubspec.lock b/pubspec.lock index 7d43ef92..98bdabab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -477,10 +477,10 @@ packages: dependency: transitive description: name: naked_ui - sha256: cb6c35fc10cf62142da74b491c0033d53c0e8c4f2a6ef895751c1713e47c41b9 + sha256: "65159cee8d20cb92cda3a5661d845055b1a0b08c72e86d09ed94caccbd7990e2" url: "https://pub.dev" source: hosted - version: "1.0.0-beta.10" + version: "1.0.0-beta.11" nested: dependency: transitive description: diff --git a/skills/using-remix/references/fortal.md b/skills/using-remix/references/fortal.md index 1b1cec50..4e22c302 100644 --- a/skills/using-remix/references/fortal.md +++ b/skills/using-remix/references/fortal.md @@ -164,8 +164,12 @@ Rules that matter when writing code: `highContrast: true` promotes it to `accent-12`. `highContrast` alone does nothing. `FortalKbd` pins `gray-12` and its own regular weight. - `truncate: true` wins over `softWrap` and forces one ellipsized line. -- A `FortalLink` **without** `onPressed` is inert styled text: no focus stop, no - link role, no activation. Only an actionable link underlines. +- A `FortalLink` **without** `onPressed` is disabled, exactly like one with + `enabled: false`: no focus stop, no link role, no activation. Only an + actionable link underlines. For accent text that never navigates, use + `FortalText(accent: true)`. +- An actionable link activates on pointer and **Enter**, not Space. Use + `FortalButton` when Space should activate. - `linkUrl` is assistive metadata and is never launched; navigation belongs in `onPressed`. Passing `linkUrl` without `onPressed` asserts. - No leading trim, `pretty`/`balance` wrapping, responsive prop objects, or