From 082a1d027b35fd4e7b9a7b3aa45ffe88b15201e6 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 12 Jul 2026 22:38:49 -0400 Subject: [PATCH 01/18] docs: retain component expansion plan --- plan/README.md | 114 ++ plan/briefing.md | 2708 ++++++++++++++++++++++++++++++++ plan/decisions.md | 66 + plan/phases/00-test-harness.md | 244 +++ plan/process.md | 103 ++ 5 files changed, 3235 insertions(+) create mode 100644 plan/README.md create mode 100644 plan/briefing.md create mode 100644 plan/decisions.md create mode 100644 plan/phases/00-test-harness.md create mode 100644 plan/process.md diff --git a/plan/README.md b/plan/README.md new file mode 100644 index 00000000..d8a52298 --- /dev/null +++ b/plan/README.md @@ -0,0 +1,114 @@ +# Naked UI component expansion — plan + +This folder is the working plan for delivering the eight new/expanded headless +primitives (and the test-harness hardening that must precede them) described in +the engineering briefing handed off from the Remix team. + +Consumer: **Remix for Flutter**. Boundary rule: Naked UI owns behavior, focus, +keyboard, overlays, timers, and semantics — never styling, product copy, or +business rules ([briefing §5](briefing.md#5-definition-of-the-headless-boundary)). + +## Folder contents + +| File | Role | Mutability | +|---|---|---| +| [briefing.md](briefing.md) | Full handoff contract (per-component behavior, semantics, tests, evidence) | **Frozen** — reference only | +| [process.md](process.md) | The repeatable per-component workflow and PR gates | Stable | +| [decisions.md](decisions.md) | Decision log D-01…D-15 and escalation rule | **Living** — update as decisions resolve | +| README.md (this file) | Index and status board | **Living** — update every phase PR | +| `phases/NN-*.md` | Executable plan for one phase | Created just-in-time when a phase starts | + +## Status board + +Phase numbers follow the briefing's PR order ([§7](briefing.md#7-delivery-sequence-and-pull-request-boundaries)). +A phase plan file is created from the briefing contract when the phase starts — +do not pre-write plans for phases whose blocking decisions are unresolved. + +| Phase | Scope | Contract | Blocking decisions | Plan | Status | +|---:|---|---|---|---|---| +| 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | +| 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 | — | Not started | +| 2 | Link | [§20](briefing.md#20-component-contract-link) | — | — | Not started | +| 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | +| 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | +| 5 | Context Menu | [§15](briefing.md#15-component-contract-context-menu) | D-03 | — | Not started | +| 6 | Toast | [§16](briefing.md#16-component-contract-toast) | D-04, D-05, D-06, D-07 | — | Not started | +| 7 | Hover Card | [§19](briefing.md#19-component-contract-hover-card--preview-card) | — | — | Not started | +| 8 | Combobox | [§18](briefing.md#18-component-contract-combobox) | D-10, D-11 + **accessibility spike ([§18.3](briefing.md#183-accessibility-spike-required-before-final-api))** | — | Blocked on spike | + +Release grouping ([§23.2](briefing.md#232-recommended-release-grouping)): +prerelease 1 = phases 1–3, prerelease 2 = phases 4–5, prerelease 3 = phases 6–7, +Combobox ships alone after its spike passes. A blocked phase does not block +independent phases. + +## How to work a phase + +1. Resolve the phase's blocking decisions in [decisions.md](decisions.md) first + — nothing is decided silently inside an implementation PR. +2. Create `phases/NN-.md` by deriving tasks from the phase's briefing + contract section (see the template at the end of [process.md](process.md)). +3. Follow the workflow in [process.md](process.md) (contract review → failing + tests → implementation → fixture → platform proof → evidence packet). +4. In the phase's final PR, update this status board and any resolved rows in + decisions.md. + +## Verification record + +The briefing's factual claims were checked against this repository at commit +`0ca0b8b` on 2026-07-12 before this plan was created. All 16 checked claims +held, including: package `1.0.0-beta.3` with Flutter `>=3.41.0` floor vs CI pin +`3.41.2`; the "macOS integration" job running `-d flutter-tester` +(`.github/workflows/integration-tests.yml:46`); Android integration being +`workflow_dispatch`-only; no web integration workflow; zero usages of +`takeScreenshot`/`matchesGoldenFile`/`meetsGuideline`; `testKeyboardActivation` +catch-and-return-false and `verifyTabOrder` asserting only widget existence +(`packages/example/integration_test/helpers/keyboard_test_helpers.dart`); the +stale `example/` path in `tool/run_integration_all.sh:9`; the Tooltip +integration file missing from `all_tests.dart`; the 30-minute blanket timeout +and 2-second real `tearDownAll` delay in `all_tests.dart`; 498 widget tests and +89 integration `testWidgets`; the advisory 7.3%-vs-80% coverage gate +(`.github/workflows/ci.yml:79`); and no committed `macos/`/`web/`/`android/` +platform directories under `packages/example`. + +Phase 0 hosted evidence was completed for PR #63 at head `3cb5487`, then +squash-merged to `main` as `58a48a3`. The main test suite, exact Flutter 3.41.0 +job, aggregate `flutter-tester`, real macOS, pinned headless Chrome, API 34 +Android emulator, and PR-title check all passed. The workflows triggered by the +merged commit also passed. Android behavior runs through `flutter test`; +`flutter drive` remains only for screenshot/report-data transport and web +integration. + +If the repo has moved past `0ca0b8b`, re-verify a claim before building on it. + +### Phase 0 closure (2026-07-12) + +All A1–A6, B1–B6, and C1–C3 implementation tasks landed in PR #63 after +maintainer approval of D-12–D-15. The inventory guard, keyboard postcondition, +tab-order assertion, golden 1px mutation, and unnamed-target accessibility +failure were each proven to fail for the intended reason; all temporary +mutations were restored. Phase 0 is closed. + +Local verification passed on Flutter 3.41.0 and 3.41.2, real macOS, and matched +Chrome/ChromeDriver 149.0.7827.201; hosted web verification used the pinned +150.0.7871.115 pair. The inspected Dialog screenshot is an 800×600 real-macOS +PNG with a complete manifest. Flutter 3.41.2 web screenshots remain explicitly +unsupported after both the WebDriver path and a repaint-boundary fallback +proved unstable; the blocking web behavior gate uploads its test log instead. +No Android emulator/AVD is installed locally, so the hosted API 34 job is the +authoritative Android proof. + +Verification evidence: + +- Flutter 3.41.0: analyze clean; 574 package tests passed with 3 documented + skips. +- Flutter 3.41.2: format and analyze clean; 574 package tests passed with 3 + documented skips; 13 example tests passed with 1 documented golden skip. +- Integration: the sequential component runner completed; the aggregate passed + 88 tests with 1 documented Tooltip skip on `flutter-tester`, real macOS, and + pinned Chrome. +- Evidence: the approved golden update/verify sequence passed with an unchanged + SHA-256 baseline; macOS screenshot/manifest generation passed and the image + was visually inspected. +- CI definitions: every workflow parses as YAML and actionlint reports no + findings; the pinned Chrome action tag, inputs, and outputs were verified + against its upstream action definition. diff --git a/plan/briefing.md b/plan/briefing.md new file mode 100644 index 00000000..8756d968 --- /dev/null +++ b/plan/briefing.md @@ -0,0 +1,2708 @@ + + +# Naked UI component expansion: engineering briefing and handoff + +**Status:** Proposed implementation contract and delivery package +**Audience:** Naked UI maintainers, Remix maintainers, accessibility reviewers, and QA +**Prepared:** 2026-07-11 +**Primary consumer:** Remix for Flutter + +## 1. Purpose + +This document defines the work Naked UI should complete before Remix adds the next high-value headless primitives. It is intentionally more specific than a component wish list. It describes: + +- the behavior Naked UI must own; +- the behavior that must remain in the styled Remix layer; +- proposed public APIs and compatibility constraints; +- the semantics, focus, keyboard, pointer, and localization contract; +- widget, semantics, integration, accessibility-guideline, golden, screenshot, and manual assistive-technology evidence; +- a component-by-component acceptance checklist; +- the CI and handoff package required before Remix consumes a release. + +The goal is not merely to make eight widgets render. The goal is to make them dependable primitives that another design system can style without having to repair interaction or accessibility behavior. + +## 2. Verification basis + +The findings and proposals below were checked against these concrete baselines on 2026-07-11. + +| Surface | Verified baseline | Why it matters | +|---|---|---| +| Remix workspace | Local HEAD 250178041; target baseline origin/chore/1.0-release@be5c17f3a | Establishes the consumer code inspected for this handoff. | +| Remix dependency | packages/remix/pubspec.yaml declares naked_ui: ^0.2.0-beta.7 | Remix is not yet consuming the current Naked UI main API. | +| Remix SDK contract | Dart >=3.12.0, Flutter >=3.44.0; workspace .fvmrc is Flutter 3.44.0 | Consumer verification must use its configured SDK even if Naked UI supports an older minimum. | +| Naked UI upstream | btwld/naked_ui@0ca0b8bc2269ed331345cc705d99a073acdf5f5f | Pins all upstream code and CI observations in this document. | +| Naked UI package | 1.0.0-beta.3, Dart >=3.9.0, Flutter >=3.41.0 | New APIs should remain compatible with the package minimum unless the maintainers explicitly raise it. | +| Naked UI CI SDK | Flutter 3.41.2 | This is the current pinned CI environment. It does not by itself prove the declared Flutter 3.41.0 minimum. | +| Local Flutter used for API inspection | Flutter 3.41.2 / Dart 3.11.0 | Confirms the named semantics APIs exist at the current Naked UI CI version. | + +External behavior was cross-checked with Flutter's official accessibility, focus, semantics, golden, and integration-test documentation; W3C WAI-ARIA Authoring Practices and WCAG 2.2 guidance; and the documented behavior of Radix and Base UI primitives. The source register is in section 25. + +### 2.1 Confirmed facts versus proposals + +To avoid presenting design choices as existing behavior: + +- **Confirmed** means observed in the pinned Remix or Naked UI source, its tests, its CI, the Flutter 3.41.2 SDK, or linked official documentation. +- **Proposed** means the recommended contract for the Naked UI team. Public names can be adjusted during API review, but the observable behavior and acceptance gates should not silently change. +- **Open decision** means the team must explicitly resolve the item before the relevant implementation PR is approved. + +## 3. Executive decision + +Naked UI should implement or expand these eight items: + +1. Alert Dialog support by extending NakedDialog. +2. A context-menu trigger that reuses Naked menu items and overlay infrastructure. +3. A complete Toggle Group with roving focus, orientation, looping, RTL, and multiple selection. +4. Toast controller, viewport, item lifecycle, announcement semantics, and focus behavior. +5. Field composition and validation semantics around controls such as NakedTextField. +6. An editable Combobox, including single and multiple selection foundations. +7. A non-interactive Hover Card / Preview Card. +8. A Link primitive with correct link semantics and keyboard behavior. + +These are behavior primitives, not Remix visual components. Naked UI should not absorb Remix tokens, Mix styles, colors, typography, radii, shadows, padding, animation curves chosen for a theme, or product copy. + +### 3.1 Items Remix can build without new Naked UI primitives + +The following nearby Remix gaps should not be added to Naked UI as new primitives: + +| Remix item | Naked UI dependency decision | +|---|---| +| Popover | Current Naked UI main already has NakedPopover. Remix should upgrade and compose it. | +| Basic single-select segmented control | Current main already has NakedToggleGroup and NakedToggleOption. Remix can prototype with them, but production parity needs the enhancements in section 14. | +| Drawer / Sheet | Compose NakedDialog with Remix-owned placement and motion. Only generic dialog behavior belongs in Naked UI. | +| Skeleton | Purely visual loading placeholder. Remix owns it. Decorative skeletons should normally be excluded from semantics while nearby content exposes the loading state. | +| Basic Scroll Area | Use Flutter scrolling primitives. Add Naked UI behavior only if a later scope requires custom scrollbar interaction or a cross-design-system scroll contract. | + +### 3.2 Naked UI work map + +Complexity is relative and is not a calendar estimate. + +| Item | Work type | Remix need | Priority | Relative complexity | Primary dependency/risk | +|---|---|---|---|---|---| +| Alert Dialog | Extend Dialog | Safe destructive/urgent confirmations | P0 | Small | Semantic role, initial focus, nondismissible default | +| Link | New primitive | Foundational inline navigation and Hover Card trigger | P0 | Small | Correct link rather than button behavior | +| Field | New composition + TextField integration | Consistent labels, descriptions, required/invalid/error behavior | P0 | Large | Avoiding duplicate semantics and preserving TextField compatibility | +| Toggle Group | Expand existing group | Segmented/content-switcher and grouped formatting controls | P0 | Large | Roving focus, RTL, single/multiple semantics compatibility | +| Context Menu | New trigger over Menu infrastructure | Pointer/touch/keyboard contextual actions | P1 | Large | Point anchoring, non-button trigger semantics, focus restoration | +| Toast | New controller/viewport/item family | Transient status and safe-action feedback | P1 | Extra large | Queue/timers, focus pause, one-time announcements | +| Hover Card | New preview overlay | Rich visual link previews | P1 | Large | Pointer grace, WCAG hover/focus behavior, semantics exclusion | +| Combobox | New editable composite | Searchable single/multiple selection | P2 until spike passes | Extra extra large | Flutter role mapping, active-option announcements, IME/editing integrity | + +P0 means a foundational primitive or prerequisite for other work. P1 means high-value follow-on work. Combobox is P2 only because its platform accessibility uncertainty should be resolved before implementation scale grows; its product value remains high. + +## 4. Why this work belongs in Naked UI + +The eight proposed items contain reusable interaction rules that are easy for styled libraries to implement inconsistently: + +- focus entry, containment, restoration, and traversal; +- keyboard activation and composite-widget navigation; +- pointer and touch gesture normalization; +- overlay positioning, collision handling, and dismissal; +- enabled, selected, highlighted, open, invalid, and timed lifecycle state; +- screen-reader role, name, value, state, action, and announcement behavior; +- deterministic timers and app-lifecycle pausing; +- platform and text-direction differences. + +If each Remix theme reimplements these rules, semantic and keyboard behavior can drift while screenshots still look correct. Naked UI should centralize those rules and expose observable state so Remix can remain responsible for presentation. + +## 5. Definition of the headless boundary + +### 5.1 Naked UI owns + +- Controlled and uncontrolled behavior only where the API explicitly promises it. +- Focus nodes it creates internally, including disposal and debug labels. +- Focus entry, traversal, escape behavior, and restoration. +- Shortcuts, intents, actions, pointer gestures, and touch gestures. +- Overlay open/close lifecycle and collision-safe anchors. +- Semantics nodes, roles/flags, values, actions, grouping, and announcement priority. +- Enabled-state propagation and suppression of actions when disabled. +- Interaction snapshots exposed to builders and NakedStateScope. +- Timer state, pause/resume behavior, and deterministic disposal. +- Directionality-sensitive keyboard behavior. +- Public documentation of every observable invariant. + +### 5.2 Remix or another styled consumer owns + +- Color, typography, spacing, size, border, radius, elevation, shadow, and icon choices. +- The visible focus-ring design, while Naked UI exposes focused state. +- Product language and localized user-facing strings. +- Navigation implementation and URL launching. +- Filtering algorithms, remote search, data fetching, caching, and business rules. +- Business validation rules and when a form chooses to reveal an error. +- Responsive layout and component-specific animation styling. +- The final visual target sizes and contrast ratios. + +### 5.3 Canonical example app owns test presentation + +Naked UI's example application should provide one deterministic reference style for every primitive. That style is test infrastructure, not a package default. It exists so real-device integration tests, accessibility guidelines, goldens, and screenshot review have a stable visible surface. + +This distinction is essential: + +- core Naked UI tests prove semantics and behavior; +- canonical example tests prove a representative styled integration; +- Remix tests prove Remix's actual styles; +- screenshots never substitute for a semantics assertion or screen-reader check. + +## 6. Current upstream strengths and gaps + +### 6.1 Confirmed strengths + +At the pinned upstream commit: + +- Naked UI has 498 widget tests under packages/naked_ui/test. +- It has dedicated semantics tests for all 13 currently integrated components plus semantics utilities. +- Semantics tests use tester.ensureSemantics(), inspect node data, and in several cases compare behavior with Flutter Material widgets. +- There are 89 example integration testWidgets cases across component files. +- Existing components follow a useful builder-first pattern and expose immutable state snapshots. +- Dialog, menu, popover, select, tabs, text field, tooltip, and other primitives already provide reusable foundations for this work. + +### 6.2 Confirmed delivery gaps to fix before adding the new suite + +| Gap | Evidence at the pinned commit | Required correction | +|---|---|---| +| “macOS integration” does not exercise macOS | The workflow runs on a macOS runner but invokes -d flutter-tester. | Add or generate macOS platform files and run -d macos. Keep a separate flutter-tester smoke job if useful. | +| Android is not a PR gate | Android integration is workflow_dispatch only. | Run affected component tests on PRs or at minimum nightly, with release branches gated on a passing run. | +| No web integration gate | No web integration workflow exists. | Add Chrome/ChromeDriver coverage for semantics DOM and keyboard-critical flows. | +| No screenshot evidence | No takeScreenshot usage was found. | Capture named screenshots for every required component state and upload artifacts. | +| No golden coverage | No matchesGoldenFile usage was found. | Add pinned, deterministic example goldens for layout/state regression. | +| No accessibility-guideline coverage | No meetsGuideline calls were found. | Run label, tap-target, and contrast checks on the canonical styled examples. | +| Keyboard helper can hide failure | testKeyboardActivation catches exceptions and returns false; callers can ignore the result. | Remove catch-and-continue behavior. A failed key event or unmet postcondition must fail the test. | +| Tab-order helper does not prove focus | verifyTabOrder verifies only that widgets exist after advancing focus. | Assert FocusManager.instance.primaryFocus or each managed node's hasFocus after every step. | +| Stale runner path | tool/run_integration_all.sh enters example/, while the app lives at packages/example/. | Repair the runner and add a CI smoke invocation so path drift fails early. | +| Broad settling can hang or conceal timing errors | Existing integration tests use pumpAndSettle() extensively. | Use targeted pumps and observable postconditions for timers and continuing animations, especially Toast and Hover Card. | +| Coverage threshold is advisory | The 80% coverage report has continue-on-error and documents a much lower current value. | Do not use the displayed threshold as proof. Add component-level acceptance coverage and make the agreed gate blocking. | +| Declared minimum is not tested exactly | The package allows Flutter 3.41.0 while CI pins 3.41.2. | Add an exact-minimum analysis/unit/semantics job or raise the package minimum deliberately. | + +The infrastructure corrections should be delivered first or in the first component PR. Otherwise the new work could appear green without proving the behavior this handoff requires. + +## 7. Delivery sequence and pull-request boundaries + +Use small, reviewable PRs with one behavior contract at a time. Recommended order: + +| Order | PR | Dependency or reason | +|---:|---|---| +| 0 | Test-harness hardening | Prevents false-positive keyboard and platform integration results for all later work. | +| 1 | Alert Dialog role and helper | Small additive extension that validates the new semantics process. | +| 2 | Naked Link | Small standalone primitive and a dependency for Hover Card examples. | +| 3 | Naked Field plus NakedTextField integration | Establishes validation semantics used by Combobox. | +| 4 | Toggle Group expansion | Exercises composite focus and RTL without an overlay. | +| 5 | Context Menu | Reuses menu item and overlay infrastructure; may require an internal menu-scope refactor. | +| 6 | Toast | Introduces deterministic timers, status announcements, and screenshot lifecycle coverage. | +| 7 | Hover Card | Reuses Link and overlay timing foundations. | +| 8 | Combobox | Highest-risk composite; builds on Field, TextField, overlay, option, and focus patterns. | + +Each PR should remain releasable. Internal refactors must preserve current public behavior and keep the existing suite green. + +## 8. Required implementation process + +The following process applies to every component PR. + +### Phase A — contract and threat-model review + +1. Copy the relevant component section from this document into the issue or PR. +2. Confirm the public API, controlled state, ownership, disabled behavior, and localization inputs. +3. Write the component semantics matrix before implementation. +4. List every input path: mouse, touch, stylus if relevant, keyboard, switch access/semantics action, and programmatic controller. +5. List focus entry, internal traversal, dismissal, and restoration behavior. +6. Identify continuous animation or timer behavior that makes pumpAndSettle() unsafe. +7. Record any Flutter engine limitation instead of hiding it behind a passing widget test. + +### Phase B — write failing tests + +Add failing tests in this order: + +1. Constructor assertions and controlled-state invariants. +2. Builder snapshot and NakedStateScope behavior. +3. Pointer/touch activation and disabled behavior. +4. Keyboard and focus behavior. +5. Semantics role, name, state, value, actions, grouping, and disabled behavior. +6. Overlay collision and dismissal where applicable. +7. Timer and lifecycle behavior where applicable. +8. Regression cases for disposal, rebuild, and dynamic child changes. + +The test must fail for the intended missing behavior, not for a harness or fixture error. + +### Phase C — implement the smallest behavior surface + +- Follow existing NakedState, builder, scope, and effective-enabled conventions. +- Prefer Flutter Shortcuts, Actions, Focus, and traversal policies over raw key handlers. +- Use long-lived focus nodes in State objects; dispose only nodes the widget owns. +- Make delays and durations injectable. +- Do not hard-code English semantics labels, shortcut labels, or dismissal text. +- Keep styles out of the package implementation. + +### Phase D — add a deterministic example fixture + +Every fixture must: + +- use stable ValueKey identifiers for triggers, controls, options, actions, status labels, and observable state output; +- use local data and no network; +- expose a visible state readout where useful; +- allow animations to be disabled or given fixed durations; +- render inside a fixed, documented viewport for goldens; +- include RTL and large-text variants when required; +- have reset behavior so tests do not depend on execution order. + +### Phase E — prove integration and visuals + +1. Run the component test on flutter-tester for fast feedback. +2. Run on a real macOS target. +3. Run the mobile-relevant paths on Android. +4. Run keyboard and semantics-DOM paths on web. +5. Capture all screenshots listed in the component section. +6. Compare the pinned widget goldens. +7. Run accessibility guidelines against the canonical example. +8. Perform the manual assistive-technology checks. + +### Phase F — prepare the handoff packet + +The PR cannot be handed to Remix with only a green checkmark. It must include the evidence package in section 22. + +## 9. Cross-component API conventions + +### 9.1 Builder and child contract + +- Follow the current Naked UI invariant that either child or builder is present when a visual surface is required. +- A builder receives an immutable state snapshot and the optional child. +- The same state snapshot must be available through NakedStateScope inside the built subtree. +- State equality and hashCode must include every public observable field. +- Rebuilding with an equivalent state must not create timer, focus, or overlay churn. + +### 9.2 Controlled state + +- Selection, query, validity, and open state must be explicitly documented as controlled or controller-owned. +- A callback being null must never mutate an allegedly controlled value. +- Callbacks fire once per accepted user action and do not fire for disabled items, repeated selection when no change occurs, or a canceled gesture. +- Multiple-selection callbacks return an immutable snapshot rather than a mutable set retained internally. + +### 9.3 Controller ownership + +- External controllers and focus nodes are never disposed by Naked UI. +- Internal controllers, timers, listeners, and focus nodes are always disposed. +- Replacing an external controller detaches all listeners from the old controller. +- Controllers reject or safely ignore calls after disposal according to a documented policy. +- Every internally created focus node has a useful debugLabel. + +### 9.4 Effective enabled state + +Compute effective enabled state from both the explicit enabled flag and the presence of the callback/controller capability required for activation. When disabled: + +- no pointer, keyboard, or semantic action changes state; +- no feedback is emitted; +- the node is removed from normal focus traversal; +- semantics expose disabled state when the control remains discoverable; +- the mouse cursor is not an activation cursor; +- descendants cannot accidentally reactivate behavior. + +### 9.5 Localization + +Naked UI may accept semantic labels and hints but must not ship English defaults for user-facing phrases such as “Open context menu,” “Notification,” “Dismiss,” “Required,” or “Invalid.” Prefer one of: + +- a required caller-provided string; +- an existing Flutter localization string with the correct meaning; +- a localization delegate introduced deliberately for Naked UI. + +Tests must use at least one non-English label and an RTL Directionality fixture to catch assumptions. + +### 9.6 Stable identifiers + +Public semantics identifiers should be optional and documented if exposed. Test keys belong to the example fixture, not the package API. Do not make production behavior depend on a test-only key. + +### 9.7 Reduced motion + +Naked UI should expose behavior state and timing hooks; it should not force visual animation. Canonical examples and Remix must honor MediaQuery.disableAnimations. Timed behavior such as Toast dismissal remains functional when visual transitions are disabled. + +## 10. Universal semantics contract + +Every component must document and test all fields below. + +| Dimension | Required question | +|---|---| +| Primitive | What user-recognizable control or region is this? | +| Accessible name | Where does the name come from, and what happens if it is absent? | +| Role or flags | Which Flutter role and flags are exposed? | +| State/value | Which open, selected, toggled, required, invalid, expanded, or value fields change? | +| Actions | Which semantic actions exist while enabled, and which disappear while disabled? | +| Focus | Is it focusable, where does focus enter, and where is it restored? | +| Traversal | Is it one tab stop or several, and what is the internal order? | +| Grouping/relations | Which nodes are containers, explicit children, or controlled content? | +| Disabled behavior | Is it discoverable, focusable, actionable, and announced as disabled? | +| Localization | Which labels/hints are caller supplied? | +| Flutter mapping | Which Semantics properties implement the contract? | +| Automated proof | Which exact node properties and transitions are asserted? | +| Manual proof | What must VoiceOver, TalkBack, and web assistive technology announce? | + +### 10.1 Aggregate matrix + +| Component | Primary semantics mapping | Name/value/state | Actions and focus | +|---|---|---|---| +| Alert Dialog | role: SemanticsRole.alertDialog, container, explicit children, route scope/name, blocked background | Visible title or caller semanticLabel; message remains readable | Focus enters the specified safe target, loops inside, and returns to invoker; no implicit dismissal when nondismissible | +| Context Menu | Trigger retains its native semantics; popup role: menu; initial-scope action items use menuItem | Trigger label only if supplied; item labels from visible content or explicit label | Secondary tap, long press, keyboard menu shortcut, item tap; focus moves into menu and restores on close | +| Toggle Group | Group semantics container; options are toggle buttons | Each option has a name and toggled state; group can have a label | One tab stop; arrows move roving focus; Enter/Space toggle; disabled options have no action | +| Toast | role: status for normal messages or role: alert for urgent messages | One concise announcement string; visual action remains a separate accessible control | No focus steal; optional user-invoked viewport focus; dismiss/action controls work normally | +| Field | Actual control carries text-field/control semantics, isRequired, and validationResult; error announcement uses role: alert only on a new visible error | Label, current value, description, and current error are associated with the control without duplicate speech | Label requests control focus; disabled/read-only distinctions remain accurate | +| Combobox | Editable text-field semantics plus SemanticsRole.comboBox where verified; expanded and controlsNodes; popup list and selected options | Name, query/value, expanded state, highlighted option, selection | Input stays focused; arrows highlight; Enter accepts; Escape closes; popup is outside page Tab sequence | +| Hover Card | Trigger keeps its original Link semantics; preview overlay is excluded from semantics by default | No duplicate accessible name; preview contains no unique essential information | Hover/focus opens; Escape dismisses; preview does not enter Tab order or steal focus | +| Link | link: true, optional linkUrl, enabled state, tap action | Visible text or explicit label; optional hint such as opening a new window is caller-localized | Enter and semantic tap activate; Space is not intercepted; disabled links are not focusable/actionable | + +### 10.2 Important Flutter 3.41.2 caveat + +The Flutter 3.41.2 SDK contains SemanticsRole.alertDialog, comboBox, status, and alert; it also contains linkUrl, controlsNodes, isRequired, and SemanticsValidationResult. + +However, enum presence is not proof of complete platform mapping. In Flutter 3.41.2, the framework's debug role checker marks SemanticsRole.comboBox as not yet implemented, and the web engine falls back to property-derived behavior for that role. The Flutter source points to [flutter/flutter#159741](https://github.com/flutter/flutter/issues/159741). Therefore: + +- include the role only after a focused prototype on every supported target; +- assert the text-field, expanded, value, action, and controlled-node properties independently; +- inspect the actual web accessibility tree; +- record real VoiceOver and TalkBack output; +- do not describe Combobox accessibility as complete based only on a widget semantics-tree assertion. + +For SemanticsRole.status and SemanticsRole.alert, Flutter's role checker expects the role to supply live-region meaning. Do not also set liveRegion: true on the same node. Test that no debug semantics exception occurs. + +## 11. Universal keyboard and focus rules + +### 11.1 Test actual outcomes + +A keyboard test is valid only if it proves the result of the key: + +- the expected node has primary focus; +- a value changed exactly once; +- an overlay opened or closed; +- the correct item became highlighted; +- focus returned to the correct invoker. + +Sending a key without asserting the outcome is not coverage. + +### 11.2 Use Flutter's focus system + +- Prefer Shortcuts and Actions with component-specific intents. +- Use FocusTraversalGroup for an intentional composite boundary. +- Keep nodes long-lived and dispose owned nodes. +- Account for Flutter focus changes applying after a frame; pump once before asserting. +- Never create a new FocusNode in build. +- Do not intercept standard text-editing keys in editable controls. +- Test both logical keyboard keys and the resulting state, not raw platform key codes. + +### 11.3 Directionality + +For horizontal composites: + +- in LTR, Right moves to the next logical item and Left to the previous; +- in RTL, the visual-direction behavior must be explicitly decided and tested; the recommendation is Right moves visually right and Left visually left; +- Up/Down behavior follows component orientation; +- disabled items are skipped; +- Home and End move to first and last enabled items; +- wrapping occurs only when loop is true. + +### 11.4 Focus restoration + +Overlay components retain an invoker reference at open time. On close: + +- keyboard-opened overlays restore focus to that invoker; +- focus is not restored to a disposed or no-longer-focusable node; +- replacing or removing the trigger while open must not throw; +- nested overlays restore to the immediate parent invoker, not an unrelated earlier node. + +## 12. Verification architecture + +No single test layer is sufficient. Each layer below answers a different question and is required unless the component-specific section explicitly marks it not applicable. + +### 12.1 Required test layers + +| Layer | Location | What it proves | What it does not prove | +|---|---|---|---| +| Logic/widget | packages/naked_ui/test/src | State transitions, callbacks, controller ownership, timers, rebuilds, disposal, gestures | Actual platform accessibility output or final styled appearance | +| Semantics | packages/naked_ui/test/semantics | Flutter semantics-tree role, flags, labels, values, actions, grouping, transitions | What a particular screen reader actually speaks | +| Parity where useful | packages/naked_ui/test/src/parity | Intended equivalence with a Flutter control | Correctness when Flutter has no equivalent or when the desired pattern differs | +| Canonical example widget | packages/example/test | Representative styled layout, accessibility guidelines, deterministic goldens | Every possible consumer style | +| Real integration | packages/example/integration_test/components | End-to-end input, focus, overlay, timer, and route behavior on a target | Pixel stability across every machine | +| Raw screenshot artifact | CI artifact per target | Human-reviewable evidence of actual rendered states and collision handling | Semantics or keyboard correctness | +| Manual assistive technology | PR evidence record | Actual announcements and navigation with VoiceOver/TalkBack/web AT | Automated regression coverage | +| Remix consumer tests | Remix repository | Actual theme styling and package integration | Naked UI's standalone minimum-version compatibility | + +### 12.2 Proposed file names + +Add these source and test files following current project conventions: + +| Component | Source | Widget tests | Semantics tests | Integration | +|---|---|---|---|---| +| Alert Dialog | Extend naked_dialog.dart | Extend naked_dialog_test.dart and parity test | Extend naked_dialog_semantics_test.dart | Extend naked_dialog_integration.dart | +| Context Menu | naked_context_menu.dart | naked_context_menu_test.dart | naked_context_menu_semantics_test.dart | naked_context_menu_integration.dart | +| Toggle Group | Extend naked_toggle.dart | Extend naked_toggle_test.dart | Extend naked_toggle_semantics_test.dart | Extend naked_toggle_integration.dart | +| Toast | naked_toast.dart | naked_toast_test.dart | naked_toast_semantics_test.dart | naked_toast_integration.dart | +| Field | naked_field.dart; integrate naked_textfield.dart | naked_field_test.dart plus text-field regressions | naked_field_semantics_test.dart plus text-field regressions | naked_field_integration.dart | +| Combobox | naked_combobox.dart | naked_combobox_test.dart | naked_combobox_semantics_test.dart | naked_combobox_integration.dart | +| Hover Card | naked_hover_card.dart | naked_hover_card_test.dart | naked_hover_card_semantics_test.dart | naked_hover_card_integration.dart | +| Link | naked_link.dart | naked_link_test.dart | naked_link_semantics_test.dart | naked_link_integration.dart | + +Export every accepted public API from packages/naked_ui/lib/src/naked_widgets.dart. Add the integration test main to packages/example/integration_test/all_tests.dart. A test file that is not included in the aggregate runner is not delivered. + +### 12.3 Semantics test standard + +Every semantics test must: + +1. Call tester.ensureSemantics() and dispose the handle with teardown-safe cleanup. +2. Locate the intended node by semantics properties or a stable fixture finder, not by fragile tree depth. +3. Assert the exact accessible name, role/flags, value/state, and action set relevant to the case. +4. Assert that disabled controls do not expose activation actions. +5. Assert state changes after pointer, keyboard, and direct semantic actions. +6. Assert there is one authoritative control node and no accidental nested duplicate of the same control. +7. Call tester.takeException() after role-sensitive builds and expect no exception. +8. Test excludeSemantics if the component exposes it. +9. Include a non-English name and an RTL fixture where direction or generated text matters. +10. Avoid dumping a whole semantics tree as the only assertion; a string snapshot can stay green while important flags change. + +Use matchesSemantics when exact matching improves clarity, and direct SemanticsData assertions for newer fields such as role, controlled-node identifiers, validation result, or link URL. + +### 12.4 Keyboard test standard + +Do not retain the current catch-and-return-false helper. Replace it with helpers that throw on failure and assert postconditions. A representative helper should: + +1. request focus through a known FocusNode, rather than tapping unless the test is specifically about pointer focus; +2. pump one frame and assert that node is primary focus; +3. send one full logical key event; +4. pump only the duration required by the behavior; +5. assert focus, value, overlay, or callback count; +6. repeat for disabled state and assert no change. + +For composite widgets, assert every step in the traversal, including skipped disabled items, Home/End, looping off, looping on, dynamic removal, and RTL. + +### 12.5 Accessibility-guideline boundary + +Flutter provides automated guidelines for: + +- Android 48-by-48 logical-pixel tap targets; +- iOS 44-by-44 logical-pixel tap targets; +- labels on tappable targets; +- text contrast. + +Run all applicable guidelines on each canonical styled example. Do not run them only against invisible/headless wrappers and call the package accessible. Naked UI cannot guarantee a consumer's target size or color contrast because it does not own size or color. The release claim should be: + +> Naked UI supplies the behavior and semantics contract; the canonical example passes Flutter guidelines; every styled consumer must repeat target-size and contrast checks. + +### 12.6 Golden and screenshot standard + +- Goldens run in a pinned host environment with fixed surface size, device-pixel ratio, locale, text direction, text scale, fonts, and animation state. +- Real-device screenshots are artifacts, not cross-platform pixel goldens. +- Every screenshot has a deterministic name of the form component__scenario__platform__theme.png. +- CI uploads current images and diffs when a golden fails. +- Updating a golden requires a reviewer to inspect the image, not merely run --update-goldens. +- Screenshots must not contain network images, current times, random IDs, blinking cursors, or in-progress indeterminate animations. + +### 12.7 Manual assistive-technology standard + +For each PR, test at least: + +- macOS VoiceOver on the real macOS example; +- Android TalkBack on the emulator or device used for integration; +- Chrome accessibility tree plus keyboard navigation on web. + +Before a release consumed by Remix, also test iOS VoiceOver and one Windows web screen-reader/browser combination when the project claims those targets. Record: + +- device/OS/browser and Flutter version; +- screen reader and version; +- exact action taken; +- actual announcement summarized in the tester's own words; +- expected result; +- pass/fail and linked issue for any deviation. + +Do not include long verbatim screen-reader transcripts when a concise outcome is sufficient. + +## 13. Component contract: Alert Dialog + +### 13.1 Why it is needed + +Remix can display a general dialog today, but a destructive or urgent confirmation has a distinct accessibility role and safer dismissal defaults. WAI-ARIA treats Alert Dialog as a modal dialog containing an alert message whose content and controls must be identified. Flutter 3.41.2 provides SemanticsRole.alertDialog, and Flutter's own Material and Cupertino alert dialogs use it. + +### 13.2 Confirmed reusable foundation + +Current NakedDialog already: + +- wraps content with dialog semantics; +- scopes and names a modal route; +- blocks background semantics; +- uses a closed-loop traversal edge by default; +- supports a barrier, root navigator selection, transitions, request-focus behavior, and focus restoration through routing; +- installs Escape dismissal only when the route is barrier-dismissible. + +The current limitation is that NakedDialog hard-codes SemanticsRole.dialog, while showNakedDialog defaults barrierDismissible to true. + +### 13.3 Proposed public API + +Keep the existing API source-compatible and add: + +~~~dart +const NakedDialog({ + Key? key, + required Widget child, + bool modal = true, + String? semanticLabel, + bool excludeSemantics = false, + SemanticsRole semanticsRole = SemanticsRole.dialog, +}); +~~~ + +Assert that semanticsRole is dialog or alertDialog. A generic arbitrary-role escape hatch would weaken the contract. + +Add a convenience helper: + +~~~dart +Future showNakedAlertDialog({ + required BuildContext context, + required WidgetBuilder builder, + required Color barrierColor, + required String semanticLabel, + String? barrierLabel, + bool barrierDismissible = false, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, + Duration transitionDuration = const Duration(milliseconds: 400), + RouteTransitionsBuilder? transitionBuilder, + bool requestFocus = true, + FocusNode? initialFocusNode, +}); +~~~ + +The helper wraps the widget returned by builder in one NakedDialog with SemanticsRole.alertDialog, the required localized semanticLabel, and nondismissible defaults. The builder returns the visual dialog contents and must not add a second NakedDialog. Public naming is proposed, but the single role node and default dismissal behavior are required. + +### 13.4 Behavioral contract + +- The background is inert to pointer, keyboard, and semantics navigation while modal. +- Focus moves inside after the route opens. +- If initialFocusNode is provided and remains focusable, it receives focus. +- Otherwise, normal route focus selects the first focusable descendant; the example must demonstrate an explicit safe target. +- For irreversible actions, the canonical example initially focuses the least destructive action. +- For a long or structurally rich message, the consumer may focus a non-action semantic container at the beginning of the message. +- Focus cannot escape with Tab or Shift+Tab. +- Closing returns focus to the invoking control when it still exists and is focusable. +- The alert helper does not close from outside tap or Escape by default. +- A consumer that deliberately enables implicit dismissal must provide a safe equivalent cancel path and test it. +- Nested alert dialogs are discouraged; if allowed, restoration follows route stack order. + +### 13.5 Semantics contract + +| Field | Required behavior | +|---|---| +| Role | SemanticsRole.alertDialog | +| Name | Visible title represented by semanticLabel, or another tested route naming strategy | +| Message | Readable as content after the dialog name; not collapsed into an unusably long control name | +| Modal state | scopesRoute, namesRoute, and BlockSemantics when modal | +| Children | Explicit child nodes so message and actions remain navigable | +| Actions | Dialog container has no fake tap action; buttons own their actions | +| Exclusion | excludeSemantics removes the dialog subtree and is documented as an advanced escape hatch | + +### 13.6 Required widget and semantics tests + +- Existing unnamed NakedDialog still defaults to the normal dialog role. +- semanticsRole: alertDialog exposes exactly that role. +- Any other role fails a debug assertion. +- The alert helper defaults to a nondismissible barrier. +- Outside tap and Escape do not close the default alert helper. +- Explicit action closes with its result. +- Optional dismissible configuration closes exactly once. +- Initial focus enters the supplied safe action after one frame. +- Tab and Shift+Tab loop inside. +- Focus returns to the invoker after every supported close path. +- Removing the invoker before close does not throw. +- Background semantics are blocked while open and restored after close. +- Title, message, Cancel, and destructive action appear as separate useful semantics nodes. +- Large message content can receive initial focus without being exposed as a button. +- The excluded-semantics case has no alert-dialog node. + +### 13.7 Integration and screenshot scenarios + +Stable fixture keys: + +- alert-dialog.open +- alert-dialog.title +- alert-dialog.message +- alert-dialog.cancel +- alert-dialog.confirm +- alert-dialog.result + +Integration scenarios: + +1. Open by keyboard, verify alert role in semantics, verify Cancel focus, Tab loop, cancel, and invoker restoration. +2. Open by pointer, tap barrier, press Escape, and verify still open. +3. Activate destructive action and verify one callback plus visible result. +4. Remove the invoker while open, close programmatically, and verify no exception. +5. Render a long message at 200% text scale and verify content scroll/focus access. + +Required screenshots: + +- alert_dialog__open_safe_focus__macos__reference.png +- alert_dialog__destructive_action__android__reference.png +- alert_dialog__long_message_200_text__macos__reference.png +- alert_dialog__rtl__web__reference.png + +Manual checks must confirm that the dialog is announced as an alert dialog, background content is not reachable, the title/message are discoverable, the initial focus is sensible, and focus returns after close. + +### 13.8 Alert Dialog acceptance + +- [ ] Existing dialog API remains source-compatible. +- [ ] Default normal dialog semantics do not change. +- [ ] Alert role and safe dismissal defaults are covered. +- [ ] Focus containment/restoration pass on macOS and Android. +- [ ] Long text and RTL screenshots are reviewed. +- [ ] VoiceOver and TalkBack evidence is attached. +- [ ] Example and API docs explain when Alert Dialog is appropriate. + +## 14. Component contract: Toggle Group + +### 14.1 Why it is needed + +Remix needs segmented/content-switcher and grouped-toggle behavior. Current Naked UI main has a useful single-select scope, but it does not own composite keyboard traversal, orientation, looping, multiple selection, a group label, or a group focus policy. Its semantics coverage currently verifies rendering but does not assert a complete group contract. + +### 14.2 Confirmed current behavior + +The current unnamed NakedToggleGroup: + +- is controlled by selectedValue and onChanged; +- is effectively disabled if enabled is false or onChanged is null; +- never clears the selected item by activating it again; +- exposes each option as a semantic button with selected state; +- gives each option independent focusability and Enter/Space activation; +- has no orientation, Home/End, arrow-key, loop, RTL, or multiple-selection contract. + +### 14.3 Proposed compatible API + +Preserve the current unnamed constructor and defaults, then add composite behavior: + +~~~dart +const NakedToggleGroup({ + Key? key, + required Widget child, + required T? selectedValue, + ValueChanged? onChanged, + bool enabled = true, + Axis orientation = Axis.horizontal, + bool loop = true, + bool allowEmptySelection = false, + String? semanticLabel, + bool excludeSemantics = false, +}); + +const NakedToggleGroup.multiple({ + Key? key, + required Widget child, + required Set selectedValues, + ValueChanged>? onValuesChanged, + bool enabled = true, + Axis orientation = Axis.horizontal, + bool loop = true, + String? semanticLabel, + bool excludeSemantics = false, +}); +~~~ + +The internal representation may differ, but constructor invariants must prevent single and multiple callbacks/values from being mixed. + +Keep NakedToggleOption source-compatible. Its existing optional focus node, builder, callbacks, semantic label, enabled state, and exclusion behavior remain. + +### 14.4 Selection contract + +Single mode: + +- Activating an unselected option emits that value once. +- Activating the selected option does nothing when allowEmptySelection is false, preserving current behavior. +- Activating the selected option emits null when allowEmptySelection is true. +- The group does not mutate the controlled value before its parent rebuilds. + +Multiple mode: + +- Activating an unselected option emits an immutable set containing it. +- Activating a selected option emits an immutable set without it. +- Input sets are never mutated. +- Equality is based on set content, not identity. +- Disabled options remain present in the selected-values view if supplied by the parent but cannot be changed by the user. + +### 14.5 Composite focus and keyboard contract + +- The group contributes one stop to page Tab order. +- Initial roving target is the most recently focused enabled option, then the selected enabled option in single mode, then the first enabled option. +- Tab enters at the roving target; a second Tab leaves the group. +- Arrow keys move focus without changing selection. +- Horizontal groups use Left/Right; vertical groups use Up/Down. +- Home and End move to first and last enabled options. +- Disabled options are skipped. +- When loop is true, movement wraps; when false, focus stays at the edge. +- Enter and Space toggle the focused option. +- Dynamic insert, remove, disable, or reorder retains a valid roving target without throwing. +- If every option is disabled, the group has no Tab stop. +- RTL horizontal behavior is visual-direction based and explicitly tested. + +### 14.6 Semantics decision + +**Recommendation:** model options as toggle buttons using button: true plus toggled, not as radio buttons. This matches the Radix/Base Toggle Group model and supports both single and multiple selection. A product requiring radio semantics should use NakedRadioGroup. + +Changing current option semantics from selected to toggled can change announcements and is therefore a behavioral compatibility change even though it is not a Dart source break. Resolve it explicitly: + +- preferred: migrate to toggled, document in the changelog, and add an announcement-focused release note; +- fallback: retain selected for the existing constructor and use toggled for a new mode, at the cost of inconsistent semantics. + +Do not expose both selected and toggled on the same option merely to satisfy old tests. + +Group semantics: + +- container with explicit child nodes; +- optional caller-provided label; +- no radioGroup role for toggle-button mode; +- group disabled state propagates to every option; +- each option has one accessible name, toggled state, enabled state, focusability, and tap action only when enabled. + +### 14.7 Required widget and semantics tests + +- All current single-select behavior remains green. +- Single allow-empty false and true paths. +- Multiple add/remove, immutable output, and input non-mutation. +- Null callbacks and disabled group suppress all actions. +- Disabled item suppresses its action without disabling siblings. +- One Tab stop, entry target, exit behavior, arrows, Home, End, loop on/off. +- LTR horizontal, RTL horizontal, and vertical traversal. +- Dynamic addition, reorder, disable, and removal of the current roving item. +- Enter and Space each emit exactly one change. +- Arrow movement emits no selection change. +- Pointer selection updates the controlled value only after the parent rebuild. +- Builder and state scope expose selected/toggled, focused, hovered, pressed, and disabled state accurately. +- Group label and option names are present exactly once. +- Option semantics expose the accepted toggled/selected decision and no action when disabled. +- Group exclusion removes all group semantics. +- No debug role or merged-semantics exception occurs. + +### 14.8 Integration and screenshot scenarios + +Stable fixture keys: + +- toggle-group.root +- toggle-group.option.bold +- toggle-group.option.italic +- toggle-group.option.underline +- toggle-group.value + +Integration scenarios: + +1. Tab into a horizontal group, traverse with arrows, toggle with Space, and Tab out. +2. Repeat with RTL and assert visual-direction focus. +3. Traverse a vertical group with a disabled middle item and loop disabled. +4. Use multiple mode and verify two independent selections. +5. Remove the focused option during rebuild and verify a valid neighbor receives the roving target. +6. Invoke the semantic tap action and verify the same callback contract. + +Required screenshots: + +- toggle_group__single_selected_focus__macos__reference.png +- toggle_group__multiple_selected__android__reference.png +- toggle_group__disabled_option__macos__reference.png +- toggle_group__vertical__web__reference.png +- toggle_group__rtl_focus__web__reference.png +- toggle_group__200_text__macos__reference.png + +Manual checks confirm that the group label is announced once, each option is identified as a toggle button with correct state, only one page Tab stop is used, and arrows do not unexpectedly toggle values. + +### 14.9 Toggle Group acceptance + +- [ ] Current unnamed constructor compiles unchanged. +- [ ] Single and multiple modes have explicit invariants. +- [ ] Roving focus passes LTR, RTL, vertical, disabled, and dynamic-child cases. +- [ ] The semantics compatibility decision is recorded in the changelog. +- [ ] Canonical example passes label and target-size guidelines. +- [ ] All six screenshot states are reviewed. +- [ ] VoiceOver, TalkBack, and web keyboard results are attached. + +## 15. Component contract: Context Menu + +### 15.1 Why it is needed + +A context menu is not merely a menu placed under a different icon. It opens from a secondary pointer action, touch long press, or keyboard context-menu command and is positioned at either the invocation point or the focused trigger. Keyboard users must receive the same actions and focus behavior as pointer users. + +### 15.2 Confirmed reusable foundation + +Current NakedMenu already supplies: + +- MenuController lifecycle; +- menu and menu-item semantics roles; +- enabled and disabled item behavior; +- anchored overlay plumbing and outside-click close; +- selection callbacks and close-on-activate behavior; +- trigger focus restoration; +- item builders and state scopes. + +Current NakedMenu is trigger-button oriented. It always builds a NakedButton trigger and does not expose secondary-tap, long-press, Shift+F10, Context Menu key, or point-anchor behavior. A new trigger primitive should reuse its items and internal menu scope rather than copy the item implementation. + +### 15.3 Proposed public API + +~~~dart +enum NakedContextMenuTriggerKind { + mouse, + touch, + keyboard, + programmatic, +} + +class NakedContextMenuState extends NakedState { + NakedContextMenuState({ + required super.states, + required this.isOpen, + required this.anchorPosition, + required this.triggerKind, + }); + + final bool isOpen; + + // Local to the trigger coordinate space used by MenuController.open. + final Offset? anchorPosition; + final NakedContextMenuTriggerKind? triggerKind; +} + +const NakedContextMenu({ + Key? key, + Widget? child, + ValueWidgetBuilder? builder, + required RawMenuAnchorOverlayBuilder overlayBuilder, + required MenuController controller, + ValueChanged? onSelected, + VoidCallback? onOpen, + VoidCallback? onClose, + VoidCallback? onCanceled, + RawMenuAnchorOpenRequestedCallback? onOpenRequested, + RawMenuAnchorCloseRequestedCallback? onCloseRequested, + bool enabled = true, + bool openOnSecondaryTap = true, + bool openOnLongPress = true, + bool openOnKeyboard = true, + bool consumeOutsideTaps = true, + bool useRootOverlay = false, + bool closeOnClickOutside = true, + bool loopFocus = true, + FocusNode? triggerFocusNode, + OverlayPositionConfig positioning = const OverlayPositionConfig(), + String? semanticLabel, + bool excludeSemantics = false, +}); +~~~ + +Retain NakedMenuItem as the item type for both normal and context menus. Achieve this by extracting the current private menu scope into shared internal infrastructure. Avoid a second public item class with subtly different semantics. + +Programmatic opening can use MenuController.open(position: ...). If the team decides a typed controller is necessary to expose trigger kind, add it before release rather than leaking mutable state through globals. + +### 15.4 Trigger and positioning behavior + +- Secondary tap records the local invocation point and opens there. +- Touch long press opens at the press point using Flutter's platform gesture timing. +- Shift+F10 and LogicalKeyboardKey.contextMenu open from the focused trigger. +- A keyboard anchor uses the trigger's lower logical start edge and respects RTL. +- Programmatic open may supply a point; without one it uses the trigger anchor. +- The overlay clamps or flips so the full menu remains in the safe visible bounds when possible. +- Opening from one method must not also fire primary tap behavior on the child. +- Repeated open requests while already open do not duplicate callbacks or overlays. +- If enabled is false, no gesture, shortcut, or semantic action opens the menu. +- Pointer position is cleared after close so the next keyboard open does not reuse stale coordinates. + +### 15.5 Focus and keyboard behavior + +- The trigger can participate in focus traversal without being forced to expose button semantics. +- On open, focus moves to the first enabled menu item. +- Down/Right behavior for nested submenus is out of initial scope; do not imply submenu support. +- Up/Down move through enabled items. +- Home/End move to first/last enabled items. +- Enter/Space activate the focused item. +- Escape closes without selection and restores trigger focus. +- Tab closes the menu and continues normal page traversal; it does not make every menu item a page Tab stop. +- When loopFocus is true, arrows wrap; otherwise focus remains at the edge. +- Removing the active item moves focus to a valid neighbor or the menu container. + +### 15.6 Semantics contract + +Trigger: + +- preserve the child's native role; a text-editing region, image, link, or custom surface must not become a fake button automatically; +- expose caller semanticLabel only when provided; +- provide an enabled long-press semantic action when long-press opening is enabled; +- do not expose any opening action when disabled; +- do not duplicate the child's label when semanticLabel overrides it. + +Popup: + +- one SemanticsRole.menu container with explicit child nodes; +- initial-scope action items use SemanticsRole.menuItem; +- disabled items remain discoverable but have no tap action; +- do not simulate checked/radio behavior with a plain action item because it would expose the wrong role; +- separators and decorative icons are excluded from semantics. + +### 15.7 Required widget and semantics tests + +- Secondary mouse tap opens once at the captured point. +- Primary tap does not open unless the child itself owns that behavior. +- Touch long press opens once and does not invoke primary tap. +- Shift+F10 and Context Menu key open from focus. +- Each disabled/open-method flag suppresses only its corresponding path. +- Builder state exposes open, trigger kind, anchor point, hover, focus, and disabled accurately. +- Opening near all four viewport edges stays in bounds. +- RTL keyboard anchor is at logical start. +- Focus enters first enabled item, skips disabled items, supports Home/End and loop setting. +- Escape, outside pointer, item activation, and programmatic close each emit correct close/cancel/selection callbacks exactly once. +- Focus restoration survives trigger rebuild and safely handles trigger removal. +- Existing NakedMenuItem works under both menu scopes. +- Trigger does not gain button semantics. +- Popup and item roles are exact; disabled item action is absent. +- Semantic long press opens the menu. +- excludeSemantics behavior is explicit and tested. + +### 15.8 Integration and screenshot scenarios + +Stable fixture keys: + +- context-menu.trigger +- context-menu.item.copy +- context-menu.item.rename +- context-menu.item.delete +- context-menu.item.disabled +- context-menu.selection + +Integration scenarios: + +1. Right-click the center, assert open point, focus first item, select, and verify callback. +2. Open near each corner and assert the overlay rectangle remains in the visible surface. +3. Long-press on Android, navigate to an action, and dismiss outside. +4. Focus the trigger, press Shift+F10 and Context Menu key in separate tests, navigate with arrows, Escape, and verify restoration. +5. Verify a disabled context menu never opens by pointer, keyboard, or semantic action. +6. Verify a disabled item is announced but skipped by focus and cannot activate. + +Required screenshots: + +- context_menu__pointer_center__macos__reference.png +- context_menu__collision_top_left__macos__reference.png +- context_menu__collision_bottom_right__web__reference.png +- context_menu__long_press__android__reference.png +- context_menu__keyboard_focus__macos__reference.png +- context_menu__rtl_anchor__web__reference.png + +Manual checks confirm that keyboard and touch users can discover and open the same menu, menu role and item states are announced, focus enters and returns predictably, and the trigger retains its original role. + +### 15.9 Context Menu non-goals + +- Nested submenus and menubars. +- Checkbox and radio menu-item variants in the first Context Menu release. Add shared menuItemCheckbox/menuItemRadio APIs deliberately before exposing those behaviors. +- OS-native process menus outside Flutter's semantics/focus tree. +- Arbitrary business commands or clipboard behavior. +- Product-specific icons, separators, destructive styling, or shortcut labels. + +### 15.10 Context Menu acceptance + +- [ ] Existing Menu behavior and item API remain compatible. +- [ ] All four open paths have direct tests. +- [ ] Point anchoring and four-edge collision behavior pass. +- [ ] Trigger semantics are not coerced to button. +- [ ] Focus entry, navigation, close, and restoration pass on real macOS. +- [ ] Long press passes on Android. +- [ ] All screenshot states and AT evidence are attached. + +## 16. Component contract: Toast + +### 16.1 Why it is needed + +Remix's inline Callout cannot replace transient application feedback. Toast must coordinate a queue, visible limit, automatic dismissal, pause/resume, announcements, optional actions, focus access, and app lifecycle. These behaviors are cross-theme and should not be reimplemented by every styled package. + +Toast is appropriate for a status update that does not require an immediate response. If the user must respond before continuing, use Alert Dialog. A toast action must be safe to ignore because the message may time out. + +### 16.2 Proposed public model + +The final names may be refined, but the recommended separation is: + +~~~dart +enum NakedToastPriority { + polite, + assertive, +} + +enum NakedToastDismissReason { + timeout, + action, + close, + swipe, + programmatic, + overflow, +} + +@immutable +class NakedToastEntry { + const NakedToastEntry({ + required this.id, + required this.payload, + required this.semanticLabel, + this.duration, + this.priority = NakedToastPriority.polite, + }); + + final Object id; + final T payload; + final String semanticLabel; + final Duration? duration; + final NakedToastPriority priority; +} + +class NakedToastState extends NakedState { + NakedToastState({ + required super.states, + required this.entry, + required this.isVisible, + required this.isPaused, + required this.remainingDuration, + }); + + final NakedToastEntry entry; + final bool isVisible; + final bool isPaused; + final Duration? remainingDuration; +} + +@immutable +class NakedToastDismissed { + const NakedToastDismissed({ + required this.entry, + required this.reason, + }); + + final NakedToastEntry entry; + final NakedToastDismissReason reason; +} + +abstract interface class NakedToastHandle { + Object get id; + bool get isActive; + void dismiss({ + NakedToastDismissReason reason = NakedToastDismissReason.programmatic, + }); +} + +typedef NakedToastItemBuilder = + Widget Function(BuildContext context, NakedToastState state); + +class NakedToastController extends ChangeNotifier { + NakedToastHandle show(NakedToastEntry entry); + void dismiss( + Object id, { + NakedToastDismissReason reason = NakedToastDismissReason.programmatic, + }); + void clear({ + NakedToastDismissReason reason = NakedToastDismissReason.programmatic, + }); +} + +const NakedToastViewport({ + Key? key, + required NakedToastController controller, + required NakedToastItemBuilder itemBuilder, + Duration defaultDuration = const Duration(seconds: 5), + int maxVisible = 3, + int? maxQueued, + bool pauseOnHover = true, + bool pauseOnFocus = true, + bool pauseWhenAppInactive = true, + SingleActivator? focusShortcut, + FocusNode? focusNode, + ValueChanged>? onDismissed, + bool excludeSemantics = false, +}); +~~~ + +Also provide scoped item helpers, or an equivalent structure, so consumers can mark: + +- the visible title/description that duplicates semanticLabel; +- an optional safe action; +- an explicit close control. + +The implementation must be able to exclude duplicate message text while retaining action and close semantics. A single ExcludeSemantics around the entire visual toast is not acceptable because it would hide the controls. + +### 16.3 Controller and queue invariants + +- IDs are unique among active and queued entries. Duplicate IDs either replace atomically through a documented API or throw a debug assertion; silent duplication is forbidden. +- show returns a handle tied to one entry. +- A handle dismisses at most once and becomes inert after removal. +- Visible entries are ordered deterministically. Recommended default: oldest visible first and new entries appended at the logical end. +- Entries beyond maxVisible wait in FIFO order and have no semantics node yet. +- A queued entry's timeout starts only when it becomes visible. +- maxVisible must be greater than zero. +- If maxQueued is supplied, overflow behavior and dismissal reason are deterministic and documented. +- Controller listeners are detached on viewport disposal; externally owned controllers are not disposed. +- Multiple viewports attached to the same controller are either explicitly supported with defined semantics or rejected. Recommendation: reject to prevent duplicate announcements. + +### 16.4 Timer and lifecycle contract + +- Null duration means persistent until action, close, or programmatic dismissal. +- Otherwise, auto-dismiss uses the entry duration or viewport default. +- Hover anywhere within the viewport pauses all visible timers when enabled. +- Keyboard focus anywhere within the viewport pauses all visible timers when enabled. +- App lifecycle states other than resumed pause timers when enabled. +- Pause stores remaining time; resume does not restart the full duration. +- Nested pause reasons are reference-safe: leaving hover does not resume while focus or app lifecycle is still paused. +- Removing an entry cancels its timer. +- Disposal cancels every timer and observer. +- A duration that expires during a visual exit transition produces one dismissal callback, not two. +- Tests use targeted pump durations; they never wait in real time. + +### 16.5 Focus and keyboard contract + +- Showing a toast never moves keyboard focus. +- Normal page Tab traversal does not unexpectedly jump to a newly inserted toast. +- If the application supplies focusShortcut, invoking it stores current focus and moves focus to the newest visible toast or its first actionable control. +- Within the viewport, Tab reaches action and close controls in visual order. +- Escape while focus is in the viewport dismisses the current toast when that behavior is documented, then returns focus to the stored prior node. +- If no prior node remains focusable, normal traversal continues without throwing. +- An auto-dismiss that occurs while one of its controls has focus is prevented by the focus pause. +- The canonical example may use F8, following common web primitive behavior, but the package must not reserve a global key without caller opt-in. + +### 16.6 Announcement semantics + +Normal status: + +- role: SemanticsRole.status; +- concise caller-provided semanticLabel; +- no liveRegion: true on the same node; +- no focus steal. + +Urgent status: + +- role: SemanticsRole.alert; +- reserved for important, time-sensitive information; +- no liveRegion: true on the same node; +- still does not steal keyboard focus. + +All messages: + +- are added to the semantics tree only when visible; +- are announced once per appearance, not on every timer tick or hover rebuild; +- do not concatenate every stacked toast into one changing announcement node; +- keep action and close controls as explicit accessible children; +- avoid duplicating title/description speech after the root semantic label; +- remove their status/alert node after dismissal; +- do not announce queued hidden entries; +- expose no swipe-only requirement because swipe is inaccessible to many users. + +### 16.7 Swipe dismissal + +Swipe is valuable but should not block the first reliable release if it would compromise semantics or deterministic timers. Recommended phasing: + +- initial release: action, close, timeout, and programmatic dismissal are required; +- follow-up: optional direction-aware swipe dismissal with observable drag state, threshold/velocity tests, cancel animation, and an equivalent close action. + +If swipe ships initially, include swipe dismissal reason and test touch, mouse drag if supported, RTL direction, below-threshold cancellation, and focused-control protection. + +### 16.8 Required widget and semantics tests + +- Show, queue, visible limit, FIFO promotion, dismiss, clear, and unique ID behavior. +- Per-entry duration, default duration, persistent duration, and zero/invalid assertions. +- Remaining-time preservation for hover, focus, lifecycle, and combined pause reasons. +- Exactly one callback and reason for every dismissal path. +- Controller replacement, viewport disposal, and post-dispose handle behavior. +- No focus movement when a toast appears. +- Opt-in shortcut focus and focus restoration. +- Auto-dismiss does not remove a focused toast. +- Status and alert roles are exact, with no live-region role conflict. +- Each semantic label appears once. +- Queued entries have no semantics. +- Action and close controls remain discoverable and actionable. +- Disabled actions have no tap action. +- Rebuilding visual state does not reannounce unchanged content. +- excludeSemantics has a documented use and does not accidentally leave unlabeled actions. + +### 16.9 Integration and screenshot scenarios + +Stable fixture keys: + +- toast.show.polite +- toast.show.assertive +- toast.show.action +- toast.viewport +- toast.item.first +- toast.action.undo +- toast.close +- toast.count + +Integration scenarios: + +1. Show a polite toast while a text field is focused; verify focus stays in the field and the status node appears once. +2. Advance exact time to just before and at expiry; verify one timeout dismissal. +3. Hover, advance beyond duration, verify retained; leave hover, advance remaining duration, verify dismissed. +4. Focus the action through the opt-in shortcut, verify timer pause, activate action, and restore prior focus. +5. Add more than maxVisible, verify only visible semantics, dismiss one, and verify queued promotion starts its timer. +6. Background/resume the app where the target permits lifecycle simulation and verify remaining time. +7. Show an assertive toast and verify alert role without focus movement. + +Required screenshots: + +- toast__single_polite__macos__reference.png +- toast__stacked_limit__macos__reference.png +- toast__action_focused_paused__web__reference.png +- toast__assertive__android__reference.png +- toast__safe_area__android__reference.png +- toast__200_text_wrap__macos__reference.png +- toast__rtl_stack__web__reference.png + +Manual checks confirm one announcement per visible toast, polite versus urgent priority as supported by the platform, no focus steal, discoverable action/close controls, and understandable behavior when several toasts arrive. + +### 16.10 Toast non-goals + +- Styling, placement tokens, icons, colors, elevation, and transition design. +- Required-response workflows. +- Persistence across application restarts. +- Network retries or business action semantics. +- Notification-center history. + +### 16.11 Toast acceptance + +- [ ] Queue, limit, timers, and all pause reasons are deterministic. +- [ ] Status and alert semantics pass without role-check exceptions. +- [ ] No show path steals focus. +- [ ] Action and close remain accessible without duplicate message speech. +- [ ] Real macOS and Android timing scenarios pass. +- [ ] All screenshot states are reviewed. +- [ ] VoiceOver/TalkBack evidence includes stacked and actionable cases. + +## 17. Component contract: Field + +### 17.1 Why it is needed + +A field is more than a decorated text input. It coordinates a visible label, description, control, required/read-only/disabled state, validation state, error visibility, label-to-control focus behavior, and error announcements. Without a headless field scope, each Remix input variant can concatenate and announce these pieces differently. + +### 17.2 Confirmed reusable foundation and gap + +Current NakedTextField already exposes: + +- editable text, focus, hover, press, read-only, disabled, and error state; +- semanticLabel, semanticHint, and semanticErrorText; +- an enabled, focused, read-only, obscured, multiline, length-aware semantics node; +- a live-region flag when error and semanticErrorText are set. + +It does not currently expose isRequired or SemanticsValidationResult. It also requires every consumer to manually keep the visible label/description/error synchronized with semantics. The proposed Field should integrate with NakedTextField without breaking standalone text-field behavior. + +### 17.3 Proposed composition API + +The recommended first release supports one primary control per field: + +~~~dart +enum NakedFieldErrorAnnouncement { + none, + whenChanged, +} + +class NakedFieldState extends NakedState { + NakedFieldState({ + required super.states, + required this.label, + required this.description, + required this.errorText, + required this.isRequired, + required this.isEnabled, + required this.isReadOnly, + required this.isTouched, + required this.isDirty, + required this.isFocused, + required this.isFilled, + required this.validationResult, + }); + + final String label; + final String? description; + final String? errorText; + final bool isRequired; + final bool isEnabled; + final bool isReadOnly; + final bool isTouched; + final bool isDirty; + final bool isFocused; + final bool isFilled; + final SemanticsValidationResult validationResult; +} + +const NakedField({ + Key? key, + required String label, + String? description, + String? errorText, + bool isRequired = false, + bool enabled = true, + bool readOnly = false, + bool touched = false, + bool dirty = false, + SemanticsValidationResult validationResult = + SemanticsValidationResult.none, + NakedFieldErrorAnnouncement errorAnnouncement = + NakedFieldErrorAnnouncement.whenChanged, + Widget? child, + ValueWidgetBuilder? builder, + bool excludeSemantics = false, +}); + +const NakedFieldLabel({required Widget child}); +const NakedFieldDescription({required Widget child}); +const NakedFieldError({required Widget child}); + +const NakedFieldControl({ + Key? key, + required Widget child, + FocusNode? focusNode, + bool hasValue = false, + bool readOnly = false, +}); +~~~ + +Public names are proposed. Required behavior: + +- NakedField is the single source of semantic label, description, error, required state, and validation result. +- NakedFieldControl registers the primary focus target and applies field metadata to a non-text control. +- NakedTextField automatically reads the nearest field scope and registers itself, so consumers do not double-wrap it. +- label, description, and error visual helpers receive state through the scope and prevent unintended duplicate semantics. +- debug mode reports multiple registered primary controls in one field unless a future group mode explicitly supports them. + +### 17.4 State ownership + +- The application controls touched, dirty, validationResult, and when errorText becomes visible. Naked UI does not invent business validation timing. +- The primary control reports focused and filled state to the scope. +- Explicit control-level disabled and read-only state must not contradict the field. Effective disabled is the stricter state. +- The field builder receives a new immutable snapshot only when an observable state changes. +- Empty error text is normalized to no visible error. +- validationResult.invalid without an error message is allowed for semantics but documented as poor user experience. +- A non-null visible error with validationResult.valid is a debug assertion because the states conflict. + +### 17.5 Label, description, and error behavior + +- Tapping the visible label requests focus on the registered enabled control. +- Label tap does not open a keyboard for a disabled or non-focusable control. +- The label text is the control's accessible name exactly once. +- Description text is included in the control hint/description exactly once. +- Current error text is associated with the control so it is discoverable whenever the user returns to the field. +- When a mounted field transitions to a new non-empty visible error and announcement policy is whenChanged, the error is exposed as SemanticsRole.alert without also setting liveRegion: true. +- An unchanged error does not reannounce on unrelated rebuilds. +- Clearing and later reintroducing the same error is a new transition and may announce again. +- Initial invalid content is discoverable on control focus; whether it announces immediately must be consistent and documented. Recommendation: do not produce a surprise assertive announcement on first build. +- Error color or icon is never the only validation indicator; the canonical example includes text. + +### 17.6 NakedTextField integration and compatibility + +Inside a field scope, the text field applies: + +- field label as the effective semantic label; +- description plus current error as effective semantic hint; +- isRequired from the field; +- validationResult from the field; +- effective disabled/read-only state; +- field-managed error announcement rather than its existing live-region path. + +Outside a field scope, all current NakedTextField parameters and semantics remain unchanged. + +If a consumer supplies both field metadata and explicit text-field semantic label/hint/error: + +- recommended behavior is a debug assertion when values conflict; +- identical values may be accepted; +- do not concatenate two labels or two copies of the error. + +This precedence rule must be decided and covered before implementation. + +### 17.7 Semantics contract + +Primary control: + +- retains its native role and actions; +- accessible name equals field label; +- hint includes description and current error in a stable order; +- isRequired is true/false when the field declares required state; +- validationResult is none, valid, or invalid as controlled; +- enabled, read-only, focused, value, and actions remain accurate; +- disabled controls expose no focus/tap/edit action that can change the value. + +Visual helpers: + +- label is not a second unrelated text node if it would duplicate the control name; +- description is not duplicated after being associated with the control; +- error remains readable and can become a single alert transition; +- required and error icons are decorative unless they add nonduplicative meaning. + +### 17.8 Required widget and semantics tests + +- Child and builder invariants plus state-scope lookup. +- Label tap focuses enabled control; disabled/missing control is safe. +- One-primary-control registration and dynamic control replacement. +- Focused and filled state updates. +- Controlled touched/dirty/validity changes and state equality/hash code. +- Effective enabled and read-only propagation. +- Required state true, false, and absent/default semantics. +- Validation result none, valid, and invalid semantics. +- Label, description, and error appear in the control semantics exactly once. +- New error announces once; unchanged rebuild does not; clear/re-add announces again. +- Initial invalid policy is exact and tested. +- Error alert role is never combined with liveRegion: true. +- Standalone NakedTextField retains existing semantics. +- Conflicting field/text-field semantic inputs follow the chosen assertion/precedence rule. +- Non-text controls through NakedFieldControl retain their role. +- Dynamic localization updates accessible text without losing focus. +- Exclusion behavior is documented and tested. + +### 17.9 Integration and screenshot scenarios + +Stable fixture keys: + +- field.email +- field.email.label +- field.email.control +- field.email.description +- field.email.error +- field.email.submit +- field.email.state + +Integration scenarios: + +1. Tap label, type a value, and verify focus, filled, touched/dirty output, and semantics value. +2. Submit invalid data, verify validation state and one error announcement node. +3. Rebuild without changing error and verify no duplicate announcement transition. +4. Correct the value, clear error, and verify valid state. +5. Verify disabled and read-only fields have distinct behavior and semantics. +6. Change locale and direction while focused; verify label/hint update without focus loss. +7. Use a non-text control fixture to prove the composition is not text-field-only. + +Required screenshots: + +- field__empty_required__macos__reference.png +- field__focused_filled__web__reference.png +- field__invalid_error__android__reference.png +- field__disabled_and_readonly__macos__reference.png +- field__long_error_200_text__macos__reference.png +- field__rtl__web__reference.png + +Manual checks confirm label/name, required state, value, hint, invalid state, error announcement timing, label activation, and the distinction between disabled and read-only. + +### 17.10 Field non-goals + +- Business validation rules or schema libraries. +- Form submission orchestration. +- Input formatting already owned by the control. +- Visual required markers, success icons, or error styling. +- Multi-control fieldsets in the first release; add a deliberate group contract later. + +### 17.11 Field acceptance + +- [ ] Standalone TextField behavior remains compatible. +- [ ] Field metadata reaches the primary control without duplicate speech. +- [ ] Required and validation semantics are asserted. +- [ ] Error transition policy is deterministic. +- [ ] Label focus works for text and non-text controls. +- [ ] Canonical styled examples pass label, target, and contrast guidelines. +- [ ] All screenshot and assistive-technology evidence is attached. + +## 18. Component contract: Combobox + +### 18.1 Why it is needed + +Combobox combines editable text, suggestions, selection, an anchored popup, dynamic filtering, keyboard highlight, focus retention, and assistive-technology announcements. It is not an editable skin over NakedSelect. Treating it as a Select would produce incorrect focus and text-editing behavior. + +This is the highest-risk item in the handoff. It should not ship until actual assistive-technology behavior is proven on the supported platforms. + +### 18.2 Confirmed reusable foundation + +- NakedTextField provides native editing, selection, IME, focus, and semantics foundations. +- NakedSelect and overlay utilities provide controller, positioning, collision, outside-dismissal, and option-state patterns. +- NakedStateScope provides builder state. +- The proposed Field supplies label, description, required, invalid, and error semantics. + +Do not subclass or silently change NakedSelect. Share private overlay/option infrastructure where behavior is truly common. + +### 18.3 Accessibility spike required before final API + +Before the full PR: + +1. Build a minimal editable input, popup list, and three options. +2. Test SemanticsRole.comboBox, text-field semantics, expanded, controlsNodes, and option nodes on Flutter 3.41.2. +3. Inspect the Chrome accessibility tree. +4. Exercise VoiceOver on macOS and TalkBack on Android while typing and moving the active option. +5. Compare strategies for announcing active-option changes: + - property/role mapping alone; + - a dedicated SemanticsRole.status announcer whose label changes; + - another platform-supported approach that does not move keyboard focus. +6. Record exact results and select the least duplicative strategy. + +If no strategy makes the active option, selection, expanded state, and errors understandable, block the component and link the Flutter engine limitation. Do not downgrade this to a documentation note after release. + +### 18.4 Proposed public API + +~~~dart +class NakedComboboxState extends NakedState { + NakedComboboxState({ + required super.states, + required this.isOpen, + required this.query, + required this.value, + required this.values, + required this.highlightedValue, + required this.enabledOptionCount, + required this.hasResults, + }); + + final bool isOpen; + final String query; + final T? value; + final Set values; + final T? highlightedValue; + final int enabledOptionCount; + final bool hasResults; +} + +class NakedComboboxOptionState extends NakedState { + NakedComboboxOptionState({ + required super.states, + required this.value, + required this.textValue, + required this.isSelected, + required this.isHighlighted, + }); + + final T value; + final String textValue; + final bool isSelected; + final bool isHighlighted; +} + +typedef NakedComboboxBuilder = + Widget Function( + BuildContext context, + NakedComboboxState state, + Widget editableText, + ); + +const NakedCombobox({ + Key? key, + required TextEditingController textController, + required MenuController menuController, + required String Function(T value) displayStringForOption, + required RawMenuAnchorOverlayBuilder overlayBuilder, + T? value, + ValueChanged? onChanged, + ValueChanged? onQueryChanged, + ValueChanged? onSubmitted, + FocusNode? focusNode, + bool enabled = true, + bool readOnly = false, + bool openOnInput = true, + bool openOnFocus = false, + bool closeOnSelect = true, + bool loopFocus = true, + OverlayPositionConfig positioning = const OverlayPositionConfig(), + required NakedComboboxBuilder builder, + String? semanticLabel, + String? semanticHint, + String? semanticErrorText, + bool excludeSemantics = false, +}); + +const NakedCombobox.multiple({ + Key? key, + required TextEditingController textController, + required MenuController menuController, + required String Function(T value) displayStringForOption, + required RawMenuAnchorOverlayBuilder overlayBuilder, + required Set values, + ValueChanged>? onValuesChanged, + ValueChanged? onQueryChanged, + FocusNode? focusNode, + bool enabled = true, + bool readOnly = false, + bool openOnInput = true, + bool openOnFocus = false, + bool clearQueryOnSelect = true, + bool loopFocus = true, + OverlayPositionConfig positioning = const OverlayPositionConfig(), + required NakedComboboxBuilder builder, + String? semanticLabel, + String? semanticHint, + String? semanticErrorText, + bool excludeSemantics = false, +}); + +const NakedComboboxOption({ + Key? key, + required T value, + required String textValue, + bool enabled = true, + Widget? child, + ValueWidgetBuilder>? builder, + String? semanticLabel, + bool excludeSemantics = false, +}); +~~~ + +The exact builder type may be specialized instead of reusing RawMenuAnchorOverlayBuilder. The behavior below is the binding contract. + +### 18.5 Query, filtering, and selection ownership + +- Naked UI owns query observation and emits onQueryChanged. +- The consumer owns filtering, sorting, async data, empty text, loading text, and which option widgets are built. +- Option textValue is required for type/display semantics and active-option announcements; it is not assumed from arbitrary widget text. +- In single mode, selecting an option emits the value. The callback alone does not mutate accepted display state. +- The text controller updates to displayStringForOption(value) only when a later widget configuration supplies the accepted value, so a parent that rejects the request never flashes an unaccepted value. +- In multiple mode, selecting toggles membership without mutating the input set. +- Multiple mode clears the query on selection only when configured. +- The core exposes selected values; Remix owns chip layout. Every remove control in the canonical example has an explicit localized label. +- Custom/free-form value creation is owned through onSubmitted; the core does not invent a T from text. +- Dynamic option changes preserve highlight by value when possible; otherwise choose the nearest enabled option or clear it. + +### 18.6 Focus and keyboard contract + +The editable input retains real keyboard focus while the popup is open. + +| Key | Closed | Open | +|---|---|---| +| Down Arrow | Open and highlight first enabled result | Highlight next enabled result | +| Up Arrow | Open and optionally highlight last enabled result | Highlight previous enabled result | +| Enter | Submit free text when no result is active and IME is not composing | Select active result when IME is not composing | +| Escape | No component action | Close popup, clear highlight, retain input value and focus | +| Tab / Shift+Tab | Normal page traversal | Close without silently selecting highlight; continue page traversal | +| Left / Right | Native text editing | Native text editing; never option navigation | +| Home / End | Native text editing | Native text editing in editable mode | +| Alt+Down | Optional open | Keep open | +| Alt+Up | No action | Close and keep focus | + +Additional rules: + +- Space inserts text; it never acts like a select trigger while editing. +- Enter during an active IME composing range is left to the IME. +- Pointer selection returns/retains input focus according to platform convention. +- Popup options are not page Tab stops. +- Disabled options are skipped and cannot select through pointer or semantics. +- Highlight and selected are separate states. +- Outside pointer closes without changing the accepted selection. +- Opening or closing does not move the text caret unexpectedly. + +### 18.7 Popup and overlay contract + +- Popup width is consumer-controlled but can read target size from overlay information. +- Collision keeps the active option and input visible when practical. +- The popup repositions on viewport/keyboard inset changes. +- Results update without closing on each keystroke. +- Empty results can remain open with a caller-rendered message. +- Async loading does not clear an accepted selection. +- A stale async result cannot reset highlight to an option no longer represented by the latest query; the consumer should key result sets and Naked UI should react safely to registry changes. +- Outside click, Escape, selection, controller close, and trigger removal each have one deterministic close callback. + +### 18.8 Semantics contract + +Input: + +- native text-field semantics remain intact; +- accessible name comes from Field or explicit semantic label; +- current text/query is the value; +- expanded reflects popup state; +- controlsNodes references a stable popup semantics identifier; +- SemanticsRole.comboBox is used only after the spike proves it does not degrade supported targets; +- required, invalid, description, and error state come from Field integration; +- editing, selection, focus, and submit actions remain available. + +Popup: + +- one SemanticsRole.list container with a stable identifier; +- options use SemanticsRole.listItem plus selected/enabled/tap properties because Flutter 3.41.2 lacks a dedicated option role; +- actual selection is not conflated with keyboard highlight; +- empty/loading status has a concise status node if it changes while the input is focused; +- decorative checkmarks/icons are excluded. + +Active option: + +- must be announced when changed by keyboard without moving real focus out of the input; +- announcement uses the required option text value and avoids hard-coded English position phrases; +- unchanged highlight does not reannounce; +- rapid repeats do not create an unintelligible backlog; +- actual screen-reader behavior is a manual release gate. + +### 18.9 Required widget and semantics tests + +- Child/builder and controller ownership invariants. +- Query callback for typing, paste, cut, programmatic controller update policy, and clear. +- Single controlled selection, parent rejection, display string, and close-on-select. +- Multiple add/remove, immutable sets, clear-query setting, and selected-state rebuild. +- Dynamic option add/remove/reorder/disable while highlighted. +- Open-on-input, open-on-focus, controller open, outside close, and disabled/read-only paths. +- Exact keyboard table, including Tab, Escape, text-editing keys, and Alt variants if supported. +- IME composing Enter is not intercepted. +- Input focus and caret survive popup navigation and result updates. +- Disabled options skipped for pointer, keyboard, and semantics. +- Collision and inset change. +- Input semantics label/value/expanded/controlled relation. +- Popup/list-item roles and selected/enabled states. +- Required/invalid/error Field integration. +- Active-option announcement strategy changes once per highlight and not on unrelated rebuild. +- No debug semantics exception on the minimum SDK. +- Exclusion and localization/RTL cases. + +### 18.10 Integration and screenshot scenarios + +Stable fixture keys: + +- combobox.input +- combobox.popup +- combobox.option.apple +- combobox.option.banana +- combobox.option.disabled +- combobox.query +- combobox.selection +- combobox.no-results + +Integration scenarios: + +1. Focus input, type to filter, arrow through results, Enter select, and verify focus/value/callback. +2. Escape closes without changing query or accepted selection. +3. Tab closes and moves to the next page control without accepting highlight. +4. Left/Right/Home/End edit or move the caret rather than navigate options. +5. Exercise an IME composition where target automation supports it; at minimum unit-test the composing-range branch. +6. Dynamically remove the highlighted result and verify safe next state. +7. Run multiple selection, remove a selected chip with its labeled control, and keep input usable. +8. Show no results, loading, disabled option, invalid Field, RTL, and mobile keyboard-inset cases. +9. Inspect web accessibility tree for name, value, expanded state, controlled popup, list, and options. + +Required screenshots: + +- combobox__closed_value__macos__reference.png +- combobox__open_highlight__macos__reference.png +- combobox__filtered_results__web__reference.png +- combobox__no_results__web__reference.png +- combobox__multiple_values__macos__reference.png +- combobox__invalid_field__android__reference.png +- combobox__keyboard_inset_collision__android__reference.png +- combobox__rtl__web__reference.png +- combobox__200_text__macos__reference.png + +Manual checks must cover typing, active-option announcements, selection, expanded/collapsed state, no results, invalid state, multiple values, Escape, and leaving with Tab. A result of “the semantics tree looks correct” is insufficient. + +### 18.11 Combobox non-goals + +- Filtering, fuzzy scoring, remote search, paging, caching, or result virtualization policy. +- Product-specific empty/loading content. +- Automatic creation of domain values from free text. +- Styled chips or chip overflow. +- Date, tree, grid, or command-palette popup variants in the first release. + +### 18.12 Combobox acceptance + +- [ ] The accessibility spike is attached and names every tested target. +- [ ] Input retains focus and native editing keys. +- [ ] Active option is understandable with real assistive technology. +- [ ] Single and multiple controlled state cannot mutate caller inputs. +- [ ] Field semantics integrate without duplicate label/error output. +- [ ] Real macOS, Android, and web integration scenarios pass. +- [ ] All nine screenshot states are reviewed. +- [ ] Known Flutter engine limitations are documented without overstating support. + +## 19. Component contract: Hover Card / Preview Card + +### 19.1 Why it is needed + +Hover Card shows a visual preview of a link destination while the pointer hovers or the link has keyboard focus. It differs from Tooltip: + +- the content is richer and larger; +- the pointer must be able to move onto it without dismissal; +- it previews information already available at the destination; +- it is normally ignored by screen readers to avoid duplicating the link destination; +- it must satisfy WCAG behavior for content appearing on hover or focus. + +It also differs from Popover: Hover Card is non-interactive preview content and does not enter the Tab sequence. Interactive or essential content belongs in Popover. + +### 19.2 Confirmed reusable foundation and gap + +Current NakedTooltip uses Flutter RawTooltip, hover/touch delays, dismissal delay, trigger modes, positioning, and optional tooltip semantics. It does not promise hoverable rich content, a pointer grace corridor, focus-triggered preview behavior, or screen-reader-excluded destination preview. + +Current NakedPopover supplies overlay and focus-restoration foundations but is click-oriented and intended for interactive content. + +Hover Card should reuse private overlay positioning and lifecycle utilities, not overload Tooltip with contradictory semantics. + +### 19.3 Proposed public API + +~~~dart +enum NakedHoverCardOpenReason { + hover, + focus, + programmatic, +} + +class NakedHoverCardState extends NakedState { + NakedHoverCardState({ + required super.states, + required this.isOpen, + required this.openReason, + }); + + final bool isOpen; + final NakedHoverCardOpenReason? openReason; +} + +const NakedHoverCard({ + Key? key, + Widget? child, + ValueWidgetBuilder? builder, + required RawMenuAnchorOverlayBuilder previewBuilder, + MenuController? controller, + Duration openDelay = const Duration(milliseconds: 700), + Duration closeDelay = const Duration(milliseconds: 300), + bool openOnHover = true, + bool openOnFocus = true, + bool useRootOverlay = false, + OverlayPositionConfig positioning = const OverlayPositionConfig(), + FocusNode? triggerFocusNode, + VoidCallback? onOpen, + VoidCallback? onClose, + bool excludePreviewSemantics = true, +}); +~~~ + +The controller can be optional because an internal controller is reasonable; ownership rules remain explicit. The exact builder type may be specialized. + +### 19.4 Behavioral contract + +- Pointer entry starts the open delay. +- Leaving before the delay cancels opening. +- Focus entry starts the open delay or opens immediately if the team chooses a separate focus delay; the choice is public and tested. Recommendation: use the same default to avoid surprise flashing during keyboard traversal. +- Once open, moving the pointer from trigger to preview through the geometric gap does not close it. +- Pointer over the preview keeps it open. +- Leaving both trigger and preview starts the close delay. +- Re-entering either before close cancels the pending close without restarting the open animation. +- Focus remaining on the trigger keeps the preview open even when the pointer leaves. +- Escape closes immediately while the trigger has focus. +- Clicking the trigger continues to activate the underlying Link; Hover Card does not consume or toggle on primary click. +- The preview never requests keyboard focus and contains no focusable descendants. +- Page Tab traversal moves from the Link to the next page control, not into the preview. +- Programmatic close cancels all timers. +- App lifecycle pause/dispose cannot leave an orphan overlay or live timer. +- Viewport collision keeps the preview visible without covering the trigger when another side is available. + +### 19.5 WCAG hover/focus requirements + +WCAG 2.2 Success Criterion 1.4.13 requires additional content triggered by hover or focus to be: + +- **dismissible:** Escape closes the preview without moving focus; +- **hoverable:** the pointer can move over the preview; +- **persistent:** it remains until hover/focus is removed, the user dismisses it, or the information becomes invalid. + +Timer-only dismissal while the pointer or trigger focus remains is forbidden. + +### 19.6 Semantics contract + +- The trigger retains its native Link semantics, name, URL, actions, and focus. +- The Hover Card wrapper adds no second button, tooltip, or link node. +- Preview content is excluded from semantics by default. +- The preview must not contain unique instructions, status, controls, or information needed to understand or operate the page. +- If a team believes preview content must be accessible, it should use Popover or render the information in normal page content rather than set an escape-hatch flag casually. +- Decorative preview images have no semantics. +- Escape dismissal is keyboard behavior; it is not represented as a fake semantic action on the Link. + +### 19.7 Pointer-grace implementation requirement + +A simple close timer alone is not sufficient when a visible gap separates trigger and preview. Implement and test either: + +- a pointer grace polygon/corridor between the exit point and preview bounds; or +- a hit-testable safe region that does not block unrelated controls. + +The grace region must update when the overlay flips sides and must not keep the card open after the pointer moves away from both surfaces. + +### 19.8 Required widget and semantics tests + +- Hover open delay, pre-open cancellation, close delay, and re-entry cancellation. +- Focus open/close behavior and Escape. +- Combined hover plus focus reasons; removing one reason does not close while the other remains. +- Pointer movement across the trigger-preview gap on all four placement sides. +- Pointer departure outside the grace corridor closes. +- Underlying Link primary activation still fires exactly once. +- Preview has no focusable descendants in the canonical contract. +- Tab skips the preview. +- Controller open/close, replacement, disposal, and pending timer cancellation. +- Collision and RTL placement. +- Every relevant trigger semantics field is identical with and without the Hover Card wrapper. +- Preview nodes are absent from the semantics tree by default. +- Exclusion cannot hide the trigger. +- No unchanged rebuild reopens, recloses, or restarts timers. + +### 19.9 Integration and screenshot scenarios + +Stable fixture keys: + +- hover-card.link +- hover-card.preview +- hover-card.next-focus +- hover-card.open-state + +Integration scenarios: + +1. Hover shorter than delay and verify no preview. +2. Hover through delay, move across the gap onto preview, wait beyond close delay, and verify still open. +3. Move outside both, wait exact close delay, and verify closed. +4. Keyboard-focus the Link, verify preview, press Escape, verify closed and focus unchanged. +5. Reopen and Tab; verify next page control receives focus. +6. Activate the Link while preview is open and verify the Link callback once. +7. Open near each edge and verify collision-safe placement. + +Required screenshots: + +- hover_card__hover_open__macos__reference.png +- hover_card__keyboard_focus__web__reference.png +- hover_card__pointer_grace__macos__reference.png +- hover_card__collision_edge__web__reference.png +- hover_card__200_text__macos__reference.png +- hover_card__rtl__web__reference.png + +Manual checks confirm the Link is announced normally, no duplicate preview text appears in screen-reader navigation, Escape works, keyboard focus does not enter the card, and essential information is available at the destination. + +### 19.10 Touch behavior and non-goals + +Hover Card has no required touch-only trigger. Long press is already used by system text/link behavior and Context Menu, and a hidden preview is a poor place for essential mobile information. Consumers needing touch access should use Popover. + +Other non-goals: + +- interactive buttons, links, text fields, or scrolling inside the preview; +- product data fetching and caching; +- destination navigation; +- preview styling or animation design. + +### 19.11 Hover Card acceptance + +- [ ] Dismissible, hoverable, and persistent behavior is automated. +- [ ] Pointer grace passes all placement sides. +- [ ] Link activation and semantics remain unchanged. +- [ ] Preview is absent from semantics and Tab traversal. +- [ ] Timers are deterministic and disposed. +- [ ] All screenshot and manual accessibility evidence is attached. + +## 20. Component contract: Link + +### 20.1 Why it is needed + +Remix needs a reusable inline navigation primitive. A Link is not a text-styled Button: assistive technologies identify it as navigation, keyboard users activate it with Enter, and Space should remain available for page scrolling rather than triggering it. + +### 20.2 Proposed public API + +~~~dart +class NakedLinkState extends NakedState { + NakedLinkState({ + required super.states, + required this.linkUrl, + }); + + final Uri? linkUrl; +} + +const NakedLink({ + Key? key, + Widget? child, + ValueWidgetBuilder? builder, + VoidCallback? onPressed, + Uri? linkUrl, + bool enabled = true, + FocusNode? focusNode, + bool autofocus = false, + MouseCursor? mouseCursor, + bool enableFeedback = true, + ValueChanged? onFocusChange, + ValueChanged? onHoverChange, + ValueChanged? onPressChange, + String? semanticLabel, + String? semanticHint, + bool excludeSemantics = false, +}); +~~~ + +Naked UI does not depend on URL launching or a router. linkUrl is semantics metadata; onPressed performs application navigation. Effective enabled state is enabled && onPressed != null. + +### 20.3 Interaction contract + +- Primary pointer tap activates once. +- A canceled pointer sequence clears pressed state and does not activate. +- Enter and Numpad Enter activate while focused. +- Space is not intercepted and does not activate the Link. +- Semantic tap activates through the same callback path. +- Disabled links do not activate, focus through normal traversal, emit feedback, or show an activation cursor. +- Hover, focus, press, and disabled state are visible to the builder and scope. +- The default enabled cursor is SystemMouseCursors.click; the disabled default is basic. +- Callback removal while focused immediately makes the Link effectively disabled and removes activation. +- Rebuild, focus-node replacement, and disposal do not leak listeners. +- The package does not invent visited state because it does not own navigation history. + +### 20.4 Semantics contract + +Flutter represents links with properties rather than SemanticsRole.link. Use: + +- link: true; +- linkUrl: linkUrl when supplied; +- enabled state; +- accessible name from visible text or semanticLabel; +- caller-localized semanticHint for behavior such as opening a new window; +- tap action only when effectively enabled; +- focused/focusable state consistent with keyboard focus. + +Additional rules: + +- Do not also expose button semantics. +- If linkUrl is non-null, the Link flag must be true; Flutter asserts this invariant. +- A decorative external-link icon is excluded from semantics when the hint already conveys the behavior. +- Disabled Link remains a discoverable disabled link when included in semantics, but has no action. +- excludeSemantics hides the Link subtree and is documented as an advanced escape hatch. + +### 20.5 Text and gesture considerations + +- Wrapping rich or selectable text must not break text selection outside the actual activation surface. +- Secondary click remains available for a surrounding Context Menu where the consumer composes one. +- Modifier-click behavior such as opening a new browser tab is router/platform policy and not synthesized in the first API. +- The hit target comes from the styled child. The canonical example and Remix must meet platform target-size guidance where the Link is presented as a standalone control; inline text links should preserve readable line layout and a discoverable focus indication. + +### 20.6 Required widget and semantics tests + +- Child/builder assertion and state-scope behavior. +- Pointer tap, canceled gesture, Enter, Numpad Enter, semantic tap, and callback count. +- Space does not activate. +- Enabled, explicit disabled, and null-callback effective disabled cases. +- Hover/focus/press callback transitions. +- Focus-node ownership, replacement, and disposal. +- Dynamic callback removal while focused. +- Link flag, URL, name, hint, enabled, focus, and tap action. +- No button flag. +- Disabled action absent. +- Visible child semantics versus overriding semantic label has no duplicate name. +- External-icon exclusion. +- Non-English label/hint and RTL fixture. +- excludeSemantics removes the node. + +### 20.7 Integration and screenshot scenarios + +Stable fixture keys: + +- link.primary +- link.disabled +- link.external +- link.result +- link.next-focus + +Integration scenarios: + +1. Tab to Link, press Enter, and verify one navigation callback plus retained predictable focus. +2. Focus Link, press Space, and verify no callback. +3. Pointer hover/press/tap and verify state readout. +4. Invoke semantic tap and verify the same callback. +5. Verify disabled Link is skipped by Tab and has no pointer or semantics action. +6. Compose Hover Card and Context Menu around Link and verify primary, secondary, hover, and keyboard paths do not conflict. + +Required screenshots: + +- link__default_inline__macos__reference.png +- link__hover__web__reference.png +- link__keyboard_focus__macos__reference.png +- link__disabled__android__reference.png +- link__external_hint__web__reference.png +- link__long_text_200__macos__reference.png +- link__rtl__web__reference.png + +Manual checks confirm “link” rather than “button,” URL exposure where supported, correct label/hint, Enter activation, no Space activation, visible focus, and disabled behavior. + +### 20.8 Link non-goals + +- Router integration or URL launching. +- Visited-history storage. +- Browser download behavior. +- Modifier-click/window management in the first release. +- Link color, underline, icon, or typography. + +### 20.9 Link acceptance + +- [ ] Link semantics and URL metadata are exact. +- [ ] Button semantics are absent. +- [ ] Enter activates and Space does not. +- [ ] Disabled state has no focus/action. +- [ ] Hover Card and Context Menu composition has integration proof. +- [ ] All screenshot and assistive-technology evidence is attached. + +## 21. Integration, screenshot, golden, and CI implementation + +### 21.1 First repair the aggregate harness + +Before relying on new component results: + +1. Change tool/run_integration_all.sh to enter packages/example, not example. +2. Ensure all_tests.dart imports Tooltip and every new component; the current pinned aggregate omits the existing Tooltip integration file. +3. Remove the two-second real delay in tearDownAll unless a documented runner bug requires it. +4. Replace cleanup pumpAndSettle() with bounded, component-aware cleanup. +5. Replace helpers that swallow keyboard exceptions. +6. Make tab-order helpers assert the actual focus node after each move. +7. Give each integration group a normal bounded timeout instead of a blanket 30-minute default that conceals hangs. +8. Run the shell runner in CI so stale paths and missing aggregate imports are caught. + +### 21.2 Deterministic pumping + +Use exact pumps for known transitions: + +~~~dart +await tester.pump(); // apply state/focus change +await tester.pump(const Duration(milliseconds: 200)); // fixed transition +~~~ + +For an asynchronous condition whose duration is not a public invariant, use a bounded helper: + +~~~dart +Future pumpUntil( + WidgetTester tester, + bool Function() condition, { + int maxFrames = 30, + Duration step = const Duration(milliseconds: 16), +}) async { + for (var frame = 0; frame < maxFrames; frame += 1) { + if (condition()) return; + await tester.pump(step); + } + fail('Condition was not reached within the bounded frame budget.'); +} +~~~ + +Do not use pumpAndSettle() for: + +- a Toast viewport with active dismissal timers; +- a Hover Card with pending open/close delay; +- a repeating or indeterminate animation; +- a cursor blink; +- an overlay intentionally holding a scheduled frame. + +A bounded helper must fail with a useful state diagnostic rather than silently continue. + +### 21.3 Screenshot helper + +Initialize one integration binding: + +~~~dart +final binding = + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); +~~~ + +Before an Android screenshot, convert the Flutter surface once in setup: + +~~~dart +await binding.convertFlutterSurfaceToImage(); +await tester.pump(); +~~~ + +Capture after all deterministic state and focus assertions: + +~~~dart +await binding.takeScreenshot( + 'toast__action_focused_paused__android__reference', +); +~~~ + +Use conditional imports for platform setup. A helper imported by web tests must not import dart:io. Keep: + +- a shared screenshot interface; +- an IO implementation for Android/macOS setup; +- a web-safe implementation; +- artifact naming and metadata shared across targets. + +The screenshot is taken only after assertions pass, so an image never masks a failed state transition. + +### 21.4 Screenshot manifest + +Every CI artifact bundle includes a machine-readable or Markdown manifest: + +The component contracts enumerate 51 distinct required screenshot names across macOS, Android, and web. Treat that list as the minimum review set; do not replace it with one generic gallery image per component. + +| Field | Example | +|---|---| +| Component | Toast | +| Scenario | action focused and timer paused | +| File | toast__action_focused_paused__web__reference.png | +| Git commit | Full Naked UI SHA | +| Flutter | 3.41.2 | +| Target | Chrome on Ubuntu runner | +| Surface | 800 by 600 logical pixels | +| DPR | 1.0 | +| Locale/direction | en-US / LTR | +| Text scale | 1.0 | +| Animation mode | disabled or fixed-duration | +| Test result | pass | +| Reviewer | name/date | + +Screenshots produced on a different SDK or surface are not silently compared as the same baseline. + +### 21.5 Golden harness + +Add canonical example golden tests under: + +packages/example/test/goldens/components + +The harness fixes: + +- Flutter SDK and host OS image; +- physical size and device-pixel ratio; +- locale and direction; +- text scale; +- brightness; +- font files loaded with FontLoader; +- animation state and clock; +- safe-area padding; +- scroll position and pointer/focus state. + +Use focused component finders with matchesGoldenFile. Include enough surrounding surface to verify overlay placement when that is the subject of the test. + +The only approved baseline update flow is: + +~~~sh +cd packages/example +flutter test test/goldens --update-goldens +flutter test test/goldens +~~~ + +The update commit includes an explanation of the intended visual change and reviewed image diffs. + +### 21.6 Accessibility-guideline fixture + +For every canonical styled component state with interactive controls: + +~~~dart +final semantics = tester.ensureSemantics(); +addTearDown(semantics.dispose); + +await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); +await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); +await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); +await expectLater(tester, meetsGuideline(textContrastGuideline)); +~~~ + +Apply only meaningful guidelines to a given fixture. For example, a purely decorative excluded Hover Card preview does not need a label itself, but its Link trigger does. Inline Link target-size interpretation needs product review because enlarging a target can distort text layout; label, keyboard focus, and contrast still remain mandatory. + +### 21.7 Exact local commands + +From the Naked UI repository root: + +~~~sh +flutter pub get +dart format --set-exit-if-changed . +flutter analyze +flutter test packages/naked_ui/test +flutter test packages/example/test +~~~ + +Target one component while developing: + +~~~sh +flutter test packages/naked_ui/test/src/naked_toast_test.dart +flutter test packages/naked_ui/test/semantics/naked_toast_semantics_test.dart +flutter test packages/example/test/goldens/components/naked_toast_golden_test.dart +~~~ + +Fast integration smoke: + +~~~sh +cd packages/example +flutter test -r compact -d flutter-tester integration_test/components/naked_toast_integration.dart +~~~ + +Real macOS target: + +~~~sh +cd packages/example +flutter config --enable-macos-desktop +flutter test -r compact -d macos integration_test/components/naked_toast_integration.dart +flutter test -r compact -d macos integration_test/all_tests.dart +~~~ + +The pinned upstream example does not contain a committed macOS directory. The team must deliberately choose to commit generated platform files or generate them reproducibly in CI before the real-target command. Do not fall back to flutter-tester while retaining a “macOS” job name. + +Android emulator: + +~~~sh +cd packages/example +flutter create --platforms android . +flutter test -r compact -d emulator-5554 integration_test/components/naked_toast_integration.dart +~~~ + +Web requires a web-safe integration driver. A standard driver entry is: + +~~~dart +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); +~~~ + +Generate the platform files if the project has chosen CI generation: + +~~~sh +cd packages/example +flutter create --platforms web . +~~~ + +Start ChromeDriver in terminal A: + +~~~sh +chromedriver --port=4444 +~~~ + +While it remains running, execute the test in terminal B: + +~~~sh +cd packages/example +flutter drive --driver=test_driver/integration_test.dart --target=integration_test/all_tests.dart -d chrome +~~~ + +Stop terminal A after the test. Keep ChromeDriver lifecycle in its own CI step. Do not use an unbounded background process locally without arranging cleanup. + +### 21.8 Proposed blocking CI matrix + +| Job | SDK/target | Trigger | Blocking proof | +|---|---|---|---| +| Exact-minimum analyze/unit/semantics | Flutter 3.41.0, Ubuntu | Every PR | Declared package minimum compatibility | +| Primary format/analyze/unit/semantics | Flutter 3.41.2, Ubuntu | Every PR | Current pinned SDK behavior and semantics tree | +| Canonical example/goldens/guidelines | Flutter 3.41.2, pinned Ubuntu image | Every affected PR | Stable visual fixture, labels, targets, contrast | +| Integration smoke | flutter-tester | Every affected PR | Fast aggregate wiring and behavior | +| Real macOS integration | Flutter 3.41.2, -d macos | Every affected PR | Desktop focus, pointer, keyboard, overlays, screenshots | +| Android integration | API 34 emulator, Flutter 3.41.2 | Affected PRs or merge queue; always before release | Touch, long press, TalkBack-oriented semantics, safe areas, screenshots | +| Web integration | Pinned Chrome/ChromeDriver, Flutter 3.41.2 | Affected PRs or merge queue; always before release | Keyboard, accessibility DOM, screenshots | +| Remix consumer | Remix FVM Flutter 3.44.0 | Release candidate | Package/API compatibility with actual consumer floor | +| iOS manual release check | Remix-supported iOS target | Before tagged release | VoiceOver and platform focus/gesture behavior | + +If cost prevents Android/web on every PR, use path filtering and a merge queue. A release must not proceed from a manual workflow that nobody ran. + +### 21.9 CI artifact retention + +Upload: + +- screenshot manifest and all named screenshots; +- golden failure current/expected/diff images; +- flutter doctor -v; +- test machine target list; +- integration result JSON where available; +- semantics/accessibility spike notes for Combobox; +- manual AT checklist as a PR attachment or linked document. + +Keep failed-run artifacts long enough for reviewers to inspect them. Do not upload secrets, home-directory dumps, or unrelated logs. + +### 21.10 Flake policy + +- A retry may gather evidence but must not turn a flaky first failure green without recording it. +- No blanket continue-on-error on required component checks. +- No catch-and-log around keyboard, screenshot, or semantics operations. +- No arbitrary real sleep used as a stability fix. +- A quarantined test has an issue, owner, reason, and removal date; the affected component cannot be declared fully validated. +- Timer tests use fake frame time. +- Pointer coordinates derive from finder geometry rather than hard-coded global pixels except when testing viewport edges. + +### 21.11 Leak and disposal checks + +Every overlay/timer component test suite includes: + +- dispose while closed; +- dispose while open; +- dispose with a pending open timer; +- dispose with a pending close timer; +- replace external controller while open; +- remove trigger while open; +- route change while open; +- no exception from stale callback; +- no remaining overlay entry; +- no active timer or focus listener owned by the component. + +Where Flutter exposes useful debug assertions for transient callbacks, scheduled frames, or overlay state, assert them after deterministic cleanup. + +## 22. Required handoff package for the Naked UI team + +The implementation handoff is a reviewable evidence set, not just a package version. + +### 22.1 Per-component PR contents + +Each PR includes: + +1. **Contract summary:** public API and observable behavior delivered. +2. **Compatibility statement:** source, semantic, keyboard, and focus changes to existing APIs. +3. **Implementation notes:** state ownership, controller ownership, focus nodes, overlays, timers, and localization. +4. **Test map:** requirement-to-test-file and test-name mapping. +5. **Screenshot manifest:** links to every required state. +6. **Accessibility evidence:** semantics assertions, guidelines, and manual AT results. +7. **Platform evidence:** exact commands/CI runs for flutter-tester, macOS, Android, and web as applicable. +8. **Known limitations:** Flutter issues, target deviations, and explicit non-goals. +9. **Documentation:** API docs, example, migration note, and changelog. +10. **Consumer note:** how Remix should adopt the API and what it must still test. + +### 22.2 Requirement traceability table template + +| Requirement ID | Requirement | Automated test | Platform run | Screenshot | Manual AT | Result | +|---|---|---|---|---|---|---| +| TOAST-FOCUS-01 | Showing a toast does not move focus | naked_toast_test.dart: named test | macOS + web | N/A | VoiceOver | Pass | +| TOAST-TIMER-02 | Hover preserves remaining duration | named test | macOS | paused screenshot | N/A | Pass | +| TOAST-SEM-03 | Polite toast uses status role once | named semantics test | web | N/A | VoiceOver + Chrome tree | Pass | + +Use stable IDs in the PR description or component issue. Do not leave rows with “covered generally.” + +### 22.3 Manual accessibility result template + +~~~markdown +#### Manual AT check + +- Component/scenario: +- Naked UI commit: +- Flutter version: +- Device/OS: +- Browser, if web: +- Assistive technology/version: +- Starting focus: +- Actions performed: +- Expected announcement/behavior: +- Actual result: +- Pass/fail: +- Evidence or linked issue: +- Tester/date: +~~~ + +### 22.4 Screenshot review template + +~~~markdown +#### Visual review + +- Manifest/artifact link: +- Expected surface, DPR, locale, direction, text scale: +- States reviewed: +- Overlay collision checked: +- Focus indicator visible: +- Text clipping/overflow checked: +- Safe area checked: +- Diff expected: +- Reviewer/date: +~~~ + +### 22.5 API review questions + +Before approval, reviewers answer: + +- Can the behavior be styled without forking it? +- Is any product/business rule accidentally in Naked UI? +- Is state truly controlled or controller-owned as documented? +- Can a disabled path still activate through semantics or keyboard? +- Does every focus node have clear ownership? +- Can a timer or overlay survive disposal? +- Are semantic name, role, state, value, and actions all represented? +- Does the API require hard-coded English from the package? +- Does a screenshot cover only appearance, while semantics and keyboard have separate proof? +- Is an existing consumer likely to experience a semantic behavior change even if code still compiles? + +## 23. Release, rollout, and Remix consumption + +### 23.1 Package-level definition of done + +No component is done until: + +- [ ] Public API and doc comments are complete. +- [ ] Source formatting and analysis pass. +- [ ] Widget, semantics, and relevant parity tests pass. +- [ ] Aggregate integration imports and runner pass. +- [ ] Real macOS integration passes. +- [ ] Android and web required scenarios pass before release. +- [ ] Canonical goldens and accessibility guidelines pass. +- [ ] Required screenshots are reviewed. +- [ ] Manual VoiceOver, TalkBack, and web results are recorded. +- [ ] Disposal/leak cases pass. +- [ ] Compatibility and migration notes are written. +- [ ] Changelog names semantic or keyboard behavior changes. +- [ ] Open decisions for the component are resolved. +- [ ] No required check is ignored, swallowed, or advisory-only. + +### 23.2 Recommended release grouping + +Prefer incremental prereleases rather than one large release: + +1. Dialog role + Link + Field. +2. Toggle Group + Context Menu. +3. Toast + Hover Card. +4. Combobox only after the accessibility spike and cross-platform proof. + +Combobox should not delay stable, lower-risk primitives if its engine mapping remains blocked. + +### 23.3 Remix adoption steps + +Remix currently consumes naked_ui ^0.2.0-beta.7, while the audited upstream main reports 1.0.0-beta.3. Treat this as a migration, not a routine patch bump: + +1. Create a dedicated Remix dependency-upgrade PR. +2. Review Naked UI changelogs and public API differences between the pinned version and release candidate. +3. Update one existing Remix component at a time and keep its tests green. +4. Add new Remix components only after the base upgrade is stable. +5. Run Remix with its configured Flutter 3.44.0 FVM SDK. +6. Repeat accessibility guidelines and goldens using Remix's actual styles. +7. Keep a temporary compatibility table mapping each Remix component to the Naked API version it requires. +8. Do not use a broad dependency override in the release branch without pinning a reviewed commit. + +### 23.4 Proposed Remix implementation order + +After Naked releases: + +1. Popover from existing NakedPopover. +2. Link. +3. Field integration for TextField and Select validation. +4. Segmented/Toggle Group. +5. Alert Dialog variant. +6. Context Menu. +7. Toast. +8. Hover Card. +9. Combobox after the separate accessibility gate. + +Skeleton, Drawer/Sheet, and basic Scroll Area can proceed independently in Remix using the boundary decisions in section 3.1. + +### 23.5 Rollback strategy + +- New components are additive and can be withheld from Remix exports without reverting the Naked dependency. +- Existing Dialog and Toggle behavior changes require feature-level migration notes and targeted regression tests. +- If a platform semantics regression is found, prefer disabling the affected new semantic mapping behind an explicit compatibility option only as a short-lived patch with an issue and removal plan. +- Never solve a screen-reader regression by excluding the entire component from semantics. +- Keep the previously working package lock available for a Remix rollback until the upgrade PR has passed release validation. + +## 24. Open decisions and risk register + +No item in this section should be decided silently during implementation. Record the resolution in the relevant issue/PR and update public documentation. + +### 24.1 Decision log + +| ID | Decision | Recommendation | Owner | Must resolve by | +|---|---|---|---|---| +| D-01 | Toggle option semantics migration | Use button + toggled for all Toggle Group modes; document the announcement change. Keep Radio Group for radio semantics. | Naked API + accessibility reviewers | Before Toggle Group implementation | +| D-02 | Alert Dialog initial focus API | Keep optional initialFocusNode, document safe-target heuristics, and make canonical examples explicit. | Naked API reviewer | Before Alert Dialog PR approval | +| D-03 | Context Menu trigger semantic action | Preserve child role and expose long-press semantics; avoid a fake button. Prototype discoverability with VoiceOver/TalkBack. | Accessibility reviewer | During Context Menu spike | +| D-04 | Toast composition API | Use structured message/action/close helpers so duplicate message semantics can be excluded without hiding controls. | Naked API reviewer | Before Toast tests are written | +| D-05 | Toast global shortcut | Caller opt-in; canonical example may use F8. Do not reserve a key globally by default. | Naked maintainer | Before Toast PR approval | +| D-06 | Toast queue overflow | Default unlimited pending queue or add explicit nullable maxQueued; never silently drop without a dismissal reason. | Naked API reviewer | Before controller implementation | +| D-07 | Toast swipe in first release | Defer unless all alternate dismissal and deterministic drag tests fit the PR. | Product + maintainer | At Toast scoping | +| D-08 | Field/TextField duplicate metadata | Debug-assert conflicting values; allow identical explicit values; field scope is semantic source of truth. | Naked API reviewer | Before Field implementation | +| D-09 | Initial Field error announcement | Make initial error discoverable but not automatically assertive; announce later error transitions once. | Accessibility reviewer | Before Field semantics tests | +| D-10 | Combobox active-option strategy | Select only after the required macOS/Android/web spike; a status announcer is the leading fallback if role mapping alone is insufficient. | Accessibility + Flutter platform reviewer | Before Combobox public API freeze | +| D-11 | Combobox role on Flutter 3.41 | Use only if prototype has no regression; otherwise document fallback and upstream issue without claiming complete mapping. | Flutter platform reviewer | During Combobox spike | +| D-12 | Naked UI minimum Flutter | Keep >=3.41.0 only if an exact 3.41.0 job passes; otherwise raise the minimum deliberately. | Package maintainer | Before first release candidate | +| D-13 | Example platform directories | Commit reviewed minimal platform files or generate them reproducibly in CI; real target job names must match actual devices. | CI maintainer | Test-harness PR | +| D-14 | Golden host/font | Pin one Ubuntu image, Flutter 3.41.2, surface configuration, and checked-in licensed test font. | CI + design reviewer | Test-harness PR | +| D-15 | Android/web PR frequency | Prefer affected-path PR/merge-queue jobs; release is blocked unless both have passed the exact release commit. | CI maintainer | Test-harness PR | + +### 24.2 Risk register + +| Risk | Likelihood/impact | Detection | Mitigation | Exit condition | +|---|---|---|---|---| +| Flutter semantics enum is not fully mapped on a target | High for Combobox / high impact | Real AT spike and web accessibility tree | Keep properties correct, prototype fallback announcement, track upstream issue | Active option, expanded state, selection, and errors are understandable on supported targets | +| Duplicate screen-reader announcements | Medium / high | Manual AT plus one-node semantics assertions | Single semantic source, structured Toast/Field helpers, avoid role + liveRegion conflict | One intended announcement per transition | +| Focus trap or restoration regression | Medium / high | Real-target keyboard integration | Managed nodes, explicit invoker tracking, remove-trigger tests | All open/close paths restore or safely fall back | +| Timer flakiness | High / medium | Repeated CI and pending-timer disposal tests | Injectable durations, fake frame time, bounded pumps | Repeated suite has no retry-dependent pass | +| Overlay collision differs by platform | Medium / medium | Four-edge screenshots on macOS/web/Android | Shared positioning utility, geometry assertions | Overlay bounds pass on required surfaces | +| Headless package overclaims contrast/target accessibility | Medium / high | Review of test layer and docs | Keep guidelines in canonical example and Remix, document boundary | Release notes state exact scope of guarantees | +| Existing semantic behavior changes without source break | Medium / high for Toggle/Dialog | Changelog review and old/new semantics tests | Explicit compatibility note and migration tests | Consumer review signs off | +| Hidden integration file is not run | Medium / high | Aggregate import audit and shell runner CI | Import every component; compare file inventory to aggregate | Inventory check passes | +| Text editing shortcuts are stolen by Combobox | Medium / high | Keyboard table and IME tests | Input retains focus; handle only permitted keys; composing guard | Editing/caret/IME tests pass | +| Hover Card contains essential or interactive content | Medium / medium | Example/content review and focus scan | Strong non-goal; use Popover instead | Preview has no focusables/unique required information | +| Controller/timer listener leaks | Medium / medium | Dispose-open/pending tests | Clear ownership and listener replacement | No stale callback/overlay/timer after disposal | +| Remix dependency migration is larger than expected | High / medium | Dedicated upgrade PR and changelog audit | Separate upgrade from new components; pin reviewed release | Existing Remix suite passes on Flutter 3.44.0 | + +### 24.3 Escalation rule + +Block the relevant component when: + +- actual screen-reader behavior contradicts the semantics contract; +- a supported target cannot perform a required keyboard or touch path; +- focus can escape or is lost after a standard close path; +- a required test is flaky without retries; +- the implementation needs product styling or business logic to make the base behavior work; +- an unresolved Flutter limitation would make the release claim misleading. + +A blocked component does not block independent components from shipping. + +## 25. Source register + +### 25.1 Audited Remix and Naked UI sources + +Local Remix: + +- [Remix package dependency and SDK contract](packages/remix/pubspec.yaml) +- [Workspace Flutter version](.fvmrc) +- [Workspace package and SDK configuration](pubspec.yaml) + +Pinned Naked UI upstream at 0ca0b8bc2269ed331345cc705d99a073acdf5f5f: + +- [Package version and minimum SDK](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/pubspec.yaml) +- [Public widget exports](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_widgets.dart) +- [Dialog implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_dialog.dart) +- [Toggle and Toggle Group implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_toggle.dart) +- [Menu implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_menu.dart) +- [Popover implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_popover.dart) +- [Select implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_select.dart) +- [TextField implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_textfield.dart) +- [Tooltip implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_tooltip.dart) +- [Dialog semantics tests](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/test/semantics/naked_dialog_semantics_test.dart) +- [Menu semantics tests](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/test/semantics/naked_menu_semantics_test.dart) +- [Toggle semantics tests](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/test/semantics/naked_toggle_semantics_test.dart) +- [Integration aggregate](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/example/integration_test/all_tests.dart) +- [Keyboard integration helpers](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/example/integration_test/helpers/keyboard_test_helpers.dart) +- [Unit-test CI](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/.github/workflows/ci.yml) +- [Current desktop integration workflow](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/.github/workflows/integration-tests.yml) +- [Current Android integration workflow](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/.github/workflows/integration-android.yml) +- [Current shell integration runner](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/tool/run_integration_all.sh) +- [Latest published Naked UI API index](https://pub.dev/documentation/naked_ui/latest/naked_ui/) + +### 25.2 Official Flutter references + +- [Accessibility testing](https://docs.flutter.dev/ui/accessibility/accessibility-testing) — semantics tests, guideline checks, and platform accessibility tools. +- [Accessibility overview and release checklist](https://docs.flutter.dev/ui/accessibility) — Flutter's overall accessibility expectations. +- [Web accessibility](https://docs.flutter.dev/ui/accessibility/web-accessibility) — semantics-to-accessibility-DOM behavior and web considerations. +- [Assistive technologies](https://docs.flutter.dev/ui/accessibility/assistive-technologies) — screen-reader testing context. +- [Accessible UI design and styling](https://docs.flutter.dev/ui/accessibility/ui-design-and-styling) — contrast, target size, text scale, and visual considerations. +- [Testing overview](https://docs.flutter.dev/testing/overview) — unit, widget, and integration test boundaries. +- [Focus and focus traversal](https://docs.flutter.dev/ui/interactivity/focus) — focus-node lifecycle and traversal. +- [Actions and Shortcuts](https://docs.flutter.dev/ui/interactivity/actions-and-shortcuts) — intent/action keyboard architecture. +- [SemanticsRole API](https://api.flutter.dev/flutter/dart-ui/SemanticsRole.html) — available complex semantics roles. +- [matchesSemantics](https://api.flutter.dev/flutter/flutter_test/matchesSemantics.html) — exact semantics matcher. +- [AccessibilityGuideline](https://api.flutter.dev/flutter/flutter_test/AccessibilityGuideline-class.html) and [meetsGuideline](https://api.flutter.dev/flutter/flutter_test/meetsGuideline.html) — automated accessibility checks. +- [matchesGoldenFile](https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html) — golden comparison. +- [Integration screenshot API](https://api.flutter.dev/flutter/package-integration_test_integration_test/IntegrationTestWidgetsFlutterBinding/takeScreenshot.html) — screenshot capture. +- [FocusTraversalGroup](https://api.flutter.dev/flutter/widgets/FocusTraversalGroup-class.html) — composite traversal boundaries. +- [Shortcuts](https://api.flutter.dev/flutter/widgets/Shortcuts-class.html) — logical-key mapping. +- [Flutter issue 159741](https://github.com/flutter/flutter/issues/159741) — framework role-check implementation gap referenced by the Flutter 3.41.2 source. + +### 25.3 W3C WAI-ARIA and WCAG references + +- [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/) +- [Modal Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) +- [Menu Button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) +- [Menu and Menubar pattern, including context-menu keyboard behavior](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) +- [Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) +- [Button and toggle-button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) +- [Link pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/) +- [Toolbar composite pattern](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/) +- [Radio Group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) +- [Keyboard interface practice](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) +- [WCAG 2.2: Content on Hover or Focus](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html) +- [WCAG 2.2: Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages) +- [Forms tutorial](https://www.w3.org/WAI/tutorials/forms/) +- [Form labels](https://www.w3.org/WAI/tutorials/forms/labels/) +- [Form validation](https://www.w3.org/WAI/tutorials/forms/validation/) +- [Form notifications](https://www.w3.org/WAI/tutorials/forms/notifications/) + +### 25.4 Comparable headless primitive references + +These are behavior references, not APIs to copy mechanically: + +- [Base UI Toast](https://base-ui.com/react/components/toast) +- [Base UI Field](https://base-ui.com/react/components/field) +- [Base UI Preview Card](https://base-ui.com/react/components/preview-card) +- [Base UI Context Menu](https://base-ui.com/react/components/context-menu) +- [Base UI Toggle Group](https://base-ui.com/react/components/toggle-group) +- [Base UI Combobox](https://base-ui.com/react/components/combobox) +- [Radix Toast](https://www.radix-ui.com/primitives/docs/components/toast) +- [Radix Hover Card](https://www.radix-ui.com/primitives/docs/components/hover-card) +- [Radix accessibility overview](https://www.radix-ui.com/primitives/docs/overview/accessibility) + +## 26. Final handoff summary + +The Naked UI team should treat this work as a behavior and evidence program: + +1. harden the integration harness so failures cannot be swallowed; +2. implement each primitive with a written semantics and focus contract; +3. prove state and semantics in widget tests; +4. prove input, overlays, focus, and timers on real targets; +5. prove a deterministic reference appearance with goldens and screenshots; +6. test actual assistive technology, especially Combobox; +7. package the evidence and compatibility notes for Remix; +8. let Remix own and revalidate the final visual system. + +The release is ready when a reviewer can trace every important behavior to an automated test, a target run, a screenshot where appearance matters, and a manual assistive-technology result where platform output matters. diff --git a/plan/decisions.md b/plan/decisions.md new file mode 100644 index 00000000..19f39ffb --- /dev/null +++ b/plan/decisions.md @@ -0,0 +1,66 @@ +# Decision log + +Living copy of the briefing's decision register +([§24.1](briefing.md#241-decision-log)). The briefing copy is frozen; **this +file is where resolutions are recorded.** No item here may be decided silently +inside an implementation PR — resolve the row, link the PR/issue, then +implement. Blocking relationships are shown on the +[status board](README.md#status-board). + +Status values: `open` → `resolved()` with a link to where it was decided. + +Phase 0 repair-slice review (2026-07-12): Group A and B1 proceeded without +choosing an SDK-floor policy, platform-directory strategy, golden host/font, +or Android/web cadence. The maintainer approved the evidence-backed D-12–D-15 +recommendations on 2026-07-12 before decision-dependent implementation began. + +### Phase 0 decision evidence (2026-07-12) + +- **D-12:** the official Flutter repository contains the exact `3.41.0` tag. + A local FVM 3.41.0 run resolved the workspace, analyzed with no issues, and + passed all 574 package/widget/semantics tests (three intentional skips). +- **D-13:** Flutter 3.41.2 generated standard Android, macOS, and web platform + directories totaling about 516 KiB (152/300/64 KiB). The reviewed generator + output is present in the working tree so local real-target commands and CI do + not regenerate target scaffolding on every run. +- **D-14:** the pinned Flutter 3.41.2 SDK contains `Roboto-Regular.ttf` with its + Apache-2.0 license. A checked-in copy plus an explicit Ubuntu runner label, + fixed surface/DPR/locale/text scale/brightness, and disabled animation now + provide the golden harness a reproducible baseline. +- **D-15:** GitHub has no recorded runs of the existing manual Android workflow. + Recent `flutter-tester` integration runs take roughly two to three minutes. + Affected-path PR/merge-queue jobs plus reusable Android/web release gates now + require the exact tagged commit while avoiding unrelated docs-only runs. + +| ID | Decision | Briefing recommendation | Must resolve by | Status | +|---|---|---|---|---| +| D-01 | Toggle option semantics migration (`selected` → `toggled`) | Use button + `toggled` for all Toggle Group modes; changelog + announcement note; keep Radio Group for radio semantics | Before Toggle Group implementation (phase 4) | open | +| D-02 | Alert Dialog initial focus API | Keep optional `initialFocusNode`; document safe-target heuristics; explicit canonical examples | Before Alert Dialog PR approval (phase 1) | open | +| D-03 | Context Menu trigger semantic action | Preserve child role + long-press semantic action; no fake button; prototype with VoiceOver/TalkBack | During Context Menu spike (phase 5) | open | +| D-04 | Toast composition API | Structured message/action/close helpers so duplicate message semantics can be excluded without hiding controls | Before Toast tests are written (phase 6) | open | +| D-05 | Toast global shortcut | Caller opt-in only; canonical example may use F8; never reserve a key by default | Before Toast PR approval (phase 6) | open | +| D-06 | Toast queue overflow | Unlimited pending queue or explicit nullable `maxQueued`; never drop silently without a dismissal reason | Before controller implementation (phase 6) | open | +| D-07 | Toast swipe in first release | Defer unless all alternate dismissal + deterministic drag tests fit the PR | At Toast scoping (phase 6) | open | +| D-08 | Field/TextField duplicate metadata | Debug-assert conflicting values; allow identical values; field scope is semantic source of truth | Before Field implementation (phase 3) | open | +| D-09 | Initial Field error announcement | Initial error discoverable but not automatically assertive; later transitions announce once | Before Field semantics tests (phase 3) | open | +| D-10 | Combobox active-option strategy | Choose only after the macOS/Android/web spike; status announcer is the leading fallback | Before Combobox public API freeze (phase 8) | open | +| D-11 | Combobox role on Flutter 3.41 | Use `SemanticsRole.comboBox` only if the prototype shows no regression; otherwise document fallback + upstream issue | During Combobox spike (phase 8) | open | +| D-12 | Naked UI minimum Flutter | Keep `>=3.41.0` only if an exact 3.41.0 CI job passes; otherwise raise the minimum deliberately | Phase 0 (test-harness PR) | [resolved(keep `>=3.41.0`; exact-minimum CI)](#phase-0-decision-evidence-2026-07-12) | +| D-13 | Example platform directories | Commit reviewed minimal platform files or generate reproducibly in CI; job names must match actual devices | Phase 0 (test-harness PR) | [resolved(commit reviewed Android/macOS/web directories)](#phase-0-decision-evidence-2026-07-12) | +| D-14 | Golden host/font pinning | One Ubuntu image, Flutter 3.41.2, fixed surface config, checked-in licensed test font | Phase 0 (test-harness PR) | [resolved(Ubuntu 24.04, Flutter 3.41.2, Roboto Apache-2.0, fixed harness)](#phase-0-decision-evidence-2026-07-12) | +| D-15 | Android/web PR frequency | Affected-path PR/merge-queue jobs; release blocked unless both passed on the exact release commit | Phase 0 (test-harness PR) | [resolved(affected PR/merge queue plus exact-tag release gates)](#phase-0-decision-evidence-2026-07-12) | + +## Risk register + +The risk register lives in the briefing +([§24.2](briefing.md#242-risk-register)) and stays there; link a risk row from +a phase plan when it becomes active. + +## Escalation rule ([§24.3](briefing.md#243-escalation-rule)) + +Block the component (not the program) when: real screen-reader behavior +contradicts the semantics contract; a supported target cannot perform a +required keyboard/touch path; focus escapes or is lost after a standard close +path; a required test is flaky without retries; base behavior needs product +styling or business logic to work; or an unresolved Flutter limitation would +make the release claim misleading. diff --git a/plan/phases/00-test-harness.md b/plan/phases/00-test-harness.md new file mode 100644 index 00000000..80c489df --- /dev/null +++ b/plan/phases/00-test-harness.md @@ -0,0 +1,244 @@ +# Phase 0 — Test-harness hardening + +Status: **Closed** — delivered by +[PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged to `main` as +`58a48a3` from reviewed head `3cb5487`. + +Goal: make it impossible for later component work to look green while proving +nothing. Today the integration harness can swallow keyboard failures, "verify" +tab order without checking focus, skips Tooltip entirely, runs a "macOS" CI job +on `flutter-tester`, and has no screenshot/golden/guideline evidence at all. +Fix the harness first so every later phase inherits trustworthy gates. + +Contract: briefing [§6.2](../briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite) +and [§21](../briefing.md#21-integration-screenshot-golden-and-ci-implementation). +Baseline commit: `0ca0b8b` (all file:line references below verified 2026-07-12). + +Decisions **D-12–D-15** were resolved with maintainer approval on 2026-07-12 +in [decisions.md](../decisions.md). Their decision-dependent tasks are included +in this implementation. + +Split guidance: land **Group A + B1** as one PR (pure repairs, releasable), +then **B2–B6** as CI PRs once D-12/D-13/D-15 are resolved. **Group C** may land +here or ride the first component PR (phase 1), per §6.2 — do not let it slip +past phase 1. + +--- + +## Group A — repair the existing harness (no decisions needed) + +### A1. Fix the stale integration runner path +- **Where:** `tool/run_integration_all.sh:9` — `pushd example`, but the app + lives at `packages/example`. The script fails on first use today. +- **How:** change to `pushd packages/example`. +- **Verify:** `bash tool/run_integration_all.sh flutter-tester` runs every + component file to completion from the repo root. + +### A2. Add Tooltip to the aggregate runner +- **Where:** `packages/example/integration_test/all_tests.dart:6-17` imports 12 + component files; `components/naked_tooltip_integration.dart` exists on disk + but is never imported, so it silently doesn't run in CI. +- **How:** add the import and a `group('Tooltip Tests', tooltip_tests.main)` + entry. Then add a guard so this can't recur: a small test (or CI step) that + lists `integration_test/components/*.dart` and fails if any file is missing + from `all_tests.dart` (briefing §21.1 item 8 / "inventory check"). +- **Verify:** aggregate run on `flutter-tester` executes Tooltip groups; + deleting the import makes the inventory check fail. + +### A3. Make `testKeyboardActivation` throw instead of returning false +- **Where:** `packages/example/integration_test/helpers/keyboard_test_helpers.dart:47-72` + — wraps key sends in `try/catch`, returns `bool`, callers can (and do) ignore + it. A failed key event currently passes CI. +- **How:** per briefing [§12.4](../briefing.md#124-keyboard-test-standard): + remove the try/catch and the `bool` return; focus via a known node (or tap + when pointer-focus is the subject), pump one frame, **assert the target has + primary focus**, send the key, pump the needed duration, and let callers + assert the outcome (value change, overlay, callback count). Update all 8 + call-site files: `naked_button_integration.dart`, + `naked_dialog_integration.dart`, `naked_popover_integration.dart`, + `naked_toggle_integration.dart`, `naked_checkbox_integration.dart`, + `naked_tabs_integration.dart`, `naked_accordion_integration.dart`, + `naked_radio_integration.dart` — each must assert the post-key outcome, not + the helper's return. +- **Verify:** aggregate green on `flutter-tester`; sanity-check by temporarily + sending a wrong key in one test and confirming the suite **fails**. + +### A4. Make `verifyTabOrder` assert actual focus +- **Where:** same helpers file, lines 24-40 — after `nextFocus()` it only + asserts `findsOneWidget` (comment admits it: "Just verify the widget + exists"). Tab order is currently unproven. +- **How:** after each traversal step, assert + `FocusManager.instance.primaryFocus` is the node attached to + `expectedOrder[i]` (e.g. compare against `Focus.of(element(...))` / + the widget's `FocusNode`). Include disabled-item skips where used. +- **Verify:** reorder two finders in one existing call and confirm the test + fails; restore. + +### A5. Remove the real 2-second delay and the 30-minute blanket timeout +- **Where:** `packages/example/integration_test/all_tests.dart:22-24` + (`defaultTestTimeout = 30 minutes` conceals hangs) and `:31-35` + (`tearDownAll` with `Future.delayed(seconds: 2)` real sleep). +- **How:** set a bounded default (e.g. 2 minutes per test), drop the + `tearDownAll` delay — if a documented runner bug requires it, keep it with a + comment linking the issue (briefing §21.1 items 3, 7). +- **Verify:** aggregate run completes; total wall time drops; an intentionally + hung test times out at the bounded limit, not 30 minutes. + +### A6. Bounded cleanup and a `pumpUntil` helper +- **Where:** `cleanupBetweenTests` (helpers file, lines 8-20) calls + `pumpAndSettle()`, which hangs on components with live timers or repeating + animations — fatal once Toast/Hover Card exist. 156 `pumpAndSettle` calls + exist across `integration_test/` overall. +- **How:** add the bounded `pumpUntil` helper from briefing + [§21.2](../briefing.md#212-deterministic-pumping) (fails with a diagnostic + after a frame budget); rewrite `cleanupBetweenTests` to use bounded pumps. + Do **not** rewrite all 156 call sites now — that churn belongs to each + component phase; this task only makes shared helpers timer-safe and provides + the tool. +- **Verify:** existing aggregate stays green; `pumpUntil` has its own small + test proving it fails (not passes) when the condition is never met. + +## Group B — CI gates + +### B1. Run the shell runner (or an inventory smoke) in CI +- **Why:** A1/A2 regressions must fail a PR, not be discovered manually + (briefing §21.1 item 8). +- **How:** add a fast job/step that executes + `bash tool/run_integration_all.sh flutter-tester` (or at minimum the + inventory check from A2 plus one component file via the script). +- **Verify:** break the path locally on a branch → CI fails. + +### B2. Make the macOS job actually run macOS *(D-13 resolved)* +- **Where:** `.github/workflows/integration-tests.yml:46` runs + `-d flutter-tester` on a `macos-latest` runner under the name "Integration + Tests". `packages/example` has **no** committed `macos/` directory, so + `-d macos` cannot run today. +- **How:** resolve D-13 (commit minimal reviewed platform files vs + `flutter create --platforms macos .` in CI), then split the workflow into a + fast `flutter-tester` smoke job and a real `-d macos` job. Job names must + match the device they run (briefing §21.7-21.8). +- **Verify:** CI log shows the run targeting `macos`; a focus-dependent test + passes there. + +### B3. Gate Android on PRs or nightly + release *(D-15 resolved)* +- **Where:** `.github/workflows/integration-android.yml:3-4` — + `workflow_dispatch` only; nobody is required to run it. +- **How:** per D-15: affected-path `pull_request` trigger or nightly schedule, + and a release gate requiring a passing run on the exact release commit. +- **Verify:** open a PR touching `packages/naked_ui/lib/` → Android job queues + (or nightly run visible + release checklist references it). + +### B4. Add a web integration workflow *(D-13, D-15 resolved)* +- **Why:** no web gate exists; web is where semantics-DOM and keyboard behavior + diverge most. The driver entry already exists + (`packages/example/test_driver/integration_test.dart`). +- **How:** pinned Chrome/ChromeDriver job running + `flutter drive --driver=test_driver/integration_test_behavior.dart --target=integration_test/all_tests.dart -d chrome` + after web platform files exist (D-13), per briefing §21.7. +- **Verify:** job green in CI; artifacts uploaded. + +### B5. Exact-minimum SDK job *(D-12 resolved)* +- **Where:** package floor is Flutter `>=3.41.0` + (`packages/naked_ui/pubspec.yaml:12`) but every CI job pins `3.41.2` — the + declared minimum is untested. +- **How:** per D-12: either add an analyze/unit/semantics job on exactly + `3.41.0`, or deliberately raise the floor to `3.41.2` in a changelog'd PR. +- **Verify:** the job exists and passes, or the floor is raised — no third state. + +### B6. Stop the advisory coverage theater +- **Where:** `.github/workflows/ci.yml:79` — coverage check is + `continue-on-error: true` with actual 7.3% vs an advertised 80% target. +- **How:** don't jump to a blocking 80% (it would be a lie in the other + direction). Agree a component-level rule instead: new/changed component + source in a phase PR must come with its contract tests (process.md Phase B), + and the coverage job reports without claiming a threshold it doesn't enforce. + Make the agreed rule blocking; delete the misleading 80% label. +- **Verify:** CI output no longer advertises an unenforced threshold. + +## Group C — evidence infrastructure (may ride phase 1; do not slip past it) + +### C1. Screenshot helper + manifest +- **How:** shared helper per briefing + [§21.3](../briefing.md#213-screenshot-helper) (single + `IntegrationTestWidgetsFlutterBinding`, `convertFlutterSurfaceToImage()` on + Android, capture **after** assertions pass, conditional io/web imports), + artifact naming `component__scenario__platform__theme.png`, and the manifest + fields from [§21.4](../briefing.md#214-screenshot-manifest). CI uploads + screenshots + manifest as artifacts. +- **Verify:** one existing component (e.g. Dialog) produces a named screenshot + artifact in CI on macOS. + +Implementation deviation (2026-07-12): Flutter 3.41.2's `integration_test` +package registers native screenshot plugins only for Android and iOS; invoking +`takeScreenshot` on macOS throws `MissingPluginException`. The helper therefore +uses a fixed `RepaintBoundary.toImage()` desktop fallback and inserts its PNG +bytes into the binding's standard `screenshots` report data. Android uses the +native surface conversion/capture path. Capture remains a dedicated +`screenshot_smoke.dart` target so the full behavioral aggregate and evidence +capture have independent blocking results without retries. The host driver +uses the standard request-data protocol and validates/writes the PNG bytes and +manifest; the behavior aggregate uses a minimal standard driver. + +Web screenshot evidence is explicitly unsupported on Flutter 3.41.2: its +WebDriver screenshot command timed out before invoking the host callback, while +the web repaint-boundary fallback produced inconsistent transparent regions +across identical pinned runs. The web implementation therefore throws a clear +unsupported error if capture is requested. The blocking pinned-Chrome behavior +aggregate remains enabled and uploads its test log; later component phases must +not claim reviewed web screenshots until this engine limitation is resolved. + +### C2. Golden harness *(D-14 resolved)* +- **How:** `packages/example/test/goldens/components/` harness pinning SDK, + surface, DPR, locale/direction, text scale, brightness, fonts via + `FontLoader`, animation state ([§21.5](../briefing.md#215-golden-harness)). + Baseline update flow is the two-command sequence in §21.5 plus reviewed + image diffs. +- **Verify:** one golden for an existing component passes twice in CI + (deterministic) and fails on an intentional 1px change. + +### C3. Accessibility-guideline fixture helper +- **How:** helper applying `labeledTapTargetGuideline`, + `androidTapTargetGuideline`, `iOSTapTargetGuideline`, + `textContrastGuideline` to canonical styled examples + ([§21.6](../briefing.md#216-accessibility-guideline-fixture)) — guidelines + run against the styled example, never the headless wrapper alone. +- **Verify:** guideline test green for one existing canonical example; fails + when a label is removed. + +--- + +## Acceptance + +- [x] A1–A6 implemented; aggregate integration suite green on `flutter-tester` +- [x] A3/A4 proven to fail on induced errors (no catch-and-continue remains) +- [x] Inventory check prevents un-imported integration files (A2) +- [x] B1 smoke and B2 real-macOS job implemented; local targeted and hosted + aggregate macOS runs are green +- [x] B3/B4/B5 implemented per resolved D-12/D-13/D-15 +- [x] B6: no CI output advertises an unenforced threshold +- [x] C1 helper/manifest implemented; named 800×600 real-macOS artifact produced + and visually inspected locally +- [x] C2 golden harness implemented; update/verify is deterministic and an + intentional 1px mutation was proven to fail +- [x] C3 accessibility-guideline helper and canonical fixture implemented; + unlabeled-target failure is covered +- [x] Existing widget and integration suites still pass +- [x] `plan/README.md` status board + `plan/decisions.md` D-12…D-15 updated + +Phase 0 is closed. PR #63 at reviewed head `3cb5487` was squash-merged to +`main` as `58a48a3`. It has green hosted proof for the main and exact-minimum +test suites, `flutter-tester`, real macOS, pinned headless Chrome, and the API +34 Android emulator; the workflows triggered by the merged commit also passed. +Android could not be run locally because this machine has no Android +SDK/emulator, so the hosted job is the authoritative Android evidence. + +## Verify commands + +```sh +flutter pub get +dart format --set-exit-if-changed . +flutter analyze +flutter test packages/naked_ui/test +bash tool/run_integration_all.sh flutter-tester +cd packages/example && flutter test -r compact -d flutter-tester integration_test/all_tests.dart +``` diff --git a/plan/process.md b/plan/process.md new file mode 100644 index 00000000..7588df2e --- /dev/null +++ b/plan/process.md @@ -0,0 +1,103 @@ +# Per-component process + +Every component phase follows this workflow. It is a navigation layer over the +briefing — the briefing sections linked here are the binding contract; this +file just makes them executable in order. Do not restate contract details here. + +## Ground rules (apply to every PR) + +- **Headless boundary** — behavior/semantics in Naked UI; styling, copy, and + business rules stay in consumers ([§5](briefing.md#5-definition-of-the-headless-boundary)). +- **API conventions** — builder/child invariant, controlled state, controller + ownership, effective-enabled, localization (no hard-coded English), reduced + motion ([§9](briefing.md#9-cross-component-api-conventions)). +- **Semantics contract** — answer every row of the universal matrix + ([§10](briefing.md#10-universal-semantics-contract)); mind the Flutter 3.41.2 + role caveats ([§10.2](briefing.md#102-important-flutter-3412-caveat)). +- **Keyboard/focus rules** — test outcomes, not key sends; use + `Shortcuts`/`Actions`/`FocusTraversalGroup`; directionality and restoration + rules ([§11](briefing.md#11-universal-keyboard-and-focus-rules)). +- **One behavior contract per PR**, each PR releasable + ([§7](briefing.md#7-delivery-sequence-and-pull-request-boundaries)). +- Confirmed vs proposed vs open-decision language is defined in + [§2.1](briefing.md#21-confirmed-facts-versus-proposals). Open decisions are + resolved in [decisions.md](decisions.md) **before** the implementation PR. + +## Workflow (briefing [§8](briefing.md#8-required-implementation-process)) + +| Phase | Deliverable | Gate | +|---|---|---| +| **A — Contract review** | Component contract section copied into the issue/PR; semantics matrix written; every input path, focus path, and timer listed; engine limitations recorded | Reviewer sign-off on the contract, decisions resolved | +| **B — Failing tests** | Tests in the order of §8 Phase B (invariants → builder/scope → pointer → keyboard/focus → semantics → overlay → timers → disposal) | Each test fails for the intended missing behavior | +| **C — Implementation** | Smallest behavior surface; existing `NakedState`/builder/scope conventions; injectable durations; no styles, no English defaults | Analyze/format/unit green | +| **D — Example fixture** | Deterministic canonical example: stable `ValueKey`s, local data, state readout, fixed viewport, RTL/large-text variants, reset behavior | Fixture reviewed against §8 Phase D list | +| **E — Platform proof** | flutter-tester → real macOS → Android paths → web keyboard/semantics DOM; screenshots; goldens; accessibility guidelines; manual AT checks | All layers in [§12.1](briefing.md#121-required-test-layers) covered or explicitly N/A | +| **F — Handoff packet** | The 10-item evidence package ([§22.1](briefing.md#221-per-component-pr-contents)) incl. traceability table ([§22.2](briefing.md#222-requirement-traceability-table-template)), manual AT records ([§22.3](briefing.md#223-manual-accessibility-result-template)), screenshot review ([§22.4](briefing.md#224-screenshot-review-template)) | Reviewer answers the API review questions ([§22.5](briefing.md#225-api-review-questions)) | + +## Test standards (non-negotiable) + +- Semantics tests follow the 10-point standard in + [§12.3](briefing.md#123-semantics-test-standard). +- Keyboard tests follow [§12.4](briefing.md#124-keyboard-test-standard) — no + catch-and-continue helpers, assert postconditions. +- Deterministic pumping: exact pumps or bounded `pumpUntil`; `pumpAndSettle()` + is forbidden for timer/repeating-animation components + ([§21.2](briefing.md#212-deterministic-pumping)). +- File placement and naming: [§12.2](briefing.md#122-proposed-file-names). + Export from `packages/naked_ui/lib/src/naked_widgets.dart`; add the + integration main to `packages/example/integration_test/all_tests.dart` — a + test file not in the aggregate runner is not delivered. +- Flake policy: no `continue-on-error` on required checks, no real sleeps, + quarantine requires issue+owner+date ([§21.10](briefing.md#2110-flake-policy)). +- Disposal/leak checklist for every overlay/timer component + ([§21.11](briefing.md#2111-leak-and-disposal-checks)). + +## Definition of done + +A component is done only when every box in +[§23.1](briefing.md#231-package-level-definition-of-done) is checked, including +real macOS/Android/web runs, reviewed screenshots, manual VoiceOver/TalkBack +records, and a changelog naming any semantic or keyboard behavior change. +Escalation conditions that block a component are listed in +[§24.3](briefing.md#243-escalation-rule). + +## Local commands + +Root of this repo (see [§21.7](briefing.md#217-exact-local-commands) for the +full set including Android/web): + +```sh +flutter pub get +dart format --set-exit-if-changed . +flutter analyze +flutter test packages/naked_ui/test +flutter test packages/example/test + +# fast integration smoke +cd packages/example +flutter test -r compact -d flutter-tester integration_test/all_tests.dart + +# real macOS (after Phase 0 lands platform files) +flutter test -r compact -d macos integration_test/all_tests.dart +``` + +## Phase plan file template + +Create `phases/NN-.md` when a phase starts: + +```markdown +# Phase NN — + +Goal: +Contract: briefing § (binding). Decisions resolved: D-xx (link decisions.md). +Baseline commit: + +## Tasks +For each task: What / Where (file:line) / How / Verify (exact command). +Order tasks so the PR stays releasable at every merge point. + +## Acceptance +- [ ] Contract checklist from briefing § acceptance section +- [ ] Existing suite green (widget + integration aggregate) +- [ ] Status board + decisions.md updated +``` From d341b90e7b09e13f83da299b4ed17ae0eaa9ddee Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 12 Jul 2026 23:23:37 -0400 Subject: [PATCH 02/18] docs: harden component phase planning --- plan/README.md | 45 ++++++- plan/decisions.md | 16 ++- plan/integration-testing.md | 229 ++++++++++++++++++++++++++++++++++++ plan/process.md | 30 ++++- 4 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 plan/integration-testing.md diff --git a/plan/README.md b/plan/README.md index d8a52298..1d4dc5a6 100644 --- a/plan/README.md +++ b/plan/README.md @@ -14,9 +14,10 @@ business rules ([briefing §5](briefing.md#5-definition-of-the-headless-boundary |---|---|---| | [briefing.md](briefing.md) | Full handoff contract (per-component behavior, semantics, tests, evidence) | **Frozen** — reference only | | [process.md](process.md) | The repeatable per-component workflow and PR gates | Stable | +| [integration-testing.md](integration-testing.md) | Mandatory runner, determinism, evidence, and failure-triage playbook | Stable | | [decisions.md](decisions.md) | Decision log D-01…D-15 and escalation rule | **Living** — update as decisions resolve | | README.md (this file) | Index and status board | **Living** — update every phase PR | -| `phases/NN-*.md` | Executable plan for one phase | Created just-in-time when a phase starts | +| [phases/](phases/) using the NN-name.md convention | Executable plan for one phase | Created just-in-time when a phase starts | ## Status board @@ -27,7 +28,7 @@ do not pre-write plans for phases whose blocking decisions are unresolved. | Phase | Scope | Contract | Blocking decisions | Plan | Status | |---:|---|---|---|---|---| | 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | -| 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 | — | Not started | +| 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Ready for phase plan | | 2 | Link | [§20](briefing.md#20-component-contract-link) | — | — | Not started | | 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | | 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | @@ -41,12 +42,48 @@ prerelease 1 = phases 1–3, prerelease 2 = phases 4–5, prerelease 3 = phases Combobox ships alone after its spike passes. A blocked phase does not block independent phases. +## Program readiness + +Phase 0 made the harness trustworthy enough to begin component work. The +program is **ready to start targeted planning**, but it is not research-complete +for every phase and no phase may bypass its open decisions, spikes, or evidence +gates. + +| Phase | Start readiness | Required work before implementation | +|---:|---|---| +| 1 — Alert Dialog | **Ready for a just-in-time phase plan** | Re-verify the current `NakedDialog` baseline, carry the resolved D-02 focus contract into tests/examples, then create the phase plan. | +| 2 — Link | **Ready for a just-in-time phase plan** | Re-verify Flutter 3.41.0/3.41.2 link semantics and existing interaction-state patterns; no product decision currently blocks implementation. | +| 3 — Field | Blocked on decisions | Resolve D-08 metadata precedence and D-09 initial-error announcement policy before implementation/semantics tests. | +| 4 — Toggle Group | Blocked on compatibility decision | Resolve D-01 and document the consumer-facing `selected` to `toggled` announcement migration. | +| 5 — Context Menu | Spike/decision required | Resolve D-03 with a trigger-role/semantic-long-press prototype and real VoiceOver/TalkBack results. | +| 6 — Toast | Blocked on scope/API decisions | Resolve D-04–D-07 before tests or controller implementation; timer, queue, focus, and announcement contracts then become the phase plan. | +| 7 — Hover Card | Contract ready, dependency pending | Land Link first, then create the phase plan and verify reusable overlay positioning plus pointer-grace geometry. | +| 8 — Combobox | **Blocked on required accessibility spike** | Land Field, run the macOS/Android/web spike, then resolve D-10/D-11 before freezing the API. | + +Shared closure gates for every component: + +- Follow [integration-testing.md](integration-testing.md) and include its + per-phase checklist in the executable phase plan. +- Re-evaluate or explicitly resolve the Flutter 3.41.2 web-screenshot + limitation; a web behavior log is not a silent substitute for a required + screenshot. +- Schedule and record the required VoiceOver, TalkBack, Chrome accessibility + tree, and release-level iOS checks. Automated semantics alone are not enough. +- Re-verify the phase's current-code baseline when its just-in-time plan is + created; the original `0ca0b8b` audit is historical evidence, not a permanent + assumption. + +Recommended next move: start the Phase 1 Alert Dialog plan in the nominal +delivery order. Phase 2 Link remains independently ready if Phase 1 becomes +blocked during implementation. Do not begin all phases in parallel. + ## How to work a phase 1. Resolve the phase's blocking decisions in [decisions.md](decisions.md) first — nothing is decided silently inside an implementation PR. -2. Create `phases/NN-.md` by deriving tasks from the phase's briefing - contract section (see the template at the end of [process.md](process.md)). +2. Create an NN-name.md plan under [phases/](phases/) by deriving tasks from + the phase's briefing contract section (see the template at the end of + [process.md](process.md)). 3. Follow the workflow in [process.md](process.md) (contract review → failing tests → implementation → fixture → platform proof → evidence packet). 4. In the phase's final PR, update this status board and any resolved rows in diff --git a/plan/decisions.md b/plan/decisions.md index 19f39ffb..ebb88405 100644 --- a/plan/decisions.md +++ b/plan/decisions.md @@ -14,6 +14,20 @@ choosing an SDK-floor policy, platform-directory strategy, golden host/font, or Android/web cadence. The maintainer approved the evidence-backed D-12–D-15 recommendations on 2026-07-12 before decision-dependent implementation began. +### Phase 1 decision evidence (2026-07-12) + +- **D-02:** approved the optional caller-owned `initialFocusNode`. When the + supplied node is available and focusable, the alert dialog focuses it after + opening; otherwise normal route focus chooses the first focusable descendant. + Canonical examples must focus the least destructive action for irreversible + work, the expected action for a simple acknowledgement, or a non-action + semantic container near the start of long or structured content. Naked UI + never disposes the caller's node. This follows the + [WAI-ARIA modal-dialog focus guidance](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/), + while retaining the target control exposed by established primitives such as + [React Spectrum AlertDialog](https://react-spectrum.adobe.com/Dialog) and + [Radix Alert Dialog](https://www.radix-ui.com/primitives/docs/components/alert-dialog). + ### Phase 0 decision evidence (2026-07-12) - **D-12:** the official Flutter repository contains the exact `3.41.0` tag. @@ -35,7 +49,7 @@ recommendations on 2026-07-12 before decision-dependent implementation began. | ID | Decision | Briefing recommendation | Must resolve by | Status | |---|---|---|---|---| | D-01 | Toggle option semantics migration (`selected` → `toggled`) | Use button + `toggled` for all Toggle Group modes; changelog + announcement note; keep Radio Group for radio semantics | Before Toggle Group implementation (phase 4) | open | -| D-02 | Alert Dialog initial focus API | Keep optional `initialFocusNode`; document safe-target heuristics; explicit canonical examples | Before Alert Dialog PR approval (phase 1) | open | +| D-02 | Alert Dialog initial focus API | Keep optional `initialFocusNode`; document safe-target heuristics; explicit canonical examples | Before Alert Dialog PR approval (phase 1) | [resolved(optional caller-owned node plus documented safe-target heuristics)](#phase-1-decision-evidence-2026-07-12) | | D-03 | Context Menu trigger semantic action | Preserve child role + long-press semantic action; no fake button; prototype with VoiceOver/TalkBack | During Context Menu spike (phase 5) | open | | D-04 | Toast composition API | Structured message/action/close helpers so duplicate message semantics can be excluded without hiding controls | Before Toast tests are written (phase 6) | open | | D-05 | Toast global shortcut | Caller opt-in only; canonical example may use F8; never reserve a key by default | Before Toast PR approval (phase 6) | open | diff --git a/plan/integration-testing.md b/plan/integration-testing.md new file mode 100644 index 00000000..0b8c0234 --- /dev/null +++ b/plan/integration-testing.md @@ -0,0 +1,229 @@ +# Integration testing playbook + +This is the operational companion to the binding verification contracts in +[briefing §12](briefing.md#12-verification-architecture) and +[briefing §21](briefing.md#21-integration-screenshot-golden-and-ci-implementation). +It applies to every component phase. If this playbook and the frozen briefing +ever conflict, stop and resolve the conflict explicitly; do not weaken the +briefing silently. + +The rules below incorporate the failures and fixes from Phase 0 / PR #63. + +## 1. Choose the cheapest test layer that proves the behavior + +- Use unit tests for pure state, equality, controller, queue, timer, and + geometry logic. +- Use widget and semantics tests for one component tree, callbacks, focus, + keyboard mappings, semantics properties/actions, disposal, and fake-frame + timing. +- Use integration tests only for behavior that needs the canonical example or + a real platform: platform focus, pointer/hover, text input, overlays, + browser behavior, screenshots, and cross-component flows. +- Keep data local and deterministic. Register fakes before pumping the app; no + production services, live APIs, current time, randomness, or network images. +- Give every driven control a stable `ValueKey`. A phase plan must list the + fixture keys before implementation begins. + +Every contract requirement must map to the cheapest owning test plus any +real-target proof it needs. An integration test is not a substitute for exact +widget or semantics assertions. + +## 2. Authoritative runners + +| Proof | Authoritative command/path | Rule | +|---|---|---| +| Fast aggregate | `flutter test -r compact -d flutter-tester integration_test/all_tests.dart` from `packages/example` | Proves aggregate registration and shared behavior quickly. | +| Real macOS behavior | Same command with `-d macos` | Must run on a real macOS target; a macOS host using `flutter-tester` is not macOS proof. | +| Android behavior | `flutter test -r compact -d integration_test/all_tests.dart` | Native behavior is authoritative. `flutter drive` is reserved for screenshot/report-data transport. | +| Web behavior | `flutter drive` with `test_driver/integration_test_behavior.dart`, `-d web-server`, and `--browser-name=chrome` | Flutter web integration uses a driver; do not replace this with `flutter test -d chrome`. | +| Screenshot evidence | Dedicated `integration_test/screenshot_smoke.dart` with `test_driver/integration_test.dart` | Behavior must pass independently before evidence capture runs. | + +Use the checked-in workflows and scripts as the executable source of truth: + +- `.github/workflows/integration-tests.yml` +- `.github/workflows/integration-android.yml` +- `.github/workflows/integration-web.yml` +- `tool/run_android_integration.sh` +- `tool/run_integration_all.sh` + +Do not combine native behavior and host-transport evidence into one result that +can hide an in-app failure. Keep the behavior aggregate and screenshot smoke as +separate blocking steps. + +## 3. Aggregate registration + +- Add every `integration_test/components/*_integration.dart` file to + `integration_test/all_tests.dart` and invoke its `main` in a named group. +- Run `packages/example/test/integration_inventory_test.dart`; importing or + creating a file without registering it in the aggregate is not delivery. +- Run the new component file directly while iterating, then run the aggregate. + A direct-file pass alone is insufficient. +- Keep the aggregate timeout bounded. Do not raise the shared two-minute + per-test timeout to make one slow or hung test green. + +## 4. Assertions must prove outcomes + +- Focus through a known `FocusNode`, pump one frame, assert primary focus, + send one complete logical key event, then assert the value, callback, overlay, + route, focus, or visible result. +- For disabled paths, prove focus/action refusal and assert no state changed. +- Test pointer and semantic activation against the same observable callback + contract where both are supported. +- Do not assert only that a widget still exists after an interaction. +- Do not catch and return `false`, catch and log, or otherwise turn a failed + key, pointer, semantics, screenshot, or cleanup operation into a green test. +- Cleanup runs in `finally`/teardown and cleanup failures fail the test. Never + suppress `removePointer`, gesture release, controller disposal, or overlay + removal errors. + +## 5. Deterministic time and pumping + +- Use `pump()` for one state/focus application frame. +- Use an exact `pump(duration)` only when the duration is a public invariant. +- Use the shared bounded `pumpUntil` for asynchronous observable state whose + completion time is not contractual. The predicate must observe the real + state under test, and timeout must fail with a diagnostic. +- Do not fix a slow runner with extra padding frames, a real + `Future.delayed`, retries, or a larger blanket timeout. +- `pumpAndSettle()` is allowed only when the tree is known to settle. It is + forbidden around live timers, cursor blinking, repeating/indeterminate + animation, pending hover-card delays, or active toast timers. +- Drive timers with fake frame time. Verify just-before, at-boundary, pause, + resume with remaining duration, cancellation, and disposal paths. +- Reset viewport/DPR, focus, pointer, lifecycle, controllers, overlays, and + any global test configuration through teardown-safe cleanup. + +The delayed hover failure from Phase 0 is the reference pattern: wait for the +observable hover state with a bound, not a guessed 32 ms frame; then propagate +pointer cleanup failures. + +## 6. Platform rules + +### macOS + +- Confirm `flutter devices` lists macOS and run with `-d macos`. +- If sandboxed IO/network behavior is introduced, review both debug/profile + and release entitlements. Run `flutter clean` after entitlement changes. +- A Flutter-tool foreground warning is evidence to investigate, not a reason + to retry. Reproduce the smallest failing file, inspect `flutter doctor -v`, + and compare with the pinned hosted macOS run. +- Moving stalls across unrelated tests indicate a runner/toolchain problem; + stable failure at one assertion indicates test or product behavior. Record + that distinction and never mask either with retries. + +### Android + +- Check `flutter devices` and `adb devices` before claiming a local run. +- Use `flutter test` for behavior and the checked-in Android script for the + behavior-plus-screenshot CI sequence. +- If no local SDK/emulator exists, say so and use the hosted API 34 result as + the authoritative Android proof. Missing local hardware is not permission to + omit the platform gate. +- Exercise touch/long-press, safe-area/keyboard inset, and TalkBack-oriented + paths on Android when the component contract requires them. + +### Web + +- Chrome and ChromeDriver must match by major version. Record both versions. +- Start ChromeDriver on port 4444, retain its PID, and arrange cleanup with a + shell trap or an equivalent lifecycle step. +- Use `-d web-server --browser-name=chrome`; keep browser dimensions explicit + for fixture evidence. +- Web-targeted Dart code must not import `dart:io`; use conditional imports. +- Flutter finders drive the Canvas-rendered widget tree. Browser URL, history, + cookies, or DOM-only assertions require a browser-level seam and must not be + inferred from widget finders. +- Preserve the full web log. A successful command must contain the success + marker and no in-app timeout/failure marker; a host process exit code alone + is not sufficient evidence. + +Flutter 3.41.2 cannot currently produce stable web screenshot evidence in this +repository. Do not claim a web screenshot passed. Before closing a component +phase, either establish a stable reviewed capture path on the pinned toolchain +or obtain an explicit maintainer decision for alternate evidence. Until then, +the pinned web behavior log is required but does not satisfy a binding web +screenshot requirement by itself. + +### iOS and assistive technology + +- The current automated matrix does not prove iOS. Record the required manual + iOS release check separately. +- Semantics-tree assertions do not replace VoiceOver, TalkBack, or Chrome + accessibility-tree results. Record target, OS/browser, AT version, actions, + expected versus actual behavior, tester, and date. + +## 7. Screenshots, goldens, and accessibility evidence + +- Run behavior assertions before capturing a screenshot. +- Real-target screenshots are review artifacts, not cross-platform pixel + goldens. Goldens use the pinned Ubuntu image, Flutter SDK, checked-in font, + 800×600 surface, DPR 1, locale/direction/text scale/brightness, and fixed + animation state. +- Android prepares the native surface once before capture. On Flutter 3.41.2, + macOS uses the reviewed `RepaintBoundary` fallback and standard binding + report data because the native screenshot plugin is unavailable. +- Use `component__scenario__platform__theme.png`; include the tested SHA, + Flutter version, target, surface, DPR, locale/direction, text scale, + animation mode, result, and reviewer in the manifest/handoff. +- For pull requests, distinguish the reviewed head SHA from GitHub's tested + merge-ref SHA. After merge, record the resulting main commit as well. +- Run accessibility guidelines on the canonical styled example, never only on + the headless wrapper. Dispose the semantics handle in teardown-safe cleanup. +- A screenshot proves appearance only. Keyboard, focus, semantics, lifecycle, + and callback behavior require separate assertions. + +## 8. Failure-triage protocol + +When any local or hosted integration check fails: + +1. Record the exact SHA, target/device, Flutter/Dart version, OS/Xcode or + Chrome/ChromeDriver version, command, first failing assertion, and full log. +2. Run the smallest failing component file on the same target; then run the + aggregate to detect ordering or leaked-state effects. +3. Reproduce at least twice without adding a retry to the gate. Compare other + targets only to classify the boundary, not to dismiss the failing target. +4. Inspect the observable state, focus node, pointer lifecycle, scheduled + frames, widget tree, and platform connection relevant to the first failure. +5. Fix the root condition. For timing, wait on observable state; for cleanup, + propagate the error; for platform setup, fix the target/toolchain. +6. Add or strengthen the smallest regression test and prove that it fails + against the old behavior before accepting the green result. +7. Rerun format, analysis, the focused regression, package/example suites, + aggregate `flutter-tester`, and every affected real target. +8. Monitor the exact PR head checks and the workflows triggered by the merged + commit. Do not rely on an older green SHA. + +Stop and escalate instead of merging when: + +- a required target cannot run and no authoritative hosted result exists; +- the same test remains flaky without a root cause; +- a required test is absent from the aggregate; +- a driver or helper can swallow an in-app or cleanup failure; +- required screenshot or manual AT evidence is unsupported or missing; +- a platform result contradicts the component contract. + +Quarantine is allowed only with a linked issue, owner, reason, and removal +date, and the component cannot be described as fully validated while +quarantined. + +## 9. Per-phase integration checklist + +Every just-in-time phase plan must name: + +- [ ] The requirement-to-test map and why each integration scenario needs a + real target rather than only a widget test. +- [ ] The integration file, aggregate group, stable fixture keys, deterministic + local data, and reset behavior. +- [ ] Pointer, keyboard/focus, semantics action, disabled, RTL, 200% text, + collision/inset, dynamic-removal, and disposal scenarios that apply. +- [ ] Exact pumps or observable `pumpUntil` conditions, timeout diagnostics, + and teardown ownership. +- [ ] The platform matrix: `flutter-tester`, real macOS, API 34 Android, pinned + web, and any explicit N/A with justification. +- [ ] Screenshot names, golden cases, manifest metadata, accessibility + guidelines, and manual AT sessions. +- [ ] Exact local commands, unavailable local targets, hosted jobs, artifact + locations, and reviewed/tested SHAs. + +A phase cannot close until every applicable item is evidenced or an explicit +blocking decision is recorded. diff --git a/plan/process.md b/plan/process.md index 7588df2e..2174a6e7 100644 --- a/plan/process.md +++ b/plan/process.md @@ -19,6 +19,9 @@ file just makes them executable in order. Do not restate contract details here. rules ([§11](briefing.md#11-universal-keyboard-and-focus-rules)). - **One behavior contract per PR**, each PR releasable ([§7](briefing.md#7-delivery-sequence-and-pull-request-boundaries)). +- **Integration proof is operational, not implied** — every phase follows + [integration-testing.md](integration-testing.md), including authoritative + runners, bounded waits, failure triage, artifacts, and manual AT evidence. - Confirmed vs proposed vs open-decision language is defined in [§2.1](briefing.md#21-confirmed-facts-versus-proposals). Open decisions are resolved in [decisions.md](decisions.md) **before** the implementation PR. @@ -27,11 +30,11 @@ file just makes them executable in order. Do not restate contract details here. | Phase | Deliverable | Gate | |---|---|---| -| **A — Contract review** | Component contract section copied into the issue/PR; semantics matrix written; every input path, focus path, and timer listed; engine limitations recorded | Reviewer sign-off on the contract, decisions resolved | +| **A — Contract review** | Component contract section copied into the issue/PR; semantics matrix written; every input path, focus path, timer, platform scenario, and known engine limitation listed | Reviewer sign-off on the contract, decisions resolved, integration playbook checklist mapped | | **B — Failing tests** | Tests in the order of §8 Phase B (invariants → builder/scope → pointer → keyboard/focus → semantics → overlay → timers → disposal) | Each test fails for the intended missing behavior | | **C — Implementation** | Smallest behavior surface; existing `NakedState`/builder/scope conventions; injectable durations; no styles, no English defaults | Analyze/format/unit green | | **D — Example fixture** | Deterministic canonical example: stable `ValueKey`s, local data, state readout, fixed viewport, RTL/large-text variants, reset behavior | Fixture reviewed against §8 Phase D list | -| **E — Platform proof** | flutter-tester → real macOS → Android paths → web keyboard/semantics DOM; screenshots; goldens; accessibility guidelines; manual AT checks | All layers in [§12.1](briefing.md#121-required-test-layers) covered or explicitly N/A | +| **E — Platform proof** | Follow [integration-testing.md](integration-testing.md): flutter-tester → real macOS → Android behavior → pinned web behavior; independent screenshot/golden evidence; accessibility guidelines; manual AT checks | All layers in [§12.1](briefing.md#121-required-test-layers) evidenced; unsupported requirements block closure unless explicitly resolved | | **F — Handoff packet** | The 10-item evidence package ([§22.1](briefing.md#221-per-component-pr-contents)) incl. traceability table ([§22.2](briefing.md#222-requirement-traceability-table-template)), manual AT records ([§22.3](briefing.md#223-manual-accessibility-result-template)), screenshot review ([§22.4](briefing.md#224-screenshot-review-template)) | Reviewer answers the API review questions ([§22.5](briefing.md#225-api-review-questions)) | ## Test standards (non-negotiable) @@ -49,6 +52,12 @@ file just makes them executable in order. Do not restate contract details here. test file not in the aggregate runner is not delivered. - Flake policy: no `continue-on-error` on required checks, no real sleeps, quarantine requires issue+owner+date ([§21.10](briefing.md#2110-flake-policy)). +- Native behavior uses `flutter test`; `flutter drive` is limited to web and + host-side screenshot/report-data transport. Behavior and evidence are + separate blocking results ([integration playbook](integration-testing.md)). +- Pointer/gesture cleanup errors propagate. Platform-specific failures follow + the playbook's root-cause protocol; retries and timeout inflation are not + fixes. - Disposal/leak checklist for every overlay/timer component ([§21.11](briefing.md#2111-leak-and-disposal-checks)). @@ -83,7 +92,7 @@ flutter test -r compact -d macos integration_test/all_tests.dart ## Phase plan file template -Create `phases/NN-.md` when a phase starts: +Create an NN-name.md plan under [phases/](phases/) when a phase starts: ```markdown # Phase NN — @@ -96,8 +105,23 @@ Baseline commit: For each task: What / Where (file:line) / How / Verify (exact command). Order tasks so the PR stays releasable at every merge point. +## Research and readiness +- Current-code baseline re-verified: +- Decisions resolved: +- Required spike/manual AT sessions: +- Known engine/platform limitations and stop conditions: + +## Integration proof plan +- Integration file and aggregate group: +- Stable fixture keys and deterministic data/reset: +- Scenario-to-platform matrix: +- Exact pumps / observable waits / teardown ownership: +- Screenshot, golden, guideline, and manual AT evidence: +- Exact local and hosted commands: + ## Acceptance - [ ] Contract checklist from briefing § acceptance section - [ ] Existing suite green (widget + integration aggregate) +- [ ] Integration playbook checklist evidenced on every applicable target - [ ] Status board + decisions.md updated ``` From ddb48488cdbef6c5d39fdcb9a73635425b872f6f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 01:14:44 -0400 Subject: [PATCH 03/18] plan: add Link phase plan --- plan/README.md | 11 +- plan/phases/02-link.md | 382 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 plan/phases/02-link.md diff --git a/plan/README.md b/plan/README.md index 1d4dc5a6..633b2c22 100644 --- a/plan/README.md +++ b/plan/README.md @@ -28,8 +28,8 @@ do not pre-write plans for phases whose blocking decisions are unresolved. | Phase | Scope | Contract | Blocking decisions | Plan | Status | |---:|---|---|---|---|---| | 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | -| 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Ready for phase plan | -| 2 | Link | [§20](briefing.md#20-component-contract-link) | — | — | Not started | +| 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Tracked independently in ready-for-review PR #64; closure blockers are recorded there | +| 2 | Link | [§20](briefing.md#20-component-contract-link) | — | [phases/02-link.md](phases/02-link.md) | **Active** — research complete; test-first implementation next | | 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | | 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | | 5 | Context Menu | [§15](briefing.md#15-component-contract-context-menu) | D-03 | — | Not started | @@ -73,9 +73,10 @@ Shared closure gates for every component: created; the original `0ca0b8b` audit is historical evidence, not a permanent assumption. -Recommended next move: start the Phase 1 Alert Dialog plan in the nominal -delivery order. Phase 2 Link remains independently ready if Phase 1 becomes -blocked during implementation. Do not begin all phases in parallel. +Recommended next move: execute the active Phase 2 Link plan test-first while +Phase 1 waits independently on its recorded evidence blockers. Do not begin +later phases in parallel and do not merge either PR without explicit maintainer +authorization. ## How to work a phase diff --git a/plan/phases/02-link.md b/plan/phases/02-link.md new file mode 100644 index 00000000..aed38e84 --- /dev/null +++ b/plan/phases/02-link.md @@ -0,0 +1,382 @@ +# Phase 2 — Link + +Status: **Active — current code and pinned Flutter behavior re-verified; +test-first implementation is next**. + +Goal: add a headless inline navigation primitive that exposes Link rather than +Button semantics, activates once through primary pointer, Enter, Numpad Enter, +or semantic tap, leaves Space and secondary click available to their normal +page/composition behavior, reports immutable interaction state, and delegates +routing, URL launching, styling, localized copy, and visited history to the +consumer. + +Contract: briefing [§20](../briefing.md#20-component-contract-link) (binding), +plus the cross-component rules in §§5 and 8–12. No open decision blocks Link. +Baseline commit: `d341b90e7b09e13f83da299b4ed17ae0eaa9ddee` (all current-code +findings below re-verified on 2026-07-13). + +## Research and readiness + +- No repository or ancestor `AGENTS.md` exists. The phase follows + `plan/process.md`, `plan/integration-testing.md`, `plan/decisions.md`, and the + binding briefing. +- The phase worktree is `.context/worktrees/phase-02-link` on branch + `feat/naked-link`, created directly from the approved baseline. It does not + stack on the unmerged Alert Dialog branch. +- There is no Link primitive, export, package test, example, registry entry, + integration group, golden, screenshot scenario, README entry, or changelog + entry in the baseline. +- The baseline Flutter 3.41.2 package suite passes 574 tests with three + intentional external-integration launcher skips. This is the pre-change + regression reference. +- Pinned Flutter 3.41.0 and 3.41.2 both expose `Semantics.link` and + `Semantics.linkUrl`. Both assert that a non-null URL requires the Link flag. + The pinned semantics source documents that `linkUrl` becomes the web DOM + `href`; the callback still remains application-owned. +- Flutter has no `SemanticsRole.link` in the pinned API. The binding contract's + `link: true` flag is the supported representation; adding Button semantics as + a fallback would be incorrect. +- Flutter's ambient `ActivateIntent` is bound to Space on every platform. On + web, Enter/Numpad Enter use `ButtonActivateIntent`, while Space uses a + prioritized Activate-then-scroll path. Reusing + `NakedIntentActions.button` or registering an `ActivateIntent` action would + let Space activate the Link. Link therefore needs a private Link-specific + intent mapped only from Enter and Numpad Enter; Space must remain absent from + its local shortcuts/actions. This follows the + [Flutter ActivateIntent contract](https://api.flutter.dev/flutter/widgets/ActivateIntent-class.html), + [WidgetsApp shortcut contract](https://api.flutter.dev/flutter/widgets/WidgetsApp/defaultShortcuts.html), + and [WAI-ARIA Link Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/). +- `NakedFocusableDetector` already owns internal focus nodes, borrows external + nodes, swaps them without disposing caller resources, preserves focus across + replacement, disables traditional traversal, exposes hover/focus callbacks, + and composes `Shortcuts`/`Actions`. `WidgetStatesMixin`, + `NakedStateScopeBuilder`, and `NakedState` provide the existing immutable + builder/scope pattern. +- `NakedButton` is useful only as a structural reference. Its Button semantics, + Space shortcut, long-press API, non-null click cursor default, and timer-based + keyboard press feedback do not satisfy the Link contract and will not be + generalized in this phase. +- A Link-specific `GestureDetector` can reserve only primary tap and leave + secondary click unhandled. It must exclude its gesture semantics so the + single outer Link node owns the semantic tap action. +- When `semanticLabel` overrides visible content, the semantics wrapper must + exclude descendant naming semantics so the accessible name appears once. + Without an override, visible text supplies the name. Decorative external-link + icons remain explicitly excluded in the canonical consumer fixture. +- Context Menu and Hover Card do not exist at this baseline. The Link PR can + prove that secondary click is unclaimed and hover/primary/keyboard paths are + independent, but binding scenario 6 and acceptance cannot close until the + later component PRs compose their real implementations around Link. +- Flutter 3.41.2 still cannot produce stable web screenshot evidence in this + repository. The three required Link web screenshots are explicit closure + blockers unless a stable pinned capture is established or a maintainer + approves alternate evidence. A passing web behavior log is not substituted. +- VoiceOver, TalkBack, Chrome accessibility-tree, and release-iOS VoiceOver + sessions require human operators. Automated semantics will not fill them. + +## Contract matrices + +### Universal semantics matrix + +| Dimension | Link answer | Automated owner | Real/manual proof | +|---|---|---|---| +| Primitive | Discoverable Link, never Button | exact semantics test | VoiceOver, TalkBack, Chrome tree | +| Name | visible child text, or one caller-localized `semanticLabel` override | semantics tests including Arabic | all AT sessions | +| Role/flags | `link: true`; `button` absent | exact flags test | all AT sessions | +| State | enabled is `enabled && onPressed != null`; focus/focusable follows effective focus | widget + semantics transitions | macOS/Android/web | +| Value/URL | optional `linkUrl` metadata; never launches by itself | state equality + semantics URL test | Chrome tree/href inspection | +| Actions | semantic tap only while effectively enabled | semantics action/callback tests | VoiceOver/TalkBack | +| Hint | optional caller-localized `semanticHint`, once | semantics test | all AT sessions | +| Children | visible label preserved unless an explicit label overrides it; decorative icon excluded by consumer | semantics/example tests | Chrome tree | +| Exclusion | `excludeSemantics` removes the Link semantics subtree and focus semantics | semantics test | N/A | +| Ownership | external focus nodes are borrowed and never disposed | lifecycle tests | aggregate teardown | + +### Input, state, and lifecycle matrix + +| Path | Enabled result | Disabled/null-callback result | Required assertion | +|---|---|---|---| +| Primary tap | one callback + optional feedback | no recognizer/callback/feedback | count and state transitions | +| Canceled primary sequence | press true → false; no callback | no state transition | pointer cancel test | +| Secondary click | unclaimed; no callback | unclaimed | gesture test and later Context Menu composition | +| Enter/Numpad Enter | one callback | no callback | known focus node + complete key event | +| Space | no Link callback and no Link press state | no callback | widget test; web scroll outcome | +| Semantic tap | same activation path once | action absent | semantics action test | +| Hover | state/callback true then false | no hover callback | mouse gesture test | +| Focus/Tab | state/callback and normal traversal | skipped in traditional traversal | focus and next-target assertions | +| Callback removal | immediately disabled, activation removed, transient press/hover cleared | already disabled | rebuild test | +| Focus-node replacement | listener moves; focused state handed off; neither external node disposed | same ownership | lifecycle test | +| Disposal | internal detector node/listeners removed; external node remains usable | same | teardown/no exception | + +## Requirement-to-test map + +| ID | Requirement | Cheapest automated owner | Required real proof | +|---|---|---|---| +| LINK-API-01 | Child/builder invariant; immutable state/scope includes URL and all widget states | `naked_link_test.dart` + hash contract | N/A | +| LINK-ACT-01 | Primary tap once; cancellation and secondary click do not activate | widget gesture tests | macOS/web hover+pointer; Android touch | +| LINK-KEY-01 | Enter and Numpad Enter activate; Space is unclaimed | widget shortcut tests | macOS and pinned web scroll/result | +| LINK-STATE-01 | Effective enabled controls activation, traversal, feedback, cursor, and disabled state | widget transitions + platform-channel feedback test | all behavior targets | +| LINK-STATE-02 | Hover/focus/press callbacks and builder/scope snapshots are exact | widget state tests | macOS/web fixture readout | +| LINK-LIFE-01 | Focus ownership/replacement/disposal and callback removal do not leak | lifecycle tests | aggregate teardown on macOS/web | +| LINK-SEM-01 | Link flag, URL, name, hint, enabled/focus/action exact; Button absent | `naked_link_semantics_test.dart` | VoiceOver/TalkBack/Chrome tree | +| LINK-SEM-02 | Disabled action absent; label override not duplicated; icon/exclusion correct; Arabic/RTL | semantics + example tests | all AT sessions | +| LINK-COMP-01 | Primary, secondary, hover, and keyboard paths compose without conflict | Link secondary-path test now; future Context Menu/Hover Card integration | later Phase 5/7 real targets | +| LINK-VIS-01 | Inline, hover, focus, disabled, external hint, 200% text, and RTL appearance | pinned golden + screenshot scenarios | reviewed macOS/Android/web artifacts | + +## Tasks + +### A1. Add failing public API, state, interaction, and lifecycle tests + +- **Where:** add `packages/naked_ui/test/src/naked_link_test.dart`; extend + `packages/naked_ui/test/hashcode_contract_test.dart` only for the new public + state type. +- **How:** first reference the binding API so the focused test fails to compile + because `NakedLink`/`NakedLinkState` do not exist. Then, before production + behavior, cover the constructor invariant; builder child and identical scope + snapshot; URL equality/hash; primary tap exactly once; canceled primary and + secondary gestures; Enter/Numpad Enter; Space with no callback or pressed + state; explicit and effective disabled paths; default/custom/basic cursors; + hover/focus/press callbacks; feedback only while enabled; dynamic callback + removal; autofocus/traversal; external focus ownership, replacement, and + disposal. +- **Red proof:** observe the missing API, then use the smallest targeted + assertions/mutations if several behaviors become green through shared + infrastructure. Record the first failing expectation for every group. +- **Verify:** `fvm flutter test packages/naked_ui/test/src/naked_link_test.dart` + and the focused hash test. + +### A2. Add failing exact semantics tests + +- **Where:** add + `packages/naked_ui/test/semantics/naked_link_semantics_test.dart`. +- **How:** use `ensureSemantics` with teardown-safe disposal and assert the full + node, not only a label finder: Link true; Button absent; URI; name; hint; + enabled; focusable/focused transitions; tap action enabled only; visible text + naming; explicit label replacing rather than concatenating child semantics; + disabled discoverability; Arabic label/hint in RTL; external icon excluded; + entire semantics subtree absent under `excludeSemantics`. Invoke the semantic + tap and assert the same callback count. +- **Red proof:** each group must fail because the Link node/metadata/action does + not exist, not because the finder is wrong. +- **Verify:** + `fvm flutter test packages/naked_ui/test/semantics/naked_link_semantics_test.dart`. + +### B1. Implement the smallest Link behavior surface + +- **Where:** add `packages/naked_ui/lib/src/naked_link.dart`; export it from + `packages/naked_ui/lib/src/naked_widgets.dart`; add a Link namespace/private + intent to `packages/naked_ui/lib/src/utilities/intents.dart`. +- **How:** implement `NakedLinkState` with state helpers, equality/hash, and + `linkUrl`. Implement the binding constructor exactly, including a nullable + `mouseCursor`, the child/builder assertion, and effective enabled + `enabled && onPressed != null`. Compose the existing state mixin, state scope, + focusable detector, primary-only gesture path, and one Link semantics node. + Route pointer, key, and semantic activation through one guarded method with + feedback. Map only Enter/Numpad Enter to a private Link intent; do not expose + an Activate/Button action and do not bind Space. Clear transient state when + effective enabled becomes false. Borrow the external focus node through the + detector and never dispose it. Use `Semantics(excludeSemantics: true)` only + when an explicit semantic label replaces child naming; use outer exclusion + plus disabled focus semantics for the advanced escape hatch. +- **Avoid:** router/launcher dependencies, visited state, modifier-click + synthesis, long-press ownership, raw key handlers, timers, styles, English + defaults, changes to Button, or a speculative generic pressable base class. +- **Verify:** focused A1/A2 tests, then + `fvm dart format --set-exit-if-changed packages/naked_ui/lib/src/naked_link.dart packages/naked_ui/lib/src/naked_widgets.dart packages/naked_ui/lib/src/utilities/intents.dart packages/naked_ui/test/src/naked_link_test.dart packages/naked_ui/test/semantics/naked_link_semantics_test.dart packages/naked_ui/test/hashcode_contract_test.dart` + and `fvm flutter analyze packages/naked_ui`. + +### C1. Add the deterministic styled fixture, guideline, and golden proof + +- **Where:** add `packages/example/lib/api/naked_link.0.dart` and + `packages/example/test/naked_link_example_test.dart`; register it in + `packages/example/lib/registry.dart`; extend + `packages/example/test/accessibility_guidelines_test.dart`; add + `packages/example/test/goldens/components/naked_link_golden_test.dart` and a + reviewed Ubuntu baseline under `.../baselines/`. +- **How:** create one resettable, locally controlled fixture containing inline + primary, disabled, external-hint, and next-focus targets plus visible result, + callback count, and hover/focus/press readout. Style only in the example with + visible focus, hover, pressed, and disabled treatments; preserve inline text + layout; exclude the decorative external icon; provide Arabic/RTL and 200% + text configurations; disable/fix animation for evidence. Use no network or + router. +- **Stable keys:** `link.primary`, `link.disabled`, `link.external`, + `link.result`, `link.next-focus`, plus `link.state`, `link.reset`, and a fixed + evidence surface key. +- **Golden:** default inline/focus-capable canonical surface at 800×600, DPR 1, + pinned Roboto, locale/direction/text scale/brightness fixed. Generate only + through the approved Ubuntu update-then-verify diagnostic, inspect the PNG, + and check it in unchanged with its SHA-256. +- **Guidelines:** prove label, standalone Android/iOS target size, and contrast + on the styled fixture without forcing inline links into a button-sized line + box. +- **Verify:** focused example, guideline, and golden commands, then all example + tests. + +### D1. Add component integration behavior and aggregate inventory + +- **Where:** add + `packages/example/integration_test/components/naked_link_integration.dart`; + import/group it in `packages/example/integration_test/all_tests.dart`; rerun + `packages/example/test/integration_inventory_test.dart`. +- **How:** drive only stable keys. Add five presently executable binding + scenarios: Tab → known Link focus → Enter → one result with retained focus; + focused Space → no callback (and observable page scroll on web); hover/down/up + → exact state readout and one result; semantic tap → same callback path; + disabled skipped by Tab with no pointer/semantic action. Add Arabic/RTL and + 200% long-text assertions, callback removal while focused, secondary click + remaining unclaimed, and teardown-safe external focus-node disposal. +- **Deferred composition:** do not create fake Hover Card/Context Menu + implementations. Record LINK-COMP-01 as a closure blocker and require Phase 5 + and Phase 7 integration suites to wrap the real `NakedLink` and prove the + sixth scenario. +- **Pumps/cleanup:** use one frame for synchronous focus/state transitions and + bounded observable waits only for web scrolling or platform attachment. + Restore view, DPR, direction, text scale, scroll controller, semantics handle, + mouse gesture, focus nodes, and fixture state. No `pumpAndSettle`, sleeps, + retries, swallowed key/gesture errors, or blanket timeout change. +- **Verify:** focused Link integration on `flutter-tester`, inventory, then the + aggregate. + +### D2. Capture required visual evidence without weakening behavior gates + +- **Where:** extend `packages/example/integration_test/screenshot_smoke.dart`, + `packages/example/test/screenshot_evidence_test.dart`, the exact artifact + assertions in `.github/workflows/integration-tests.yml`, and + `tool/run_android_integration.sh`; extend the Ubuntu golden diagnostic in + `.github/workflows/ci.yml` only as required for a genuinely absent baseline. +- **How:** assert scenario behavior before every capture. Produce and review + `link__default_inline__macos__reference.png`, + `link__keyboard_focus__macos__reference.png`, + `link__disabled__android__reference.png`, and + `link__long_text_200__macos__reference.png` with complete manifests. Keep the + three binding web names (`hover`, `external_hint`, `rtl`) explicitly blocked + by the documented Flutter 3.41.2 limitation; do not manufacture a widget + golden or behavior log as a substitute. +- **Verify:** behavior suites first; dedicated local macOS screenshot driver; + hosted API 34 behavior plus transport; exact file-name/size/manifest checks; + human visual review of every required PNG produced. Record exact head and + GitHub merge-ref separately. + +### E1. Complete docs, review, evidence, and PR handoff + +- **Where:** dartdoc in `naked_link.dart`; root and package READMEs; package + changelog; registry; this plan and `plan/README.md`; PR description. +- **How:** document Link-versus-Button use, callback/URL separation, effective + enabled, Enter/Numpad/Space behavior, state and focus ownership, semantics + override/icon rules, secondary/modifier-click boundaries, styling and router + non-goals, and Remix responsibilities. Build the §22 ten-item packet with the + stable requirement table, platform commands/runs, screenshot review, manual + AT rows, limitations, and exact SHAs. +- **Review:** inspect the entire diff for API drift, accidental Button/Space + behavior, duplicate semantics/names/actions, disabled descendants, + feedback/cursor/focus leaks, selection interference, router or styling scope, + duplicated helpers, speculative abstraction, timer use, and unrelated files. +- **Delivery:** stage only Phase 2 files, commit intentionally, push + `feat/naked-link`, and open one ready-for-review PR targeting `main`. Monitor + every applicable check on the exact PR head, manually dispatch affected-path + workflows if a documentation-only final commit would otherwise lack an exact + run, and do not merge without explicit maintainer authorization. + +## Integration proof plan + +### Scenario-to-platform matrix + +| Scenario | flutter-tester | real macOS | API 34 Android | pinned Chrome/web | +|---|---:|---:|---:|---:| +| Tab, Enter, one callback, retained focus | Yes | Required | Required focus path | Required | +| Space no activation | Yes | Required | Required | Required + scroll outcome | +| Pointer hover/press/tap state | Yes | Required | Touch/press required; hover N/A | Required hover | +| Semantic tap same callback | Yes | Required | Required | Required tree/action | +| Disabled skipped/no action/cursor | Yes | Required | Required + screenshot | Required | +| Arabic RTL + 200% long text | Yes | Required + screenshots | Required behavior | Required behavior; RTL screenshot blocked | +| Secondary click unclaimed | Yes | Required | N/A | Required | +| Real Context Menu/Hover Card composition | Not available | Deferred | Deferred | Deferred to Phase 5/7 | + +### Evidence and manual sessions + +- Pinned Linux golden: canonical Link surface, fixed 800×600/DPR 1/Roboto, + reviewed with SHA-256 and mutation/compare proof. +- macOS screenshots: default inline, keyboard focus, and 200% long text. +- Android screenshot: disabled state. Hosted API 34 is authoritative because + the local SDK is incomplete and no emulator/device is attached. +- Web screenshots: hover, external hint, and RTL are required but currently + unsupported. Pinned behavior log and Chrome-tree record remain separate. +- Manual sessions: macOS VoiceOver, API 34 TalkBack, pinned Chrome + accessibility tree/keyboard, and release-level iOS VoiceOver. Record exact + target/version/actions/expected/actual/tester/date; never infer them. + +## Verification and publication gates + +Focused development: + +```sh +fvm flutter test packages/naked_ui/test/src/naked_link_test.dart +fvm flutter test packages/naked_ui/test/semantics/naked_link_semantics_test.dart +fvm flutter test packages/naked_ui/test/hashcode_contract_test.dart +fvm flutter test packages/example/test/naked_link_example_test.dart +fvm flutter test packages/example/test/accessibility_guidelines_test.dart +fvm flutter test packages/example/test/goldens/components/naked_link_golden_test.dart +cd packages/example +fvm flutter test -r compact -d flutter-tester integration_test/components/naked_link_integration.dart +``` + +Required local publication gate from the repository root: + +```sh +fvm dart format --set-exit-if-changed . +fvm flutter analyze +fvm flutter test packages/naked_ui/test +fvm flutter test packages/example/test +cd packages/example +fvm flutter test -r compact -d flutter-tester integration_test/all_tests.dart +``` + +Additional exact proof: + +```sh +fvm flutter test packages/example/test/integration_inventory_test.dart +cd packages/example +fvm flutter test -r compact -d macos integration_test/components/naked_link_integration.dart +fvm flutter test -r compact -d macos integration_test/all_tests.dart +fvm flutter drive --driver=test_driver/integration_test.dart --target=integration_test/screenshot_smoke.dart -d macos --dart-define=NAKED_UI_CAPTURE_SCREENSHOTS=true --dart-define=NAKED_UI_GIT_SHA= --dart-define=NAKED_UI_FLUTTER_VERSION=3.41.2 +``` + +Hosted gates: primary and exact-minimum suites; canonical golden/guidelines; +`flutter-tester`; real macOS; API 34 Android behavior plus screenshot transport; +pinned Chrome/ChromeDriver behavior log; PR-title policy. Every result must be +green on the exact PR head or an identified GitHub merge ref. + +## Acceptance and stop conditions + +- [ ] Every A1/A2 test was observed failing for the intended missing behavior + before implementation and the red evidence is recorded. +- [ ] Link public API, state equality/scope, and effective-enabled behavior + match the binding contract without router, styling, or visited state. +- [ ] Primary/canceled/secondary pointer, Enter/Numpad/Space, semantic tap, + feedback, cursor, callbacks, and dynamic removal pass focused tests. +- [ ] Link/URL/name/hint/enabled/focus/action semantics are exact; Button and + duplicate naming are absent; disabled/excluded behavior passes. +- [ ] Focus-node ownership/replacement/disposal and aggregate teardown pass. +- [ ] Canonical fixture, stable result/reset/readout, Arabic RTL, 200% text, + external-icon exclusion, golden, and accessibility guidelines pass. +- [ ] Integration component, inventory, fast aggregate, real macOS aggregate, + hosted API 34, and pinned web behavior pass on the exact PR head. +- [ ] All seven screenshot names have reviewed evidence, or Phase 2 is + explicitly blocked; unsupported web screenshots are not marked passed. +- [ ] VoiceOver, TalkBack, Chrome accessibility-tree, and release-level iOS + records are attached; missing human evidence blocks closure. +- [ ] Real Context Menu and Hover Card composition proof is attached after + those components exist; placeholder wrappers do not satisfy it. +- [ ] Full publication commands and hosted Flutter 3.41.0 pass. +- [ ] Docs, changelog, compatibility statement, traceability, manifests, + visual review, and ten-item handoff packet are ready. +- [ ] Entire diff reviewed; ready-for-review PR open; exact-head checks green; + plan/status board contain final evidence; PR remains unmerged without + explicit maintainer authorization. + +Block Phase 2 closure (not independent program work) if Link maps as a Button, +Space activates or is swallowed on web, disabled paths retain activation or +focus, linkUrl lacks the required Link flag/href mapping, accessible naming is +duplicated, focus ownership leaks, any required target is retry-dependent, the +real composition scenario is unavailable, required web screenshots remain +unsupported, or manual AT evidence is unavailable. From ce529fc9b92235e07d532ddc406a3d50c4eea2ff Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 01:32:31 -0400 Subject: [PATCH 04/18] feat: add naked link --- packages/naked_ui/lib/src/naked_link.dart | 210 +++++++ packages/naked_ui/lib/src/naked_widgets.dart | 1 + .../naked_ui/lib/src/utilities/intents.dart | 27 + .../naked_ui/test/hashcode_contract_test.dart | 8 + .../semantics/naked_link_semantics_test.dart | 326 +++++++++++ .../naked_ui/test/src/naked_link_test.dart | 527 ++++++++++++++++++ 6 files changed, 1099 insertions(+) create mode 100644 packages/naked_ui/lib/src/naked_link.dart create mode 100644 packages/naked_ui/test/semantics/naked_link_semantics_test.dart create mode 100644 packages/naked_ui/test/src/naked_link_test.dart diff --git a/packages/naked_ui/lib/src/naked_link.dart b/packages/naked_ui/lib/src/naked_link.dart new file mode 100644 index 00000000..e4ff8351 --- /dev/null +++ b/packages/naked_ui/lib/src/naked_link.dart @@ -0,0 +1,210 @@ +import 'package:flutter/widgets.dart'; + +import 'mixins/naked_mixins.dart'; +import 'utilities/intents.dart'; +import 'utilities/naked_focusable_detector.dart'; +import 'utilities/naked_state_scope.dart'; +import 'utilities/state.dart'; + +/// Immutable view passed to [NakedLink.builder]. +class NakedLinkState extends NakedState { + /// Creates an immutable snapshot of Link interaction [states] and [linkUrl]. + NakedLinkState({required super.states, required this.linkUrl}); + + /// Optional URL exposed as Link semantics metadata. + final Uri? linkUrl; + + /// Returns the nearest [NakedLinkState] provided by [NakedStateScope]. + static NakedLinkState of(BuildContext context) => NakedState.of(context); + + /// Returns the nearest [NakedLinkState], if one is available. + static NakedLinkState? maybeOf(BuildContext context) => + NakedState.maybeOf(context); + + /// Returns the [WidgetStatesController] from the nearest state scope. + static WidgetStatesController controllerOf(BuildContext context) => + NakedState.controllerOf(context); + + /// Returns the nearest state scope's controller, if one is available. + static WidgetStatesController? maybeControllerOf(BuildContext context) => + NakedState.maybeControllerOf(context); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is NakedLinkState && + statesEqual(other) && + other.linkUrl == linkUrl; + } + + @override + int get hashCode => Object.hash(statesHashCode, linkUrl); +} + +/// A headless Link without default visuals or navigation ownership. +class NakedLink extends StatefulWidget { + /// Creates a headless Link. + const NakedLink({ + super.key, + this.child, + this.builder, + this.onPressed, + this.linkUrl, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.mouseCursor, + this.enableFeedback = true, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : assert( + child != null || builder != null, + 'Either child or builder must be provided', + ); + + /// The visual Link content. + final Widget? child; + + /// Builds the Link using the current immutable state. + final ValueWidgetBuilder? builder; + + /// Performs application-owned navigation when the Link activates. + final VoidCallback? onPressed; + + /// Optional URL exposed only as semantics metadata. + final Uri? linkUrl; + + /// Whether the Link may activate when [onPressed] is also non-null. + final bool enabled; + + /// Optional caller-owned focus node. + final FocusNode? focusNode; + + /// Whether the Link should request focus when first built. + final bool autofocus; + + /// Optional cursor used while effectively enabled. + final MouseCursor? mouseCursor; + + /// Whether accepted activations provide platform feedback. + final bool enableFeedback; + + /// Called when keyboard focus changes. + final ValueChanged? onFocusChange; + + /// Called when pointer hover changes. + final ValueChanged? onHoverChange; + + /// Called when primary-pointer press state changes. + final ValueChanged? onPressChange; + + /// Optional caller-localized accessible name overriding child semantics. + final String? semanticLabel; + + /// Optional caller-localized accessible hint. + final String? semanticHint; + + /// Whether to hide the Link and its subtree from semantics. + final bool excludeSemantics; + + bool get _effectiveEnabled => enabled && onPressed != null; + + @override + State createState() => _NakedLinkState(); +} + +class _NakedLinkState extends State + with WidgetStatesMixin { + void _handleActivation() { + if (!widget._effectiveEnabled) return; + + if (widget.enableFeedback) { + Feedback.forTap(context); + } + widget.onPressed!(); + } + + void _handlePressStart(TapDownDetails details) { + updatePressState(true, widget.onPressChange); + } + + void _handlePressEnd() { + updatePressState(false, widget.onPressChange); + } + + @override + void initializeWidgetStates() { + updateDisabledState(!widget._effectiveEnabled); + } + + @override + void didUpdateWidget(covariant NakedLink oldWidget) { + super.didUpdateWidget(oldWidget); + + final wasEnabled = oldWidget.enabled && oldWidget.onPressed != null; + if (wasEnabled != widget._effectiveEnabled) { + updateDisabledState(!widget._effectiveEnabled); + if (!widget._effectiveEnabled) { + _handlePressEnd(); + updateHoverState(false, widget.onHoverChange); + } + } + } + + @override + Widget build(BuildContext context) { + Widget result = GestureDetector( + onTapDown: widget._effectiveEnabled ? _handlePressStart : null, + onTapUp: widget._effectiveEnabled ? (_) => _handlePressEnd() : null, + onTapCancel: widget._effectiveEnabled ? _handlePressEnd : null, + onTap: widget._effectiveEnabled ? _handleActivation : null, + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + child: NakedStateScopeBuilder( + value: NakedLinkState(states: widgetStates, linkUrl: widget.linkUrl), + child: widget.child, + builder: widget.builder, + ), + ); + + if (!widget.excludeSemantics) { + result = Semantics( + enabled: widget._effectiveEnabled, + link: true, + linkUrl: widget.linkUrl, + label: widget.semanticLabel, + hint: widget.semanticHint, + excludeSemantics: widget.semanticLabel != null, + onTap: widget._effectiveEnabled ? _handleActivation : null, + child: result, + ); + } + + result = NakedFocusableDetector( + enabled: widget._effectiveEnabled, + autofocus: widget.autofocus, + includeSemantics: !widget.excludeSemantics, + onFocusChange: (focused) { + updateFocusState(focused, widget.onFocusChange); + }, + onHoverChange: (hovered) { + updateHoverState(hovered, widget.onHoverChange); + }, + focusNode: widget.focusNode, + mouseCursor: widget._effectiveEnabled + ? (widget.mouseCursor ?? SystemMouseCursors.click) + : SystemMouseCursors.basic, + shortcuts: NakedIntentActions.link.shortcuts, + actions: NakedIntentActions.link.actions(onPressed: _handleActivation), + debugLabel: 'NakedLink', + child: result, + ); + + return widget.excludeSemantics ? ExcludeSemantics(child: result) : result; + } +} diff --git a/packages/naked_ui/lib/src/naked_widgets.dart b/packages/naked_ui/lib/src/naked_widgets.dart index 8ea1c65b..7bec8208 100644 --- a/packages/naked_ui/lib/src/naked_widgets.dart +++ b/packages/naked_ui/lib/src/naked_widgets.dart @@ -2,6 +2,7 @@ export 'naked_accordion.dart'; export 'naked_button.dart'; export 'naked_checkbox.dart'; export 'naked_dialog.dart'; +export 'naked_link.dart'; export 'naked_menu.dart'; export 'naked_popover.dart'; export 'naked_radio.dart'; diff --git a/packages/naked_ui/lib/src/utilities/intents.dart b/packages/naked_ui/lib/src/utilities/intents.dart index e8007235..594a073c 100644 --- a/packages/naked_ui/lib/src/utilities/intents.dart +++ b/packages/naked_ui/lib/src/utilities/intents.dart @@ -12,6 +12,9 @@ class NakedIntentActions { /// Intent bindings for button activation. static const _ButtonIntentActions button = _ButtonIntentActions(); + /// Intent bindings for Link activation. + static const _LinkIntentActions link = _LinkIntentActions(); + /// Intent bindings for checkbox activation. static const _CheckboxIntentActions checkbox = _CheckboxIntentActions(); @@ -48,6 +51,19 @@ class _ButtonIntentActions { _activation(onPressed, includeButtonIntent: true); } +class _LinkIntentActions { + const _LinkIntentActions(); + + Map get shortcuts => _linkShortcuts; + + Map> actions({required VoidCallback onPressed}) => + >{ + _LinkActivateIntent: CallbackAction<_LinkActivateIntent>( + onInvoke: (_) => onPressed(), + ), + }; +} + class _CheckboxIntentActions { const _CheckboxIntentActions(); @@ -289,6 +305,12 @@ const Map _buttonShortcuts = SingleActivator(LogicalKeyboardKey.numpadEnter): ButtonActivateIntent(), }; +const Map _linkShortcuts = + { + SingleActivator(LogicalKeyboardKey.enter): _LinkActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): _LinkActivateIntent(), + }; + const Map _tabShortcuts = { SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), @@ -405,6 +427,11 @@ Map> _activation( // Custom intent definitions for keyboard navigation and slider actions +/// Intent: activate a Link without inheriting Button/Space behavior. +class _LinkActivateIntent extends Intent { + const _LinkActivateIntent(); +} + /// Intent: Move focus to first item in a collection. class _FirstFocusIntent extends Intent { const _FirstFocusIntent(); diff --git a/packages/naked_ui/test/hashcode_contract_test.dart b/packages/naked_ui/test/hashcode_contract_test.dart index d3311bbd..1a926212 100644 --- a/packages/naked_ui/test/hashcode_contract_test.dart +++ b/packages/naked_ui/test/hashcode_contract_test.dart @@ -44,6 +44,14 @@ void main() { } final cases = <_ContractCase>[ + _ContractCase( + description: 'NakedLinkState', + orderedStates: [WidgetState.hovered, WidgetState.focused], + create: (states) => NakedLinkState( + states: states, + linkUrl: Uri.parse('https://example.com/docs'), + ), + ), _ContractCase( description: 'NakedButtonState', orderedStates: [WidgetState.hovered, WidgetState.focused], diff --git a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart new file mode 100644 index 00000000..80082ba5 --- /dev/null +++ b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart @@ -0,0 +1,326 @@ +import 'dart:ui' show Tristate; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:naked_ui/naked_ui.dart'; + +void main() { + group('NakedLink semantics', () { + testWidgets('enabled Link exposes exact name role URL hint and action', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + final linkUrl = Uri.parse('https://example.com/docs'); + + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: linkUrl, + semanticLabel: 'Documentation', + semanticHint: 'Opens in a new window', + onPressed: () {}, + child: const Text('Visible documentation'), + ), + ), + ); + + final data = _singleLinkData(tester); + expect(data.label, 'Documentation'); + expect(data.hint, 'Opens in a new window'); + expect(data.linkUrl, linkUrl); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isTrue); + expect(data.flagsCollection.isFocused, Tristate.isFalse); + expect(data.hasAction(SemanticsAction.tap), isTrue); + handle.dispose(); + }); + + testWidgets('visible text supplies the name when no override is given', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _testApp( + NakedLink(onPressed: () {}, child: const Text('Visible name')), + ), + ); + + expect(_singleLinkData(tester).label, 'Visible name'); + handle.dispose(); + }); + + testWidgets('semantic label replaces child naming without duplication', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _testApp( + NakedLink( + semanticLabel: 'Accessible documentation', + onPressed: () {}, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Visible documentation'), + Semantics( + label: 'Decorative arrow', + image: true, + child: const SizedBox(width: 16, height: 16), + ), + ], + ), + ), + ), + ); + + final data = _singleLinkData(tester); + expect(data.label, 'Accessible documentation'); + final allLabels = _allSemanticsData( + tester, + ).map((value) => value.label).where((label) => label.isNotEmpty).toList(); + expect( + allLabels.where((label) => label == 'Accessible documentation'), + hasLength(1), + ); + expect(allLabels, isNot(contains('Visible documentation'))); + expect(allLabels, isNot(contains('Decorative arrow'))); + handle.dispose(); + }); + + testWidgets('caller can exclude a decorative external icon', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _testApp( + NakedLink( + semanticHint: 'Opens in a new window', + onPressed: () {}, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('External documentation'), + ExcludeSemantics( + child: Icon(Icons.open_in_new, semanticLabel: 'External'), + ), + ], + ), + ), + ), + ); + + final data = _singleLinkData(tester); + expect(data.label, 'External documentation'); + expect(data.hint, 'Opens in a new window'); + expect( + _allSemanticsData(tester).where((value) => value.label == 'External'), + isEmpty, + ); + handle.dispose(); + }); + + testWidgets('focus flags follow the known focus node', (tester) async { + final handle = tester.ensureSemantics(); + final focusNode = FocusNode(debugLabel: 'semantic link'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + _testApp( + NakedLink( + focusNode: focusNode, + onPressed: () {}, + child: const Text('Documentation'), + ), + ), + ); + expect( + _singleLinkData(tester).flagsCollection.isFocused, + Tristate.isFalse, + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + expect( + _singleLinkData(tester).flagsCollection.isFocused, + Tristate.isTrue, + ); + handle.dispose(); + }); + + testWidgets('semantic tap uses the same activation path exactly once', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + var callbackCount = 0; + + await tester.pumpWidget( + _testApp( + NakedLink( + onPressed: () => callbackCount++, + child: const Text('Documentation'), + ), + ), + ); + + final node = _singleLinkNode(tester); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pump(); + expect(callbackCount, 1); + handle.dispose(); + }); + + testWidgets('callback removal retains disabled Link and removes action', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + VoidCallback? callback = () {}; + late StateSetter rebuild; + + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + linkUrl: Uri.parse('https://example.com/docs'), + semanticLabel: 'Documentation', + onPressed: callback, + child: const Text('Visible documentation'), + ); + }, + ), + ), + ); + expect(_singleLinkData(tester).hasAction(SemanticsAction.tap), isTrue); + + rebuild(() => callback = null); + await tester.pump(); + final data = _singleLinkData(tester); + expect(data.label, 'Documentation'); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isFalse); + expect(data.flagsCollection.isFocused, Tristate.none); + expect(data.hasAction(SemanticsAction.tap), isFalse); + handle.dispose(); + }); + + testWidgets('Arabic label and hint remain exact in RTL', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _testApp( + Directionality( + textDirection: TextDirection.rtl, + child: NakedLink( + semanticLabel: 'الوثائق', + semanticHint: 'يفتح في نافذة جديدة', + onPressed: () {}, + child: const Text('المستندات'), + ), + ), + ), + ); + + final data = _singleLinkData(tester); + expect(data.label, 'الوثائق'); + expect(data.hint, 'يفتح في نافذة جديدة'); + expect(data.textDirection, TextDirection.rtl); + handle.dispose(); + }); + + testWidgets('excludeSemantics removes Link and descendant semantics', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _testApp( + NakedLink( + semanticLabel: 'Documentation', + onPressed: () {}, + child: const Text('Visible documentation'), + ), + ), + ); + expect(_linkNodes(tester), hasLength(1)); + + await tester.pumpWidget( + _testApp( + NakedLink( + semanticLabel: 'Documentation', + excludeSemantics: true, + onPressed: () {}, + child: const Text('Visible documentation'), + ), + ), + ); + + expect(_linkNodes(tester), isEmpty); + expect( + _allSemanticsData(tester).where( + (value) => + value.label == 'Documentation' || + value.label == 'Visible documentation', + ), + isEmpty, + ); + handle.dispose(); + }); + }); +} + +Widget _testApp(Widget child) { + return MaterialApp( + home: Scaffold(body: Center(child: child)), + ); +} + +SemanticsNode _singleLinkNode(WidgetTester tester) { + final nodes = _linkNodes(tester); + expect(nodes, hasLength(1)); + return nodes.single; +} + +SemanticsData _singleLinkData(WidgetTester tester) => + _singleLinkNode(tester).getSemanticsData(); + +List _linkNodes(WidgetTester tester) { + final root = tester.getSemantics(find.byType(Scaffold)); + final nodes = []; + + void collect(SemanticsNode node) { + if (node.getSemanticsData().flagsCollection.isLink) { + nodes.add(node); + } + node.visitChildren((child) { + collect(child); + return true; + }); + } + + collect(root); + return nodes; +} + +List _allSemanticsData(WidgetTester tester) { + final root = tester.getSemantics(find.byType(Scaffold)); + final data = []; + + void collect(SemanticsNode node) { + data.add(node.getSemanticsData()); + node.visitChildren((child) { + collect(child); + return true; + }); + } + + collect(root); + return data; +} diff --git a/packages/naked_ui/test/src/naked_link_test.dart b/packages/naked_ui/test/src/naked_link_test.dart new file mode 100644 index 00000000..85534253 --- /dev/null +++ b/packages/naked_ui/test/src/naked_link_test.dart @@ -0,0 +1,527 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:naked_ui/naked_ui.dart'; + +import '../test_helpers.dart'; + +void main() { + group('NakedLink public state contract', () { + test('requires either a child or builder', () { + expect(() => NakedLink(onPressed: () {}), throwsAssertionError); + }); + + testWidgets('renders its child without a builder', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: NakedLink(onPressed: () {}, child: const Text('Documentation')), + ), + ); + + expect(find.text('Documentation'), findsOneWidget); + }); + + testWidgets('builder and scope receive one immutable state snapshot', ( + tester, + ) async { + final linkUrl = Uri.parse('https://example.com/docs'); + NakedLinkState? builderState; + NakedLinkState? scopedState; + Widget? receivedChild; + + await tester.pumpWidget( + MaterialApp( + home: NakedLink( + linkUrl: linkUrl, + onPressed: () {}, + child: const Text('Documentation'), + builder: (context, state, child) { + builderState = state; + scopedState = NakedLinkState.of(context); + receivedChild = child; + return child!; + }, + ), + ), + ); + + expect(builderState, isNotNull); + expect(scopedState, same(builderState)); + expect(receivedChild, isA()); + expect(builderState!.linkUrl, linkUrl); + expect(builderState!.states, isEmpty); + expect( + () => builderState!.states.add(WidgetState.hovered), + throwsUnsupportedError, + ); + expect(builderState!.states, isEmpty); + }); + + test('state equality and hash include states and URL metadata', () { + final first = NakedLinkState( + states: const {WidgetState.hovered, WidgetState.focused}, + linkUrl: Uri.parse('https://example.com/docs'), + ); + final reordered = NakedLinkState( + states: const {WidgetState.focused, WidgetState.hovered}, + linkUrl: Uri.parse('https://example.com/docs'), + ); + final otherUrl = NakedLinkState( + states: const {WidgetState.hovered, WidgetState.focused}, + linkUrl: Uri.parse('https://example.com/support'), + ); + + expect(first, reordered); + expect(first.hashCode, reordered.hashCode); + expect(first, isNot(otherUrl)); + }); + }); + + group('NakedLink activation contract', () { + testWidgets('primary tap updates press state and activates exactly once', ( + tester, + ) async { + const linkKey = ValueKey('link'); + var callbackCount = 0; + final pressChanges = []; + NakedLinkState? state; + + await tester.pumpWidget( + _testApp( + NakedLink( + key: linkKey, + onPressed: () => callbackCount++, + onPressChange: pressChanges.add, + builder: (context, value, child) { + state = value; + return const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ); + }, + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(linkKey)), + ); + await tester.pump(); + expect(state!.isPressed, isTrue); + expect(pressChanges, [true]); + expect(callbackCount, 0); + + await gesture.up(); + await tester.pump(); + expect(state!.isPressed, isFalse); + expect(pressChanges, [true, false]); + expect(callbackCount, 1); + }); + + testWidgets('canceled primary gesture clears press without activating', ( + tester, + ) async { + const linkKey = ValueKey('link'); + var callbackCount = 0; + final pressChanges = []; + + await tester.pumpWidget( + _testApp( + NakedLink( + key: linkKey, + onPressed: () => callbackCount++, + onPressChange: pressChanges.add, + child: const SizedBox(width: 160, height: 48, child: Text('Link')), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(linkKey)), + ); + await tester.pump(); + await gesture.moveTo(const Offset(-100, -100)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(pressChanges, [true, false]); + expect(callbackCount, 0); + }); + + testWidgets('secondary click remains unclaimed', (tester) async { + const linkKey = ValueKey('link'); + var callbackCount = 0; + final pressChanges = []; + + await tester.pumpWidget( + _testApp( + NakedLink( + key: linkKey, + onPressed: () => callbackCount++, + onPressChange: pressChanges.add, + child: const SizedBox(width: 160, height: 48, child: Text('Link')), + ), + ), + ); + + await tester.tapAt( + tester.getCenter(find.byKey(linkKey)), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + + expect(callbackCount, 0); + expect(pressChanges, isEmpty); + }); + + testWidgets('Enter and Numpad Enter activate while Space does not', ( + tester, + ) async { + final focusNode = FocusNode(debugLabel: 'link test'); + addTearDown(focusNode.dispose); + var callbackCount = 0; + NakedLinkState? state; + + await tester.pumpWidget( + _testApp( + NakedLink( + focusNode: focusNode, + onPressed: () => callbackCount++, + builder: (context, value, child) { + state = value; + return const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ); + }, + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(callbackCount, 1); + + await tester.sendKeyEvent(LogicalKeyboardKey.numpadEnter); + await tester.pump(); + expect(callbackCount, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(callbackCount, 2); + expect(state!.isPressed, isFalse); + }); + + testWidgets('feedback occurs only for accepted enabled activation', ( + tester, + ) async { + final oldPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final platformCalls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + platformCalls.add(call); + return null; + }); + + var enabled = true; + var feedback = true; + late StateSetter rebuild; + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + enabled: enabled, + enableFeedback: feedback, + onPressed: () {}, + child: const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Link')); + await tester.pump(); + expect( + platformCalls.where((call) => call.method == 'SystemSound.play'), + hasLength(1), + ); + rebuild(() => feedback = false); + await tester.pump(); + await tester.tap(find.text('Link')); + await tester.pump(); + expect( + platformCalls.where((call) => call.method == 'SystemSound.play'), + hasLength(1), + ); + + rebuild(() { + feedback = true; + enabled = false; + }); + await tester.pump(); + await tester.tap(find.text('Link')); + await tester.pump(); + expect( + platformCalls.where((call) => call.method == 'SystemSound.play'), + hasLength(1), + ); + messenger.setMockMethodCallHandler(SystemChannels.platform, null); + debugDefaultTargetPlatformOverride = oldPlatform; + }); + }); + + group('NakedLink interaction state and lifecycle', () { + testWidgets('hover and focus transitions update callbacks and scope', ( + tester, + ) async { + const linkKey = ValueKey('link'); + final focusNode = FocusNode(debugLabel: 'link hover focus'); + addTearDown(focusNode.dispose); + final hoverChanges = []; + final focusChanges = []; + NakedLinkState? state; + + await tester.pumpWidget( + _testApp( + NakedLink( + key: linkKey, + focusNode: focusNode, + onPressed: () {}, + onHoverChange: hoverChanges.add, + onFocusChange: focusChanges.add, + builder: (context, value, child) { + state = NakedLinkState.of(context); + expect(state, same(value)); + return const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ); + }, + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(find.byKey(linkKey))); + await tester.pump(); + expect(state!.isHovered, isTrue); + expect(hoverChanges, [true]); + + focusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + expect(focusChanges, [true]); + expect(state!.isFocused, isTrue); + + await mouse.moveTo(const Offset(-100, -100)); + await tester.pump(); + expect(state!.isHovered, isFalse); + expect(hoverChanges, [true, false]); + + focusNode.unfocus(); + await tester.pump(); + await tester.pump(); + expect(state!.isFocused, isFalse); + expect(focusChanges, [true, false]); + }); + + testWidgets('effective disabled state controls traversal and cursor', ( + tester, + ) async { + const enabledKey = ValueKey('enabled'); + const explicitDisabledKey = ValueKey('explicit-disabled'); + const callbackDisabledKey = ValueKey('callback-disabled'); + const customKey = ValueKey('custom'); + final enabledNode = FocusNode(debugLabel: 'enabled link'); + final explicitDisabledNode = FocusNode(debugLabel: 'explicit disabled'); + final callbackDisabledNode = FocusNode(debugLabel: 'callback disabled'); + final nextNode = FocusNode(debugLabel: 'next'); + addTearDown(enabledNode.dispose); + addTearDown(explicitDisabledNode.dispose); + addTearDown(callbackDisabledNode.dispose); + addTearDown(nextNode.dispose); + + await tester.pumpWidget( + _testApp( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + NakedLink( + key: explicitDisabledKey, + enabled: false, + focusNode: explicitDisabledNode, + onPressed: () {}, + child: const SizedBox(child: Text('Explicit disabled')), + ), + NakedLink( + key: callbackDisabledKey, + focusNode: callbackDisabledNode, + child: const SizedBox(child: Text('Callback disabled')), + ), + NakedLink( + key: enabledKey, + focusNode: enabledNode, + onPressed: () {}, + child: const SizedBox(child: Text('Enabled')), + ), + NakedLink( + key: customKey, + mouseCursor: SystemMouseCursors.help, + onPressed: () {}, + child: const SizedBox(child: Text('Custom')), + ), + TextButton( + focusNode: nextNode, + onPressed: () {}, + child: const Text('Next'), + ), + ], + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(enabledNode.hasFocus, isTrue); + expect(explicitDisabledNode.hasFocus, isFalse); + expect(callbackDisabledNode.hasFocus, isFalse); + + tester.expectCursor(SystemMouseCursors.click, on: enabledKey); + tester.expectCursor(SystemMouseCursors.basic, on: explicitDisabledKey); + tester.expectCursor(SystemMouseCursors.basic, on: callbackDisabledKey); + tester.expectCursor(SystemMouseCursors.help, on: customKey); + }); + + testWidgets('callback removal immediately disables and clears hover', ( + tester, + ) async { + const linkKey = ValueKey('link'); + final focusNode = FocusNode(debugLabel: 'dynamic link'); + addTearDown(focusNode.dispose); + final hoverChanges = []; + var callbackCount = 0; + VoidCallback? callback = () => callbackCount++; + NakedLinkState? state; + late StateSetter rebuild; + + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + key: linkKey, + focusNode: focusNode, + onPressed: callback, + onHoverChange: hoverChanges.add, + builder: (context, value, child) { + state = value; + return const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ); + }, + ); + }, + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(find.byKey(linkKey))); + await tester.pump(); + expect(state!.isHovered, isTrue); + + focusNode.requestFocus(); + await tester.pump(); + rebuild(() => callback = null); + await tester.pump(); + + expect(state!.isDisabled, isTrue); + expect(state!.isHovered, isFalse); + expect(state!.isPressed, isFalse); + expect(hoverChanges, [true, false]); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.tap(find.text('Link')); + await tester.pump(); + expect(callbackCount, 0); + tester.expectCursor(SystemMouseCursors.basic, on: linkKey); + }); + + testWidgets( + 'autofocus works and focus-node replacement preserves ownership', + (tester) async { + final firstNode = FocusNode(debugLabel: 'first external link'); + final secondNode = FocusNode(debugLabel: 'second external link'); + addTearDown(firstNode.dispose); + addTearDown(secondNode.dispose); + var currentNode = firstNode; + late StateSetter rebuild; + + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + autofocus: true, + focusNode: currentNode, + onPressed: () {}, + child: const SizedBox(child: Text('Link')), + ); + }, + ), + ), + ); + await tester.pump(); + expect(firstNode.hasFocus, isTrue); + + rebuild(() => currentNode = secondNode); + await tester.pump(); + await tester.pump(); + expect(firstNode.hasFocus, isFalse); + expect(secondNode.hasFocus, isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + final listener = () {}; + expect(() => firstNode.addListener(listener), returnsNormally); + firstNode.removeListener(listener); + expect(() => secondNode.addListener(listener), returnsNormally); + secondNode.removeListener(listener); + }, + ); + }); +} + +Widget _testApp(Widget child) { + return MaterialApp( + home: Scaffold(body: Center(child: child)), + ); +} From bdf086cd5fff08d7df76b16d2e0dbacd63baf5d7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 01:56:01 -0400 Subject: [PATCH 05/18] feat: add Link fixture and platform proof --- .github/workflows/ci.yml | 21 + .github/workflows/integration-tests.yml | 3 + README.md | 24 ++ .../example/integration_test/all_tests.dart | 2 + .../components/naked_link_integration.dart | 226 ++++++++++ .../integration_test/screenshot_smoke.dart | 127 ++++++ packages/example/lib/api/naked_link.0.dart | 336 +++++++++++++++ packages/example/lib/registry.dart | 11 + .../test/accessibility_guidelines_test.dart | 26 ++ .../components/naked_link_golden_test.dart | 35 ++ .../example/test/naked_link_example_test.dart | 148 +++++++ .../test/screenshot_evidence_test.dart | 20 + packages/naked_ui/CHANGELOG.md | 8 + packages/naked_ui/README.md | 24 ++ packages/naked_ui/lib/src/naked_link.dart | 67 ++- .../semantics/naked_link_semantics_test.dart | 406 ++++++++++-------- .../naked_ui/test/src/naked_link_test.dart | 99 ++--- plan/README.md | 2 +- plan/phases/02-link.md | 11 +- tool/run_android_integration.sh | 1 + 20 files changed, 1345 insertions(+), 252 deletions(-) create mode 100644 packages/example/integration_test/components/naked_link_integration.dart create mode 100644 packages/example/lib/api/naked_link.0.dart create mode 100644 packages/example/test/goldens/components/naked_link_golden_test.dart create mode 100644 packages/example/test/naked_link_example_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe97bc96..9fc8496b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,8 +87,29 @@ jobs: run: flutter test packages/naked_ui/test --coverage - name: Run example tests + id: example_tests run: flutter test packages/example/test + # 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 + + - 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() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index b75f58e3..f912dd65 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -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/manifest.json - name: Upload macOS integration evidence diff --git a/README.md b/README.md index 8b5f9082..5502c793 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The complete documentation covers detailed component APIs and examples, guides a ## Supported Components - NakedButton — button interactions (hover, press, focus) +- NakedLink — Link semantics, URL metadata, and Enter-only keyboard activation - NakedCheckbox — toggle behavior and semantics - NakedRadio — single‑select radio with group management - NakedSelect — dropdown/select with keyboard navigation @@ -58,6 +59,29 @@ NakedButton( ) ``` +### Custom Link + +Use a Link for navigation rather than styling a Button like text. The URL is +semantics metadata; your callback still owns routing or launching. Enter and +Numpad Enter activate, while Space remains available to the page. + +```dart +NakedLink( + linkUrl: Uri.parse('https://example.com/docs'), + onPressed: openDocumentation, + 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 Checkbox Build a checkbox with custom visuals while maintaining proper state management. diff --git a/packages/example/integration_test/all_tests.dart b/packages/example/integration_test/all_tests.dart index c3de9cf0..9cb11584 100644 --- a/packages/example/integration_test/all_tests.dart +++ b/packages/example/integration_test/all_tests.dart @@ -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; @@ -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); diff --git a/packages/example/integration_test/components/naked_link_integration.dart b/packages/example/integration_test/components/naked_link_integration.dart new file mode 100644 index 00000000..64db2207 --- /dev/null +++ b/packages/example/integration_test/components/naked_link_integration.dart @@ -0,0 +1,226 @@ +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: 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 callback 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().hasAction(SemanticsAction.tap), + isFalse, + ); + } finally { + handle.dispose(); + } + }, + ); + + testWidgets('callback removal 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); + }); + }); +} diff --git a/packages/example/integration_test/screenshot_smoke.dart b/packages/example/integration_test/screenshot_smoke.dart index 0625ee83..b3394541 100644 --- a/packages/example/integration_test/screenshot_smoke.dart +++ b/packages/example/integration_test/screenshot_smoke.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:example/api/naked_dialog.0.dart' as dialog_example; +import 'package:example/api/naked_link.0.dart' as link_example; import 'package:example/src/testing/screenshot_evidence.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'; @@ -50,4 +53,128 @@ void main() { surface: screenshotSurface, ); }); + + testWidgets('Link default inline screenshot evidence', (tester) async { + final screenshotSurface = await _pumpLinkSurface( + tester, + const link_example.LinkExample(), + ); + expect(find.byKey(const ValueKey('link.primary')), findsOneWidget); + + await _captureLinkEvidence( + tester, + binding, + screenshotSurface, + scenario: 'default_inline', + ); + }); + + testWidgets('Link keyboard focus screenshot evidence', (tester) async { + final screenshotSurface = await _pumpLinkSurface( + tester, + const link_example.LinkExample(), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.pump(); + expect( + find.text('hovered:false focused:true pressed:false enabled:true'), + findsOneWidget, + ); + + await _captureLinkEvidence( + tester, + binding, + screenshotSurface, + scenario: 'keyboard_focus', + ); + }); + + testWidgets('Link disabled screenshot evidence', (tester) async { + final screenshotSurface = await _pumpLinkSurface( + tester, + const link_example.LinkExample(), + ); + final semantics = tester.ensureSemantics(); + try { + final disabled = tester + .getSemantics(find.text('Unavailable documentation')) + .getSemanticsData(); + expect(disabled.flagsCollection.isLink, isTrue); + expect(disabled.hasAction(SemanticsAction.tap), isFalse); + } finally { + semantics.dispose(); + } + + await _captureLinkEvidence( + tester, + binding, + screenshotSurface, + scenario: 'disabled', + ); + }); + + testWidgets('Link 200% long text screenshot evidence', (tester) async { + final screenshotSurface = await _pumpLinkSurface( + tester, + const link_example.LinkExample(textScale: 2, longText: true), + ); + expect(tester.takeException(), isNull); + expect(find.textContaining('complete accessibility guide'), findsOneWidget); + + await _captureLinkEvidence( + tester, + binding, + screenshotSurface, + scenario: 'long_text_200', + textScale: 2, + ); + }); +} + +Future _pumpLinkSurface(WidgetTester tester, Widget child) async { + final usesNativeSurface = Platform.isAndroid || Platform.isIOS; + if (!usesNativeSurface) { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: SizedBox.expand(child: child)), + ), + ); + await tester.pump(); + final screenshotSurface = find.byKey(const ValueKey('link.evidence.surface')); + expect(screenshotSurface, findsOneWidget); + if (!usesNativeSurface) { + expect(tester.getSize(screenshotSurface), const Size(800, 600)); + } + return screenshotSurface; +} + +Future _captureLinkEvidence( + WidgetTester tester, + IntegrationTestWidgetsFlutterBinding binding, + Finder screenshotSurface, { + required String scenario, + double textScale = 1, +}) async { + final logicalSize = tester.getSize(screenshotSurface); + await tester.captureEvidenceScreenshot( + binding, + ScreenshotEvidence( + component: 'link', + scenario: scenario, + surface: '${logicalSize.width}x${logicalSize.height} logical pixels', + devicePixelRatio: tester.view.devicePixelRatio, + textScale: textScale, + animationMode: 'disabled', + ), + surface: screenshotSurface, + ); } diff --git a/packages/example/lib/api/naked_link.0.dart b/packages/example/lib/api/naked_link.0.dart new file mode 100644 index 00000000..8d86e533 --- /dev/null +++ b/packages/example/lib/api/naked_link.0.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; +import 'package:naked_ui/naked_ui.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + backgroundColor: Color(0xFFF8FAFC), + body: SafeArea(child: LinkExample()), + ), + ); + } +} + +/// Deterministic styled fixture for the headless Link contract. +class LinkExample extends StatefulWidget { + const LinkExample({ + super.key, + this.textDirection = TextDirection.ltr, + this.textScale = 1, + this.longText = false, + this.disableAnimations = true, + }); + + final TextDirection textDirection; + final double textScale; + final bool longText; + final bool disableAnimations; + + @override + State createState() => _LinkExampleState(); +} + +class _LinkExampleState extends State { + var _result = 'none'; + var _activationCount = 0; + var _primaryEnabled = true; + var _hovered = false; + var _focused = false; + var _pressed = false; + + bool get _isRtl => widget.textDirection == TextDirection.rtl; + + void _activate(String result) { + setState(() { + _result = result; + _activationCount++; + }); + } + + void _reset() { + FocusScope.of(context).unfocus(); + setState(() { + _result = 'none'; + _activationCount = 0; + _primaryEnabled = true; + _hovered = false; + _focused = false; + _pressed = false; + }); + } + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + + return MediaQuery( + data: media.copyWith( + textScaler: TextScaler.linear(widget.textScale), + disableAnimations: widget.disableAnimations, + ), + child: Directionality( + textDirection: widget.textDirection, + child: RepaintBoundary( + key: const ValueKey('link.evidence.surface'), + child: Material( + color: const Color(0xFFF8FAFC), + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _isRtl ? 'روابط Naked UI' : 'Naked UI links', + style: const TextStyle( + color: Color(0xFF0F172A), + fontSize: 24, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + _buildPrimaryLine(), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [_buildExternalLink(), _buildDisabledLink()], + ), + const SizedBox(height: 20), + Text( + 'Result: $_result; activations: $_activationCount', + key: const ValueKey('link.result'), + style: const TextStyle( + color: Color(0xFF0F172A), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'hovered:$_hovered focused:$_focused ' + 'pressed:$_pressed enabled:$_primaryEnabled', + key: const ValueKey('link.state'), + style: const TextStyle(color: Color(0xFF334155)), + ), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton( + key: const ValueKey('link.next-focus'), + onPressed: () => _activate('next-focus'), + child: const Text('Next focus target'), + ), + OutlinedButton( + key: const ValueKey('link.disable-primary'), + onPressed: _primaryEnabled + ? () => setState(() { + _primaryEnabled = false; + _hovered = false; + _pressed = false; + }) + : null, + child: const Text('Disable primary Link'), + ), + OutlinedButton( + key: const ValueKey('link.reset'), + onPressed: _reset, + child: const Text('Reset Link fixture'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildPrimaryLine() { + final linkText = _isRtl + ? widget.longText + ? 'دليل الوصول الكامل للمكونات التفاعلية والقابلة للتخصيص' + : 'دليل الوصول' + : widget.longText + ? 'Read the complete accessibility guide for customizable interactive components' + : 'Read the documentation'; + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + _isRtl ? 'تعرّف على المكوّنات في ' : 'Learn about the components in ', + style: const TextStyle(color: Color(0xFF334155), fontSize: 16), + ), + NakedLink( + key: const ValueKey('link.primary'), + linkUrl: Uri.parse('https://example.com/naked-ui'), + semanticLabel: _isRtl ? linkText : null, + onPressed: _primaryEnabled ? () => _activate('primary') : null, + onHoverChange: (value) => setState(() => _hovered = value), + onFocusChange: (value) => setState(() => _focused = value), + onPressChange: (value) => setState(() => _pressed = value), + child: Text(linkText), + builder: (context, state, child) => _LinkSurface( + state: state, + standalone: false, + disableAnimations: widget.disableAnimations, + child: child!, + ), + ), + const Text( + '.', + style: TextStyle(color: Color(0xFF334155), fontSize: 16), + ), + ], + ); + } + + Widget _buildExternalLink() { + return NakedLink( + key: const ValueKey('link.external'), + linkUrl: Uri.parse('https://docs.flutter.dev/ui/accessibility'), + semanticLabel: 'Flutter accessibility documentation', + semanticHint: 'Opens in a new window', + onPressed: () => _activate('external'), + child: const Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text('Flutter accessibility'), + SizedBox(width: 6), + ExcludeSemantics( + child: Icon( + Icons.open_in_new, + size: 16, + semanticLabel: 'External link icon', + ), + ), + ], + ), + builder: (context, state, child) => _LinkSurface( + state: state, + standalone: true, + disableAnimations: widget.disableAnimations, + child: child!, + ), + ); + } + + Widget _buildDisabledLink() { + return NakedLink( + key: const ValueKey('link.disabled'), + enabled: false, + onPressed: () => _activate('disabled'), + child: const Text('Unavailable documentation'), + builder: (context, state, child) => _LinkSurface( + state: state, + standalone: true, + disableAnimations: widget.disableAnimations, + child: child!, + ), + ); + } +} + +/// A standalone styled Link used to verify platform target-size guidance. +class StandaloneLinkExample extends StatelessWidget { + const StandaloneLinkExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: NakedLink( + key: const ValueKey('link.standalone'), + linkUrl: Uri.parse('https://example.com/naked-ui'), + onPressed: () {}, + child: const Text('Open documentation'), + builder: (context, state, child) => _LinkSurface( + state: state, + standalone: true, + disableAnimations: true, + child: child!, + ), + ), + ); + } +} + +class _LinkSurface extends StatelessWidget { + const _LinkSurface({ + required this.state, + required this.standalone, + required this.disableAnimations, + required this.child, + }); + + final NakedLinkState state; + final bool standalone; + final bool disableAnimations; + final Widget child; + + @override + Widget build(BuildContext context) { + final foreground = state.isDisabled + ? const Color(0xFF64748B) + : const Color(0xFF1D4ED8); + final background = state.when( + disabled: const Color(0xFFE2E8F0), + pressed: const Color(0xFFBFDBFE), + hovered: const Color(0xFFDBEAFE), + orElse: Colors.transparent, + ); + + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: state.isFocused ? const Color(0xFF2563EB) : Colors.transparent, + width: 2, + ), + borderRadius: BorderRadius.circular(standalone ? 8 : 4), + ), + child: AnimatedContainer( + duration: disableAnimations + ? Duration.zero + : const Duration(milliseconds: 120), + constraints: standalone + ? const BoxConstraints(minWidth: 48, minHeight: 48) + : const BoxConstraints(), + padding: standalone + ? const EdgeInsets.symmetric(horizontal: 10, vertical: 10) + : const EdgeInsets.symmetric(horizontal: 2, vertical: 2), + alignment: standalone ? Alignment.center : null, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(standalone ? 6 : 2), + ), + child: DefaultTextStyle( + style: TextStyle( + color: foreground, + fontSize: 16, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + child: IconTheme( + data: IconThemeData(color: foreground), + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/example/lib/registry.dart b/packages/example/lib/registry.dart index f8c53560..db1c9da7 100644 --- a/packages/example/lib/registry.dart +++ b/packages/example/lib/registry.dart @@ -9,6 +9,8 @@ import 'api/naked_button.1.dart' as button_builder_example; import 'api/naked_checkbox.0.dart' as checkbox_basic_example; // Dialog import 'api/naked_dialog.0.dart' as dialog_basic_example; +// Link +import 'api/naked_link.0.dart' as link_example; // Menu import 'api/naked_menu.0.dart' as menu_example; // Popover @@ -167,6 +169,15 @@ class DemoRegistry { 'https://github.com/btwld/naked_ui/blob/main/example/lib/api/naked_dialog.0.dart', tags: ['dialog'], ), + Demo( + id: 'link-basic', + title: 'Link – Interaction states', + category: 'Link', + builder: (_) => const link_example.LinkExample(), + sourceUrl: + 'https://github.com/btwld/naked_ui/blob/main/packages/example/lib/api/naked_link.0.dart', + tags: ['link', 'navigation', 'accessibility'], + ), Demo( id: 'popover-basic', title: 'Popover – Basic', diff --git a/packages/example/test/accessibility_guidelines_test.dart b/packages/example/test/accessibility_guidelines_test.dart index 82baa31d..43da71e4 100644 --- a/packages/example/test/accessibility_guidelines_test.dart +++ b/packages/example/test/accessibility_guidelines_test.dart @@ -1,4 +1,5 @@ import 'package:example/api/naked_button.0.dart' as button_example; +import 'package:example/api/naked_link.0.dart' as link_example; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:naked_ui/naked_ui.dart'; @@ -6,6 +7,31 @@ import 'package:naked_ui/naked_ui.dart'; import 'helpers/accessibility_guideline_helpers.dart'; void main() { + testWidgets('inline Link meets labeling and contrast guidelines', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: link_example.LinkExample())), + ); + + await tester.expectMeetsAccessibilityGuidelines( + androidTapTarget: false, + iOSTapTarget: false, + ); + }); + + testWidgets('standalone styled Link meets platform target guidelines', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: link_example.StandaloneLinkExample()), + ), + ); + + await tester.expectMeetsAccessibilityGuidelines(); + }); + testWidgets('canonical styled button meets accessibility guidelines', ( tester, ) async { diff --git a/packages/example/test/goldens/components/naked_link_golden_test.dart b/packages/example/test/goldens/components/naked_link_golden_test.dart new file mode 100644 index 00000000..f910dfd3 --- /dev/null +++ b/packages/example/test/goldens/components/naked_link_golden_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:example/api/naked_link.0.dart' as link_example; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../golden_test_harness.dart'; + +void main() { + setUpAll(loadGoldenTestFont); + + testWidgets( + 'canonical Link keyboard focus matches its reference golden', + (tester) async { + await pumpGoldenSurface(tester, child: const link_example.LinkExample()); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.pump(); + expect( + find.text('hovered:false focused:true pressed:false enabled:true'), + findsOneWidget, + ); + + await expectLater( + find.byKey(const ValueKey('link.evidence.surface')), + matchesGoldenFile('baselines/naked_link__keyboard_focus.png'), + ); + }, + // Skia text rasterization is host-specific. CI pins this golden to Ubuntu + // 24.04; other platforms skip the incompatible pixel comparison. + skip: !Platform.isLinux, + ); +} diff --git a/packages/example/test/naked_link_example_test.dart b/packages/example/test/naked_link_example_test.dart new file mode 100644 index 00000000..d5de5f3a --- /dev/null +++ b/packages/example/test/naked_link_example_test.dart @@ -0,0 +1,148 @@ +import 'package:example/api/naked_link.0.dart' as link_example; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('canonical Link fixture exposes stable deterministic state', ( + tester, + ) async { + await tester.pumpWidget(_app(const link_example.LinkExample())); + + for (final key in [ + 'link.primary', + 'link.disabled', + 'link.external', + 'link.result', + 'link.state', + 'link.next-focus', + 'link.disable-primary', + 'link.reset', + ]) { + expect(find.byKey(ValueKey(key)), findsOneWidget); + } + expect(find.text('Result: none; activations: 0'), findsOneWidget); + expect( + find.text('hovered:false focused:false pressed:false enabled:true'), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const ValueKey('link.primary'))).height, + lessThan(48), + reason: 'An inline Link must not be forced into a button-sized line box.', + ); + }); + + testWidgets('primary and external activation update result exactly once', ( + tester, + ) async { + await tester.pumpWidget(_app(const link_example.LinkExample())); + + await tester.tap(find.byKey(const ValueKey('link.primary'))); + await tester.pump(); + expect(find.text('Result: primary; activations: 1'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('link.external'))); + await tester.pump(); + expect(find.text('Result: external; activations: 2'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('link.reset'))); + await tester.pump(); + expect(find.text('Result: none; activations: 0'), findsOneWidget); + }); + + testWidgets('disabled Link cannot change the fixture result', (tester) async { + await tester.pumpWidget(_app(const link_example.LinkExample())); + + await tester.tap(find.byKey(const ValueKey('link.disabled'))); + await tester.pump(); + expect(find.text('Result: none; activations: 0'), findsOneWidget); + + final data = tester + .getSemantics(find.text('Unavailable documentation')) + .getSemanticsData(); + expect(data.flagsCollection.isLink, isTrue); + expect(data.hasAction(SemanticsAction.tap), isFalse); + }); + + testWidgets('focus and dynamic callback state are visible and resettable', ( + tester, + ) async { + await tester.pumpWidget(_app(const link_example.LinkExample())); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.pump(); + expect( + find.text('hovered:false focused:true pressed:false enabled:true'), + findsOneWidget, + ); + + 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.tap(find.byKey(const ValueKey('link.reset'))); + await tester.pump(); + expect( + find.text('hovered:false focused:false pressed:false enabled:true'), + findsOneWidget, + ); + }); + + testWidgets('external hint is named once and its icon is decorative', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + try { + await tester.pumpWidget(_app(const link_example.LinkExample())); + + final data = tester + .getSemantics(find.byKey(const ValueKey('link.external'))) + .getSemanticsData(); + expect(data.label, 'Flutter accessibility documentation'); + expect(data.hint, 'Opens in a new window'); + expect( + find.bySemanticsLabel(RegExp(r'^External link icon$')), + findsNothing, + ); + } finally { + handle.dispose(); + } + }); + + testWidgets('fixture supports RTL and 200% long text without overflow', ( + tester, + ) async { + await tester.pumpWidget( + _app( + const link_example.LinkExample( + textDirection: TextDirection.rtl, + textScale: 2, + longText: true, + ), + ), + ); + + expect(tester.takeException(), isNull); + final directionality = tester.widget( + find + .ancestor( + of: find.byKey(const ValueKey('link.primary')), + matching: find.byType(Directionality), + ) + .first, + ); + expect(directionality.textDirection, TextDirection.rtl); + expect(find.textContaining('دليل الوصول'), findsOneWidget); + }); +} + +Widget _app(Widget child) { + return MaterialApp(home: Scaffold(body: child)); +} diff --git a/packages/example/test/screenshot_evidence_test.dart b/packages/example/test/screenshot_evidence_test.dart index 1fe5ef40..5eae11d1 100644 --- a/packages/example/test/screenshot_evidence_test.dart +++ b/packages/example/test/screenshot_evidence_test.dart @@ -36,4 +36,24 @@ void main() { throwsArgumentError, ); }); + + test('Link evidence preserves all required stable artifact names', () { + for (final entry in <(String, String), String>{ + ('default_inline', 'macos'): 'link__default_inline__macos__reference.png', + ('hover', 'web'): 'link__hover__web__reference.png', + ('keyboard_focus', 'macos'): 'link__keyboard_focus__macos__reference.png', + ('disabled', 'android'): 'link__disabled__android__reference.png', + ('external_hint', 'web'): 'link__external_hint__web__reference.png', + ('long_text_200', 'macos'): 'link__long_text_200__macos__reference.png', + ('rtl', 'web'): 'link__rtl__web__reference.png', + }.entries) { + final (scenario, target) = entry.key; + final evidence = ScreenshotEvidence( + component: 'link', + scenario: scenario, + ); + expect(evidence.artifactNameFor(target), entry.value); + expect(evidence.manifestEntryFor(target)['testResult'], 'pass'); + } + }); } diff --git a/packages/naked_ui/CHANGELOG.md b/packages/naked_ui/CHANGELOG.md index fea7ed4d..332739ef 100644 --- a/packages/naked_ui/CHANGELOG.md +++ b/packages/naked_ui/CHANGELOG.md @@ -1,5 +1,13 @@ ## 1.0.0-beta.3 +### Features + +- Add `NakedLink` with Link semantics and optional URL metadata, observable + hover/focus/press/disabled state, caller-owned focus nodes, primary and + semantic activation, and Enter/Numpad Enter keyboard support. Space and + secondary click remain unclaimed, and routing, launching, visited state, + styling, and localized copy stay consumer-owned. + ### Fixes and hardening - Scope overlay-item builders to their declared state type: `NakedMenuItem` and diff --git a/packages/naked_ui/README.md b/packages/naked_ui/README.md index 8b5f9082..5502c793 100644 --- a/packages/naked_ui/README.md +++ b/packages/naked_ui/README.md @@ -17,6 +17,7 @@ The complete documentation covers detailed component APIs and examples, guides a ## Supported Components - NakedButton — button interactions (hover, press, focus) +- NakedLink — Link semantics, URL metadata, and Enter-only keyboard activation - NakedCheckbox — toggle behavior and semantics - NakedRadio — single‑select radio with group management - NakedSelect — dropdown/select with keyboard navigation @@ -58,6 +59,29 @@ NakedButton( ) ``` +### Custom Link + +Use a Link for navigation rather than styling a Button like text. The URL is +semantics metadata; your callback still owns routing or launching. Enter and +Numpad Enter activate, while Space remains available to the page. + +```dart +NakedLink( + linkUrl: Uri.parse('https://example.com/docs'), + onPressed: openDocumentation, + 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 Checkbox Build a checkbox with custom visuals while maintaining proper state management. diff --git a/packages/naked_ui/lib/src/naked_link.dart b/packages/naked_ui/lib/src/naked_link.dart index e4ff8351..3b867326 100644 --- a/packages/naked_ui/lib/src/naked_link.dart +++ b/packages/naked_ui/lib/src/naked_link.dart @@ -6,12 +6,15 @@ import 'utilities/naked_focusable_detector.dart'; import 'utilities/naked_state_scope.dart'; import 'utilities/state.dart'; -/// Immutable view passed to [NakedLink.builder]. +/// An immutable snapshot of a [NakedLink]'s interaction state and URL metadata. class NakedLinkState extends NakedState { - /// Creates an immutable snapshot of Link interaction [states] and [linkUrl]. + /// Creates a snapshot with the current interaction [states] and [linkUrl]. NakedLinkState({required super.states, required this.linkUrl}); - /// Optional URL exposed as Link semantics metadata. + /// The URL exposed to assistive technologies, if one was supplied. + /// + /// This value is metadata only. Naked UI does not launch it or update browser + /// history. final Uri? linkUrl; /// Returns the nearest [NakedLinkState] provided by [NakedStateScope]. @@ -42,9 +45,38 @@ class NakedLinkState extends NakedState { int get hashCode => Object.hash(statesHashCode, linkUrl); } -/// A headless Link without default visuals or navigation ownership. +/// A headless navigation Link with observable interaction state. +/// +/// Primary pointer tap, Enter, Numpad Enter, and semantic tap invoke +/// [onPressed] once while the Link is effectively enabled. Space is not bound +/// by this widget, so a surrounding page retains its normal scrolling +/// behavior. Secondary click is likewise left available for consumer-owned +/// context menus. +/// +/// [linkUrl] is accessibility metadata and does not perform navigation. The +/// application owns routing or URL launching, while [builder] owns all visual +/// styling. A supplied [focusNode] remains caller-owned and is never disposed +/// by Naked UI. +/// +/// ```dart +/// NakedLink( +/// linkUrl: Uri.parse('https://example.com/docs'), +/// onPressed: openDocumentation, +/// child: const Text('Documentation'), +/// builder: (context, state, child) => DecoratedBox( +/// decoration: BoxDecoration( +/// border: Border.all( +/// color: state.isFocused +/// ? const Color(0xFF2563EB) +/// : const Color(0x00000000), +/// ), +/// ), +/// child: child!, +/// ), +/// ) +/// ``` class NakedLink extends StatefulWidget { - /// Creates a headless Link. + /// Creates a Link with either [child] or [builder] as its visual surface. const NakedLink({ super.key, this.child, @@ -74,21 +106,32 @@ class NakedLink extends StatefulWidget { final ValueWidgetBuilder? builder; /// Performs application-owned navigation when the Link activates. + /// + /// A null callback makes the Link effectively disabled even when [enabled] + /// is true. final VoidCallback? onPressed; - /// Optional URL exposed only as semantics metadata. + /// The optional URL exposed to assistive technologies and the web DOM. + /// + /// Supplying a URL does not launch it; [onPressed] remains responsible for + /// application navigation. final Uri? linkUrl; /// Whether the Link may activate when [onPressed] is also non-null. final bool enabled; - /// Optional caller-owned focus node. + /// The optional caller-owned focus node. + /// + /// Naked UI borrows this node and never disposes it. final FocusNode? focusNode; /// Whether the Link should request focus when first built. final bool autofocus; - /// Optional cursor used while effectively enabled. + /// The cursor used while effectively enabled. + /// + /// Defaults to [SystemMouseCursors.click]. Disabled Links always use + /// [SystemMouseCursors.basic]. final MouseCursor? mouseCursor; /// Whether accepted activations provide platform feedback. @@ -103,13 +146,19 @@ class NakedLink extends StatefulWidget { /// Called when primary-pointer press state changes. final ValueChanged? onPressChange; - /// Optional caller-localized accessible name overriding child semantics. + /// The optional caller-localized accessible name. + /// + /// When non-null, this replaces descendant naming semantics so the Link is + /// announced once. Otherwise visible child text supplies the name. final String? semanticLabel; /// Optional caller-localized accessible hint. final String? semanticHint; /// Whether to hide the Link and its subtree from semantics. + /// + /// This is an advanced escape hatch. Callers remain responsible for + /// providing an equivalent accessible navigation path. final bool excludeSemantics; bool get _effectiveEnabled => enabled && onPressed != null; diff --git a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart index 80082ba5..4393c9d1 100644 --- a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart +++ b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart @@ -13,28 +13,31 @@ void main() { final handle = tester.ensureSemantics(); final linkUrl = Uri.parse('https://example.com/docs'); - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: linkUrl, - semanticLabel: 'Documentation', - semanticHint: 'Opens in a new window', - onPressed: () {}, - child: const Text('Visible documentation'), + try { + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: linkUrl, + semanticLabel: 'Documentation', + semanticHint: 'Opens in a new window', + onPressed: () {}, + child: const Text('Visible documentation'), + ), ), - ), - ); - - final data = _singleLinkData(tester); - expect(data.label, 'Documentation'); - expect(data.hint, 'Opens in a new window'); - expect(data.linkUrl, linkUrl); - expect(data.flagsCollection.isLink, isTrue); - expect(data.flagsCollection.isButton, isFalse); - expect(data.flagsCollection.isEnabled, Tristate.isTrue); - expect(data.flagsCollection.isFocused, Tristate.isFalse); - expect(data.hasAction(SemanticsAction.tap), isTrue); - handle.dispose(); + ); + + final data = _singleLinkData(tester); + expect(data.label, 'Documentation'); + expect(data.hint, 'Opens in a new window'); + expect(data.linkUrl, linkUrl); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isTrue); + expect(data.flagsCollection.isFocused, Tristate.isFalse); + expect(data.hasAction(SemanticsAction.tap), isTrue); + } finally { + handle.dispose(); + } }); testWidgets('visible text supplies the name when no override is given', ( @@ -42,14 +45,17 @@ void main() { ) async { final handle = tester.ensureSemantics(); - await tester.pumpWidget( - _testApp( - NakedLink(onPressed: () {}, child: const Text('Visible name')), - ), - ); + try { + await tester.pumpWidget( + _testApp( + NakedLink(onPressed: () {}, child: const Text('Visible name')), + ), + ); - expect(_singleLinkData(tester).label, 'Visible name'); - handle.dispose(); + expect(_singleLinkData(tester).label, 'Visible name'); + } finally { + handle.dispose(); + } }); testWidgets('semantic label replaces child naming without duplication', ( @@ -57,38 +63,42 @@ void main() { ) async { final handle = tester.ensureSemantics(); - await tester.pumpWidget( - _testApp( - NakedLink( - semanticLabel: 'Accessible documentation', - onPressed: () {}, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Visible documentation'), - Semantics( - label: 'Decorative arrow', - image: true, - child: const SizedBox(width: 16, height: 16), - ), - ], + try { + await tester.pumpWidget( + _testApp( + NakedLink( + semanticLabel: 'Accessible documentation', + onPressed: () {}, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Visible documentation'), + Semantics( + label: 'Decorative arrow', + image: true, + child: const SizedBox(width: 16, height: 16), + ), + ], + ), ), ), - ), - ); - - final data = _singleLinkData(tester); - expect(data.label, 'Accessible documentation'); - final allLabels = _allSemanticsData( - tester, - ).map((value) => value.label).where((label) => label.isNotEmpty).toList(); - expect( - allLabels.where((label) => label == 'Accessible documentation'), - hasLength(1), - ); - expect(allLabels, isNot(contains('Visible documentation'))); - expect(allLabels, isNot(contains('Decorative arrow'))); - handle.dispose(); + ); + + final data = _singleLinkData(tester); + expect(data.label, 'Accessible documentation'); + final allLabels = _allSemanticsData(tester) + .map((value) => value.label) + .where((label) => label.isNotEmpty) + .toList(); + expect( + allLabels.where((label) => label == 'Accessible documentation'), + hasLength(1), + ); + expect(allLabels, isNot(contains('Visible documentation'))); + expect(allLabels, isNot(contains('Decorative arrow'))); + } finally { + handle.dispose(); + } }); testWidgets('caller can exclude a decorative external icon', ( @@ -96,32 +106,35 @@ void main() { ) async { final handle = tester.ensureSemantics(); - await tester.pumpWidget( - _testApp( - NakedLink( - semanticHint: 'Opens in a new window', - onPressed: () {}, - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('External documentation'), - ExcludeSemantics( - child: Icon(Icons.open_in_new, semanticLabel: 'External'), - ), - ], + try { + await tester.pumpWidget( + _testApp( + NakedLink( + semanticHint: 'Opens in a new window', + onPressed: () {}, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('External documentation'), + ExcludeSemantics( + child: Icon(Icons.open_in_new, semanticLabel: 'External'), + ), + ], + ), ), ), - ), - ); - - final data = _singleLinkData(tester); - expect(data.label, 'External documentation'); - expect(data.hint, 'Opens in a new window'); - expect( - _allSemanticsData(tester).where((value) => value.label == 'External'), - isEmpty, - ); - handle.dispose(); + ); + + final data = _singleLinkData(tester); + expect(data.label, 'External documentation'); + expect(data.hint, 'Opens in a new window'); + expect( + _allSemanticsData(tester).where((value) => value.label == 'External'), + isEmpty, + ); + } finally { + handle.dispose(); + } }); testWidgets('focus flags follow the known focus node', (tester) async { @@ -129,28 +142,31 @@ void main() { final focusNode = FocusNode(debugLabel: 'semantic link'); addTearDown(focusNode.dispose); - await tester.pumpWidget( - _testApp( - NakedLink( - focusNode: focusNode, - onPressed: () {}, - child: const Text('Documentation'), + try { + await tester.pumpWidget( + _testApp( + NakedLink( + focusNode: focusNode, + onPressed: () {}, + child: const Text('Documentation'), + ), ), - ), - ); - expect( - _singleLinkData(tester).flagsCollection.isFocused, - Tristate.isFalse, - ); - - focusNode.requestFocus(); - await tester.pump(); - expect(focusNode.hasFocus, isTrue); - expect( - _singleLinkData(tester).flagsCollection.isFocused, - Tristate.isTrue, - ); - handle.dispose(); + ); + expect( + _singleLinkData(tester).flagsCollection.isFocused, + Tristate.isFalse, + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + expect( + _singleLinkData(tester).flagsCollection.isFocused, + Tristate.isTrue, + ); + } finally { + handle.dispose(); + } }); testWidgets('semantic tap uses the same activation path exactly once', ( @@ -159,20 +175,23 @@ void main() { final handle = tester.ensureSemantics(); var callbackCount = 0; - await tester.pumpWidget( - _testApp( - NakedLink( - onPressed: () => callbackCount++, - child: const Text('Documentation'), + try { + await tester.pumpWidget( + _testApp( + NakedLink( + onPressed: () => callbackCount++, + child: const Text('Documentation'), + ), ), - ), - ); - - final node = _singleLinkNode(tester); - node.owner!.performAction(node.id, SemanticsAction.tap); - await tester.pump(); - expect(callbackCount, 1); - handle.dispose(); + ); + + final node = _singleLinkNode(tester); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pump(); + expect(callbackCount, 1); + } finally { + handle.dispose(); + } }); testWidgets('callback removal retains disabled Link and removes action', ( @@ -182,57 +201,63 @@ void main() { VoidCallback? callback = () {}; late StateSetter rebuild; - await tester.pumpWidget( - _testApp( - StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedLink( - linkUrl: Uri.parse('https://example.com/docs'), - semanticLabel: 'Documentation', - onPressed: callback, - child: const Text('Visible documentation'), - ); - }, + try { + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + linkUrl: Uri.parse('https://example.com/docs'), + semanticLabel: 'Documentation', + onPressed: callback, + child: const Text('Visible documentation'), + ); + }, + ), ), - ), - ); - expect(_singleLinkData(tester).hasAction(SemanticsAction.tap), isTrue); - - rebuild(() => callback = null); - await tester.pump(); - final data = _singleLinkData(tester); - expect(data.label, 'Documentation'); - expect(data.flagsCollection.isLink, isTrue); - expect(data.flagsCollection.isButton, isFalse); - expect(data.flagsCollection.isEnabled, Tristate.isFalse); - expect(data.flagsCollection.isFocused, Tristate.none); - expect(data.hasAction(SemanticsAction.tap), isFalse); - handle.dispose(); + ); + expect(_singleLinkData(tester).hasAction(SemanticsAction.tap), isTrue); + + rebuild(() => callback = null); + await tester.pump(); + final data = _singleLinkData(tester); + expect(data.label, 'Documentation'); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isFalse); + expect(data.flagsCollection.isFocused, Tristate.none); + expect(data.hasAction(SemanticsAction.tap), isFalse); + } finally { + handle.dispose(); + } }); testWidgets('Arabic label and hint remain exact in RTL', (tester) async { final handle = tester.ensureSemantics(); - await tester.pumpWidget( - _testApp( - Directionality( - textDirection: TextDirection.rtl, - child: NakedLink( - semanticLabel: 'الوثائق', - semanticHint: 'يفتح في نافذة جديدة', - onPressed: () {}, - child: const Text('المستندات'), + try { + await tester.pumpWidget( + _testApp( + Directionality( + textDirection: TextDirection.rtl, + child: NakedLink( + semanticLabel: 'الوثائق', + semanticHint: 'يفتح في نافذة جديدة', + onPressed: () {}, + child: const Text('المستندات'), + ), ), ), - ), - ); - - final data = _singleLinkData(tester); - expect(data.label, 'الوثائق'); - expect(data.hint, 'يفتح في نافذة جديدة'); - expect(data.textDirection, TextDirection.rtl); - handle.dispose(); + ); + + final data = _singleLinkData(tester); + expect(data.label, 'الوثائق'); + expect(data.hint, 'يفتح في نافذة جديدة'); + expect(data.textDirection, TextDirection.rtl); + } finally { + handle.dispose(); + } }); testWidgets('excludeSemantics removes Link and descendant semantics', ( @@ -240,38 +265,41 @@ void main() { ) async { final handle = tester.ensureSemantics(); - await tester.pumpWidget( - _testApp( - NakedLink( - semanticLabel: 'Documentation', - onPressed: () {}, - child: const Text('Visible documentation'), + try { + await tester.pumpWidget( + _testApp( + NakedLink( + semanticLabel: 'Documentation', + onPressed: () {}, + child: const Text('Visible documentation'), + ), + ), + ); + expect(_linkNodes(tester), hasLength(1)); + + await tester.pumpWidget( + _testApp( + NakedLink( + semanticLabel: 'Documentation', + excludeSemantics: true, + onPressed: () {}, + child: const Text('Visible documentation'), + ), ), - ), - ); - expect(_linkNodes(tester), hasLength(1)); - - await tester.pumpWidget( - _testApp( - NakedLink( - semanticLabel: 'Documentation', - excludeSemantics: true, - onPressed: () {}, - child: const Text('Visible documentation'), + ); + + expect(_linkNodes(tester), isEmpty); + expect( + _allSemanticsData(tester).where( + (value) => + value.label == 'Documentation' || + value.label == 'Visible documentation', ), - ), - ); - - expect(_linkNodes(tester), isEmpty); - expect( - _allSemanticsData(tester).where( - (value) => - value.label == 'Documentation' || - value.label == 'Visible documentation', - ), - isEmpty, - ); - handle.dispose(); + isEmpty, + ); + } finally { + handle.dispose(); + } }); }); } diff --git a/packages/naked_ui/test/src/naked_link_test.dart b/packages/naked_ui/test/src/naked_link_test.dart index 85534253..ec689eac 100644 --- a/packages/naked_ui/test/src/naked_link_test.dart +++ b/packages/naked_ui/test/src/naked_link_test.dart @@ -236,57 +236,60 @@ void main() { return null; }); - var enabled = true; - var feedback = true; - late StateSetter rebuild; - await tester.pumpWidget( - _testApp( - StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedLink( - enabled: enabled, - enableFeedback: feedback, - onPressed: () {}, - child: const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ), - ); - }, + try { + var enabled = true; + var feedback = true; + late StateSetter rebuild; + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + enabled: enabled, + enableFeedback: feedback, + onPressed: () {}, + child: const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ), + ); + }, + ), ), - ), - ); + ); - await tester.tap(find.text('Link')); - await tester.pump(); - expect( - platformCalls.where((call) => call.method == 'SystemSound.play'), - hasLength(1), - ); - rebuild(() => feedback = false); - await tester.pump(); - await tester.tap(find.text('Link')); - await tester.pump(); - expect( - platformCalls.where((call) => call.method == 'SystemSound.play'), - hasLength(1), - ); + await tester.tap(find.text('Link')); + await tester.pump(); + expect( + platformCalls.where((call) => call.method == 'SystemSound.play'), + hasLength(1), + ); + rebuild(() => feedback = false); + await tester.pump(); + await tester.tap(find.text('Link')); + await tester.pump(); + expect( + platformCalls.where((call) => call.method == 'SystemSound.play'), + hasLength(1), + ); - rebuild(() { - feedback = true; - enabled = false; - }); - await tester.pump(); - await tester.tap(find.text('Link')); - await tester.pump(); - expect( - platformCalls.where((call) => call.method == 'SystemSound.play'), - hasLength(1), - ); - messenger.setMockMethodCallHandler(SystemChannels.platform, null); - debugDefaultTargetPlatformOverride = oldPlatform; + rebuild(() { + feedback = true; + enabled = false; + }); + await tester.pump(); + await tester.tap(find.text('Link')); + await tester.pump(); + expect( + platformCalls.where((call) => call.method == 'SystemSound.play'), + hasLength(1), + ); + } finally { + messenger.setMockMethodCallHandler(SystemChannels.platform, null); + debugDefaultTargetPlatformOverride = oldPlatform; + } }); }); diff --git a/plan/README.md b/plan/README.md index 633b2c22..adcdb4cc 100644 --- a/plan/README.md +++ b/plan/README.md @@ -29,7 +29,7 @@ do not pre-write plans for phases whose blocking decisions are unresolved. |---:|---|---|---|---|---| | 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | | 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Tracked independently in ready-for-review PR #64; closure blockers are recorded there | -| 2 | Link | [§20](briefing.md#20-component-contract-link) | — | [phases/02-link.md](phases/02-link.md) | **Active** — research complete; test-first implementation next | +| 2 | Link | [§20](briefing.md#20-component-contract-link) | — | [phases/02-link.md](phases/02-link.md) | **Active** — implementation and fixture complete; publication/platform proof in progress | | 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | | 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | | 5 | Context Menu | [§15](briefing.md#15-component-contract-context-menu) | D-03 | — | Not started | diff --git a/plan/phases/02-link.md b/plan/phases/02-link.md index aed38e84..6600f855 100644 --- a/plan/phases/02-link.md +++ b/plan/phases/02-link.md @@ -1,7 +1,7 @@ # Phase 2 — Link -Status: **Active — current code and pinned Flutter behavior re-verified; -test-first implementation is next**. +Status: **Active — core implementation and deterministic fixture complete; +publication and real-platform evidence gates are in progress**. Goal: add a headless inline navigation primitive that exposes Link rather than Button semantics, activates once through primary pointer, Enter, Numpad Enter, @@ -200,8 +200,8 @@ findings below re-verified on 2026-07-13). text configurations; disable/fix animation for evidence. Use no network or router. - **Stable keys:** `link.primary`, `link.disabled`, `link.external`, - `link.result`, `link.next-focus`, plus `link.state`, `link.reset`, and a fixed - evidence surface key. + `link.result`, `link.next-focus`, plus `link.state`, + `link.disable-primary`, `link.reset`, and a fixed evidence surface key. - **Golden:** default inline/focus-capable canonical surface at 800×600, DPR 1, pinned Roboto, locale/direction/text scale/brightness fixed. Generate only through the approved Ubuntu update-then-verify diagnostic, inspect the PNG, @@ -229,7 +229,8 @@ findings below re-verified on 2026-07-13). implementations. Record LINK-COMP-01 as a closure blocker and require Phase 5 and Phase 7 integration suites to wrap the real `NakedLink` and prove the sixth scenario. -- **Pumps/cleanup:** use one frame for synchronous focus/state transitions and +- **Pumps/cleanup:** use one event-delivery frame plus one focus-callback rebuild + frame for traversal, one frame for other synchronous state transitions, and bounded observable waits only for web scrolling or platform attachment. Restore view, DPR, direction, text scale, scroll controller, semantics handle, mouse gesture, focus nodes, and fixture state. No `pumpAndSettle`, sleeps, diff --git a/tool/run_android_integration.sh b/tool/run_android_integration.sh index ca6664ed..67d44ecf 100755 --- a/tool/run_android_integration.sh +++ b/tool/run_android_integration.sh @@ -24,5 +24,6 @@ adb -s "$device_id" get-state --dart-define="NAKED_UI_FLUTTER_VERSION=$NAKED_UI_FLUTTER_VERSION" test -s build/integration_test_screenshots/dialog__open__android__reference.png + test -s build/integration_test_screenshots/link__disabled__android__reference.png test -s build/integration_test_screenshots/manifest.json ) From c885fcb08cf88c48cce1ed61ce610697947f2d06 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 02:01:54 -0400 Subject: [PATCH 06/18] fix: tighten Link fixture layout --- packages/example/lib/api/naked_link.0.dart | 5 ----- packages/example/test/naked_link_example_test.dart | 10 ++++++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/example/lib/api/naked_link.0.dart b/packages/example/lib/api/naked_link.0.dart index 8d86e533..57317089 100644 --- a/packages/example/lib/api/naked_link.0.dart +++ b/packages/example/lib/api/naked_link.0.dart @@ -193,10 +193,6 @@ class _LinkExampleState extends State { child: child!, ), ), - const Text( - '.', - style: TextStyle(color: Color(0xFF334155), fontSize: 16), - ), ], ); } @@ -313,7 +309,6 @@ class _LinkSurface extends StatelessWidget { padding: standalone ? const EdgeInsets.symmetric(horizontal: 10, vertical: 10) : const EdgeInsets.symmetric(horizontal: 2, vertical: 2), - alignment: standalone ? Alignment.center : null, decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(standalone ? 6 : 2), diff --git a/packages/example/test/naked_link_example_test.dart b/packages/example/test/naked_link_example_test.dart index d5de5f3a..e7ef5519 100644 --- a/packages/example/test/naked_link_example_test.dart +++ b/packages/example/test/naked_link_example_test.dart @@ -32,6 +32,16 @@ void main() { lessThan(48), reason: 'An inline Link must not be forced into a button-sized line box.', ); + for (final key in const ['link.external', 'link.disabled']) { + final size = tester.getSize(find.byKey(ValueKey(key))); + expect(size.height, greaterThanOrEqualTo(48)); + expect( + size.width, + lessThan(600), + reason: '$key should wrap its content, not fill the 680px row.', + ); + } + expect(find.text('.'), findsNothing); }); testWidgets('primary and external activation update result exactly once', ( From 0a97c0853913861c75df55c0a44756842649e2fb Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 02:09:44 -0400 Subject: [PATCH 07/18] fix: pin Link golden surface --- packages/example/lib/api/naked_link.0.dart | 2 +- .../components/naked_link_golden_test.dart | 18 +++++++++++++++++- .../example/test/naked_link_example_test.dart | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/example/lib/api/naked_link.0.dart b/packages/example/lib/api/naked_link.0.dart index 57317089..bfa155de 100644 --- a/packages/example/lib/api/naked_link.0.dart +++ b/packages/example/lib/api/naked_link.0.dart @@ -313,7 +313,7 @@ class _LinkSurface extends StatelessWidget { color: background, borderRadius: BorderRadius.circular(standalone ? 6 : 2), ), - child: DefaultTextStyle( + child: DefaultTextStyle.merge( style: TextStyle( color: foreground, fontSize: 16, diff --git a/packages/example/test/goldens/components/naked_link_golden_test.dart b/packages/example/test/goldens/components/naked_link_golden_test.dart index f910dfd3..7be1ce3c 100644 --- a/packages/example/test/goldens/components/naked_link_golden_test.dart +++ b/packages/example/test/goldens/components/naked_link_golden_test.dart @@ -10,10 +10,19 @@ import '../golden_test_harness.dart'; void main() { setUpAll(loadGoldenTestFont); + testWidgets('canonical Link golden uses the fixed surface', (tester) async { + await _pumpLinkGoldenSurface(tester); + + expect( + tester.getSize(find.byKey(const ValueKey('link.evidence.surface'))), + goldenSurfaceSize, + ); + }); + testWidgets( 'canonical Link keyboard focus matches its reference golden', (tester) async { - await pumpGoldenSurface(tester, child: const link_example.LinkExample()); + await _pumpLinkGoldenSurface(tester); await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); @@ -33,3 +42,10 @@ void main() { skip: !Platform.isLinux, ); } + +Future _pumpLinkGoldenSurface(WidgetTester tester) { + return pumpGoldenSurface( + tester, + child: const SizedBox.expand(child: link_example.LinkExample()), + ); +} diff --git a/packages/example/test/naked_link_example_test.dart b/packages/example/test/naked_link_example_test.dart index e7ef5519..d9ae33e8 100644 --- a/packages/example/test/naked_link_example_test.dart +++ b/packages/example/test/naked_link_example_test.dart @@ -151,6 +151,20 @@ void main() { expect(directionality.textDirection, TextDirection.rtl); expect(find.textContaining('دليل الوصول'), findsOneWidget); }); + + testWidgets('styled Links preserve the ambient font family', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(fontFamily: 'FixtureFont'), + home: const Scaffold(body: link_example.LinkExample()), + ), + ); + + final style = DefaultTextStyle.of( + tester.element(find.text('Read the documentation')), + ).style; + expect(style.fontFamily, 'FixtureFont'); + }); } Widget _app(Widget child) { From 8f2af56895a3e89cf3b5dfbf9f0576d19e11a558 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 02:16:15 -0400 Subject: [PATCH 08/18] test: load Material Icons in goldens --- .../test/goldens/golden_test_harness.dart | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/example/test/goldens/golden_test_harness.dart b/packages/example/test/goldens/golden_test_harness.dart index f5b2b572..da5f0a0b 100644 --- a/packages/example/test/goldens/golden_test_harness.dart +++ b/packages/example/test/goldens/golden_test_harness.dart @@ -8,7 +8,7 @@ const goldenSurfaceSize = Size(800, 600); const goldenDevicePixelRatio = 1.0; const goldenFontFamily = 'GoldenRoboto'; -/// Loads the checked-in Apache-2.0 Roboto font used by every example golden. +/// Loads the pinned text and icon fonts used by every example golden. Future loadGoldenTestFont() async { final packageRelative = File('test/goldens/fonts/Roboto-Regular.ttf'); final workspaceRelative = File( @@ -20,8 +20,37 @@ Future loadGoldenTestFont() async { if (!fontFile.existsSync()) { throw StateError('Unable to locate the checked-in golden Roboto font.'); } - final bytes = await fontFile.readAsBytes(); - final loader = FontLoader(goldenFontFamily) + await _loadFont(goldenFontFamily, fontFile); + + final flutterRoot = Platform.environment['FLUTTER_ROOT']; + final executableFlutterRoot = File( + Platform.resolvedExecutable, + ).parent.parent.parent.parent.parent.path; + final materialIconsCandidates = [ + if (flutterRoot != null) + File( + '$flutterRoot/bin/cache/artifacts/material_fonts/' + 'MaterialIcons-Regular.otf', + ), + File( + '$executableFlutterRoot/bin/cache/artifacts/material_fonts/' + 'MaterialIcons-Regular.otf', + ), + ]; + final materialIcons = materialIconsCandidates + .where((candidate) => candidate.existsSync()) + .firstOrNull; + if (materialIcons == null) { + throw StateError( + 'Unable to locate MaterialIcons-Regular.otf in the pinned Flutter SDK.', + ); + } + await _loadFont('MaterialIcons', materialIcons); +} + +Future _loadFont(String family, File file) async { + final bytes = await file.readAsBytes(); + final loader = FontLoader(family) ..addFont(Future.value(ByteData.sublistView(bytes))); await loader.load(); } From 2565a27cc3944ddedc61f463dbfb2a8f6d892c10 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 02:19:49 -0400 Subject: [PATCH 09/18] test: add reviewed Link golden --- .../baselines/naked_link__keyboard_focus.png | Bin 0 -> 40825 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/example/test/goldens/components/baselines/naked_link__keyboard_focus.png diff --git a/packages/example/test/goldens/components/baselines/naked_link__keyboard_focus.png b/packages/example/test/goldens/components/baselines/naked_link__keyboard_focus.png new file mode 100644 index 0000000000000000000000000000000000000000..ec8abc41afc905f0693e5fae4aa599cf6865d6ac GIT binary patch literal 40825 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 literal 0 HcmV?d00001 From 24460f0a94b657854c95d5dc900e5ef7215d9604 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 02:28:07 -0400 Subject: [PATCH 10/18] fix: keep Link screenshots inside safe areas --- .../integration_test/screenshot_smoke.dart | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/example/integration_test/screenshot_smoke.dart b/packages/example/integration_test/screenshot_smoke.dart index b3394541..063d33e5 100644 --- a/packages/example/integration_test/screenshot_smoke.dart +++ b/packages/example/integration_test/screenshot_smoke.dart @@ -69,6 +69,31 @@ void main() { ); }); + testWidgets('Link screenshot surface respects safe insets', (tester) async { + const viewPadding = FakeViewPadding(top: 24, bottom: 16); + tester.view.padding = viewPadding; + tester.view.viewPadding = viewPadding; + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewPadding); + + final screenshotSurface = await _pumpLinkSurface( + tester, + const link_example.LinkExample(), + ); + final logicalPadding = EdgeInsets.fromViewPadding( + tester.view.padding, + tester.view.devicePixelRatio, + ); + final logicalViewSize = + tester.view.physicalSize / tester.view.devicePixelRatio; + + expect(tester.getTopLeft(screenshotSurface).dy, logicalPadding.top); + expect( + tester.getBottomRight(screenshotSurface).dy, + logicalViewSize.height - logicalPadding.bottom, + ); + }); + testWidgets('Link keyboard focus screenshot evidence', (tester) async { final screenshotSurface = await _pumpLinkSurface( tester, @@ -145,14 +170,28 @@ Future _pumpLinkSurface(WidgetTester tester, Widget child) async { await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, - home: Scaffold(body: SizedBox.expand(child: child)), + home: Scaffold( + body: SafeArea(child: SizedBox.expand(child: child)), + ), ), ); await tester.pump(); final screenshotSurface = find.byKey(const ValueKey('link.evidence.surface')); expect(screenshotSurface, findsOneWidget); if (!usesNativeSurface) { - expect(tester.getSize(screenshotSurface), const Size(800, 600)); + final logicalPadding = EdgeInsets.fromViewPadding( + tester.view.padding, + tester.view.devicePixelRatio, + ); + final logicalViewSize = + tester.view.physicalSize / tester.view.devicePixelRatio; + expect( + tester.getSize(screenshotSurface), + Size( + logicalViewSize.width - logicalPadding.horizontal, + logicalViewSize.height - logicalPadding.vertical, + ), + ); } return screenshotSurface; } From 261455521a27fb822307f7ff83bd1082725c5ecc Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 02:36:14 -0400 Subject: [PATCH 11/18] docs: record Link evidence --- plan/README.md | 9 ++- plan/phases/02-link.md | 121 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/plan/README.md b/plan/README.md index adcdb4cc..1b793d23 100644 --- a/plan/README.md +++ b/plan/README.md @@ -29,7 +29,7 @@ do not pre-write plans for phases whose blocking decisions are unresolved. |---:|---|---|---|---|---| | 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | | 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Tracked independently in ready-for-review PR #64; closure blockers are recorded there | -| 2 | Link | [§20](briefing.md#20-component-contract-link) | — | [phases/02-link.md](phases/02-link.md) | **Active** — implementation and fixture complete; publication/platform proof in progress | +| 2 | Link | [§20](briefing.md#20-component-contract-link) | — | [phases/02-link.md](phases/02-link.md) | Ready for review in [PR #65](https://github.com/btwld/naked_ui/pull/65); closure blocked on required web screenshots, manual AT, and Phase 5/7 composition evidence | | 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | | 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | | 5 | Context Menu | [§15](briefing.md#15-component-contract-context-menu) | D-03 | — | Not started | @@ -73,10 +73,9 @@ Shared closure gates for every component: created; the original `0ca0b8b` audit is historical evidence, not a permanent assumption. -Recommended next move: execute the active Phase 2 Link plan test-first while -Phase 1 waits independently on its recorded evidence blockers. Do not begin -later phases in parallel and do not merge either PR without explicit maintainer -authorization. +Recommended next move: keep Phase 1 and Phase 2 unmerged while their recorded +closure evidence is unavailable, then research D-08 and D-09 and obtain +explicit maintainer approval before beginning Phase 3 Field implementation. ## How to work a phase diff --git a/plan/phases/02-link.md b/plan/phases/02-link.md index 6600f855..debf0c00 100644 --- a/plan/phases/02-link.md +++ b/plan/phases/02-link.md @@ -1,7 +1,8 @@ # Phase 2 — Link -Status: **Active — core implementation and deterministic fixture complete; -publication and real-platform evidence gates are in progress**. +Status: **Ready for review in PR #65 — automated local and hosted evidence is +green; closure remains blocked by required web screenshots, manual assistive +technology sessions, and real Context Menu/Hover Card composition**. Goal: add a headless inline navigation primitive that exposes Link rather than Button semantics, activates once through primary pointer, Enter, Numpad Enter, @@ -347,31 +348,125 @@ Hosted gates: primary and exact-minimum suites; canonical golden/guidelines; pinned Chrome/ChromeDriver behavior log; PR-title policy. Every result must be green on the exact PR head or an identified GitHub merge ref. +## Execution evidence — 2026-07-13 + +Review-ready PR: [#65](https://github.com/btwld/naked_ui/pull/65). The fully +tested implementation/evidence head is +`24460f0a94b657854c95d5dc900e5ef7215d9604`; its GitHub test merge ref is +`09e62c8dc29b424a1d00e5e7de8cfc4a99cd124f`. The PR remains unmerged. + +### Test-first and failure-triage record + +- The first API tests failed to compile because `NakedLink` and + `NakedLinkState` did not exist. The first semantics tests failed because no + Link node, URL, or Link action existed. +- Targeted restored mutations proved the tests reject Space activation + (callback count 3 instead of 2), secondary activation (1 instead of 0), and + Button semantics (Button true instead of false). A contrast mutation failed + at ratio 1.00 before the approved link color was restored. +- Fixture review produced intentional red proofs before each correction: + inline height was 48px instead of less than 48px; a standalone target filled + 680px; the standalone guideline fixture was absent; ambient font family was + null; and a synthetic safe-inset surface was 800×600 instead of 800×560. +- The first hosted golden candidate was rejected at 800×397. The second fixed + the surface but exposed fallback-glyph blocks and a missing Material icon. + The third candidate fixed both by preserving the ambient font and loading + Material Icons from the pinned Flutter SDK. Only that reviewed 800×600 PNG + was checked in unchanged. +- The first API 34 screenshot was rejected because content overlapped the top + system inset. The synthetic-inset regression failed for the same reason; + `SafeArea` fixed the root condition, and the replacement hosted image was + reviewed. + +### Local verification on Flutter 3.41.2 + +- Format, analyze, and dartdoc dry run: pass with no findings. +- Package suite: 597 pass; three documented external-integration skips. +- Example suite after the reviewed golden: 24 pass; two host-specific pixel + comparison skips on macOS. +- Focused Link package/semantics/hash proof: 39 pass. +- Focused Link integration: 8/8 on `flutter-tester` and real macOS. +- Aggregate: 96 pass and one documented Tooltip skip on both + `flutter-tester` and real macOS. +- Screenshot smoke: six behavior scenarios pass; the exact-head real-macOS + driver captures the four Link states plus the inherited Dialog state and + records a complete manifest. + +### Hosted verification + +All seven checks passed for head `24460f0` / merge ref `09e62c8`: + +- [Flutter CI run 29229092269](https://github.com/btwld/naked_ui/actions/runs/29229092269): + primary Ubuntu 24.04 tests, format/analyze/DCM, reviewed golden comparison, + guidelines, and exact-minimum Flutter 3.41.0 all pass. +- [Integration run 29229092358](https://github.com/btwld/naked_ui/actions/runs/29229092358): + aggregate `flutter-tester` and real macOS behavior pass; macOS screenshot + capture, exact file assertions, and artifact transport pass. +- [Web run 29229092255](https://github.com/btwld/naked_ui/actions/runs/29229092255): + matched Chrome/ChromeDriver 150.0.7871.115 behavior passes, including the + observable Space-scroll postcondition; the behavior log is retained as an + artifact but is not substituted for missing screenshots. +- [Android run 29229092250](https://github.com/btwld/naked_ui/actions/runs/29229092250): + API 34 Pixel 6 behavior and screenshot transport pass. +- [PR-title run 29229091554](https://github.com/btwld/naked_ui/actions/runs/29229091554): + pass. + +### Reviewed visual evidence + +- Ubuntu golden `naked_link__keyboard_focus.png`: 800×600, DPR 1, pinned + Roboto/Material Icons, en-US/LTR, SHA-256 + `88f39adcc2a5916f370d2ed5fdd8ff897e9c6cc4f00a5e3a9d71152ccf22086e`. + The same hosted job subsequently compared the checked-in file unchanged. +- macOS default inline: + `02f2565b397780d8ea50d2a8c98c754fcab85464d13abe76c8b962097fd3a6a7`. +- macOS keyboard focus: + `18683fbc41e260fa54622db65893c164bd4bdb29b06cbdea32754a098c9d9095`. +- macOS 200% long text: + `4bf12ea4007dadc972a74bb71fa9a376f85dc823aca53658d381a70a68085cda`. + All three are 800×600/DPR 1, and hosted bytes match the reviewed local files. +- Android disabled: + `db651e38a3e4b5f53e4ae07cd059e02a235b29563ea222806ce96795b5636b75`; + 1080×2274 physical pixels, DPR 2.625, safe content surface approximately + 411.43×817.52 logical pixels. The manifest records merge ref `09e62c8`. + +### Required closure blockers + +- `link__hover__web__reference.png`, + `link__external_hint__web__reference.png`, and + `link__rtl__web__reference.png` remain unsupported on Flutter 3.41.2. +- VoiceOver, TalkBack, Chrome accessibility-tree, and release-level iOS + VoiceOver sessions require human operators and are not available here. +- LINK-COMP-01 requires real Phase 5 Context Menu and Phase 7 Hover Card + implementations around Link; placeholders do not satisfy the contract. +- Maintainer merge authorization and post-merge `main` verification have not + been provided. These blockers prevent closure but do not invalidate the + review-ready PR. + ## Acceptance and stop conditions -- [ ] Every A1/A2 test was observed failing for the intended missing behavior +- [x] Every A1/A2 test was observed failing for the intended missing behavior before implementation and the red evidence is recorded. -- [ ] Link public API, state equality/scope, and effective-enabled behavior +- [x] Link public API, state equality/scope, and effective-enabled behavior match the binding contract without router, styling, or visited state. -- [ ] Primary/canceled/secondary pointer, Enter/Numpad/Space, semantic tap, +- [x] Primary/canceled/secondary pointer, Enter/Numpad/Space, semantic tap, feedback, cursor, callbacks, and dynamic removal pass focused tests. -- [ ] Link/URL/name/hint/enabled/focus/action semantics are exact; Button and +- [x] Link/URL/name/hint/enabled/focus/action semantics are exact; Button and duplicate naming are absent; disabled/excluded behavior passes. -- [ ] Focus-node ownership/replacement/disposal and aggregate teardown pass. -- [ ] Canonical fixture, stable result/reset/readout, Arabic RTL, 200% text, +- [x] Focus-node ownership/replacement/disposal and aggregate teardown pass. +- [x] Canonical fixture, stable result/reset/readout, Arabic RTL, 200% text, external-icon exclusion, golden, and accessibility guidelines pass. -- [ ] Integration component, inventory, fast aggregate, real macOS aggregate, +- [x] Integration component, inventory, fast aggregate, real macOS aggregate, hosted API 34, and pinned web behavior pass on the exact PR head. -- [ ] All seven screenshot names have reviewed evidence, or Phase 2 is +- [x] All seven screenshot names have reviewed evidence, or Phase 2 is explicitly blocked; unsupported web screenshots are not marked passed. - [ ] VoiceOver, TalkBack, Chrome accessibility-tree, and release-level iOS records are attached; missing human evidence blocks closure. - [ ] Real Context Menu and Hover Card composition proof is attached after those components exist; placeholder wrappers do not satisfy it. -- [ ] Full publication commands and hosted Flutter 3.41.0 pass. -- [ ] Docs, changelog, compatibility statement, traceability, manifests, +- [x] Full publication commands and hosted Flutter 3.41.0 pass. +- [x] Docs, changelog, compatibility statement, traceability, manifests, visual review, and ten-item handoff packet are ready. -- [ ] Entire diff reviewed; ready-for-review PR open; exact-head checks green; +- [x] Entire diff reviewed; ready-for-review PR open; evidence-head checks green; plan/status board contain final evidence; PR remains unmerged without explicit maintainer authorization. From dc20214ba1aab5d665d80f2baa0fe13855de2792 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 13:32:05 -0400 Subject: [PATCH 12/18] fix: harden NakedLink navigation ownership --- .github/workflows/integration-web.yml | 12 + README.md | 9 +- .../components/naked_link_integration.dart | 130 +++++- .../helpers/link_dom_probe.dart | 2 + .../helpers/link_dom_probe_stub.dart | 12 + .../helpers/link_dom_probe_web.dart | 53 +++ .../link_browser_driver_app.dart | 74 ++++ .../integration_test/screenshot_smoke.dart | 2 +- packages/example/lib/api/naked_link.0.dart | 11 +- packages/example/pubspec.yaml | 4 + .../example/test/naked_link_example_test.dart | 55 +-- .../test_driver/link_browser_driver.dart | 154 +++++++ packages/naked_ui/CHANGELOG.md | 20 +- packages/naked_ui/README.md | 9 +- .../naked_ui/lib/src/mixins/naked_mixins.dart | 11 +- packages/naked_ui/lib/src/naked_link.dart | 143 ++++-- .../naked_ui/lib/src/utilities/intents.dart | 6 +- .../utilities/naked_focusable_detector.dart | 94 +++- packages/naked_ui/pubspec.yaml | 1 + .../semantics/naked_link_semantics_test.dart | 154 +++++-- .../naked_ui/test/src/naked_link_test.dart | 414 +++++++++++++++++- .../naked_focusable_detector_test.dart | 92 ++++ plan/README.md | 6 +- plan/decisions.md | 21 + plan/phases/02-link.md | 168 +++++-- pubspec.lock | 66 ++- 26 files changed, 1508 insertions(+), 215 deletions(-) create mode 100644 packages/example/integration_test/helpers/link_dom_probe.dart create mode 100644 packages/example/integration_test/helpers/link_dom_probe_stub.dart create mode 100644 packages/example/integration_test/helpers/link_dom_probe_web.dart create mode 100644 packages/example/integration_test/link_browser_driver_app.dart create mode 100644 packages/example/test_driver/link_browser_driver.dart diff --git a/.github/workflows/integration-web.yml b/.github/workflows/integration-web.yml index b05f36d3..88f85ac7 100644 --- a/.github/workflows/integration-web.yml +++ b/.github/workflows/integration-web.yml @@ -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 @@ -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() @@ -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 diff --git a/README.md b/README.md index 5502c793..0e4d86ee 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The complete documentation covers detailed component APIs and examples, guides a ## Supported Components - NakedButton — button interactions (hover, press, focus) -- NakedLink — Link semantics, URL metadata, and Enter-only keyboard activation +- 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 @@ -62,13 +62,14 @@ NakedButton( ### Custom Link Use a Link for navigation rather than styling a Button like text. The URL is -semantics metadata; your callback still owns routing or launching. Enter and -Numpad Enter activate, while Space remains available to the page. +the destination and enables the Link. Naked UI delegates default navigation to +Flutter's official `url_launcher.Link`; provide `onPressed` only when custom +routing should replace that default. Enter and Numpad Enter activate, while +Space remains available to the page. ```dart NakedLink( linkUrl: Uri.parse('https://example.com/docs'), - onPressed: openDocumentation, child: const Text('Documentation'), builder: (context, state, child) => DecoratedBox( decoration: BoxDecoration( diff --git a/packages/example/integration_test/components/naked_link_integration.dart b/packages/example/integration_test/components/naked_link_integration.dart index 64db2207..cbc1f6a9 100644 --- a/packages/example/integration_test/components/naked_link_integration.dart +++ b/packages/example/integration_test/components/naked_link_integration.dart @@ -6,8 +6,10 @@ 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 'package:naked_ui/naked_ui.dart'; import '../helpers/keyboard_test_helpers.dart'; +import '../helpers/link_dom_probe.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -38,7 +40,13 @@ void main() { await tester.pumpWidget( const MaterialApp( home: Scaffold( - body: link_example.LinkExample(textScale: 2, longText: true), + body: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: 320, + child: link_example.LinkExample(textScale: 2, longText: true), + ), + ), ), ), ); @@ -124,6 +132,123 @@ void main() { } }); + testWidgets('web destination-only Link delegates to native navigation', ( + tester, + ) async { + if (!supportsLinkDomProbe) return; + + final originalUri = currentBrowserUri!; + const marker = 'naked-link-native-navigation'; + final destination = originalUri.replace(fragment: marker); + addTearDown(() => restoreBrowserUri(originalUri)); + + final semantics = tester.ensureSemantics(); + addTearDown(semantics.dispose); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: NakedLink( + linkUrl: destination, + child: const Text('Native navigation'), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpUntil( + () => hasLinkHrefContaining(marker), + timeout: const Duration(seconds: 2), + ); + + expect(hasLinkHrefContaining(marker), isTrue); + final node = tester.getSemantics(find.text('Native navigation')); + node.owner!.performAction(node.id, SemanticsAction.tap); + final click = await dispatchSyntheticLinkClick(marker); + expect(click?.defaultPrevented, isFalse); + expect(click?.resultingUri.fragment, marker); + expect(currentBrowserUri, originalUri); + }); + + testWidgets('web custom callback signals native suppression', ( + tester, + ) async { + if (!supportsLinkDomProbe) return; + + final originalUri = currentBrowserUri!; + const marker = 'naked-link-custom-override'; + final destination = originalUri.replace(fragment: marker); + var activations = 0; + addTearDown(() => restoreBrowserUri(originalUri)); + + final semantics = tester.ensureSemantics(); + addTearDown(semantics.dispose); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: NakedLink( + linkUrl: destination, + onPressed: () => activations++, + child: const Text('Custom navigation'), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpUntil( + () => hasLinkHrefContaining(marker), + timeout: const Duration(seconds: 2), + ); + + expect(hasLinkHrefContaining(marker), isTrue); + await tester.tap(find.text('Custom navigation')); + await tester.pump(); + expect(activations, 1); + expect(currentBrowserUri, originalUri); + + final click = await dispatchSyntheticLinkClick(marker); + expect(click?.defaultPrevented, isTrue); + expect(currentBrowserUri, originalUri); + }); + + testWidgets('web disabled Link removes its native destination', ( + tester, + ) async { + if (!supportsLinkDomProbe) return; + + final originalUri = currentBrowserUri!; + const marker = 'naked-link-disabled'; + final destination = originalUri.replace(fragment: marker); + var activations = 0; + addTearDown(() => restoreBrowserUri(originalUri)); + + final semantics = tester.ensureSemantics(); + addTearDown(semantics.dispose); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: NakedLink( + linkUrl: destination, + enabled: false, + onPressed: () => activations++, + child: const Text('Unavailable destination'), + ), + ), + ), + ); + await tester.pump(); + + expect(hasLinkHrefContaining(marker), isFalse); + await tester.tap(find.text('Unavailable destination')); + await tester.pump(); + expect(activations, 0); + expect(currentBrowserUri, originalUri); + final data = tester + .getSemantics(find.text('Unavailable destination')) + .getSemanticsData(); + expect(data.flagsCollection.isLink, isFalse); + expect(data.hasAction(SemanticsAction.tap), isFalse); + }); + testWidgets( 'disabled Link is skipped and has no pointer or semantic action', (tester) async { @@ -149,6 +274,7 @@ void main() { final disabled = tester.getSemantics( find.text('Unavailable documentation'), ); + expect(disabled.getSemanticsData().flagsCollection.isLink, isFalse); expect( disabled.getSemanticsData().hasAction(SemanticsAction.tap), isFalse, @@ -159,7 +285,7 @@ void main() { }, ); - testWidgets('callback removal while focused blocks later activation', ( + testWidgets('destination removal while focused blocks later activation', ( tester, ) async { await tester.pumpWidget(const link_example.MyApp()); diff --git a/packages/example/integration_test/helpers/link_dom_probe.dart b/packages/example/integration_test/helpers/link_dom_probe.dart new file mode 100644 index 00000000..189498b2 --- /dev/null +++ b/packages/example/integration_test/helpers/link_dom_probe.dart @@ -0,0 +1,2 @@ +export 'link_dom_probe_stub.dart' + if (dart.library.js_interop) 'link_dom_probe_web.dart'; diff --git a/packages/example/integration_test/helpers/link_dom_probe_stub.dart b/packages/example/integration_test/helpers/link_dom_probe_stub.dart new file mode 100644 index 00000000..f55e18a0 --- /dev/null +++ b/packages/example/integration_test/helpers/link_dom_probe_stub.dart @@ -0,0 +1,12 @@ +bool get supportsLinkDomProbe => false; + +Uri? get currentBrowserUri => null; + +bool hasLinkHrefContaining(String marker) => false; + +typedef LinkClickResult = ({bool defaultPrevented, Uri resultingUri}); + +Future dispatchSyntheticLinkClick(String marker) async => + null; + +void restoreBrowserUri(Uri uri) {} diff --git a/packages/example/integration_test/helpers/link_dom_probe_web.dart b/packages/example/integration_test/helpers/link_dom_probe_web.dart new file mode 100644 index 00000000..30038fec --- /dev/null +++ b/packages/example/integration_test/helpers/link_dom_probe_web.dart @@ -0,0 +1,53 @@ +import 'package:web/web.dart' as web; + +bool get supportsLinkDomProbe => true; + +Uri get currentBrowserUri => Uri.parse(web.window.location.href); + +web.Element? _findLink(String marker) { + final nativeAnchor = _findAnchor( + web.document.querySelectorAll('a[rel="noreferrer noopener"]'), + marker, + ); + return nativeAnchor ?? + _findAnchor(web.document.querySelectorAll('a'), marker); +} + +web.Element? _findAnchor(web.NodeList anchors, String marker) { + for (var index = 0; index < anchors.length; index++) { + final anchor = anchors.item(index)! as web.Element; + if (anchor.getAttribute('href')?.contains(marker) ?? false) { + return anchor; + } + } + return null; +} + +bool hasLinkHrefContaining(String marker) => _findLink(marker) != null; + +typedef LinkClickResult = ({bool defaultPrevented, Uri resultingUri}); + +/// Dispatches a synthetic DOM event to inspect the Link coordinator signal. +/// +/// Resulting-location ownership for custom callbacks is verified separately by +/// the WebDriver test because synthetic anchor default actions do not model the +/// coordinator's microtask timing reliably. +Future dispatchSyntheticLinkClick(String marker) async { + final anchor = _findLink(marker); + if (anchor == null) return null; + + final originalUri = currentBrowserUri; + final event = web.MouseEvent( + 'click', + web.MouseEventInit(bubbles: true, cancelable: true), + ); + anchor.dispatchEvent(event); + await Future.delayed(Duration.zero); + final resultingUri = currentBrowserUri; + restoreBrowserUri(originalUri); + return (defaultPrevented: event.defaultPrevented, resultingUri: resultingUri); +} + +void restoreBrowserUri(Uri uri) { + web.window.history.replaceState(null, '', uri.toString()); +} diff --git a/packages/example/integration_test/link_browser_driver_app.dart b/packages/example/integration_test/link_browser_driver_app.dart new file mode 100644 index 00000000..b77199db --- /dev/null +++ b/packages/example/integration_test/link_browser_driver_app.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:naked_ui/naked_ui.dart'; + +void main() { + enableFlutterDriverExtension(); + SemanticsBinding.instance.ensureSemantics(); + runApp(const _LinkBrowserDriverApp()); +} + +class _LinkBrowserDriverApp extends StatefulWidget { + const _LinkBrowserDriverApp(); + + @override + State<_LinkBrowserDriverApp> createState() => _LinkBrowserDriverAppState(); +} + +class _LinkBrowserDriverAppState extends State<_LinkBrowserDriverApp> { + var _customActivations = 0; + var _dynamicActivations = 0; + var _dynamicEnabled = true; + + Uri _destination(String fragment) => Uri.base.replace(fragment: fragment); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + NakedLink( + linkUrl: _destination('naked-link-browser-default'), + child: const Text('Default navigation'), + ), + NakedLink( + linkUrl: _destination('naked-link-browser-custom'), + onPressed: () { + setState(() => _customActivations++); + }, + child: const Text('Custom navigation'), + ), + NakedLink( + enabled: _dynamicEnabled, + linkUrl: _destination('naked-link-browser-dynamic'), + onPressed: () { + setState(() => _dynamicActivations++); + }, + child: const Text('Dynamic navigation'), + ), + Text( + 'custom:$_customActivations', + key: const ValueKey('browser-link.custom-result'), + ), + Text( + 'dynamic:$_dynamicActivations; enabled:$_dynamicEnabled', + key: const ValueKey('browser-link.dynamic-result'), + ), + TextButton( + key: const ValueKey('browser-link.disable-dynamic'), + onPressed: _dynamicEnabled + ? () => setState(() => _dynamicEnabled = false) + : null, + child: const Text('Disable dynamic Link'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/example/integration_test/screenshot_smoke.dart b/packages/example/integration_test/screenshot_smoke.dart index 063d33e5..8385a4ab 100644 --- a/packages/example/integration_test/screenshot_smoke.dart +++ b/packages/example/integration_test/screenshot_smoke.dart @@ -125,7 +125,7 @@ void main() { final disabled = tester .getSemantics(find.text('Unavailable documentation')) .getSemanticsData(); - expect(disabled.flagsCollection.isLink, isTrue); + expect(disabled.flagsCollection.isLink, isFalse); expect(disabled.hasAction(SemanticsAction.tap), isFalse); } finally { semantics.dispose(); diff --git a/packages/example/lib/api/naked_link.0.dart b/packages/example/lib/api/naked_link.0.dart index bfa155de..a1451247 100644 --- a/packages/example/lib/api/naked_link.0.dart +++ b/packages/example/lib/api/naked_link.0.dart @@ -137,8 +137,6 @@ class _LinkExampleState extends State { onPressed: _primaryEnabled ? () => setState(() { _primaryEnabled = false; - _hovered = false; - _pressed = false; }) : null, child: const Text('Disable primary Link'), @@ -179,9 +177,11 @@ class _LinkExampleState extends State { ), NakedLink( key: const ValueKey('link.primary'), - linkUrl: Uri.parse('https://example.com/naked-ui'), + linkUrl: _primaryEnabled + ? Uri.parse('https://example.com/naked-ui') + : null, semanticLabel: _isRtl ? linkText : null, - onPressed: _primaryEnabled ? () => _activate('primary') : null, + onPressed: () => _activate('primary'), onHoverChange: (value) => setState(() => _hovered = value), onFocusChange: (value) => setState(() => _focused = value), onPressChange: (value) => setState(() => _pressed = value), @@ -202,7 +202,7 @@ class _LinkExampleState extends State { key: const ValueKey('link.external'), linkUrl: Uri.parse('https://docs.flutter.dev/ui/accessibility'), semanticLabel: 'Flutter accessibility documentation', - semanticHint: 'Opens in a new window', + semanticHint: 'External destination', onPressed: () => _activate('external'), child: const Wrap( crossAxisAlignment: WrapCrossAlignment.center, @@ -231,6 +231,7 @@ class _LinkExampleState extends State { return NakedLink( key: const ValueKey('link.disabled'), enabled: false, + linkUrl: Uri.parse('https://example.com/unavailable'), onPressed: () => _activate('disabled'), child: const Text('Unavailable documentation'), builder: (context, state, child) => _LinkSurface( diff --git a/packages/example/pubspec.yaml b/packages/example/pubspec.yaml index fe8930c0..ae88221d 100644 --- a/packages/example/pubspec.yaml +++ b/packages/example/pubspec.yaml @@ -19,10 +19,14 @@ dependencies: google_fonts: ^6.1.0 dev_dependencies: + flutter_driver: + sdk: flutter flutter_test: sdk: flutter integration_test: sdk: flutter + web: ^1.1.1 + webdriver: ^3.1.0 flutter: uses-material-design: true diff --git a/packages/example/test/naked_link_example_test.dart b/packages/example/test/naked_link_example_test.dart index d9ae33e8..4c7aa92a 100644 --- a/packages/example/test/naked_link_example_test.dart +++ b/packages/example/test/naked_link_example_test.dart @@ -72,38 +72,39 @@ void main() { final data = tester .getSemantics(find.text('Unavailable documentation')) .getSemanticsData(); - expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isLink, isFalse); expect(data.hasAction(SemanticsAction.tap), isFalse); }); - testWidgets('focus and dynamic callback state are visible and resettable', ( - tester, - ) async { - await tester.pumpWidget(_app(const link_example.LinkExample())); + testWidgets( + 'focus and dynamic destination state are visible and resettable', + (tester) async { + await tester.pumpWidget(_app(const link_example.LinkExample())); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - await tester.pump(); - expect( - find.text('hovered:false focused:true pressed:false enabled:true'), - findsOneWidget, - ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.pump(); + expect( + find.text('hovered:false focused:true pressed:false enabled:true'), + findsOneWidget, + ); - 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.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.tap(find.byKey(const ValueKey('link.reset'))); - await tester.pump(); - expect( - find.text('hovered:false focused:false pressed:false enabled:true'), - findsOneWidget, - ); - }); + await tester.tap(find.byKey(const ValueKey('link.reset'))); + await tester.pump(); + expect( + find.text('hovered:false focused:false pressed:false enabled:true'), + findsOneWidget, + ); + }, + ); testWidgets('external hint is named once and its icon is decorative', ( tester, @@ -116,7 +117,7 @@ void main() { .getSemantics(find.byKey(const ValueKey('link.external'))) .getSemanticsData(); expect(data.label, 'Flutter accessibility documentation'); - expect(data.hint, 'Opens in a new window'); + expect(data.hint, 'External destination'); expect( find.bySemanticsLabel(RegExp(r'^External link icon$')), findsNothing, diff --git a/packages/example/test_driver/link_browser_driver.dart b/packages/example/test_driver/link_browser_driver.dart new file mode 100644 index 00000000..679fa15b --- /dev/null +++ b/packages/example/test_driver/link_browser_driver.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:webdriver/async_io.dart'; + +const _anchorSelector = By.cssSelector('a[rel="noreferrer noopener"]'); + +Future main() async { + final driver = await FlutterDriver.connect(); + try { + await driver.waitFor( + find.byValueKey('browser-link.custom-result'), + timeout: const Duration(seconds: 10), + ); + + final webDriver = driver.webDriver; + final anchors = await _waitForAnchors(webDriver); + await _anchorFor(anchors, 'naked-link-browser-default'); + final customAnchor = await _anchorFor(anchors, 'naked-link-browser-custom'); + await _anchorFor(anchors, 'naked-link-browser-dynamic'); + final originalWindow = await webDriver.window; + final originalWindowIds = await _windowIds(webDriver); + final originalUrl = Uri.parse(await webDriver.currentUrl); + stdout.writeln( + 'Native Link hrefs: ${await Future.wait(anchors.map((anchor) => anchor.attributes['href']))}', + ); + + await _captureNextClickTrust(webDriver); + await _click(webDriver, customAnchor); + await driver.waitFor( + find.text('custom:1'), + timeout: const Duration(seconds: 5), + ); + await _expectNoNavigation( + webDriver, + originalWindow, + originalWindowIds, + originalUrl, + 'A custom callback must suppress native browser navigation.', + ); + await _expectTrustedClick(webDriver, 'custom Link'); + + await driver.tap(find.byValueKey('browser-link.disable-dynamic')); + await driver.waitFor( + find.text('dynamic:0; enabled:false'), + timeout: const Duration(seconds: 5), + ); + await _waitForAnchorRemoval(webDriver, 'naked-link-browser-dynamic'); + await driver.tap(find.text('Dynamic navigation')); + await driver.waitFor( + find.text('dynamic:0; enabled:false'), + timeout: const Duration(seconds: 5), + ); + await _expectNoNavigation( + webDriver, + originalWindow, + originalWindowIds, + originalUrl, + 'A disabled Link must not navigate.', + ); + + stdout.writeln('Trusted browser Link ownership checks passed.'); + } finally { + await driver.close(); + } +} + +Future> _waitForAnchors(WebDriver driver) async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (DateTime.now().isBefore(deadline)) { + final anchors = await driver.findElements(_anchorSelector).toList(); + if (anchors.length == 3) { + final hrefs = await Future.wait( + anchors.map((anchor) => anchor.attributes['href']), + ); + if (hrefs.every((href) => href != null)) return anchors; + } + await Future.delayed(const Duration(milliseconds: 20)); + } + throw StateError('Timed out waiting for the three native Link anchors.'); +} + +Future _anchorFor(List anchors, String marker) async { + for (final anchor in anchors) { + final href = await anchor.attributes['href']; + if (href?.contains(marker) ?? false) return anchor; + } + throw StateError('No native Link anchor contained $marker.'); +} + +Future _click(WebDriver driver, WebElement anchor) async { + await driver.mouse.moveToElementCenter(anchor); + await driver.mouse.click(); +} + +Future _captureNextClickTrust(WebDriver driver) { + return driver.execute( + 'window.__nakedLinkClickTrusted = null; ' + 'window.addEventListener("click", function(event) { ' + 'window.__nakedLinkClickTrusted = event.isTrusted; ' + '}, {capture: true, once: true});', + const [], + ); +} + +Future _expectTrustedClick(WebDriver driver, String description) async { + final isTrusted = await driver.execute( + 'return window.__nakedLinkClickTrusted;', + const [], + ); + if (isTrusted != true) { + throw StateError('The $description click was not a trusted browser event.'); + } +} + +Future _waitForAnchorRemoval(WebDriver driver, String marker) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + final anchors = await driver.findElements(_anchorSelector).toList(); + var markerFound = false; + for (final anchor in anchors) { + final href = await anchor.attributes['href']; + if (href?.contains(marker) ?? false) markerFound = true; + } + if (!markerFound && anchors.length == 2) return; + await Future.delayed(const Duration(milliseconds: 20)); + } + throw StateError('The disabled native Link anchor was not removed.'); +} + +Future> _windowIds(WebDriver driver) async => + (await driver.windows.toList()).map((window) => window.id).toSet(); + +Future _expectNoNavigation( + WebDriver driver, + Window originalWindow, + Set expectedWindowIds, + Uri expectedUrl, + String message, +) async { + final actualWindowIds = await _windowIds(driver); + if (actualWindowIds.length != expectedWindowIds.length || + !actualWindowIds.containsAll(expectedWindowIds)) { + throw StateError( + '$message Expected windows $expectedWindowIds, got $actualWindowIds.', + ); + } + await originalWindow.setAsActive(); + final actualUrl = Uri.parse(await driver.currentUrl); + if (actualUrl != expectedUrl) { + throw StateError('$message Expected $expectedUrl, got $actualUrl.'); + } +} diff --git a/packages/naked_ui/CHANGELOG.md b/packages/naked_ui/CHANGELOG.md index 332739ef..e582deb6 100644 --- a/packages/naked_ui/CHANGELOG.md +++ b/packages/naked_ui/CHANGELOG.md @@ -1,12 +1,20 @@ -## 1.0.0-beta.3 +## Unreleased ### Features -- Add `NakedLink` with Link semantics and optional URL metadata, observable - hover/focus/press/disabled state, caller-owned focus nodes, primary and - semantic activation, and Enter/Numpad Enter keyboard support. Space and - secondary click remain unclaimed, and routing, launching, visited state, - styling, and localized copy stay consumer-owned. +- Add `NakedLink` with destination-driven availability, native web anchors, + default navigation through Flutter's official `url_launcher.Link`, and an + optional `onPressed` override for custom routing. It exposes Link semantics, + observable hover/focus/press/disabled state, caller-owned focus nodes, + primary and semantic activation, and Enter/Numpad Enter keyboard support. + Space and secondary click remain unclaimed; visited state, styling, and + localized copy stay consumer-owned. +- Safely clear Link interaction callbacks after dynamic disabling, restore + hover when re-enabled under a stationary pointer, suppress held-key repeats, + preserve stateful descendants across availability changes, and remove + disabled destinations from semantics and the web DOM. + +## 1.0.0-beta.3 ### Fixes and hardening diff --git a/packages/naked_ui/README.md b/packages/naked_ui/README.md index 5502c793..0e4d86ee 100644 --- a/packages/naked_ui/README.md +++ b/packages/naked_ui/README.md @@ -17,7 +17,7 @@ The complete documentation covers detailed component APIs and examples, guides a ## Supported Components - NakedButton — button interactions (hover, press, focus) -- NakedLink — Link semantics, URL metadata, and Enter-only keyboard activation +- 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 @@ -62,13 +62,14 @@ NakedButton( ### Custom Link Use a Link for navigation rather than styling a Button like text. The URL is -semantics metadata; your callback still owns routing or launching. Enter and -Numpad Enter activate, while Space remains available to the page. +the destination and enables the Link. Naked UI delegates default navigation to +Flutter's official `url_launcher.Link`; provide `onPressed` only when custom +routing should replace that default. Enter and Numpad Enter activate, while +Space remains available to the page. ```dart NakedLink( linkUrl: Uri.parse('https://example.com/docs'), - onPressed: openDocumentation, child: const Text('Documentation'), builder: (context, state, child) => DecoratedBox( decoration: BoxDecoration( diff --git a/packages/naked_ui/lib/src/mixins/naked_mixins.dart b/packages/naked_ui/lib/src/mixins/naked_mixins.dart index f0490176..23c4c6b9 100644 --- a/packages/naked_ui/lib/src/mixins/naked_mixins.dart +++ b/packages/naked_ui/lib/src/mixins/naked_mixins.dart @@ -84,8 +84,11 @@ mixin WidgetStatesMixin on State { } /// Change-detecting state update. Returns true if the value actually changed. + /// + /// Set [rebuild] to false only while synchronizing state during a framework + /// lifecycle callback that is already followed by a build. @protected - bool updateState(WidgetState state, bool value) { + bool updateState(WidgetState state, bool value, {bool rebuild = true}) { final before = _widgetStates.contains(state); if (before == value) return false; @@ -95,7 +98,7 @@ mixin WidgetStatesMixin on State { _widgetStates.remove(state); } - if (mounted) { + if (mounted && rebuild) { // ignore: no-empty-block setState(() {}); // Trigger rebuild when widget state changes } @@ -141,8 +144,8 @@ mixin WidgetStatesMixin on State { /// Update disabled state. Returns true if the state actually changed. @protected - bool updateDisabledState(bool value) { - return updateState(WidgetState.disabled, value); + bool updateDisabledState(bool value, {bool rebuild = true}) { + return updateState(WidgetState.disabled, value, rebuild: rebuild); } /// Update error state. Returns true if the state actually changed. diff --git a/packages/naked_ui/lib/src/naked_link.dart b/packages/naked_ui/lib/src/naked_link.dart index 3b867326..020d3c40 100644 --- a/packages/naked_ui/lib/src/naked_link.dart +++ b/packages/naked_ui/lib/src/naked_link.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; +import 'package:url_launcher/link.dart' as launcher; import 'mixins/naked_mixins.dart'; import 'utilities/intents.dart'; @@ -6,15 +9,12 @@ import 'utilities/naked_focusable_detector.dart'; import 'utilities/naked_state_scope.dart'; import 'utilities/state.dart'; -/// An immutable snapshot of a [NakedLink]'s interaction state and URL metadata. +/// An immutable snapshot of a [NakedLink]'s interaction state and destination. class NakedLinkState extends NakedState { /// Creates a snapshot with the current interaction [states] and [linkUrl]. NakedLinkState({required super.states, required this.linkUrl}); - /// The URL exposed to assistive technologies, if one was supplied. - /// - /// This value is metadata only. Naked UI does not launch it or update browser - /// history. + /// The Link's destination, or null when it is unavailable. final Uri? linkUrl; /// Returns the nearest [NakedLinkState] provided by [NakedStateScope]. @@ -47,21 +47,19 @@ class NakedLinkState extends NakedState { /// A headless navigation Link with observable interaction state. /// -/// Primary pointer tap, Enter, Numpad Enter, and semantic tap invoke -/// [onPressed] once while the Link is effectively enabled. Space is not bound -/// by this widget, so a surrounding page retains its normal scrolling -/// behavior. Secondary click is likewise left available for consumer-owned -/// context menus. +/// Primary pointer tap, Enter, Numpad Enter, and semantic tap follow [linkUrl] +/// while the Link is effectively enabled. When [onPressed] is supplied, it +/// replaces the default navigation path. Space is not bound by this widget, so +/// a surrounding page retains its normal scrolling behavior. Secondary click +/// is likewise left available for consumer-owned context menus. /// -/// [linkUrl] is accessibility metadata and does not perform navigation. The -/// application owns routing or URL launching, while [builder] owns all visual -/// styling. A supplied [focusNode] remains caller-owned and is never disposed -/// by Naked UI. +/// Naked UI delegates default navigation and the native web anchor to +/// `url_launcher`'s Link coordinator. The [builder] owns all visual styling. A +/// supplied [focusNode] remains caller-owned and is never disposed by Naked UI. /// /// ```dart /// NakedLink( /// linkUrl: Uri.parse('https://example.com/docs'), -/// onPressed: openDocumentation, /// child: const Text('Documentation'), /// builder: (context, state, child) => DecoratedBox( /// decoration: BoxDecoration( @@ -105,19 +103,20 @@ class NakedLink extends StatefulWidget { /// Builds the Link using the current immutable state. final ValueWidgetBuilder? builder; - /// Performs application-owned navigation when the Link activates. + /// Overrides default navigation when the Link activates. /// - /// A null callback makes the Link effectively disabled even when [enabled] - /// is true. + /// When null, activation follows [linkUrl] through the platform Link + /// coordinator. When non-null, only this callback runs; native navigation is + /// suppressed. final VoidCallback? onPressed; - /// The optional URL exposed to assistive technologies and the web DOM. + /// The destination exposed to assistive technologies and the web DOM. /// - /// Supplying a URL does not launch it; [onPressed] remains responsible for - /// application navigation. + /// A null destination makes the Link effectively disabled, even if + /// [onPressed] is supplied. final Uri? linkUrl; - /// Whether the Link may activate when [onPressed] is also non-null. + /// Whether the Link may activate when [linkUrl] is also non-null. final bool enabled; /// The optional caller-owned focus node. @@ -161,7 +160,7 @@ class NakedLink extends StatefulWidget { /// providing an equivalent accessible navigation path. final bool excludeSemantics; - bool get _effectiveEnabled => enabled && onPressed != null; + bool get _effectiveEnabled => enabled && linkUrl != null; @override State createState() => _NakedLinkState(); @@ -169,13 +168,24 @@ class NakedLink extends StatefulWidget { class _NakedLinkState extends State with WidgetStatesMixin { - void _handleActivation() { + // url_launcher's web delegate always contributes Link semantics, including + // for a null URI. Keep its wrapper out of the unavailable tree and use this + // key to preserve the consumer subtree as the wrapper is added or removed. + final _contentKey = GlobalKey(debugLabel: 'NakedLink content'); + + void _handleActivation(launcher.FollowLink? followLink) { if (!widget._effectiveEnabled) return; if (widget.enableFeedback) { Feedback.forTap(context); } - widget.onPressed!(); + final override = widget.onPressed; + if (override != null) { + override(); + } else { + assert(followLink != null); + unawaited(followLink!()); + } } void _handlePressStart(TapDownDetails details) { @@ -186,6 +196,24 @@ class _NakedLinkState extends State updatePressState(false, widget.onPressChange); } + void _clearInteractionStates() { + final endedPress = updateState(WidgetState.pressed, false, rebuild: false); + final endedHover = updateState(WidgetState.hovered, false, rebuild: false); + final endedFocus = updateState(WidgetState.focused, false, rebuild: false); + if (!endedPress && !endedHover && !endedFocus) return; + + final onPressChange = widget.onPressChange; + final onHoverChange = widget.onHoverChange; + final onFocusChange = widget.onFocusChange; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (endedPress) onPressChange?.call(false); + if (endedHover) onHoverChange?.call(false); + if (endedFocus) onFocusChange?.call(false); + }); + } + @override void initializeWidgetStates() { updateDisabledState(!widget._effectiveEnabled); @@ -195,27 +223,28 @@ class _NakedLinkState extends State void didUpdateWidget(covariant NakedLink oldWidget) { super.didUpdateWidget(oldWidget); - final wasEnabled = oldWidget.enabled && oldWidget.onPressed != null; - if (wasEnabled != widget._effectiveEnabled) { - updateDisabledState(!widget._effectiveEnabled); - if (!widget._effectiveEnabled) { - _handlePressEnd(); - updateHoverState(false, widget.onHoverChange); - } - } + final wasEnabled = oldWidget.enabled && oldWidget.linkUrl != null; + if (wasEnabled == widget._effectiveEnabled) return; + + updateDisabledState(!widget._effectiveEnabled, rebuild: false); + if (!widget._effectiveEnabled) _clearInteractionStates(); } - @override - Widget build(BuildContext context) { + Widget _buildLink(launcher.FollowLink? followLink) { + final isEnabled = widget._effectiveEnabled; + final activation = isEnabled ? () => _handleActivation(followLink) : null; Widget result = GestureDetector( - onTapDown: widget._effectiveEnabled ? _handlePressStart : null, - onTapUp: widget._effectiveEnabled ? (_) => _handlePressEnd() : null, - onTapCancel: widget._effectiveEnabled ? _handlePressEnd : null, - onTap: widget._effectiveEnabled ? _handleActivation : null, + onTapDown: isEnabled ? _handlePressStart : null, + onTapUp: isEnabled ? (_) => _handlePressEnd() : null, + onTapCancel: isEnabled ? _handlePressEnd : null, + onTap: activation, behavior: HitTestBehavior.opaque, excludeFromSemantics: true, child: NakedStateScopeBuilder( - value: NakedLinkState(states: widgetStates, linkUrl: widget.linkUrl), + value: NakedLinkState( + states: widgetStates, + linkUrl: isEnabled ? widget.linkUrl : null, + ), child: widget.child, builder: widget.builder, ), @@ -223,21 +252,24 @@ class _NakedLinkState extends State if (!widget.excludeSemantics) { result = Semantics( - enabled: widget._effectiveEnabled, - link: true, - linkUrl: widget.linkUrl, + enabled: isEnabled, + link: isEnabled, + linkUrl: isEnabled ? widget.linkUrl : null, label: widget.semanticLabel, hint: widget.semanticHint, excludeSemantics: widget.semanticLabel != null, - onTap: widget._effectiveEnabled ? _handleActivation : null, + onTap: activation, child: result, ); } result = NakedFocusableDetector( - enabled: widget._effectiveEnabled, + key: _contentKey, + enabled: isEnabled, autofocus: widget.autofocus, + canRequestFocus: isEnabled, includeSemantics: !widget.excludeSemantics, + restoreHoverOnEnable: true, onFocusChange: (focused) { updateFocusState(focused, widget.onFocusChange); }, @@ -245,15 +277,32 @@ class _NakedLinkState extends State updateHoverState(hovered, widget.onHoverChange); }, focusNode: widget.focusNode, - mouseCursor: widget._effectiveEnabled + mouseCursor: isEnabled ? (widget.mouseCursor ?? SystemMouseCursors.click) : SystemMouseCursors.basic, shortcuts: NakedIntentActions.link.shortcuts, - actions: NakedIntentActions.link.actions(onPressed: _handleActivation), + actions: NakedIntentActions.link.actions( + onPressed: () => _handleActivation(followLink), + ), debugLabel: 'NakedLink', child: result, ); + return result; + } + + @override + Widget build(BuildContext context) { + final Widget result; + if (widget._effectiveEnabled) { + result = launcher.Link( + uri: widget.linkUrl, + builder: (context, followLink) => _buildLink(followLink), + ); + } else { + result = _buildLink(null); + } + return widget.excludeSemantics ? ExcludeSemantics(child: result) : result; } } diff --git a/packages/naked_ui/lib/src/utilities/intents.dart b/packages/naked_ui/lib/src/utilities/intents.dart index 594a073c..d68242c3 100644 --- a/packages/naked_ui/lib/src/utilities/intents.dart +++ b/packages/naked_ui/lib/src/utilities/intents.dart @@ -307,8 +307,10 @@ const Map _buttonShortcuts = const Map _linkShortcuts = { - SingleActivator(LogicalKeyboardKey.enter): _LinkActivateIntent(), - SingleActivator(LogicalKeyboardKey.numpadEnter): _LinkActivateIntent(), + SingleActivator(LogicalKeyboardKey.enter, includeRepeats: false): + _LinkActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter, includeRepeats: false): + _LinkActivateIntent(), }; const Map _tabShortcuts = diff --git a/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart b/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart index e5463599..07a8d740 100644 --- a/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart +++ b/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../mixins/naked_mixins.dart'; @@ -16,6 +17,7 @@ class NakedFocusableDetector extends StatefulWidget { this.descendantsAreTraversable = true, this.skipTraversal = false, this.includeSemantics = true, + this.restoreHoverOnEnable = false, this.onFocusChange, this.onHoverChange, this.onEnableChange, @@ -52,6 +54,11 @@ class NakedFocusableDetector extends StatefulWidget { /// Whether to include focus semantics. final bool includeSemantics; + /// Whether to restore hover after re-enabling under a stationary pointer. + /// + /// This is opt-in so existing component hover behavior remains unchanged. + final bool restoreHoverOnEnable; + /// Called when the focus state changes. final ValueChanged? onFocusChange; @@ -86,6 +93,8 @@ class NakedFocusableDetector extends StatefulWidget { class _NakedFocusableDetectorState extends State with FocusNodeMixin { bool _wasEnabled = true; + bool _pointerInside = false; + bool _hoverReported = false; @override FocusNode? get widgetProvidedNode => widget.focusNode; @@ -107,12 +116,54 @@ class _NakedFocusableDetectorState extends State _wasEnabled = widget.enabled; } + void _handlePointerEnter(PointerEnterEvent event) { + _pointerInside = true; + if (widget.enabled) _reportHover(true); + } + + void _handlePointerExit(PointerExitEvent event) { + _pointerInside = false; + if (widget.enabled) _reportHover(false); + } + + void _reportHover(bool hovered) { + if (_hoverReported == hovered) return; + _hoverReported = hovered; + widget.onHoverChange?.call(hovered); + } + + void _restoreHoverAfterFrame() { + if (!_pointerInside || widget.onHoverChange == null) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + widget.enabled && + widget.restoreHoverOnEnable && + _pointerInside) { + _reportHover(true); + } + }); + } + @override void didUpdateWidget(NakedFocusableDetector oldWidget) { super.didUpdateWidget(oldWidget); if (widget.enabled != oldWidget.enabled) { _handleEnabledChange(); + if (widget.restoreHoverOnEnable) { + if (widget.enabled) { + _restoreHoverAfterFrame(); + } else { + _hoverReported = false; + } + } + } + + if ((!widget.restoreHoverOnEnable && oldWidget.restoreHoverOnEnable) || + (widget.onHoverChange == null && oldWidget.onHoverChange != null)) { + _pointerInside = false; + _hoverReported = false; } } @@ -144,28 +195,45 @@ class _NakedFocusableDetectorState extends State ); // Wrap with MouseRegion if hover detection is needed - // When disabled, MouseRegion still exists (for cursor) but doesn't trigger callbacks if (widget.onHoverChange != null) { + void Function(PointerEnterEvent)? onEnter; + void Function(PointerExitEvent)? onExit; + if (widget.restoreHoverOnEnable) { + onEnter = _handlePointerEnter; + onExit = _handlePointerExit; + } else if (widget.enabled) { + onEnter = (_) => widget.onHoverChange!(true); + onExit = (_) => widget.onHoverChange!(false); + } + result = MouseRegion( - onEnter: widget.enabled ? (_) => widget.onHoverChange!(true) : null, - onExit: widget.enabled ? (_) => widget.onHoverChange!(false) : null, + onEnter: onEnter, + onExit: onExit, cursor: widget.mouseCursor ?? MouseCursor.defer, child: result, ); } - // Add Actions if provided and enabled - if (widget.enabled && - widget.actions != null && - widget.actions!.isNotEmpty) { - result = Actions(actions: widget.actions!, child: result); + // Keep the wrapper stable across enabled changes so stateful descendants + // are not recreated. An empty map disables local actions. + if (widget.actions != null && widget.actions!.isNotEmpty) { + result = Actions( + actions: widget.enabled + ? widget.actions! + : const >{}, + child: result, + ); } - // Add Shortcuts last (outermost) if provided and enabled - if (widget.enabled && - widget.shortcuts != null && - widget.shortcuts!.isNotEmpty) { - result = Shortcuts(shortcuts: widget.shortcuts!, child: result); + // Add Shortcuts last (outermost). An empty map disables local shortcuts + // without changing the widget-tree shape. + if (widget.shortcuts != null && widget.shortcuts!.isNotEmpty) { + result = Shortcuts( + shortcuts: widget.enabled + ? widget.shortcuts! + : const {}, + child: result, + ); } return result; diff --git a/packages/naked_ui/pubspec.yaml b/packages/naked_ui/pubspec.yaml index 2b43bf52..62cc3c36 100644 --- a/packages/naked_ui/pubspec.yaml +++ b/packages/naked_ui/pubspec.yaml @@ -14,6 +14,7 @@ environment: dependencies: flutter: sdk: flutter + url_launcher: ^6.3.2 dev_dependencies: flutter_test: diff --git a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart index 4393c9d1..f3a3996d 100644 --- a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart +++ b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart @@ -5,6 +5,8 @@ import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:naked_ui/naked_ui.dart'; +final _destination = Uri.parse('https://example.com/docs'); + void main() { group('NakedLink semantics', () { testWidgets('enabled Link exposes exact name role URL hint and action', ( @@ -48,7 +50,11 @@ void main() { try { await tester.pumpWidget( _testApp( - NakedLink(onPressed: () {}, child: const Text('Visible name')), + NakedLink( + linkUrl: _destination, + onPressed: () {}, + child: const Text('Visible name'), + ), ), ); @@ -58,6 +64,33 @@ void main() { } }); + testWidgets('rich text supplies one complete Link name', (tester) async { + final handle = tester.ensureSemantics(); + + try { + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: _destination, + onPressed: () {}, + child: const Text.rich( + TextSpan( + children: [ + TextSpan(text: 'Read '), + TextSpan(text: 'docs'), + ], + ), + ), + ), + ), + ); + + expect(_singleLinkData(tester).label, 'Read docs'); + } finally { + handle.dispose(); + } + }); + testWidgets('semantic label replaces child naming without duplication', ( tester, ) async { @@ -67,6 +100,7 @@ void main() { await tester.pumpWidget( _testApp( NakedLink( + linkUrl: _destination, semanticLabel: 'Accessible documentation', onPressed: () {}, child: Row( @@ -110,6 +144,7 @@ void main() { await tester.pumpWidget( _testApp( NakedLink( + linkUrl: _destination, semanticHint: 'Opens in a new window', onPressed: () {}, child: const Row( @@ -147,6 +182,7 @@ void main() { _testApp( NakedLink( focusNode: focusNode, + linkUrl: _destination, onPressed: () {}, child: const Text('Documentation'), ), @@ -179,6 +215,7 @@ void main() { await tester.pumpWidget( _testApp( NakedLink( + linkUrl: _destination, onPressed: () => callbackCount++, child: const Text('Documentation'), ), @@ -194,44 +231,82 @@ void main() { } }); - testWidgets('callback removal retains disabled Link and removes action', ( - tester, - ) async { - final handle = tester.ensureSemantics(); - VoidCallback? callback = () {}; - late StateSetter rebuild; - - try { - await tester.pumpWidget( - _testApp( - StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedLink( - linkUrl: Uri.parse('https://example.com/docs'), - semanticLabel: 'Documentation', - onPressed: callback, - child: const Text('Visible documentation'), - ); - }, + testWidgets( + 'callback removal keeps the destination available to default navigation', + (tester) async { + final handle = tester.ensureSemantics(); + VoidCallback? callback = () {}; + late StateSetter rebuild; + + try { + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + linkUrl: Uri.parse('https://example.com/docs'), + semanticLabel: 'Documentation', + onPressed: callback, + child: const Text('Visible documentation'), + ); + }, + ), ), - ), - ); - expect(_singleLinkData(tester).hasAction(SemanticsAction.tap), isTrue); - - rebuild(() => callback = null); - await tester.pump(); - final data = _singleLinkData(tester); - expect(data.label, 'Documentation'); - expect(data.flagsCollection.isLink, isTrue); - expect(data.flagsCollection.isButton, isFalse); - expect(data.flagsCollection.isEnabled, Tristate.isFalse); - expect(data.flagsCollection.isFocused, Tristate.none); - expect(data.hasAction(SemanticsAction.tap), isFalse); - } finally { - handle.dispose(); - } - }); + ); + expect( + _singleLinkData(tester).hasAction(SemanticsAction.tap), + isTrue, + ); + + rebuild(() => callback = null); + await tester.pump(); + final data = _singleLinkData(tester); + expect(data.label, 'Documentation'); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isTrue); + expect(data.flagsCollection.isFocused, Tristate.isFalse); + expect(data.hasAction(SemanticsAction.tap), isTrue); + } finally { + handle.dispose(); + } + }, + ); + + testWidgets( + 'disabled destination exposes unavailable text without URL or action', + (tester) async { + final handle = tester.ensureSemantics(); + final linkUrl = Uri.parse('https://example.com/docs'); + + try { + await tester.pumpWidget( + _testApp( + NakedLink( + enabled: false, + linkUrl: linkUrl, + semanticLabel: 'Unavailable documentation', + onPressed: () {}, + child: const Text('Documentation'), + ), + ), + ); + + final data = tester + .getSemantics(find.text('Documentation')) + .getSemanticsData(); + expect(data.label, 'Unavailable documentation'); + expect(data.linkUrl, isNull); + expect(data.flagsCollection.isLink, isFalse); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isFalse); + expect(data.hasAction(SemanticsAction.tap), isFalse); + } finally { + handle.dispose(); + } + }, + ); testWidgets('Arabic label and hint remain exact in RTL', (tester) async { final handle = tester.ensureSemantics(); @@ -242,6 +317,7 @@ void main() { Directionality( textDirection: TextDirection.rtl, child: NakedLink( + linkUrl: _destination, semanticLabel: 'الوثائق', semanticHint: 'يفتح في نافذة جديدة', onPressed: () {}, @@ -269,6 +345,7 @@ void main() { await tester.pumpWidget( _testApp( NakedLink( + linkUrl: _destination, semanticLabel: 'Documentation', onPressed: () {}, child: const Text('Visible documentation'), @@ -280,6 +357,7 @@ void main() { await tester.pumpWidget( _testApp( NakedLink( + linkUrl: _destination, semanticLabel: 'Documentation', excludeSemantics: true, onPressed: () {}, diff --git a/packages/naked_ui/test/src/naked_link_test.dart b/packages/naked_ui/test/src/naked_link_test.dart index ec689eac..40b1564c 100644 --- a/packages/naked_ui/test/src/naked_link_test.dart +++ b/packages/naked_ui/test/src/naked_link_test.dart @@ -7,6 +7,8 @@ import 'package:naked_ui/naked_ui.dart'; import '../test_helpers.dart'; +final _destination = Uri.parse('https://example.com/docs'); + void main() { group('NakedLink public state contract', () { test('requires either a child or builder', () { @@ -16,7 +18,11 @@ void main() { testWidgets('renders its child without a builder', (tester) async { await tester.pumpWidget( MaterialApp( - home: NakedLink(onPressed: () {}, child: const Text('Documentation')), + home: NakedLink( + linkUrl: _destination, + onPressed: () {}, + child: const Text('Documentation'), + ), ), ); @@ -80,6 +86,79 @@ void main() { }); group('NakedLink activation contract', () { + testWidgets('destination URL is the effective-enabled source of truth', ( + tester, + ) async { + const callbackOnlyKey = ValueKey('callback-only'); + const destinationOnlyKey = ValueKey('destination-only'); + const explicitDisabledKey = ValueKey('explicit-disabled'); + var callbackCount = 0; + NakedLinkState? callbackOnlyState; + NakedLinkState? destinationOnlyState; + NakedLinkState? explicitDisabledState; + + await tester.pumpWidget( + _testApp( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + NakedLink( + key: callbackOnlyKey, + onPressed: () => callbackCount++, + builder: (context, state, child) { + callbackOnlyState = state; + return const SizedBox( + width: 160, + height: 48, + child: Text('Callback only'), + ); + }, + ), + NakedLink( + key: destinationOnlyKey, + linkUrl: Uri.parse('https://example.com/docs'), + builder: (context, state, child) { + destinationOnlyState = state; + return const SizedBox( + width: 160, + height: 48, + child: Text('Destination only'), + ); + }, + ), + NakedLink( + key: explicitDisabledKey, + enabled: false, + linkUrl: Uri.parse('https://example.com/unavailable'), + builder: (context, state, child) { + explicitDisabledState = state; + return const SizedBox( + width: 160, + height: 48, + child: Text('Explicitly disabled'), + ); + }, + ), + ], + ), + ), + ); + + expect(callbackOnlyState!.isDisabled, isTrue); + expect(callbackOnlyState!.linkUrl, isNull); + expect(destinationOnlyState!.isDisabled, isFalse); + expect(destinationOnlyState!.linkUrl, _destination); + expect(explicitDisabledState!.isDisabled, isTrue); + expect(explicitDisabledState!.linkUrl, isNull); + + await tester.tap(find.byKey(callbackOnlyKey)); + await tester.pump(); + expect(callbackCount, 0); + tester.expectCursor(SystemMouseCursors.basic, on: callbackOnlyKey); + tester.expectCursor(SystemMouseCursors.click, on: destinationOnlyKey); + tester.expectCursor(SystemMouseCursors.basic, on: explicitDisabledKey); + }); + testWidgets('primary tap updates press state and activates exactly once', ( tester, ) async { @@ -92,6 +171,7 @@ void main() { _testApp( NakedLink( key: linkKey, + linkUrl: _destination, onPressed: () => callbackCount++, onPressChange: pressChanges.add, builder: (context, value, child) { @@ -132,6 +212,7 @@ void main() { _testApp( NakedLink( key: linkKey, + linkUrl: _destination, onPressed: () => callbackCount++, onPressChange: pressChanges.add, child: const SizedBox(width: 160, height: 48, child: Text('Link')), @@ -161,6 +242,7 @@ void main() { _testApp( NakedLink( key: linkKey, + linkUrl: _destination, onPressed: () => callbackCount++, onPressChange: pressChanges.add, child: const SizedBox(width: 160, height: 48, child: Text('Link')), @@ -191,6 +273,7 @@ void main() { _testApp( NakedLink( focusNode: focusNode, + linkUrl: _destination, onPressed: () => callbackCount++, builder: (context, value, child) { state = value; @@ -222,6 +305,58 @@ void main() { expect(state!.isPressed, isFalse); }); + testWidgets('a held Enter key activates only once per key sequence', ( + tester, + ) async { + final focusNode = FocusNode(debugLabel: 'repeating link'); + addTearDown(focusNode.dispose); + var callbackCount = 0; + + await tester.pumpWidget( + _testApp( + NakedLink( + focusNode: focusNode, + linkUrl: _destination, + onPressed: () => callbackCount++, + child: const Text('Link'), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.enter); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(callbackCount, 1); + }); + + testWidgets('long-press selection wins over Link activation', ( + tester, + ) async { + var callbackCount = 0; + + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: _destination, + onPressed: () => callbackCount++, + child: const SelectableText('Selectable documentation text'), + ), + ), + ); + + await tester.longPress(find.text('Selectable documentation text')); + await tester.pump(); + + final editable = tester.widget(find.byType(EditableText)); + expect(editable.controller.selection.isCollapsed, isFalse); + expect(callbackCount, 0); + }); + testWidgets('feedback occurs only for accepted enabled activation', ( tester, ) async { @@ -248,6 +383,7 @@ void main() { return NakedLink( enabled: enabled, enableFeedback: feedback, + linkUrl: _destination, onPressed: () {}, child: const SizedBox( width: 160, @@ -309,6 +445,7 @@ void main() { NakedLink( key: linkKey, focusNode: focusNode, + linkUrl: _destination, onPressed: () {}, onHoverChange: hoverChanges.add, onFocusChange: focusChanges.add, @@ -357,15 +494,17 @@ void main() { ) async { const enabledKey = ValueKey('enabled'); const explicitDisabledKey = ValueKey('explicit-disabled'); - const callbackDisabledKey = ValueKey('callback-disabled'); + const destinationDisabledKey = ValueKey('destination-disabled'); const customKey = ValueKey('custom'); final enabledNode = FocusNode(debugLabel: 'enabled link'); final explicitDisabledNode = FocusNode(debugLabel: 'explicit disabled'); - final callbackDisabledNode = FocusNode(debugLabel: 'callback disabled'); + final destinationDisabledNode = FocusNode( + debugLabel: 'destination disabled', + ); final nextNode = FocusNode(debugLabel: 'next'); addTearDown(enabledNode.dispose); addTearDown(explicitDisabledNode.dispose); - addTearDown(callbackDisabledNode.dispose); + addTearDown(destinationDisabledNode.dispose); addTearDown(nextNode.dispose); await tester.pumpWidget( @@ -377,23 +516,27 @@ void main() { key: explicitDisabledKey, enabled: false, focusNode: explicitDisabledNode, + linkUrl: _destination, onPressed: () {}, child: const SizedBox(child: Text('Explicit disabled')), ), NakedLink( - key: callbackDisabledKey, - focusNode: callbackDisabledNode, - child: const SizedBox(child: Text('Callback disabled')), + key: destinationDisabledKey, + focusNode: destinationDisabledNode, + onPressed: () {}, + child: const SizedBox(child: Text('Destination disabled')), ), NakedLink( key: enabledKey, focusNode: enabledNode, + linkUrl: _destination, onPressed: () {}, child: const SizedBox(child: Text('Enabled')), ), NakedLink( key: customKey, mouseCursor: SystemMouseCursors.help, + linkUrl: _destination, onPressed: () {}, child: const SizedBox(child: Text('Custom')), ), @@ -411,15 +554,44 @@ void main() { await tester.pump(); expect(enabledNode.hasFocus, isTrue); expect(explicitDisabledNode.hasFocus, isFalse); - expect(callbackDisabledNode.hasFocus, isFalse); + expect(destinationDisabledNode.hasFocus, isFalse); tester.expectCursor(SystemMouseCursors.click, on: enabledKey); tester.expectCursor(SystemMouseCursors.basic, on: explicitDisabledKey); - tester.expectCursor(SystemMouseCursors.basic, on: callbackDisabledKey); + tester.expectCursor(SystemMouseCursors.basic, on: destinationDisabledKey); tester.expectCursor(SystemMouseCursors.help, on: customKey); }); - testWidgets('callback removal immediately disables and clears hover', ( + testWidgets('disabled Link rejects focus in directional navigation', ( + tester, + ) async { + final focusNode = FocusNode(debugLabel: 'directional disabled Link'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + navigationMode: NavigationMode.directional, + ), + child: NakedLink( + enabled: false, + focusNode: focusNode, + linkUrl: _destination, + onPressed: () {}, + child: const Text('Unavailable Link'), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + + expect(focusNode.hasFocus, isFalse); + }); + + testWidgets('destination removal immediately disables and clears hover', ( tester, ) async { const linkKey = ValueKey('link'); @@ -427,7 +599,7 @@ void main() { addTearDown(focusNode.dispose); final hoverChanges = []; var callbackCount = 0; - VoidCallback? callback = () => callbackCount++; + Uri? destination = _destination; NakedLinkState? state; late StateSetter rebuild; @@ -439,7 +611,8 @@ void main() { return NakedLink( key: linkKey, focusNode: focusNode, - onPressed: callback, + linkUrl: destination, + onPressed: () => callbackCount++, onHoverChange: hoverChanges.add, builder: (context, value, child) { state = value; @@ -464,7 +637,7 @@ void main() { focusNode.requestFocus(); await tester.pump(); - rebuild(() => callback = null); + rebuild(() => destination = null); await tester.pump(); expect(state!.isDisabled, isTrue); @@ -478,6 +651,193 @@ void main() { tester.expectCursor(SystemMouseCursors.basic, on: linkKey); }); + testWidgets('disabling while hovered permits a parent-setState callback', ( + tester, + ) async { + const linkKey = ValueKey('link'); + var enabled = true; + var hovered = false; + late StateSetter rebuild; + + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + key: linkKey, + enabled: enabled, + linkUrl: Uri.parse('https://example.com/docs'), + onPressed: () {}, + onHoverChange: (value) => setState(() => hovered = value), + child: const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ), + ); + }, + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(find.byKey(linkKey))); + await tester.pump(); + expect(hovered, isTrue); + + rebuild(() => enabled = false); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(hovered, isFalse); + }); + + testWidgets('disabling while pressed permits a parent-setState callback', ( + tester, + ) async { + const linkKey = ValueKey('link'); + var enabled = true; + var pressed = false; + late StateSetter rebuild; + + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + key: linkKey, + enabled: enabled, + linkUrl: Uri.parse('https://example.com/docs'), + onPressed: () {}, + onPressChange: (value) => setState(() => pressed = value), + child: const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ), + ); + }, + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(linkKey)), + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.cancel); + await tester.pump(); + expect(pressed, isTrue); + + rebuild(() => enabled = false); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(pressed, isFalse); + }); + + testWidgets('reenabling under a stationary pointer restores hover', ( + tester, + ) async { + const linkKey = ValueKey('link'); + var enabled = true; + final hoverChanges = []; + NakedLinkState? state; + late StateSetter rebuild; + + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + key: linkKey, + enabled: enabled, + linkUrl: Uri.parse('https://example.com/docs'), + onPressed: () {}, + onHoverChange: hoverChanges.add, + builder: (context, value, child) { + state = value; + return const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ); + }, + ); + }, + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(find.byKey(linkKey))); + await tester.pump(); + expect(state!.isHovered, isTrue); + + rebuild(() => enabled = false); + await tester.pump(); + await tester.pump(); + expect(state!.isHovered, isFalse); + + rebuild(() => enabled = true); + await tester.pump(); + await tester.pump(); + + expect(state!.isHovered, isTrue); + expect(hoverChanges, [true, false, true]); + }); + + testWidgets('availability changes preserve the stateful child subtree', ( + tester, + ) async { + var enabled = true; + var initCount = 0; + var disposeCount = 0; + late StateSetter rebuild; + + await tester.pumpWidget( + _testApp( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedLink( + enabled: enabled, + linkUrl: _destination, + onPressed: () {}, + child: _LifecycleProbe( + onInit: () => initCount++, + onDispose: () => disposeCount++, + ), + ); + }, + ), + ), + ); + + expect(initCount, 1); + expect(disposeCount, 0); + + rebuild(() => enabled = false); + await tester.pump(); + rebuild(() => enabled = true); + await tester.pump(); + + expect(initCount, 1); + expect(disposeCount, 0); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(disposeCount, 1); + }); + testWidgets( 'autofocus works and focus-node replacement preserves ownership', (tester) async { @@ -496,6 +856,7 @@ void main() { return NakedLink( autofocus: true, focusNode: currentNode, + linkUrl: _destination, onPressed: () {}, child: const SizedBox(child: Text('Link')), ); @@ -528,3 +889,30 @@ Widget _testApp(Widget child) { home: Scaffold(body: Center(child: child)), ); } + +class _LifecycleProbe extends StatefulWidget { + const _LifecycleProbe({required this.onInit, required this.onDispose}); + + final VoidCallback onInit; + final VoidCallback onDispose; + + @override + State<_LifecycleProbe> createState() => _LifecycleProbeState(); +} + +class _LifecycleProbeState extends State<_LifecycleProbe> { + @override + void initState() { + super.initState(); + widget.onInit(); + } + + @override + void dispose() { + widget.onDispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('Stateful child'); +} diff --git a/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart b/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart index 82603d92..0f75d0a9 100644 --- a/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart +++ b/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart @@ -184,6 +184,98 @@ void main() { expect(hoverState, isFalse); }); + testWidgets('reports hover after reenable under a stationary pointer', ( + tester, + ) async { + var enabled = true; + final hoverChanges = []; + late StateSetter rebuild; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedFocusableDetector( + enabled: enabled, + restoreHoverOnEnable: true, + onHoverChange: hoverChanges.add, + child: Container( + width: 100, + height: 100, + color: Colors.red, + ), + ); + }, + ), + ), + ), + ); + + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Container))); + await tester.pump(); + expect(hoverChanges, [true]); + + rebuild(() => enabled = false); + await tester.pump(); + rebuild(() => enabled = true); + await tester.pump(); + await tester.pump(); + + expect(hoverChanges, [true, true]); + }); + + testWidgets('does not restore hover after reenable by default', ( + tester, + ) async { + var enabled = true; + final hoverChanges = []; + late StateSetter rebuild; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedFocusableDetector( + enabled: enabled, + onHoverChange: hoverChanges.add, + child: Container( + width: 100, + height: 100, + color: Colors.red, + ), + ); + }, + ), + ), + ), + ); + + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Container))); + await tester.pump(); + + rebuild(() => enabled = false); + await tester.pump(); + rebuild(() => enabled = true); + await tester.pump(); + await tester.pump(); + + expect(hoverChanges, [true]); + }); + testWidgets('sets custom mouse cursor', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/plan/README.md b/plan/README.md index 1b793d23..51c27bd6 100644 --- a/plan/README.md +++ b/plan/README.md @@ -15,7 +15,7 @@ business rules ([briefing §5](briefing.md#5-definition-of-the-headless-boundary | [briefing.md](briefing.md) | Full handoff contract (per-component behavior, semantics, tests, evidence) | **Frozen** — reference only | | [process.md](process.md) | The repeatable per-component workflow and PR gates | Stable | | [integration-testing.md](integration-testing.md) | Mandatory runner, determinism, evidence, and failure-triage playbook | Stable | -| [decisions.md](decisions.md) | Decision log D-01…D-15 and escalation rule | **Living** — update as decisions resolve | +| [decisions.md](decisions.md) | Decision log D-01…D-16 and escalation rule | **Living** — update as decisions resolve | | README.md (this file) | Index and status board | **Living** — update every phase PR | | [phases/](phases/) using the NN-name.md convention | Executable plan for one phase | Created just-in-time when a phase starts | @@ -29,7 +29,7 @@ do not pre-write plans for phases whose blocking decisions are unresolved. |---:|---|---|---|---|---| | 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | | 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Tracked independently in ready-for-review PR #64; closure blockers are recorded there | -| 2 | Link | [§20](briefing.md#20-component-contract-link) | — | [phases/02-link.md](phases/02-link.md) | Ready for review in [PR #65](https://github.com/btwld/naked_ui/pull/65); closure blocked on required web screenshots, manual AT, and Phase 5/7 composition evidence | +| 2 | Link | [§20](briefing.md#20-component-contract-link) + D-16 correction | D-16 (resolved) | [phases/02-link.md](phases/02-link.md) | Reviewer correction for [PR #65](https://github.com/btwld/naked_ui/pull/65) passes all available local gates; exact-head hosted verification, required web screenshots, manual AT, and Phase 5/7 composition evidence remain pending | | 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | | 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | | 5 | Context Menu | [§15](briefing.md#15-component-contract-context-menu) | D-03 | — | Not started | @@ -52,7 +52,7 @@ gates. | Phase | Start readiness | Required work before implementation | |---:|---|---| | 1 — Alert Dialog | **Ready for a just-in-time phase plan** | Re-verify the current `NakedDialog` baseline, carry the resolved D-02 focus contract into tests/examples, then create the phase plan. | -| 2 — Link | **Ready for a just-in-time phase plan** | Re-verify Flutter 3.41.0/3.41.2 link semantics and existing interaction-state patterns; no product decision currently blocks implementation. | +| 2 — Link | **Implemented; reviewer correction locally verified** | D-16 supersedes the metadata-only ownership clause. Publish the correction, rerun exact-head hosted gates (including Android), then complete the outstanding manual and composition evidence. | | 3 — Field | Blocked on decisions | Resolve D-08 metadata precedence and D-09 initial-error announcement policy before implementation/semantics tests. | | 4 — Toggle Group | Blocked on compatibility decision | Resolve D-01 and document the consumer-facing `selected` to `toggled` announcement migration. | | 5 — Context Menu | Spike/decision required | Resolve D-03 with a trigger-role/semantic-long-press prototype and real VoiceOver/TalkBack results. | diff --git a/plan/decisions.md b/plan/decisions.md index ebb88405..9f83968e 100644 --- a/plan/decisions.md +++ b/plan/decisions.md @@ -14,6 +14,26 @@ choosing an SDK-floor policy, platform-directory strategy, golden host/font, or Android/web cadence. The maintainer approved the evidence-backed D-12–D-15 recommendations on 2026-07-12 before decision-dependent implementation began. +### Phase 2 decision evidence (2026-07-13) + +- **D-16:** approved destination-owned Link availability and navigation. A + non-null `linkUrl` is the destination and, together with `enabled`, controls + availability. With no `onPressed`, Naked UI delegates to Flutter's official + [`url_launcher.Link`](https://pub.dev/documentation/url_launcher/latest/link/Link-class.html), + which renders a real web link and follows the URI on native platforms. A + supplied `onPressed` replaces that default path so custom routing and native + navigation cannot both run. A null or explicitly disabled destination is + exposed as unavailable text with no URL, Link role, tap action, focus, or web + `href`; HTML does not define anchors as actually disabled + [elements](https://html.spec.whatwg.org/dev/semantics-other.html#disabled-elements). + This also follows WAI-ARIA guidance to prefer a native anchor because a Link + role alone does not provide browser navigation or context-menu behavior + ([Link Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/)). The public + `linkUrl` name remains unchanged for source compatibility. + Flutter's web Link delegate still contributes Link semantics for a null URI, + so the unavailable path omits that delegate; a keyed inner detector preserves + the consumer subtree when the platform wrapper enters or leaves the tree. + ### Phase 1 decision evidence (2026-07-12) - **D-02:** approved the optional caller-owned `initialFocusNode`. When the @@ -63,6 +83,7 @@ recommendations on 2026-07-12 before decision-dependent implementation began. | D-13 | Example platform directories | Commit reviewed minimal platform files or generate reproducibly in CI; job names must match actual devices | Phase 0 (test-harness PR) | [resolved(commit reviewed Android/macOS/web directories)](#phase-0-decision-evidence-2026-07-12) | | D-14 | Golden host/font pinning | One Ubuntu image, Flutter 3.41.2, fixed surface config, checked-in licensed test font | Phase 0 (test-harness PR) | [resolved(Ubuntu 24.04, Flutter 3.41.2, Roboto Apache-2.0, fixed harness)](#phase-0-decision-evidence-2026-07-12) | | D-15 | Android/web PR frequency | Affected-path PR/merge-queue jobs; release blocked unless both passed on the exact release commit | Phase 0 (test-harness PR) | [resolved(affected PR/merge queue plus exact-tag release gates)](#phase-0-decision-evidence-2026-07-12) | +| D-16 | Link destination and web-navigation ownership | Make `linkUrl` the destination and availability source; use official `url_launcher.Link` for default navigation; a custom callback replaces the default; unavailable Links expose no native destination | Before Phase 2 reviewer corrections | [resolved(destination-owned availability; official default navigation; callback override)](#phase-2-decision-evidence-2026-07-13) | ## Risk register diff --git a/plan/phases/02-link.md b/plan/phases/02-link.md index debf0c00..17d7a7e1 100644 --- a/plan/phases/02-link.md +++ b/plan/phases/02-link.md @@ -1,21 +1,49 @@ # Phase 2 — Link -Status: **Ready for review in PR #65 — automated local and hosted evidence is -green; closure remains blocked by required web screenshots, manual assistive -technology sessions, and real Context Menu/Hover Card composition**. +Status: **PR #65 reviewer corrections pass every available local publication +gate. Exact-head hosted verification (including Android) is pending publication; +closure remains blocked by required web screenshots, manual assistive-technology +sessions, and real Context Menu/Hover Card composition**. Goal: add a headless inline navigation primitive that exposes Link rather than Button semantics, activates once through primary pointer, Enter, Numpad Enter, or semantic tap, leaves Space and secondary click available to their normal page/composition behavior, reports immutable interaction state, and delegates -routing, URL launching, styling, localized copy, and visited history to the -consumer. +default navigation and native web-anchor coordination to Flutter's official +Link implementation. Consumers may replace default navigation with a custom +callback and continue to own styling, localized copy, and visited history. Contract: briefing [§20](../briefing.md#20-component-contract-link) (binding), -plus the cross-component rules in §§5 and 8–12. No open decision blocks Link. +plus the cross-component rules in §§5 and 8–12, as corrected by resolved +[D-16](../decisions.md#phase-2-decision-evidence-2026-07-13). D-16 supersedes +the metadata-only, callback-required clauses after real-browser review proved +they permit duplicate or disabled navigation. No open decision blocks Link. Baseline commit: `d341b90e7b09e13f83da299b4ed17ae0eaa9ddee` (all current-code findings below re-verified on 2026-07-13). +## Reviewer correction addendum — 2026-07-13 + +- `linkUrl` is the destination and availability source. Effective enabled state + is `enabled && linkUrl != null`; removing `onPressed` switches to default + navigation, while removing `linkUrl` makes the Link unavailable. +- Default navigation is delegated to `url_launcher.Link`. A supplied + `onPressed` replaces its `FollowLink` callback, so one activation cannot run + both custom routing and browser navigation. +- An unavailable Link has no Link flag, URL, tap action, focus path, or web + `href`. Flutter's web delegate retains Link semantics for a null URI, so the + unavailable path omits that wrapper. A keyed inner detector preserves the + consumer's stateful subtree as the wrapper enters or leaves the tree. The + content remains discoverable as ordinary unavailable text because HTML has no + disabled-anchor contract. +- Interaction state is synchronized during `didUpdateWidget` without invoking + consumer callbacks during build; ended hover, press, and focus callbacks are + delivered after the frame. Re-enabling beneath a stationary pointer restores + hover after layout. +- Added regressions cover native DOM ownership, dynamic parent `setState`, + stationary hover, held Enter/Numpad Enter, directional disabled focus, + selectable text, and rich text. Real Context Menu/Hover Card composition + remains deferred to Phases 5 and 7 as originally required. + ## Research and readiness - No repository or ancestor `AGENTS.md` exists. The phase follows @@ -32,8 +60,12 @@ findings below re-verified on 2026-07-13). regression reference. - Pinned Flutter 3.41.0 and 3.41.2 both expose `Semantics.link` and `Semantics.linkUrl`. Both assert that a non-null URL requires the Link flag. - The pinned semantics source documents that `linkUrl` becomes the web DOM - `href`; the callback still remains application-owned. + Real-browser review showed that combining an application callback with this + live web `href` does not coordinate navigation ownership. Flutter's official + [`url_launcher.Link`](https://pub.dev/documentation/url_launcher/latest/link/Link-class.html) + provides that missing native/web coordination while a destination exists. + Its web delegate still contributes Link semantics for a null URI, so Naked UI + bypasses the wrapper while unavailable. - Flutter has no `SemanticsRole.link` in the pinned API. The binding contract's `link: true` flag is the supported representation; adding Button semantics as a fallback would be incorrect. @@ -84,8 +116,8 @@ findings below re-verified on 2026-07-13). | Primitive | Discoverable Link, never Button | exact semantics test | VoiceOver, TalkBack, Chrome tree | | Name | visible child text, or one caller-localized `semanticLabel` override | semantics tests including Arabic | all AT sessions | | Role/flags | `link: true`; `button` absent | exact flags test | all AT sessions | -| State | enabled is `enabled && onPressed != null`; focus/focusable follows effective focus | widget + semantics transitions | macOS/Android/web | -| Value/URL | optional `linkUrl` metadata; never launches by itself | state equality + semantics URL test | Chrome tree/href inspection | +| State | enabled is `enabled && linkUrl != null`; focus/focusable follows effective focus | widget + semantics transitions | macOS/Android/web | +| Value/URL | non-null `linkUrl` is the destination; default navigation uses official Link; unavailable state removes URL/href | state, semantics, and pinned-Chrome DOM tests | Chrome tree/href inspection | | Actions | semantic tap only while effectively enabled | semantics action/callback tests | VoiceOver/TalkBack | | Hint | optional caller-localized `semanticHint`, once | semantics test | all AT sessions | | Children | visible label preserved unless an explicit label overrides it; decorative icon excluded by consumer | semantics/example tests | Chrome tree | @@ -94,17 +126,19 @@ findings below re-verified on 2026-07-13). ### Input, state, and lifecycle matrix -| Path | Enabled result | Disabled/null-callback result | Required assertion | +| Path | Enabled result | Disabled/null-destination result | Required assertion | |---|---|---|---| -| Primary tap | one callback + optional feedback | no recognizer/callback/feedback | count and state transitions | +| Primary tap | custom callback once, or default navigation once, plus optional feedback | no recognizer/callback/feedback | callback, DOM location, and state transitions | | Canceled primary sequence | press true → false; no callback | no state transition | pointer cancel test | | Secondary click | unclaimed; no callback | unclaimed | gesture test and later Context Menu composition | -| Enter/Numpad Enter | one callback | no callback | known focus node + complete key event | +| Enter/Numpad Enter | one activation per physical key sequence | no activation | known focus node + key-down/repeat/up sequence | | Space | no Link callback and no Link press state | no callback | widget test; web scroll outcome | | Semantic tap | same activation path once | action absent | semantics action test | | Hover | state/callback true then false | no hover callback | mouse gesture test | | Focus/Tab | state/callback and normal traversal | skipped in traditional traversal | focus and next-target assertions | -| Callback removal | immediately disabled, activation removed, transient press/hover cleared | already disabled | rebuild test | +| Callback removal | remains enabled and switches to default navigation | already default | rebuild test | +| Destination removal | immediately unavailable; transient press/hover/focus clear after safe notification | already unavailable | parent-`setState` lifecycle tests | +| Stationary re-enable | hover restores after layout when the pointer remains inside | N/A | detector and Link hover regression | | Focus-node replacement | listener moves; focused state handed off; neither external node disposed | same ownership | lifecycle test | | Disposal | internal detector node/listeners removed; external node remains usable | same | teardown/no exception | @@ -115,9 +149,10 @@ findings below re-verified on 2026-07-13). | LINK-API-01 | Child/builder invariant; immutable state/scope includes URL and all widget states | `naked_link_test.dart` + hash contract | N/A | | LINK-ACT-01 | Primary tap once; cancellation and secondary click do not activate | widget gesture tests | macOS/web hover+pointer; Android touch | | LINK-KEY-01 | Enter and Numpad Enter activate; Space is unclaimed | widget shortcut tests | macOS and pinned web scroll/result | -| LINK-STATE-01 | Effective enabled controls activation, traversal, feedback, cursor, and disabled state | widget transitions + platform-channel feedback test | all behavior targets | +| LINK-STATE-01 | Destination-owned effective enabled state controls activation, traversal, feedback, cursor, and disabled state | widget transitions + platform-channel feedback test | all behavior targets | | LINK-STATE-02 | Hover/focus/press callbacks and builder/scope snapshots are exact | widget state tests | macOS/web fixture readout | -| LINK-LIFE-01 | Focus ownership/replacement/disposal and callback removal do not leak | lifecycle tests | aggregate teardown on macOS/web | +| LINK-LIFE-01 | Focus ownership/replacement/disposal, safe destination removal, and stationary hover restoration do not leak | lifecycle + detector tests | aggregate teardown on macOS/web | +| LINK-NAV-01 | Official default navigation, custom override, and unavailable DOM paths have one owner | widget + pinned-Chrome DOM click tests | pinned Chrome location/href | | LINK-SEM-01 | Link flag, URL, name, hint, enabled/focus/action exact; Button absent | `naked_link_semantics_test.dart` | VoiceOver/TalkBack/Chrome tree | | LINK-SEM-02 | Disabled action absent; label override not duplicated; icon/exclusion correct; Arabic/RTL | semantics + example tests | all AT sessions | | LINK-COMP-01 | Primary, secondary, hover, and keyboard paths compose without conflict | Link secondary-path test now; future Context Menu/Hover Card integration | later Phase 5/7 real targets | @@ -137,8 +172,9 @@ findings below re-verified on 2026-07-13). secondary gestures; Enter/Numpad Enter; Space with no callback or pressed state; explicit and effective disabled paths; default/custom/basic cursors; hover/focus/press callbacks; feedback only while enabled; dynamic callback - removal; autofocus/traversal; external focus ownership, replacement, and - disposal. + and destination removal; parent-`setState` lifecycle safety; stationary + hover restoration; held-key repeats; directional disabled focus; selectable + and rich text; external focus ownership, replacement, and disposal. - **Red proof:** observe the missing API, then use the smallest targeted assertions/mutations if several behaviors become green through shared infrastructure. Record the first failing expectation for every group. @@ -169,18 +205,20 @@ findings below re-verified on 2026-07-13). - **How:** implement `NakedLinkState` with state helpers, equality/hash, and `linkUrl`. Implement the binding constructor exactly, including a nullable `mouseCursor`, the child/builder assertion, and effective enabled - `enabled && onPressed != null`. Compose the existing state mixin, state scope, - focusable detector, primary-only gesture path, and one Link semantics node. - Route pointer, key, and semantic activation through one guarded method with - feedback. Map only Enter/Numpad Enter to a private Link intent; do not expose - an Activate/Button action and do not bind Space. Clear transient state when - effective enabled becomes false. Borrow the external focus node through the - detector and never dispose it. Use `Semantics(excludeSemantics: true)` only - when an explicit semantic label replaces child naming; use outer exclusion - plus disabled focus semantics for the advanced escape hatch. -- **Avoid:** router/launcher dependencies, visited state, modifier-click - synthesis, long-press ownership, raw key handlers, timers, styles, English - defaults, changes to Button, or a speculative generic pressable base class. + `enabled && linkUrl != null`. Delegate default navigation and native web + anchors to `url_launcher.Link`; when `onPressed` is present, route activation + only to that override. Compose the existing state mixin, state scope, + focusable detector, primary-only gesture path, and one effective Link + semantics node. Map only Enter/Numpad Enter without repeat events; do not bind + Space. When the destination becomes unavailable, synchronize transient state + without rebuilding or invoking consumers during `didUpdateWidget`, then + deliver ended-state callbacks after the frame. Borrow external focus nodes + and never dispose them. Remove URL, Link flag, focus, and actions while + unavailable; preserve the advanced semantics-exclusion escape hatch. +- **Avoid:** direct `launchUrl` calls, hand-rolled DOM anchors or event + coordination, router dependencies, visited state, modifier-click synthesis, + long-press ownership, raw key handlers, timers, styles, English defaults, + changes to Button, or a speculative generic pressable base class. - **Verify:** focused A1/A2 tests, then `fvm dart format --set-exit-if-changed packages/naked_ui/lib/src/naked_link.dart packages/naked_ui/lib/src/naked_widgets.dart packages/naked_ui/lib/src/utilities/intents.dart packages/naked_ui/test/src/naked_link_test.dart packages/naked_ui/test/semantics/naked_link_semantics_test.dart packages/naked_ui/test/hashcode_contract_test.dart` and `fvm flutter analyze packages/naked_ui`. @@ -224,8 +262,9 @@ findings below re-verified on 2026-07-13). focused Space → no callback (and observable page scroll on web); hover/down/up → exact state readout and one result; semantic tap → same callback path; disabled skipped by Tab with no pointer/semantic action. Add Arabic/RTL and - 200% long-text assertions, callback removal while focused, secondary click - remaining unclaimed, and teardown-safe external focus-node disposal. + 200% long-text assertions, destination removal while focused, secondary click + remaining unclaimed, and pinned-Chrome DOM cases for destination-only native + navigation, custom override, and unavailable href removal. - **Deferred composition:** do not create fake Hover Card/Context Menu implementations. Record LINK-COMP-01 as a closure blocker and require Phase 5 and Phase 7 integration suites to wrap the real `NakedLink` and prove the @@ -263,12 +302,13 @@ findings below re-verified on 2026-07-13). - **Where:** dartdoc in `naked_link.dart`; root and package READMEs; package changelog; registry; this plan and `plan/README.md`; PR description. -- **How:** document Link-versus-Button use, callback/URL separation, effective - enabled, Enter/Numpad/Space behavior, state and focus ownership, semantics - override/icon rules, secondary/modifier-click boundaries, styling and router - non-goals, and Remix responsibilities. Build the §22 ten-item packet with the - stable requirement table, platform commands/runs, screenshot review, manual - AT rows, limitations, and exact SHAs. +- **How:** document Link-versus-Button use, destination/default/override + ownership, effective enabled state, Enter/Numpad/Space behavior, state and + focus ownership, semantics override/icon rules, secondary/modifier-click + boundaries, styling and router non-goals, and Remix responsibilities. Build + the §22 ten-item packet with the stable requirement table, platform + commands/runs, screenshot review, manual AT rows, limitations, and exact + SHAs. - **Review:** inspect the entire diff for API drift, accidental Button/Space behavior, duplicate semantics/names/actions, disabled descendants, feedback/cursor/focus leaks, selection interference, router or styling scope, @@ -348,12 +388,48 @@ Hosted gates: primary and exact-minimum suites; canonical golden/guidelines; pinned Chrome/ChromeDriver behavior log; PR-title policy. Every result must be green on the exact PR head or an identified GitHub merge ref. -## Execution evidence — 2026-07-13 +## Reviewer-correction local evidence — 2026-07-13 + +The correction candidate was verified from parent `2614555` before publication. +The implementation commit is recorded in the follow-up evidence commit. No push, +PR update, merge, or hosted exact-head run is claimed here. + +### Available local publication gates + +- Flutter 3.41.2 format and analyze pass with no findings. +- Package suite: 609 pass; three documented external-integration skips. +- Example suite: 24 pass; two host-specific golden comparison skips on macOS. +- Focused Link, semantics, detector, and hash proof: 66 pass. +- Aggregate: 99 pass and one documented Tooltip skip on both `flutter-tester` + and the real macOS runner. +- Matched Chrome/ChromeDriver 150.0.7871.115 aggregate passes. Exact disabled + semantics and DOM assertions prove no Link role, URL, action, anchor, or + `href` remains. +- A separate W3C WebDriver proof performs a trusted primary click, proves the + custom override runs exactly once without navigation, and proves dynamic + disable removes the native anchor and cannot activate. The aggregate's + in-app coordinator separately proves the default destination outcome. +- Flutter 3.41.0 exact-minimum dependency resolution, analyze, and the full + 609-test package suite pass with the same three documented skips. + +### Correction-specific pending gates + +- The correction has not been pushed, so prior hosted checks do not validate + this candidate. Exact-head Ubuntu, macOS, pinned-web, and API 34 Android jobs + must be rerun after publication. +- No Android emulator or device is attached locally; API 34 remains a hosted + gate. +- Required web screenshots, human assistive-technology sessions, and real + Phase 5/7 composition remain closure blockers below. + +## Original PR execution evidence — 2026-07-13 (superseded) Review-ready PR: [#65](https://github.com/btwld/naked_ui/pull/65). The fully tested implementation/evidence head is `24460f0a94b657854c95d5dc900e5ef7215d9604`; its GitHub test merge ref is `09e62c8dc29b424a1d00e5e7de8cfc4a99cd124f`. The PR remains unmerged. +These checks establish the original implementation's evidence but do not +validate the unpublished D-16 reviewer correction above. ### Test-first and failure-triage record @@ -455,20 +531,22 @@ All seven checks passed for head `24460f0` / merge ref `09e62c8`: - [x] Focus-node ownership/replacement/disposal and aggregate teardown pass. - [x] Canonical fixture, stable result/reset/readout, Arabic RTL, 200% text, external-icon exclusion, golden, and accessibility guidelines pass. -- [x] Integration component, inventory, fast aggregate, real macOS aggregate, - hosted API 34, and pinned web behavior pass on the exact PR head. +- [ ] Correction integration, inventory, fast aggregate, and real macOS + aggregate pass locally; exact-head hosted API 34 and pinned-web reruns + remain pending publication. - [x] All seven screenshot names have reviewed evidence, or Phase 2 is explicitly blocked; unsupported web screenshots are not marked passed. - [ ] VoiceOver, TalkBack, Chrome accessibility-tree, and release-level iOS records are attached; missing human evidence blocks closure. - [ ] Real Context Menu and Hover Card composition proof is attached after those components exist; placeholder wrappers do not satisfy it. -- [x] Full publication commands and hosted Flutter 3.41.0 pass. +- [ ] Full local publication commands and Flutter 3.41.0 pass; hosted + correction-head verification remains pending publication. - [x] Docs, changelog, compatibility statement, traceability, manifests, visual review, and ten-item handoff packet are ready. -- [x] Entire diff reviewed; ready-for-review PR open; evidence-head checks green; - plan/status board contain final evidence; PR remains unmerged without - explicit maintainer authorization. +- [ ] Entire correction diff is locally reviewed and committed; PR #65 still + needs the correction push and exact-head checks. It remains unmerged + without explicit maintainer authorization. Block Phase 2 closure (not independent program work) if Link maps as a Button, Space activates or is swallowed on web, disabled paths retain activation or diff --git a/pubspec.lock b/pubspec.lock index 88c081f3..d07be7ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -540,6 +540,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_graphics: dependency: transitive description: @@ -629,5 +693,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0 <4.0.0" flutter: ">=3.41.0" From 8084ecfaf9ee1ac31808da7671afce76b0c475f3 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 13:32:37 -0400 Subject: [PATCH 13/18] docs: record NakedLink correction evidence --- plan/README.md | 2 +- plan/phases/02-link.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plan/README.md b/plan/README.md index 51c27bd6..60e81d15 100644 --- a/plan/README.md +++ b/plan/README.md @@ -29,7 +29,7 @@ do not pre-write plans for phases whose blocking decisions are unresolved. |---:|---|---|---|---|---| | 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | | 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Tracked independently in ready-for-review PR #64; closure blockers are recorded there | -| 2 | Link | [§20](briefing.md#20-component-contract-link) + D-16 correction | D-16 (resolved) | [phases/02-link.md](phases/02-link.md) | Reviewer correction for [PR #65](https://github.com/btwld/naked_ui/pull/65) passes all available local gates; exact-head hosted verification, required web screenshots, manual AT, and Phase 5/7 composition evidence remain pending | +| 2 | Link | [§20](briefing.md#20-component-contract-link) + D-16 correction | D-16 (resolved) | [phases/02-link.md](phases/02-link.md) | Reviewer correction `dc20214` for [PR #65](https://github.com/btwld/naked_ui/pull/65) passes all available local gates; exact-head hosted verification, required web screenshots, manual AT, and Phase 5/7 composition evidence remain pending | | 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | | 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | | 5 | Context Menu | [§15](briefing.md#15-component-contract-context-menu) | D-03 | — | Not started | diff --git a/plan/phases/02-link.md b/plan/phases/02-link.md index 17d7a7e1..345a93f1 100644 --- a/plan/phases/02-link.md +++ b/plan/phases/02-link.md @@ -390,9 +390,10 @@ green on the exact PR head or an identified GitHub merge ref. ## Reviewer-correction local evidence — 2026-07-13 -The correction candidate was verified from parent `2614555` before publication. -The implementation commit is recorded in the follow-up evidence commit. No push, -PR update, merge, or hosted exact-head run is claimed here. +Correction implementation `dc20214ba1aab5d665d80f2baa0fe13855de2792` +was verified from parent `2614555` before publication. This docs-only follow-up +records that exact implementation SHA. No push, PR update, merge, or hosted +exact-head run is claimed here. ### Available local publication gates From 7d40110c73fc8107666d7a5171e7044e786cd197 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 16:34:40 -0400 Subject: [PATCH 14/18] feat: hard-break NakedLink activation contract --- README.md | 34 +- .../components/naked_link_integration.dart | 124 +---- .../helpers/link_dom_probe.dart | 2 - .../helpers/link_dom_probe_stub.dart | 12 - .../helpers/link_dom_probe_web.dart | 53 --- .../link_browser_driver_app.dart | 105 +++-- packages/example/lib/api/naked_link.0.dart | 173 +++---- .../example/test/naked_link_example_test.dart | 51 +- .../test_driver/link_browser_driver.dart | 438 ++++++++++++++++-- packages/naked_ui/CHANGELOG.md | 21 +- packages/naked_ui/README.md | 34 +- packages/naked_ui/lib/src/naked_link.dart | 184 ++++++-- .../utilities/naked_focusable_detector.dart | 10 +- .../semantics/naked_link_semantics_test.dart | 109 ++--- .../naked_ui/test/src/naked_link_test.dart | 358 +++++++++++--- .../naked_focusable_detector_test.dart | 2 +- plan/README.md | 6 +- plan/decisions.md | 22 +- plan/phases/02-link.md | 322 +++++++------ 19 files changed, 1361 insertions(+), 699 deletions(-) delete mode 100644 packages/example/integration_test/helpers/link_dom_probe.dart delete mode 100644 packages/example/integration_test/helpers/link_dom_probe_stub.dart delete mode 100644 packages/example/integration_test/helpers/link_dom_probe_web.dart diff --git a/README.md b/README.md index 0e4d86ee..7a76bc20 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,18 @@ NakedButton( ### Custom Link -Use a Link for navigation rather than styling a Button like text. The URL is -the destination and enables the Link. Naked UI delegates default navigation to -Flutter's official `url_launcher.Link`; provide `onPressed` only when custom -routing should replace that default. Enter and Numpad Enter activate, while -Space remains available to the page. +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( @@ -83,6 +86,27 @@ NakedLink( ) ``` +### 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. diff --git a/packages/example/integration_test/components/naked_link_integration.dart b/packages/example/integration_test/components/naked_link_integration.dart index cbc1f6a9..b6489f94 100644 --- a/packages/example/integration_test/components/naked_link_integration.dart +++ b/packages/example/integration_test/components/naked_link_integration.dart @@ -6,10 +6,7 @@ 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 'package:naked_ui/naked_ui.dart'; - import '../helpers/keyboard_test_helpers.dart'; -import '../helpers/link_dom_probe.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -116,7 +113,7 @@ void main() { ); }); - testWidgets('semantic tap follows the same callback path', (tester) async { + testWidgets('semantic tap follows the same resolver path', (tester) async { final handle = tester.ensureSemantics(); try { await tester.pumpWidget(const link_example.MyApp()); @@ -132,123 +129,6 @@ void main() { } }); - testWidgets('web destination-only Link delegates to native navigation', ( - tester, - ) async { - if (!supportsLinkDomProbe) return; - - final originalUri = currentBrowserUri!; - const marker = 'naked-link-native-navigation'; - final destination = originalUri.replace(fragment: marker); - addTearDown(() => restoreBrowserUri(originalUri)); - - final semantics = tester.ensureSemantics(); - addTearDown(semantics.dispose); - await tester.pumpWidget( - MaterialApp( - home: Center( - child: NakedLink( - linkUrl: destination, - child: const Text('Native navigation'), - ), - ), - ), - ); - await tester.pump(); - await tester.pumpUntil( - () => hasLinkHrefContaining(marker), - timeout: const Duration(seconds: 2), - ); - - expect(hasLinkHrefContaining(marker), isTrue); - final node = tester.getSemantics(find.text('Native navigation')); - node.owner!.performAction(node.id, SemanticsAction.tap); - final click = await dispatchSyntheticLinkClick(marker); - expect(click?.defaultPrevented, isFalse); - expect(click?.resultingUri.fragment, marker); - expect(currentBrowserUri, originalUri); - }); - - testWidgets('web custom callback signals native suppression', ( - tester, - ) async { - if (!supportsLinkDomProbe) return; - - final originalUri = currentBrowserUri!; - const marker = 'naked-link-custom-override'; - final destination = originalUri.replace(fragment: marker); - var activations = 0; - addTearDown(() => restoreBrowserUri(originalUri)); - - final semantics = tester.ensureSemantics(); - addTearDown(semantics.dispose); - await tester.pumpWidget( - MaterialApp( - home: Center( - child: NakedLink( - linkUrl: destination, - onPressed: () => activations++, - child: const Text('Custom navigation'), - ), - ), - ), - ); - await tester.pump(); - await tester.pumpUntil( - () => hasLinkHrefContaining(marker), - timeout: const Duration(seconds: 2), - ); - - expect(hasLinkHrefContaining(marker), isTrue); - await tester.tap(find.text('Custom navigation')); - await tester.pump(); - expect(activations, 1); - expect(currentBrowserUri, originalUri); - - final click = await dispatchSyntheticLinkClick(marker); - expect(click?.defaultPrevented, isTrue); - expect(currentBrowserUri, originalUri); - }); - - testWidgets('web disabled Link removes its native destination', ( - tester, - ) async { - if (!supportsLinkDomProbe) return; - - final originalUri = currentBrowserUri!; - const marker = 'naked-link-disabled'; - final destination = originalUri.replace(fragment: marker); - var activations = 0; - addTearDown(() => restoreBrowserUri(originalUri)); - - final semantics = tester.ensureSemantics(); - addTearDown(semantics.dispose); - await tester.pumpWidget( - MaterialApp( - home: Center( - child: NakedLink( - linkUrl: destination, - enabled: false, - onPressed: () => activations++, - child: const Text('Unavailable destination'), - ), - ), - ), - ); - await tester.pump(); - - expect(hasLinkHrefContaining(marker), isFalse); - await tester.tap(find.text('Unavailable destination')); - await tester.pump(); - expect(activations, 0); - expect(currentBrowserUri, originalUri); - final data = tester - .getSemantics(find.text('Unavailable destination')) - .getSemanticsData(); - expect(data.flagsCollection.isLink, isFalse); - expect(data.hasAction(SemanticsAction.tap), isFalse); - }); - testWidgets( 'disabled Link is skipped and has no pointer or semantic action', (tester) async { @@ -285,7 +165,7 @@ void main() { }, ); - testWidgets('destination removal while focused blocks later activation', ( + testWidgets('disabling while focused blocks later activation', ( tester, ) async { await tester.pumpWidget(const link_example.MyApp()); diff --git a/packages/example/integration_test/helpers/link_dom_probe.dart b/packages/example/integration_test/helpers/link_dom_probe.dart deleted file mode 100644 index 189498b2..00000000 --- a/packages/example/integration_test/helpers/link_dom_probe.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'link_dom_probe_stub.dart' - if (dart.library.js_interop) 'link_dom_probe_web.dart'; diff --git a/packages/example/integration_test/helpers/link_dom_probe_stub.dart b/packages/example/integration_test/helpers/link_dom_probe_stub.dart deleted file mode 100644 index f55e18a0..00000000 --- a/packages/example/integration_test/helpers/link_dom_probe_stub.dart +++ /dev/null @@ -1,12 +0,0 @@ -bool get supportsLinkDomProbe => false; - -Uri? get currentBrowserUri => null; - -bool hasLinkHrefContaining(String marker) => false; - -typedef LinkClickResult = ({bool defaultPrevented, Uri resultingUri}); - -Future dispatchSyntheticLinkClick(String marker) async => - null; - -void restoreBrowserUri(Uri uri) {} diff --git a/packages/example/integration_test/helpers/link_dom_probe_web.dart b/packages/example/integration_test/helpers/link_dom_probe_web.dart deleted file mode 100644 index 30038fec..00000000 --- a/packages/example/integration_test/helpers/link_dom_probe_web.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:web/web.dart' as web; - -bool get supportsLinkDomProbe => true; - -Uri get currentBrowserUri => Uri.parse(web.window.location.href); - -web.Element? _findLink(String marker) { - final nativeAnchor = _findAnchor( - web.document.querySelectorAll('a[rel="noreferrer noopener"]'), - marker, - ); - return nativeAnchor ?? - _findAnchor(web.document.querySelectorAll('a'), marker); -} - -web.Element? _findAnchor(web.NodeList anchors, String marker) { - for (var index = 0; index < anchors.length; index++) { - final anchor = anchors.item(index)! as web.Element; - if (anchor.getAttribute('href')?.contains(marker) ?? false) { - return anchor; - } - } - return null; -} - -bool hasLinkHrefContaining(String marker) => _findLink(marker) != null; - -typedef LinkClickResult = ({bool defaultPrevented, Uri resultingUri}); - -/// Dispatches a synthetic DOM event to inspect the Link coordinator signal. -/// -/// Resulting-location ownership for custom callbacks is verified separately by -/// the WebDriver test because synthetic anchor default actions do not model the -/// coordinator's microtask timing reliably. -Future dispatchSyntheticLinkClick(String marker) async { - final anchor = _findLink(marker); - if (anchor == null) return null; - - final originalUri = currentBrowserUri; - final event = web.MouseEvent( - 'click', - web.MouseEventInit(bubbles: true, cancelable: true), - ); - anchor.dispatchEvent(event); - await Future.delayed(Duration.zero); - final resultingUri = currentBrowserUri; - restoreBrowserUri(originalUri); - return (defaultPrevented: event.defaultPrevented, resultingUri: resultingUri); -} - -void restoreBrowserUri(Uri uri) { - web.window.history.replaceState(null, '', uri.toString()); -} diff --git a/packages/example/integration_test/link_browser_driver_app.dart b/packages/example/integration_test/link_browser_driver_app.dart index b77199db..f0f77e0c 100644 --- a/packages/example/integration_test/link_browser_driver_app.dart +++ b/packages/example/integration_test/link_browser_driver_app.dart @@ -17,55 +17,78 @@ class _LinkBrowserDriverApp extends StatefulWidget { } class _LinkBrowserDriverAppState extends State<_LinkBrowserDriverApp> { - var _customActivations = 0; - var _dynamicActivations = 0; + var _customObserverCalls = 0; + var _customResolverCalls = 0; + var _dynamicObserverCalls = 0; + var _dynamicResolverCalls = 0; var _dynamicEnabled = true; - Uri _destination(String fragment) => Uri.base.replace(fragment: fragment); + Uri _destination(String marker) => Uri.base.replace( + queryParameters: {'link-destination': marker}, + fragment: '', + ); + + NakedLinkResolution _resolveLink(BuildContext context, Uri linkUrl) { + switch (linkUrl.queryParameters['link-destination']) { + case 'naked-link-browser-custom': + setState(() => _customResolverCalls++); + return NakedLinkResolution.handled; + case 'naked-link-browser-dynamic': + setState(() => _dynamicResolverCalls++); + return NakedLinkResolution.handled; + default: + return NakedLinkResolution.platformDefault; + } + } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - NakedLink( - linkUrl: _destination('naked-link-browser-default'), - child: const Text('Default navigation'), - ), - NakedLink( - linkUrl: _destination('naked-link-browser-custom'), - onPressed: () { - setState(() => _customActivations++); - }, - child: const Text('Custom navigation'), - ), - NakedLink( - enabled: _dynamicEnabled, - linkUrl: _destination('naked-link-browser-dynamic'), - onPressed: () { - setState(() => _dynamicActivations++); - }, - child: const Text('Dynamic navigation'), - ), - Text( - 'custom:$_customActivations', - key: const ValueKey('browser-link.custom-result'), - ), - Text( - 'dynamic:$_dynamicActivations; enabled:$_dynamicEnabled', - key: const ValueKey('browser-link.dynamic-result'), - ), - TextButton( - key: const ValueKey('browser-link.disable-dynamic'), - onPressed: _dynamicEnabled - ? () => setState(() => _dynamicEnabled = false) - : null, - child: const Text('Disable dynamic Link'), - ), - ], + child: NakedLinkResolver( + resolve: _resolveLink, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + NakedLink( + linkUrl: _destination('naked-link-browser-default'), + child: const Text('Default navigation'), + ), + NakedLink( + linkUrl: _destination('naked-link-browser-custom'), + onActivated: (_) { + setState(() => _customObserverCalls++); + }, + child: const Text('Custom navigation'), + ), + NakedLink( + enabled: _dynamicEnabled, + linkUrl: _destination('naked-link-browser-dynamic'), + onActivated: (_) { + setState(() => _dynamicObserverCalls++); + }, + child: const Text('Dynamic navigation'), + ), + Text( + 'custom:observer=$_customObserverCalls;' + 'resolver=$_customResolverCalls', + key: const ValueKey('browser-link.custom-result'), + ), + Text( + 'dynamic:observer=$_dynamicObserverCalls;' + 'resolver=$_dynamicResolverCalls;enabled=$_dynamicEnabled', + key: const ValueKey('browser-link.dynamic-result'), + ), + TextButton( + key: const ValueKey('browser-link.disable-dynamic'), + onPressed: _dynamicEnabled + ? () => setState(() => _dynamicEnabled = false) + : null, + child: const Text('Disable dynamic Link'), + ), + ], + ), ), ), ), diff --git a/packages/example/lib/api/naked_link.0.dart b/packages/example/lib/api/naked_link.0.dart index a1451247..5d04826e 100644 --- a/packages/example/lib/api/naked_link.0.dart +++ b/packages/example/lib/api/naked_link.0.dart @@ -39,6 +39,11 @@ class LinkExample extends StatefulWidget { } class _LinkExampleState extends State { + static final _primaryLinkUrl = Uri.parse('https://example.com/naked-ui'); + static final _externalLinkUrl = Uri.parse( + 'https://docs.flutter.dev/ui/accessibility', + ); + var _result = 'none'; var _activationCount = 0; var _primaryEnabled = true; @@ -48,13 +53,21 @@ class _LinkExampleState extends State { bool get _isRtl => widget.textDirection == TextDirection.rtl; - void _activate(String result) { + void _setResult(String result) { setState(() { _result = result; - _activationCount++; }); } + void _recordActivation(Uri _) { + setState(() => _activationCount++); + } + + NakedLinkResolution _resolveLink(BuildContext context, Uri linkUrl) { + _setResult(linkUrl == _primaryLinkUrl ? 'primary' : 'external'); + return NakedLinkResolution.handled; + } + void _reset() { FocusScope.of(context).unfocus(); setState(() { @@ -78,77 +91,83 @@ class _LinkExampleState extends State { ), child: Directionality( textDirection: widget.textDirection, - child: RepaintBoundary( - key: const ValueKey('link.evidence.surface'), - child: Material( - color: const Color(0xFFF8FAFC), - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - _isRtl ? 'روابط Naked UI' : 'Naked UI links', - style: const TextStyle( - color: Color(0xFF0F172A), - fontSize: 24, - fontWeight: FontWeight.w700, + child: NakedLinkResolver( + resolve: _resolveLink, + child: RepaintBoundary( + key: const ValueKey('link.evidence.surface'), + child: Material( + color: const Color(0xFFF8FAFC), + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _isRtl ? 'روابط Naked UI' : 'Naked UI links', + style: const TextStyle( + color: Color(0xFF0F172A), + fontSize: 24, + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 12), - _buildPrimaryLine(), - const SizedBox(height: 20), - Wrap( - spacing: 12, - runSpacing: 12, - children: [_buildExternalLink(), _buildDisabledLink()], - ), - const SizedBox(height: 20), - Text( - 'Result: $_result; activations: $_activationCount', - key: const ValueKey('link.result'), - style: const TextStyle( - color: Color(0xFF0F172A), - fontWeight: FontWeight.w600, + const SizedBox(height: 12), + _buildPrimaryLine(), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _buildExternalLink(), + _buildDisabledLink(), + ], ), - ), - const SizedBox(height: 8), - Text( - 'hovered:$_hovered focused:$_focused ' - 'pressed:$_pressed enabled:$_primaryEnabled', - key: const ValueKey('link.state'), - style: const TextStyle(color: Color(0xFF334155)), - ), - const SizedBox(height: 20), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - OutlinedButton( - key: const ValueKey('link.next-focus'), - onPressed: () => _activate('next-focus'), - child: const Text('Next focus target'), + const SizedBox(height: 20), + Text( + 'Result: $_result; activations: $_activationCount', + key: const ValueKey('link.result'), + style: const TextStyle( + color: Color(0xFF0F172A), + fontWeight: FontWeight.w600, ), - OutlinedButton( - key: const ValueKey('link.disable-primary'), - onPressed: _primaryEnabled - ? () => setState(() { - _primaryEnabled = false; - }) - : null, - child: const Text('Disable primary Link'), - ), - OutlinedButton( - key: const ValueKey('link.reset'), - onPressed: _reset, - child: const Text('Reset Link fixture'), - ), - ], - ), - ], + ), + const SizedBox(height: 8), + Text( + 'hovered:$_hovered focused:$_focused ' + 'pressed:$_pressed enabled:$_primaryEnabled', + key: const ValueKey('link.state'), + style: const TextStyle(color: Color(0xFF334155)), + ), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton( + key: const ValueKey('link.next-focus'), + onPressed: () => _setResult('next-focus'), + child: const Text('Next focus target'), + ), + OutlinedButton( + key: const ValueKey('link.disable-primary'), + onPressed: _primaryEnabled + ? () => setState(() { + _primaryEnabled = false; + }) + : null, + child: const Text('Disable primary Link'), + ), + OutlinedButton( + key: const ValueKey('link.reset'), + onPressed: _reset, + child: const Text('Reset Link fixture'), + ), + ], + ), + ], + ), ), ), ), @@ -177,11 +196,10 @@ class _LinkExampleState extends State { ), NakedLink( key: const ValueKey('link.primary'), - linkUrl: _primaryEnabled - ? Uri.parse('https://example.com/naked-ui') - : null, + enabled: _primaryEnabled, + linkUrl: _primaryLinkUrl, semanticLabel: _isRtl ? linkText : null, - onPressed: () => _activate('primary'), + onActivated: _recordActivation, onHoverChange: (value) => setState(() => _hovered = value), onFocusChange: (value) => setState(() => _focused = value), onPressChange: (value) => setState(() => _pressed = value), @@ -200,10 +218,10 @@ class _LinkExampleState extends State { Widget _buildExternalLink() { return NakedLink( key: const ValueKey('link.external'), - linkUrl: Uri.parse('https://docs.flutter.dev/ui/accessibility'), + linkUrl: _externalLinkUrl, semanticLabel: 'Flutter accessibility documentation', semanticHint: 'External destination', - onPressed: () => _activate('external'), + onActivated: _recordActivation, child: const Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -232,7 +250,7 @@ class _LinkExampleState extends State { key: const ValueKey('link.disabled'), enabled: false, linkUrl: Uri.parse('https://example.com/unavailable'), - onPressed: () => _activate('disabled'), + onActivated: _recordActivation, child: const Text('Unavailable documentation'), builder: (context, state, child) => _LinkSurface( state: state, @@ -254,7 +272,6 @@ class StandaloneLinkExample extends StatelessWidget { child: NakedLink( key: const ValueKey('link.standalone'), linkUrl: Uri.parse('https://example.com/naked-ui'), - onPressed: () {}, child: const Text('Open documentation'), builder: (context, state, child) => _LinkSurface( state: state, diff --git a/packages/example/test/naked_link_example_test.dart b/packages/example/test/naked_link_example_test.dart index 4c7aa92a..33a5c810 100644 --- a/packages/example/test/naked_link_example_test.dart +++ b/packages/example/test/naked_link_example_test.dart @@ -76,35 +76,34 @@ void main() { expect(data.hasAction(SemanticsAction.tap), isFalse); }); - testWidgets( - 'focus and dynamic destination state are visible and resettable', - (tester) async { - await tester.pumpWidget(_app(const link_example.LinkExample())); + testWidgets('focus and dynamic enabled state are visible and resettable', ( + tester, + ) async { + await tester.pumpWidget(_app(const link_example.LinkExample())); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - await tester.pump(); - expect( - find.text('hovered:false focused:true pressed:false enabled:true'), - findsOneWidget, - ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.pump(); + expect( + find.text('hovered:false focused:true pressed:false enabled:true'), + findsOneWidget, + ); - 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.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.tap(find.byKey(const ValueKey('link.reset'))); - await tester.pump(); - expect( - find.text('hovered:false focused:false pressed:false enabled:true'), - findsOneWidget, - ); - }, - ); + await tester.tap(find.byKey(const ValueKey('link.reset'))); + await tester.pump(); + expect( + find.text('hovered:false focused:false pressed:false enabled:true'), + findsOneWidget, + ); + }); testWidgets('external hint is named once and its icon is decorative', ( tester, diff --git a/packages/example/test_driver/link_browser_driver.dart b/packages/example/test_driver/link_browser_driver.dart index 679fa15b..a0192a55 100644 --- a/packages/example/test_driver/link_browser_driver.dart +++ b/packages/example/test_driver/link_browser_driver.dart @@ -1,10 +1,17 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter_driver/flutter_driver.dart'; import 'package:webdriver/async_io.dart'; -const _anchorSelector = By.cssSelector('a[rel="noreferrer noopener"]'); +const _semanticsAnchorSelector = By.cssSelector( + 'flt-semantics-host a:not([aria-hidden="true"])', +); +const _nativeAnchorSelector = By.cssSelector( + 'a[rel="noreferrer noopener"][aria-hidden="true"]', +); +const _w3cElementKey = 'element-6066-11e4-a52e-4f735466cecf'; Future main() async { final driver = await FlutterDriver.connect(); @@ -15,21 +22,25 @@ Future main() async { ); final webDriver = driver.webDriver; - final anchors = await _waitForAnchors(webDriver); - await _anchorFor(anchors, 'naked-link-browser-default'); - final customAnchor = await _anchorFor(anchors, 'naked-link-browser-custom'); - await _anchorFor(anchors, 'naked-link-browser-dynamic'); final originalWindow = await webDriver.window; final originalWindowIds = await _windowIds(webDriver); final originalUrl = Uri.parse(await webDriver.currentUrl); - stdout.writeln( - 'Native Link hrefs: ${await Future.wait(anchors.map((anchor) => anchor.attributes['href']))}', + await _expectNativeHrefs(webDriver, const [ + 'naked-link-browser-default', + 'naked-link-browser-custom', + 'naked-link-browser-dynamic', + ]); + + var semanticsAnchors = await _waitForSemanticsAnchors(webDriver); + var customAnchor = await _anchorFor( + semanticsAnchors, + 'naked-link-browser-custom', ); await _captureNextClickTrust(webDriver); await _click(webDriver, customAnchor); await driver.waitFor( - find.text('custom:1'), + find.text('custom:observer=1;resolver=1'), timeout: const Duration(seconds: 5), ); await _expectNoNavigation( @@ -37,19 +48,19 @@ Future main() async { originalWindow, originalWindowIds, originalUrl, - 'A custom callback must suppress native browser navigation.', + 'A resolver-handled activation must suppress native browser navigation.', ); - await _expectTrustedClick(webDriver, 'custom Link'); + await _expectTrustedClick(webDriver, 'resolver-handled Link'); - await driver.tap(find.byValueKey('browser-link.disable-dynamic')); - await driver.waitFor( - find.text('dynamic:0; enabled:false'), - timeout: const Duration(seconds: 5), + semanticsAnchors = await _waitForSemanticsAnchors(webDriver); + final dynamicAnchor = await _anchorFor( + semanticsAnchors, + 'naked-link-browser-dynamic', ); - await _waitForAnchorRemoval(webDriver, 'naked-link-browser-dynamic'); - await driver.tap(find.text('Dynamic navigation')); + await _captureNextClickTrust(webDriver); + await _click(webDriver, dynamicAnchor); await driver.waitFor( - find.text('dynamic:0; enabled:false'), + find.text('dynamic:observer=1;resolver=1;enabled=true'), timeout: const Duration(seconds: 5), ); await _expectNoNavigation( @@ -57,8 +68,87 @@ Future main() async { originalWindow, originalWindowIds, originalUrl, - 'A disabled Link must not navigate.', + 'A dynamic resolver-handled activation must not navigate.', + ); + await _expectTrustedClick(webDriver, 'dynamic resolver-handled Link'); + + semanticsAnchors = await _waitForSemanticsAnchors(webDriver); + customAnchor = await _anchorFor( + semanticsAnchors, + 'naked-link-browser-custom', + ); + await _captureNextAuxiliaryClickTrust(webDriver); + await _auxiliaryClick(webDriver, customAnchor); + await _expectTrustedAuxiliaryClick(webDriver, 'middle-click Link'); + await _expectAuxiliaryNavigation( + webDriver, + originalWindow, + originalWindowIds, + 'naked-link-browser-custom', + ); + await driver.waitFor( + find.text('custom:observer=1;resolver=1'), + timeout: const Duration(seconds: 5), + ); + + semanticsAnchors = await _waitForSemanticsAnchors(webDriver); + customAnchor = await _anchorFor( + semanticsAnchors, + 'naked-link-browser-custom', + ); + await _captureNextClickTrust(webDriver); + await _modifiedPrimaryClick(webDriver, customAnchor); + await _expectTrustedClick(webDriver, 'modified primary Link'); + await _expectAuxiliaryNavigation( + webDriver, + originalWindow, + originalWindowIds, + 'naked-link-browser-custom', + ); + await driver.waitFor( + find.text('custom:observer=1;resolver=1'), + timeout: const Duration(seconds: 5), + ); + + await driver.tap(find.byValueKey('browser-link.disable-dynamic')); + await driver.waitFor( + find.text('dynamic:observer=1;resolver=1;enabled=false'), + timeout: const Duration(seconds: 5), + ); + await _waitForLinkRemoval(webDriver, 'naked-link-browser-dynamic'); + + semanticsAnchors = await _waitForSemanticsAnchors(webDriver, count: 2); + final defaultAnchor = await _anchorFor( + semanticsAnchors, + 'naked-link-browser-default', + ); + await _captureNextClickTrust(webDriver); + await _click(webDriver, defaultAnchor); + await _expectCurrentTabNavigation( + webDriver, + originalWindow, + originalWindowIds, + 'naked-link-browser-default', + 'An unmodified default Link must navigate the current tab exactly once.', + ); + + await webDriver.get(originalUrl); + semanticsAnchors = await _waitForSemanticsAnchors(webDriver); + final keyboardAnchor = await _anchorFor( + semanticsAnchors, + 'naked-link-browser-default', + ); + await webDriver.execute('arguments[0].focus();', [keyboardAnchor]); + await _captureNextKeyTrust(webDriver); + await webDriver.keyboard.sendKeys(Keyboard.enter); + await _expectCurrentTabNavigation( + webDriver, + originalWindow, + originalWindowIds, + 'naked-link-browser-default', + 'Enter must navigate the default Link in the current tab exactly once.', ); + await _expectTrustedKey(webDriver, 'keyboard Enter Link'); stdout.writeln('Trusted browser Link ownership checks passed.'); } finally { @@ -66,11 +156,16 @@ Future main() async { } } -Future> _waitForAnchors(WebDriver driver) async { +Future> _waitForSemanticsAnchors( + WebDriver driver, { + int count = 3, +}) async { final deadline = DateTime.now().add(const Duration(seconds: 10)); while (DateTime.now().isBefore(deadline)) { - final anchors = await driver.findElements(_anchorSelector).toList(); - if (anchors.length == 3) { + final anchors = await driver + .findElements(_semanticsAnchorSelector) + .toList(); + if (anchors.length == count) { final hrefs = await Future.wait( anchors.map((anchor) => anchor.attributes['href']), ); @@ -78,7 +173,23 @@ Future> _waitForAnchors(WebDriver driver) async { } await Future.delayed(const Duration(milliseconds: 20)); } - throw StateError('Timed out waiting for the three native Link anchors.'); + final domSnapshot = await driver.execute(r''' + return JSON.stringify({ + anchors: Array.from(document.querySelectorAll('a')).map( + (element) => element.outerHTML, + ), + linkRoles: Array.from(document.querySelectorAll('[role="link"]')).map( + (element) => element.outerHTML, + ), + semanticsHosts: Array.from( + document.querySelectorAll('flt-semantics-host'), + ).map((element) => element.innerHTML), + }); + ''', const []); + throw StateError( + 'Timed out waiting for $count visible semantics Link anchors. ' + 'DOM snapshot: $domSnapshot', + ); } Future _anchorFor(List anchors, String marker) async { @@ -86,7 +197,24 @@ Future _anchorFor(List anchors, String marker) async { final href = await anchor.attributes['href']; if (href?.contains(marker) ?? false) return anchor; } - throw StateError('No native Link anchor contained $marker.'); + throw StateError('No visible semantics Link anchor contained $marker.'); +} + +Future _expectNativeHrefs(WebDriver driver, List markers) async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (DateTime.now().isBefore(deadline)) { + final anchors = await driver.findElements(_nativeAnchorSelector).toList(); + final hrefs = await Future.wait( + anchors.map((anchor) => anchor.attributes['href']), + ); + if (markers.every( + (marker) => hrefs.any((href) => href?.contains(marker) ?? false), + )) { + return; + } + await Future.delayed(const Duration(milliseconds: 20)); + } + throw StateError('Timed out waiting for native Link hrefs: $markers.'); } Future _click(WebDriver driver, WebElement anchor) async { @@ -94,11 +222,159 @@ Future _click(WebDriver driver, WebElement anchor) async { await driver.mouse.click(); } +Future _modifiedPrimaryClick(WebDriver driver, WebElement anchor) async { + final client = HttpClient(); + try { + final request = await client.postUrl( + driver.uri.resolve('session/${driver.id}/actions'), + ); + request.headers.contentType = ContentType.json; + final body = utf8.encode( + jsonEncode({ + 'actions': [ + { + 'type': 'key', + 'id': 'modifier-keys', + 'actions': [ + {'type': 'keyDown', 'value': Keyboard.control}, + {'type': 'pause'}, + {'type': 'pause'}, + {'type': 'keyUp', 'value': Keyboard.control}, + ], + }, + { + 'type': 'pointer', + 'id': 'modified-primary-pointer', + 'parameters': {'pointerType': 'mouse'}, + 'actions': [ + { + 'type': 'pointerMove', + 'duration': 0, + 'origin': {_w3cElementKey: anchor.id}, + 'x': 0, + 'y': 0, + }, + {'type': 'pointerDown', 'button': MouseButton.primary.value}, + {'type': 'pointerUp', 'button': MouseButton.primary.value}, + {'type': 'pause'}, + ], + }, + ], + }), + ); + request.contentLength = body.length; + request.add(body); + final response = await request.close(); + final responseBody = await utf8.decoder.bind(response).join(); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError( + 'Modified primary action failed (${response.statusCode}): ' + '$responseBody', + ); + } + } finally { + client.close(force: true); + } +} + +Future _auxiliaryClick(WebDriver driver, WebElement anchor) async { + final centerJson = await driver.execute( + r''' + const rect = arguments[0].getBoundingClientRect(); + return JSON.stringify({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }); + ''', + [anchor], + ); + final center = jsonDecode(centerJson! as String) as Map; + + await _dispatchCdpMouseEvent( + driver, + type: 'mousePressed', + x: center['x']! as num, + y: center['y']! as num, + buttons: 4, + ); + await _dispatchCdpMouseEvent( + driver, + type: 'mouseReleased', + x: center['x']! as num, + y: center['y']! as num, + buttons: 0, + ); +} + +Future _dispatchCdpMouseEvent( + WebDriver driver, { + required String type, + required num x, + required num y, + required int buttons, +}) async { + final client = HttpClient(); + try { + final request = await client.postUrl( + driver.uri.resolve('session/${driver.id}/goog/cdp/execute'), + ); + request.headers.contentType = ContentType.json; + final body = utf8.encode( + jsonEncode({ + 'cmd': 'Input.dispatchMouseEvent', + 'params': { + 'type': type, + 'x': x, + 'y': y, + 'button': 'middle', + 'buttons': buttons, + 'clickCount': 1, + 'pointerType': 'mouse', + }, + }), + ); + request.contentLength = body.length; + request.add(body); + final response = await request.close(); + final responseBody = await utf8.decoder.bind(response).join(); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError( + 'CDP $type failed (${response.statusCode}): $responseBody', + ); + } + } finally { + client.close(force: true); + } +} + Future _captureNextClickTrust(WebDriver driver) { return driver.execute( - 'window.__nakedLinkClickTrusted = null; ' + 'sessionStorage.setItem("__nakedLinkClickTrusted", "pending"); ' 'window.addEventListener("click", function(event) { ' - 'window.__nakedLinkClickTrusted = event.isTrusted; ' + 'sessionStorage.setItem("__nakedLinkClickTrusted", String(event.isTrusted)); ' + '}, {capture: true, once: true});', + const [], + ); +} + +Future _captureNextAuxiliaryClickTrust(WebDriver driver) { + return driver.execute( + 'sessionStorage.setItem("__nakedLinkAuxClickTrusted", "pending"); ' + 'window.addEventListener("auxclick", function(event) { ' + 'sessionStorage.setItem("__nakedLinkAuxClickTrusted", ' + 'String(event.isTrusted && event.button === 1)); ' + '}, {capture: true, once: true});', + const [], + ); +} + +Future _captureNextKeyTrust(WebDriver driver) { + return driver.execute( + 'sessionStorage.setItem("__nakedLinkKeyTrusted", "pending"); ' + 'window.addEventListener("keydown", function(event) { ' + 'if (event.key === "Enter") { ' + 'sessionStorage.setItem("__nakedLinkKeyTrusted", String(event.isTrusted)); ' + '} ' '}, {capture: true, once: true});', const [], ); @@ -106,27 +382,54 @@ Future _captureNextClickTrust(WebDriver driver) { Future _expectTrustedClick(WebDriver driver, String description) async { final isTrusted = await driver.execute( - 'return window.__nakedLinkClickTrusted;', + 'return sessionStorage.getItem("__nakedLinkClickTrusted");', const [], ); - if (isTrusted != true) { + if (isTrusted != 'true') { throw StateError('The $description click was not a trusted browser event.'); } } -Future _waitForAnchorRemoval(WebDriver driver, String marker) async { +Future _expectTrustedAuxiliaryClick( + WebDriver driver, + String description, +) async { + final isTrusted = await driver.execute( + 'return sessionStorage.getItem("__nakedLinkAuxClickTrusted");', + const [], + ); + if (isTrusted != 'true') { + throw StateError( + 'The $description did not emit a trusted middle-button auxclick.', + ); + } +} + +Future _expectTrustedKey(WebDriver driver, String description) async { + final isTrusted = await driver.execute( + 'return sessionStorage.getItem("__nakedLinkKeyTrusted");', + const [], + ); + if (isTrusted != 'true') { + throw StateError('The $description key event was not trusted.'); + } +} + +Future _waitForLinkRemoval(WebDriver driver, String marker) async { final deadline = DateTime.now().add(const Duration(seconds: 5)); while (DateTime.now().isBefore(deadline)) { - final anchors = await driver.findElements(_anchorSelector).toList(); - var markerFound = false; - for (final anchor in anchors) { - final href = await anchor.attributes['href']; - if (href?.contains(marker) ?? false) markerFound = true; - } - if (!markerFound && anchors.length == 2) return; + final visible = await driver + .findElements(_semanticsAnchorSelector) + .toList(); + final native = await driver.findElements(_nativeAnchorSelector).toList(); + final anchors = [...visible, ...native]; + final hrefs = await Future.wait( + anchors.map((anchor) => anchor.attributes['href']), + ); + if (hrefs.every((href) => !(href?.contains(marker) ?? false))) return; await Future.delayed(const Duration(milliseconds: 20)); } - throw StateError('The disabled native Link anchor was not removed.'); + throw StateError('The disabled Link retained a visible or native anchor.'); } Future> _windowIds(WebDriver driver) async => @@ -152,3 +455,66 @@ Future _expectNoNavigation( throw StateError('$message Expected $expectedUrl, got $actualUrl.'); } } + +Future _expectAuxiliaryNavigation( + WebDriver driver, + Window originalWindow, + Set originalWindowIds, + String marker, +) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + final windows = await driver.windows.toList(); + final secondary = windows.where( + (window) => !originalWindowIds.contains(window.id), + ); + if (secondary.isNotEmpty) { + final secondaryWindow = secondary.single; + await secondaryWindow.setAsActive(); + final destination = Uri.parse(await driver.currentUrl); + if (!destination.toString().contains(marker)) { + throw StateError( + 'Auxiliary navigation opened $destination, not $marker.', + ); + } + await secondaryWindow.close(); + await originalWindow.setAsActive(); + return; + } + await Future.delayed(const Duration(milliseconds: 20)); + } + throw StateError('An auxiliary Link activation did not open a new context.'); +} + +Future _expectCurrentTabNavigation( + WebDriver driver, + Window originalWindow, + Set expectedWindowIds, + String marker, + String message, +) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + final actualWindowIds = await _windowIds(driver); + if (actualWindowIds.length == expectedWindowIds.length && + actualWindowIds.containsAll(expectedWindowIds)) { + await originalWindow.setAsActive(); + final destination = Uri.parse(await driver.currentUrl); + if (destination.toString().contains(marker)) return; + } + await Future.delayed(const Duration(milliseconds: 20)); + } + final windows = await driver.windows.toList(); + final destinations = {}; + for (final window in windows) { + await window.setAsActive(); + destinations[window.id] = await driver.currentUrl; + } + if (windows.any((window) => window.id == originalWindow.id)) { + await originalWindow.setAsActive(); + } + throw StateError( + '$message Expected windows $expectedWindowIds and marker $marker; ' + 'actual destinations: $destinations.', + ); +} diff --git a/packages/naked_ui/CHANGELOG.md b/packages/naked_ui/CHANGELOG.md index e582deb6..1e29bdd9 100644 --- a/packages/naked_ui/CHANGELOG.md +++ b/packages/naked_ui/CHANGELOG.md @@ -2,17 +2,20 @@ ### Features -- Add `NakedLink` with destination-driven availability, native web anchors, - default navigation through Flutter's official `url_launcher.Link`, and an - optional `onPressed` override for custom routing. It exposes Link semantics, - observable hover/focus/press/disabled state, caller-owned focus nodes, - primary and semantic activation, and Enter/Numpad Enter keyboard support. - Space and secondary click remain unclaimed; visited state, styling, and - localized copy stay consumer-owned. +- Add `NakedLink` with a required destination URI, native web anchors through + Flutter's official `url_launcher.Link`, current-tab external web defaults, + official `FollowLink` internal/non-web defaults, optional subtree + `NakedLinkResolver` routing, and non-canceling `onActivated` observation. It + exposes Link semantics, observable hover/focus/press/disabled state, + caller-owned focus nodes, primary and semantic activation, and Enter/Numpad + Enter keyboard support. Space and browser-owned auxiliary actions remain + unclaimed; callers own URI validation, visited state, styling, and localized + copy. - Safely clear Link interaction callbacks after dynamic disabling, restore hover when re-enabled under a stationary pointer, suppress held-key repeats, - preserve stateful descendants across availability changes, and remove - disabled destinations from semantics and the web DOM. + preserve stateful descendants across availability changes, retain disabled + URIs in state, and remove disabled destination metadata from semantics and + the web DOM. ## 1.0.0-beta.3 diff --git a/packages/naked_ui/README.md b/packages/naked_ui/README.md index 0e4d86ee..7a76bc20 100644 --- a/packages/naked_ui/README.md +++ b/packages/naked_ui/README.md @@ -61,15 +61,18 @@ NakedButton( ### Custom Link -Use a Link for navigation rather than styling a Button like text. The URL is -the destination and enables the Link. Naked UI delegates default navigation to -Flutter's official `url_launcher.Link`; provide `onPressed` only when custom -routing should replace that default. Enter and Numpad Enter activate, while -Space remains available to the page. +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( @@ -83,6 +86,27 @@ NakedLink( ) ``` +### 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. diff --git a/packages/naked_ui/lib/src/naked_link.dart b/packages/naked_ui/lib/src/naked_link.dart index 020d3c40..bac8a1d7 100644 --- a/packages/naked_ui/lib/src/naked_link.dart +++ b/packages/naked_ui/lib/src/naked_link.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:url_launcher/link.dart' as launcher; +import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'mixins/naked_mixins.dart'; import 'utilities/intents.dart'; @@ -9,13 +12,55 @@ import 'utilities/naked_focusable_detector.dart'; import 'utilities/naked_state_scope.dart'; import 'utilities/state.dart'; +/// The result of resolving an ordinary [NakedLink] activation. +enum NakedLinkResolution { + /// The resolver has requested navigation and platform navigation is skipped. + handled, + + /// [NakedLink] continues with its platform-default navigation behavior. + platformDefault, +} + +/// Resolves an ordinary [NakedLink] activation for a subtree. +/// +/// The callback receives the activating Link's [BuildContext] and its exact +/// destination URI. It is synchronous so a [NakedLink] can reliably prevent +/// duplicate platform navigation when it returns [NakedLinkResolution.handled]. +typedef NakedLinkResolveCallback = + NakedLinkResolution Function(BuildContext context, Uri linkUrl); + +/// Supplies navigation policy to descendant [NakedLink] widgets. +/// +/// The closest resolver wins. Resolvers observe only ordinary primary, Enter, +/// Numpad Enter, and semantic activation; browser-owned auxiliary actions such +/// as modified and middle clicks bypass this scope. +class NakedLinkResolver extends InheritedWidget { + /// Creates a subtree navigation resolver. + const NakedLinkResolver({ + required this.resolve, + required super.child, + super.key, + }); + + /// Resolves a descendant Link's ordinary activation. + final NakedLinkResolveCallback resolve; + + /// Returns the closest [NakedLinkResolver], if one is in scope. + static NakedLinkResolver? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(NakedLinkResolver oldWidget) => + resolve != oldWidget.resolve; +} + /// An immutable snapshot of a [NakedLink]'s interaction state and destination. class NakedLinkState extends NakedState { /// Creates a snapshot with the current interaction [states] and [linkUrl]. NakedLinkState({required super.states, required this.linkUrl}); - /// The Link's destination, or null when it is unavailable. - final Uri? linkUrl; + /// The Link's destination, including while the Link is disabled. + final Uri linkUrl; /// Returns the nearest [NakedLinkState] provided by [NakedStateScope]. static NakedLinkState of(BuildContext context) => NakedState.of(context); @@ -47,15 +92,17 @@ class NakedLinkState extends NakedState { /// A headless navigation Link with observable interaction state. /// -/// Primary pointer tap, Enter, Numpad Enter, and semantic tap follow [linkUrl] -/// while the Link is effectively enabled. When [onPressed] is supplied, it -/// replaces the default navigation path. Space is not bound by this widget, so -/// a surrounding page retains its normal scrolling behavior. Secondary click -/// is likewise left available for consumer-owned context menus. +/// Primary pointer tap, Enter, Numpad Enter, and semantic tap use [linkUrl] +/// while [enabled]. [onActivated] observes each accepted ordinary activation, +/// then the nearest [NakedLinkResolver] decides whether to handle it or use +/// platform-default navigation. Space is not bound by this widget, so a +/// surrounding page retains its normal scrolling behavior. Secondary, +/// middle, and modified primary clicks remain browser-owned. /// /// Naked UI delegates default navigation and the native web anchor to /// `url_launcher`'s Link coordinator. The [builder] owns all visual styling. A /// supplied [focusNode] remains caller-owned and is never disposed by Naked UI. +/// Callers own destination validation and must not pass untrusted URIs. /// /// ```dart /// NakedLink( @@ -79,8 +126,8 @@ class NakedLink extends StatefulWidget { super.key, this.child, this.builder, - this.onPressed, - this.linkUrl, + required this.linkUrl, + this.onActivated, this.enabled = true, this.focusNode, this.autofocus = false, @@ -103,20 +150,19 @@ class NakedLink extends StatefulWidget { /// Builds the Link using the current immutable state. final ValueWidgetBuilder? builder; - /// Overrides default navigation when the Link activates. + /// Observes an accepted ordinary activation before its navigation resolves. /// - /// When null, activation follows [linkUrl] through the platform Link - /// coordinator. When non-null, only this callback runs; native navigation is - /// suppressed. - final VoidCallback? onPressed; + /// This callback cannot cancel navigation. Use [NakedLinkResolver] when a + /// subtree needs to handle a destination itself. + final ValueChanged? onActivated; /// The destination exposed to assistive technologies and the web DOM. /// - /// A null destination makes the Link effectively disabled, even if - /// [onPressed] is supplied. - final Uri? linkUrl; + /// Naked UI accepts every URI unchanged. Callers are responsible for + /// validating its destination and must not pass untrusted values. + final Uri linkUrl; - /// Whether the Link may activate when [linkUrl] is also non-null. + /// Whether the Link may activate. final bool enabled; /// The optional caller-owned focus node. @@ -160,7 +206,7 @@ class NakedLink extends StatefulWidget { /// providing an equivalent accessible navigation path. final bool excludeSemantics; - bool get _effectiveEnabled => enabled && linkUrl != null; + bool get _effectiveEnabled => enabled; @override State createState() => _NakedLinkState(); @@ -168,34 +214,99 @@ class NakedLink extends StatefulWidget { class _NakedLinkState extends State with WidgetStatesMixin { - // url_launcher's web delegate always contributes Link semantics, including - // for a null URI. Keep its wrapper out of the unavailable tree and use this - // key to preserve the consumer subtree as the wrapper is added or removed. + // url_launcher's web delegate contributes Link semantics whenever it wraps + // the Link. Keep that wrapper out of the disabled tree and use this key to + // preserve the consumer subtree as it is added or removed. final _contentKey = GlobalKey(debugLabel: 'NakedLink content'); + var _modifiedPointerActivation = false; + + bool get _hasPointerModifier { + final keyboard = HardwareKeyboard.instance; + return keyboard.isAltPressed || + keyboard.isControlPressed || + keyboard.isMetaPressed || + keyboard.isShiftPressed; + } - void _handleActivation(launcher.FollowLink? followLink) { + void _handleOrdinaryActivation(launcher.FollowLink followLink) { if (!widget._effectiveEnabled) return; if (widget.enableFeedback) { Feedback.forTap(context); } - final override = widget.onPressed; - if (override != null) { - override(); - } else { - assert(followLink != null); - unawaited(followLink!()); + + widget.onActivated?.call(widget.linkUrl); + final resolution = + NakedLinkResolver.maybeOf(context)?.resolve(context, widget.linkUrl) ?? + NakedLinkResolution.platformDefault; + if (resolution == NakedLinkResolution.platformDefault) { + _followPlatformDefault(followLink); } } + void _followPlatformDefault(launcher.FollowLink followLink) { + if (kIsWeb && widget.linkUrl.hasScheme) { + unawaited(_launchWebExternalLink()); + return; + } + unawaited(followLink()); + } + + Future _launchWebExternalLink() async { + try { + final launched = await url_launcher.launchUrl( + widget.linkUrl, + webOnlyWindowName: '_self', + ); + if (launched) return; + + FlutterError.reportError( + FlutterErrorDetails( + exception: StateError('Could not launch Link ${widget.linkUrl}.'), + stack: StackTrace.current, + library: 'naked_ui', + context: ErrorDescription('while following a NakedLink'), + ), + ); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'naked_ui', + context: ErrorDescription('while following a NakedLink'), + ), + ); + } + } + + void _handlePointerActivation(launcher.FollowLink followLink) { + final modified = _modifiedPointerActivation || _hasPointerModifier; + _modifiedPointerActivation = false; + if (modified) { + unawaited(followLink()); + return; + } + _handleOrdinaryActivation(followLink); + } + void _handlePressStart(TapDownDetails details) { + _modifiedPointerActivation = _hasPointerModifier; + if (_modifiedPointerActivation) return; updatePressState(true, widget.onPressChange); } void _handlePressEnd() { + if (_modifiedPointerActivation) return; updatePressState(false, widget.onPressChange); } + void _handlePressCancel() { + final modified = _modifiedPointerActivation; + _modifiedPointerActivation = false; + if (!modified) updatePressState(false, widget.onPressChange); + } + void _clearInteractionStates() { final endedPress = updateState(WidgetState.pressed, false, rebuild: false); final endedHover = updateState(WidgetState.hovered, false, rebuild: false); @@ -223,7 +334,7 @@ class _NakedLinkState extends State void didUpdateWidget(covariant NakedLink oldWidget) { super.didUpdateWidget(oldWidget); - final wasEnabled = oldWidget.enabled && oldWidget.linkUrl != null; + final wasEnabled = oldWidget.enabled; if (wasEnabled == widget._effectiveEnabled) return; updateDisabledState(!widget._effectiveEnabled, rebuild: false); @@ -232,19 +343,18 @@ class _NakedLinkState extends State Widget _buildLink(launcher.FollowLink? followLink) { final isEnabled = widget._effectiveEnabled; - final activation = isEnabled ? () => _handleActivation(followLink) : null; + final activation = isEnabled + ? () => _handlePointerActivation(followLink!) + : null; Widget result = GestureDetector( onTapDown: isEnabled ? _handlePressStart : null, onTapUp: isEnabled ? (_) => _handlePressEnd() : null, - onTapCancel: isEnabled ? _handlePressEnd : null, + onTapCancel: isEnabled ? _handlePressCancel : null, onTap: activation, behavior: HitTestBehavior.opaque, excludeFromSemantics: true, child: NakedStateScopeBuilder( - value: NakedLinkState( - states: widgetStates, - linkUrl: isEnabled ? widget.linkUrl : null, - ), + value: NakedLinkState(states: widgetStates, linkUrl: widget.linkUrl), child: widget.child, builder: widget.builder, ), @@ -282,7 +392,7 @@ class _NakedLinkState extends State : SystemMouseCursors.basic, shortcuts: NakedIntentActions.link.shortcuts, actions: NakedIntentActions.link.actions( - onPressed: () => _handleActivation(followLink), + onPressed: () => _handleOrdinaryActivation(followLink!), ), debugLabel: 'NakedLink', child: result, diff --git a/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart b/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart index 07a8d740..2063f75b 100644 --- a/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart +++ b/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart @@ -145,6 +145,14 @@ class _NakedFocusableDetectorState extends State }); } + void _clearHoverAfterFrame() { + if (!_hoverReported) return; + _hoverReported = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onHoverChange?.call(false); + }); + } + @override void didUpdateWidget(NakedFocusableDetector oldWidget) { super.didUpdateWidget(oldWidget); @@ -155,7 +163,7 @@ class _NakedFocusableDetectorState extends State if (widget.enabled) { _restoreHoverAfterFrame(); } else { - _hoverReported = false; + _clearHoverAfterFrame(); } } } diff --git a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart index f3a3996d..321d0341 100644 --- a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart +++ b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart @@ -22,7 +22,7 @@ void main() { linkUrl: linkUrl, semanticLabel: 'Documentation', semanticHint: 'Opens in a new window', - onPressed: () {}, + onActivated: (_) {}, child: const Text('Visible documentation'), ), ), @@ -52,7 +52,7 @@ void main() { _testApp( NakedLink( linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const Text('Visible name'), ), ), @@ -72,7 +72,7 @@ void main() { _testApp( NakedLink( linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const Text.rich( TextSpan( children: [ @@ -102,7 +102,7 @@ void main() { NakedLink( linkUrl: _destination, semanticLabel: 'Accessible documentation', - onPressed: () {}, + onActivated: (_) {}, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -146,7 +146,7 @@ void main() { NakedLink( linkUrl: _destination, semanticHint: 'Opens in a new window', - onPressed: () {}, + onActivated: (_) {}, child: const Row( mainAxisSize: MainAxisSize.min, children: [ @@ -183,7 +183,7 @@ void main() { NakedLink( focusNode: focusNode, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const Text('Documentation'), ), ), @@ -209,76 +209,69 @@ void main() { tester, ) async { final handle = tester.ensureSemantics(); - var callbackCount = 0; + final events = []; try { await tester.pumpWidget( _testApp( NakedLink( linkUrl: _destination, - onPressed: () => callbackCount++, + onActivated: (url) => events.add('observer:$url'), child: const Text('Documentation'), ), + resolve: (_, url) { + events.add('resolver:$url'); + return NakedLinkResolution.handled; + }, ), ); final node = _singleLinkNode(tester); node.owner!.performAction(node.id, SemanticsAction.tap); await tester.pump(); - expect(callbackCount, 1); + expect(events, ['observer:$_destination', 'resolver:$_destination']); } finally { handle.dispose(); } }); - testWidgets( - 'callback removal keeps the destination available to default navigation', - (tester) async { - final handle = tester.ensureSemantics(); - VoidCallback? callback = () {}; - late StateSetter rebuild; + testWidgets('a resolver does not alter the semantic contract', ( + tester, + ) async { + final handle = tester.ensureSemantics(); - try { - await tester.pumpWidget( - _testApp( - StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedLink( - linkUrl: Uri.parse('https://example.com/docs'), - semanticLabel: 'Documentation', - onPressed: callback, - child: const Text('Visible documentation'), - ); - }, + try { + await tester.pumpWidget( + _testApp( + NakedLinkResolver( + resolve: (_, _) => NakedLinkResolution.handled, + child: NakedLink( + linkUrl: Uri.parse('https://example.com/docs'), + semanticLabel: 'Documentation', + child: const Text('Visible documentation'), ), ), - ); - expect( - _singleLinkData(tester).hasAction(SemanticsAction.tap), - isTrue, - ); + ), + ); - rebuild(() => callback = null); - await tester.pump(); - final data = _singleLinkData(tester); - expect(data.label, 'Documentation'); - expect(data.flagsCollection.isLink, isTrue); - expect(data.flagsCollection.isButton, isFalse); - expect(data.flagsCollection.isEnabled, Tristate.isTrue); - expect(data.flagsCollection.isFocused, Tristate.isFalse); - expect(data.hasAction(SemanticsAction.tap), isTrue); - } finally { - handle.dispose(); - } - }, - ); + final data = _singleLinkData(tester); + expect(data.label, 'Documentation'); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isTrue); + expect(data.flagsCollection.isFocused, Tristate.isFalse); + expect(data.hasAction(SemanticsAction.tap), isTrue); + } finally { + handle.dispose(); + } + }); testWidgets( 'disabled destination exposes unavailable text without URL or action', (tester) async { final handle = tester.ensureSemantics(); final linkUrl = Uri.parse('https://example.com/docs'); + var activations = 0; try { await tester.pumpWidget( @@ -287,7 +280,7 @@ void main() { enabled: false, linkUrl: linkUrl, semanticLabel: 'Unavailable documentation', - onPressed: () {}, + onActivated: (_) => activations++, child: const Text('Documentation'), ), ), @@ -302,6 +295,9 @@ void main() { expect(data.flagsCollection.isButton, isFalse); expect(data.flagsCollection.isEnabled, Tristate.isFalse); expect(data.hasAction(SemanticsAction.tap), isFalse); + await tester.tap(find.text('Documentation')); + await tester.pump(); + expect(activations, 0); } finally { handle.dispose(); } @@ -320,7 +316,7 @@ void main() { linkUrl: _destination, semanticLabel: 'الوثائق', semanticHint: 'يفتح في نافذة جديدة', - onPressed: () {}, + onActivated: (_) {}, child: const Text('المستندات'), ), ), @@ -347,7 +343,7 @@ void main() { NakedLink( linkUrl: _destination, semanticLabel: 'Documentation', - onPressed: () {}, + onActivated: (_) {}, child: const Text('Visible documentation'), ), ), @@ -360,7 +356,7 @@ void main() { linkUrl: _destination, semanticLabel: 'Documentation', excludeSemantics: true, - onPressed: () {}, + onActivated: (_) {}, child: const Text('Visible documentation'), ), ), @@ -382,9 +378,16 @@ void main() { }); } -Widget _testApp(Widget child) { +Widget _testApp(Widget child, {NakedLinkResolveCallback? resolve}) { return MaterialApp( - home: Scaffold(body: Center(child: child)), + home: Scaffold( + body: Center( + child: NakedLinkResolver( + resolve: resolve ?? (_, _) => NakedLinkResolution.handled, + child: child, + ), + ), + ), ); } diff --git a/packages/naked_ui/test/src/naked_link_test.dart b/packages/naked_ui/test/src/naked_link_test.dart index 40b1564c..896ea3c5 100644 --- a/packages/naked_ui/test/src/naked_link_test.dart +++ b/packages/naked_ui/test/src/naked_link_test.dart @@ -12,7 +12,7 @@ final _destination = Uri.parse('https://example.com/docs'); void main() { group('NakedLink public state contract', () { test('requires either a child or builder', () { - expect(() => NakedLink(onPressed: () {}), throwsAssertionError); + expect(() => NakedLink(linkUrl: _destination), throwsAssertionError); }); testWidgets('renders its child without a builder', (tester) async { @@ -20,7 +20,6 @@ void main() { MaterialApp( home: NakedLink( linkUrl: _destination, - onPressed: () {}, child: const Text('Documentation'), ), ), @@ -41,7 +40,6 @@ void main() { MaterialApp( home: NakedLink( linkUrl: linkUrl, - onPressed: () {}, child: const Text('Documentation'), builder: (context, state, child) { builderState = state; @@ -85,16 +83,186 @@ void main() { }); }); + group('NakedLink resolver contract', () { + testWidgets('nearest resolver follows the observer with the exact URI', ( + tester, + ) async { + final linkUrl = Uri.parse('custom-scheme:destination'); + final events = []; + + await tester.pumpWidget( + _testApp( + NakedLinkResolver( + resolve: (context, resolvedUrl) { + events.add('outer:$resolvedUrl'); + expect(resolvedUrl, linkUrl); + return NakedLinkResolution.handled; + }, + child: NakedLinkResolver( + resolve: (context, resolvedUrl) { + events.add('inner:$resolvedUrl'); + expect(context, same(tester.element(find.byType(NakedLink)))); + return NakedLinkResolution.handled; + }, + child: NakedLink( + linkUrl: linkUrl, + onActivated: (activatedUrl) { + events.add('observer:$activatedUrl'); + }, + child: const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Link')); + await tester.pump(); + + expect(events, ['observer:$linkUrl', 'inner:$linkUrl']); + }); + + testWidgets('platformDefault falls through to platform navigation', ( + tester, + ) async { + final platformCalls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const launcherChannel = MethodChannel('plugins.flutter.io/url_launcher'); + messenger.setMockMethodCallHandler(launcherChannel, (call) async { + platformCalls.add(call); + return true; + }); + addTearDown( + () => messenger.setMockMethodCallHandler(launcherChannel, null), + ); + + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: Uri.parse('https://example.com/platform-default'), + child: const SizedBox(width: 160, height: 48, child: Text('Link')), + ), + resolve: (_, _) => NakedLinkResolution.platformDefault, + ), + ); + + await tester.tap(find.text('Link')); + await tester.pump(); + await tester.pump(); + + expect(platformCalls.map((call) => call.method), contains('launch')); + }); + + testWidgets('no resolver is equivalent to platformDefault', (tester) async { + final platformCalls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const launcherChannel = MethodChannel('plugins.flutter.io/url_launcher'); + messenger.setMockMethodCallHandler(launcherChannel, (call) async { + platformCalls.add(call); + return true; + }); + addTearDown( + () => messenger.setMockMethodCallHandler(launcherChannel, null), + ); + + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: Uri.parse('https://example.com/no-resolver'), + child: const SizedBox(width: 160, height: 48, child: Text('Link')), + ), + includeResolver: false, + ), + ); + + await tester.tap(find.text('Link')); + await tester.pump(); + await tester.pump(); + + expect(platformCalls.map((call) => call.method), contains('launch')); + }); + + testWidgets('resolver exceptions surface without default fallback', ( + tester, + ) async { + final platformCalls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const launcherChannel = MethodChannel('plugins.flutter.io/url_launcher'); + messenger.setMockMethodCallHandler(launcherChannel, (call) async { + platformCalls.add(call); + return true; + }); + addTearDown( + () => messenger.setMockMethodCallHandler(launcherChannel, null), + ); + final events = []; + + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: Uri.parse('https://example.com/resolver-error'), + onActivated: (_) => events.add('observer'), + child: const SizedBox(width: 160, height: 48, child: Text('Link')), + ), + resolve: (_, _) { + events.add('resolver'); + throw StateError('resolver failure'); + }, + ), + ); + + await tester.tap(find.text('Link')); + await tester.pump(); + + expect(events, ['observer', 'resolver']); + expect(tester.takeException(), isA()); + expect(platformCalls, isEmpty); + }); + + testWidgets('disabled Link invokes neither observer nor resolver', ( + tester, + ) async { + var observerCalls = 0; + var resolverCalls = 0; + + await tester.pumpWidget( + _testApp( + NakedLink( + enabled: false, + linkUrl: Uri.parse('https://example.com/disabled'), + onActivated: (_) => observerCalls++, + child: const SizedBox(width: 160, height: 48, child: Text('Link')), + ), + resolve: (_, _) { + resolverCalls++; + return NakedLinkResolution.handled; + }, + ), + ); + + await tester.tap(find.text('Link')); + await tester.pump(); + + expect(observerCalls, 0); + expect(resolverCalls, 0); + }); + }); + group('NakedLink activation contract', () { - testWidgets('destination URL is the effective-enabled source of truth', ( + testWidgets('enabled is the only availability switch and retains the URI', ( tester, ) async { - const callbackOnlyKey = ValueKey('callback-only'); - const destinationOnlyKey = ValueKey('destination-only'); + const enabledKey = ValueKey('enabled'); const explicitDisabledKey = ValueKey('explicit-disabled'); var callbackCount = 0; - NakedLinkState? callbackOnlyState; - NakedLinkState? destinationOnlyState; + NakedLinkState? enabledState; NakedLinkState? explicitDisabledState; await tester.pumpWidget( @@ -103,26 +271,15 @@ void main() { mainAxisSize: MainAxisSize.min, children: [ NakedLink( - key: callbackOnlyKey, - onPressed: () => callbackCount++, - builder: (context, state, child) { - callbackOnlyState = state; - return const SizedBox( - width: 160, - height: 48, - child: Text('Callback only'), - ); - }, - ), - NakedLink( - key: destinationOnlyKey, - linkUrl: Uri.parse('https://example.com/docs'), + key: enabledKey, + linkUrl: _destination, + onActivated: (_) => callbackCount++, builder: (context, state, child) { - destinationOnlyState = state; + enabledState = state; return const SizedBox( width: 160, height: 48, - child: Text('Destination only'), + child: Text('Enabled'), ); }, ), @@ -130,6 +287,7 @@ void main() { key: explicitDisabledKey, enabled: false, linkUrl: Uri.parse('https://example.com/unavailable'), + onActivated: (_) => callbackCount++, builder: (context, state, child) { explicitDisabledState = state; return const SizedBox( @@ -144,18 +302,18 @@ void main() { ), ); - expect(callbackOnlyState!.isDisabled, isTrue); - expect(callbackOnlyState!.linkUrl, isNull); - expect(destinationOnlyState!.isDisabled, isFalse); - expect(destinationOnlyState!.linkUrl, _destination); + expect(enabledState!.isDisabled, isFalse); + expect(enabledState!.linkUrl, _destination); expect(explicitDisabledState!.isDisabled, isTrue); - expect(explicitDisabledState!.linkUrl, isNull); + expect( + explicitDisabledState!.linkUrl, + Uri.parse('https://example.com/unavailable'), + ); - await tester.tap(find.byKey(callbackOnlyKey)); + await tester.tap(find.byKey(explicitDisabledKey)); await tester.pump(); expect(callbackCount, 0); - tester.expectCursor(SystemMouseCursors.basic, on: callbackOnlyKey); - tester.expectCursor(SystemMouseCursors.click, on: destinationOnlyKey); + tester.expectCursor(SystemMouseCursors.click, on: enabledKey); tester.expectCursor(SystemMouseCursors.basic, on: explicitDisabledKey); }); @@ -172,7 +330,7 @@ void main() { NakedLink( key: linkKey, linkUrl: _destination, - onPressed: () => callbackCount++, + onActivated: (_) => callbackCount++, onPressChange: pressChanges.add, builder: (context, value, child) { state = value; @@ -213,7 +371,7 @@ void main() { NakedLink( key: linkKey, linkUrl: _destination, - onPressed: () => callbackCount++, + onActivated: (_) => callbackCount++, onPressChange: pressChanges.add, child: const SizedBox(width: 160, height: 48, child: Text('Link')), ), @@ -233,7 +391,7 @@ void main() { expect(callbackCount, 0); }); - testWidgets('secondary click remains unclaimed', (tester) async { + testWidgets('secondary and middle clicks remain unclaimed', (tester) async { const linkKey = ValueKey('link'); var callbackCount = 0; final pressChanges = []; @@ -243,7 +401,7 @@ void main() { NakedLink( key: linkKey, linkUrl: _destination, - onPressed: () => callbackCount++, + onActivated: (_) => callbackCount++, onPressChange: pressChanges.add, child: const SizedBox(width: 160, height: 48, child: Text('Link')), ), @@ -259,14 +417,63 @@ void main() { expect(callbackCount, 0); expect(pressChanges, isEmpty); + + await tester.tapAt( + tester.getCenter(find.byKey(linkKey)), + kind: PointerDeviceKind.mouse, + buttons: kMiddleMouseButton, + ); + await tester.pump(); + + expect(callbackCount, 0); + expect(pressChanges, isEmpty); }); + testWidgets( + 'modified primary activation bypasses the observer and resolver', + (tester) async { + const linkKey = ValueKey('link'); + var observerCalls = 0; + var resolverCalls = 0; + + await tester.pumpWidget( + _testApp( + NakedLink( + key: linkKey, + linkUrl: Uri.parse('/modified-primary'), + onActivated: (_) => observerCalls++, + child: const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ), + ), + resolve: (_, _) { + resolverCalls++; + return NakedLinkResolution.handled; + }, + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tapAt( + tester.getCenter(find.byKey(linkKey)), + kind: PointerDeviceKind.mouse, + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(observerCalls, 0); + expect(resolverCalls, 0); + }, + ); + testWidgets('Enter and Numpad Enter activate while Space does not', ( tester, ) async { final focusNode = FocusNode(debugLabel: 'link test'); addTearDown(focusNode.dispose); - var callbackCount = 0; + final events = []; NakedLinkState? state; await tester.pumpWidget( @@ -274,7 +481,7 @@ void main() { NakedLink( focusNode: focusNode, linkUrl: _destination, - onPressed: () => callbackCount++, + onActivated: (_) => events.add('observer'), builder: (context, value, child) { state = value; return const SizedBox( @@ -284,6 +491,10 @@ void main() { ); }, ), + resolve: (_, _) { + events.add('resolver'); + return NakedLinkResolution.handled; + }, ), ); focusNode.requestFocus(); @@ -293,15 +504,15 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pump(); - expect(callbackCount, 1); + expect(events, ['observer', 'resolver']); await tester.sendKeyEvent(LogicalKeyboardKey.numpadEnter); await tester.pump(); - expect(callbackCount, 2); + expect(events, ['observer', 'resolver', 'observer', 'resolver']); await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pump(); - expect(callbackCount, 2); + expect(events, ['observer', 'resolver', 'observer', 'resolver']); expect(state!.isPressed, isFalse); }); @@ -317,7 +528,7 @@ void main() { NakedLink( focusNode: focusNode, linkUrl: _destination, - onPressed: () => callbackCount++, + onActivated: (_) => callbackCount++, child: const Text('Link'), ), ), @@ -343,7 +554,7 @@ void main() { _testApp( NakedLink( linkUrl: _destination, - onPressed: () => callbackCount++, + onActivated: (_) => callbackCount++, child: const SelectableText('Selectable documentation text'), ), ), @@ -384,7 +595,7 @@ void main() { enabled: enabled, enableFeedback: feedback, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const SizedBox( width: 160, height: 48, @@ -446,7 +657,7 @@ void main() { key: linkKey, focusNode: focusNode, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, onHoverChange: hoverChanges.add, onFocusChange: focusChanges.add, builder: (context, value, child) { @@ -494,17 +705,12 @@ void main() { ) async { const enabledKey = ValueKey('enabled'); const explicitDisabledKey = ValueKey('explicit-disabled'); - const destinationDisabledKey = ValueKey('destination-disabled'); const customKey = ValueKey('custom'); final enabledNode = FocusNode(debugLabel: 'enabled link'); final explicitDisabledNode = FocusNode(debugLabel: 'explicit disabled'); - final destinationDisabledNode = FocusNode( - debugLabel: 'destination disabled', - ); final nextNode = FocusNode(debugLabel: 'next'); addTearDown(enabledNode.dispose); addTearDown(explicitDisabledNode.dispose); - addTearDown(destinationDisabledNode.dispose); addTearDown(nextNode.dispose); await tester.pumpWidget( @@ -517,27 +723,21 @@ void main() { enabled: false, focusNode: explicitDisabledNode, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const SizedBox(child: Text('Explicit disabled')), ), - NakedLink( - key: destinationDisabledKey, - focusNode: destinationDisabledNode, - onPressed: () {}, - child: const SizedBox(child: Text('Destination disabled')), - ), NakedLink( key: enabledKey, focusNode: enabledNode, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const SizedBox(child: Text('Enabled')), ), NakedLink( key: customKey, mouseCursor: SystemMouseCursors.help, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const SizedBox(child: Text('Custom')), ), TextButton( @@ -554,11 +754,9 @@ void main() { await tester.pump(); expect(enabledNode.hasFocus, isTrue); expect(explicitDisabledNode.hasFocus, isFalse); - expect(destinationDisabledNode.hasFocus, isFalse); tester.expectCursor(SystemMouseCursors.click, on: enabledKey); tester.expectCursor(SystemMouseCursors.basic, on: explicitDisabledKey); - tester.expectCursor(SystemMouseCursors.basic, on: destinationDisabledKey); tester.expectCursor(SystemMouseCursors.help, on: customKey); }); @@ -578,7 +776,7 @@ void main() { enabled: false, focusNode: focusNode, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const Text('Unavailable Link'), ), ), @@ -591,7 +789,7 @@ void main() { expect(focusNode.hasFocus, isFalse); }); - testWidgets('destination removal immediately disables and clears hover', ( + testWidgets('disabling retains the destination and clears hover', ( tester, ) async { const linkKey = ValueKey('link'); @@ -599,7 +797,7 @@ void main() { addTearDown(focusNode.dispose); final hoverChanges = []; var callbackCount = 0; - Uri? destination = _destination; + var enabled = true; NakedLinkState? state; late StateSetter rebuild; @@ -611,8 +809,9 @@ void main() { return NakedLink( key: linkKey, focusNode: focusNode, - linkUrl: destination, - onPressed: () => callbackCount++, + enabled: enabled, + linkUrl: _destination, + onActivated: (_) => callbackCount++, onHoverChange: hoverChanges.add, builder: (context, value, child) { state = value; @@ -637,10 +836,11 @@ void main() { focusNode.requestFocus(); await tester.pump(); - rebuild(() => destination = null); + rebuild(() => enabled = false); await tester.pump(); expect(state!.isDisabled, isTrue); + expect(state!.linkUrl, _destination); expect(state!.isHovered, isFalse); expect(state!.isPressed, isFalse); expect(hoverChanges, [true, false]); @@ -668,7 +868,7 @@ void main() { key: linkKey, enabled: enabled, linkUrl: Uri.parse('https://example.com/docs'), - onPressed: () {}, + onActivated: (_) {}, onHoverChange: (value) => setState(() => hovered = value), child: const SizedBox( width: 160, @@ -713,7 +913,7 @@ void main() { key: linkKey, enabled: enabled, linkUrl: Uri.parse('https://example.com/docs'), - onPressed: () {}, + onActivated: (_) {}, onPressChange: (value) => setState(() => pressed = value), child: const SizedBox( width: 160, @@ -760,7 +960,7 @@ void main() { key: linkKey, enabled: enabled, linkUrl: Uri.parse('https://example.com/docs'), - onPressed: () {}, + onActivated: (_) {}, onHoverChange: hoverChanges.add, builder: (context, value, child) { state = value; @@ -812,7 +1012,7 @@ void main() { return NakedLink( enabled: enabled, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: _LifecycleProbe( onInit: () => initCount++, onDispose: () => disposeCount++, @@ -857,7 +1057,7 @@ void main() { autofocus: true, focusNode: currentNode, linkUrl: _destination, - onPressed: () {}, + onActivated: (_) {}, child: const SizedBox(child: Text('Link')), ); }, @@ -884,9 +1084,19 @@ void main() { }); } -Widget _testApp(Widget child) { +Widget _testApp( + Widget child, { + NakedLinkResolveCallback? resolve, + bool includeResolver = true, +}) { + final content = includeResolver + ? NakedLinkResolver( + resolve: resolve ?? (_, _) => NakedLinkResolution.handled, + child: child, + ) + : child; return MaterialApp( - home: Scaffold(body: Center(child: child)), + home: Scaffold(body: Center(child: content)), ); } diff --git a/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart b/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart index 0f75d0a9..2061b853 100644 --- a/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart +++ b/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart @@ -228,7 +228,7 @@ void main() { await tester.pump(); await tester.pump(); - expect(hoverChanges, [true, true]); + expect(hoverChanges, [true, false, true]); }); testWidgets('does not restore hover after reenable by default', ( diff --git a/plan/README.md b/plan/README.md index 60e81d15..a0c08eb1 100644 --- a/plan/README.md +++ b/plan/README.md @@ -15,7 +15,7 @@ business rules ([briefing §5](briefing.md#5-definition-of-the-headless-boundary | [briefing.md](briefing.md) | Full handoff contract (per-component behavior, semantics, tests, evidence) | **Frozen** — reference only | | [process.md](process.md) | The repeatable per-component workflow and PR gates | Stable | | [integration-testing.md](integration-testing.md) | Mandatory runner, determinism, evidence, and failure-triage playbook | Stable | -| [decisions.md](decisions.md) | Decision log D-01…D-16 and escalation rule | **Living** — update as decisions resolve | +| [decisions.md](decisions.md) | Decision log D-01…D-17 and escalation rule | **Living** — update as decisions resolve | | README.md (this file) | Index and status board | **Living** — update every phase PR | | [phases/](phases/) using the NN-name.md convention | Executable plan for one phase | Created just-in-time when a phase starts | @@ -29,7 +29,7 @@ do not pre-write plans for phases whose blocking decisions are unresolved. |---:|---|---|---|---|---| | 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | | 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Tracked independently in ready-for-review PR #64; closure blockers are recorded there | -| 2 | Link | [§20](briefing.md#20-component-contract-link) + D-16 correction | D-16 (resolved) | [phases/02-link.md](phases/02-link.md) | Reviewer correction `dc20214` for [PR #65](https://github.com/btwld/naked_ui/pull/65) passes all available local gates; exact-head hosted verification, required web screenshots, manual AT, and Phase 5/7 composition evidence remain pending | +| 2 | Link | [§20](briefing.md#20-component-contract-link) + D-17 hard cut | D-17 (resolved) | [phases/02-link.md](phases/02-link.md) | Resolver/observer hard cut is implemented and passes the required local Flutter/widget gates; exact-head hosted verification, required web screenshots, manual AT, and Phase 5/7 composition evidence remain pending | | 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | | 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | | 5 | Context Menu | [§15](briefing.md#15-component-contract-context-menu) | D-03 | — | Not started | @@ -52,7 +52,7 @@ gates. | Phase | Start readiness | Required work before implementation | |---:|---|---| | 1 — Alert Dialog | **Ready for a just-in-time phase plan** | Re-verify the current `NakedDialog` baseline, carry the resolved D-02 focus contract into tests/examples, then create the phase plan. | -| 2 — Link | **Implemented; reviewer correction locally verified** | D-16 supersedes the metadata-only ownership clause. Publish the correction, rerun exact-head hosted gates (including Android), then complete the outstanding manual and composition evidence. | +| 2 — Link | **Implemented; external gates pending** | D-17 supersedes D-16's callback override. Local exact Flutter 3.41.0 and matched Chrome/ChromeDriver 150 pass; rerun exact-head hosted macOS/Android, then complete the outstanding manual and composition evidence. | | 3 — Field | Blocked on decisions | Resolve D-08 metadata precedence and D-09 initial-error announcement policy before implementation/semantics tests. | | 4 — Toggle Group | Blocked on compatibility decision | Resolve D-01 and document the consumer-facing `selected` to `toggled` announcement migration. | | 5 — Context Menu | Spike/decision required | Resolve D-03 with a trigger-role/semantic-long-press prototype and real VoiceOver/TalkBack results. | diff --git a/plan/decisions.md b/plan/decisions.md index 9f83968e..cb75c760 100644 --- a/plan/decisions.md +++ b/plan/decisions.md @@ -16,7 +16,24 @@ recommendations on 2026-07-12 before decision-dependent implementation began. ### Phase 2 decision evidence (2026-07-13) -- **D-16:** approved destination-owned Link availability and navigation. A +- **D-17:** supersedes D-16's callback override. `NakedLink.linkUrl` is + required and non-nullable; `enabled` is the only availability switch. + `onActivated` observes each accepted ordinary activation before resolution + and cannot cancel it. The closest synchronous `NakedLinkResolver` receives + the Link context and exact URI, then returns `handled` or `platformDefault`; + no resolver is `platformDefault`. Resolver exceptions surface without a + fallback to prevent double navigation. Ordinary unmodified external web + links use `launchUrl(..., webOnlyWindowName: '_self')`; scheme-less internal + routes and non-web platforms use `url_launcher.Link`'s `FollowLink` path. + Modified primary, middle, secondary, and context-menu browser actions bypass + both the observer and resolver. Disabled Links retain their URI in state but + expose no Link role, semantic URL/action, focus path, or web `href`. Naked + UI accepts every URI unchanged, so callers own trust and scheme validation. + This is an unreleased hard cut: no `onPressed` compatibility constructor or + deprecated alias is retained. + +- **D-16 (superseded):** originally approved destination-owned Link + availability and navigation. A non-null `linkUrl` is the destination and, together with `enabled`, controls availability. With no `onPressed`, Naked UI delegates to Flutter's official [`url_launcher.Link`](https://pub.dev/documentation/url_launcher/latest/link/Link-class.html), @@ -83,7 +100,8 @@ recommendations on 2026-07-12 before decision-dependent implementation began. | D-13 | Example platform directories | Commit reviewed minimal platform files or generate reproducibly in CI; job names must match actual devices | Phase 0 (test-harness PR) | [resolved(commit reviewed Android/macOS/web directories)](#phase-0-decision-evidence-2026-07-12) | | D-14 | Golden host/font pinning | One Ubuntu image, Flutter 3.41.2, fixed surface config, checked-in licensed test font | Phase 0 (test-harness PR) | [resolved(Ubuntu 24.04, Flutter 3.41.2, Roboto Apache-2.0, fixed harness)](#phase-0-decision-evidence-2026-07-12) | | D-15 | Android/web PR frequency | Affected-path PR/merge-queue jobs; release blocked unless both passed on the exact release commit | Phase 0 (test-harness PR) | [resolved(affected PR/merge queue plus exact-tag release gates)](#phase-0-decision-evidence-2026-07-12) | -| D-16 | Link destination and web-navigation ownership | Make `linkUrl` the destination and availability source; use official `url_launcher.Link` for default navigation; a custom callback replaces the default; unavailable Links expose no native destination | Before Phase 2 reviewer corrections | [resolved(destination-owned availability; official default navigation; callback override)](#phase-2-decision-evidence-2026-07-13) | +| D-16 | Link destination and web-navigation ownership | Make `linkUrl` the destination and availability source; use official `url_launcher.Link` for default navigation; a custom callback replaces the default; unavailable Links expose no native destination | Before Phase 2 reviewer corrections | superseded by D-17 | +| D-17 | Link activation resolver and observer | Require `linkUrl`, use `enabled` alone for availability, resolve ordinary activation through the nearest synchronous resolver, and keep `onActivated` observational | Before Phase 2 API correction | [resolved(required destination; resolver ownership; observational hook)](#phase-2-decision-evidence-2026-07-13) | ## Risk register diff --git a/plan/phases/02-link.md b/plan/phases/02-link.md index 345a93f1..0606c072 100644 --- a/plan/phases/02-link.md +++ b/plan/phases/02-link.md @@ -1,48 +1,52 @@ # Phase 2 — Link -Status: **PR #65 reviewer corrections pass every available local publication -gate. Exact-head hosted verification (including Android) is pending publication; -closure remains blocked by required web screenshots, manual assistive-technology -sessions, and real Context Menu/Hover Card composition**. - -Goal: add a headless inline navigation primitive that exposes Link rather than -Button semantics, activates once through primary pointer, Enter, Numpad Enter, -or semantic tap, leaves Space and secondary click available to their normal -page/composition behavior, reports immutable interaction state, and delegates -default navigation and native web-anchor coordination to Flutter's official -Link implementation. Consumers may replace default navigation with a custom -callback and continue to own styling, localized copy, and visited history. - -Contract: briefing [§20](../briefing.md#20-component-contract-link) (binding), -plus the cross-component rules in §§5 and 8–12, as corrected by resolved -[D-16](../decisions.md#phase-2-decision-evidence-2026-07-13). D-16 supersedes -the metadata-only, callback-required clauses after real-browser review proved -they permit duplicate or disabled navigation. No open decision blocks Link. -Baseline commit: `d341b90e7b09e13f83da299b4ed17ae0eaa9ddee` (all current-code -findings below re-verified on 2026-07-13). - -## Reviewer correction addendum — 2026-07-13 - -- `linkUrl` is the destination and availability source. Effective enabled state - is `enabled && linkUrl != null`; removing `onPressed` switches to default - navigation, while removing `linkUrl` makes the Link unavailable. -- Default navigation is delegated to `url_launcher.Link`. A supplied - `onPressed` replaces its `FollowLink` callback, so one activation cannot run - both custom routing and browser navigation. -- An unavailable Link has no Link flag, URL, tap action, focus path, or web - `href`. Flutter's web delegate retains Link semantics for a null URI, so the - unavailable path omits that wrapper. A keyed inner detector preserves the - consumer's stateful subtree as the wrapper enters or leaves the tree. The - content remains discoverable as ordinary unavailable text because HTML has no - disabled-anchor contract. -- Interaction state is synchronized during `didUpdateWidget` without invoking - consumer callbacks during build; ended hover, press, and focus callbacks are - delivered after the frame. Re-enabling beneath a stationary pointer restores - hover after layout. -- Added regressions cover native DOM ownership, dynamic parent `setState`, - stationary hover, held Enter/Numpad Enter, directional disabled focus, - selectable text, and rich text. Real Context Menu/Hover Card composition - remains deferred to Phases 5 and 7 as originally required. +Status: **D-17 is implemented and the required local format, analyze, widget, +semantics, example, focused integration, and `flutter-tester` aggregate gates +pass. Exact Flutter 3.41.0 and matched Chrome/ChromeDriver 150.0.7871.115 also +pass locally. Focused real-macOS Link integration passes; the full macOS +aggregate remains host-blocked because this interactive session cannot keep the +test app foregrounded without injecting real keyboard events into Flutter's +synthetic keyboard state. Exact-head hosted verification, Android, web +screenshots, manual assistive-technology sessions, and real Context Menu/Hover +Card composition remain closure gates.** + +Goal: provide a headless inline navigation primitive that exposes Link rather +than Button semantics. It activates once through an ordinary primary pointer, +Enter, Numpad Enter, or semantic tap; leaves Space, secondary click, modified +activation, and browser context-menu actions to their normal owners; reports +immutable interaction state; and retains native web anchors through Flutter's +official `url_launcher.Link`. Consumers own styling, localized copy, visited +history, destination trust, and optional subtree routing policy. + +Contract: the final API is defined by resolved +[D-17](../decisions.md#phase-2-decision-evidence-2026-07-13). +It supersedes D-16 and the frozen briefing's callback-override language. No +compatibility constructor or deprecated alias is permitted. + +## D-17 final contract — 2026-07-13 + +- `NakedLink.linkUrl` and `NakedLinkState.linkUrl` are required, non-nullable + `Uri` values. `enabled` is the only availability switch; disabled Links retain + their URI in state but expose no Link role, semantic URL/action, focus path, + or web `href`. +- `onPressed` is removed. `onActivated` is an observational + `ValueChanged?`: for an accepted ordinary activation it runs exactly once + before resolution and cannot cancel navigation. +- The closest `NakedLinkResolver` receives the Link build context and exact URI. + `handled` suppresses default navigation; `platformDefault`, or no resolver, + continues to it. Resolver exceptions surface and must not trigger fallback. +- On web, ordinary external URI activation uses + `launchUrl(uri, webOnlyWindowName: '_self')`; failure is reported through + Flutter error reporting without retry. Scheme-less internal routes and all + non-web default navigation use the official `FollowLink` callback. +- `url_launcher.Link` remains enabled-only to preserve real native anchors, + copy-link, middle-click, modifier, and context-menu behavior. A keyed inner + detector preserves a consumer's stateful child as that wrapper changes. +- Pointer modifiers are read from `HardwareKeyboard`. Modified primary, + middle-click, secondary click, and browser context-menu paths bypass both the + observer and resolver. Interaction state clears safely during disable; + post-frame hover restoration reports `[true, false, true]` beneath a + stationary pointer. ## Research and readiness @@ -63,9 +67,10 @@ findings below re-verified on 2026-07-13). Real-browser review showed that combining an application callback with this live web `href` does not coordinate navigation ownership. Flutter's official [`url_launcher.Link`](https://pub.dev/documentation/url_launcher/latest/link/Link-class.html) - provides that missing native/web coordination while a destination exists. - Its web delegate still contributes Link semantics for a null URI, so Naked UI - bypasses the wrapper while unavailable. + provides native-anchor coordination. D-17 keeps the delegate only while the + Link is enabled; its `FollowLink` callback remains the default for + scheme-less internal routes and non-web platforms, while ordinary external + web navigation explicitly stays in the current tab. - Flutter has no `SemanticsRole.link` in the pinned API. The binding contract's `link: true` flag is the supported representation; adding Button semantics as a fallback would be incorrect. @@ -116,9 +121,9 @@ findings below re-verified on 2026-07-13). | Primitive | Discoverable Link, never Button | exact semantics test | VoiceOver, TalkBack, Chrome tree | | Name | visible child text, or one caller-localized `semanticLabel` override | semantics tests including Arabic | all AT sessions | | Role/flags | `link: true`; `button` absent | exact flags test | all AT sessions | -| State | enabled is `enabled && linkUrl != null`; focus/focusable follows effective focus | widget + semantics transitions | macOS/Android/web | -| Value/URL | non-null `linkUrl` is the destination; default navigation uses official Link; unavailable state removes URL/href | state, semantics, and pinned-Chrome DOM tests | Chrome tree/href inspection | -| Actions | semantic tap only while effectively enabled | semantics action/callback tests | VoiceOver/TalkBack | +| State | `enabled` alone controls availability; the required URI stays in state | widget + semantics transitions | macOS/Android/web | +| Value/URL | required `linkUrl` is retained while disabled; enabled native anchor/semantic URL is removed on disable | state, semantics, and pinned-Chrome DOM tests | Chrome tree/href inspection | +| Actions | semantic tap only while enabled; observer then nearest resolver/default | semantics action/resolver tests | VoiceOver/TalkBack | | Hint | optional caller-localized `semanticHint`, once | semantics test | all AT sessions | | Children | visible label preserved unless an explicit label overrides it; decorative icon excluded by consumer | semantics/example tests | Chrome tree | | Exclusion | `excludeSemantics` removes the Link semantics subtree and focus semantics | semantics test | N/A | @@ -126,18 +131,18 @@ findings below re-verified on 2026-07-13). ### Input, state, and lifecycle matrix -| Path | Enabled result | Disabled/null-destination result | Required assertion | +| Path | Enabled result | Disabled result | Required assertion | |---|---|---|---| -| Primary tap | custom callback once, or default navigation once, plus optional feedback | no recognizer/callback/feedback | callback, DOM location, and state transitions | +| Ordinary primary tap | feedback, observer, then nearest resolver/default once | no recognizer/callback/feedback | ordering, DOM location, and state transitions | | Canceled primary sequence | press true → false; no callback | no state transition | pointer cancel test | -| Secondary click | unclaimed; no callback | unclaimed | gesture test and later Context Menu composition | -| Enter/Numpad Enter | one activation per physical key sequence | no activation | known focus node + key-down/repeat/up sequence | -| Space | no Link callback and no Link press state | no callback | widget test; web scroll outcome | -| Semantic tap | same activation path once | action absent | semantics action test | +| Modified primary / middle / secondary | browser-owned; observer/resolver bypassed | unclaimed | trusted browser auxiliary-context tests | +| Enter/Numpad Enter | observer, resolver/default once per physical key sequence | no activation | known focus node + key-down/repeat/up sequence | +| Space | no Link observer/resolver and no Link press state | no callback | widget test; web scroll outcome | +| Semantic tap | same observer/resolver/default path once | action absent | semantics action test | | Hover | state/callback true then false | no hover callback | mouse gesture test | | Focus/Tab | state/callback and normal traversal | skipped in traditional traversal | focus and next-target assertions | -| Callback removal | remains enabled and switches to default navigation | already default | rebuild test | -| Destination removal | immediately unavailable; transient press/hover/focus clear after safe notification | already unavailable | parent-`setState` lifecycle tests | +| Resolver change | nearest current resolver owns future ordinary activation | N/A | nested resolver/rebuild test | +| Disable | immediately unavailable; transient press/hover/focus clear after safe notification | already unavailable | parent-`setState` lifecycle tests | | Stationary re-enable | hover restores after layout when the pointer remains inside | N/A | detector and Link hover regression | | Focus-node replacement | listener moves; focused state handed off; neither external node disposed | same ownership | lifecycle test | | Disposal | internal detector node/listeners removed; external node remains usable | same | teardown/no exception | @@ -149,10 +154,10 @@ findings below re-verified on 2026-07-13). | LINK-API-01 | Child/builder invariant; immutable state/scope includes URL and all widget states | `naked_link_test.dart` + hash contract | N/A | | LINK-ACT-01 | Primary tap once; cancellation and secondary click do not activate | widget gesture tests | macOS/web hover+pointer; Android touch | | LINK-KEY-01 | Enter and Numpad Enter activate; Space is unclaimed | widget shortcut tests | macOS and pinned web scroll/result | -| LINK-STATE-01 | Destination-owned effective enabled state controls activation, traversal, feedback, cursor, and disabled state | widget transitions + platform-channel feedback test | all behavior targets | +| LINK-STATE-01 | `enabled` controls activation, traversal, feedback, cursor, semantic URL, and disabled state while URI remains retained | widget transitions + platform-channel feedback test | all behavior targets | | LINK-STATE-02 | Hover/focus/press callbacks and builder/scope snapshots are exact | widget state tests | macOS/web fixture readout | -| LINK-LIFE-01 | Focus ownership/replacement/disposal, safe destination removal, and stationary hover restoration do not leak | lifecycle + detector tests | aggregate teardown on macOS/web | -| LINK-NAV-01 | Official default navigation, custom override, and unavailable DOM paths have one owner | widget + pinned-Chrome DOM click tests | pinned Chrome location/href | +| LINK-LIFE-01 | Focus ownership/replacement/disposal, safe disable, and stationary hover restoration do not leak | lifecycle + detector tests | aggregate teardown on macOS/web | +| LINK-NAV-01 | Observer ordering, nearest resolver ownership, current-tab default navigation, and disabled DOM paths have one owner | widget + pinned-Chrome DOM click tests | pinned Chrome location/href | | LINK-SEM-01 | Link flag, URL, name, hint, enabled/focus/action exact; Button absent | `naked_link_semantics_test.dart` | VoiceOver/TalkBack/Chrome tree | | LINK-SEM-02 | Disabled action absent; label override not duplicated; icon/exclusion correct; Arabic/RTL | semantics + example tests | all AT sessions | | LINK-COMP-01 | Primary, secondary, hover, and keyboard paths compose without conflict | Link secondary-path test now; future Context Menu/Hover Card integration | later Phase 5/7 real targets | @@ -165,16 +170,18 @@ findings below re-verified on 2026-07-13). - **Where:** add `packages/naked_ui/test/src/naked_link_test.dart`; extend `packages/naked_ui/test/hashcode_contract_test.dart` only for the new public state type. -- **How:** first reference the binding API so the focused test fails to compile - because `NakedLink`/`NakedLinkState` do not exist. Then, before production - behavior, cover the constructor invariant; builder child and identical scope - snapshot; URL equality/hash; primary tap exactly once; canceled primary and - secondary gestures; Enter/Numpad Enter; Space with no callback or pressed - state; explicit and effective disabled paths; default/custom/basic cursors; - hover/focus/press callbacks; feedback only while enabled; dynamic callback - and destination removal; parent-`setState` lifecycle safety; stationary - hover restoration; held-key repeats; directional disabled focus; selectable - and rich text; external focus ownership, replacement, and disposal. +- **How:** first reference the final required-URI constructor and resolver API + so the focused test fails to compile. Before production behavior, cover the + child/builder invariant; immutable state/scope URL equality/hash; nearest + resolver wins; `handled` suppresses fallback; `platformDefault` and no + resolver fall through; observer-before-resolver ordering; Link context and + exact custom URI; resolver exception behavior; ordinary primary once; + canceled, secondary, middle, and modified gestures; Enter/Numpad Enter; + Space with no observer/resolver or pressed state; enabled-only availability; + hover/focus/press callbacks; feedback only while enabled; dynamic disable; + stationary hover restoration; held-key repeats; directional disabled focus; + selectable and rich text; external focus ownership, replacement, and + disposal. - **Red proof:** observe the missing API, then use the smallest targeted assertions/mutations if several behaviors become green through shared infrastructure. Record the first failing expectation for every group. @@ -202,23 +209,29 @@ findings below re-verified on 2026-07-13). - **Where:** add `packages/naked_ui/lib/src/naked_link.dart`; export it from `packages/naked_ui/lib/src/naked_widgets.dart`; add a Link namespace/private intent to `packages/naked_ui/lib/src/utilities/intents.dart`. -- **How:** implement `NakedLinkState` with state helpers, equality/hash, and - `linkUrl`. Implement the binding constructor exactly, including a nullable - `mouseCursor`, the child/builder assertion, and effective enabled - `enabled && linkUrl != null`. Delegate default navigation and native web - anchors to `url_launcher.Link`; when `onPressed` is present, route activation - only to that override. Compose the existing state mixin, state scope, - focusable detector, primary-only gesture path, and one effective Link - semantics node. Map only Enter/Numpad Enter without repeat events; do not bind - Space. When the destination becomes unavailable, synchronize transient state - without rebuilding or invoking consumers during `didUpdateWidget`, then +- **How:** implement `NakedLinkState` with state helpers, equality/hash, and a + required non-null `linkUrl`. Add `NakedLinkResolution`, + `NakedLinkResolveCallback`, and `NakedLinkResolver.maybeOf`. The constructor + exposes `onActivated`, never `onPressed`; `enabled` alone determines + availability. Compose the existing state mixin, state scope, focusable + detector, primary-only gesture path, and one effective Link semantics node. + Accepted ordinary activation performs feedback, observer, then the nearest + resolver exactly once. `handled` stops; `platformDefault`/no resolver follows + the current-tab external web default through `launchUrl(..., '_self')`, or + `FollowLink` for internal/non-web defaults. Let resolver failures surface. + Map only Enter/Numpad Enter without repeats; do not bind Space. Read pointer + modifiers from `HardwareKeyboard` and delegate modified primary actions + straight to `FollowLink`. Keep `url_launcher.Link` only for enabled native + anchors and preserve the keyed wrapper transition. When disabled, synchronize + transient state without invoking consumers during `didUpdateWidget`, then deliver ended-state callbacks after the frame. Borrow external focus nodes and never dispose them. Remove URL, Link flag, focus, and actions while - unavailable; preserve the advanced semantics-exclusion escape hatch. -- **Avoid:** direct `launchUrl` calls, hand-rolled DOM anchors or event - coordination, router dependencies, visited state, modifier-click synthesis, - long-press ownership, raw key handlers, timers, styles, English defaults, - changes to Button, or a speculative generic pressable base class. + disabled; preserve the advanced semantics-exclusion escape hatch. +- **Avoid:** an `onPressed` compatibility shim, per-Link resolver/target, + validation or rewriting of caller URIs, retry after a failed web launch, + hand-rolled DOM anchors, router dependencies, visited state, long-press + ownership, raw key handlers, timers, styles, English defaults, changes to + Button, or a speculative generic pressable base class. - **Verify:** focused A1/A2 tests, then `fvm dart format --set-exit-if-changed packages/naked_ui/lib/src/naked_link.dart packages/naked_ui/lib/src/naked_widgets.dart packages/naked_ui/lib/src/utilities/intents.dart packages/naked_ui/test/src/naked_link_test.dart packages/naked_ui/test/semantics/naked_link_semantics_test.dart packages/naked_ui/test/hashcode_contract_test.dart` and `fvm flutter analyze packages/naked_ui`. @@ -258,13 +271,17 @@ findings below re-verified on 2026-07-13). import/group it in `packages/example/integration_test/all_tests.dart`; rerun `packages/example/test/integration_inventory_test.dart`. - **How:** drive only stable keys. Add five presently executable binding - scenarios: Tab → known Link focus → Enter → one result with retained focus; - focused Space → no callback (and observable page scroll on web); hover/down/up - → exact state readout and one result; semantic tap → same callback path; - disabled skipped by Tab with no pointer/semantic action. Add Arabic/RTL and - 200% long-text assertions, destination removal while focused, secondary click - remaining unclaimed, and pinned-Chrome DOM cases for destination-only native - navigation, custom override, and unavailable href removal. + scenarios: Tab → known Link focus → Enter → observer then resolver once with + retained focus; focused Space → no activation (and observable page scroll on + web); hover/down/up → exact state readout and one resolver-owned result; + semantic tap → the same ordinary activation path; disabled skipped by Tab + with no pointer/semantic action. Add Arabic/RTL and 200% long-text assertions, + disabling while focused, secondary click remaining unclaimed, and pinned + Chrome cases for current-tab default navigation, resolver-owned activation, + browser auxiliary actions that bypass observer/resolver, and enabled-only + native `href` presence/removal. The WebDriver driver must click the visible + semantics anchor under `flt-semantics-host`; hidden native anchors are checked + only for `href` presence/removal. - **Deferred composition:** do not create fake Hover Card/Context Menu implementations. Record LINK-COMP-01 as a closure blocker and require Phase 5 and Phase 7 integration suites to wrap the real `NakedLink` and prove the @@ -302,13 +319,14 @@ findings below re-verified on 2026-07-13). - **Where:** dartdoc in `naked_link.dart`; root and package READMEs; package changelog; registry; this plan and `plan/README.md`; PR description. -- **How:** document Link-versus-Button use, destination/default/override - ownership, effective enabled state, Enter/Numpad/Space behavior, state and - focus ownership, semantics override/icon rules, secondary/modifier-click - boundaries, styling and router non-goals, and Remix responsibilities. Build - the §22 ten-item packet with the stable requirement table, platform - commands/runs, screenshot review, manual AT rows, limitations, and exact - SHAs. +- **How:** document Link-versus-Button use, required URI ownership, default + navigation, subtree resolver policy, observational activation, `enabled` as + the sole availability switch, Enter/Numpad/Space behavior, state and focus + ownership, semantics override/icon rules, secondary/modifier-click + boundaries, styling and router non-goals, and Remix responsibilities. State + explicitly that callers validate and trust their own URIs. Build the §22 + ten-item packet with the stable requirement table, platform commands/runs, + screenshot review, manual AT rows, limitations, and exact SHAs. - **Review:** inspect the entire diff for API drift, accidental Button/Space behavior, duplicate semantics/names/actions, disabled descendants, feedback/cursor/focus leaks, selection interference, router or styling scope, @@ -325,13 +343,13 @@ findings below re-verified on 2026-07-13). | Scenario | flutter-tester | real macOS | API 34 Android | pinned Chrome/web | |---|---:|---:|---:|---:| -| Tab, Enter, one callback, retained focus | Yes | Required | Required focus path | Required | -| Space no activation | Yes | Required | Required | Required + scroll outcome | +| Tab, Enter, observer then resolver/default once, retained focus | Yes | Required | Required focus path | Required | +| Space no observer/resolver activation | Yes | Required | Required | Required + scroll outcome | | Pointer hover/press/tap state | Yes | Required | Touch/press required; hover N/A | Required hover | -| Semantic tap same callback | Yes | Required | Required | Required tree/action | +| Semantic tap same observer/resolver/default path | Yes | Required | Required | Required tree/action | | Disabled skipped/no action/cursor | Yes | Required | Required + screenshot | Required | | Arabic RTL + 200% long text | Yes | Required + screenshots | Required behavior | Required behavior; RTL screenshot blocked | -| Secondary click unclaimed | Yes | Required | N/A | Required | +| Secondary/middle/modified activation browser-owned | Yes | Required | N/A | Required | | Real Context Menu/Hover Card composition | Not available | Deferred | Deferred | Deferred to Phase 5/7 | ### Evidence and manual sessions @@ -352,25 +370,23 @@ findings below re-verified on 2026-07-13). Focused development: ```sh -fvm flutter test packages/naked_ui/test/src/naked_link_test.dart -fvm flutter test packages/naked_ui/test/semantics/naked_link_semantics_test.dart -fvm flutter test packages/naked_ui/test/hashcode_contract_test.dart -fvm flutter test packages/example/test/naked_link_example_test.dart -fvm flutter test packages/example/test/accessibility_guidelines_test.dart -fvm flutter test packages/example/test/goldens/components/naked_link_golden_test.dart +flutter test packages/naked_ui/test/src/naked_link_test.dart +flutter test packages/naked_ui/test/semantics/naked_link_semantics_test.dart +flutter test packages/example/test/naked_link_example_test.dart cd packages/example -fvm flutter test -r compact -d flutter-tester integration_test/components/naked_link_integration.dart +flutter test -r compact -d flutter-tester integration_test/components/naked_link_integration.dart ``` Required local publication gate from the repository root: ```sh -fvm dart format --set-exit-if-changed . -fvm flutter analyze -fvm flutter test packages/naked_ui/test -fvm flutter test packages/example/test +dart format --output=none --set-exit-if-changed . +flutter analyze --fatal-infos +flutter test packages/naked_ui/test +flutter test packages/example/test cd packages/example -fvm flutter test -r compact -d flutter-tester integration_test/all_tests.dart +flutter test -r compact -d flutter-tester integration_test/components/naked_link_integration.dart +flutter test -r compact -d flutter-tester integration_test/all_tests.dart ``` Additional exact proof: @@ -388,12 +404,36 @@ Hosted gates: primary and exact-minimum suites; canonical golden/guidelines; pinned Chrome/ChromeDriver behavior log; PR-title policy. Every result must be green on the exact PR head or an identified GitHub merge ref. -## Reviewer-correction local evidence — 2026-07-13 - -Correction implementation `dc20214ba1aab5d665d80f2baa0fe13855de2792` -was verified from parent `2614555` before publication. This docs-only follow-up -records that exact implementation SHA. No push, PR update, merge, or hosted -exact-head run is claimed here. +## D-17 local execution evidence — 2026-07-13 + +- Exact Flutter 3.41.0 dependency resolution and analysis pass; the full + package suite passes with 615 tests and three documented external-integration + skips. +- Matched Chrome/ChromeDriver 150.0.7871.115 passes both the full web aggregate + and the trusted visible-semantics-anchor Link driver. The Link proof covers + resolver-owned activation, native middle and modified auxiliary navigation, + disabled visible/native anchor removal, and current-tab pointer and Enter + defaults. +- Chrome exposes semantic Links as native `` elements with an implicit Link + role, not a redundant `role="link"` attribute. The driver therefore selects + visible non-hidden anchors beneath `flt-semantics-host`. +- ChromeDriver's W3C middle-button action does not perform native auxiliary + navigation even for a plain HTML anchor. The accepted driver uses + ChromeDriver's DevTools input endpoint, asserts a trusted middle-button + `auxclick`, and verifies the new context and destination before closing it. +- Browser destinations use a query marker. A hash marker is consumed as a + Flutter web route and normalized back to `/`, which makes successful `_self` + navigation unobservable. +- Focused real-macOS Link integration passes. The full aggregate's unrelated + foreground retry is not accepted as Link evidence: a manual foreground lets + Accordion keyboard focus proceed, but real host keystrokes then corrupt the + synthetic `HardwareKeyboard` stream. Hosted macOS remains authoritative. + +## Superseded D-16 historical evidence — 2026-07-13 + +This retained audit record describes the earlier D-16 callback-override +implementation. It does not specify or validate the D-17 resolver API. No +push, PR update, merge, or hosted exact-head D-17 run is claimed here. ### Available local publication gates @@ -406,10 +446,9 @@ exact-head run is claimed here. - Matched Chrome/ChromeDriver 150.0.7871.115 aggregate passes. Exact disabled semantics and DOM assertions prove no Link role, URL, action, anchor, or `href` remains. -- A separate W3C WebDriver proof performs a trusted primary click, proves the - custom override runs exactly once without navigation, and proves dynamic - disable removes the native anchor and cannot activate. The aggregate's - in-app coordinator separately proves the default destination outcome. +- The separate W3C WebDriver proof in this record exercised D-16's custom + callback override. It is superseded by the D-17 trusted visible-semantics + anchor coverage for observer/resolver ownership and current-tab defaults. - Flutter 3.41.0 exact-minimum dependency resolution, analyze, and the full 609-test package suite pass with the same three documented skips. @@ -430,7 +469,7 @@ tested implementation/evidence head is `24460f0a94b657854c95d5dc900e5ef7215d9604`; its GitHub test merge ref is `09e62c8dc29b424a1d00e5e7de8cfc4a99cd124f`. The PR remains unmerged. These checks establish the original implementation's evidence but do not -validate the unpublished D-16 reviewer correction above. +validate the unpublished D-17 resolver correction above. ### Test-first and failure-triage record @@ -523,18 +562,21 @@ All seven checks passed for head `24460f0` / merge ref `09e62c8`: - [x] Every A1/A2 test was observed failing for the intended missing behavior before implementation and the red evidence is recorded. -- [x] Link public API, state equality/scope, and effective-enabled behavior - match the binding contract without router, styling, or visited state. -- [x] Primary/canceled/secondary pointer, Enter/Numpad/Space, semantic tap, - feedback, cursor, callbacks, and dynamic removal pass focused tests. +- [x] Link public API, required URI state equality/scope, `enabled`-only + availability, resolver ownership, and observational activation match + D-17 without router, styling, or visited state. +- [x] Ordinary primary/Enter/Numpad/semantic activation, browser-owned + auxiliary paths, feedback, cursor, callbacks, and dynamic disable pass + focused tests. - [x] Link/URL/name/hint/enabled/focus/action semantics are exact; Button and duplicate naming are absent; disabled/excluded behavior passes. - [x] Focus-node ownership/replacement/disposal and aggregate teardown pass. - [x] Canonical fixture, stable result/reset/readout, Arabic RTL, 200% text, external-icon exclusion, golden, and accessibility guidelines pass. -- [ ] Correction integration, inventory, fast aggregate, and real macOS - aggregate pass locally; exact-head hosted API 34 and pinned-web reruns - remain pending publication. +- [ ] Correction integration, inventory, fast aggregate, focused real-macOS, + exact Flutter 3.41.0, and pinned Chrome 150 pass locally. The full macOS + aggregate is host-blocked; exact-head hosted macOS and API 34 remain + pending publication. - [x] All seven screenshot names have reviewed evidence, or Phase 2 is explicitly blocked; unsupported web screenshots are not marked passed. - [ ] VoiceOver, TalkBack, Chrome accessibility-tree, and release-level iOS @@ -542,7 +584,8 @@ All seven checks passed for head `24460f0` / merge ref `09e62c8`: - [ ] Real Context Menu and Hover Card composition proof is attached after those components exist; placeholder wrappers do not satisfy it. - [ ] Full local publication commands and Flutter 3.41.0 pass; hosted - correction-head verification remains pending publication. + correction-head macOS and Android verification remains pending + publication. - [x] Docs, changelog, compatibility statement, traceability, manifests, visual review, and ten-item handoff packet are ready. - [ ] Entire correction diff is locally reviewed and committed; PR #65 still @@ -550,8 +593,9 @@ All seven checks passed for head `24460f0` / merge ref `09e62c8`: without explicit maintainer authorization. Block Phase 2 closure (not independent program work) if Link maps as a Button, -Space activates or is swallowed on web, disabled paths retain activation or -focus, linkUrl lacks the required Link flag/href mapping, accessible naming is -duplicated, focus ownership leaks, any required target is retry-dependent, the -real composition scenario is unavailable, required web screenshots remain -unsupported, or manual AT evidence is unavailable. +Space activates or is swallowed on web, ordinary default activation opens a new +tab or navigates twice, an auxiliary browser action invokes the observer or +resolver, disabled paths retain activation, focus, or anchors, accessible +naming is duplicated, focus ownership leaks, any required target is +retry-dependent, the real composition scenario is unavailable, required web +screenshots remain unsupported, or manual AT evidence is unavailable. From 52d9c97ebbdc242cd8a9c3bd8d588a3ed177dd10 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 16:50:11 -0400 Subject: [PATCH 15/18] chore: remove branch planning artifacts --- plan/README.md | 151 -- plan/briefing.md | 2708 -------------------------------- plan/decisions.md | 119 -- plan/integration-testing.md | 229 --- plan/phases/00-test-harness.md | 244 --- plan/phases/02-link.md | 601 ------- plan/process.md | 127 -- 7 files changed, 4179 deletions(-) delete mode 100644 plan/README.md delete mode 100644 plan/briefing.md delete mode 100644 plan/decisions.md delete mode 100644 plan/integration-testing.md delete mode 100644 plan/phases/00-test-harness.md delete mode 100644 plan/phases/02-link.md delete mode 100644 plan/process.md diff --git a/plan/README.md b/plan/README.md deleted file mode 100644 index a0c08eb1..00000000 --- a/plan/README.md +++ /dev/null @@ -1,151 +0,0 @@ -# Naked UI component expansion — plan - -This folder is the working plan for delivering the eight new/expanded headless -primitives (and the test-harness hardening that must precede them) described in -the engineering briefing handed off from the Remix team. - -Consumer: **Remix for Flutter**. Boundary rule: Naked UI owns behavior, focus, -keyboard, overlays, timers, and semantics — never styling, product copy, or -business rules ([briefing §5](briefing.md#5-definition-of-the-headless-boundary)). - -## Folder contents - -| File | Role | Mutability | -|---|---|---| -| [briefing.md](briefing.md) | Full handoff contract (per-component behavior, semantics, tests, evidence) | **Frozen** — reference only | -| [process.md](process.md) | The repeatable per-component workflow and PR gates | Stable | -| [integration-testing.md](integration-testing.md) | Mandatory runner, determinism, evidence, and failure-triage playbook | Stable | -| [decisions.md](decisions.md) | Decision log D-01…D-17 and escalation rule | **Living** — update as decisions resolve | -| README.md (this file) | Index and status board | **Living** — update every phase PR | -| [phases/](phases/) using the NN-name.md convention | Executable plan for one phase | Created just-in-time when a phase starts | - -## Status board - -Phase numbers follow the briefing's PR order ([§7](briefing.md#7-delivery-sequence-and-pull-request-boundaries)). -A phase plan file is created from the briefing contract when the phase starts — -do not pre-write plans for phases whose blocking decisions are unresolved. - -| Phase | Scope | Contract | Blocking decisions | Plan | Status | -|---:|---|---|---|---|---| -| 0 | Test-harness hardening | [§6.2](briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite), [§21](briefing.md#21-integration-screenshot-golden-and-ci-implementation) | D-12, D-13, D-14, D-15 (resolved) | [phases/00-test-harness.md](phases/00-test-harness.md) | **Closed** — delivered by [PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged as `58a48a3` | -| 1 | Alert Dialog (extend `NakedDialog`) | [§13](briefing.md#13-component-contract-alert-dialog) | D-02 (resolved) | — | Tracked independently in ready-for-review PR #64; closure blockers are recorded there | -| 2 | Link | [§20](briefing.md#20-component-contract-link) + D-17 hard cut | D-17 (resolved) | [phases/02-link.md](phases/02-link.md) | Resolver/observer hard cut is implemented and passes the required local Flutter/widget gates; exact-head hosted verification, required web screenshots, manual AT, and Phase 5/7 composition evidence remain pending | -| 3 | Field + `NakedTextField` integration | [§17](briefing.md#17-component-contract-field) | D-08, D-09 | — | Not started | -| 4 | Toggle Group expansion | [§14](briefing.md#14-component-contract-toggle-group) | D-01 | — | Not started | -| 5 | Context Menu | [§15](briefing.md#15-component-contract-context-menu) | D-03 | — | Not started | -| 6 | Toast | [§16](briefing.md#16-component-contract-toast) | D-04, D-05, D-06, D-07 | — | Not started | -| 7 | Hover Card | [§19](briefing.md#19-component-contract-hover-card--preview-card) | — | — | Not started | -| 8 | Combobox | [§18](briefing.md#18-component-contract-combobox) | D-10, D-11 + **accessibility spike ([§18.3](briefing.md#183-accessibility-spike-required-before-final-api))** | — | Blocked on spike | - -Release grouping ([§23.2](briefing.md#232-recommended-release-grouping)): -prerelease 1 = phases 1–3, prerelease 2 = phases 4–5, prerelease 3 = phases 6–7, -Combobox ships alone after its spike passes. A blocked phase does not block -independent phases. - -## Program readiness - -Phase 0 made the harness trustworthy enough to begin component work. The -program is **ready to start targeted planning**, but it is not research-complete -for every phase and no phase may bypass its open decisions, spikes, or evidence -gates. - -| Phase | Start readiness | Required work before implementation | -|---:|---|---| -| 1 — Alert Dialog | **Ready for a just-in-time phase plan** | Re-verify the current `NakedDialog` baseline, carry the resolved D-02 focus contract into tests/examples, then create the phase plan. | -| 2 — Link | **Implemented; external gates pending** | D-17 supersedes D-16's callback override. Local exact Flutter 3.41.0 and matched Chrome/ChromeDriver 150 pass; rerun exact-head hosted macOS/Android, then complete the outstanding manual and composition evidence. | -| 3 — Field | Blocked on decisions | Resolve D-08 metadata precedence and D-09 initial-error announcement policy before implementation/semantics tests. | -| 4 — Toggle Group | Blocked on compatibility decision | Resolve D-01 and document the consumer-facing `selected` to `toggled` announcement migration. | -| 5 — Context Menu | Spike/decision required | Resolve D-03 with a trigger-role/semantic-long-press prototype and real VoiceOver/TalkBack results. | -| 6 — Toast | Blocked on scope/API decisions | Resolve D-04–D-07 before tests or controller implementation; timer, queue, focus, and announcement contracts then become the phase plan. | -| 7 — Hover Card | Contract ready, dependency pending | Land Link first, then create the phase plan and verify reusable overlay positioning plus pointer-grace geometry. | -| 8 — Combobox | **Blocked on required accessibility spike** | Land Field, run the macOS/Android/web spike, then resolve D-10/D-11 before freezing the API. | - -Shared closure gates for every component: - -- Follow [integration-testing.md](integration-testing.md) and include its - per-phase checklist in the executable phase plan. -- Re-evaluate or explicitly resolve the Flutter 3.41.2 web-screenshot - limitation; a web behavior log is not a silent substitute for a required - screenshot. -- Schedule and record the required VoiceOver, TalkBack, Chrome accessibility - tree, and release-level iOS checks. Automated semantics alone are not enough. -- Re-verify the phase's current-code baseline when its just-in-time plan is - created; the original `0ca0b8b` audit is historical evidence, not a permanent - assumption. - -Recommended next move: keep Phase 1 and Phase 2 unmerged while their recorded -closure evidence is unavailable, then research D-08 and D-09 and obtain -explicit maintainer approval before beginning Phase 3 Field implementation. - -## How to work a phase - -1. Resolve the phase's blocking decisions in [decisions.md](decisions.md) first - — nothing is decided silently inside an implementation PR. -2. Create an NN-name.md plan under [phases/](phases/) by deriving tasks from - the phase's briefing contract section (see the template at the end of - [process.md](process.md)). -3. Follow the workflow in [process.md](process.md) (contract review → failing - tests → implementation → fixture → platform proof → evidence packet). -4. In the phase's final PR, update this status board and any resolved rows in - decisions.md. - -## Verification record - -The briefing's factual claims were checked against this repository at commit -`0ca0b8b` on 2026-07-12 before this plan was created. All 16 checked claims -held, including: package `1.0.0-beta.3` with Flutter `>=3.41.0` floor vs CI pin -`3.41.2`; the "macOS integration" job running `-d flutter-tester` -(`.github/workflows/integration-tests.yml:46`); Android integration being -`workflow_dispatch`-only; no web integration workflow; zero usages of -`takeScreenshot`/`matchesGoldenFile`/`meetsGuideline`; `testKeyboardActivation` -catch-and-return-false and `verifyTabOrder` asserting only widget existence -(`packages/example/integration_test/helpers/keyboard_test_helpers.dart`); the -stale `example/` path in `tool/run_integration_all.sh:9`; the Tooltip -integration file missing from `all_tests.dart`; the 30-minute blanket timeout -and 2-second real `tearDownAll` delay in `all_tests.dart`; 498 widget tests and -89 integration `testWidgets`; the advisory 7.3%-vs-80% coverage gate -(`.github/workflows/ci.yml:79`); and no committed `macos/`/`web/`/`android/` -platform directories under `packages/example`. - -Phase 0 hosted evidence was completed for PR #63 at head `3cb5487`, then -squash-merged to `main` as `58a48a3`. The main test suite, exact Flutter 3.41.0 -job, aggregate `flutter-tester`, real macOS, pinned headless Chrome, API 34 -Android emulator, and PR-title check all passed. The workflows triggered by the -merged commit also passed. Android behavior runs through `flutter test`; -`flutter drive` remains only for screenshot/report-data transport and web -integration. - -If the repo has moved past `0ca0b8b`, re-verify a claim before building on it. - -### Phase 0 closure (2026-07-12) - -All A1–A6, B1–B6, and C1–C3 implementation tasks landed in PR #63 after -maintainer approval of D-12–D-15. The inventory guard, keyboard postcondition, -tab-order assertion, golden 1px mutation, and unnamed-target accessibility -failure were each proven to fail for the intended reason; all temporary -mutations were restored. Phase 0 is closed. - -Local verification passed on Flutter 3.41.0 and 3.41.2, real macOS, and matched -Chrome/ChromeDriver 149.0.7827.201; hosted web verification used the pinned -150.0.7871.115 pair. The inspected Dialog screenshot is an 800×600 real-macOS -PNG with a complete manifest. Flutter 3.41.2 web screenshots remain explicitly -unsupported after both the WebDriver path and a repaint-boundary fallback -proved unstable; the blocking web behavior gate uploads its test log instead. -No Android emulator/AVD is installed locally, so the hosted API 34 job is the -authoritative Android proof. - -Verification evidence: - -- Flutter 3.41.0: analyze clean; 574 package tests passed with 3 documented - skips. -- Flutter 3.41.2: format and analyze clean; 574 package tests passed with 3 - documented skips; 13 example tests passed with 1 documented golden skip. -- Integration: the sequential component runner completed; the aggregate passed - 88 tests with 1 documented Tooltip skip on `flutter-tester`, real macOS, and - pinned Chrome. -- Evidence: the approved golden update/verify sequence passed with an unchanged - SHA-256 baseline; macOS screenshot/manifest generation passed and the image - was visually inspected. -- CI definitions: every workflow parses as YAML and actionlint reports no - findings; the pinned Chrome action tag, inputs, and outputs were verified - against its upstream action definition. diff --git a/plan/briefing.md b/plan/briefing.md deleted file mode 100644 index 8756d968..00000000 --- a/plan/briefing.md +++ /dev/null @@ -1,2708 +0,0 @@ - - -# Naked UI component expansion: engineering briefing and handoff - -**Status:** Proposed implementation contract and delivery package -**Audience:** Naked UI maintainers, Remix maintainers, accessibility reviewers, and QA -**Prepared:** 2026-07-11 -**Primary consumer:** Remix for Flutter - -## 1. Purpose - -This document defines the work Naked UI should complete before Remix adds the next high-value headless primitives. It is intentionally more specific than a component wish list. It describes: - -- the behavior Naked UI must own; -- the behavior that must remain in the styled Remix layer; -- proposed public APIs and compatibility constraints; -- the semantics, focus, keyboard, pointer, and localization contract; -- widget, semantics, integration, accessibility-guideline, golden, screenshot, and manual assistive-technology evidence; -- a component-by-component acceptance checklist; -- the CI and handoff package required before Remix consumes a release. - -The goal is not merely to make eight widgets render. The goal is to make them dependable primitives that another design system can style without having to repair interaction or accessibility behavior. - -## 2. Verification basis - -The findings and proposals below were checked against these concrete baselines on 2026-07-11. - -| Surface | Verified baseline | Why it matters | -|---|---|---| -| Remix workspace | Local HEAD 250178041; target baseline origin/chore/1.0-release@be5c17f3a | Establishes the consumer code inspected for this handoff. | -| Remix dependency | packages/remix/pubspec.yaml declares naked_ui: ^0.2.0-beta.7 | Remix is not yet consuming the current Naked UI main API. | -| Remix SDK contract | Dart >=3.12.0, Flutter >=3.44.0; workspace .fvmrc is Flutter 3.44.0 | Consumer verification must use its configured SDK even if Naked UI supports an older minimum. | -| Naked UI upstream | btwld/naked_ui@0ca0b8bc2269ed331345cc705d99a073acdf5f5f | Pins all upstream code and CI observations in this document. | -| Naked UI package | 1.0.0-beta.3, Dart >=3.9.0, Flutter >=3.41.0 | New APIs should remain compatible with the package minimum unless the maintainers explicitly raise it. | -| Naked UI CI SDK | Flutter 3.41.2 | This is the current pinned CI environment. It does not by itself prove the declared Flutter 3.41.0 minimum. | -| Local Flutter used for API inspection | Flutter 3.41.2 / Dart 3.11.0 | Confirms the named semantics APIs exist at the current Naked UI CI version. | - -External behavior was cross-checked with Flutter's official accessibility, focus, semantics, golden, and integration-test documentation; W3C WAI-ARIA Authoring Practices and WCAG 2.2 guidance; and the documented behavior of Radix and Base UI primitives. The source register is in section 25. - -### 2.1 Confirmed facts versus proposals - -To avoid presenting design choices as existing behavior: - -- **Confirmed** means observed in the pinned Remix or Naked UI source, its tests, its CI, the Flutter 3.41.2 SDK, or linked official documentation. -- **Proposed** means the recommended contract for the Naked UI team. Public names can be adjusted during API review, but the observable behavior and acceptance gates should not silently change. -- **Open decision** means the team must explicitly resolve the item before the relevant implementation PR is approved. - -## 3. Executive decision - -Naked UI should implement or expand these eight items: - -1. Alert Dialog support by extending NakedDialog. -2. A context-menu trigger that reuses Naked menu items and overlay infrastructure. -3. A complete Toggle Group with roving focus, orientation, looping, RTL, and multiple selection. -4. Toast controller, viewport, item lifecycle, announcement semantics, and focus behavior. -5. Field composition and validation semantics around controls such as NakedTextField. -6. An editable Combobox, including single and multiple selection foundations. -7. A non-interactive Hover Card / Preview Card. -8. A Link primitive with correct link semantics and keyboard behavior. - -These are behavior primitives, not Remix visual components. Naked UI should not absorb Remix tokens, Mix styles, colors, typography, radii, shadows, padding, animation curves chosen for a theme, or product copy. - -### 3.1 Items Remix can build without new Naked UI primitives - -The following nearby Remix gaps should not be added to Naked UI as new primitives: - -| Remix item | Naked UI dependency decision | -|---|---| -| Popover | Current Naked UI main already has NakedPopover. Remix should upgrade and compose it. | -| Basic single-select segmented control | Current main already has NakedToggleGroup and NakedToggleOption. Remix can prototype with them, but production parity needs the enhancements in section 14. | -| Drawer / Sheet | Compose NakedDialog with Remix-owned placement and motion. Only generic dialog behavior belongs in Naked UI. | -| Skeleton | Purely visual loading placeholder. Remix owns it. Decorative skeletons should normally be excluded from semantics while nearby content exposes the loading state. | -| Basic Scroll Area | Use Flutter scrolling primitives. Add Naked UI behavior only if a later scope requires custom scrollbar interaction or a cross-design-system scroll contract. | - -### 3.2 Naked UI work map - -Complexity is relative and is not a calendar estimate. - -| Item | Work type | Remix need | Priority | Relative complexity | Primary dependency/risk | -|---|---|---|---|---|---| -| Alert Dialog | Extend Dialog | Safe destructive/urgent confirmations | P0 | Small | Semantic role, initial focus, nondismissible default | -| Link | New primitive | Foundational inline navigation and Hover Card trigger | P0 | Small | Correct link rather than button behavior | -| Field | New composition + TextField integration | Consistent labels, descriptions, required/invalid/error behavior | P0 | Large | Avoiding duplicate semantics and preserving TextField compatibility | -| Toggle Group | Expand existing group | Segmented/content-switcher and grouped formatting controls | P0 | Large | Roving focus, RTL, single/multiple semantics compatibility | -| Context Menu | New trigger over Menu infrastructure | Pointer/touch/keyboard contextual actions | P1 | Large | Point anchoring, non-button trigger semantics, focus restoration | -| Toast | New controller/viewport/item family | Transient status and safe-action feedback | P1 | Extra large | Queue/timers, focus pause, one-time announcements | -| Hover Card | New preview overlay | Rich visual link previews | P1 | Large | Pointer grace, WCAG hover/focus behavior, semantics exclusion | -| Combobox | New editable composite | Searchable single/multiple selection | P2 until spike passes | Extra extra large | Flutter role mapping, active-option announcements, IME/editing integrity | - -P0 means a foundational primitive or prerequisite for other work. P1 means high-value follow-on work. Combobox is P2 only because its platform accessibility uncertainty should be resolved before implementation scale grows; its product value remains high. - -## 4. Why this work belongs in Naked UI - -The eight proposed items contain reusable interaction rules that are easy for styled libraries to implement inconsistently: - -- focus entry, containment, restoration, and traversal; -- keyboard activation and composite-widget navigation; -- pointer and touch gesture normalization; -- overlay positioning, collision handling, and dismissal; -- enabled, selected, highlighted, open, invalid, and timed lifecycle state; -- screen-reader role, name, value, state, action, and announcement behavior; -- deterministic timers and app-lifecycle pausing; -- platform and text-direction differences. - -If each Remix theme reimplements these rules, semantic and keyboard behavior can drift while screenshots still look correct. Naked UI should centralize those rules and expose observable state so Remix can remain responsible for presentation. - -## 5. Definition of the headless boundary - -### 5.1 Naked UI owns - -- Controlled and uncontrolled behavior only where the API explicitly promises it. -- Focus nodes it creates internally, including disposal and debug labels. -- Focus entry, traversal, escape behavior, and restoration. -- Shortcuts, intents, actions, pointer gestures, and touch gestures. -- Overlay open/close lifecycle and collision-safe anchors. -- Semantics nodes, roles/flags, values, actions, grouping, and announcement priority. -- Enabled-state propagation and suppression of actions when disabled. -- Interaction snapshots exposed to builders and NakedStateScope. -- Timer state, pause/resume behavior, and deterministic disposal. -- Directionality-sensitive keyboard behavior. -- Public documentation of every observable invariant. - -### 5.2 Remix or another styled consumer owns - -- Color, typography, spacing, size, border, radius, elevation, shadow, and icon choices. -- The visible focus-ring design, while Naked UI exposes focused state. -- Product language and localized user-facing strings. -- Navigation implementation and URL launching. -- Filtering algorithms, remote search, data fetching, caching, and business rules. -- Business validation rules and when a form chooses to reveal an error. -- Responsive layout and component-specific animation styling. -- The final visual target sizes and contrast ratios. - -### 5.3 Canonical example app owns test presentation - -Naked UI's example application should provide one deterministic reference style for every primitive. That style is test infrastructure, not a package default. It exists so real-device integration tests, accessibility guidelines, goldens, and screenshot review have a stable visible surface. - -This distinction is essential: - -- core Naked UI tests prove semantics and behavior; -- canonical example tests prove a representative styled integration; -- Remix tests prove Remix's actual styles; -- screenshots never substitute for a semantics assertion or screen-reader check. - -## 6. Current upstream strengths and gaps - -### 6.1 Confirmed strengths - -At the pinned upstream commit: - -- Naked UI has 498 widget tests under packages/naked_ui/test. -- It has dedicated semantics tests for all 13 currently integrated components plus semantics utilities. -- Semantics tests use tester.ensureSemantics(), inspect node data, and in several cases compare behavior with Flutter Material widgets. -- There are 89 example integration testWidgets cases across component files. -- Existing components follow a useful builder-first pattern and expose immutable state snapshots. -- Dialog, menu, popover, select, tabs, text field, tooltip, and other primitives already provide reusable foundations for this work. - -### 6.2 Confirmed delivery gaps to fix before adding the new suite - -| Gap | Evidence at the pinned commit | Required correction | -|---|---|---| -| “macOS integration” does not exercise macOS | The workflow runs on a macOS runner but invokes -d flutter-tester. | Add or generate macOS platform files and run -d macos. Keep a separate flutter-tester smoke job if useful. | -| Android is not a PR gate | Android integration is workflow_dispatch only. | Run affected component tests on PRs or at minimum nightly, with release branches gated on a passing run. | -| No web integration gate | No web integration workflow exists. | Add Chrome/ChromeDriver coverage for semantics DOM and keyboard-critical flows. | -| No screenshot evidence | No takeScreenshot usage was found. | Capture named screenshots for every required component state and upload artifacts. | -| No golden coverage | No matchesGoldenFile usage was found. | Add pinned, deterministic example goldens for layout/state regression. | -| No accessibility-guideline coverage | No meetsGuideline calls were found. | Run label, tap-target, and contrast checks on the canonical styled examples. | -| Keyboard helper can hide failure | testKeyboardActivation catches exceptions and returns false; callers can ignore the result. | Remove catch-and-continue behavior. A failed key event or unmet postcondition must fail the test. | -| Tab-order helper does not prove focus | verifyTabOrder verifies only that widgets exist after advancing focus. | Assert FocusManager.instance.primaryFocus or each managed node's hasFocus after every step. | -| Stale runner path | tool/run_integration_all.sh enters example/, while the app lives at packages/example/. | Repair the runner and add a CI smoke invocation so path drift fails early. | -| Broad settling can hang or conceal timing errors | Existing integration tests use pumpAndSettle() extensively. | Use targeted pumps and observable postconditions for timers and continuing animations, especially Toast and Hover Card. | -| Coverage threshold is advisory | The 80% coverage report has continue-on-error and documents a much lower current value. | Do not use the displayed threshold as proof. Add component-level acceptance coverage and make the agreed gate blocking. | -| Declared minimum is not tested exactly | The package allows Flutter 3.41.0 while CI pins 3.41.2. | Add an exact-minimum analysis/unit/semantics job or raise the package minimum deliberately. | - -The infrastructure corrections should be delivered first or in the first component PR. Otherwise the new work could appear green without proving the behavior this handoff requires. - -## 7. Delivery sequence and pull-request boundaries - -Use small, reviewable PRs with one behavior contract at a time. Recommended order: - -| Order | PR | Dependency or reason | -|---:|---|---| -| 0 | Test-harness hardening | Prevents false-positive keyboard and platform integration results for all later work. | -| 1 | Alert Dialog role and helper | Small additive extension that validates the new semantics process. | -| 2 | Naked Link | Small standalone primitive and a dependency for Hover Card examples. | -| 3 | Naked Field plus NakedTextField integration | Establishes validation semantics used by Combobox. | -| 4 | Toggle Group expansion | Exercises composite focus and RTL without an overlay. | -| 5 | Context Menu | Reuses menu item and overlay infrastructure; may require an internal menu-scope refactor. | -| 6 | Toast | Introduces deterministic timers, status announcements, and screenshot lifecycle coverage. | -| 7 | Hover Card | Reuses Link and overlay timing foundations. | -| 8 | Combobox | Highest-risk composite; builds on Field, TextField, overlay, option, and focus patterns. | - -Each PR should remain releasable. Internal refactors must preserve current public behavior and keep the existing suite green. - -## 8. Required implementation process - -The following process applies to every component PR. - -### Phase A — contract and threat-model review - -1. Copy the relevant component section from this document into the issue or PR. -2. Confirm the public API, controlled state, ownership, disabled behavior, and localization inputs. -3. Write the component semantics matrix before implementation. -4. List every input path: mouse, touch, stylus if relevant, keyboard, switch access/semantics action, and programmatic controller. -5. List focus entry, internal traversal, dismissal, and restoration behavior. -6. Identify continuous animation or timer behavior that makes pumpAndSettle() unsafe. -7. Record any Flutter engine limitation instead of hiding it behind a passing widget test. - -### Phase B — write failing tests - -Add failing tests in this order: - -1. Constructor assertions and controlled-state invariants. -2. Builder snapshot and NakedStateScope behavior. -3. Pointer/touch activation and disabled behavior. -4. Keyboard and focus behavior. -5. Semantics role, name, state, value, actions, grouping, and disabled behavior. -6. Overlay collision and dismissal where applicable. -7. Timer and lifecycle behavior where applicable. -8. Regression cases for disposal, rebuild, and dynamic child changes. - -The test must fail for the intended missing behavior, not for a harness or fixture error. - -### Phase C — implement the smallest behavior surface - -- Follow existing NakedState, builder, scope, and effective-enabled conventions. -- Prefer Flutter Shortcuts, Actions, Focus, and traversal policies over raw key handlers. -- Use long-lived focus nodes in State objects; dispose only nodes the widget owns. -- Make delays and durations injectable. -- Do not hard-code English semantics labels, shortcut labels, or dismissal text. -- Keep styles out of the package implementation. - -### Phase D — add a deterministic example fixture - -Every fixture must: - -- use stable ValueKey identifiers for triggers, controls, options, actions, status labels, and observable state output; -- use local data and no network; -- expose a visible state readout where useful; -- allow animations to be disabled or given fixed durations; -- render inside a fixed, documented viewport for goldens; -- include RTL and large-text variants when required; -- have reset behavior so tests do not depend on execution order. - -### Phase E — prove integration and visuals - -1. Run the component test on flutter-tester for fast feedback. -2. Run on a real macOS target. -3. Run the mobile-relevant paths on Android. -4. Run keyboard and semantics-DOM paths on web. -5. Capture all screenshots listed in the component section. -6. Compare the pinned widget goldens. -7. Run accessibility guidelines against the canonical example. -8. Perform the manual assistive-technology checks. - -### Phase F — prepare the handoff packet - -The PR cannot be handed to Remix with only a green checkmark. It must include the evidence package in section 22. - -## 9. Cross-component API conventions - -### 9.1 Builder and child contract - -- Follow the current Naked UI invariant that either child or builder is present when a visual surface is required. -- A builder receives an immutable state snapshot and the optional child. -- The same state snapshot must be available through NakedStateScope inside the built subtree. -- State equality and hashCode must include every public observable field. -- Rebuilding with an equivalent state must not create timer, focus, or overlay churn. - -### 9.2 Controlled state - -- Selection, query, validity, and open state must be explicitly documented as controlled or controller-owned. -- A callback being null must never mutate an allegedly controlled value. -- Callbacks fire once per accepted user action and do not fire for disabled items, repeated selection when no change occurs, or a canceled gesture. -- Multiple-selection callbacks return an immutable snapshot rather than a mutable set retained internally. - -### 9.3 Controller ownership - -- External controllers and focus nodes are never disposed by Naked UI. -- Internal controllers, timers, listeners, and focus nodes are always disposed. -- Replacing an external controller detaches all listeners from the old controller. -- Controllers reject or safely ignore calls after disposal according to a documented policy. -- Every internally created focus node has a useful debugLabel. - -### 9.4 Effective enabled state - -Compute effective enabled state from both the explicit enabled flag and the presence of the callback/controller capability required for activation. When disabled: - -- no pointer, keyboard, or semantic action changes state; -- no feedback is emitted; -- the node is removed from normal focus traversal; -- semantics expose disabled state when the control remains discoverable; -- the mouse cursor is not an activation cursor; -- descendants cannot accidentally reactivate behavior. - -### 9.5 Localization - -Naked UI may accept semantic labels and hints but must not ship English defaults for user-facing phrases such as “Open context menu,” “Notification,” “Dismiss,” “Required,” or “Invalid.” Prefer one of: - -- a required caller-provided string; -- an existing Flutter localization string with the correct meaning; -- a localization delegate introduced deliberately for Naked UI. - -Tests must use at least one non-English label and an RTL Directionality fixture to catch assumptions. - -### 9.6 Stable identifiers - -Public semantics identifiers should be optional and documented if exposed. Test keys belong to the example fixture, not the package API. Do not make production behavior depend on a test-only key. - -### 9.7 Reduced motion - -Naked UI should expose behavior state and timing hooks; it should not force visual animation. Canonical examples and Remix must honor MediaQuery.disableAnimations. Timed behavior such as Toast dismissal remains functional when visual transitions are disabled. - -## 10. Universal semantics contract - -Every component must document and test all fields below. - -| Dimension | Required question | -|---|---| -| Primitive | What user-recognizable control or region is this? | -| Accessible name | Where does the name come from, and what happens if it is absent? | -| Role or flags | Which Flutter role and flags are exposed? | -| State/value | Which open, selected, toggled, required, invalid, expanded, or value fields change? | -| Actions | Which semantic actions exist while enabled, and which disappear while disabled? | -| Focus | Is it focusable, where does focus enter, and where is it restored? | -| Traversal | Is it one tab stop or several, and what is the internal order? | -| Grouping/relations | Which nodes are containers, explicit children, or controlled content? | -| Disabled behavior | Is it discoverable, focusable, actionable, and announced as disabled? | -| Localization | Which labels/hints are caller supplied? | -| Flutter mapping | Which Semantics properties implement the contract? | -| Automated proof | Which exact node properties and transitions are asserted? | -| Manual proof | What must VoiceOver, TalkBack, and web assistive technology announce? | - -### 10.1 Aggregate matrix - -| Component | Primary semantics mapping | Name/value/state | Actions and focus | -|---|---|---|---| -| Alert Dialog | role: SemanticsRole.alertDialog, container, explicit children, route scope/name, blocked background | Visible title or caller semanticLabel; message remains readable | Focus enters the specified safe target, loops inside, and returns to invoker; no implicit dismissal when nondismissible | -| Context Menu | Trigger retains its native semantics; popup role: menu; initial-scope action items use menuItem | Trigger label only if supplied; item labels from visible content or explicit label | Secondary tap, long press, keyboard menu shortcut, item tap; focus moves into menu and restores on close | -| Toggle Group | Group semantics container; options are toggle buttons | Each option has a name and toggled state; group can have a label | One tab stop; arrows move roving focus; Enter/Space toggle; disabled options have no action | -| Toast | role: status for normal messages or role: alert for urgent messages | One concise announcement string; visual action remains a separate accessible control | No focus steal; optional user-invoked viewport focus; dismiss/action controls work normally | -| Field | Actual control carries text-field/control semantics, isRequired, and validationResult; error announcement uses role: alert only on a new visible error | Label, current value, description, and current error are associated with the control without duplicate speech | Label requests control focus; disabled/read-only distinctions remain accurate | -| Combobox | Editable text-field semantics plus SemanticsRole.comboBox where verified; expanded and controlsNodes; popup list and selected options | Name, query/value, expanded state, highlighted option, selection | Input stays focused; arrows highlight; Enter accepts; Escape closes; popup is outside page Tab sequence | -| Hover Card | Trigger keeps its original Link semantics; preview overlay is excluded from semantics by default | No duplicate accessible name; preview contains no unique essential information | Hover/focus opens; Escape dismisses; preview does not enter Tab order or steal focus | -| Link | link: true, optional linkUrl, enabled state, tap action | Visible text or explicit label; optional hint such as opening a new window is caller-localized | Enter and semantic tap activate; Space is not intercepted; disabled links are not focusable/actionable | - -### 10.2 Important Flutter 3.41.2 caveat - -The Flutter 3.41.2 SDK contains SemanticsRole.alertDialog, comboBox, status, and alert; it also contains linkUrl, controlsNodes, isRequired, and SemanticsValidationResult. - -However, enum presence is not proof of complete platform mapping. In Flutter 3.41.2, the framework's debug role checker marks SemanticsRole.comboBox as not yet implemented, and the web engine falls back to property-derived behavior for that role. The Flutter source points to [flutter/flutter#159741](https://github.com/flutter/flutter/issues/159741). Therefore: - -- include the role only after a focused prototype on every supported target; -- assert the text-field, expanded, value, action, and controlled-node properties independently; -- inspect the actual web accessibility tree; -- record real VoiceOver and TalkBack output; -- do not describe Combobox accessibility as complete based only on a widget semantics-tree assertion. - -For SemanticsRole.status and SemanticsRole.alert, Flutter's role checker expects the role to supply live-region meaning. Do not also set liveRegion: true on the same node. Test that no debug semantics exception occurs. - -## 11. Universal keyboard and focus rules - -### 11.1 Test actual outcomes - -A keyboard test is valid only if it proves the result of the key: - -- the expected node has primary focus; -- a value changed exactly once; -- an overlay opened or closed; -- the correct item became highlighted; -- focus returned to the correct invoker. - -Sending a key without asserting the outcome is not coverage. - -### 11.2 Use Flutter's focus system - -- Prefer Shortcuts and Actions with component-specific intents. -- Use FocusTraversalGroup for an intentional composite boundary. -- Keep nodes long-lived and dispose owned nodes. -- Account for Flutter focus changes applying after a frame; pump once before asserting. -- Never create a new FocusNode in build. -- Do not intercept standard text-editing keys in editable controls. -- Test both logical keyboard keys and the resulting state, not raw platform key codes. - -### 11.3 Directionality - -For horizontal composites: - -- in LTR, Right moves to the next logical item and Left to the previous; -- in RTL, the visual-direction behavior must be explicitly decided and tested; the recommendation is Right moves visually right and Left visually left; -- Up/Down behavior follows component orientation; -- disabled items are skipped; -- Home and End move to first and last enabled items; -- wrapping occurs only when loop is true. - -### 11.4 Focus restoration - -Overlay components retain an invoker reference at open time. On close: - -- keyboard-opened overlays restore focus to that invoker; -- focus is not restored to a disposed or no-longer-focusable node; -- replacing or removing the trigger while open must not throw; -- nested overlays restore to the immediate parent invoker, not an unrelated earlier node. - -## 12. Verification architecture - -No single test layer is sufficient. Each layer below answers a different question and is required unless the component-specific section explicitly marks it not applicable. - -### 12.1 Required test layers - -| Layer | Location | What it proves | What it does not prove | -|---|---|---|---| -| Logic/widget | packages/naked_ui/test/src | State transitions, callbacks, controller ownership, timers, rebuilds, disposal, gestures | Actual platform accessibility output or final styled appearance | -| Semantics | packages/naked_ui/test/semantics | Flutter semantics-tree role, flags, labels, values, actions, grouping, transitions | What a particular screen reader actually speaks | -| Parity where useful | packages/naked_ui/test/src/parity | Intended equivalence with a Flutter control | Correctness when Flutter has no equivalent or when the desired pattern differs | -| Canonical example widget | packages/example/test | Representative styled layout, accessibility guidelines, deterministic goldens | Every possible consumer style | -| Real integration | packages/example/integration_test/components | End-to-end input, focus, overlay, timer, and route behavior on a target | Pixel stability across every machine | -| Raw screenshot artifact | CI artifact per target | Human-reviewable evidence of actual rendered states and collision handling | Semantics or keyboard correctness | -| Manual assistive technology | PR evidence record | Actual announcements and navigation with VoiceOver/TalkBack/web AT | Automated regression coverage | -| Remix consumer tests | Remix repository | Actual theme styling and package integration | Naked UI's standalone minimum-version compatibility | - -### 12.2 Proposed file names - -Add these source and test files following current project conventions: - -| Component | Source | Widget tests | Semantics tests | Integration | -|---|---|---|---|---| -| Alert Dialog | Extend naked_dialog.dart | Extend naked_dialog_test.dart and parity test | Extend naked_dialog_semantics_test.dart | Extend naked_dialog_integration.dart | -| Context Menu | naked_context_menu.dart | naked_context_menu_test.dart | naked_context_menu_semantics_test.dart | naked_context_menu_integration.dart | -| Toggle Group | Extend naked_toggle.dart | Extend naked_toggle_test.dart | Extend naked_toggle_semantics_test.dart | Extend naked_toggle_integration.dart | -| Toast | naked_toast.dart | naked_toast_test.dart | naked_toast_semantics_test.dart | naked_toast_integration.dart | -| Field | naked_field.dart; integrate naked_textfield.dart | naked_field_test.dart plus text-field regressions | naked_field_semantics_test.dart plus text-field regressions | naked_field_integration.dart | -| Combobox | naked_combobox.dart | naked_combobox_test.dart | naked_combobox_semantics_test.dart | naked_combobox_integration.dart | -| Hover Card | naked_hover_card.dart | naked_hover_card_test.dart | naked_hover_card_semantics_test.dart | naked_hover_card_integration.dart | -| Link | naked_link.dart | naked_link_test.dart | naked_link_semantics_test.dart | naked_link_integration.dart | - -Export every accepted public API from packages/naked_ui/lib/src/naked_widgets.dart. Add the integration test main to packages/example/integration_test/all_tests.dart. A test file that is not included in the aggregate runner is not delivered. - -### 12.3 Semantics test standard - -Every semantics test must: - -1. Call tester.ensureSemantics() and dispose the handle with teardown-safe cleanup. -2. Locate the intended node by semantics properties or a stable fixture finder, not by fragile tree depth. -3. Assert the exact accessible name, role/flags, value/state, and action set relevant to the case. -4. Assert that disabled controls do not expose activation actions. -5. Assert state changes after pointer, keyboard, and direct semantic actions. -6. Assert there is one authoritative control node and no accidental nested duplicate of the same control. -7. Call tester.takeException() after role-sensitive builds and expect no exception. -8. Test excludeSemantics if the component exposes it. -9. Include a non-English name and an RTL fixture where direction or generated text matters. -10. Avoid dumping a whole semantics tree as the only assertion; a string snapshot can stay green while important flags change. - -Use matchesSemantics when exact matching improves clarity, and direct SemanticsData assertions for newer fields such as role, controlled-node identifiers, validation result, or link URL. - -### 12.4 Keyboard test standard - -Do not retain the current catch-and-return-false helper. Replace it with helpers that throw on failure and assert postconditions. A representative helper should: - -1. request focus through a known FocusNode, rather than tapping unless the test is specifically about pointer focus; -2. pump one frame and assert that node is primary focus; -3. send one full logical key event; -4. pump only the duration required by the behavior; -5. assert focus, value, overlay, or callback count; -6. repeat for disabled state and assert no change. - -For composite widgets, assert every step in the traversal, including skipped disabled items, Home/End, looping off, looping on, dynamic removal, and RTL. - -### 12.5 Accessibility-guideline boundary - -Flutter provides automated guidelines for: - -- Android 48-by-48 logical-pixel tap targets; -- iOS 44-by-44 logical-pixel tap targets; -- labels on tappable targets; -- text contrast. - -Run all applicable guidelines on each canonical styled example. Do not run them only against invisible/headless wrappers and call the package accessible. Naked UI cannot guarantee a consumer's target size or color contrast because it does not own size or color. The release claim should be: - -> Naked UI supplies the behavior and semantics contract; the canonical example passes Flutter guidelines; every styled consumer must repeat target-size and contrast checks. - -### 12.6 Golden and screenshot standard - -- Goldens run in a pinned host environment with fixed surface size, device-pixel ratio, locale, text direction, text scale, fonts, and animation state. -- Real-device screenshots are artifacts, not cross-platform pixel goldens. -- Every screenshot has a deterministic name of the form component__scenario__platform__theme.png. -- CI uploads current images and diffs when a golden fails. -- Updating a golden requires a reviewer to inspect the image, not merely run --update-goldens. -- Screenshots must not contain network images, current times, random IDs, blinking cursors, or in-progress indeterminate animations. - -### 12.7 Manual assistive-technology standard - -For each PR, test at least: - -- macOS VoiceOver on the real macOS example; -- Android TalkBack on the emulator or device used for integration; -- Chrome accessibility tree plus keyboard navigation on web. - -Before a release consumed by Remix, also test iOS VoiceOver and one Windows web screen-reader/browser combination when the project claims those targets. Record: - -- device/OS/browser and Flutter version; -- screen reader and version; -- exact action taken; -- actual announcement summarized in the tester's own words; -- expected result; -- pass/fail and linked issue for any deviation. - -Do not include long verbatim screen-reader transcripts when a concise outcome is sufficient. - -## 13. Component contract: Alert Dialog - -### 13.1 Why it is needed - -Remix can display a general dialog today, but a destructive or urgent confirmation has a distinct accessibility role and safer dismissal defaults. WAI-ARIA treats Alert Dialog as a modal dialog containing an alert message whose content and controls must be identified. Flutter 3.41.2 provides SemanticsRole.alertDialog, and Flutter's own Material and Cupertino alert dialogs use it. - -### 13.2 Confirmed reusable foundation - -Current NakedDialog already: - -- wraps content with dialog semantics; -- scopes and names a modal route; -- blocks background semantics; -- uses a closed-loop traversal edge by default; -- supports a barrier, root navigator selection, transitions, request-focus behavior, and focus restoration through routing; -- installs Escape dismissal only when the route is barrier-dismissible. - -The current limitation is that NakedDialog hard-codes SemanticsRole.dialog, while showNakedDialog defaults barrierDismissible to true. - -### 13.3 Proposed public API - -Keep the existing API source-compatible and add: - -~~~dart -const NakedDialog({ - Key? key, - required Widget child, - bool modal = true, - String? semanticLabel, - bool excludeSemantics = false, - SemanticsRole semanticsRole = SemanticsRole.dialog, -}); -~~~ - -Assert that semanticsRole is dialog or alertDialog. A generic arbitrary-role escape hatch would weaken the contract. - -Add a convenience helper: - -~~~dart -Future showNakedAlertDialog({ - required BuildContext context, - required WidgetBuilder builder, - required Color barrierColor, - required String semanticLabel, - String? barrierLabel, - bool barrierDismissible = false, - bool useRootNavigator = true, - RouteSettings? routeSettings, - Offset? anchorPoint, - Duration transitionDuration = const Duration(milliseconds: 400), - RouteTransitionsBuilder? transitionBuilder, - bool requestFocus = true, - FocusNode? initialFocusNode, -}); -~~~ - -The helper wraps the widget returned by builder in one NakedDialog with SemanticsRole.alertDialog, the required localized semanticLabel, and nondismissible defaults. The builder returns the visual dialog contents and must not add a second NakedDialog. Public naming is proposed, but the single role node and default dismissal behavior are required. - -### 13.4 Behavioral contract - -- The background is inert to pointer, keyboard, and semantics navigation while modal. -- Focus moves inside after the route opens. -- If initialFocusNode is provided and remains focusable, it receives focus. -- Otherwise, normal route focus selects the first focusable descendant; the example must demonstrate an explicit safe target. -- For irreversible actions, the canonical example initially focuses the least destructive action. -- For a long or structurally rich message, the consumer may focus a non-action semantic container at the beginning of the message. -- Focus cannot escape with Tab or Shift+Tab. -- Closing returns focus to the invoking control when it still exists and is focusable. -- The alert helper does not close from outside tap or Escape by default. -- A consumer that deliberately enables implicit dismissal must provide a safe equivalent cancel path and test it. -- Nested alert dialogs are discouraged; if allowed, restoration follows route stack order. - -### 13.5 Semantics contract - -| Field | Required behavior | -|---|---| -| Role | SemanticsRole.alertDialog | -| Name | Visible title represented by semanticLabel, or another tested route naming strategy | -| Message | Readable as content after the dialog name; not collapsed into an unusably long control name | -| Modal state | scopesRoute, namesRoute, and BlockSemantics when modal | -| Children | Explicit child nodes so message and actions remain navigable | -| Actions | Dialog container has no fake tap action; buttons own their actions | -| Exclusion | excludeSemantics removes the dialog subtree and is documented as an advanced escape hatch | - -### 13.6 Required widget and semantics tests - -- Existing unnamed NakedDialog still defaults to the normal dialog role. -- semanticsRole: alertDialog exposes exactly that role. -- Any other role fails a debug assertion. -- The alert helper defaults to a nondismissible barrier. -- Outside tap and Escape do not close the default alert helper. -- Explicit action closes with its result. -- Optional dismissible configuration closes exactly once. -- Initial focus enters the supplied safe action after one frame. -- Tab and Shift+Tab loop inside. -- Focus returns to the invoker after every supported close path. -- Removing the invoker before close does not throw. -- Background semantics are blocked while open and restored after close. -- Title, message, Cancel, and destructive action appear as separate useful semantics nodes. -- Large message content can receive initial focus without being exposed as a button. -- The excluded-semantics case has no alert-dialog node. - -### 13.7 Integration and screenshot scenarios - -Stable fixture keys: - -- alert-dialog.open -- alert-dialog.title -- alert-dialog.message -- alert-dialog.cancel -- alert-dialog.confirm -- alert-dialog.result - -Integration scenarios: - -1. Open by keyboard, verify alert role in semantics, verify Cancel focus, Tab loop, cancel, and invoker restoration. -2. Open by pointer, tap barrier, press Escape, and verify still open. -3. Activate destructive action and verify one callback plus visible result. -4. Remove the invoker while open, close programmatically, and verify no exception. -5. Render a long message at 200% text scale and verify content scroll/focus access. - -Required screenshots: - -- alert_dialog__open_safe_focus__macos__reference.png -- alert_dialog__destructive_action__android__reference.png -- alert_dialog__long_message_200_text__macos__reference.png -- alert_dialog__rtl__web__reference.png - -Manual checks must confirm that the dialog is announced as an alert dialog, background content is not reachable, the title/message are discoverable, the initial focus is sensible, and focus returns after close. - -### 13.8 Alert Dialog acceptance - -- [ ] Existing dialog API remains source-compatible. -- [ ] Default normal dialog semantics do not change. -- [ ] Alert role and safe dismissal defaults are covered. -- [ ] Focus containment/restoration pass on macOS and Android. -- [ ] Long text and RTL screenshots are reviewed. -- [ ] VoiceOver and TalkBack evidence is attached. -- [ ] Example and API docs explain when Alert Dialog is appropriate. - -## 14. Component contract: Toggle Group - -### 14.1 Why it is needed - -Remix needs segmented/content-switcher and grouped-toggle behavior. Current Naked UI main has a useful single-select scope, but it does not own composite keyboard traversal, orientation, looping, multiple selection, a group label, or a group focus policy. Its semantics coverage currently verifies rendering but does not assert a complete group contract. - -### 14.2 Confirmed current behavior - -The current unnamed NakedToggleGroup: - -- is controlled by selectedValue and onChanged; -- is effectively disabled if enabled is false or onChanged is null; -- never clears the selected item by activating it again; -- exposes each option as a semantic button with selected state; -- gives each option independent focusability and Enter/Space activation; -- has no orientation, Home/End, arrow-key, loop, RTL, or multiple-selection contract. - -### 14.3 Proposed compatible API - -Preserve the current unnamed constructor and defaults, then add composite behavior: - -~~~dart -const NakedToggleGroup({ - Key? key, - required Widget child, - required T? selectedValue, - ValueChanged? onChanged, - bool enabled = true, - Axis orientation = Axis.horizontal, - bool loop = true, - bool allowEmptySelection = false, - String? semanticLabel, - bool excludeSemantics = false, -}); - -const NakedToggleGroup.multiple({ - Key? key, - required Widget child, - required Set selectedValues, - ValueChanged>? onValuesChanged, - bool enabled = true, - Axis orientation = Axis.horizontal, - bool loop = true, - String? semanticLabel, - bool excludeSemantics = false, -}); -~~~ - -The internal representation may differ, but constructor invariants must prevent single and multiple callbacks/values from being mixed. - -Keep NakedToggleOption source-compatible. Its existing optional focus node, builder, callbacks, semantic label, enabled state, and exclusion behavior remain. - -### 14.4 Selection contract - -Single mode: - -- Activating an unselected option emits that value once. -- Activating the selected option does nothing when allowEmptySelection is false, preserving current behavior. -- Activating the selected option emits null when allowEmptySelection is true. -- The group does not mutate the controlled value before its parent rebuilds. - -Multiple mode: - -- Activating an unselected option emits an immutable set containing it. -- Activating a selected option emits an immutable set without it. -- Input sets are never mutated. -- Equality is based on set content, not identity. -- Disabled options remain present in the selected-values view if supplied by the parent but cannot be changed by the user. - -### 14.5 Composite focus and keyboard contract - -- The group contributes one stop to page Tab order. -- Initial roving target is the most recently focused enabled option, then the selected enabled option in single mode, then the first enabled option. -- Tab enters at the roving target; a second Tab leaves the group. -- Arrow keys move focus without changing selection. -- Horizontal groups use Left/Right; vertical groups use Up/Down. -- Home and End move to first and last enabled options. -- Disabled options are skipped. -- When loop is true, movement wraps; when false, focus stays at the edge. -- Enter and Space toggle the focused option. -- Dynamic insert, remove, disable, or reorder retains a valid roving target without throwing. -- If every option is disabled, the group has no Tab stop. -- RTL horizontal behavior is visual-direction based and explicitly tested. - -### 14.6 Semantics decision - -**Recommendation:** model options as toggle buttons using button: true plus toggled, not as radio buttons. This matches the Radix/Base Toggle Group model and supports both single and multiple selection. A product requiring radio semantics should use NakedRadioGroup. - -Changing current option semantics from selected to toggled can change announcements and is therefore a behavioral compatibility change even though it is not a Dart source break. Resolve it explicitly: - -- preferred: migrate to toggled, document in the changelog, and add an announcement-focused release note; -- fallback: retain selected for the existing constructor and use toggled for a new mode, at the cost of inconsistent semantics. - -Do not expose both selected and toggled on the same option merely to satisfy old tests. - -Group semantics: - -- container with explicit child nodes; -- optional caller-provided label; -- no radioGroup role for toggle-button mode; -- group disabled state propagates to every option; -- each option has one accessible name, toggled state, enabled state, focusability, and tap action only when enabled. - -### 14.7 Required widget and semantics tests - -- All current single-select behavior remains green. -- Single allow-empty false and true paths. -- Multiple add/remove, immutable output, and input non-mutation. -- Null callbacks and disabled group suppress all actions. -- Disabled item suppresses its action without disabling siblings. -- One Tab stop, entry target, exit behavior, arrows, Home, End, loop on/off. -- LTR horizontal, RTL horizontal, and vertical traversal. -- Dynamic addition, reorder, disable, and removal of the current roving item. -- Enter and Space each emit exactly one change. -- Arrow movement emits no selection change. -- Pointer selection updates the controlled value only after the parent rebuild. -- Builder and state scope expose selected/toggled, focused, hovered, pressed, and disabled state accurately. -- Group label and option names are present exactly once. -- Option semantics expose the accepted toggled/selected decision and no action when disabled. -- Group exclusion removes all group semantics. -- No debug role or merged-semantics exception occurs. - -### 14.8 Integration and screenshot scenarios - -Stable fixture keys: - -- toggle-group.root -- toggle-group.option.bold -- toggle-group.option.italic -- toggle-group.option.underline -- toggle-group.value - -Integration scenarios: - -1. Tab into a horizontal group, traverse with arrows, toggle with Space, and Tab out. -2. Repeat with RTL and assert visual-direction focus. -3. Traverse a vertical group with a disabled middle item and loop disabled. -4. Use multiple mode and verify two independent selections. -5. Remove the focused option during rebuild and verify a valid neighbor receives the roving target. -6. Invoke the semantic tap action and verify the same callback contract. - -Required screenshots: - -- toggle_group__single_selected_focus__macos__reference.png -- toggle_group__multiple_selected__android__reference.png -- toggle_group__disabled_option__macos__reference.png -- toggle_group__vertical__web__reference.png -- toggle_group__rtl_focus__web__reference.png -- toggle_group__200_text__macos__reference.png - -Manual checks confirm that the group label is announced once, each option is identified as a toggle button with correct state, only one page Tab stop is used, and arrows do not unexpectedly toggle values. - -### 14.9 Toggle Group acceptance - -- [ ] Current unnamed constructor compiles unchanged. -- [ ] Single and multiple modes have explicit invariants. -- [ ] Roving focus passes LTR, RTL, vertical, disabled, and dynamic-child cases. -- [ ] The semantics compatibility decision is recorded in the changelog. -- [ ] Canonical example passes label and target-size guidelines. -- [ ] All six screenshot states are reviewed. -- [ ] VoiceOver, TalkBack, and web keyboard results are attached. - -## 15. Component contract: Context Menu - -### 15.1 Why it is needed - -A context menu is not merely a menu placed under a different icon. It opens from a secondary pointer action, touch long press, or keyboard context-menu command and is positioned at either the invocation point or the focused trigger. Keyboard users must receive the same actions and focus behavior as pointer users. - -### 15.2 Confirmed reusable foundation - -Current NakedMenu already supplies: - -- MenuController lifecycle; -- menu and menu-item semantics roles; -- enabled and disabled item behavior; -- anchored overlay plumbing and outside-click close; -- selection callbacks and close-on-activate behavior; -- trigger focus restoration; -- item builders and state scopes. - -Current NakedMenu is trigger-button oriented. It always builds a NakedButton trigger and does not expose secondary-tap, long-press, Shift+F10, Context Menu key, or point-anchor behavior. A new trigger primitive should reuse its items and internal menu scope rather than copy the item implementation. - -### 15.3 Proposed public API - -~~~dart -enum NakedContextMenuTriggerKind { - mouse, - touch, - keyboard, - programmatic, -} - -class NakedContextMenuState extends NakedState { - NakedContextMenuState({ - required super.states, - required this.isOpen, - required this.anchorPosition, - required this.triggerKind, - }); - - final bool isOpen; - - // Local to the trigger coordinate space used by MenuController.open. - final Offset? anchorPosition; - final NakedContextMenuTriggerKind? triggerKind; -} - -const NakedContextMenu({ - Key? key, - Widget? child, - ValueWidgetBuilder? builder, - required RawMenuAnchorOverlayBuilder overlayBuilder, - required MenuController controller, - ValueChanged? onSelected, - VoidCallback? onOpen, - VoidCallback? onClose, - VoidCallback? onCanceled, - RawMenuAnchorOpenRequestedCallback? onOpenRequested, - RawMenuAnchorCloseRequestedCallback? onCloseRequested, - bool enabled = true, - bool openOnSecondaryTap = true, - bool openOnLongPress = true, - bool openOnKeyboard = true, - bool consumeOutsideTaps = true, - bool useRootOverlay = false, - bool closeOnClickOutside = true, - bool loopFocus = true, - FocusNode? triggerFocusNode, - OverlayPositionConfig positioning = const OverlayPositionConfig(), - String? semanticLabel, - bool excludeSemantics = false, -}); -~~~ - -Retain NakedMenuItem as the item type for both normal and context menus. Achieve this by extracting the current private menu scope into shared internal infrastructure. Avoid a second public item class with subtly different semantics. - -Programmatic opening can use MenuController.open(position: ...). If the team decides a typed controller is necessary to expose trigger kind, add it before release rather than leaking mutable state through globals. - -### 15.4 Trigger and positioning behavior - -- Secondary tap records the local invocation point and opens there. -- Touch long press opens at the press point using Flutter's platform gesture timing. -- Shift+F10 and LogicalKeyboardKey.contextMenu open from the focused trigger. -- A keyboard anchor uses the trigger's lower logical start edge and respects RTL. -- Programmatic open may supply a point; without one it uses the trigger anchor. -- The overlay clamps or flips so the full menu remains in the safe visible bounds when possible. -- Opening from one method must not also fire primary tap behavior on the child. -- Repeated open requests while already open do not duplicate callbacks or overlays. -- If enabled is false, no gesture, shortcut, or semantic action opens the menu. -- Pointer position is cleared after close so the next keyboard open does not reuse stale coordinates. - -### 15.5 Focus and keyboard behavior - -- The trigger can participate in focus traversal without being forced to expose button semantics. -- On open, focus moves to the first enabled menu item. -- Down/Right behavior for nested submenus is out of initial scope; do not imply submenu support. -- Up/Down move through enabled items. -- Home/End move to first/last enabled items. -- Enter/Space activate the focused item. -- Escape closes without selection and restores trigger focus. -- Tab closes the menu and continues normal page traversal; it does not make every menu item a page Tab stop. -- When loopFocus is true, arrows wrap; otherwise focus remains at the edge. -- Removing the active item moves focus to a valid neighbor or the menu container. - -### 15.6 Semantics contract - -Trigger: - -- preserve the child's native role; a text-editing region, image, link, or custom surface must not become a fake button automatically; -- expose caller semanticLabel only when provided; -- provide an enabled long-press semantic action when long-press opening is enabled; -- do not expose any opening action when disabled; -- do not duplicate the child's label when semanticLabel overrides it. - -Popup: - -- one SemanticsRole.menu container with explicit child nodes; -- initial-scope action items use SemanticsRole.menuItem; -- disabled items remain discoverable but have no tap action; -- do not simulate checked/radio behavior with a plain action item because it would expose the wrong role; -- separators and decorative icons are excluded from semantics. - -### 15.7 Required widget and semantics tests - -- Secondary mouse tap opens once at the captured point. -- Primary tap does not open unless the child itself owns that behavior. -- Touch long press opens once and does not invoke primary tap. -- Shift+F10 and Context Menu key open from focus. -- Each disabled/open-method flag suppresses only its corresponding path. -- Builder state exposes open, trigger kind, anchor point, hover, focus, and disabled accurately. -- Opening near all four viewport edges stays in bounds. -- RTL keyboard anchor is at logical start. -- Focus enters first enabled item, skips disabled items, supports Home/End and loop setting. -- Escape, outside pointer, item activation, and programmatic close each emit correct close/cancel/selection callbacks exactly once. -- Focus restoration survives trigger rebuild and safely handles trigger removal. -- Existing NakedMenuItem works under both menu scopes. -- Trigger does not gain button semantics. -- Popup and item roles are exact; disabled item action is absent. -- Semantic long press opens the menu. -- excludeSemantics behavior is explicit and tested. - -### 15.8 Integration and screenshot scenarios - -Stable fixture keys: - -- context-menu.trigger -- context-menu.item.copy -- context-menu.item.rename -- context-menu.item.delete -- context-menu.item.disabled -- context-menu.selection - -Integration scenarios: - -1. Right-click the center, assert open point, focus first item, select, and verify callback. -2. Open near each corner and assert the overlay rectangle remains in the visible surface. -3. Long-press on Android, navigate to an action, and dismiss outside. -4. Focus the trigger, press Shift+F10 and Context Menu key in separate tests, navigate with arrows, Escape, and verify restoration. -5. Verify a disabled context menu never opens by pointer, keyboard, or semantic action. -6. Verify a disabled item is announced but skipped by focus and cannot activate. - -Required screenshots: - -- context_menu__pointer_center__macos__reference.png -- context_menu__collision_top_left__macos__reference.png -- context_menu__collision_bottom_right__web__reference.png -- context_menu__long_press__android__reference.png -- context_menu__keyboard_focus__macos__reference.png -- context_menu__rtl_anchor__web__reference.png - -Manual checks confirm that keyboard and touch users can discover and open the same menu, menu role and item states are announced, focus enters and returns predictably, and the trigger retains its original role. - -### 15.9 Context Menu non-goals - -- Nested submenus and menubars. -- Checkbox and radio menu-item variants in the first Context Menu release. Add shared menuItemCheckbox/menuItemRadio APIs deliberately before exposing those behaviors. -- OS-native process menus outside Flutter's semantics/focus tree. -- Arbitrary business commands or clipboard behavior. -- Product-specific icons, separators, destructive styling, or shortcut labels. - -### 15.10 Context Menu acceptance - -- [ ] Existing Menu behavior and item API remain compatible. -- [ ] All four open paths have direct tests. -- [ ] Point anchoring and four-edge collision behavior pass. -- [ ] Trigger semantics are not coerced to button. -- [ ] Focus entry, navigation, close, and restoration pass on real macOS. -- [ ] Long press passes on Android. -- [ ] All screenshot states and AT evidence are attached. - -## 16. Component contract: Toast - -### 16.1 Why it is needed - -Remix's inline Callout cannot replace transient application feedback. Toast must coordinate a queue, visible limit, automatic dismissal, pause/resume, announcements, optional actions, focus access, and app lifecycle. These behaviors are cross-theme and should not be reimplemented by every styled package. - -Toast is appropriate for a status update that does not require an immediate response. If the user must respond before continuing, use Alert Dialog. A toast action must be safe to ignore because the message may time out. - -### 16.2 Proposed public model - -The final names may be refined, but the recommended separation is: - -~~~dart -enum NakedToastPriority { - polite, - assertive, -} - -enum NakedToastDismissReason { - timeout, - action, - close, - swipe, - programmatic, - overflow, -} - -@immutable -class NakedToastEntry { - const NakedToastEntry({ - required this.id, - required this.payload, - required this.semanticLabel, - this.duration, - this.priority = NakedToastPriority.polite, - }); - - final Object id; - final T payload; - final String semanticLabel; - final Duration? duration; - final NakedToastPriority priority; -} - -class NakedToastState extends NakedState { - NakedToastState({ - required super.states, - required this.entry, - required this.isVisible, - required this.isPaused, - required this.remainingDuration, - }); - - final NakedToastEntry entry; - final bool isVisible; - final bool isPaused; - final Duration? remainingDuration; -} - -@immutable -class NakedToastDismissed { - const NakedToastDismissed({ - required this.entry, - required this.reason, - }); - - final NakedToastEntry entry; - final NakedToastDismissReason reason; -} - -abstract interface class NakedToastHandle { - Object get id; - bool get isActive; - void dismiss({ - NakedToastDismissReason reason = NakedToastDismissReason.programmatic, - }); -} - -typedef NakedToastItemBuilder = - Widget Function(BuildContext context, NakedToastState state); - -class NakedToastController extends ChangeNotifier { - NakedToastHandle show(NakedToastEntry entry); - void dismiss( - Object id, { - NakedToastDismissReason reason = NakedToastDismissReason.programmatic, - }); - void clear({ - NakedToastDismissReason reason = NakedToastDismissReason.programmatic, - }); -} - -const NakedToastViewport({ - Key? key, - required NakedToastController controller, - required NakedToastItemBuilder itemBuilder, - Duration defaultDuration = const Duration(seconds: 5), - int maxVisible = 3, - int? maxQueued, - bool pauseOnHover = true, - bool pauseOnFocus = true, - bool pauseWhenAppInactive = true, - SingleActivator? focusShortcut, - FocusNode? focusNode, - ValueChanged>? onDismissed, - bool excludeSemantics = false, -}); -~~~ - -Also provide scoped item helpers, or an equivalent structure, so consumers can mark: - -- the visible title/description that duplicates semanticLabel; -- an optional safe action; -- an explicit close control. - -The implementation must be able to exclude duplicate message text while retaining action and close semantics. A single ExcludeSemantics around the entire visual toast is not acceptable because it would hide the controls. - -### 16.3 Controller and queue invariants - -- IDs are unique among active and queued entries. Duplicate IDs either replace atomically through a documented API or throw a debug assertion; silent duplication is forbidden. -- show returns a handle tied to one entry. -- A handle dismisses at most once and becomes inert after removal. -- Visible entries are ordered deterministically. Recommended default: oldest visible first and new entries appended at the logical end. -- Entries beyond maxVisible wait in FIFO order and have no semantics node yet. -- A queued entry's timeout starts only when it becomes visible. -- maxVisible must be greater than zero. -- If maxQueued is supplied, overflow behavior and dismissal reason are deterministic and documented. -- Controller listeners are detached on viewport disposal; externally owned controllers are not disposed. -- Multiple viewports attached to the same controller are either explicitly supported with defined semantics or rejected. Recommendation: reject to prevent duplicate announcements. - -### 16.4 Timer and lifecycle contract - -- Null duration means persistent until action, close, or programmatic dismissal. -- Otherwise, auto-dismiss uses the entry duration or viewport default. -- Hover anywhere within the viewport pauses all visible timers when enabled. -- Keyboard focus anywhere within the viewport pauses all visible timers when enabled. -- App lifecycle states other than resumed pause timers when enabled. -- Pause stores remaining time; resume does not restart the full duration. -- Nested pause reasons are reference-safe: leaving hover does not resume while focus or app lifecycle is still paused. -- Removing an entry cancels its timer. -- Disposal cancels every timer and observer. -- A duration that expires during a visual exit transition produces one dismissal callback, not two. -- Tests use targeted pump durations; they never wait in real time. - -### 16.5 Focus and keyboard contract - -- Showing a toast never moves keyboard focus. -- Normal page Tab traversal does not unexpectedly jump to a newly inserted toast. -- If the application supplies focusShortcut, invoking it stores current focus and moves focus to the newest visible toast or its first actionable control. -- Within the viewport, Tab reaches action and close controls in visual order. -- Escape while focus is in the viewport dismisses the current toast when that behavior is documented, then returns focus to the stored prior node. -- If no prior node remains focusable, normal traversal continues without throwing. -- An auto-dismiss that occurs while one of its controls has focus is prevented by the focus pause. -- The canonical example may use F8, following common web primitive behavior, but the package must not reserve a global key without caller opt-in. - -### 16.6 Announcement semantics - -Normal status: - -- role: SemanticsRole.status; -- concise caller-provided semanticLabel; -- no liveRegion: true on the same node; -- no focus steal. - -Urgent status: - -- role: SemanticsRole.alert; -- reserved for important, time-sensitive information; -- no liveRegion: true on the same node; -- still does not steal keyboard focus. - -All messages: - -- are added to the semantics tree only when visible; -- are announced once per appearance, not on every timer tick or hover rebuild; -- do not concatenate every stacked toast into one changing announcement node; -- keep action and close controls as explicit accessible children; -- avoid duplicating title/description speech after the root semantic label; -- remove their status/alert node after dismissal; -- do not announce queued hidden entries; -- expose no swipe-only requirement because swipe is inaccessible to many users. - -### 16.7 Swipe dismissal - -Swipe is valuable but should not block the first reliable release if it would compromise semantics or deterministic timers. Recommended phasing: - -- initial release: action, close, timeout, and programmatic dismissal are required; -- follow-up: optional direction-aware swipe dismissal with observable drag state, threshold/velocity tests, cancel animation, and an equivalent close action. - -If swipe ships initially, include swipe dismissal reason and test touch, mouse drag if supported, RTL direction, below-threshold cancellation, and focused-control protection. - -### 16.8 Required widget and semantics tests - -- Show, queue, visible limit, FIFO promotion, dismiss, clear, and unique ID behavior. -- Per-entry duration, default duration, persistent duration, and zero/invalid assertions. -- Remaining-time preservation for hover, focus, lifecycle, and combined pause reasons. -- Exactly one callback and reason for every dismissal path. -- Controller replacement, viewport disposal, and post-dispose handle behavior. -- No focus movement when a toast appears. -- Opt-in shortcut focus and focus restoration. -- Auto-dismiss does not remove a focused toast. -- Status and alert roles are exact, with no live-region role conflict. -- Each semantic label appears once. -- Queued entries have no semantics. -- Action and close controls remain discoverable and actionable. -- Disabled actions have no tap action. -- Rebuilding visual state does not reannounce unchanged content. -- excludeSemantics has a documented use and does not accidentally leave unlabeled actions. - -### 16.9 Integration and screenshot scenarios - -Stable fixture keys: - -- toast.show.polite -- toast.show.assertive -- toast.show.action -- toast.viewport -- toast.item.first -- toast.action.undo -- toast.close -- toast.count - -Integration scenarios: - -1. Show a polite toast while a text field is focused; verify focus stays in the field and the status node appears once. -2. Advance exact time to just before and at expiry; verify one timeout dismissal. -3. Hover, advance beyond duration, verify retained; leave hover, advance remaining duration, verify dismissed. -4. Focus the action through the opt-in shortcut, verify timer pause, activate action, and restore prior focus. -5. Add more than maxVisible, verify only visible semantics, dismiss one, and verify queued promotion starts its timer. -6. Background/resume the app where the target permits lifecycle simulation and verify remaining time. -7. Show an assertive toast and verify alert role without focus movement. - -Required screenshots: - -- toast__single_polite__macos__reference.png -- toast__stacked_limit__macos__reference.png -- toast__action_focused_paused__web__reference.png -- toast__assertive__android__reference.png -- toast__safe_area__android__reference.png -- toast__200_text_wrap__macos__reference.png -- toast__rtl_stack__web__reference.png - -Manual checks confirm one announcement per visible toast, polite versus urgent priority as supported by the platform, no focus steal, discoverable action/close controls, and understandable behavior when several toasts arrive. - -### 16.10 Toast non-goals - -- Styling, placement tokens, icons, colors, elevation, and transition design. -- Required-response workflows. -- Persistence across application restarts. -- Network retries or business action semantics. -- Notification-center history. - -### 16.11 Toast acceptance - -- [ ] Queue, limit, timers, and all pause reasons are deterministic. -- [ ] Status and alert semantics pass without role-check exceptions. -- [ ] No show path steals focus. -- [ ] Action and close remain accessible without duplicate message speech. -- [ ] Real macOS and Android timing scenarios pass. -- [ ] All screenshot states are reviewed. -- [ ] VoiceOver/TalkBack evidence includes stacked and actionable cases. - -## 17. Component contract: Field - -### 17.1 Why it is needed - -A field is more than a decorated text input. It coordinates a visible label, description, control, required/read-only/disabled state, validation state, error visibility, label-to-control focus behavior, and error announcements. Without a headless field scope, each Remix input variant can concatenate and announce these pieces differently. - -### 17.2 Confirmed reusable foundation and gap - -Current NakedTextField already exposes: - -- editable text, focus, hover, press, read-only, disabled, and error state; -- semanticLabel, semanticHint, and semanticErrorText; -- an enabled, focused, read-only, obscured, multiline, length-aware semantics node; -- a live-region flag when error and semanticErrorText are set. - -It does not currently expose isRequired or SemanticsValidationResult. It also requires every consumer to manually keep the visible label/description/error synchronized with semantics. The proposed Field should integrate with NakedTextField without breaking standalone text-field behavior. - -### 17.3 Proposed composition API - -The recommended first release supports one primary control per field: - -~~~dart -enum NakedFieldErrorAnnouncement { - none, - whenChanged, -} - -class NakedFieldState extends NakedState { - NakedFieldState({ - required super.states, - required this.label, - required this.description, - required this.errorText, - required this.isRequired, - required this.isEnabled, - required this.isReadOnly, - required this.isTouched, - required this.isDirty, - required this.isFocused, - required this.isFilled, - required this.validationResult, - }); - - final String label; - final String? description; - final String? errorText; - final bool isRequired; - final bool isEnabled; - final bool isReadOnly; - final bool isTouched; - final bool isDirty; - final bool isFocused; - final bool isFilled; - final SemanticsValidationResult validationResult; -} - -const NakedField({ - Key? key, - required String label, - String? description, - String? errorText, - bool isRequired = false, - bool enabled = true, - bool readOnly = false, - bool touched = false, - bool dirty = false, - SemanticsValidationResult validationResult = - SemanticsValidationResult.none, - NakedFieldErrorAnnouncement errorAnnouncement = - NakedFieldErrorAnnouncement.whenChanged, - Widget? child, - ValueWidgetBuilder? builder, - bool excludeSemantics = false, -}); - -const NakedFieldLabel({required Widget child}); -const NakedFieldDescription({required Widget child}); -const NakedFieldError({required Widget child}); - -const NakedFieldControl({ - Key? key, - required Widget child, - FocusNode? focusNode, - bool hasValue = false, - bool readOnly = false, -}); -~~~ - -Public names are proposed. Required behavior: - -- NakedField is the single source of semantic label, description, error, required state, and validation result. -- NakedFieldControl registers the primary focus target and applies field metadata to a non-text control. -- NakedTextField automatically reads the nearest field scope and registers itself, so consumers do not double-wrap it. -- label, description, and error visual helpers receive state through the scope and prevent unintended duplicate semantics. -- debug mode reports multiple registered primary controls in one field unless a future group mode explicitly supports them. - -### 17.4 State ownership - -- The application controls touched, dirty, validationResult, and when errorText becomes visible. Naked UI does not invent business validation timing. -- The primary control reports focused and filled state to the scope. -- Explicit control-level disabled and read-only state must not contradict the field. Effective disabled is the stricter state. -- The field builder receives a new immutable snapshot only when an observable state changes. -- Empty error text is normalized to no visible error. -- validationResult.invalid without an error message is allowed for semantics but documented as poor user experience. -- A non-null visible error with validationResult.valid is a debug assertion because the states conflict. - -### 17.5 Label, description, and error behavior - -- Tapping the visible label requests focus on the registered enabled control. -- Label tap does not open a keyboard for a disabled or non-focusable control. -- The label text is the control's accessible name exactly once. -- Description text is included in the control hint/description exactly once. -- Current error text is associated with the control so it is discoverable whenever the user returns to the field. -- When a mounted field transitions to a new non-empty visible error and announcement policy is whenChanged, the error is exposed as SemanticsRole.alert without also setting liveRegion: true. -- An unchanged error does not reannounce on unrelated rebuilds. -- Clearing and later reintroducing the same error is a new transition and may announce again. -- Initial invalid content is discoverable on control focus; whether it announces immediately must be consistent and documented. Recommendation: do not produce a surprise assertive announcement on first build. -- Error color or icon is never the only validation indicator; the canonical example includes text. - -### 17.6 NakedTextField integration and compatibility - -Inside a field scope, the text field applies: - -- field label as the effective semantic label; -- description plus current error as effective semantic hint; -- isRequired from the field; -- validationResult from the field; -- effective disabled/read-only state; -- field-managed error announcement rather than its existing live-region path. - -Outside a field scope, all current NakedTextField parameters and semantics remain unchanged. - -If a consumer supplies both field metadata and explicit text-field semantic label/hint/error: - -- recommended behavior is a debug assertion when values conflict; -- identical values may be accepted; -- do not concatenate two labels or two copies of the error. - -This precedence rule must be decided and covered before implementation. - -### 17.7 Semantics contract - -Primary control: - -- retains its native role and actions; -- accessible name equals field label; -- hint includes description and current error in a stable order; -- isRequired is true/false when the field declares required state; -- validationResult is none, valid, or invalid as controlled; -- enabled, read-only, focused, value, and actions remain accurate; -- disabled controls expose no focus/tap/edit action that can change the value. - -Visual helpers: - -- label is not a second unrelated text node if it would duplicate the control name; -- description is not duplicated after being associated with the control; -- error remains readable and can become a single alert transition; -- required and error icons are decorative unless they add nonduplicative meaning. - -### 17.8 Required widget and semantics tests - -- Child and builder invariants plus state-scope lookup. -- Label tap focuses enabled control; disabled/missing control is safe. -- One-primary-control registration and dynamic control replacement. -- Focused and filled state updates. -- Controlled touched/dirty/validity changes and state equality/hash code. -- Effective enabled and read-only propagation. -- Required state true, false, and absent/default semantics. -- Validation result none, valid, and invalid semantics. -- Label, description, and error appear in the control semantics exactly once. -- New error announces once; unchanged rebuild does not; clear/re-add announces again. -- Initial invalid policy is exact and tested. -- Error alert role is never combined with liveRegion: true. -- Standalone NakedTextField retains existing semantics. -- Conflicting field/text-field semantic inputs follow the chosen assertion/precedence rule. -- Non-text controls through NakedFieldControl retain their role. -- Dynamic localization updates accessible text without losing focus. -- Exclusion behavior is documented and tested. - -### 17.9 Integration and screenshot scenarios - -Stable fixture keys: - -- field.email -- field.email.label -- field.email.control -- field.email.description -- field.email.error -- field.email.submit -- field.email.state - -Integration scenarios: - -1. Tap label, type a value, and verify focus, filled, touched/dirty output, and semantics value. -2. Submit invalid data, verify validation state and one error announcement node. -3. Rebuild without changing error and verify no duplicate announcement transition. -4. Correct the value, clear error, and verify valid state. -5. Verify disabled and read-only fields have distinct behavior and semantics. -6. Change locale and direction while focused; verify label/hint update without focus loss. -7. Use a non-text control fixture to prove the composition is not text-field-only. - -Required screenshots: - -- field__empty_required__macos__reference.png -- field__focused_filled__web__reference.png -- field__invalid_error__android__reference.png -- field__disabled_and_readonly__macos__reference.png -- field__long_error_200_text__macos__reference.png -- field__rtl__web__reference.png - -Manual checks confirm label/name, required state, value, hint, invalid state, error announcement timing, label activation, and the distinction between disabled and read-only. - -### 17.10 Field non-goals - -- Business validation rules or schema libraries. -- Form submission orchestration. -- Input formatting already owned by the control. -- Visual required markers, success icons, or error styling. -- Multi-control fieldsets in the first release; add a deliberate group contract later. - -### 17.11 Field acceptance - -- [ ] Standalone TextField behavior remains compatible. -- [ ] Field metadata reaches the primary control without duplicate speech. -- [ ] Required and validation semantics are asserted. -- [ ] Error transition policy is deterministic. -- [ ] Label focus works for text and non-text controls. -- [ ] Canonical styled examples pass label, target, and contrast guidelines. -- [ ] All screenshot and assistive-technology evidence is attached. - -## 18. Component contract: Combobox - -### 18.1 Why it is needed - -Combobox combines editable text, suggestions, selection, an anchored popup, dynamic filtering, keyboard highlight, focus retention, and assistive-technology announcements. It is not an editable skin over NakedSelect. Treating it as a Select would produce incorrect focus and text-editing behavior. - -This is the highest-risk item in the handoff. It should not ship until actual assistive-technology behavior is proven on the supported platforms. - -### 18.2 Confirmed reusable foundation - -- NakedTextField provides native editing, selection, IME, focus, and semantics foundations. -- NakedSelect and overlay utilities provide controller, positioning, collision, outside-dismissal, and option-state patterns. -- NakedStateScope provides builder state. -- The proposed Field supplies label, description, required, invalid, and error semantics. - -Do not subclass or silently change NakedSelect. Share private overlay/option infrastructure where behavior is truly common. - -### 18.3 Accessibility spike required before final API - -Before the full PR: - -1. Build a minimal editable input, popup list, and three options. -2. Test SemanticsRole.comboBox, text-field semantics, expanded, controlsNodes, and option nodes on Flutter 3.41.2. -3. Inspect the Chrome accessibility tree. -4. Exercise VoiceOver on macOS and TalkBack on Android while typing and moving the active option. -5. Compare strategies for announcing active-option changes: - - property/role mapping alone; - - a dedicated SemanticsRole.status announcer whose label changes; - - another platform-supported approach that does not move keyboard focus. -6. Record exact results and select the least duplicative strategy. - -If no strategy makes the active option, selection, expanded state, and errors understandable, block the component and link the Flutter engine limitation. Do not downgrade this to a documentation note after release. - -### 18.4 Proposed public API - -~~~dart -class NakedComboboxState extends NakedState { - NakedComboboxState({ - required super.states, - required this.isOpen, - required this.query, - required this.value, - required this.values, - required this.highlightedValue, - required this.enabledOptionCount, - required this.hasResults, - }); - - final bool isOpen; - final String query; - final T? value; - final Set values; - final T? highlightedValue; - final int enabledOptionCount; - final bool hasResults; -} - -class NakedComboboxOptionState extends NakedState { - NakedComboboxOptionState({ - required super.states, - required this.value, - required this.textValue, - required this.isSelected, - required this.isHighlighted, - }); - - final T value; - final String textValue; - final bool isSelected; - final bool isHighlighted; -} - -typedef NakedComboboxBuilder = - Widget Function( - BuildContext context, - NakedComboboxState state, - Widget editableText, - ); - -const NakedCombobox({ - Key? key, - required TextEditingController textController, - required MenuController menuController, - required String Function(T value) displayStringForOption, - required RawMenuAnchorOverlayBuilder overlayBuilder, - T? value, - ValueChanged? onChanged, - ValueChanged? onQueryChanged, - ValueChanged? onSubmitted, - FocusNode? focusNode, - bool enabled = true, - bool readOnly = false, - bool openOnInput = true, - bool openOnFocus = false, - bool closeOnSelect = true, - bool loopFocus = true, - OverlayPositionConfig positioning = const OverlayPositionConfig(), - required NakedComboboxBuilder builder, - String? semanticLabel, - String? semanticHint, - String? semanticErrorText, - bool excludeSemantics = false, -}); - -const NakedCombobox.multiple({ - Key? key, - required TextEditingController textController, - required MenuController menuController, - required String Function(T value) displayStringForOption, - required RawMenuAnchorOverlayBuilder overlayBuilder, - required Set values, - ValueChanged>? onValuesChanged, - ValueChanged? onQueryChanged, - FocusNode? focusNode, - bool enabled = true, - bool readOnly = false, - bool openOnInput = true, - bool openOnFocus = false, - bool clearQueryOnSelect = true, - bool loopFocus = true, - OverlayPositionConfig positioning = const OverlayPositionConfig(), - required NakedComboboxBuilder builder, - String? semanticLabel, - String? semanticHint, - String? semanticErrorText, - bool excludeSemantics = false, -}); - -const NakedComboboxOption({ - Key? key, - required T value, - required String textValue, - bool enabled = true, - Widget? child, - ValueWidgetBuilder>? builder, - String? semanticLabel, - bool excludeSemantics = false, -}); -~~~ - -The exact builder type may be specialized instead of reusing RawMenuAnchorOverlayBuilder. The behavior below is the binding contract. - -### 18.5 Query, filtering, and selection ownership - -- Naked UI owns query observation and emits onQueryChanged. -- The consumer owns filtering, sorting, async data, empty text, loading text, and which option widgets are built. -- Option textValue is required for type/display semantics and active-option announcements; it is not assumed from arbitrary widget text. -- In single mode, selecting an option emits the value. The callback alone does not mutate accepted display state. -- The text controller updates to displayStringForOption(value) only when a later widget configuration supplies the accepted value, so a parent that rejects the request never flashes an unaccepted value. -- In multiple mode, selecting toggles membership without mutating the input set. -- Multiple mode clears the query on selection only when configured. -- The core exposes selected values; Remix owns chip layout. Every remove control in the canonical example has an explicit localized label. -- Custom/free-form value creation is owned through onSubmitted; the core does not invent a T from text. -- Dynamic option changes preserve highlight by value when possible; otherwise choose the nearest enabled option or clear it. - -### 18.6 Focus and keyboard contract - -The editable input retains real keyboard focus while the popup is open. - -| Key | Closed | Open | -|---|---|---| -| Down Arrow | Open and highlight first enabled result | Highlight next enabled result | -| Up Arrow | Open and optionally highlight last enabled result | Highlight previous enabled result | -| Enter | Submit free text when no result is active and IME is not composing | Select active result when IME is not composing | -| Escape | No component action | Close popup, clear highlight, retain input value and focus | -| Tab / Shift+Tab | Normal page traversal | Close without silently selecting highlight; continue page traversal | -| Left / Right | Native text editing | Native text editing; never option navigation | -| Home / End | Native text editing | Native text editing in editable mode | -| Alt+Down | Optional open | Keep open | -| Alt+Up | No action | Close and keep focus | - -Additional rules: - -- Space inserts text; it never acts like a select trigger while editing. -- Enter during an active IME composing range is left to the IME. -- Pointer selection returns/retains input focus according to platform convention. -- Popup options are not page Tab stops. -- Disabled options are skipped and cannot select through pointer or semantics. -- Highlight and selected are separate states. -- Outside pointer closes without changing the accepted selection. -- Opening or closing does not move the text caret unexpectedly. - -### 18.7 Popup and overlay contract - -- Popup width is consumer-controlled but can read target size from overlay information. -- Collision keeps the active option and input visible when practical. -- The popup repositions on viewport/keyboard inset changes. -- Results update without closing on each keystroke. -- Empty results can remain open with a caller-rendered message. -- Async loading does not clear an accepted selection. -- A stale async result cannot reset highlight to an option no longer represented by the latest query; the consumer should key result sets and Naked UI should react safely to registry changes. -- Outside click, Escape, selection, controller close, and trigger removal each have one deterministic close callback. - -### 18.8 Semantics contract - -Input: - -- native text-field semantics remain intact; -- accessible name comes from Field or explicit semantic label; -- current text/query is the value; -- expanded reflects popup state; -- controlsNodes references a stable popup semantics identifier; -- SemanticsRole.comboBox is used only after the spike proves it does not degrade supported targets; -- required, invalid, description, and error state come from Field integration; -- editing, selection, focus, and submit actions remain available. - -Popup: - -- one SemanticsRole.list container with a stable identifier; -- options use SemanticsRole.listItem plus selected/enabled/tap properties because Flutter 3.41.2 lacks a dedicated option role; -- actual selection is not conflated with keyboard highlight; -- empty/loading status has a concise status node if it changes while the input is focused; -- decorative checkmarks/icons are excluded. - -Active option: - -- must be announced when changed by keyboard without moving real focus out of the input; -- announcement uses the required option text value and avoids hard-coded English position phrases; -- unchanged highlight does not reannounce; -- rapid repeats do not create an unintelligible backlog; -- actual screen-reader behavior is a manual release gate. - -### 18.9 Required widget and semantics tests - -- Child/builder and controller ownership invariants. -- Query callback for typing, paste, cut, programmatic controller update policy, and clear. -- Single controlled selection, parent rejection, display string, and close-on-select. -- Multiple add/remove, immutable sets, clear-query setting, and selected-state rebuild. -- Dynamic option add/remove/reorder/disable while highlighted. -- Open-on-input, open-on-focus, controller open, outside close, and disabled/read-only paths. -- Exact keyboard table, including Tab, Escape, text-editing keys, and Alt variants if supported. -- IME composing Enter is not intercepted. -- Input focus and caret survive popup navigation and result updates. -- Disabled options skipped for pointer, keyboard, and semantics. -- Collision and inset change. -- Input semantics label/value/expanded/controlled relation. -- Popup/list-item roles and selected/enabled states. -- Required/invalid/error Field integration. -- Active-option announcement strategy changes once per highlight and not on unrelated rebuild. -- No debug semantics exception on the minimum SDK. -- Exclusion and localization/RTL cases. - -### 18.10 Integration and screenshot scenarios - -Stable fixture keys: - -- combobox.input -- combobox.popup -- combobox.option.apple -- combobox.option.banana -- combobox.option.disabled -- combobox.query -- combobox.selection -- combobox.no-results - -Integration scenarios: - -1. Focus input, type to filter, arrow through results, Enter select, and verify focus/value/callback. -2. Escape closes without changing query or accepted selection. -3. Tab closes and moves to the next page control without accepting highlight. -4. Left/Right/Home/End edit or move the caret rather than navigate options. -5. Exercise an IME composition where target automation supports it; at minimum unit-test the composing-range branch. -6. Dynamically remove the highlighted result and verify safe next state. -7. Run multiple selection, remove a selected chip with its labeled control, and keep input usable. -8. Show no results, loading, disabled option, invalid Field, RTL, and mobile keyboard-inset cases. -9. Inspect web accessibility tree for name, value, expanded state, controlled popup, list, and options. - -Required screenshots: - -- combobox__closed_value__macos__reference.png -- combobox__open_highlight__macos__reference.png -- combobox__filtered_results__web__reference.png -- combobox__no_results__web__reference.png -- combobox__multiple_values__macos__reference.png -- combobox__invalid_field__android__reference.png -- combobox__keyboard_inset_collision__android__reference.png -- combobox__rtl__web__reference.png -- combobox__200_text__macos__reference.png - -Manual checks must cover typing, active-option announcements, selection, expanded/collapsed state, no results, invalid state, multiple values, Escape, and leaving with Tab. A result of “the semantics tree looks correct” is insufficient. - -### 18.11 Combobox non-goals - -- Filtering, fuzzy scoring, remote search, paging, caching, or result virtualization policy. -- Product-specific empty/loading content. -- Automatic creation of domain values from free text. -- Styled chips or chip overflow. -- Date, tree, grid, or command-palette popup variants in the first release. - -### 18.12 Combobox acceptance - -- [ ] The accessibility spike is attached and names every tested target. -- [ ] Input retains focus and native editing keys. -- [ ] Active option is understandable with real assistive technology. -- [ ] Single and multiple controlled state cannot mutate caller inputs. -- [ ] Field semantics integrate without duplicate label/error output. -- [ ] Real macOS, Android, and web integration scenarios pass. -- [ ] All nine screenshot states are reviewed. -- [ ] Known Flutter engine limitations are documented without overstating support. - -## 19. Component contract: Hover Card / Preview Card - -### 19.1 Why it is needed - -Hover Card shows a visual preview of a link destination while the pointer hovers or the link has keyboard focus. It differs from Tooltip: - -- the content is richer and larger; -- the pointer must be able to move onto it without dismissal; -- it previews information already available at the destination; -- it is normally ignored by screen readers to avoid duplicating the link destination; -- it must satisfy WCAG behavior for content appearing on hover or focus. - -It also differs from Popover: Hover Card is non-interactive preview content and does not enter the Tab sequence. Interactive or essential content belongs in Popover. - -### 19.2 Confirmed reusable foundation and gap - -Current NakedTooltip uses Flutter RawTooltip, hover/touch delays, dismissal delay, trigger modes, positioning, and optional tooltip semantics. It does not promise hoverable rich content, a pointer grace corridor, focus-triggered preview behavior, or screen-reader-excluded destination preview. - -Current NakedPopover supplies overlay and focus-restoration foundations but is click-oriented and intended for interactive content. - -Hover Card should reuse private overlay positioning and lifecycle utilities, not overload Tooltip with contradictory semantics. - -### 19.3 Proposed public API - -~~~dart -enum NakedHoverCardOpenReason { - hover, - focus, - programmatic, -} - -class NakedHoverCardState extends NakedState { - NakedHoverCardState({ - required super.states, - required this.isOpen, - required this.openReason, - }); - - final bool isOpen; - final NakedHoverCardOpenReason? openReason; -} - -const NakedHoverCard({ - Key? key, - Widget? child, - ValueWidgetBuilder? builder, - required RawMenuAnchorOverlayBuilder previewBuilder, - MenuController? controller, - Duration openDelay = const Duration(milliseconds: 700), - Duration closeDelay = const Duration(milliseconds: 300), - bool openOnHover = true, - bool openOnFocus = true, - bool useRootOverlay = false, - OverlayPositionConfig positioning = const OverlayPositionConfig(), - FocusNode? triggerFocusNode, - VoidCallback? onOpen, - VoidCallback? onClose, - bool excludePreviewSemantics = true, -}); -~~~ - -The controller can be optional because an internal controller is reasonable; ownership rules remain explicit. The exact builder type may be specialized. - -### 19.4 Behavioral contract - -- Pointer entry starts the open delay. -- Leaving before the delay cancels opening. -- Focus entry starts the open delay or opens immediately if the team chooses a separate focus delay; the choice is public and tested. Recommendation: use the same default to avoid surprise flashing during keyboard traversal. -- Once open, moving the pointer from trigger to preview through the geometric gap does not close it. -- Pointer over the preview keeps it open. -- Leaving both trigger and preview starts the close delay. -- Re-entering either before close cancels the pending close without restarting the open animation. -- Focus remaining on the trigger keeps the preview open even when the pointer leaves. -- Escape closes immediately while the trigger has focus. -- Clicking the trigger continues to activate the underlying Link; Hover Card does not consume or toggle on primary click. -- The preview never requests keyboard focus and contains no focusable descendants. -- Page Tab traversal moves from the Link to the next page control, not into the preview. -- Programmatic close cancels all timers. -- App lifecycle pause/dispose cannot leave an orphan overlay or live timer. -- Viewport collision keeps the preview visible without covering the trigger when another side is available. - -### 19.5 WCAG hover/focus requirements - -WCAG 2.2 Success Criterion 1.4.13 requires additional content triggered by hover or focus to be: - -- **dismissible:** Escape closes the preview without moving focus; -- **hoverable:** the pointer can move over the preview; -- **persistent:** it remains until hover/focus is removed, the user dismisses it, or the information becomes invalid. - -Timer-only dismissal while the pointer or trigger focus remains is forbidden. - -### 19.6 Semantics contract - -- The trigger retains its native Link semantics, name, URL, actions, and focus. -- The Hover Card wrapper adds no second button, tooltip, or link node. -- Preview content is excluded from semantics by default. -- The preview must not contain unique instructions, status, controls, or information needed to understand or operate the page. -- If a team believes preview content must be accessible, it should use Popover or render the information in normal page content rather than set an escape-hatch flag casually. -- Decorative preview images have no semantics. -- Escape dismissal is keyboard behavior; it is not represented as a fake semantic action on the Link. - -### 19.7 Pointer-grace implementation requirement - -A simple close timer alone is not sufficient when a visible gap separates trigger and preview. Implement and test either: - -- a pointer grace polygon/corridor between the exit point and preview bounds; or -- a hit-testable safe region that does not block unrelated controls. - -The grace region must update when the overlay flips sides and must not keep the card open after the pointer moves away from both surfaces. - -### 19.8 Required widget and semantics tests - -- Hover open delay, pre-open cancellation, close delay, and re-entry cancellation. -- Focus open/close behavior and Escape. -- Combined hover plus focus reasons; removing one reason does not close while the other remains. -- Pointer movement across the trigger-preview gap on all four placement sides. -- Pointer departure outside the grace corridor closes. -- Underlying Link primary activation still fires exactly once. -- Preview has no focusable descendants in the canonical contract. -- Tab skips the preview. -- Controller open/close, replacement, disposal, and pending timer cancellation. -- Collision and RTL placement. -- Every relevant trigger semantics field is identical with and without the Hover Card wrapper. -- Preview nodes are absent from the semantics tree by default. -- Exclusion cannot hide the trigger. -- No unchanged rebuild reopens, recloses, or restarts timers. - -### 19.9 Integration and screenshot scenarios - -Stable fixture keys: - -- hover-card.link -- hover-card.preview -- hover-card.next-focus -- hover-card.open-state - -Integration scenarios: - -1. Hover shorter than delay and verify no preview. -2. Hover through delay, move across the gap onto preview, wait beyond close delay, and verify still open. -3. Move outside both, wait exact close delay, and verify closed. -4. Keyboard-focus the Link, verify preview, press Escape, verify closed and focus unchanged. -5. Reopen and Tab; verify next page control receives focus. -6. Activate the Link while preview is open and verify the Link callback once. -7. Open near each edge and verify collision-safe placement. - -Required screenshots: - -- hover_card__hover_open__macos__reference.png -- hover_card__keyboard_focus__web__reference.png -- hover_card__pointer_grace__macos__reference.png -- hover_card__collision_edge__web__reference.png -- hover_card__200_text__macos__reference.png -- hover_card__rtl__web__reference.png - -Manual checks confirm the Link is announced normally, no duplicate preview text appears in screen-reader navigation, Escape works, keyboard focus does not enter the card, and essential information is available at the destination. - -### 19.10 Touch behavior and non-goals - -Hover Card has no required touch-only trigger. Long press is already used by system text/link behavior and Context Menu, and a hidden preview is a poor place for essential mobile information. Consumers needing touch access should use Popover. - -Other non-goals: - -- interactive buttons, links, text fields, or scrolling inside the preview; -- product data fetching and caching; -- destination navigation; -- preview styling or animation design. - -### 19.11 Hover Card acceptance - -- [ ] Dismissible, hoverable, and persistent behavior is automated. -- [ ] Pointer grace passes all placement sides. -- [ ] Link activation and semantics remain unchanged. -- [ ] Preview is absent from semantics and Tab traversal. -- [ ] Timers are deterministic and disposed. -- [ ] All screenshot and manual accessibility evidence is attached. - -## 20. Component contract: Link - -### 20.1 Why it is needed - -Remix needs a reusable inline navigation primitive. A Link is not a text-styled Button: assistive technologies identify it as navigation, keyboard users activate it with Enter, and Space should remain available for page scrolling rather than triggering it. - -### 20.2 Proposed public API - -~~~dart -class NakedLinkState extends NakedState { - NakedLinkState({ - required super.states, - required this.linkUrl, - }); - - final Uri? linkUrl; -} - -const NakedLink({ - Key? key, - Widget? child, - ValueWidgetBuilder? builder, - VoidCallback? onPressed, - Uri? linkUrl, - bool enabled = true, - FocusNode? focusNode, - bool autofocus = false, - MouseCursor? mouseCursor, - bool enableFeedback = true, - ValueChanged? onFocusChange, - ValueChanged? onHoverChange, - ValueChanged? onPressChange, - String? semanticLabel, - String? semanticHint, - bool excludeSemantics = false, -}); -~~~ - -Naked UI does not depend on URL launching or a router. linkUrl is semantics metadata; onPressed performs application navigation. Effective enabled state is enabled && onPressed != null. - -### 20.3 Interaction contract - -- Primary pointer tap activates once. -- A canceled pointer sequence clears pressed state and does not activate. -- Enter and Numpad Enter activate while focused. -- Space is not intercepted and does not activate the Link. -- Semantic tap activates through the same callback path. -- Disabled links do not activate, focus through normal traversal, emit feedback, or show an activation cursor. -- Hover, focus, press, and disabled state are visible to the builder and scope. -- The default enabled cursor is SystemMouseCursors.click; the disabled default is basic. -- Callback removal while focused immediately makes the Link effectively disabled and removes activation. -- Rebuild, focus-node replacement, and disposal do not leak listeners. -- The package does not invent visited state because it does not own navigation history. - -### 20.4 Semantics contract - -Flutter represents links with properties rather than SemanticsRole.link. Use: - -- link: true; -- linkUrl: linkUrl when supplied; -- enabled state; -- accessible name from visible text or semanticLabel; -- caller-localized semanticHint for behavior such as opening a new window; -- tap action only when effectively enabled; -- focused/focusable state consistent with keyboard focus. - -Additional rules: - -- Do not also expose button semantics. -- If linkUrl is non-null, the Link flag must be true; Flutter asserts this invariant. -- A decorative external-link icon is excluded from semantics when the hint already conveys the behavior. -- Disabled Link remains a discoverable disabled link when included in semantics, but has no action. -- excludeSemantics hides the Link subtree and is documented as an advanced escape hatch. - -### 20.5 Text and gesture considerations - -- Wrapping rich or selectable text must not break text selection outside the actual activation surface. -- Secondary click remains available for a surrounding Context Menu where the consumer composes one. -- Modifier-click behavior such as opening a new browser tab is router/platform policy and not synthesized in the first API. -- The hit target comes from the styled child. The canonical example and Remix must meet platform target-size guidance where the Link is presented as a standalone control; inline text links should preserve readable line layout and a discoverable focus indication. - -### 20.6 Required widget and semantics tests - -- Child/builder assertion and state-scope behavior. -- Pointer tap, canceled gesture, Enter, Numpad Enter, semantic tap, and callback count. -- Space does not activate. -- Enabled, explicit disabled, and null-callback effective disabled cases. -- Hover/focus/press callback transitions. -- Focus-node ownership, replacement, and disposal. -- Dynamic callback removal while focused. -- Link flag, URL, name, hint, enabled, focus, and tap action. -- No button flag. -- Disabled action absent. -- Visible child semantics versus overriding semantic label has no duplicate name. -- External-icon exclusion. -- Non-English label/hint and RTL fixture. -- excludeSemantics removes the node. - -### 20.7 Integration and screenshot scenarios - -Stable fixture keys: - -- link.primary -- link.disabled -- link.external -- link.result -- link.next-focus - -Integration scenarios: - -1. Tab to Link, press Enter, and verify one navigation callback plus retained predictable focus. -2. Focus Link, press Space, and verify no callback. -3. Pointer hover/press/tap and verify state readout. -4. Invoke semantic tap and verify the same callback. -5. Verify disabled Link is skipped by Tab and has no pointer or semantics action. -6. Compose Hover Card and Context Menu around Link and verify primary, secondary, hover, and keyboard paths do not conflict. - -Required screenshots: - -- link__default_inline__macos__reference.png -- link__hover__web__reference.png -- link__keyboard_focus__macos__reference.png -- link__disabled__android__reference.png -- link__external_hint__web__reference.png -- link__long_text_200__macos__reference.png -- link__rtl__web__reference.png - -Manual checks confirm “link” rather than “button,” URL exposure where supported, correct label/hint, Enter activation, no Space activation, visible focus, and disabled behavior. - -### 20.8 Link non-goals - -- Router integration or URL launching. -- Visited-history storage. -- Browser download behavior. -- Modifier-click/window management in the first release. -- Link color, underline, icon, or typography. - -### 20.9 Link acceptance - -- [ ] Link semantics and URL metadata are exact. -- [ ] Button semantics are absent. -- [ ] Enter activates and Space does not. -- [ ] Disabled state has no focus/action. -- [ ] Hover Card and Context Menu composition has integration proof. -- [ ] All screenshot and assistive-technology evidence is attached. - -## 21. Integration, screenshot, golden, and CI implementation - -### 21.1 First repair the aggregate harness - -Before relying on new component results: - -1. Change tool/run_integration_all.sh to enter packages/example, not example. -2. Ensure all_tests.dart imports Tooltip and every new component; the current pinned aggregate omits the existing Tooltip integration file. -3. Remove the two-second real delay in tearDownAll unless a documented runner bug requires it. -4. Replace cleanup pumpAndSettle() with bounded, component-aware cleanup. -5. Replace helpers that swallow keyboard exceptions. -6. Make tab-order helpers assert the actual focus node after each move. -7. Give each integration group a normal bounded timeout instead of a blanket 30-minute default that conceals hangs. -8. Run the shell runner in CI so stale paths and missing aggregate imports are caught. - -### 21.2 Deterministic pumping - -Use exact pumps for known transitions: - -~~~dart -await tester.pump(); // apply state/focus change -await tester.pump(const Duration(milliseconds: 200)); // fixed transition -~~~ - -For an asynchronous condition whose duration is not a public invariant, use a bounded helper: - -~~~dart -Future pumpUntil( - WidgetTester tester, - bool Function() condition, { - int maxFrames = 30, - Duration step = const Duration(milliseconds: 16), -}) async { - for (var frame = 0; frame < maxFrames; frame += 1) { - if (condition()) return; - await tester.pump(step); - } - fail('Condition was not reached within the bounded frame budget.'); -} -~~~ - -Do not use pumpAndSettle() for: - -- a Toast viewport with active dismissal timers; -- a Hover Card with pending open/close delay; -- a repeating or indeterminate animation; -- a cursor blink; -- an overlay intentionally holding a scheduled frame. - -A bounded helper must fail with a useful state diagnostic rather than silently continue. - -### 21.3 Screenshot helper - -Initialize one integration binding: - -~~~dart -final binding = - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); -~~~ - -Before an Android screenshot, convert the Flutter surface once in setup: - -~~~dart -await binding.convertFlutterSurfaceToImage(); -await tester.pump(); -~~~ - -Capture after all deterministic state and focus assertions: - -~~~dart -await binding.takeScreenshot( - 'toast__action_focused_paused__android__reference', -); -~~~ - -Use conditional imports for platform setup. A helper imported by web tests must not import dart:io. Keep: - -- a shared screenshot interface; -- an IO implementation for Android/macOS setup; -- a web-safe implementation; -- artifact naming and metadata shared across targets. - -The screenshot is taken only after assertions pass, so an image never masks a failed state transition. - -### 21.4 Screenshot manifest - -Every CI artifact bundle includes a machine-readable or Markdown manifest: - -The component contracts enumerate 51 distinct required screenshot names across macOS, Android, and web. Treat that list as the minimum review set; do not replace it with one generic gallery image per component. - -| Field | Example | -|---|---| -| Component | Toast | -| Scenario | action focused and timer paused | -| File | toast__action_focused_paused__web__reference.png | -| Git commit | Full Naked UI SHA | -| Flutter | 3.41.2 | -| Target | Chrome on Ubuntu runner | -| Surface | 800 by 600 logical pixels | -| DPR | 1.0 | -| Locale/direction | en-US / LTR | -| Text scale | 1.0 | -| Animation mode | disabled or fixed-duration | -| Test result | pass | -| Reviewer | name/date | - -Screenshots produced on a different SDK or surface are not silently compared as the same baseline. - -### 21.5 Golden harness - -Add canonical example golden tests under: - -packages/example/test/goldens/components - -The harness fixes: - -- Flutter SDK and host OS image; -- physical size and device-pixel ratio; -- locale and direction; -- text scale; -- brightness; -- font files loaded with FontLoader; -- animation state and clock; -- safe-area padding; -- scroll position and pointer/focus state. - -Use focused component finders with matchesGoldenFile. Include enough surrounding surface to verify overlay placement when that is the subject of the test. - -The only approved baseline update flow is: - -~~~sh -cd packages/example -flutter test test/goldens --update-goldens -flutter test test/goldens -~~~ - -The update commit includes an explanation of the intended visual change and reviewed image diffs. - -### 21.6 Accessibility-guideline fixture - -For every canonical styled component state with interactive controls: - -~~~dart -final semantics = tester.ensureSemantics(); -addTearDown(semantics.dispose); - -await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); -await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); -await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); -await expectLater(tester, meetsGuideline(textContrastGuideline)); -~~~ - -Apply only meaningful guidelines to a given fixture. For example, a purely decorative excluded Hover Card preview does not need a label itself, but its Link trigger does. Inline Link target-size interpretation needs product review because enlarging a target can distort text layout; label, keyboard focus, and contrast still remain mandatory. - -### 21.7 Exact local commands - -From the Naked UI repository root: - -~~~sh -flutter pub get -dart format --set-exit-if-changed . -flutter analyze -flutter test packages/naked_ui/test -flutter test packages/example/test -~~~ - -Target one component while developing: - -~~~sh -flutter test packages/naked_ui/test/src/naked_toast_test.dart -flutter test packages/naked_ui/test/semantics/naked_toast_semantics_test.dart -flutter test packages/example/test/goldens/components/naked_toast_golden_test.dart -~~~ - -Fast integration smoke: - -~~~sh -cd packages/example -flutter test -r compact -d flutter-tester integration_test/components/naked_toast_integration.dart -~~~ - -Real macOS target: - -~~~sh -cd packages/example -flutter config --enable-macos-desktop -flutter test -r compact -d macos integration_test/components/naked_toast_integration.dart -flutter test -r compact -d macos integration_test/all_tests.dart -~~~ - -The pinned upstream example does not contain a committed macOS directory. The team must deliberately choose to commit generated platform files or generate them reproducibly in CI before the real-target command. Do not fall back to flutter-tester while retaining a “macOS” job name. - -Android emulator: - -~~~sh -cd packages/example -flutter create --platforms android . -flutter test -r compact -d emulator-5554 integration_test/components/naked_toast_integration.dart -~~~ - -Web requires a web-safe integration driver. A standard driver entry is: - -~~~dart -import 'package:integration_test/integration_test_driver.dart'; - -Future main() => integrationDriver(); -~~~ - -Generate the platform files if the project has chosen CI generation: - -~~~sh -cd packages/example -flutter create --platforms web . -~~~ - -Start ChromeDriver in terminal A: - -~~~sh -chromedriver --port=4444 -~~~ - -While it remains running, execute the test in terminal B: - -~~~sh -cd packages/example -flutter drive --driver=test_driver/integration_test.dart --target=integration_test/all_tests.dart -d chrome -~~~ - -Stop terminal A after the test. Keep ChromeDriver lifecycle in its own CI step. Do not use an unbounded background process locally without arranging cleanup. - -### 21.8 Proposed blocking CI matrix - -| Job | SDK/target | Trigger | Blocking proof | -|---|---|---|---| -| Exact-minimum analyze/unit/semantics | Flutter 3.41.0, Ubuntu | Every PR | Declared package minimum compatibility | -| Primary format/analyze/unit/semantics | Flutter 3.41.2, Ubuntu | Every PR | Current pinned SDK behavior and semantics tree | -| Canonical example/goldens/guidelines | Flutter 3.41.2, pinned Ubuntu image | Every affected PR | Stable visual fixture, labels, targets, contrast | -| Integration smoke | flutter-tester | Every affected PR | Fast aggregate wiring and behavior | -| Real macOS integration | Flutter 3.41.2, -d macos | Every affected PR | Desktop focus, pointer, keyboard, overlays, screenshots | -| Android integration | API 34 emulator, Flutter 3.41.2 | Affected PRs or merge queue; always before release | Touch, long press, TalkBack-oriented semantics, safe areas, screenshots | -| Web integration | Pinned Chrome/ChromeDriver, Flutter 3.41.2 | Affected PRs or merge queue; always before release | Keyboard, accessibility DOM, screenshots | -| Remix consumer | Remix FVM Flutter 3.44.0 | Release candidate | Package/API compatibility with actual consumer floor | -| iOS manual release check | Remix-supported iOS target | Before tagged release | VoiceOver and platform focus/gesture behavior | - -If cost prevents Android/web on every PR, use path filtering and a merge queue. A release must not proceed from a manual workflow that nobody ran. - -### 21.9 CI artifact retention - -Upload: - -- screenshot manifest and all named screenshots; -- golden failure current/expected/diff images; -- flutter doctor -v; -- test machine target list; -- integration result JSON where available; -- semantics/accessibility spike notes for Combobox; -- manual AT checklist as a PR attachment or linked document. - -Keep failed-run artifacts long enough for reviewers to inspect them. Do not upload secrets, home-directory dumps, or unrelated logs. - -### 21.10 Flake policy - -- A retry may gather evidence but must not turn a flaky first failure green without recording it. -- No blanket continue-on-error on required component checks. -- No catch-and-log around keyboard, screenshot, or semantics operations. -- No arbitrary real sleep used as a stability fix. -- A quarantined test has an issue, owner, reason, and removal date; the affected component cannot be declared fully validated. -- Timer tests use fake frame time. -- Pointer coordinates derive from finder geometry rather than hard-coded global pixels except when testing viewport edges. - -### 21.11 Leak and disposal checks - -Every overlay/timer component test suite includes: - -- dispose while closed; -- dispose while open; -- dispose with a pending open timer; -- dispose with a pending close timer; -- replace external controller while open; -- remove trigger while open; -- route change while open; -- no exception from stale callback; -- no remaining overlay entry; -- no active timer or focus listener owned by the component. - -Where Flutter exposes useful debug assertions for transient callbacks, scheduled frames, or overlay state, assert them after deterministic cleanup. - -## 22. Required handoff package for the Naked UI team - -The implementation handoff is a reviewable evidence set, not just a package version. - -### 22.1 Per-component PR contents - -Each PR includes: - -1. **Contract summary:** public API and observable behavior delivered. -2. **Compatibility statement:** source, semantic, keyboard, and focus changes to existing APIs. -3. **Implementation notes:** state ownership, controller ownership, focus nodes, overlays, timers, and localization. -4. **Test map:** requirement-to-test-file and test-name mapping. -5. **Screenshot manifest:** links to every required state. -6. **Accessibility evidence:** semantics assertions, guidelines, and manual AT results. -7. **Platform evidence:** exact commands/CI runs for flutter-tester, macOS, Android, and web as applicable. -8. **Known limitations:** Flutter issues, target deviations, and explicit non-goals. -9. **Documentation:** API docs, example, migration note, and changelog. -10. **Consumer note:** how Remix should adopt the API and what it must still test. - -### 22.2 Requirement traceability table template - -| Requirement ID | Requirement | Automated test | Platform run | Screenshot | Manual AT | Result | -|---|---|---|---|---|---|---| -| TOAST-FOCUS-01 | Showing a toast does not move focus | naked_toast_test.dart: named test | macOS + web | N/A | VoiceOver | Pass | -| TOAST-TIMER-02 | Hover preserves remaining duration | named test | macOS | paused screenshot | N/A | Pass | -| TOAST-SEM-03 | Polite toast uses status role once | named semantics test | web | N/A | VoiceOver + Chrome tree | Pass | - -Use stable IDs in the PR description or component issue. Do not leave rows with “covered generally.” - -### 22.3 Manual accessibility result template - -~~~markdown -#### Manual AT check - -- Component/scenario: -- Naked UI commit: -- Flutter version: -- Device/OS: -- Browser, if web: -- Assistive technology/version: -- Starting focus: -- Actions performed: -- Expected announcement/behavior: -- Actual result: -- Pass/fail: -- Evidence or linked issue: -- Tester/date: -~~~ - -### 22.4 Screenshot review template - -~~~markdown -#### Visual review - -- Manifest/artifact link: -- Expected surface, DPR, locale, direction, text scale: -- States reviewed: -- Overlay collision checked: -- Focus indicator visible: -- Text clipping/overflow checked: -- Safe area checked: -- Diff expected: -- Reviewer/date: -~~~ - -### 22.5 API review questions - -Before approval, reviewers answer: - -- Can the behavior be styled without forking it? -- Is any product/business rule accidentally in Naked UI? -- Is state truly controlled or controller-owned as documented? -- Can a disabled path still activate through semantics or keyboard? -- Does every focus node have clear ownership? -- Can a timer or overlay survive disposal? -- Are semantic name, role, state, value, and actions all represented? -- Does the API require hard-coded English from the package? -- Does a screenshot cover only appearance, while semantics and keyboard have separate proof? -- Is an existing consumer likely to experience a semantic behavior change even if code still compiles? - -## 23. Release, rollout, and Remix consumption - -### 23.1 Package-level definition of done - -No component is done until: - -- [ ] Public API and doc comments are complete. -- [ ] Source formatting and analysis pass. -- [ ] Widget, semantics, and relevant parity tests pass. -- [ ] Aggregate integration imports and runner pass. -- [ ] Real macOS integration passes. -- [ ] Android and web required scenarios pass before release. -- [ ] Canonical goldens and accessibility guidelines pass. -- [ ] Required screenshots are reviewed. -- [ ] Manual VoiceOver, TalkBack, and web results are recorded. -- [ ] Disposal/leak cases pass. -- [ ] Compatibility and migration notes are written. -- [ ] Changelog names semantic or keyboard behavior changes. -- [ ] Open decisions for the component are resolved. -- [ ] No required check is ignored, swallowed, or advisory-only. - -### 23.2 Recommended release grouping - -Prefer incremental prereleases rather than one large release: - -1. Dialog role + Link + Field. -2. Toggle Group + Context Menu. -3. Toast + Hover Card. -4. Combobox only after the accessibility spike and cross-platform proof. - -Combobox should not delay stable, lower-risk primitives if its engine mapping remains blocked. - -### 23.3 Remix adoption steps - -Remix currently consumes naked_ui ^0.2.0-beta.7, while the audited upstream main reports 1.0.0-beta.3. Treat this as a migration, not a routine patch bump: - -1. Create a dedicated Remix dependency-upgrade PR. -2. Review Naked UI changelogs and public API differences between the pinned version and release candidate. -3. Update one existing Remix component at a time and keep its tests green. -4. Add new Remix components only after the base upgrade is stable. -5. Run Remix with its configured Flutter 3.44.0 FVM SDK. -6. Repeat accessibility guidelines and goldens using Remix's actual styles. -7. Keep a temporary compatibility table mapping each Remix component to the Naked API version it requires. -8. Do not use a broad dependency override in the release branch without pinning a reviewed commit. - -### 23.4 Proposed Remix implementation order - -After Naked releases: - -1. Popover from existing NakedPopover. -2. Link. -3. Field integration for TextField and Select validation. -4. Segmented/Toggle Group. -5. Alert Dialog variant. -6. Context Menu. -7. Toast. -8. Hover Card. -9. Combobox after the separate accessibility gate. - -Skeleton, Drawer/Sheet, and basic Scroll Area can proceed independently in Remix using the boundary decisions in section 3.1. - -### 23.5 Rollback strategy - -- New components are additive and can be withheld from Remix exports without reverting the Naked dependency. -- Existing Dialog and Toggle behavior changes require feature-level migration notes and targeted regression tests. -- If a platform semantics regression is found, prefer disabling the affected new semantic mapping behind an explicit compatibility option only as a short-lived patch with an issue and removal plan. -- Never solve a screen-reader regression by excluding the entire component from semantics. -- Keep the previously working package lock available for a Remix rollback until the upgrade PR has passed release validation. - -## 24. Open decisions and risk register - -No item in this section should be decided silently during implementation. Record the resolution in the relevant issue/PR and update public documentation. - -### 24.1 Decision log - -| ID | Decision | Recommendation | Owner | Must resolve by | -|---|---|---|---|---| -| D-01 | Toggle option semantics migration | Use button + toggled for all Toggle Group modes; document the announcement change. Keep Radio Group for radio semantics. | Naked API + accessibility reviewers | Before Toggle Group implementation | -| D-02 | Alert Dialog initial focus API | Keep optional initialFocusNode, document safe-target heuristics, and make canonical examples explicit. | Naked API reviewer | Before Alert Dialog PR approval | -| D-03 | Context Menu trigger semantic action | Preserve child role and expose long-press semantics; avoid a fake button. Prototype discoverability with VoiceOver/TalkBack. | Accessibility reviewer | During Context Menu spike | -| D-04 | Toast composition API | Use structured message/action/close helpers so duplicate message semantics can be excluded without hiding controls. | Naked API reviewer | Before Toast tests are written | -| D-05 | Toast global shortcut | Caller opt-in; canonical example may use F8. Do not reserve a key globally by default. | Naked maintainer | Before Toast PR approval | -| D-06 | Toast queue overflow | Default unlimited pending queue or add explicit nullable maxQueued; never silently drop without a dismissal reason. | Naked API reviewer | Before controller implementation | -| D-07 | Toast swipe in first release | Defer unless all alternate dismissal and deterministic drag tests fit the PR. | Product + maintainer | At Toast scoping | -| D-08 | Field/TextField duplicate metadata | Debug-assert conflicting values; allow identical explicit values; field scope is semantic source of truth. | Naked API reviewer | Before Field implementation | -| D-09 | Initial Field error announcement | Make initial error discoverable but not automatically assertive; announce later error transitions once. | Accessibility reviewer | Before Field semantics tests | -| D-10 | Combobox active-option strategy | Select only after the required macOS/Android/web spike; a status announcer is the leading fallback if role mapping alone is insufficient. | Accessibility + Flutter platform reviewer | Before Combobox public API freeze | -| D-11 | Combobox role on Flutter 3.41 | Use only if prototype has no regression; otherwise document fallback and upstream issue without claiming complete mapping. | Flutter platform reviewer | During Combobox spike | -| D-12 | Naked UI minimum Flutter | Keep >=3.41.0 only if an exact 3.41.0 job passes; otherwise raise the minimum deliberately. | Package maintainer | Before first release candidate | -| D-13 | Example platform directories | Commit reviewed minimal platform files or generate them reproducibly in CI; real target job names must match actual devices. | CI maintainer | Test-harness PR | -| D-14 | Golden host/font | Pin one Ubuntu image, Flutter 3.41.2, surface configuration, and checked-in licensed test font. | CI + design reviewer | Test-harness PR | -| D-15 | Android/web PR frequency | Prefer affected-path PR/merge-queue jobs; release is blocked unless both have passed the exact release commit. | CI maintainer | Test-harness PR | - -### 24.2 Risk register - -| Risk | Likelihood/impact | Detection | Mitigation | Exit condition | -|---|---|---|---|---| -| Flutter semantics enum is not fully mapped on a target | High for Combobox / high impact | Real AT spike and web accessibility tree | Keep properties correct, prototype fallback announcement, track upstream issue | Active option, expanded state, selection, and errors are understandable on supported targets | -| Duplicate screen-reader announcements | Medium / high | Manual AT plus one-node semantics assertions | Single semantic source, structured Toast/Field helpers, avoid role + liveRegion conflict | One intended announcement per transition | -| Focus trap or restoration regression | Medium / high | Real-target keyboard integration | Managed nodes, explicit invoker tracking, remove-trigger tests | All open/close paths restore or safely fall back | -| Timer flakiness | High / medium | Repeated CI and pending-timer disposal tests | Injectable durations, fake frame time, bounded pumps | Repeated suite has no retry-dependent pass | -| Overlay collision differs by platform | Medium / medium | Four-edge screenshots on macOS/web/Android | Shared positioning utility, geometry assertions | Overlay bounds pass on required surfaces | -| Headless package overclaims contrast/target accessibility | Medium / high | Review of test layer and docs | Keep guidelines in canonical example and Remix, document boundary | Release notes state exact scope of guarantees | -| Existing semantic behavior changes without source break | Medium / high for Toggle/Dialog | Changelog review and old/new semantics tests | Explicit compatibility note and migration tests | Consumer review signs off | -| Hidden integration file is not run | Medium / high | Aggregate import audit and shell runner CI | Import every component; compare file inventory to aggregate | Inventory check passes | -| Text editing shortcuts are stolen by Combobox | Medium / high | Keyboard table and IME tests | Input retains focus; handle only permitted keys; composing guard | Editing/caret/IME tests pass | -| Hover Card contains essential or interactive content | Medium / medium | Example/content review and focus scan | Strong non-goal; use Popover instead | Preview has no focusables/unique required information | -| Controller/timer listener leaks | Medium / medium | Dispose-open/pending tests | Clear ownership and listener replacement | No stale callback/overlay/timer after disposal | -| Remix dependency migration is larger than expected | High / medium | Dedicated upgrade PR and changelog audit | Separate upgrade from new components; pin reviewed release | Existing Remix suite passes on Flutter 3.44.0 | - -### 24.3 Escalation rule - -Block the relevant component when: - -- actual screen-reader behavior contradicts the semantics contract; -- a supported target cannot perform a required keyboard or touch path; -- focus can escape or is lost after a standard close path; -- a required test is flaky without retries; -- the implementation needs product styling or business logic to make the base behavior work; -- an unresolved Flutter limitation would make the release claim misleading. - -A blocked component does not block independent components from shipping. - -## 25. Source register - -### 25.1 Audited Remix and Naked UI sources - -Local Remix: - -- [Remix package dependency and SDK contract](packages/remix/pubspec.yaml) -- [Workspace Flutter version](.fvmrc) -- [Workspace package and SDK configuration](pubspec.yaml) - -Pinned Naked UI upstream at 0ca0b8bc2269ed331345cc705d99a073acdf5f5f: - -- [Package version and minimum SDK](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/pubspec.yaml) -- [Public widget exports](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_widgets.dart) -- [Dialog implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_dialog.dart) -- [Toggle and Toggle Group implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_toggle.dart) -- [Menu implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_menu.dart) -- [Popover implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_popover.dart) -- [Select implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_select.dart) -- [TextField implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_textfield.dart) -- [Tooltip implementation](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/lib/src/naked_tooltip.dart) -- [Dialog semantics tests](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/test/semantics/naked_dialog_semantics_test.dart) -- [Menu semantics tests](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/test/semantics/naked_menu_semantics_test.dart) -- [Toggle semantics tests](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/naked_ui/test/semantics/naked_toggle_semantics_test.dart) -- [Integration aggregate](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/example/integration_test/all_tests.dart) -- [Keyboard integration helpers](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/packages/example/integration_test/helpers/keyboard_test_helpers.dart) -- [Unit-test CI](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/.github/workflows/ci.yml) -- [Current desktop integration workflow](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/.github/workflows/integration-tests.yml) -- [Current Android integration workflow](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/.github/workflows/integration-android.yml) -- [Current shell integration runner](https://github.com/btwld/naked_ui/blob/0ca0b8bc2269ed331345cc705d99a073acdf5f5f/tool/run_integration_all.sh) -- [Latest published Naked UI API index](https://pub.dev/documentation/naked_ui/latest/naked_ui/) - -### 25.2 Official Flutter references - -- [Accessibility testing](https://docs.flutter.dev/ui/accessibility/accessibility-testing) — semantics tests, guideline checks, and platform accessibility tools. -- [Accessibility overview and release checklist](https://docs.flutter.dev/ui/accessibility) — Flutter's overall accessibility expectations. -- [Web accessibility](https://docs.flutter.dev/ui/accessibility/web-accessibility) — semantics-to-accessibility-DOM behavior and web considerations. -- [Assistive technologies](https://docs.flutter.dev/ui/accessibility/assistive-technologies) — screen-reader testing context. -- [Accessible UI design and styling](https://docs.flutter.dev/ui/accessibility/ui-design-and-styling) — contrast, target size, text scale, and visual considerations. -- [Testing overview](https://docs.flutter.dev/testing/overview) — unit, widget, and integration test boundaries. -- [Focus and focus traversal](https://docs.flutter.dev/ui/interactivity/focus) — focus-node lifecycle and traversal. -- [Actions and Shortcuts](https://docs.flutter.dev/ui/interactivity/actions-and-shortcuts) — intent/action keyboard architecture. -- [SemanticsRole API](https://api.flutter.dev/flutter/dart-ui/SemanticsRole.html) — available complex semantics roles. -- [matchesSemantics](https://api.flutter.dev/flutter/flutter_test/matchesSemantics.html) — exact semantics matcher. -- [AccessibilityGuideline](https://api.flutter.dev/flutter/flutter_test/AccessibilityGuideline-class.html) and [meetsGuideline](https://api.flutter.dev/flutter/flutter_test/meetsGuideline.html) — automated accessibility checks. -- [matchesGoldenFile](https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html) — golden comparison. -- [Integration screenshot API](https://api.flutter.dev/flutter/package-integration_test_integration_test/IntegrationTestWidgetsFlutterBinding/takeScreenshot.html) — screenshot capture. -- [FocusTraversalGroup](https://api.flutter.dev/flutter/widgets/FocusTraversalGroup-class.html) — composite traversal boundaries. -- [Shortcuts](https://api.flutter.dev/flutter/widgets/Shortcuts-class.html) — logical-key mapping. -- [Flutter issue 159741](https://github.com/flutter/flutter/issues/159741) — framework role-check implementation gap referenced by the Flutter 3.41.2 source. - -### 25.3 W3C WAI-ARIA and WCAG references - -- [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/) -- [Modal Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) -- [Menu Button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) -- [Menu and Menubar pattern, including context-menu keyboard behavior](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) -- [Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) -- [Button and toggle-button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) -- [Link pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/) -- [Toolbar composite pattern](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/) -- [Radio Group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) -- [Keyboard interface practice](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) -- [WCAG 2.2: Content on Hover or Focus](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html) -- [WCAG 2.2: Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages) -- [Forms tutorial](https://www.w3.org/WAI/tutorials/forms/) -- [Form labels](https://www.w3.org/WAI/tutorials/forms/labels/) -- [Form validation](https://www.w3.org/WAI/tutorials/forms/validation/) -- [Form notifications](https://www.w3.org/WAI/tutorials/forms/notifications/) - -### 25.4 Comparable headless primitive references - -These are behavior references, not APIs to copy mechanically: - -- [Base UI Toast](https://base-ui.com/react/components/toast) -- [Base UI Field](https://base-ui.com/react/components/field) -- [Base UI Preview Card](https://base-ui.com/react/components/preview-card) -- [Base UI Context Menu](https://base-ui.com/react/components/context-menu) -- [Base UI Toggle Group](https://base-ui.com/react/components/toggle-group) -- [Base UI Combobox](https://base-ui.com/react/components/combobox) -- [Radix Toast](https://www.radix-ui.com/primitives/docs/components/toast) -- [Radix Hover Card](https://www.radix-ui.com/primitives/docs/components/hover-card) -- [Radix accessibility overview](https://www.radix-ui.com/primitives/docs/overview/accessibility) - -## 26. Final handoff summary - -The Naked UI team should treat this work as a behavior and evidence program: - -1. harden the integration harness so failures cannot be swallowed; -2. implement each primitive with a written semantics and focus contract; -3. prove state and semantics in widget tests; -4. prove input, overlays, focus, and timers on real targets; -5. prove a deterministic reference appearance with goldens and screenshots; -6. test actual assistive technology, especially Combobox; -7. package the evidence and compatibility notes for Remix; -8. let Remix own and revalidate the final visual system. - -The release is ready when a reviewer can trace every important behavior to an automated test, a target run, a screenshot where appearance matters, and a manual assistive-technology result where platform output matters. diff --git a/plan/decisions.md b/plan/decisions.md deleted file mode 100644 index cb75c760..00000000 --- a/plan/decisions.md +++ /dev/null @@ -1,119 +0,0 @@ -# Decision log - -Living copy of the briefing's decision register -([§24.1](briefing.md#241-decision-log)). The briefing copy is frozen; **this -file is where resolutions are recorded.** No item here may be decided silently -inside an implementation PR — resolve the row, link the PR/issue, then -implement. Blocking relationships are shown on the -[status board](README.md#status-board). - -Status values: `open` → `resolved()` with a link to where it was decided. - -Phase 0 repair-slice review (2026-07-12): Group A and B1 proceeded without -choosing an SDK-floor policy, platform-directory strategy, golden host/font, -or Android/web cadence. The maintainer approved the evidence-backed D-12–D-15 -recommendations on 2026-07-12 before decision-dependent implementation began. - -### Phase 2 decision evidence (2026-07-13) - -- **D-17:** supersedes D-16's callback override. `NakedLink.linkUrl` is - required and non-nullable; `enabled` is the only availability switch. - `onActivated` observes each accepted ordinary activation before resolution - and cannot cancel it. The closest synchronous `NakedLinkResolver` receives - the Link context and exact URI, then returns `handled` or `platformDefault`; - no resolver is `platformDefault`. Resolver exceptions surface without a - fallback to prevent double navigation. Ordinary unmodified external web - links use `launchUrl(..., webOnlyWindowName: '_self')`; scheme-less internal - routes and non-web platforms use `url_launcher.Link`'s `FollowLink` path. - Modified primary, middle, secondary, and context-menu browser actions bypass - both the observer and resolver. Disabled Links retain their URI in state but - expose no Link role, semantic URL/action, focus path, or web `href`. Naked - UI accepts every URI unchanged, so callers own trust and scheme validation. - This is an unreleased hard cut: no `onPressed` compatibility constructor or - deprecated alias is retained. - -- **D-16 (superseded):** originally approved destination-owned Link - availability and navigation. A - non-null `linkUrl` is the destination and, together with `enabled`, controls - availability. With no `onPressed`, Naked UI delegates to Flutter's official - [`url_launcher.Link`](https://pub.dev/documentation/url_launcher/latest/link/Link-class.html), - which renders a real web link and follows the URI on native platforms. A - supplied `onPressed` replaces that default path so custom routing and native - navigation cannot both run. A null or explicitly disabled destination is - exposed as unavailable text with no URL, Link role, tap action, focus, or web - `href`; HTML does not define anchors as actually disabled - [elements](https://html.spec.whatwg.org/dev/semantics-other.html#disabled-elements). - This also follows WAI-ARIA guidance to prefer a native anchor because a Link - role alone does not provide browser navigation or context-menu behavior - ([Link Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/)). The public - `linkUrl` name remains unchanged for source compatibility. - Flutter's web Link delegate still contributes Link semantics for a null URI, - so the unavailable path omits that delegate; a keyed inner detector preserves - the consumer subtree when the platform wrapper enters or leaves the tree. - -### Phase 1 decision evidence (2026-07-12) - -- **D-02:** approved the optional caller-owned `initialFocusNode`. When the - supplied node is available and focusable, the alert dialog focuses it after - opening; otherwise normal route focus chooses the first focusable descendant. - Canonical examples must focus the least destructive action for irreversible - work, the expected action for a simple acknowledgement, or a non-action - semantic container near the start of long or structured content. Naked UI - never disposes the caller's node. This follows the - [WAI-ARIA modal-dialog focus guidance](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/), - while retaining the target control exposed by established primitives such as - [React Spectrum AlertDialog](https://react-spectrum.adobe.com/Dialog) and - [Radix Alert Dialog](https://www.radix-ui.com/primitives/docs/components/alert-dialog). - -### Phase 0 decision evidence (2026-07-12) - -- **D-12:** the official Flutter repository contains the exact `3.41.0` tag. - A local FVM 3.41.0 run resolved the workspace, analyzed with no issues, and - passed all 574 package/widget/semantics tests (three intentional skips). -- **D-13:** Flutter 3.41.2 generated standard Android, macOS, and web platform - directories totaling about 516 KiB (152/300/64 KiB). The reviewed generator - output is present in the working tree so local real-target commands and CI do - not regenerate target scaffolding on every run. -- **D-14:** the pinned Flutter 3.41.2 SDK contains `Roboto-Regular.ttf` with its - Apache-2.0 license. A checked-in copy plus an explicit Ubuntu runner label, - fixed surface/DPR/locale/text scale/brightness, and disabled animation now - provide the golden harness a reproducible baseline. -- **D-15:** GitHub has no recorded runs of the existing manual Android workflow. - Recent `flutter-tester` integration runs take roughly two to three minutes. - Affected-path PR/merge-queue jobs plus reusable Android/web release gates now - require the exact tagged commit while avoiding unrelated docs-only runs. - -| ID | Decision | Briefing recommendation | Must resolve by | Status | -|---|---|---|---|---| -| D-01 | Toggle option semantics migration (`selected` → `toggled`) | Use button + `toggled` for all Toggle Group modes; changelog + announcement note; keep Radio Group for radio semantics | Before Toggle Group implementation (phase 4) | open | -| D-02 | Alert Dialog initial focus API | Keep optional `initialFocusNode`; document safe-target heuristics; explicit canonical examples | Before Alert Dialog PR approval (phase 1) | [resolved(optional caller-owned node plus documented safe-target heuristics)](#phase-1-decision-evidence-2026-07-12) | -| D-03 | Context Menu trigger semantic action | Preserve child role + long-press semantic action; no fake button; prototype with VoiceOver/TalkBack | During Context Menu spike (phase 5) | open | -| D-04 | Toast composition API | Structured message/action/close helpers so duplicate message semantics can be excluded without hiding controls | Before Toast tests are written (phase 6) | open | -| D-05 | Toast global shortcut | Caller opt-in only; canonical example may use F8; never reserve a key by default | Before Toast PR approval (phase 6) | open | -| D-06 | Toast queue overflow | Unlimited pending queue or explicit nullable `maxQueued`; never drop silently without a dismissal reason | Before controller implementation (phase 6) | open | -| D-07 | Toast swipe in first release | Defer unless all alternate dismissal + deterministic drag tests fit the PR | At Toast scoping (phase 6) | open | -| D-08 | Field/TextField duplicate metadata | Debug-assert conflicting values; allow identical values; field scope is semantic source of truth | Before Field implementation (phase 3) | open | -| D-09 | Initial Field error announcement | Initial error discoverable but not automatically assertive; later transitions announce once | Before Field semantics tests (phase 3) | open | -| D-10 | Combobox active-option strategy | Choose only after the macOS/Android/web spike; status announcer is the leading fallback | Before Combobox public API freeze (phase 8) | open | -| D-11 | Combobox role on Flutter 3.41 | Use `SemanticsRole.comboBox` only if the prototype shows no regression; otherwise document fallback + upstream issue | During Combobox spike (phase 8) | open | -| D-12 | Naked UI minimum Flutter | Keep `>=3.41.0` only if an exact 3.41.0 CI job passes; otherwise raise the minimum deliberately | Phase 0 (test-harness PR) | [resolved(keep `>=3.41.0`; exact-minimum CI)](#phase-0-decision-evidence-2026-07-12) | -| D-13 | Example platform directories | Commit reviewed minimal platform files or generate reproducibly in CI; job names must match actual devices | Phase 0 (test-harness PR) | [resolved(commit reviewed Android/macOS/web directories)](#phase-0-decision-evidence-2026-07-12) | -| D-14 | Golden host/font pinning | One Ubuntu image, Flutter 3.41.2, fixed surface config, checked-in licensed test font | Phase 0 (test-harness PR) | [resolved(Ubuntu 24.04, Flutter 3.41.2, Roboto Apache-2.0, fixed harness)](#phase-0-decision-evidence-2026-07-12) | -| D-15 | Android/web PR frequency | Affected-path PR/merge-queue jobs; release blocked unless both passed on the exact release commit | Phase 0 (test-harness PR) | [resolved(affected PR/merge queue plus exact-tag release gates)](#phase-0-decision-evidence-2026-07-12) | -| D-16 | Link destination and web-navigation ownership | Make `linkUrl` the destination and availability source; use official `url_launcher.Link` for default navigation; a custom callback replaces the default; unavailable Links expose no native destination | Before Phase 2 reviewer corrections | superseded by D-17 | -| D-17 | Link activation resolver and observer | Require `linkUrl`, use `enabled` alone for availability, resolve ordinary activation through the nearest synchronous resolver, and keep `onActivated` observational | Before Phase 2 API correction | [resolved(required destination; resolver ownership; observational hook)](#phase-2-decision-evidence-2026-07-13) | - -## Risk register - -The risk register lives in the briefing -([§24.2](briefing.md#242-risk-register)) and stays there; link a risk row from -a phase plan when it becomes active. - -## Escalation rule ([§24.3](briefing.md#243-escalation-rule)) - -Block the component (not the program) when: real screen-reader behavior -contradicts the semantics contract; a supported target cannot perform a -required keyboard/touch path; focus escapes or is lost after a standard close -path; a required test is flaky without retries; base behavior needs product -styling or business logic to work; or an unresolved Flutter limitation would -make the release claim misleading. diff --git a/plan/integration-testing.md b/plan/integration-testing.md deleted file mode 100644 index 0b8c0234..00000000 --- a/plan/integration-testing.md +++ /dev/null @@ -1,229 +0,0 @@ -# Integration testing playbook - -This is the operational companion to the binding verification contracts in -[briefing §12](briefing.md#12-verification-architecture) and -[briefing §21](briefing.md#21-integration-screenshot-golden-and-ci-implementation). -It applies to every component phase. If this playbook and the frozen briefing -ever conflict, stop and resolve the conflict explicitly; do not weaken the -briefing silently. - -The rules below incorporate the failures and fixes from Phase 0 / PR #63. - -## 1. Choose the cheapest test layer that proves the behavior - -- Use unit tests for pure state, equality, controller, queue, timer, and - geometry logic. -- Use widget and semantics tests for one component tree, callbacks, focus, - keyboard mappings, semantics properties/actions, disposal, and fake-frame - timing. -- Use integration tests only for behavior that needs the canonical example or - a real platform: platform focus, pointer/hover, text input, overlays, - browser behavior, screenshots, and cross-component flows. -- Keep data local and deterministic. Register fakes before pumping the app; no - production services, live APIs, current time, randomness, or network images. -- Give every driven control a stable `ValueKey`. A phase plan must list the - fixture keys before implementation begins. - -Every contract requirement must map to the cheapest owning test plus any -real-target proof it needs. An integration test is not a substitute for exact -widget or semantics assertions. - -## 2. Authoritative runners - -| Proof | Authoritative command/path | Rule | -|---|---|---| -| Fast aggregate | `flutter test -r compact -d flutter-tester integration_test/all_tests.dart` from `packages/example` | Proves aggregate registration and shared behavior quickly. | -| Real macOS behavior | Same command with `-d macos` | Must run on a real macOS target; a macOS host using `flutter-tester` is not macOS proof. | -| Android behavior | `flutter test -r compact -d integration_test/all_tests.dart` | Native behavior is authoritative. `flutter drive` is reserved for screenshot/report-data transport. | -| Web behavior | `flutter drive` with `test_driver/integration_test_behavior.dart`, `-d web-server`, and `--browser-name=chrome` | Flutter web integration uses a driver; do not replace this with `flutter test -d chrome`. | -| Screenshot evidence | Dedicated `integration_test/screenshot_smoke.dart` with `test_driver/integration_test.dart` | Behavior must pass independently before evidence capture runs. | - -Use the checked-in workflows and scripts as the executable source of truth: - -- `.github/workflows/integration-tests.yml` -- `.github/workflows/integration-android.yml` -- `.github/workflows/integration-web.yml` -- `tool/run_android_integration.sh` -- `tool/run_integration_all.sh` - -Do not combine native behavior and host-transport evidence into one result that -can hide an in-app failure. Keep the behavior aggregate and screenshot smoke as -separate blocking steps. - -## 3. Aggregate registration - -- Add every `integration_test/components/*_integration.dart` file to - `integration_test/all_tests.dart` and invoke its `main` in a named group. -- Run `packages/example/test/integration_inventory_test.dart`; importing or - creating a file without registering it in the aggregate is not delivery. -- Run the new component file directly while iterating, then run the aggregate. - A direct-file pass alone is insufficient. -- Keep the aggregate timeout bounded. Do not raise the shared two-minute - per-test timeout to make one slow or hung test green. - -## 4. Assertions must prove outcomes - -- Focus through a known `FocusNode`, pump one frame, assert primary focus, - send one complete logical key event, then assert the value, callback, overlay, - route, focus, or visible result. -- For disabled paths, prove focus/action refusal and assert no state changed. -- Test pointer and semantic activation against the same observable callback - contract where both are supported. -- Do not assert only that a widget still exists after an interaction. -- Do not catch and return `false`, catch and log, or otherwise turn a failed - key, pointer, semantics, screenshot, or cleanup operation into a green test. -- Cleanup runs in `finally`/teardown and cleanup failures fail the test. Never - suppress `removePointer`, gesture release, controller disposal, or overlay - removal errors. - -## 5. Deterministic time and pumping - -- Use `pump()` for one state/focus application frame. -- Use an exact `pump(duration)` only when the duration is a public invariant. -- Use the shared bounded `pumpUntil` for asynchronous observable state whose - completion time is not contractual. The predicate must observe the real - state under test, and timeout must fail with a diagnostic. -- Do not fix a slow runner with extra padding frames, a real - `Future.delayed`, retries, or a larger blanket timeout. -- `pumpAndSettle()` is allowed only when the tree is known to settle. It is - forbidden around live timers, cursor blinking, repeating/indeterminate - animation, pending hover-card delays, or active toast timers. -- Drive timers with fake frame time. Verify just-before, at-boundary, pause, - resume with remaining duration, cancellation, and disposal paths. -- Reset viewport/DPR, focus, pointer, lifecycle, controllers, overlays, and - any global test configuration through teardown-safe cleanup. - -The delayed hover failure from Phase 0 is the reference pattern: wait for the -observable hover state with a bound, not a guessed 32 ms frame; then propagate -pointer cleanup failures. - -## 6. Platform rules - -### macOS - -- Confirm `flutter devices` lists macOS and run with `-d macos`. -- If sandboxed IO/network behavior is introduced, review both debug/profile - and release entitlements. Run `flutter clean` after entitlement changes. -- A Flutter-tool foreground warning is evidence to investigate, not a reason - to retry. Reproduce the smallest failing file, inspect `flutter doctor -v`, - and compare with the pinned hosted macOS run. -- Moving stalls across unrelated tests indicate a runner/toolchain problem; - stable failure at one assertion indicates test or product behavior. Record - that distinction and never mask either with retries. - -### Android - -- Check `flutter devices` and `adb devices` before claiming a local run. -- Use `flutter test` for behavior and the checked-in Android script for the - behavior-plus-screenshot CI sequence. -- If no local SDK/emulator exists, say so and use the hosted API 34 result as - the authoritative Android proof. Missing local hardware is not permission to - omit the platform gate. -- Exercise touch/long-press, safe-area/keyboard inset, and TalkBack-oriented - paths on Android when the component contract requires them. - -### Web - -- Chrome and ChromeDriver must match by major version. Record both versions. -- Start ChromeDriver on port 4444, retain its PID, and arrange cleanup with a - shell trap or an equivalent lifecycle step. -- Use `-d web-server --browser-name=chrome`; keep browser dimensions explicit - for fixture evidence. -- Web-targeted Dart code must not import `dart:io`; use conditional imports. -- Flutter finders drive the Canvas-rendered widget tree. Browser URL, history, - cookies, or DOM-only assertions require a browser-level seam and must not be - inferred from widget finders. -- Preserve the full web log. A successful command must contain the success - marker and no in-app timeout/failure marker; a host process exit code alone - is not sufficient evidence. - -Flutter 3.41.2 cannot currently produce stable web screenshot evidence in this -repository. Do not claim a web screenshot passed. Before closing a component -phase, either establish a stable reviewed capture path on the pinned toolchain -or obtain an explicit maintainer decision for alternate evidence. Until then, -the pinned web behavior log is required but does not satisfy a binding web -screenshot requirement by itself. - -### iOS and assistive technology - -- The current automated matrix does not prove iOS. Record the required manual - iOS release check separately. -- Semantics-tree assertions do not replace VoiceOver, TalkBack, or Chrome - accessibility-tree results. Record target, OS/browser, AT version, actions, - expected versus actual behavior, tester, and date. - -## 7. Screenshots, goldens, and accessibility evidence - -- Run behavior assertions before capturing a screenshot. -- Real-target screenshots are review artifacts, not cross-platform pixel - goldens. Goldens use the pinned Ubuntu image, Flutter SDK, checked-in font, - 800×600 surface, DPR 1, locale/direction/text scale/brightness, and fixed - animation state. -- Android prepares the native surface once before capture. On Flutter 3.41.2, - macOS uses the reviewed `RepaintBoundary` fallback and standard binding - report data because the native screenshot plugin is unavailable. -- Use `component__scenario__platform__theme.png`; include the tested SHA, - Flutter version, target, surface, DPR, locale/direction, text scale, - animation mode, result, and reviewer in the manifest/handoff. -- For pull requests, distinguish the reviewed head SHA from GitHub's tested - merge-ref SHA. After merge, record the resulting main commit as well. -- Run accessibility guidelines on the canonical styled example, never only on - the headless wrapper. Dispose the semantics handle in teardown-safe cleanup. -- A screenshot proves appearance only. Keyboard, focus, semantics, lifecycle, - and callback behavior require separate assertions. - -## 8. Failure-triage protocol - -When any local or hosted integration check fails: - -1. Record the exact SHA, target/device, Flutter/Dart version, OS/Xcode or - Chrome/ChromeDriver version, command, first failing assertion, and full log. -2. Run the smallest failing component file on the same target; then run the - aggregate to detect ordering or leaked-state effects. -3. Reproduce at least twice without adding a retry to the gate. Compare other - targets only to classify the boundary, not to dismiss the failing target. -4. Inspect the observable state, focus node, pointer lifecycle, scheduled - frames, widget tree, and platform connection relevant to the first failure. -5. Fix the root condition. For timing, wait on observable state; for cleanup, - propagate the error; for platform setup, fix the target/toolchain. -6. Add or strengthen the smallest regression test and prove that it fails - against the old behavior before accepting the green result. -7. Rerun format, analysis, the focused regression, package/example suites, - aggregate `flutter-tester`, and every affected real target. -8. Monitor the exact PR head checks and the workflows triggered by the merged - commit. Do not rely on an older green SHA. - -Stop and escalate instead of merging when: - -- a required target cannot run and no authoritative hosted result exists; -- the same test remains flaky without a root cause; -- a required test is absent from the aggregate; -- a driver or helper can swallow an in-app or cleanup failure; -- required screenshot or manual AT evidence is unsupported or missing; -- a platform result contradicts the component contract. - -Quarantine is allowed only with a linked issue, owner, reason, and removal -date, and the component cannot be described as fully validated while -quarantined. - -## 9. Per-phase integration checklist - -Every just-in-time phase plan must name: - -- [ ] The requirement-to-test map and why each integration scenario needs a - real target rather than only a widget test. -- [ ] The integration file, aggregate group, stable fixture keys, deterministic - local data, and reset behavior. -- [ ] Pointer, keyboard/focus, semantics action, disabled, RTL, 200% text, - collision/inset, dynamic-removal, and disposal scenarios that apply. -- [ ] Exact pumps or observable `pumpUntil` conditions, timeout diagnostics, - and teardown ownership. -- [ ] The platform matrix: `flutter-tester`, real macOS, API 34 Android, pinned - web, and any explicit N/A with justification. -- [ ] Screenshot names, golden cases, manifest metadata, accessibility - guidelines, and manual AT sessions. -- [ ] Exact local commands, unavailable local targets, hosted jobs, artifact - locations, and reviewed/tested SHAs. - -A phase cannot close until every applicable item is evidenced or an explicit -blocking decision is recorded. diff --git a/plan/phases/00-test-harness.md b/plan/phases/00-test-harness.md deleted file mode 100644 index 80c489df..00000000 --- a/plan/phases/00-test-harness.md +++ /dev/null @@ -1,244 +0,0 @@ -# Phase 0 — Test-harness hardening - -Status: **Closed** — delivered by -[PR #63](https://github.com/btwld/naked_ui/pull/63), squash-merged to `main` as -`58a48a3` from reviewed head `3cb5487`. - -Goal: make it impossible for later component work to look green while proving -nothing. Today the integration harness can swallow keyboard failures, "verify" -tab order without checking focus, skips Tooltip entirely, runs a "macOS" CI job -on `flutter-tester`, and has no screenshot/golden/guideline evidence at all. -Fix the harness first so every later phase inherits trustworthy gates. - -Contract: briefing [§6.2](../briefing.md#62-confirmed-delivery-gaps-to-fix-before-adding-the-new-suite) -and [§21](../briefing.md#21-integration-screenshot-golden-and-ci-implementation). -Baseline commit: `0ca0b8b` (all file:line references below verified 2026-07-12). - -Decisions **D-12–D-15** were resolved with maintainer approval on 2026-07-12 -in [decisions.md](../decisions.md). Their decision-dependent tasks are included -in this implementation. - -Split guidance: land **Group A + B1** as one PR (pure repairs, releasable), -then **B2–B6** as CI PRs once D-12/D-13/D-15 are resolved. **Group C** may land -here or ride the first component PR (phase 1), per §6.2 — do not let it slip -past phase 1. - ---- - -## Group A — repair the existing harness (no decisions needed) - -### A1. Fix the stale integration runner path -- **Where:** `tool/run_integration_all.sh:9` — `pushd example`, but the app - lives at `packages/example`. The script fails on first use today. -- **How:** change to `pushd packages/example`. -- **Verify:** `bash tool/run_integration_all.sh flutter-tester` runs every - component file to completion from the repo root. - -### A2. Add Tooltip to the aggregate runner -- **Where:** `packages/example/integration_test/all_tests.dart:6-17` imports 12 - component files; `components/naked_tooltip_integration.dart` exists on disk - but is never imported, so it silently doesn't run in CI. -- **How:** add the import and a `group('Tooltip Tests', tooltip_tests.main)` - entry. Then add a guard so this can't recur: a small test (or CI step) that - lists `integration_test/components/*.dart` and fails if any file is missing - from `all_tests.dart` (briefing §21.1 item 8 / "inventory check"). -- **Verify:** aggregate run on `flutter-tester` executes Tooltip groups; - deleting the import makes the inventory check fail. - -### A3. Make `testKeyboardActivation` throw instead of returning false -- **Where:** `packages/example/integration_test/helpers/keyboard_test_helpers.dart:47-72` - — wraps key sends in `try/catch`, returns `bool`, callers can (and do) ignore - it. A failed key event currently passes CI. -- **How:** per briefing [§12.4](../briefing.md#124-keyboard-test-standard): - remove the try/catch and the `bool` return; focus via a known node (or tap - when pointer-focus is the subject), pump one frame, **assert the target has - primary focus**, send the key, pump the needed duration, and let callers - assert the outcome (value change, overlay, callback count). Update all 8 - call-site files: `naked_button_integration.dart`, - `naked_dialog_integration.dart`, `naked_popover_integration.dart`, - `naked_toggle_integration.dart`, `naked_checkbox_integration.dart`, - `naked_tabs_integration.dart`, `naked_accordion_integration.dart`, - `naked_radio_integration.dart` — each must assert the post-key outcome, not - the helper's return. -- **Verify:** aggregate green on `flutter-tester`; sanity-check by temporarily - sending a wrong key in one test and confirming the suite **fails**. - -### A4. Make `verifyTabOrder` assert actual focus -- **Where:** same helpers file, lines 24-40 — after `nextFocus()` it only - asserts `findsOneWidget` (comment admits it: "Just verify the widget - exists"). Tab order is currently unproven. -- **How:** after each traversal step, assert - `FocusManager.instance.primaryFocus` is the node attached to - `expectedOrder[i]` (e.g. compare against `Focus.of(element(...))` / - the widget's `FocusNode`). Include disabled-item skips where used. -- **Verify:** reorder two finders in one existing call and confirm the test - fails; restore. - -### A5. Remove the real 2-second delay and the 30-minute blanket timeout -- **Where:** `packages/example/integration_test/all_tests.dart:22-24` - (`defaultTestTimeout = 30 minutes` conceals hangs) and `:31-35` - (`tearDownAll` with `Future.delayed(seconds: 2)` real sleep). -- **How:** set a bounded default (e.g. 2 minutes per test), drop the - `tearDownAll` delay — if a documented runner bug requires it, keep it with a - comment linking the issue (briefing §21.1 items 3, 7). -- **Verify:** aggregate run completes; total wall time drops; an intentionally - hung test times out at the bounded limit, not 30 minutes. - -### A6. Bounded cleanup and a `pumpUntil` helper -- **Where:** `cleanupBetweenTests` (helpers file, lines 8-20) calls - `pumpAndSettle()`, which hangs on components with live timers or repeating - animations — fatal once Toast/Hover Card exist. 156 `pumpAndSettle` calls - exist across `integration_test/` overall. -- **How:** add the bounded `pumpUntil` helper from briefing - [§21.2](../briefing.md#212-deterministic-pumping) (fails with a diagnostic - after a frame budget); rewrite `cleanupBetweenTests` to use bounded pumps. - Do **not** rewrite all 156 call sites now — that churn belongs to each - component phase; this task only makes shared helpers timer-safe and provides - the tool. -- **Verify:** existing aggregate stays green; `pumpUntil` has its own small - test proving it fails (not passes) when the condition is never met. - -## Group B — CI gates - -### B1. Run the shell runner (or an inventory smoke) in CI -- **Why:** A1/A2 regressions must fail a PR, not be discovered manually - (briefing §21.1 item 8). -- **How:** add a fast job/step that executes - `bash tool/run_integration_all.sh flutter-tester` (or at minimum the - inventory check from A2 plus one component file via the script). -- **Verify:** break the path locally on a branch → CI fails. - -### B2. Make the macOS job actually run macOS *(D-13 resolved)* -- **Where:** `.github/workflows/integration-tests.yml:46` runs - `-d flutter-tester` on a `macos-latest` runner under the name "Integration - Tests". `packages/example` has **no** committed `macos/` directory, so - `-d macos` cannot run today. -- **How:** resolve D-13 (commit minimal reviewed platform files vs - `flutter create --platforms macos .` in CI), then split the workflow into a - fast `flutter-tester` smoke job and a real `-d macos` job. Job names must - match the device they run (briefing §21.7-21.8). -- **Verify:** CI log shows the run targeting `macos`; a focus-dependent test - passes there. - -### B3. Gate Android on PRs or nightly + release *(D-15 resolved)* -- **Where:** `.github/workflows/integration-android.yml:3-4` — - `workflow_dispatch` only; nobody is required to run it. -- **How:** per D-15: affected-path `pull_request` trigger or nightly schedule, - and a release gate requiring a passing run on the exact release commit. -- **Verify:** open a PR touching `packages/naked_ui/lib/` → Android job queues - (or nightly run visible + release checklist references it). - -### B4. Add a web integration workflow *(D-13, D-15 resolved)* -- **Why:** no web gate exists; web is where semantics-DOM and keyboard behavior - diverge most. The driver entry already exists - (`packages/example/test_driver/integration_test.dart`). -- **How:** pinned Chrome/ChromeDriver job running - `flutter drive --driver=test_driver/integration_test_behavior.dart --target=integration_test/all_tests.dart -d chrome` - after web platform files exist (D-13), per briefing §21.7. -- **Verify:** job green in CI; artifacts uploaded. - -### B5. Exact-minimum SDK job *(D-12 resolved)* -- **Where:** package floor is Flutter `>=3.41.0` - (`packages/naked_ui/pubspec.yaml:12`) but every CI job pins `3.41.2` — the - declared minimum is untested. -- **How:** per D-12: either add an analyze/unit/semantics job on exactly - `3.41.0`, or deliberately raise the floor to `3.41.2` in a changelog'd PR. -- **Verify:** the job exists and passes, or the floor is raised — no third state. - -### B6. Stop the advisory coverage theater -- **Where:** `.github/workflows/ci.yml:79` — coverage check is - `continue-on-error: true` with actual 7.3% vs an advertised 80% target. -- **How:** don't jump to a blocking 80% (it would be a lie in the other - direction). Agree a component-level rule instead: new/changed component - source in a phase PR must come with its contract tests (process.md Phase B), - and the coverage job reports without claiming a threshold it doesn't enforce. - Make the agreed rule blocking; delete the misleading 80% label. -- **Verify:** CI output no longer advertises an unenforced threshold. - -## Group C — evidence infrastructure (may ride phase 1; do not slip past it) - -### C1. Screenshot helper + manifest -- **How:** shared helper per briefing - [§21.3](../briefing.md#213-screenshot-helper) (single - `IntegrationTestWidgetsFlutterBinding`, `convertFlutterSurfaceToImage()` on - Android, capture **after** assertions pass, conditional io/web imports), - artifact naming `component__scenario__platform__theme.png`, and the manifest - fields from [§21.4](../briefing.md#214-screenshot-manifest). CI uploads - screenshots + manifest as artifacts. -- **Verify:** one existing component (e.g. Dialog) produces a named screenshot - artifact in CI on macOS. - -Implementation deviation (2026-07-12): Flutter 3.41.2's `integration_test` -package registers native screenshot plugins only for Android and iOS; invoking -`takeScreenshot` on macOS throws `MissingPluginException`. The helper therefore -uses a fixed `RepaintBoundary.toImage()` desktop fallback and inserts its PNG -bytes into the binding's standard `screenshots` report data. Android uses the -native surface conversion/capture path. Capture remains a dedicated -`screenshot_smoke.dart` target so the full behavioral aggregate and evidence -capture have independent blocking results without retries. The host driver -uses the standard request-data protocol and validates/writes the PNG bytes and -manifest; the behavior aggregate uses a minimal standard driver. - -Web screenshot evidence is explicitly unsupported on Flutter 3.41.2: its -WebDriver screenshot command timed out before invoking the host callback, while -the web repaint-boundary fallback produced inconsistent transparent regions -across identical pinned runs. The web implementation therefore throws a clear -unsupported error if capture is requested. The blocking pinned-Chrome behavior -aggregate remains enabled and uploads its test log; later component phases must -not claim reviewed web screenshots until this engine limitation is resolved. - -### C2. Golden harness *(D-14 resolved)* -- **How:** `packages/example/test/goldens/components/` harness pinning SDK, - surface, DPR, locale/direction, text scale, brightness, fonts via - `FontLoader`, animation state ([§21.5](../briefing.md#215-golden-harness)). - Baseline update flow is the two-command sequence in §21.5 plus reviewed - image diffs. -- **Verify:** one golden for an existing component passes twice in CI - (deterministic) and fails on an intentional 1px change. - -### C3. Accessibility-guideline fixture helper -- **How:** helper applying `labeledTapTargetGuideline`, - `androidTapTargetGuideline`, `iOSTapTargetGuideline`, - `textContrastGuideline` to canonical styled examples - ([§21.6](../briefing.md#216-accessibility-guideline-fixture)) — guidelines - run against the styled example, never the headless wrapper alone. -- **Verify:** guideline test green for one existing canonical example; fails - when a label is removed. - ---- - -## Acceptance - -- [x] A1–A6 implemented; aggregate integration suite green on `flutter-tester` -- [x] A3/A4 proven to fail on induced errors (no catch-and-continue remains) -- [x] Inventory check prevents un-imported integration files (A2) -- [x] B1 smoke and B2 real-macOS job implemented; local targeted and hosted - aggregate macOS runs are green -- [x] B3/B4/B5 implemented per resolved D-12/D-13/D-15 -- [x] B6: no CI output advertises an unenforced threshold -- [x] C1 helper/manifest implemented; named 800×600 real-macOS artifact produced - and visually inspected locally -- [x] C2 golden harness implemented; update/verify is deterministic and an - intentional 1px mutation was proven to fail -- [x] C3 accessibility-guideline helper and canonical fixture implemented; - unlabeled-target failure is covered -- [x] Existing widget and integration suites still pass -- [x] `plan/README.md` status board + `plan/decisions.md` D-12…D-15 updated - -Phase 0 is closed. PR #63 at reviewed head `3cb5487` was squash-merged to -`main` as `58a48a3`. It has green hosted proof for the main and exact-minimum -test suites, `flutter-tester`, real macOS, pinned headless Chrome, and the API -34 Android emulator; the workflows triggered by the merged commit also passed. -Android could not be run locally because this machine has no Android -SDK/emulator, so the hosted job is the authoritative Android evidence. - -## Verify commands - -```sh -flutter pub get -dart format --set-exit-if-changed . -flutter analyze -flutter test packages/naked_ui/test -bash tool/run_integration_all.sh flutter-tester -cd packages/example && flutter test -r compact -d flutter-tester integration_test/all_tests.dart -``` diff --git a/plan/phases/02-link.md b/plan/phases/02-link.md deleted file mode 100644 index 0606c072..00000000 --- a/plan/phases/02-link.md +++ /dev/null @@ -1,601 +0,0 @@ -# Phase 2 — Link - -Status: **D-17 is implemented and the required local format, analyze, widget, -semantics, example, focused integration, and `flutter-tester` aggregate gates -pass. Exact Flutter 3.41.0 and matched Chrome/ChromeDriver 150.0.7871.115 also -pass locally. Focused real-macOS Link integration passes; the full macOS -aggregate remains host-blocked because this interactive session cannot keep the -test app foregrounded without injecting real keyboard events into Flutter's -synthetic keyboard state. Exact-head hosted verification, Android, web -screenshots, manual assistive-technology sessions, and real Context Menu/Hover -Card composition remain closure gates.** - -Goal: provide a headless inline navigation primitive that exposes Link rather -than Button semantics. It activates once through an ordinary primary pointer, -Enter, Numpad Enter, or semantic tap; leaves Space, secondary click, modified -activation, and browser context-menu actions to their normal owners; reports -immutable interaction state; and retains native web anchors through Flutter's -official `url_launcher.Link`. Consumers own styling, localized copy, visited -history, destination trust, and optional subtree routing policy. - -Contract: the final API is defined by resolved -[D-17](../decisions.md#phase-2-decision-evidence-2026-07-13). -It supersedes D-16 and the frozen briefing's callback-override language. No -compatibility constructor or deprecated alias is permitted. - -## D-17 final contract — 2026-07-13 - -- `NakedLink.linkUrl` and `NakedLinkState.linkUrl` are required, non-nullable - `Uri` values. `enabled` is the only availability switch; disabled Links retain - their URI in state but expose no Link role, semantic URL/action, focus path, - or web `href`. -- `onPressed` is removed. `onActivated` is an observational - `ValueChanged?`: for an accepted ordinary activation it runs exactly once - before resolution and cannot cancel navigation. -- The closest `NakedLinkResolver` receives the Link build context and exact URI. - `handled` suppresses default navigation; `platformDefault`, or no resolver, - continues to it. Resolver exceptions surface and must not trigger fallback. -- On web, ordinary external URI activation uses - `launchUrl(uri, webOnlyWindowName: '_self')`; failure is reported through - Flutter error reporting without retry. Scheme-less internal routes and all - non-web default navigation use the official `FollowLink` callback. -- `url_launcher.Link` remains enabled-only to preserve real native anchors, - copy-link, middle-click, modifier, and context-menu behavior. A keyed inner - detector preserves a consumer's stateful child as that wrapper changes. -- Pointer modifiers are read from `HardwareKeyboard`. Modified primary, - middle-click, secondary click, and browser context-menu paths bypass both the - observer and resolver. Interaction state clears safely during disable; - post-frame hover restoration reports `[true, false, true]` beneath a - stationary pointer. - -## Research and readiness - -- No repository or ancestor `AGENTS.md` exists. The phase follows - `plan/process.md`, `plan/integration-testing.md`, `plan/decisions.md`, and the - binding briefing. -- The phase worktree is `.context/worktrees/phase-02-link` on branch - `feat/naked-link`, created directly from the approved baseline. It does not - stack on the unmerged Alert Dialog branch. -- There is no Link primitive, export, package test, example, registry entry, - integration group, golden, screenshot scenario, README entry, or changelog - entry in the baseline. -- The baseline Flutter 3.41.2 package suite passes 574 tests with three - intentional external-integration launcher skips. This is the pre-change - regression reference. -- Pinned Flutter 3.41.0 and 3.41.2 both expose `Semantics.link` and - `Semantics.linkUrl`. Both assert that a non-null URL requires the Link flag. - Real-browser review showed that combining an application callback with this - live web `href` does not coordinate navigation ownership. Flutter's official - [`url_launcher.Link`](https://pub.dev/documentation/url_launcher/latest/link/Link-class.html) - provides native-anchor coordination. D-17 keeps the delegate only while the - Link is enabled; its `FollowLink` callback remains the default for - scheme-less internal routes and non-web platforms, while ordinary external - web navigation explicitly stays in the current tab. -- Flutter has no `SemanticsRole.link` in the pinned API. The binding contract's - `link: true` flag is the supported representation; adding Button semantics as - a fallback would be incorrect. -- Flutter's ambient `ActivateIntent` is bound to Space on every platform. On - web, Enter/Numpad Enter use `ButtonActivateIntent`, while Space uses a - prioritized Activate-then-scroll path. Reusing - `NakedIntentActions.button` or registering an `ActivateIntent` action would - let Space activate the Link. Link therefore needs a private Link-specific - intent mapped only from Enter and Numpad Enter; Space must remain absent from - its local shortcuts/actions. This follows the - [Flutter ActivateIntent contract](https://api.flutter.dev/flutter/widgets/ActivateIntent-class.html), - [WidgetsApp shortcut contract](https://api.flutter.dev/flutter/widgets/WidgetsApp/defaultShortcuts.html), - and [WAI-ARIA Link Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/link/). -- `NakedFocusableDetector` already owns internal focus nodes, borrows external - nodes, swaps them without disposing caller resources, preserves focus across - replacement, disables traditional traversal, exposes hover/focus callbacks, - and composes `Shortcuts`/`Actions`. `WidgetStatesMixin`, - `NakedStateScopeBuilder`, and `NakedState` provide the existing immutable - builder/scope pattern. -- `NakedButton` is useful only as a structural reference. Its Button semantics, - Space shortcut, long-press API, non-null click cursor default, and timer-based - keyboard press feedback do not satisfy the Link contract and will not be - generalized in this phase. -- A Link-specific `GestureDetector` can reserve only primary tap and leave - secondary click unhandled. It must exclude its gesture semantics so the - single outer Link node owns the semantic tap action. -- When `semanticLabel` overrides visible content, the semantics wrapper must - exclude descendant naming semantics so the accessible name appears once. - Without an override, visible text supplies the name. Decorative external-link - icons remain explicitly excluded in the canonical consumer fixture. -- Context Menu and Hover Card do not exist at this baseline. The Link PR can - prove that secondary click is unclaimed and hover/primary/keyboard paths are - independent, but binding scenario 6 and acceptance cannot close until the - later component PRs compose their real implementations around Link. -- Flutter 3.41.2 still cannot produce stable web screenshot evidence in this - repository. The three required Link web screenshots are explicit closure - blockers unless a stable pinned capture is established or a maintainer - approves alternate evidence. A passing web behavior log is not substituted. -- VoiceOver, TalkBack, Chrome accessibility-tree, and release-iOS VoiceOver - sessions require human operators. Automated semantics will not fill them. - -## Contract matrices - -### Universal semantics matrix - -| Dimension | Link answer | Automated owner | Real/manual proof | -|---|---|---|---| -| Primitive | Discoverable Link, never Button | exact semantics test | VoiceOver, TalkBack, Chrome tree | -| Name | visible child text, or one caller-localized `semanticLabel` override | semantics tests including Arabic | all AT sessions | -| Role/flags | `link: true`; `button` absent | exact flags test | all AT sessions | -| State | `enabled` alone controls availability; the required URI stays in state | widget + semantics transitions | macOS/Android/web | -| Value/URL | required `linkUrl` is retained while disabled; enabled native anchor/semantic URL is removed on disable | state, semantics, and pinned-Chrome DOM tests | Chrome tree/href inspection | -| Actions | semantic tap only while enabled; observer then nearest resolver/default | semantics action/resolver tests | VoiceOver/TalkBack | -| Hint | optional caller-localized `semanticHint`, once | semantics test | all AT sessions | -| Children | visible label preserved unless an explicit label overrides it; decorative icon excluded by consumer | semantics/example tests | Chrome tree | -| Exclusion | `excludeSemantics` removes the Link semantics subtree and focus semantics | semantics test | N/A | -| Ownership | external focus nodes are borrowed and never disposed | lifecycle tests | aggregate teardown | - -### Input, state, and lifecycle matrix - -| Path | Enabled result | Disabled result | Required assertion | -|---|---|---|---| -| Ordinary primary tap | feedback, observer, then nearest resolver/default once | no recognizer/callback/feedback | ordering, DOM location, and state transitions | -| Canceled primary sequence | press true → false; no callback | no state transition | pointer cancel test | -| Modified primary / middle / secondary | browser-owned; observer/resolver bypassed | unclaimed | trusted browser auxiliary-context tests | -| Enter/Numpad Enter | observer, resolver/default once per physical key sequence | no activation | known focus node + key-down/repeat/up sequence | -| Space | no Link observer/resolver and no Link press state | no callback | widget test; web scroll outcome | -| Semantic tap | same observer/resolver/default path once | action absent | semantics action test | -| Hover | state/callback true then false | no hover callback | mouse gesture test | -| Focus/Tab | state/callback and normal traversal | skipped in traditional traversal | focus and next-target assertions | -| Resolver change | nearest current resolver owns future ordinary activation | N/A | nested resolver/rebuild test | -| Disable | immediately unavailable; transient press/hover/focus clear after safe notification | already unavailable | parent-`setState` lifecycle tests | -| Stationary re-enable | hover restores after layout when the pointer remains inside | N/A | detector and Link hover regression | -| Focus-node replacement | listener moves; focused state handed off; neither external node disposed | same ownership | lifecycle test | -| Disposal | internal detector node/listeners removed; external node remains usable | same | teardown/no exception | - -## Requirement-to-test map - -| ID | Requirement | Cheapest automated owner | Required real proof | -|---|---|---|---| -| LINK-API-01 | Child/builder invariant; immutable state/scope includes URL and all widget states | `naked_link_test.dart` + hash contract | N/A | -| LINK-ACT-01 | Primary tap once; cancellation and secondary click do not activate | widget gesture tests | macOS/web hover+pointer; Android touch | -| LINK-KEY-01 | Enter and Numpad Enter activate; Space is unclaimed | widget shortcut tests | macOS and pinned web scroll/result | -| LINK-STATE-01 | `enabled` controls activation, traversal, feedback, cursor, semantic URL, and disabled state while URI remains retained | widget transitions + platform-channel feedback test | all behavior targets | -| LINK-STATE-02 | Hover/focus/press callbacks and builder/scope snapshots are exact | widget state tests | macOS/web fixture readout | -| LINK-LIFE-01 | Focus ownership/replacement/disposal, safe disable, and stationary hover restoration do not leak | lifecycle + detector tests | aggregate teardown on macOS/web | -| LINK-NAV-01 | Observer ordering, nearest resolver ownership, current-tab default navigation, and disabled DOM paths have one owner | widget + pinned-Chrome DOM click tests | pinned Chrome location/href | -| LINK-SEM-01 | Link flag, URL, name, hint, enabled/focus/action exact; Button absent | `naked_link_semantics_test.dart` | VoiceOver/TalkBack/Chrome tree | -| LINK-SEM-02 | Disabled action absent; label override not duplicated; icon/exclusion correct; Arabic/RTL | semantics + example tests | all AT sessions | -| LINK-COMP-01 | Primary, secondary, hover, and keyboard paths compose without conflict | Link secondary-path test now; future Context Menu/Hover Card integration | later Phase 5/7 real targets | -| LINK-VIS-01 | Inline, hover, focus, disabled, external hint, 200% text, and RTL appearance | pinned golden + screenshot scenarios | reviewed macOS/Android/web artifacts | - -## Tasks - -### A1. Add failing public API, state, interaction, and lifecycle tests - -- **Where:** add `packages/naked_ui/test/src/naked_link_test.dart`; extend - `packages/naked_ui/test/hashcode_contract_test.dart` only for the new public - state type. -- **How:** first reference the final required-URI constructor and resolver API - so the focused test fails to compile. Before production behavior, cover the - child/builder invariant; immutable state/scope URL equality/hash; nearest - resolver wins; `handled` suppresses fallback; `platformDefault` and no - resolver fall through; observer-before-resolver ordering; Link context and - exact custom URI; resolver exception behavior; ordinary primary once; - canceled, secondary, middle, and modified gestures; Enter/Numpad Enter; - Space with no observer/resolver or pressed state; enabled-only availability; - hover/focus/press callbacks; feedback only while enabled; dynamic disable; - stationary hover restoration; held-key repeats; directional disabled focus; - selectable and rich text; external focus ownership, replacement, and - disposal. -- **Red proof:** observe the missing API, then use the smallest targeted - assertions/mutations if several behaviors become green through shared - infrastructure. Record the first failing expectation for every group. -- **Verify:** `fvm flutter test packages/naked_ui/test/src/naked_link_test.dart` - and the focused hash test. - -### A2. Add failing exact semantics tests - -- **Where:** add - `packages/naked_ui/test/semantics/naked_link_semantics_test.dart`. -- **How:** use `ensureSemantics` with teardown-safe disposal and assert the full - node, not only a label finder: Link true; Button absent; URI; name; hint; - enabled; focusable/focused transitions; tap action enabled only; visible text - naming; explicit label replacing rather than concatenating child semantics; - disabled discoverability; Arabic label/hint in RTL; external icon excluded; - entire semantics subtree absent under `excludeSemantics`. Invoke the semantic - tap and assert the same callback count. -- **Red proof:** each group must fail because the Link node/metadata/action does - not exist, not because the finder is wrong. -- **Verify:** - `fvm flutter test packages/naked_ui/test/semantics/naked_link_semantics_test.dart`. - -### B1. Implement the smallest Link behavior surface - -- **Where:** add `packages/naked_ui/lib/src/naked_link.dart`; export it from - `packages/naked_ui/lib/src/naked_widgets.dart`; add a Link namespace/private - intent to `packages/naked_ui/lib/src/utilities/intents.dart`. -- **How:** implement `NakedLinkState` with state helpers, equality/hash, and a - required non-null `linkUrl`. Add `NakedLinkResolution`, - `NakedLinkResolveCallback`, and `NakedLinkResolver.maybeOf`. The constructor - exposes `onActivated`, never `onPressed`; `enabled` alone determines - availability. Compose the existing state mixin, state scope, focusable - detector, primary-only gesture path, and one effective Link semantics node. - Accepted ordinary activation performs feedback, observer, then the nearest - resolver exactly once. `handled` stops; `platformDefault`/no resolver follows - the current-tab external web default through `launchUrl(..., '_self')`, or - `FollowLink` for internal/non-web defaults. Let resolver failures surface. - Map only Enter/Numpad Enter without repeats; do not bind Space. Read pointer - modifiers from `HardwareKeyboard` and delegate modified primary actions - straight to `FollowLink`. Keep `url_launcher.Link` only for enabled native - anchors and preserve the keyed wrapper transition. When disabled, synchronize - transient state without invoking consumers during `didUpdateWidget`, then - deliver ended-state callbacks after the frame. Borrow external focus nodes - and never dispose them. Remove URL, Link flag, focus, and actions while - disabled; preserve the advanced semantics-exclusion escape hatch. -- **Avoid:** an `onPressed` compatibility shim, per-Link resolver/target, - validation or rewriting of caller URIs, retry after a failed web launch, - hand-rolled DOM anchors, router dependencies, visited state, long-press - ownership, raw key handlers, timers, styles, English defaults, changes to - Button, or a speculative generic pressable base class. -- **Verify:** focused A1/A2 tests, then - `fvm dart format --set-exit-if-changed packages/naked_ui/lib/src/naked_link.dart packages/naked_ui/lib/src/naked_widgets.dart packages/naked_ui/lib/src/utilities/intents.dart packages/naked_ui/test/src/naked_link_test.dart packages/naked_ui/test/semantics/naked_link_semantics_test.dart packages/naked_ui/test/hashcode_contract_test.dart` - and `fvm flutter analyze packages/naked_ui`. - -### C1. Add the deterministic styled fixture, guideline, and golden proof - -- **Where:** add `packages/example/lib/api/naked_link.0.dart` and - `packages/example/test/naked_link_example_test.dart`; register it in - `packages/example/lib/registry.dart`; extend - `packages/example/test/accessibility_guidelines_test.dart`; add - `packages/example/test/goldens/components/naked_link_golden_test.dart` and a - reviewed Ubuntu baseline under `.../baselines/`. -- **How:** create one resettable, locally controlled fixture containing inline - primary, disabled, external-hint, and next-focus targets plus visible result, - callback count, and hover/focus/press readout. Style only in the example with - visible focus, hover, pressed, and disabled treatments; preserve inline text - layout; exclude the decorative external icon; provide Arabic/RTL and 200% - text configurations; disable/fix animation for evidence. Use no network or - router. -- **Stable keys:** `link.primary`, `link.disabled`, `link.external`, - `link.result`, `link.next-focus`, plus `link.state`, - `link.disable-primary`, `link.reset`, and a fixed evidence surface key. -- **Golden:** default inline/focus-capable canonical surface at 800×600, DPR 1, - pinned Roboto, locale/direction/text scale/brightness fixed. Generate only - through the approved Ubuntu update-then-verify diagnostic, inspect the PNG, - and check it in unchanged with its SHA-256. -- **Guidelines:** prove label, standalone Android/iOS target size, and contrast - on the styled fixture without forcing inline links into a button-sized line - box. -- **Verify:** focused example, guideline, and golden commands, then all example - tests. - -### D1. Add component integration behavior and aggregate inventory - -- **Where:** add - `packages/example/integration_test/components/naked_link_integration.dart`; - import/group it in `packages/example/integration_test/all_tests.dart`; rerun - `packages/example/test/integration_inventory_test.dart`. -- **How:** drive only stable keys. Add five presently executable binding - scenarios: Tab → known Link focus → Enter → observer then resolver once with - retained focus; focused Space → no activation (and observable page scroll on - web); hover/down/up → exact state readout and one resolver-owned result; - semantic tap → the same ordinary activation path; disabled skipped by Tab - with no pointer/semantic action. Add Arabic/RTL and 200% long-text assertions, - disabling while focused, secondary click remaining unclaimed, and pinned - Chrome cases for current-tab default navigation, resolver-owned activation, - browser auxiliary actions that bypass observer/resolver, and enabled-only - native `href` presence/removal. The WebDriver driver must click the visible - semantics anchor under `flt-semantics-host`; hidden native anchors are checked - only for `href` presence/removal. -- **Deferred composition:** do not create fake Hover Card/Context Menu - implementations. Record LINK-COMP-01 as a closure blocker and require Phase 5 - and Phase 7 integration suites to wrap the real `NakedLink` and prove the - sixth scenario. -- **Pumps/cleanup:** use one event-delivery frame plus one focus-callback rebuild - frame for traversal, one frame for other synchronous state transitions, and - bounded observable waits only for web scrolling or platform attachment. - Restore view, DPR, direction, text scale, scroll controller, semantics handle, - mouse gesture, focus nodes, and fixture state. No `pumpAndSettle`, sleeps, - retries, swallowed key/gesture errors, or blanket timeout change. -- **Verify:** focused Link integration on `flutter-tester`, inventory, then the - aggregate. - -### D2. Capture required visual evidence without weakening behavior gates - -- **Where:** extend `packages/example/integration_test/screenshot_smoke.dart`, - `packages/example/test/screenshot_evidence_test.dart`, the exact artifact - assertions in `.github/workflows/integration-tests.yml`, and - `tool/run_android_integration.sh`; extend the Ubuntu golden diagnostic in - `.github/workflows/ci.yml` only as required for a genuinely absent baseline. -- **How:** assert scenario behavior before every capture. Produce and review - `link__default_inline__macos__reference.png`, - `link__keyboard_focus__macos__reference.png`, - `link__disabled__android__reference.png`, and - `link__long_text_200__macos__reference.png` with complete manifests. Keep the - three binding web names (`hover`, `external_hint`, `rtl`) explicitly blocked - by the documented Flutter 3.41.2 limitation; do not manufacture a widget - golden or behavior log as a substitute. -- **Verify:** behavior suites first; dedicated local macOS screenshot driver; - hosted API 34 behavior plus transport; exact file-name/size/manifest checks; - human visual review of every required PNG produced. Record exact head and - GitHub merge-ref separately. - -### E1. Complete docs, review, evidence, and PR handoff - -- **Where:** dartdoc in `naked_link.dart`; root and package READMEs; package - changelog; registry; this plan and `plan/README.md`; PR description. -- **How:** document Link-versus-Button use, required URI ownership, default - navigation, subtree resolver policy, observational activation, `enabled` as - the sole availability switch, Enter/Numpad/Space behavior, state and focus - ownership, semantics override/icon rules, secondary/modifier-click - boundaries, styling and router non-goals, and Remix responsibilities. State - explicitly that callers validate and trust their own URIs. Build the §22 - ten-item packet with the stable requirement table, platform commands/runs, - screenshot review, manual AT rows, limitations, and exact SHAs. -- **Review:** inspect the entire diff for API drift, accidental Button/Space - behavior, duplicate semantics/names/actions, disabled descendants, - feedback/cursor/focus leaks, selection interference, router or styling scope, - duplicated helpers, speculative abstraction, timer use, and unrelated files. -- **Delivery:** stage only Phase 2 files, commit intentionally, push - `feat/naked-link`, and open one ready-for-review PR targeting `main`. Monitor - every applicable check on the exact PR head, manually dispatch affected-path - workflows if a documentation-only final commit would otherwise lack an exact - run, and do not merge without explicit maintainer authorization. - -## Integration proof plan - -### Scenario-to-platform matrix - -| Scenario | flutter-tester | real macOS | API 34 Android | pinned Chrome/web | -|---|---:|---:|---:|---:| -| Tab, Enter, observer then resolver/default once, retained focus | Yes | Required | Required focus path | Required | -| Space no observer/resolver activation | Yes | Required | Required | Required + scroll outcome | -| Pointer hover/press/tap state | Yes | Required | Touch/press required; hover N/A | Required hover | -| Semantic tap same observer/resolver/default path | Yes | Required | Required | Required tree/action | -| Disabled skipped/no action/cursor | Yes | Required | Required + screenshot | Required | -| Arabic RTL + 200% long text | Yes | Required + screenshots | Required behavior | Required behavior; RTL screenshot blocked | -| Secondary/middle/modified activation browser-owned | Yes | Required | N/A | Required | -| Real Context Menu/Hover Card composition | Not available | Deferred | Deferred | Deferred to Phase 5/7 | - -### Evidence and manual sessions - -- Pinned Linux golden: canonical Link surface, fixed 800×600/DPR 1/Roboto, - reviewed with SHA-256 and mutation/compare proof. -- macOS screenshots: default inline, keyboard focus, and 200% long text. -- Android screenshot: disabled state. Hosted API 34 is authoritative because - the local SDK is incomplete and no emulator/device is attached. -- Web screenshots: hover, external hint, and RTL are required but currently - unsupported. Pinned behavior log and Chrome-tree record remain separate. -- Manual sessions: macOS VoiceOver, API 34 TalkBack, pinned Chrome - accessibility tree/keyboard, and release-level iOS VoiceOver. Record exact - target/version/actions/expected/actual/tester/date; never infer them. - -## Verification and publication gates - -Focused development: - -```sh -flutter test packages/naked_ui/test/src/naked_link_test.dart -flutter test packages/naked_ui/test/semantics/naked_link_semantics_test.dart -flutter test packages/example/test/naked_link_example_test.dart -cd packages/example -flutter test -r compact -d flutter-tester integration_test/components/naked_link_integration.dart -``` - -Required local publication gate from the repository root: - -```sh -dart format --output=none --set-exit-if-changed . -flutter analyze --fatal-infos -flutter test packages/naked_ui/test -flutter test packages/example/test -cd packages/example -flutter test -r compact -d flutter-tester integration_test/components/naked_link_integration.dart -flutter test -r compact -d flutter-tester integration_test/all_tests.dart -``` - -Additional exact proof: - -```sh -fvm flutter test packages/example/test/integration_inventory_test.dart -cd packages/example -fvm flutter test -r compact -d macos integration_test/components/naked_link_integration.dart -fvm flutter test -r compact -d macos integration_test/all_tests.dart -fvm flutter drive --driver=test_driver/integration_test.dart --target=integration_test/screenshot_smoke.dart -d macos --dart-define=NAKED_UI_CAPTURE_SCREENSHOTS=true --dart-define=NAKED_UI_GIT_SHA= --dart-define=NAKED_UI_FLUTTER_VERSION=3.41.2 -``` - -Hosted gates: primary and exact-minimum suites; canonical golden/guidelines; -`flutter-tester`; real macOS; API 34 Android behavior plus screenshot transport; -pinned Chrome/ChromeDriver behavior log; PR-title policy. Every result must be -green on the exact PR head or an identified GitHub merge ref. - -## D-17 local execution evidence — 2026-07-13 - -- Exact Flutter 3.41.0 dependency resolution and analysis pass; the full - package suite passes with 615 tests and three documented external-integration - skips. -- Matched Chrome/ChromeDriver 150.0.7871.115 passes both the full web aggregate - and the trusted visible-semantics-anchor Link driver. The Link proof covers - resolver-owned activation, native middle and modified auxiliary navigation, - disabled visible/native anchor removal, and current-tab pointer and Enter - defaults. -- Chrome exposes semantic Links as native `` elements with an implicit Link - role, not a redundant `role="link"` attribute. The driver therefore selects - visible non-hidden anchors beneath `flt-semantics-host`. -- ChromeDriver's W3C middle-button action does not perform native auxiliary - navigation even for a plain HTML anchor. The accepted driver uses - ChromeDriver's DevTools input endpoint, asserts a trusted middle-button - `auxclick`, and verifies the new context and destination before closing it. -- Browser destinations use a query marker. A hash marker is consumed as a - Flutter web route and normalized back to `/`, which makes successful `_self` - navigation unobservable. -- Focused real-macOS Link integration passes. The full aggregate's unrelated - foreground retry is not accepted as Link evidence: a manual foreground lets - Accordion keyboard focus proceed, but real host keystrokes then corrupt the - synthetic `HardwareKeyboard` stream. Hosted macOS remains authoritative. - -## Superseded D-16 historical evidence — 2026-07-13 - -This retained audit record describes the earlier D-16 callback-override -implementation. It does not specify or validate the D-17 resolver API. No -push, PR update, merge, or hosted exact-head D-17 run is claimed here. - -### Available local publication gates - -- Flutter 3.41.2 format and analyze pass with no findings. -- Package suite: 609 pass; three documented external-integration skips. -- Example suite: 24 pass; two host-specific golden comparison skips on macOS. -- Focused Link, semantics, detector, and hash proof: 66 pass. -- Aggregate: 99 pass and one documented Tooltip skip on both `flutter-tester` - and the real macOS runner. -- Matched Chrome/ChromeDriver 150.0.7871.115 aggregate passes. Exact disabled - semantics and DOM assertions prove no Link role, URL, action, anchor, or - `href` remains. -- The separate W3C WebDriver proof in this record exercised D-16's custom - callback override. It is superseded by the D-17 trusted visible-semantics - anchor coverage for observer/resolver ownership and current-tab defaults. -- Flutter 3.41.0 exact-minimum dependency resolution, analyze, and the full - 609-test package suite pass with the same three documented skips. - -### Correction-specific pending gates - -- The correction has not been pushed, so prior hosted checks do not validate - this candidate. Exact-head Ubuntu, macOS, pinned-web, and API 34 Android jobs - must be rerun after publication. -- No Android emulator or device is attached locally; API 34 remains a hosted - gate. -- Required web screenshots, human assistive-technology sessions, and real - Phase 5/7 composition remain closure blockers below. - -## Original PR execution evidence — 2026-07-13 (superseded) - -Review-ready PR: [#65](https://github.com/btwld/naked_ui/pull/65). The fully -tested implementation/evidence head is -`24460f0a94b657854c95d5dc900e5ef7215d9604`; its GitHub test merge ref is -`09e62c8dc29b424a1d00e5e7de8cfc4a99cd124f`. The PR remains unmerged. -These checks establish the original implementation's evidence but do not -validate the unpublished D-17 resolver correction above. - -### Test-first and failure-triage record - -- The first API tests failed to compile because `NakedLink` and - `NakedLinkState` did not exist. The first semantics tests failed because no - Link node, URL, or Link action existed. -- Targeted restored mutations proved the tests reject Space activation - (callback count 3 instead of 2), secondary activation (1 instead of 0), and - Button semantics (Button true instead of false). A contrast mutation failed - at ratio 1.00 before the approved link color was restored. -- Fixture review produced intentional red proofs before each correction: - inline height was 48px instead of less than 48px; a standalone target filled - 680px; the standalone guideline fixture was absent; ambient font family was - null; and a synthetic safe-inset surface was 800×600 instead of 800×560. -- The first hosted golden candidate was rejected at 800×397. The second fixed - the surface but exposed fallback-glyph blocks and a missing Material icon. - The third candidate fixed both by preserving the ambient font and loading - Material Icons from the pinned Flutter SDK. Only that reviewed 800×600 PNG - was checked in unchanged. -- The first API 34 screenshot was rejected because content overlapped the top - system inset. The synthetic-inset regression failed for the same reason; - `SafeArea` fixed the root condition, and the replacement hosted image was - reviewed. - -### Local verification on Flutter 3.41.2 - -- Format, analyze, and dartdoc dry run: pass with no findings. -- Package suite: 597 pass; three documented external-integration skips. -- Example suite after the reviewed golden: 24 pass; two host-specific pixel - comparison skips on macOS. -- Focused Link package/semantics/hash proof: 39 pass. -- Focused Link integration: 8/8 on `flutter-tester` and real macOS. -- Aggregate: 96 pass and one documented Tooltip skip on both - `flutter-tester` and real macOS. -- Screenshot smoke: six behavior scenarios pass; the exact-head real-macOS - driver captures the four Link states plus the inherited Dialog state and - records a complete manifest. - -### Hosted verification - -All seven checks passed for head `24460f0` / merge ref `09e62c8`: - -- [Flutter CI run 29229092269](https://github.com/btwld/naked_ui/actions/runs/29229092269): - primary Ubuntu 24.04 tests, format/analyze/DCM, reviewed golden comparison, - guidelines, and exact-minimum Flutter 3.41.0 all pass. -- [Integration run 29229092358](https://github.com/btwld/naked_ui/actions/runs/29229092358): - aggregate `flutter-tester` and real macOS behavior pass; macOS screenshot - capture, exact file assertions, and artifact transport pass. -- [Web run 29229092255](https://github.com/btwld/naked_ui/actions/runs/29229092255): - matched Chrome/ChromeDriver 150.0.7871.115 behavior passes, including the - observable Space-scroll postcondition; the behavior log is retained as an - artifact but is not substituted for missing screenshots. -- [Android run 29229092250](https://github.com/btwld/naked_ui/actions/runs/29229092250): - API 34 Pixel 6 behavior and screenshot transport pass. -- [PR-title run 29229091554](https://github.com/btwld/naked_ui/actions/runs/29229091554): - pass. - -### Reviewed visual evidence - -- Ubuntu golden `naked_link__keyboard_focus.png`: 800×600, DPR 1, pinned - Roboto/Material Icons, en-US/LTR, SHA-256 - `88f39adcc2a5916f370d2ed5fdd8ff897e9c6cc4f00a5e3a9d71152ccf22086e`. - The same hosted job subsequently compared the checked-in file unchanged. -- macOS default inline: - `02f2565b397780d8ea50d2a8c98c754fcab85464d13abe76c8b962097fd3a6a7`. -- macOS keyboard focus: - `18683fbc41e260fa54622db65893c164bd4bdb29b06cbdea32754a098c9d9095`. -- macOS 200% long text: - `4bf12ea4007dadc972a74bb71fa9a376f85dc823aca53658d381a70a68085cda`. - All three are 800×600/DPR 1, and hosted bytes match the reviewed local files. -- Android disabled: - `db651e38a3e4b5f53e4ae07cd059e02a235b29563ea222806ce96795b5636b75`; - 1080×2274 physical pixels, DPR 2.625, safe content surface approximately - 411.43×817.52 logical pixels. The manifest records merge ref `09e62c8`. - -### Required closure blockers - -- `link__hover__web__reference.png`, - `link__external_hint__web__reference.png`, and - `link__rtl__web__reference.png` remain unsupported on Flutter 3.41.2. -- VoiceOver, TalkBack, Chrome accessibility-tree, and release-level iOS - VoiceOver sessions require human operators and are not available here. -- LINK-COMP-01 requires real Phase 5 Context Menu and Phase 7 Hover Card - implementations around Link; placeholders do not satisfy the contract. -- Maintainer merge authorization and post-merge `main` verification have not - been provided. These blockers prevent closure but do not invalidate the - review-ready PR. - -## Acceptance and stop conditions - -- [x] Every A1/A2 test was observed failing for the intended missing behavior - before implementation and the red evidence is recorded. -- [x] Link public API, required URI state equality/scope, `enabled`-only - availability, resolver ownership, and observational activation match - D-17 without router, styling, or visited state. -- [x] Ordinary primary/Enter/Numpad/semantic activation, browser-owned - auxiliary paths, feedback, cursor, callbacks, and dynamic disable pass - focused tests. -- [x] Link/URL/name/hint/enabled/focus/action semantics are exact; Button and - duplicate naming are absent; disabled/excluded behavior passes. -- [x] Focus-node ownership/replacement/disposal and aggregate teardown pass. -- [x] Canonical fixture, stable result/reset/readout, Arabic RTL, 200% text, - external-icon exclusion, golden, and accessibility guidelines pass. -- [ ] Correction integration, inventory, fast aggregate, focused real-macOS, - exact Flutter 3.41.0, and pinned Chrome 150 pass locally. The full macOS - aggregate is host-blocked; exact-head hosted macOS and API 34 remain - pending publication. -- [x] All seven screenshot names have reviewed evidence, or Phase 2 is - explicitly blocked; unsupported web screenshots are not marked passed. -- [ ] VoiceOver, TalkBack, Chrome accessibility-tree, and release-level iOS - records are attached; missing human evidence blocks closure. -- [ ] Real Context Menu and Hover Card composition proof is attached after - those components exist; placeholder wrappers do not satisfy it. -- [ ] Full local publication commands and Flutter 3.41.0 pass; hosted - correction-head macOS and Android verification remains pending - publication. -- [x] Docs, changelog, compatibility statement, traceability, manifests, - visual review, and ten-item handoff packet are ready. -- [ ] Entire correction diff is locally reviewed and committed; PR #65 still - needs the correction push and exact-head checks. It remains unmerged - without explicit maintainer authorization. - -Block Phase 2 closure (not independent program work) if Link maps as a Button, -Space activates or is swallowed on web, ordinary default activation opens a new -tab or navigates twice, an auxiliary browser action invokes the observer or -resolver, disabled paths retain activation, focus, or anchors, accessible -naming is duplicated, focus ownership leaks, any required target is -retry-dependent, the real composition scenario is unavailable, required web -screenshots remain unsupported, or manual AT evidence is unavailable. diff --git a/plan/process.md b/plan/process.md deleted file mode 100644 index 2174a6e7..00000000 --- a/plan/process.md +++ /dev/null @@ -1,127 +0,0 @@ -# Per-component process - -Every component phase follows this workflow. It is a navigation layer over the -briefing — the briefing sections linked here are the binding contract; this -file just makes them executable in order. Do not restate contract details here. - -## Ground rules (apply to every PR) - -- **Headless boundary** — behavior/semantics in Naked UI; styling, copy, and - business rules stay in consumers ([§5](briefing.md#5-definition-of-the-headless-boundary)). -- **API conventions** — builder/child invariant, controlled state, controller - ownership, effective-enabled, localization (no hard-coded English), reduced - motion ([§9](briefing.md#9-cross-component-api-conventions)). -- **Semantics contract** — answer every row of the universal matrix - ([§10](briefing.md#10-universal-semantics-contract)); mind the Flutter 3.41.2 - role caveats ([§10.2](briefing.md#102-important-flutter-3412-caveat)). -- **Keyboard/focus rules** — test outcomes, not key sends; use - `Shortcuts`/`Actions`/`FocusTraversalGroup`; directionality and restoration - rules ([§11](briefing.md#11-universal-keyboard-and-focus-rules)). -- **One behavior contract per PR**, each PR releasable - ([§7](briefing.md#7-delivery-sequence-and-pull-request-boundaries)). -- **Integration proof is operational, not implied** — every phase follows - [integration-testing.md](integration-testing.md), including authoritative - runners, bounded waits, failure triage, artifacts, and manual AT evidence. -- Confirmed vs proposed vs open-decision language is defined in - [§2.1](briefing.md#21-confirmed-facts-versus-proposals). Open decisions are - resolved in [decisions.md](decisions.md) **before** the implementation PR. - -## Workflow (briefing [§8](briefing.md#8-required-implementation-process)) - -| Phase | Deliverable | Gate | -|---|---|---| -| **A — Contract review** | Component contract section copied into the issue/PR; semantics matrix written; every input path, focus path, timer, platform scenario, and known engine limitation listed | Reviewer sign-off on the contract, decisions resolved, integration playbook checklist mapped | -| **B — Failing tests** | Tests in the order of §8 Phase B (invariants → builder/scope → pointer → keyboard/focus → semantics → overlay → timers → disposal) | Each test fails for the intended missing behavior | -| **C — Implementation** | Smallest behavior surface; existing `NakedState`/builder/scope conventions; injectable durations; no styles, no English defaults | Analyze/format/unit green | -| **D — Example fixture** | Deterministic canonical example: stable `ValueKey`s, local data, state readout, fixed viewport, RTL/large-text variants, reset behavior | Fixture reviewed against §8 Phase D list | -| **E — Platform proof** | Follow [integration-testing.md](integration-testing.md): flutter-tester → real macOS → Android behavior → pinned web behavior; independent screenshot/golden evidence; accessibility guidelines; manual AT checks | All layers in [§12.1](briefing.md#121-required-test-layers) evidenced; unsupported requirements block closure unless explicitly resolved | -| **F — Handoff packet** | The 10-item evidence package ([§22.1](briefing.md#221-per-component-pr-contents)) incl. traceability table ([§22.2](briefing.md#222-requirement-traceability-table-template)), manual AT records ([§22.3](briefing.md#223-manual-accessibility-result-template)), screenshot review ([§22.4](briefing.md#224-screenshot-review-template)) | Reviewer answers the API review questions ([§22.5](briefing.md#225-api-review-questions)) | - -## Test standards (non-negotiable) - -- Semantics tests follow the 10-point standard in - [§12.3](briefing.md#123-semantics-test-standard). -- Keyboard tests follow [§12.4](briefing.md#124-keyboard-test-standard) — no - catch-and-continue helpers, assert postconditions. -- Deterministic pumping: exact pumps or bounded `pumpUntil`; `pumpAndSettle()` - is forbidden for timer/repeating-animation components - ([§21.2](briefing.md#212-deterministic-pumping)). -- File placement and naming: [§12.2](briefing.md#122-proposed-file-names). - Export from `packages/naked_ui/lib/src/naked_widgets.dart`; add the - integration main to `packages/example/integration_test/all_tests.dart` — a - test file not in the aggregate runner is not delivered. -- Flake policy: no `continue-on-error` on required checks, no real sleeps, - quarantine requires issue+owner+date ([§21.10](briefing.md#2110-flake-policy)). -- Native behavior uses `flutter test`; `flutter drive` is limited to web and - host-side screenshot/report-data transport. Behavior and evidence are - separate blocking results ([integration playbook](integration-testing.md)). -- Pointer/gesture cleanup errors propagate. Platform-specific failures follow - the playbook's root-cause protocol; retries and timeout inflation are not - fixes. -- Disposal/leak checklist for every overlay/timer component - ([§21.11](briefing.md#2111-leak-and-disposal-checks)). - -## Definition of done - -A component is done only when every box in -[§23.1](briefing.md#231-package-level-definition-of-done) is checked, including -real macOS/Android/web runs, reviewed screenshots, manual VoiceOver/TalkBack -records, and a changelog naming any semantic or keyboard behavior change. -Escalation conditions that block a component are listed in -[§24.3](briefing.md#243-escalation-rule). - -## Local commands - -Root of this repo (see [§21.7](briefing.md#217-exact-local-commands) for the -full set including Android/web): - -```sh -flutter pub get -dart format --set-exit-if-changed . -flutter analyze -flutter test packages/naked_ui/test -flutter test packages/example/test - -# fast integration smoke -cd packages/example -flutter test -r compact -d flutter-tester integration_test/all_tests.dart - -# real macOS (after Phase 0 lands platform files) -flutter test -r compact -d macos integration_test/all_tests.dart -``` - -## Phase plan file template - -Create an NN-name.md plan under [phases/](phases/) when a phase starts: - -```markdown -# Phase NN — - -Goal: -Contract: briefing § (binding). Decisions resolved: D-xx (link decisions.md). -Baseline commit: - -## Tasks -For each task: What / Where (file:line) / How / Verify (exact command). -Order tasks so the PR stays releasable at every merge point. - -## Research and readiness -- Current-code baseline re-verified: -- Decisions resolved: -- Required spike/manual AT sessions: -- Known engine/platform limitations and stop conditions: - -## Integration proof plan -- Integration file and aggregate group: -- Stable fixture keys and deterministic data/reset: -- Scenario-to-platform matrix: -- Exact pumps / observable waits / teardown ownership: -- Screenshot, golden, guideline, and manual AT evidence: -- Exact local and hosted commands: - -## Acceptance -- [ ] Contract checklist from briefing § acceptance section -- [ ] Existing suite green (widget + integration aggregate) -- [ ] Integration playbook checklist evidenced on every applicable target -- [ ] Status board + decisions.md updated -``` From 51c2a2a4b8f953421b14565ec340ead641be0f3c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 13 Aug 2026 12:45:28 -0400 Subject: [PATCH 16/18] fix: harden NakedLink accessibility and routing --- .github/workflows/integration-web.yml | 6 + docs.json | 4 + docs/index.mdx | 1 + docs/widget/link.mdx | 157 ++++++++++++++++ packages/naked_ui/CHANGELOG.md | 37 ++-- packages/naked_ui/README.md | 7 +- packages/naked_ui/lib/src/naked_link.dart | 41 +++-- packages/naked_ui/pubspec.yaml | 1 + .../{ => test}/flutter_test_config.dart | 0 .../semantics/naked_link_semantics_test.dart | 57 ++++++ .../naked_ui/test/src/naked_link_test.dart | 49 +++++ .../test/src/naked_link_web_test.dart | 172 ++++++++++++++++++ 12 files changed, 505 insertions(+), 27 deletions(-) create mode 100644 docs/widget/link.mdx rename packages/naked_ui/{ => test}/flutter_test_config.dart (100%) create mode 100644 packages/naked_ui/test/src/naked_link_web_test.dart diff --git a/.github/workflows/integration-web.yml b/.github/workflows/integration-web.yml index 88f85ac7..6e21e96b 100644 --- a/.github/workflows/integration-web.yml +++ b/.github/workflows/integration-web.yml @@ -52,6 +52,12 @@ jobs: "${{ steps.chrome.outputs.chrome-path }}" --version "${{ steps.chrome.outputs.chromedriver-path }}" --version + - name: Run NakedLink browser unit tests + working-directory: packages/naked_ui + env: + CHROME_EXECUTABLE: ${{ steps.chrome.outputs.chrome-path }} + run: flutter test --platform chrome test/src/naked_link_web_test.dart + - name: Run integration tests on web working-directory: packages/example run: | diff --git a/docs.json b/docs.json index d41b2fc8..4556b317 100644 --- a/docs.json +++ b/docs.json @@ -65,6 +65,10 @@ "title": "NakedDialog", "href": "/widget/dialog" }, + { + "title": "NakedLink", + "href": "/widget/link" + }, { "title": "NakedMenu", "href": "/widget/menu" diff --git a/docs/index.mdx b/docs/index.mdx index 868fef51..0e8010fe 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -73,6 +73,7 @@ Components handle behavior. Builders define appearance. - NakedAccordion - expandable/collapsible sections - NakedMenu - anchored overlay menu - NakedDialog - modal dialog behavior + focus trap +- NakedLink - native navigation, link semantics, and observable interaction state - NakedTooltip - anchored tooltip with lifecycle - NakedPopover - anchored, dismissible popover overlay - NakedSwitch - on/off control with switch semantics diff --git a/docs/widget/link.mdx b/docs/widget/link.mdx new file mode 100644 index 00000000..4005201d --- /dev/null +++ b/docs/widget/link.mdx @@ -0,0 +1,157 @@ +--- +title: NakedLink +description: Headless Flutter link with native navigation, accessible semantics, resolution policy, and observable interaction state +keywords: [flutter, link, navigation, headless, accessibility, semantics, keyboard, web] +--- + +`NakedLink` is a navigation primitive with no built-in styling. It exposes +hover, focus, press, disabled, and destination state while preserving native +web anchors through `url_launcher.Link`. + +## When to use this + +- **Navigation**: Open a route, document, website, email address, or other URI +- **Inline links**: Compose navigation into a sentence without button styling +- **Custom routing**: Let an application resolver handle ordinary activation +- **Design systems**: Render brand-specific visuals from observable link state + +Use a button for an in-place action. Link semantics communicate that activation +navigates to or references another destination. + + + The complete fixture is in the [GitHub repository](https://github.com/conceptadev/naked_ui/blob/main/packages/example/lib/api/naked_link.0.dart). + + +## Basic implementation + +```dart +NakedLink( + linkUrl: Uri.parse('https://example.com/docs'), + onActivated: (url) => debugPrint('Activated $url'), + child: const Text('Documentation'), + builder: (context, state, child) { + return DecoratedBox( + decoration: BoxDecoration( + color: state.isHovered ? const Color(0xFFEFF6FF) : null, + border: Border.all( + color: state.isFocused + ? const Color(0xFF2563EB) + : const Color(0x00000000), + ), + ), + child: child!, + ); + }, +) +``` + +## Application routing + +`onActivated` observes an accepted ordinary activation but cannot cancel +navigation. Wrap a subtree in `NakedLinkResolver` when application routing +should replace the platform default: + +```dart +NakedLinkResolver( + resolve: (context, url) { + Navigator.of(context).pushNamed(url.toString()); + return NakedLinkResolution.handled; + }, + child: NakedLink( + linkUrl: Uri.parse('/account'), + child: const Text('Account settings'), + ), +) +``` + +Return `NakedLinkResolution.platformDefault` to delegate navigation. On web, +HTTP and HTTPS destinations open in the current tab. Scheme-less routes and +browser/OS handler schemes such as `mailto:`, `tel:`, and custom schemes use +`url_launcher.Link`'s `FollowLink` path so the browser and platform plugin +retain ownership. Primary, keyboard, and semantic activation of the +`javascript:` scheme remains on `url_launcher_web`'s guarded launch path. +Auxiliary actions remain browser-owned, so callers must validate destinations. + +## Activation behavior + +| Input | Behavior | +| --- | --- | +| Primary tap, Enter, Numpad Enter, semantic tap | Observer, then nearest resolver, then platform default when unhandled | +| Modified primary click | Browser-owned; bypasses observer and resolver | +| Middle or secondary click | Left available to browser or composed context-menu behavior | +| Space | Does not activate, preserving normal page scrolling | + +Disabled links expose no link role, destination URL, tap action, or focus stop. + +## Accessible naming + +Visible child text supplies the accessible name by default. A non-empty, +caller-localized `semanticLabel` replaces descendant naming semantics so the +link is announced once. Null and whitespace-only labels preserve the visible +child name. Use `semanticHint` only when the result is not clear from the name. + +Set `excludeSemantics` only when the surrounding application supplies an +equivalent accessible navigation path. + +## Constructor + +```dart +const NakedLink({ + Key? key, + Widget? child, + ValueWidgetBuilder? builder, + required Uri linkUrl, + ValueChanged? onActivated, + bool enabled = true, + FocusNode? focusNode, + bool autofocus = false, + MouseCursor? mouseCursor, + bool enableFeedback = true, + ValueChanged? onFocusChange, + ValueChanged? onHoverChange, + ValueChanged? onPressChange, + String? semanticLabel, + String? semanticHint, + bool excludeSemantics = false, +}) +``` + +## Properties + +#### linkUrl → `Uri` + +The exact destination exposed to navigation and assistive technologies. Validate +untrusted destinations before constructing the link. + +#### child → `Widget?` + +The visual content. Supply `child`, `builder`, or both. + +#### builder → `ValueWidgetBuilder?` + +Builds visuals from an immutable state containing interaction flags and the +current `linkUrl`. + +#### onActivated → `ValueChanged?` + +Observes ordinary accepted activation before resolver and platform navigation. + +#### enabled → `bool` + +The only availability switch. Defaults to `true`. + +#### focusNode → `FocusNode?` + +Optional caller-owned focus node. `NakedLink` never disposes it. + +#### semanticLabel → `String?` + +Optional localized accessible-name override. Blank values are treated as absent. + +#### semanticHint → `String?` + +Optional localized description of a non-obvious navigation result. + +#### excludeSemantics → `bool` + +Hides the link and its subtree from accessibility services. Defaults to `false`. diff --git a/packages/naked_ui/CHANGELOG.md b/packages/naked_ui/CHANGELOG.md index d9c0e5bc..2ed22d96 100644 --- a/packages/naked_ui/CHANGELOG.md +++ b/packages/naked_ui/CHANGELOG.md @@ -1,3 +1,26 @@ +## Unreleased + +### Features + +- Add `NakedLink` with a required destination URI, native web anchors through + Flutter's official `url_launcher.Link`, current-tab HTTP(S) web defaults, + and official `FollowLink` defaults for scheme-less routes, browser/OS handler + schemes such as `mailto:`, `tel:`, and custom schemes, and non-web platforms. + Primary, keyboard, and semantic activation of the `javascript:` scheme + remains on `url_launcher_web`'s guarded launch path. The widget includes + optional subtree + `NakedLinkResolver` routing and non-canceling `onActivated` observation; + exposes Link semantics plus observable hover/focus/press/disabled state; and + supports caller-owned focus nodes, primary and semantic activation, and + Enter/Numpad Enter. Space and browser-owned auxiliary actions remain + unclaimed; callers own URI validation, visited state, styling, and localized + copy. +- Safely clear Link interaction callbacks after dynamic disabling, restore + hover when re-enabled under a stationary pointer, suppress held-key repeats, + preserve stateful descendants across availability changes, retain disabled + URIs in state, and remove disabled destination metadata from semantics and + the web DOM. + ## 1.0.0-beta.10 ### Features @@ -91,20 +114,6 @@ ### Features -- Add `NakedLink` with a required destination URI, native web anchors through - Flutter's official `url_launcher.Link`, current-tab external web defaults, - official `FollowLink` internal/non-web defaults, optional subtree - `NakedLinkResolver` routing, and non-canceling `onActivated` observation. It - exposes Link semantics, observable hover/focus/press/disabled state, - caller-owned focus nodes, primary and semantic activation, and Enter/Numpad - Enter keyboard support. Space and browser-owned auxiliary actions remain - unclaimed; callers own URI validation, visited state, styling, and localized - copy. -- Safely clear Link interaction callbacks after dynamic disabling, restore - hover when re-enabled under a stationary pointer, suppress held-key repeats, - preserve stateful descendants across availability changes, retain disabled - URIs in state, and remove disabled destination metadata from semantics and - the web DOM. - Add `SemanticsRole.alertDialog` support to `NakedDialog` and a `showNakedAlertDialog` helper with required non-empty caller-localized names, a non-dismissible outside barrier by default, null cancellation from Escape diff --git a/packages/naked_ui/README.md b/packages/naked_ui/README.md index 81c4ed96..98a7756d 100644 --- a/packages/naked_ui/README.md +++ b/packages/naked_ui/README.md @@ -97,8 +97,11 @@ NakedButton( 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 +HTTP(S) navigation opens in the current tab, while scheme-less routes, +browser/OS handler schemes such as `mailto:`, `tel:`, and custom schemes use its +`FollowLink` path. Primary, keyboard, and semantic activation of the +`javascript:` scheme remains on `url_launcher_web`'s guarded launch path. +Non-web defaults also use `FollowLink`. Enter and Numpad Enter activate, while Space remains available to the page. Validate destinations before constructing a Link—Naked UI accepts every `Uri` unchanged. diff --git a/packages/naked_ui/lib/src/naked_link.dart b/packages/naked_ui/lib/src/naked_link.dart index bac8a1d7..19f9f4c3 100644 --- a/packages/naked_ui/lib/src/naked_link.dart +++ b/packages/naked_ui/lib/src/naked_link.dart @@ -193,8 +193,9 @@ class NakedLink extends StatefulWidget { /// The optional caller-localized accessible name. /// - /// When non-null, this replaces descendant naming semantics so the Link is - /// announced once. Otherwise visible child text supplies the name. + /// When non-empty, this replaces descendant naming semantics so the Link is + /// announced once. Null or whitespace-only values let visible child text + /// supply the name. final String? semanticLabel; /// Optional caller-localized accessible hint. @@ -228,6 +229,9 @@ class _NakedLinkState extends State keyboard.isShiftPressed; } + bool get _isWebJavascriptLink => + kIsWeb && widget.linkUrl.scheme.toLowerCase() == 'javascript'; + void _handleOrdinaryActivation(launcher.FollowLink followLink) { if (!widget._effectiveEnabled) return; @@ -245,14 +249,20 @@ class _NakedLinkState extends State } void _followPlatformDefault(launcher.FollowLink followLink) { - if (kIsWeb && widget.linkUrl.hasScheme) { - unawaited(_launchWebExternalLink()); + final scheme = widget.linkUrl.scheme.toLowerCase(); + final usesWebLauncher = + _isWebJavascriptLink || + (kIsWeb && (scheme == 'http' || scheme == 'https')); + if (usesWebLauncher) { + // Keep javascript URIs on url_launcher_web's guarded path. Its Link + // delegate otherwise exposes the URI directly through a DOM anchor. + unawaited(_launchWebLink()); return; } unawaited(followLink()); } - Future _launchWebExternalLink() async { + Future _launchWebLink() async { try { final launched = await url_launcher.launchUrl( widget.linkUrl, @@ -284,7 +294,11 @@ class _NakedLinkState extends State final modified = _modifiedPointerActivation || _hasPointerModifier; _modifiedPointerActivation = false; if (modified) { - unawaited(followLink()); + if (_isWebJavascriptLink) { + _followPlatformDefault(followLink); + } else { + unawaited(followLink()); + } return; } _handleOrdinaryActivation(followLink); @@ -343,14 +357,19 @@ class _NakedLinkState extends State Widget _buildLink(launcher.FollowLink? followLink) { final isEnabled = widget._effectiveEnabled; - final activation = isEnabled + final semanticLabel = widget.semanticLabel; + final hasSemanticLabel = semanticLabel?.trim().isNotEmpty ?? false; + final pointerActivation = isEnabled ? () => _handlePointerActivation(followLink!) : null; + final semanticActivation = isEnabled + ? () => _handleOrdinaryActivation(followLink!) + : null; Widget result = GestureDetector( onTapDown: isEnabled ? _handlePressStart : null, onTapUp: isEnabled ? (_) => _handlePressEnd() : null, onTapCancel: isEnabled ? _handlePressCancel : null, - onTap: activation, + onTap: pointerActivation, behavior: HitTestBehavior.opaque, excludeFromSemantics: true, child: NakedStateScopeBuilder( @@ -365,10 +384,10 @@ class _NakedLinkState extends State enabled: isEnabled, link: isEnabled, linkUrl: isEnabled ? widget.linkUrl : null, - label: widget.semanticLabel, + label: hasSemanticLabel ? semanticLabel : null, hint: widget.semanticHint, - excludeSemantics: widget.semanticLabel != null, - onTap: activation, + excludeSemantics: hasSemanticLabel, + onTap: semanticActivation, child: result, ); } diff --git a/packages/naked_ui/pubspec.yaml b/packages/naked_ui/pubspec.yaml index d0290bbe..b88e3414 100644 --- a/packages/naked_ui/pubspec.yaml +++ b/packages/naked_ui/pubspec.yaml @@ -20,6 +20,7 @@ dev_dependencies: flutter_test: sdk: flutter meta: ^1.15.0 + url_launcher_platform_interface: ^2.3.2 flutter: diff --git a/packages/naked_ui/flutter_test_config.dart b/packages/naked_ui/test/flutter_test_config.dart similarity index 100% rename from packages/naked_ui/flutter_test_config.dart rename to packages/naked_ui/test/flutter_test_config.dart diff --git a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart index 321d0341..17246c05 100644 --- a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart +++ b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart @@ -2,6 +2,7 @@ import 'dart:ui' show 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'; @@ -64,6 +65,29 @@ void main() { } }); + testWidgets('blank semantic label falls back to visible text', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + try { + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: _destination, + semanticLabel: ' \t\n ', + onActivated: (_) {}, + child: const Text('Visible name'), + ), + ), + ); + + expect(_singleLinkData(tester).label, 'Visible name'); + } finally { + handle.dispose(); + } + }); + testWidgets('rich text supplies one complete Link name', (tester) async { final handle = tester.ensureSemantics(); @@ -235,6 +259,39 @@ void main() { } }); + testWidgets('semantic tap remains ordinary while a modifier is held', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + final events = []; + + try { + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: _destination, + onActivated: (url) => events.add('observer:$url'), + child: const Text('Documentation'), + ), + resolve: (_, url) { + events.add('resolver:$url'); + return NakedLinkResolution.handled; + }, + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + final node = _singleLinkNode(tester); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pump(); + + expect(events, ['observer:$_destination', 'resolver:$_destination']); + } finally { + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + handle.dispose(); + } + }); + testWidgets('a resolver does not alter the semantic contract', ( tester, ) async { diff --git a/packages/naked_ui/test/src/naked_link_test.dart b/packages/naked_ui/test/src/naked_link_test.dart index 896ea3c5..604c7e54 100644 --- a/packages/naked_ui/test/src/naked_link_test.dart +++ b/packages/naked_ui/test/src/naked_link_test.dart @@ -468,6 +468,55 @@ void main() { }, ); + testWidgets( + 'modifier pressed after pointer down keeps activation browser-owned', + (tester) async { + const linkKey = ValueKey('link'); + var observerCalls = 0; + var resolverCalls = 0; + + await tester.pumpWidget( + _testApp( + NakedLink( + key: linkKey, + linkUrl: Uri.parse('/modified-after-down'), + onActivated: (_) => observerCalls++, + child: const SizedBox( + width: 160, + height: 48, + child: Text('Link'), + ), + ), + resolve: (_, _) { + resolverCalls++; + return NakedLinkResolution.handled; + }, + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(linkKey)), + kind: PointerDeviceKind.mouse, + ); + var pointerIsDown = true; + addTearDown(() async { + if (pointerIsDown) await gesture.cancel(); + }); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + try { + await gesture.up(); + pointerIsDown = false; + await tester.pump(); + + expect(observerCalls, 0); + expect(resolverCalls, 0); + } finally { + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + }, + ); + testWidgets('Enter and Numpad Enter activate while Space does not', ( tester, ) async { diff --git a/packages/naked_ui/test/src/naked_link_web_test.dart b/packages/naked_ui/test/src/naked_link_web_test.dart new file mode 100644 index 00000000..9bf37b08 --- /dev/null +++ b/packages/naked_ui/test/src/naked_link_web_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:naked_ui/naked_ui.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +void main() { + late UrlLauncherPlatform originalPlatform; + late _FakeUrlLauncherPlatform platform; + + setUp(() { + originalPlatform = UrlLauncherPlatform.instance; + platform = _FakeUrlLauncherPlatform(); + UrlLauncherPlatform.instance = platform; + }); + + tearDown(() { + UrlLauncherPlatform.instance = originalPlatform; + }); + + testWidgets('web delegates browser and OS handler schemes to FollowLink', ( + tester, + ) async { + final destinations = [ + Uri.parse('mailto:person@example.com'), + Uri.parse('tel:+15551234567'), + Uri.parse('custom-scheme:destination'), + ]; + + for (final destination in destinations) { + platform.clear(); + await tester.pumpWidget(_testApp(destination)); + await tester.tap(find.text('Open destination')); + await tester.pump(); + + expect(platform.followedUrls, [destination]); + expect(platform.launchCalls, isEmpty); + } + }, skip: !kIsWeb); + + testWidgets('web keeps modified handler schemes browser-owned', ( + tester, + ) async { + final destination = Uri.parse('mailto:person@example.com'); + + await tester.pumpWidget(_testApp(destination)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + try { + await tester.tap(find.text('Open destination')); + await tester.pump(); + + expect(platform.followedUrls, [destination]); + expect(platform.launchCalls, isEmpty); + } finally { + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + }, skip: !kIsWeb); + + testWidgets('web opens HTTP and HTTPS destinations in the current tab', ( + tester, + ) async { + final destinations = [ + Uri.parse('http://example.com/docs'), + Uri.parse('https://example.com/docs'), + ]; + + for (final destination in destinations) { + platform.clear(); + await tester.pumpWidget(_testApp(destination)); + await tester.tap(find.text('Open destination')); + await tester.pump(); + + expect(platform.followedUrls, isEmpty); + expect(platform.launchCalls, hasLength(1)); + expect(platform.launchCalls.single.$1, destination.toString()); + expect(platform.launchCalls.single.$2.webOnlyWindowName, '_self'); + } + }, skip: !kIsWeb); + + testWidgets('web routes javascript to url_launcher launchUrl', ( + tester, + ) async { + final destination = Uri.parse('javascript:alert("blocked")'); + + await tester.pumpWidget(_testApp(destination)); + await tester.tap(find.text('Open destination')); + await tester.pump(); + + expect(platform.followedUrls, isEmpty); + expect(platform.launchCalls, hasLength(1)); + expect(platform.launchCalls.single.$1, destination.toString()); + }, skip: !kIsWeb); + + testWidgets('web routes modified javascript to url_launcher launchUrl', ( + tester, + ) async { + final destination = Uri.parse('javascript:alert("blocked")'); + + await tester.pumpWidget(_testApp(destination)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + try { + await tester.tap(find.text('Open destination')); + await tester.pump(); + + expect(platform.followedUrls, isEmpty); + expect(platform.launchCalls, hasLength(1)); + expect(platform.launchCalls.single.$1, destination.toString()); + } finally { + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + }, skip: !kIsWeb); + + testWidgets('web delegates scheme-less routes to Link followLink', ( + tester, + ) async { + final destination = Uri.parse('/account/settings'); + + await tester.pumpWidget(_testApp(destination)); + await tester.tap(find.text('Open destination')); + await tester.pump(); + + expect(platform.followedUrls, [destination]); + expect(platform.launchCalls, isEmpty); + }, skip: !kIsWeb); +} + +Widget _testApp(Uri destination) { + return MaterialApp( + home: Scaffold( + body: NakedLink( + linkUrl: destination, + child: const SizedBox( + width: 160, + height: 48, + child: Text('Open destination'), + ), + ), + ), + ); +} + +class _FakeUrlLauncherPlatform extends UrlLauncherPlatform { + final followedUrls = []; + final launchCalls = <(String, LaunchOptions)>[]; + + @override + LinkDelegate get linkDelegate { + return (link) => Builder( + builder: (context) => link.builder( + context, + link.isDisabled + ? null + : () async { + followedUrls.add(link.uri!); + }, + ), + ); + } + + @override + Future launchUrl(String url, LaunchOptions options) async { + launchCalls.add((url, options)); + return true; + } + + void clear() { + followedUrls.clear(); + launchCalls.clear(); + } +} From c5bf5214f252edaef5f8f547359a5f9eb91bf36a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 13 Aug 2026 13:50:26 -0400 Subject: [PATCH 17/18] fix(ci): stabilize semantics and web link tests --- .github/workflows/ci.yml | 9 +- packages/naked_ui/lib/src/naked_link.dart | 39 +++++-- .../utilities/naked_focusable_detector.dart | 54 +++++++++- .../utilities/web_event_modifiers_stub.dart | 5 + .../utilities/web_event_modifiers_web.dart | 55 ++++++++++ .../test/src/naked_link_web_test.dart | 13 +++ .../test/src/web_modifier_listener_stub.dart | 1 + .../test/src/web_modifier_listener_web.dart | 59 +++++++++++ .../naked_focusable_detector_test.dart | 100 ++++++++++++++++++ 9 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart create mode 100644 packages/naked_ui/lib/src/utilities/web_event_modifiers_web.dart create mode 100644 packages/naked_ui/test/src/web_modifier_listener_stub.dart create mode 100644 packages/naked_ui/test/src/web_modifier_listener_web.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a13a7906..23d728da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,8 @@ jobs: pull-requests: write strategy: fail-fast: false + env: + HAS_DCM_LICENSE: ${{ secrets.DCM_CI_KEY != '' && secrets.DCM_EMAIL != '' }} steps: - name: Checkout repository @@ -74,14 +76,19 @@ jobs: run: flutter analyze - name: Install DCM + if: env.HAS_DCM_LICENSE == 'true' uses: CQLabs/setup-dcm@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} continue-on-error: true - name: Run DCM + if: env.HAS_DCM_LICENSE == 'true' + env: + DCM_CI_KEY: ${{ secrets.DCM_CI_KEY }} + DCM_EMAIL: ${{ secrets.DCM_EMAIL }} run: dcm analyze . - continue-on-error: true # DCM requires paid license for CI - apply for free OSS license at dcm.dev/pricing + continue-on-error: true - name: Run tests run: flutter test packages/naked_ui/test --coverage diff --git a/packages/naked_ui/lib/src/naked_link.dart b/packages/naked_ui/lib/src/naked_link.dart index 19f9f4c3..173fbc4d 100644 --- a/packages/naked_ui/lib/src/naked_link.dart +++ b/packages/naked_ui/lib/src/naked_link.dart @@ -11,6 +11,9 @@ import 'utilities/intents.dart'; import 'utilities/naked_focusable_detector.dart'; import 'utilities/naked_state_scope.dart'; import 'utilities/state.dart'; +import 'utilities/web_event_modifiers_stub.dart' + if (dart.library.js_interop) 'utilities/web_event_modifiers_web.dart' + as web_event_modifiers; /// The result of resolving an ordinary [NakedLink] activation. enum NakedLinkResolution { @@ -221,6 +224,12 @@ class _NakedLinkState extends State final _contentKey = GlobalKey(debugLabel: 'NakedLink content'); var _modifiedPointerActivation = false; + @override + void initState() { + super.initState(); + web_event_modifiers.ensureInitialized(); + } + bool get _hasPointerModifier { final keyboard = HardwareKeyboard.instance; return keyboard.isAltPressed || @@ -232,6 +241,19 @@ class _NakedLinkState extends State bool get _isWebJavascriptLink => kIsWeb && widget.linkUrl.scheme.toLowerCase() == 'javascript'; + bool get _isModifiedWebClick { + return kIsWeb && web_event_modifiers.consumeModifiedClick(); + } + + void _handleModifiedActivation(launcher.FollowLink followLink) { + _modifiedPointerActivation = false; + if (_isWebJavascriptLink) { + _followPlatformDefault(followLink); + } else { + unawaited(followLink()); + } + } + void _handleOrdinaryActivation(launcher.FollowLink followLink) { if (!widget._effectiveEnabled) return; @@ -294,11 +316,7 @@ class _NakedLinkState extends State final modified = _modifiedPointerActivation || _hasPointerModifier; _modifiedPointerActivation = false; if (modified) { - if (_isWebJavascriptLink) { - _followPlatformDefault(followLink); - } else { - unawaited(followLink()); - } + _handleModifiedActivation(followLink); return; } _handleOrdinaryActivation(followLink); @@ -363,7 +381,16 @@ class _NakedLinkState extends State ? () => _handlePointerActivation(followLink!) : null; final semanticActivation = isEnabled - ? () => _handleOrdinaryActivation(followLink!) + ? () { + // Flutter web converts a trusted DOM click on the semantics anchor + // into SemanticsAction.tap. Preserve the browser-owned modified + // click while keeping assistive-technology taps ordinary. + if (_isModifiedWebClick) { + _handleModifiedActivation(followLink!); + } else { + _handleOrdinaryActivation(followLink!); + } + } : null; Widget result = GestureDetector( onTapDown: isEnabled ? _handlePressStart : null, diff --git a/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart b/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart index 2063f75b..6e8d9137 100644 --- a/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart +++ b/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show Listenable, defaultTargetPlatform; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -187,7 +188,16 @@ class _NakedFocusableDetectorState extends State // Traditional: disabled = unfocusable. : widget.enabled && widget.canRequestFocus; - // Start with Focus wrapping the child + // Focus derives these values from a cached FocusNode snapshot. The custom + // semantics wrapper reads the current node and its ancestors instead. + final focusChild = widget.includeSemantics + ? _NakedFocusSemantics( + canRequestFocus: effectiveCanRequestFocus, + child: widget.child, + ) + : widget.child; + + // Start with Focus wrapping the child. Widget result = Focus( focusNode: effectiveFocusNode, autofocus: widget.autofocus, @@ -198,8 +208,8 @@ class _NakedFocusableDetectorState extends State skipTraversal: widget.skipTraversal, descendantsAreFocusable: widget.descendantsAreFocusable, descendantsAreTraversable: widget.descendantsAreTraversable, - includeSemantics: widget.includeSemantics, - child: widget.child, + includeSemantics: false, + child: focusChild, ); // Wrap with MouseRegion if hover detection is needed @@ -247,3 +257,41 @@ class _NakedFocusableDetectorState extends State return result; } } + +class _NakedFocusSemantics extends StatelessWidget { + const _NakedFocusSemantics({ + required this.canRequestFocus, + required this.child, + }); + + final bool canRequestFocus; + final Widget child; + + @override + Widget build(BuildContext context) { + final focusNode = Focus.of(context); + final focusChain = Listenable.merge([ + focusNode, + ...focusNode.ancestors, + ]); + + return ListenableBuilder( + listenable: focusChain, + builder: (context, child) { + final effectiveCanRequestFocus = + canRequestFocus && focusNode.canRequestFocus; + return Semantics( + onFocus: + defaultTargetPlatform != TargetPlatform.iOS && + effectiveCanRequestFocus + ? focusNode.requestFocus + : null, + focusable: effectiveCanRequestFocus, + focused: effectiveCanRequestFocus ? focusNode.hasPrimaryFocus : null, + child: child, + ); + }, + child: child, + ); + } +} diff --git a/packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart b/packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart new file mode 100644 index 00000000..48321d0d --- /dev/null +++ b/packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart @@ -0,0 +1,5 @@ +/// Installs the browser click observer when running on the web. +void ensureInitialized() {} + +/// Consumes a pending modified browser click. +bool consumeModifiedClick() => false; diff --git a/packages/naked_ui/lib/src/utilities/web_event_modifiers_web.dart b/packages/naked_ui/lib/src/utilities/web_event_modifiers_web.dart new file mode 100644 index 00000000..5be36a32 --- /dev/null +++ b/packages/naked_ui/lib/src/utilities/web_event_modifiers_web.dart @@ -0,0 +1,55 @@ +@JS() +library; + +import 'dart:async'; +import 'dart:js_interop'; + +@JS('window') +external _BrowserWindow get _window; + +extension type _BrowserWindow._(JSObject _) implements JSObject { + external void addEventListener( + String type, + JSFunction listener, [ + JSAny options, + ]); +} + +extension type _BrowserEvent._(JSObject _) implements JSObject { + external bool get altKey; + external bool get ctrlKey; + external bool get metaKey; + external bool get shiftKey; +} + +var _initialized = false; +var _modifiedClickInProgress = false; +var _clickGeneration = 0; +late final JSFunction _clickListener; + +/// Installs the browser click observer when running on the web. +void ensureInitialized() { + if (_initialized) return; + _initialized = true; + _clickListener = _recordClick.toJS; + _window.addEventListener('click', _clickListener, true.toJS); +} + +void _recordClick(_BrowserEvent event) { + final generation = ++_clickGeneration; + _modifiedClickInProgress = + event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; + // Browsers may run microtasks between DOM listeners, so keep the modifier + // available until Flutter's target listener has dispatched its semantics + // action. Consumption normally clears it sooner. + Timer.run(() { + if (_clickGeneration == generation) _modifiedClickInProgress = false; + }); +} + +/// Consumes a pending modified browser click. +bool consumeModifiedClick() { + final modified = _modifiedClickInProgress; + _modifiedClickInProgress = false; + return modified; +} diff --git a/packages/naked_ui/test/src/naked_link_web_test.dart b/packages/naked_ui/test/src/naked_link_web_test.dart index 9bf37b08..aa6f96a1 100644 --- a/packages/naked_ui/test/src/naked_link_web_test.dart +++ b/packages/naked_ui/test/src/naked_link_web_test.dart @@ -6,6 +6,10 @@ import 'package:naked_ui/naked_ui.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'web_modifier_listener_stub.dart' + if (dart.library.js_interop) 'web_modifier_listener_web.dart' + as web_modifier_listener; + void main() { late UrlLauncherPlatform originalPlatform; late _FakeUrlLauncherPlatform platform; @@ -58,6 +62,15 @@ void main() { } }, skip: !kIsWeb); + testWidgets('web retains DOM modifiers through target click listeners', ( + tester, + ) async { + await tester.pumpWidget(_testApp(Uri.parse('https://example.com/docs'))); + + expect(await web_modifier_listener.probeCaptureToTarget(), isTrue); + await tester.pump(Duration.zero); + }, skip: !kIsWeb); + testWidgets('web opens HTTP and HTTPS destinations in the current tab', ( tester, ) async { diff --git a/packages/naked_ui/test/src/web_modifier_listener_stub.dart b/packages/naked_ui/test/src/web_modifier_listener_stub.dart new file mode 100644 index 00000000..572679d5 --- /dev/null +++ b/packages/naked_ui/test/src/web_modifier_listener_stub.dart @@ -0,0 +1 @@ +Future probeCaptureToTarget() async => false; diff --git a/packages/naked_ui/test/src/web_modifier_listener_web.dart b/packages/naked_ui/test/src/web_modifier_listener_web.dart new file mode 100644 index 00000000..3dc792f1 --- /dev/null +++ b/packages/naked_ui/test/src/web_modifier_listener_web.dart @@ -0,0 +1,59 @@ +@JS() +library; + +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:naked_ui/src/utilities/web_event_modifiers_web.dart'; + +@JS('document') +external _Document get _document; + +extension type _EventTarget._(JSObject _) implements JSObject { + external void addEventListener( + String type, + JSFunction listener, [ + JSAny options, + ]); + + external bool dispatchEvent(_MouseEvent event); +} + +extension type _Document._(JSObject _) implements JSObject { + external _Element createElement(String localName); + external _Element get body; +} + +extension type _Element._(JSObject _) implements _EventTarget, JSObject { + external void append(_Element child); + external void remove(); +} + +@JS('MouseEvent') +extension type _MouseEvent._(JSObject _) implements JSObject { + external factory _MouseEvent(String type, [_MouseEventInit eventInit]); +} + +@JS('Object') +extension type _MouseEventInit._(JSObject _) implements JSObject { + external factory _MouseEventInit({bool bubbles, bool ctrlKey}); +} + +Future probeCaptureToTarget() async { + ensureInitialized(); + final target = _document.createElement('button'); + final result = Completer(); + target.addEventListener( + 'click', + ((JSAny _) => result.complete(consumeModifiedClick())).toJS, + ); + _document.body.append(target); + try { + target.dispatchEvent( + _MouseEvent('click', _MouseEventInit(bubbles: true, ctrlKey: true)), + ); + return await result.future; + } finally { + target.remove(); + } +} diff --git a/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart b/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart index 2061b853..9e3fd350 100644 --- a/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart +++ b/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart @@ -1,5 +1,8 @@ +import 'dart:ui' show Tristate; + 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:naked_ui/src/utilities/naked_focusable_detector.dart'; @@ -123,6 +126,103 @@ void main() { final focus = tester.widget(focusFinder); expect(focus.canRequestFocus, isTrue); // Should still be focusable }); + + testWidgets('updates focus semantics immediately when disabled', ( + tester, + ) async { + var enabled = true; + late StateSetter rebuild; + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return NakedFocusableDetector( + enabled: enabled, + child: Semantics( + label: 'target', + child: const Text('Target'), + ), + ); + }, + ), + ), + ); + + var data = tester.getSemantics(find.text('Target')).getSemanticsData(); + expect(data.hasAction(SemanticsAction.focus), isTrue); + + rebuild(() => enabled = false); + await tester.pump(); + + data = tester.getSemantics(find.text('Target')).getSemanticsData(); + expect(data.hasAction(SemanticsAction.focus), isFalse); + expect(data.flagsCollection.isFocused, Tristate.none); + semantics.dispose(); + }); + + testWidgets('ancestor focus exclusion removes focus semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + home: ExcludeFocus( + child: NakedFocusableDetector( + child: Semantics(label: 'target', child: const Text('Target')), + ), + ), + ), + ); + await tester.pump(); + + final data = tester + .getSemantics(find.text('Target')) + .getSemanticsData(); + expect(data.hasAction(SemanticsAction.focus), isFalse); + expect(data.flagsCollection.isFocused, Tristate.none); + semantics.dispose(); + }); + + testWidgets('updates focus semantics when ancestor exclusion changes', ( + tester, + ) async { + var excluding = false; + late StateSetter rebuild; + final semantics = tester.ensureSemantics(); + final target = NakedFocusableDetector( + child: Semantics(label: 'target', child: const Text('Target')), + ); + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return ExcludeFocus(excluding: excluding, child: target); + }, + ), + ), + ); + + SemanticsData data() => + tester.getSemantics(find.text('Target')).getSemanticsData(); + + expect(data().hasAction(SemanticsAction.focus), isTrue); + + rebuild(() => excluding = true); + await tester.pump(); + expect(data().hasAction(SemanticsAction.focus), isFalse); + expect(data().flagsCollection.isFocused, Tristate.none); + + rebuild(() => excluding = false); + await tester.pump(); + expect(data().hasAction(SemanticsAction.focus), isTrue); + semantics.dispose(); + }); }); group('Hover detection', () { From 9bd2b3a8502560015c3b1782af1696c82657fbce Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 13 Aug 2026 15:19:02 -0400 Subject: [PATCH 18/18] refactor: simplify naked link contract --- .github/workflows/ci.yml | 34 +- .github/workflows/integration-tests.yml | 3 - .github/workflows/integration-web.yml | 18 - README.md | 43 +- docs/index.mdx | 2 +- docs/widget/link.mdx | 102 +- .../components/naked_link_integration.dart | 187 +--- .../link_browser_driver_app.dart | 97 -- .../integration_test/screenshot_smoke.dart | 165 --- packages/example/lib/api/naked_link.0.dart | 358 ++----- packages/example/pubspec.yaml | 4 - .../test/accessibility_guidelines_test.dart | 26 - .../baselines/naked_link__keyboard_focus.png | Bin 40825 -> 0 bytes .../components/naked_link_golden_test.dart | 51 - .../test/goldens/golden_test_harness.dart | 35 +- .../example/test/naked_link_example_test.dart | 144 +-- .../test/screenshot_evidence_test.dart | 20 - .../test_driver/link_browser_driver.dart | 520 ---------- packages/naked_ui/CHANGELOG.md | 23 +- packages/naked_ui/README.md | 46 +- .../{test => }/flutter_test_config.dart | 0 .../naked_ui/lib/src/mixins/naked_mixins.dart | 11 +- packages/naked_ui/lib/src/naked_link.dart | 293 +----- .../utilities/naked_focusable_detector.dart | 156 +-- .../utilities/web_event_modifiers_stub.dart | 5 - .../utilities/web_event_modifiers_web.dart | 55 - packages/naked_ui/pubspec.yaml | 2 - .../semantics/naked_link_semantics_test.dart | 554 ++++------ .../naked_ui/test/src/naked_link_test.dart | 945 +++--------------- .../test/src/naked_link_web_test.dart | 185 ---- .../test/src/web_modifier_listener_stub.dart | 1 - .../test/src/web_modifier_listener_web.dart | 59 -- .../naked_focusable_detector_test.dart | 192 ---- pubspec.lock | 66 +- tool/run_android_integration.sh | 1 - 35 files changed, 584 insertions(+), 3819 deletions(-) delete mode 100644 packages/example/integration_test/link_browser_driver_app.dart delete mode 100644 packages/example/test/goldens/components/baselines/naked_link__keyboard_focus.png delete mode 100644 packages/example/test/goldens/components/naked_link_golden_test.dart delete mode 100644 packages/example/test_driver/link_browser_driver.dart rename packages/naked_ui/{test => }/flutter_test_config.dart (100%) delete mode 100644 packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart delete mode 100644 packages/naked_ui/lib/src/utilities/web_event_modifiers_web.dart delete mode 100644 packages/naked_ui/test/src/naked_link_web_test.dart delete mode 100644 packages/naked_ui/test/src/web_modifier_listener_stub.dart delete mode 100644 packages/naked_ui/test/src/web_modifier_listener_web.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23d728da..3121f76e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,8 +52,6 @@ jobs: pull-requests: write strategy: fail-fast: false - env: - HAS_DCM_LICENSE: ${{ secrets.DCM_CI_KEY != '' && secrets.DCM_EMAIL != '' }} steps: - name: Checkout repository @@ -76,19 +74,14 @@ jobs: run: flutter analyze - name: Install DCM - if: env.HAS_DCM_LICENSE == 'true' uses: CQLabs/setup-dcm@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} continue-on-error: true - name: Run DCM - if: env.HAS_DCM_LICENSE == 'true' - env: - DCM_CI_KEY: ${{ secrets.DCM_CI_KEY }} - DCM_EMAIL: ${{ secrets.DCM_EMAIL }} run: dcm analyze . - continue-on-error: true + continue-on-error: true # DCM requires paid license for CI - apply for free OSS license at dcm.dev/pricing - name: Run tests run: flutter test packages/naked_ui/test --coverage @@ -97,25 +90,14 @@ jobs: id: example_tests run: flutter test packages/example/test - # 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 - - - name: Upload golden diagnostic candidates + # 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 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 + run: flutter test packages/example/test/goldens --update-goldens - name: Upload golden failure images if: failure() diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 89cc9a85..462bc85a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -91,9 +91,6 @@ 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/toggle_group__roving_rtl__macos__reference.png diff --git a/.github/workflows/integration-web.yml b/.github/workflows/integration-web.yml index 6e21e96b..b05f36d3 100644 --- a/.github/workflows/integration-web.yml +++ b/.github/workflows/integration-web.yml @@ -52,12 +52,6 @@ jobs: "${{ steps.chrome.outputs.chrome-path }}" --version "${{ steps.chrome.outputs.chromedriver-path }}" --version - - name: Run NakedLink browser unit tests - working-directory: packages/naked_ui - env: - CHROME_EXECUTABLE: ${{ steps.chrome.outputs.chrome-path }} - run: flutter test --platform chrome test/src/naked_link_web_test.dart - - name: Run integration tests on web working-directory: packages/example run: | @@ -75,15 +69,6 @@ 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 @@ -92,8 +77,6 @@ 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() @@ -102,7 +85,6 @@ 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 diff --git a/README.md b/README.md index 4a66dad6..487071ab 100644 --- a/README.md +++ b/README.md @@ -17,7 +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 +- NakedLink — link semantics and Enter-only activation - NakedCheckbox — toggle behavior and semantics - NakedRadio — single‑select radio with group management - NakedSelect — dropdown/select with keyboard navigation @@ -94,18 +94,20 @@ 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. +Use a Link for navigation rather than styling a Button like text. Naked UI owns +the link interaction contract; the caller owns routing or launching. Enter and +Numpad Enter activate, while Space remains available to the page. A Link is +interactive only when `enabled` is true and `onPressed` is non-null. + +`linkUrl` is optional semantics metadata. On Flutter web it also becomes an +anchor `href`, so omit it when `onPressed` performs navigation; otherwise one +DOM activation can have two navigation owners. Validate destinations before +passing them to either API. Modified-click policy belongs to the caller or an +opt-in anchor/launcher layer. ```dart NakedLink( - linkUrl: Uri.parse('https://example.com/docs'), - onActivated: (url) => debugPrint('Activated $url'), + onPressed: () => Navigator.of(context).pushNamed('/docs'), child: const Text('Documentation'), builder: (context, state, child) => DecoratedBox( decoration: BoxDecoration( @@ -119,27 +121,6 @@ NakedLink( ) ``` -### 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. diff --git a/docs/index.mdx b/docs/index.mdx index 0e8010fe..7be9629e 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -73,7 +73,7 @@ Components handle behavior. Builders define appearance. - NakedAccordion - expandable/collapsible sections - NakedMenu - anchored overlay menu - NakedDialog - modal dialog behavior + focus trap -- NakedLink - native navigation, link semantics, and observable interaction state +- NakedLink - link semantics, activation, and observable interaction state - NakedTooltip - anchored tooltip with lifecycle - NakedPopover - anchored, dismissible popover overlay - NakedSwitch - on/off control with switch semantics diff --git a/docs/widget/link.mdx b/docs/widget/link.mdx index 4005201d..f9ab291d 100644 --- a/docs/widget/link.mdx +++ b/docs/widget/link.mdx @@ -1,21 +1,21 @@ --- title: NakedLink -description: Headless Flutter link with native navigation, accessible semantics, resolution policy, and observable interaction state -keywords: [flutter, link, navigation, headless, accessibility, semantics, keyboard, web] +description: Headless Flutter link with caller-owned navigation, accessible semantics, and observable interaction state +keywords: [flutter, link, navigation, headless, accessibility, semantics, keyboard] --- -`NakedLink` is a navigation primitive with no built-in styling. It exposes -hover, focus, press, disabled, and destination state while preserving native -web anchors through `url_launcher.Link`. +`NakedLink` is a dependency-free navigation primitive with no built-in styling +or launching policy. It supplies Link semantics and the correct keyboard +contract while the application owns routing, URL launching, and validation. ## When to use this -- **Navigation**: Open a route, document, website, email address, or other URI -- **Inline links**: Compose navigation into a sentence without button styling -- **Custom routing**: Let an application resolver handle ordinary activation -- **Design systems**: Render brand-specific visuals from observable link state +- **Navigation**: Move to a route, document, website, or other destination +- **Inline links**: Compose navigation into text without button behavior +- **Design systems**: Render custom visuals from hover, focus, press, and + disabled state -Use a button for an in-place action. Link semantics communicate that activation +Use a button for an in-place action. A Link communicates that activation navigates to or references another destination. @@ -26,8 +26,7 @@ navigates to or references another destination. ```dart NakedLink( - linkUrl: Uri.parse('https://example.com/docs'), - onActivated: (url) => debugPrint('Activated $url'), + onPressed: () => Navigator.of(context).pushNamed('/docs'), child: const Text('Documentation'), builder: (context, state, child) { return DecoratedBox( @@ -45,49 +44,42 @@ NakedLink( ) ``` -## Application routing +## Navigation ownership -`onActivated` observes an accepted ordinary activation but cannot cancel -navigation. Wrap a subtree in `NakedLinkResolver` when application routing -should replace the platform default: +`onPressed` is the only application activation callback. `NakedLink` does not +call a launcher or choose a router, so applications can use `Navigator`, a +routing package, or an optional launcher dependency without adding that policy +to every consumer. -```dart -NakedLinkResolver( - resolve: (context, url) { - Navigator.of(context).pushNamed(url.toString()); - return NakedLinkResolution.handled; - }, - child: NakedLink( - linkUrl: Uri.parse('/account'), - child: const Text('Account settings'), - ), -) -``` +`linkUrl` is optional destination metadata exposed through Flutter semantics. +On Flutter web, a non-null semantics URL becomes an anchor `href`. Omit +`linkUrl` when `onPressed` performs navigation; otherwise one DOM activation +can call the callback and follow the anchor. Callers must validate destinations +before passing them to either API. + +Modified primary clicks follow the ordinary `onPressed` path. If an application +needs browser-style open-in-new-tab behavior, its callback or an opt-in +anchor/launcher layer must implement that policy. -Return `NakedLinkResolution.platformDefault` to delegate navigation. On web, -HTTP and HTTPS destinations open in the current tab. Scheme-less routes and -browser/OS handler schemes such as `mailto:`, `tel:`, and custom schemes use -`url_launcher.Link`'s `FollowLink` path so the browser and platform plugin -retain ownership. Primary, keyboard, and semantic activation of the -`javascript:` scheme remains on `url_launcher_web`'s guarded launch path. -Auxiliary actions remain browser-owned, so callers must validate destinations. +A Link is interactive only when `enabled` is true and `onPressed` is non-null. +Otherwise it has no Link role, destination URL, tap action, or focus stop. ## Activation behavior | Input | Behavior | | --- | --- | -| Primary tap, Enter, Numpad Enter, semantic tap | Observer, then nearest resolver, then platform default when unhandled | -| Modified primary click | Browser-owned; bypasses observer and resolver | -| Middle or secondary click | Left available to browser or composed context-menu behavior | -| Space | Does not activate, preserving normal page scrolling | - -Disabled links expose no link role, destination URL, tap action, or focus stop. +| Primary tap | Calls `onPressed` once | +| Enter or Numpad Enter | Calls `onPressed` once; held-key repeats are ignored | +| Semantic tap | Calls the same `onPressed` path | +| Space | Remains unclaimed so the page can scroll | +| Modified primary click | Calls `onPressed`; modifier policy is caller-owned | +| Secondary click | Remains unclaimed | ## Accessible naming Visible child text supplies the accessible name by default. A non-empty, caller-localized `semanticLabel` replaces descendant naming semantics so the -link is announced once. Null and whitespace-only labels preserve the visible +Link is announced once. Null and whitespace-only labels preserve the visible child name. Use `semanticHint` only when the result is not clear from the name. Set `excludeSemantics` only when the surrounding application supplies an @@ -100,8 +92,8 @@ const NakedLink({ Key? key, Widget? child, ValueWidgetBuilder? builder, - required Uri linkUrl, - ValueChanged? onActivated, + VoidCallback? onPressed, + Uri? linkUrl, bool enabled = true, FocusNode? focusNode, bool autofocus = false, @@ -118,10 +110,14 @@ const NakedLink({ ## Properties -#### linkUrl → `Uri` +#### onPressed → `VoidCallback?` + +Performs caller-owned navigation. A null callback makes the Link inert. + +#### linkUrl → `Uri?` -The exact destination exposed to navigation and assistive technologies. Validate -untrusted destinations before constructing the link. +Optional destination metadata for assistive technologies. On web it also maps +to an anchor `href` while the Link is enabled. #### child → `Widget?` @@ -132,13 +128,9 @@ The visual content. Supply `child`, `builder`, or both. Builds visuals from an immutable state containing interaction flags and the current `linkUrl`. -#### onActivated → `ValueChanged?` - -Observes ordinary accepted activation before resolver and platform navigation. - #### enabled → `bool` -The only availability switch. Defaults to `true`. +Allows activation when `onPressed` is also non-null. Defaults to `true`. #### focusNode → `FocusNode?` @@ -146,7 +138,8 @@ Optional caller-owned focus node. `NakedLink` never disposes it. #### semanticLabel → `String?` -Optional localized accessible-name override. Blank values are treated as absent. +Optional localized accessible-name override. Blank values are treated as +absent. #### semanticHint → `String?` @@ -154,4 +147,5 @@ Optional localized description of a non-obvious navigation result. #### excludeSemantics → `bool` -Hides the link and its subtree from accessibility services. Defaults to `false`. +Hides the Link and its subtree from accessibility services. Defaults to +`false`. diff --git a/packages/example/integration_test/components/naked_link_integration.dart b/packages/example/integration_test/components/naked_link_integration.dart index b6489f94..13a9f366 100644 --- a/packages/example/integration_test/components/naked_link_integration.dart +++ b/packages/example/integration_test/components/naked_link_integration.dart @@ -1,22 +1,21 @@ 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', ( + testWidgets('Tab focuses the Link, Enter activates, and Space does not', ( tester, ) async { await tester.pumpWidget(const link_example.MyApp()); - await tester.pump(); final primary = find.byKey(const ValueKey('link.primary')); FocusManager.instance.primaryFocus?.unfocus(); @@ -25,58 +24,20 @@ void main() { 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.sendKeyEvent(LogicalKeyboardKey.space); await tester.pump(); - expect(tester.hasPrimaryFocusOn(primary), isTrue); - final before = scrollable.position.pixels; + expect(find.text('Result: none'), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pump(); - expect(find.text('Result: none; activations: 0'), findsOneWidget); + expect(find.text('Result: documentation'), 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', ( + testWidgets('pointer hover, press, and tap expose exact state', ( 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); @@ -94,139 +55,51 @@ void main() { 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('Result: documentation'), findsOneWidget); expect( find.text('hovered:true focused:false pressed:false enabled:true'), findsOneWidget, ); }); - testWidgets('semantic tap follows the same resolver path', (tester) async { + testWidgets('disabled Link is skipped and exposes no Link action', ( + 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); + final primary = find.byKey(const ValueKey('link.primary')); + final next = find.byKey(const ValueKey('link.next-focus')); - node.owner!.performAction(node.id, SemanticsAction.tap); + FocusManager.instance.primaryFocus?.unfocus(); 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.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); - - await tester.tapAt( - tester.getCenter(find.byKey(const ValueKey('link.primary'))), - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); + expect(tester.hasPrimaryFocusOn(primary), isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); - expect(find.text('Result: none; activations: 0'), findsOneWidget); - }, - ); + expect(tester.hasPrimaryFocusOn(next), isTrue); - 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(); + await tester.tap(find.byKey(const ValueKey('link.disabled'))); + await tester.pump(); + expect(find.text('Result: none'), findsOneWidget); - 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); + final data = tester + .getSemantics(find.text('Unavailable documentation')) + .getSemanticsData(); + expect(data.flagsCollection.isLink, isFalse); + expect(data.hasAction(SemanticsAction.tap), isFalse); + } finally { + handle.dispose(); + } }); }); } diff --git a/packages/example/integration_test/link_browser_driver_app.dart b/packages/example/integration_test/link_browser_driver_app.dart deleted file mode 100644 index f0f77e0c..00000000 --- a/packages/example/integration_test/link_browser_driver_app.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; -import 'package:flutter_driver/driver_extension.dart'; -import 'package:naked_ui/naked_ui.dart'; - -void main() { - enableFlutterDriverExtension(); - SemanticsBinding.instance.ensureSemantics(); - runApp(const _LinkBrowserDriverApp()); -} - -class _LinkBrowserDriverApp extends StatefulWidget { - const _LinkBrowserDriverApp(); - - @override - State<_LinkBrowserDriverApp> createState() => _LinkBrowserDriverAppState(); -} - -class _LinkBrowserDriverAppState extends State<_LinkBrowserDriverApp> { - var _customObserverCalls = 0; - var _customResolverCalls = 0; - var _dynamicObserverCalls = 0; - var _dynamicResolverCalls = 0; - var _dynamicEnabled = true; - - Uri _destination(String marker) => Uri.base.replace( - queryParameters: {'link-destination': marker}, - fragment: '', - ); - - NakedLinkResolution _resolveLink(BuildContext context, Uri linkUrl) { - switch (linkUrl.queryParameters['link-destination']) { - case 'naked-link-browser-custom': - setState(() => _customResolverCalls++); - return NakedLinkResolution.handled; - case 'naked-link-browser-dynamic': - setState(() => _dynamicResolverCalls++); - return NakedLinkResolution.handled; - default: - return NakedLinkResolution.platformDefault; - } - } - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - body: Center( - child: NakedLinkResolver( - resolve: _resolveLink, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - NakedLink( - linkUrl: _destination('naked-link-browser-default'), - child: const Text('Default navigation'), - ), - NakedLink( - linkUrl: _destination('naked-link-browser-custom'), - onActivated: (_) { - setState(() => _customObserverCalls++); - }, - child: const Text('Custom navigation'), - ), - NakedLink( - enabled: _dynamicEnabled, - linkUrl: _destination('naked-link-browser-dynamic'), - onActivated: (_) { - setState(() => _dynamicObserverCalls++); - }, - child: const Text('Dynamic navigation'), - ), - Text( - 'custom:observer=$_customObserverCalls;' - 'resolver=$_customResolverCalls', - key: const ValueKey('browser-link.custom-result'), - ), - Text( - 'dynamic:observer=$_dynamicObserverCalls;' - 'resolver=$_dynamicResolverCalls;enabled=$_dynamicEnabled', - key: const ValueKey('browser-link.dynamic-result'), - ), - TextButton( - key: const ValueKey('browser-link.disable-dynamic'), - onPressed: _dynamicEnabled - ? () => setState(() => _dynamicEnabled = false) - : null, - child: const Text('Disable dynamic Link'), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/packages/example/integration_test/screenshot_smoke.dart b/packages/example/integration_test/screenshot_smoke.dart index c3939fcb..5345af04 100644 --- a/packages/example/integration_test/screenshot_smoke.dart +++ b/packages/example/integration_test/screenshot_smoke.dart @@ -1,11 +1,9 @@ import 'dart:io'; import 'package:example/api/naked_dialog.0.dart' as dialog_example; -import 'package:example/api/naked_link.0.dart' as link_example; import 'package:example/api/naked_toggle.0.dart' as toggle_example; import 'package:example/src/testing/screenshot_evidence.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'; @@ -249,108 +247,6 @@ void main() { ); }); - testWidgets('Link default inline screenshot evidence', (tester) async { - final screenshotSurface = await _pumpLinkSurface( - tester, - const link_example.LinkExample(), - ); - expect(find.byKey(const ValueKey('link.primary')), findsOneWidget); - - await _captureLinkEvidence( - tester, - binding, - screenshotSurface, - scenario: 'default_inline', - ); - }); - - testWidgets('Link screenshot surface respects safe insets', (tester) async { - const viewPadding = FakeViewPadding(top: 24, bottom: 16); - tester.view.padding = viewPadding; - tester.view.viewPadding = viewPadding; - addTearDown(tester.view.resetPadding); - addTearDown(tester.view.resetViewPadding); - - final screenshotSurface = await _pumpLinkSurface( - tester, - const link_example.LinkExample(), - ); - final logicalPadding = EdgeInsets.fromViewPadding( - tester.view.padding, - tester.view.devicePixelRatio, - ); - final logicalViewSize = - tester.view.physicalSize / tester.view.devicePixelRatio; - - expect(tester.getTopLeft(screenshotSurface).dy, logicalPadding.top); - expect( - tester.getBottomRight(screenshotSurface).dy, - logicalViewSize.height - logicalPadding.bottom, - ); - }); - - testWidgets('Link keyboard focus screenshot evidence', (tester) async { - final screenshotSurface = await _pumpLinkSurface( - tester, - const link_example.LinkExample(), - ); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - await tester.pump(); - expect( - find.text('hovered:false focused:true pressed:false enabled:true'), - findsOneWidget, - ); - - await _captureLinkEvidence( - tester, - binding, - screenshotSurface, - scenario: 'keyboard_focus', - ); - }); - - testWidgets('Link disabled screenshot evidence', (tester) async { - final screenshotSurface = await _pumpLinkSurface( - tester, - const link_example.LinkExample(), - ); - final semantics = tester.ensureSemantics(); - try { - final disabled = tester - .getSemantics(find.text('Unavailable documentation')) - .getSemanticsData(); - expect(disabled.flagsCollection.isLink, isFalse); - expect(disabled.hasAction(SemanticsAction.tap), isFalse); - } finally { - semantics.dispose(); - } - - await _captureLinkEvidence( - tester, - binding, - screenshotSurface, - scenario: 'disabled', - ); - }); - - testWidgets('Link 200% long text screenshot evidence', (tester) async { - final screenshotSurface = await _pumpLinkSurface( - tester, - const link_example.LinkExample(textScale: 2, longText: true), - ); - expect(tester.takeException(), isNull); - expect(find.textContaining('complete accessibility guide'), findsOneWidget); - - await _captureLinkEvidence( - tester, - binding, - screenshotSurface, - scenario: 'long_text_200', - textScale: 2, - ); - }); - testWidgets('toggle group roving RTL screenshot evidence', (tester) async { const screenshotSurfaceKey = ValueKey('toggle-group.screenshot.roving-rtl'); _configureScreenshotView(tester); @@ -472,64 +368,3 @@ void main() { ); }); } - -Future _pumpLinkSurface(WidgetTester tester, Widget child) async { - final usesNativeSurface = Platform.isAndroid || Platform.isIOS; - if (!usesNativeSurface) { - tester.view.physicalSize = const Size(800, 600); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - } - - FocusManager.instance.primaryFocus?.unfocus(); - await tester.pumpWidget( - MaterialApp( - debugShowCheckedModeBanner: false, - home: Scaffold( - body: SafeArea(child: SizedBox.expand(child: child)), - ), - ), - ); - await tester.pump(); - final screenshotSurface = find.byKey(const ValueKey('link.evidence.surface')); - expect(screenshotSurface, findsOneWidget); - if (!usesNativeSurface) { - final logicalPadding = EdgeInsets.fromViewPadding( - tester.view.padding, - tester.view.devicePixelRatio, - ); - final logicalViewSize = - tester.view.physicalSize / tester.view.devicePixelRatio; - expect( - tester.getSize(screenshotSurface), - Size( - logicalViewSize.width - logicalPadding.horizontal, - logicalViewSize.height - logicalPadding.vertical, - ), - ); - } - return screenshotSurface; -} - -Future _captureLinkEvidence( - WidgetTester tester, - IntegrationTestWidgetsFlutterBinding binding, - Finder screenshotSurface, { - required String scenario, - double textScale = 1, -}) async { - final logicalSize = tester.getSize(screenshotSurface); - await tester.captureEvidenceScreenshot( - binding, - ScreenshotEvidence( - component: 'link', - scenario: scenario, - surface: '${logicalSize.width}x${logicalSize.height} logical pixels', - devicePixelRatio: tester.view.devicePixelRatio, - textScale: textScale, - animationMode: 'disabled', - ), - surface: screenshotSurface, - ); -} diff --git a/packages/example/lib/api/naked_link.0.dart b/packages/example/lib/api/naked_link.0.dart index 5d04826e..9a786958 100644 --- a/packages/example/lib/api/naked_link.0.dart +++ b/packages/example/lib/api/naked_link.0.dart @@ -11,69 +11,35 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return const MaterialApp( - home: Scaffold( - backgroundColor: Color(0xFFF8FAFC), - body: SafeArea(child: LinkExample()), - ), + home: Scaffold(body: SafeArea(child: LinkExample())), ); } } -/// Deterministic styled fixture for the headless Link contract. +/// A small styled fixture for the headless Link contract. class LinkExample extends StatefulWidget { - const LinkExample({ - super.key, - this.textDirection = TextDirection.ltr, - this.textScale = 1, - this.longText = false, - this.disableAnimations = true, - }); - - final TextDirection textDirection; - final double textScale; - final bool longText; - final bool disableAnimations; + const LinkExample({super.key}); @override State createState() => _LinkExampleState(); } class _LinkExampleState extends State { - static final _primaryLinkUrl = Uri.parse('https://example.com/naked-ui'); - static final _externalLinkUrl = Uri.parse( - 'https://docs.flutter.dev/ui/accessibility', - ); - var _result = 'none'; - var _activationCount = 0; - var _primaryEnabled = true; + var _enabled = true; var _hovered = false; var _focused = false; var _pressed = false; - bool get _isRtl => widget.textDirection == TextDirection.rtl; - - void _setResult(String result) { - setState(() { - _result = result; - }); - } - - void _recordActivation(Uri _) { - setState(() => _activationCount++); - } - - NakedLinkResolution _resolveLink(BuildContext context, Uri linkUrl) { - _setResult(linkUrl == _primaryLinkUrl ? 'primary' : 'external'); - return NakedLinkResolution.handled; + void _activate() { + setState(() => _result = 'documentation'); } void _reset() { FocusScope.of(context).unfocus(); setState(() { _result = 'none'; - _activationCount = 0; - _primaryEnabled = true; + _enabled = true; _hovered = false; _focused = false; _pressed = false; @@ -82,202 +48,78 @@ class _LinkExampleState extends State { @override Widget build(BuildContext context) { - final media = MediaQuery.of(context); - - return MediaQuery( - data: media.copyWith( - textScaler: TextScaler.linear(widget.textScale), - disableAnimations: widget.disableAnimations, - ), - child: Directionality( - textDirection: widget.textDirection, - child: NakedLinkResolver( - resolve: _resolveLink, - child: RepaintBoundary( - key: const ValueKey('link.evidence.surface'), - child: Material( - color: const Color(0xFFF8FAFC), - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - _isRtl ? 'روابط Naked UI' : 'Naked UI links', - style: const TextStyle( - color: Color(0xFF0F172A), - fontSize: 24, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - _buildPrimaryLine(), - const SizedBox(height: 20), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - _buildExternalLink(), - _buildDisabledLink(), - ], - ), - const SizedBox(height: 20), - Text( - 'Result: $_result; activations: $_activationCount', - key: const ValueKey('link.result'), - style: const TextStyle( - color: Color(0xFF0F172A), - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - 'hovered:$_hovered focused:$_focused ' - 'pressed:$_pressed enabled:$_primaryEnabled', - key: const ValueKey('link.state'), - style: const TextStyle(color: Color(0xFF334155)), - ), - const SizedBox(height: 20), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - OutlinedButton( - key: const ValueKey('link.next-focus'), - onPressed: () => _setResult('next-focus'), - child: const Text('Next focus target'), - ), - OutlinedButton( - key: const ValueKey('link.disable-primary'), - onPressed: _primaryEnabled - ? () => setState(() { - _primaryEnabled = false; - }) - : null, - child: const Text('Disable primary Link'), - ), - OutlinedButton( - key: const ValueKey('link.reset'), - onPressed: _reset, - child: const Text('Reset Link fixture'), - ), - ], - ), - ], - ), + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'NakedLink', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 12), + const Text( + 'The widget owns link behavior and state; the callback owns ' + 'navigation.', + ), + const SizedBox(height: 20), + NakedLink( + key: const ValueKey('link.primary'), + enabled: _enabled, + onPressed: _activate, + onHoverChange: (value) => setState(() => _hovered = value), + onFocusChange: (value) => setState(() => _focused = value), + onPressChange: (value) => setState(() => _pressed = value), + child: const Text('Open documentation'), + builder: (context, state, child) => + _LinkSurface(state: state, child: child!), + ), + const SizedBox(height: 12), + NakedLink( + key: const ValueKey('link.disabled'), + enabled: false, + onPressed: () => setState(() => _result = 'unavailable'), + child: const Text('Unavailable documentation'), + builder: (context, state, child) => + _LinkSurface(state: state, child: child!), + ), + const SizedBox(height: 20), + Text('Result: $_result', key: const ValueKey('link.result')), + const SizedBox(height: 4), + Text( + 'hovered:$_hovered focused:$_focused ' + 'pressed:$_pressed enabled:$_enabled', + key: const ValueKey('link.state'), + ), + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton( + key: const ValueKey('link.next-focus'), + onPressed: () => setState(() => _result = 'next-focus'), + child: const Text('Next focus target'), + ), + OutlinedButton( + key: const ValueKey('link.disable-primary'), + onPressed: _enabled + ? () => setState(() => _enabled = false) + : null, + child: const Text('Disable Link'), ), - ), + OutlinedButton( + key: const ValueKey('link.reset'), + onPressed: _reset, + child: const Text('Reset'), + ), + ], ), - ), - ), - ), - ), - ); - } - - Widget _buildPrimaryLine() { - final linkText = _isRtl - ? widget.longText - ? 'دليل الوصول الكامل للمكونات التفاعلية والقابلة للتخصيص' - : 'دليل الوصول' - : widget.longText - ? 'Read the complete accessibility guide for customizable interactive components' - : 'Read the documentation'; - - return Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - _isRtl ? 'تعرّف على المكوّنات في ' : 'Learn about the components in ', - style: const TextStyle(color: Color(0xFF334155), fontSize: 16), - ), - NakedLink( - key: const ValueKey('link.primary'), - enabled: _primaryEnabled, - linkUrl: _primaryLinkUrl, - semanticLabel: _isRtl ? linkText : null, - onActivated: _recordActivation, - onHoverChange: (value) => setState(() => _hovered = value), - onFocusChange: (value) => setState(() => _focused = value), - onPressChange: (value) => setState(() => _pressed = value), - child: Text(linkText), - builder: (context, state, child) => _LinkSurface( - state: state, - standalone: false, - disableAnimations: widget.disableAnimations, - child: child!, - ), - ), - ], - ); - } - - Widget _buildExternalLink() { - return NakedLink( - key: const ValueKey('link.external'), - linkUrl: _externalLinkUrl, - semanticLabel: 'Flutter accessibility documentation', - semanticHint: 'External destination', - onActivated: _recordActivation, - child: const Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text('Flutter accessibility'), - SizedBox(width: 6), - ExcludeSemantics( - child: Icon( - Icons.open_in_new, - size: 16, - semanticLabel: 'External link icon', - ), + ], ), - ], - ), - builder: (context, state, child) => _LinkSurface( - state: state, - standalone: true, - disableAnimations: widget.disableAnimations, - child: child!, - ), - ); - } - - Widget _buildDisabledLink() { - return NakedLink( - key: const ValueKey('link.disabled'), - enabled: false, - linkUrl: Uri.parse('https://example.com/unavailable'), - onActivated: _recordActivation, - child: const Text('Unavailable documentation'), - builder: (context, state, child) => _LinkSurface( - state: state, - standalone: true, - disableAnimations: widget.disableAnimations, - child: child!, - ), - ); - } -} - -/// A standalone styled Link used to verify platform target-size guidance. -class StandaloneLinkExample extends StatelessWidget { - const StandaloneLinkExample({super.key}); - - @override - Widget build(BuildContext context) { - return Center( - child: NakedLink( - key: const ValueKey('link.standalone'), - linkUrl: Uri.parse('https://example.com/naked-ui'), - child: const Text('Open documentation'), - builder: (context, state, child) => _LinkSurface( - state: state, - standalone: true, - disableAnimations: true, - child: child!, ), ), ); @@ -285,16 +127,9 @@ class StandaloneLinkExample extends StatelessWidget { } class _LinkSurface extends StatelessWidget { - const _LinkSurface({ - required this.state, - required this.standalone, - required this.disableAnimations, - required this.child, - }); + const _LinkSurface({required this.state, required this.child}); final NakedLinkState state; - final bool standalone; - final bool disableAnimations; final Widget child; @override @@ -309,40 +144,25 @@ class _LinkSurface extends StatelessWidget { orElse: Colors.transparent, ); - return DecoratedBox( + return Container( + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( + color: background, border: Border.all( color: state.isFocused ? const Color(0xFF2563EB) : Colors.transparent, width: 2, ), - borderRadius: BorderRadius.circular(standalone ? 8 : 4), + borderRadius: BorderRadius.circular(8), ), - child: AnimatedContainer( - duration: disableAnimations - ? Duration.zero - : const Duration(milliseconds: 120), - constraints: standalone - ? const BoxConstraints(minWidth: 48, minHeight: 48) - : const BoxConstraints(), - padding: standalone - ? const EdgeInsets.symmetric(horizontal: 10, vertical: 10) - : const EdgeInsets.symmetric(horizontal: 2, vertical: 2), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(standalone ? 6 : 2), - ), - child: DefaultTextStyle.merge( - style: TextStyle( - color: foreground, - fontSize: 16, - fontWeight: FontWeight.w600, - decoration: TextDecoration.underline, - ), - child: IconTheme( - data: IconThemeData(color: foreground), - child: child, - ), + alignment: Alignment.centerLeft, + child: DefaultTextStyle.merge( + style: TextStyle( + color: foreground, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, ), + child: child, ), ); } diff --git a/packages/example/pubspec.yaml b/packages/example/pubspec.yaml index ae88221d..fe8930c0 100644 --- a/packages/example/pubspec.yaml +++ b/packages/example/pubspec.yaml @@ -19,14 +19,10 @@ dependencies: google_fonts: ^6.1.0 dev_dependencies: - flutter_driver: - sdk: flutter flutter_test: sdk: flutter integration_test: sdk: flutter - web: ^1.1.1 - webdriver: ^3.1.0 flutter: uses-material-design: true diff --git a/packages/example/test/accessibility_guidelines_test.dart b/packages/example/test/accessibility_guidelines_test.dart index d696d18e..7dfb5a5f 100644 --- a/packages/example/test/accessibility_guidelines_test.dart +++ b/packages/example/test/accessibility_guidelines_test.dart @@ -1,6 +1,5 @@ import 'package:example/api/naked_button.0.dart' as button_example; import 'package:example/api/naked_dialog.0.dart' as dialog_example; -import 'package:example/api/naked_link.0.dart' as link_example; import 'package:example/api/naked_toggle.0.dart' as toggle_example; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -9,31 +8,6 @@ import 'package:naked_ui/naked_ui.dart'; import 'helpers/accessibility_guideline_helpers.dart'; void main() { - testWidgets('inline Link meets labeling and contrast guidelines', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp(home: Scaffold(body: link_example.LinkExample())), - ); - - await tester.expectMeetsAccessibilityGuidelines( - androidTapTarget: false, - iOSTapTarget: false, - ); - }); - - testWidgets('standalone styled Link meets platform target guidelines', ( - tester, - ) async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold(body: link_example.StandaloneLinkExample()), - ), - ); - - await tester.expectMeetsAccessibilityGuidelines(); - }); - testWidgets('canonical styled alert dialog meets accessibility guidelines', ( tester, ) async { diff --git a/packages/example/test/goldens/components/baselines/naked_link__keyboard_focus.png b/packages/example/test/goldens/components/baselines/naked_link__keyboard_focus.png deleted file mode 100644 index ec8abc41afc905f0693e5fae4aa599cf6865d6ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40825 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 diff --git a/packages/example/test/goldens/components/naked_link_golden_test.dart b/packages/example/test/goldens/components/naked_link_golden_test.dart deleted file mode 100644 index 7be1ce3c..00000000 --- a/packages/example/test/goldens/components/naked_link_golden_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:io'; - -import 'package:example/api/naked_link.0.dart' as link_example; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../golden_test_harness.dart'; - -void main() { - setUpAll(loadGoldenTestFont); - - testWidgets('canonical Link golden uses the fixed surface', (tester) async { - await _pumpLinkGoldenSurface(tester); - - expect( - tester.getSize(find.byKey(const ValueKey('link.evidence.surface'))), - goldenSurfaceSize, - ); - }); - - testWidgets( - 'canonical Link keyboard focus matches its reference golden', - (tester) async { - await _pumpLinkGoldenSurface(tester); - - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - await tester.pump(); - expect( - find.text('hovered:false focused:true pressed:false enabled:true'), - findsOneWidget, - ); - - await expectLater( - find.byKey(const ValueKey('link.evidence.surface')), - matchesGoldenFile('baselines/naked_link__keyboard_focus.png'), - ); - }, - // Skia text rasterization is host-specific. CI pins this golden to Ubuntu - // 24.04; other platforms skip the incompatible pixel comparison. - skip: !Platform.isLinux, - ); -} - -Future _pumpLinkGoldenSurface(WidgetTester tester) { - return pumpGoldenSurface( - tester, - child: const SizedBox.expand(child: link_example.LinkExample()), - ); -} diff --git a/packages/example/test/goldens/golden_test_harness.dart b/packages/example/test/goldens/golden_test_harness.dart index 50fdd651..7740b0c7 100644 --- a/packages/example/test/goldens/golden_test_harness.dart +++ b/packages/example/test/goldens/golden_test_harness.dart @@ -8,7 +8,7 @@ const goldenSurfaceSize = Size(800, 600); const goldenDevicePixelRatio = 1.0; const goldenFontFamily = 'GoldenRoboto'; -/// Loads the pinned text and icon fonts used by every example golden. +/// Loads the checked-in Apache-2.0 Roboto font used by every example golden. Future loadGoldenTestFont() async { final packageRelative = File('test/goldens/fonts/Roboto-Regular.ttf'); final workspaceRelative = File( @@ -20,37 +20,8 @@ Future loadGoldenTestFont() async { if (!fontFile.existsSync()) { throw StateError('Unable to locate the checked-in golden Roboto font.'); } - await _loadFont(goldenFontFamily, fontFile); - - final flutterRoot = Platform.environment['FLUTTER_ROOT']; - final executableFlutterRoot = File( - Platform.resolvedExecutable, - ).parent.parent.parent.parent.parent.path; - final materialIconsCandidates = [ - if (flutterRoot != null) - File( - '$flutterRoot/bin/cache/artifacts/material_fonts/' - 'MaterialIcons-Regular.otf', - ), - File( - '$executableFlutterRoot/bin/cache/artifacts/material_fonts/' - 'MaterialIcons-Regular.otf', - ), - ]; - final materialIcons = materialIconsCandidates - .where((candidate) => candidate.existsSync()) - .firstOrNull; - if (materialIcons == null) { - throw StateError( - 'Unable to locate MaterialIcons-Regular.otf in the pinned Flutter SDK.', - ); - } - await _loadFont('MaterialIcons', materialIcons); -} - -Future _loadFont(String family, File file) async { - final bytes = await file.readAsBytes(); - final loader = FontLoader(family) + final bytes = await fontFile.readAsBytes(); + final loader = FontLoader(goldenFontFamily) ..addFont(Future.value(ByteData.sublistView(bytes))); await loader.load(); } diff --git a/packages/example/test/naked_link_example_test.dart b/packages/example/test/naked_link_example_test.dart index 33a5c810..046ee83f 100644 --- a/packages/example/test/naked_link_example_test.dart +++ b/packages/example/test/naked_link_example_test.dart @@ -1,19 +1,17 @@ import 'package:example/api/naked_link.0.dart' as link_example; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('canonical Link fixture exposes stable deterministic state', ( + testWidgets('fixture exposes the essential Link states and controls', ( tester, ) async { - await tester.pumpWidget(_app(const link_example.LinkExample())); + await tester.pumpWidget(const link_example.MyApp()); for (final key in [ 'link.primary', 'link.disabled', - 'link.external', 'link.result', 'link.state', 'link.next-focus', @@ -22,151 +20,57 @@ void main() { ]) { expect(find.byKey(ValueKey(key)), findsOneWidget); } - expect(find.text('Result: none; activations: 0'), findsOneWidget); + expect(find.text('Result: none'), findsOneWidget); expect( find.text('hovered:false focused:false pressed:false enabled:true'), findsOneWidget, ); expect( tester.getSize(find.byKey(const ValueKey('link.primary'))).height, - lessThan(48), - reason: 'An inline Link must not be forced into a button-sized line box.', + greaterThanOrEqualTo(48), ); - for (final key in const ['link.external', 'link.disabled']) { - final size = tester.getSize(find.byKey(ValueKey(key))); - expect(size.height, greaterThanOrEqualTo(48)); - expect( - size.width, - lessThan(600), - reason: '$key should wrap its content, not fill the 680px row.', - ); - } - expect(find.text('.'), findsNothing); }); - testWidgets('primary and external activation update result exactly once', ( + testWidgets('activation, disabling, and reset are deterministic', ( tester, ) async { - await tester.pumpWidget(_app(const link_example.LinkExample())); + await tester.pumpWidget(const link_example.MyApp()); await tester.tap(find.byKey(const ValueKey('link.primary'))); await tester.pump(); - expect(find.text('Result: primary; activations: 1'), findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('link.external'))); - await tester.pump(); - expect(find.text('Result: external; activations: 2'), findsOneWidget); + expect(find.text('Result: documentation'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('link.reset'))); - await tester.pump(); - expect(find.text('Result: none; activations: 0'), findsOneWidget); - }); - - testWidgets('disabled Link cannot change the fixture result', (tester) async { - await tester.pumpWidget(_app(const link_example.LinkExample())); - - await tester.tap(find.byKey(const ValueKey('link.disabled'))); - await tester.pump(); - expect(find.text('Result: none; activations: 0'), findsOneWidget); - - final data = tester - .getSemantics(find.text('Unavailable documentation')) - .getSemanticsData(); - expect(data.flagsCollection.isLink, isFalse); - expect(data.hasAction(SemanticsAction.tap), isFalse); - }); - - testWidgets('focus and dynamic enabled state are visible and resettable', ( - tester, - ) async { - await tester.pumpWidget(_app(const link_example.LinkExample())); - - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - await tester.pump(); - expect( - find.text('hovered:false focused:true pressed:false enabled:true'), - findsOneWidget, - ); - await tester.tap(find.byKey(const ValueKey('link.disable-primary'))); await tester.pump(); + await tester.tap(find.byKey(const ValueKey('link.primary'))); await tester.pump(); + expect(find.text('Result: none'), findsOneWidget); expect( find.text('hovered:false focused:false pressed:false enabled:false'), findsOneWidget, ); - - await tester.tap(find.byKey(const ValueKey('link.reset'))); - await tester.pump(); - expect( - find.text('hovered:false focused:false pressed:false enabled:true'), - findsOneWidget, - ); }); - testWidgets('external hint is named once and its icon is decorative', ( + testWidgets('fixture exposes enabled and inert semantic contracts', ( tester, ) async { final handle = tester.ensureSemantics(); - try { - await tester.pumpWidget(_app(const link_example.LinkExample())); - - final data = tester - .getSemantics(find.byKey(const ValueKey('link.external'))) - .getSemanticsData(); - expect(data.label, 'Flutter accessibility documentation'); - expect(data.hint, 'External destination'); - expect( - find.bySemanticsLabel(RegExp(r'^External link icon$')), - findsNothing, - ); - } finally { - handle.dispose(); - } - }); + await tester.pumpWidget(const link_example.MyApp()); - testWidgets('fixture supports RTL and 200% long text without overflow', ( - tester, - ) async { - await tester.pumpWidget( - _app( - const link_example.LinkExample( - textDirection: TextDirection.rtl, - textScale: 2, - longText: true, - ), - ), - ); - - expect(tester.takeException(), isNull); - final directionality = tester.widget( - find - .ancestor( - of: find.byKey(const ValueKey('link.primary')), - matching: find.byType(Directionality), - ) - .first, - ); - expect(directionality.textDirection, TextDirection.rtl); - expect(find.textContaining('دليل الوصول'), findsOneWidget); - }); - - testWidgets('styled Links preserve the ambient font family', (tester) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(fontFamily: 'FixtureFont'), - home: const Scaffold(body: link_example.LinkExample()), - ), - ); + final enabled = tester + .getSemantics(find.text('Open documentation')) + .getSemanticsData(); + expect(enabled.flagsCollection.isLink, isTrue); + expect(enabled.linkUrl, isNull); + expect(enabled.hasAction(SemanticsAction.tap), isTrue); - final style = DefaultTextStyle.of( - tester.element(find.text('Read the documentation')), - ).style; - expect(style.fontFamily, 'FixtureFont'); + final disabled = tester + .getSemantics(find.text('Unavailable documentation')) + .getSemanticsData(); + expect(disabled.flagsCollection.isLink, isFalse); + expect(disabled.linkUrl, isNull); + expect(disabled.hasAction(SemanticsAction.tap), isFalse); + handle.dispose(); }); } - -Widget _app(Widget child) { - return MaterialApp(home: Scaffold(body: child)); -} diff --git a/packages/example/test/screenshot_evidence_test.dart b/packages/example/test/screenshot_evidence_test.dart index 96a6bee6..872f0c8c 100644 --- a/packages/example/test/screenshot_evidence_test.dart +++ b/packages/example/test/screenshot_evidence_test.dart @@ -37,26 +37,6 @@ void main() { ); }); - test('Link evidence preserves all required stable artifact names', () { - for (final entry in <(String, String), String>{ - ('default_inline', 'macos'): 'link__default_inline__macos__reference.png', - ('hover', 'web'): 'link__hover__web__reference.png', - ('keyboard_focus', 'macos'): 'link__keyboard_focus__macos__reference.png', - ('disabled', 'android'): 'link__disabled__android__reference.png', - ('external_hint', 'web'): 'link__external_hint__web__reference.png', - ('long_text_200', 'macos'): 'link__long_text_200__macos__reference.png', - ('rtl', 'web'): 'link__rtl__web__reference.png', - }.entries) { - final (scenario, target) = entry.key; - final evidence = ScreenshotEvidence( - component: 'link', - scenario: scenario, - ); - expect(evidence.artifactNameFor(target), entry.value); - expect(evidence.manifestEntryFor(target)['testResult'], 'pass'); - } - }); - test('alert dialog evidence uses every required native artifact name', () { expect( ScreenshotEvidence( diff --git a/packages/example/test_driver/link_browser_driver.dart b/packages/example/test_driver/link_browser_driver.dart deleted file mode 100644 index a0192a55..00000000 --- a/packages/example/test_driver/link_browser_driver.dart +++ /dev/null @@ -1,520 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_driver/flutter_driver.dart'; -import 'package:webdriver/async_io.dart'; - -const _semanticsAnchorSelector = By.cssSelector( - 'flt-semantics-host a:not([aria-hidden="true"])', -); -const _nativeAnchorSelector = By.cssSelector( - 'a[rel="noreferrer noopener"][aria-hidden="true"]', -); -const _w3cElementKey = 'element-6066-11e4-a52e-4f735466cecf'; - -Future main() async { - final driver = await FlutterDriver.connect(); - try { - await driver.waitFor( - find.byValueKey('browser-link.custom-result'), - timeout: const Duration(seconds: 10), - ); - - final webDriver = driver.webDriver; - final originalWindow = await webDriver.window; - final originalWindowIds = await _windowIds(webDriver); - final originalUrl = Uri.parse(await webDriver.currentUrl); - await _expectNativeHrefs(webDriver, const [ - 'naked-link-browser-default', - 'naked-link-browser-custom', - 'naked-link-browser-dynamic', - ]); - - var semanticsAnchors = await _waitForSemanticsAnchors(webDriver); - var customAnchor = await _anchorFor( - semanticsAnchors, - 'naked-link-browser-custom', - ); - - await _captureNextClickTrust(webDriver); - await _click(webDriver, customAnchor); - await driver.waitFor( - find.text('custom:observer=1;resolver=1'), - timeout: const Duration(seconds: 5), - ); - await _expectNoNavigation( - webDriver, - originalWindow, - originalWindowIds, - originalUrl, - 'A resolver-handled activation must suppress native browser navigation.', - ); - await _expectTrustedClick(webDriver, 'resolver-handled Link'); - - semanticsAnchors = await _waitForSemanticsAnchors(webDriver); - final dynamicAnchor = await _anchorFor( - semanticsAnchors, - 'naked-link-browser-dynamic', - ); - await _captureNextClickTrust(webDriver); - await _click(webDriver, dynamicAnchor); - await driver.waitFor( - find.text('dynamic:observer=1;resolver=1;enabled=true'), - timeout: const Duration(seconds: 5), - ); - await _expectNoNavigation( - webDriver, - originalWindow, - originalWindowIds, - originalUrl, - 'A dynamic resolver-handled activation must not navigate.', - ); - await _expectTrustedClick(webDriver, 'dynamic resolver-handled Link'); - - semanticsAnchors = await _waitForSemanticsAnchors(webDriver); - customAnchor = await _anchorFor( - semanticsAnchors, - 'naked-link-browser-custom', - ); - await _captureNextAuxiliaryClickTrust(webDriver); - await _auxiliaryClick(webDriver, customAnchor); - await _expectTrustedAuxiliaryClick(webDriver, 'middle-click Link'); - await _expectAuxiliaryNavigation( - webDriver, - originalWindow, - originalWindowIds, - 'naked-link-browser-custom', - ); - await driver.waitFor( - find.text('custom:observer=1;resolver=1'), - timeout: const Duration(seconds: 5), - ); - - semanticsAnchors = await _waitForSemanticsAnchors(webDriver); - customAnchor = await _anchorFor( - semanticsAnchors, - 'naked-link-browser-custom', - ); - await _captureNextClickTrust(webDriver); - await _modifiedPrimaryClick(webDriver, customAnchor); - await _expectTrustedClick(webDriver, 'modified primary Link'); - await _expectAuxiliaryNavigation( - webDriver, - originalWindow, - originalWindowIds, - 'naked-link-browser-custom', - ); - await driver.waitFor( - find.text('custom:observer=1;resolver=1'), - timeout: const Duration(seconds: 5), - ); - - await driver.tap(find.byValueKey('browser-link.disable-dynamic')); - await driver.waitFor( - find.text('dynamic:observer=1;resolver=1;enabled=false'), - timeout: const Duration(seconds: 5), - ); - await _waitForLinkRemoval(webDriver, 'naked-link-browser-dynamic'); - - semanticsAnchors = await _waitForSemanticsAnchors(webDriver, count: 2); - final defaultAnchor = await _anchorFor( - semanticsAnchors, - 'naked-link-browser-default', - ); - await _captureNextClickTrust(webDriver); - await _click(webDriver, defaultAnchor); - await _expectCurrentTabNavigation( - webDriver, - originalWindow, - originalWindowIds, - 'naked-link-browser-default', - 'An unmodified default Link must navigate the current tab exactly once.', - ); - - await webDriver.get(originalUrl); - semanticsAnchors = await _waitForSemanticsAnchors(webDriver); - final keyboardAnchor = await _anchorFor( - semanticsAnchors, - 'naked-link-browser-default', - ); - await webDriver.execute('arguments[0].focus();', [keyboardAnchor]); - await _captureNextKeyTrust(webDriver); - await webDriver.keyboard.sendKeys(Keyboard.enter); - await _expectCurrentTabNavigation( - webDriver, - originalWindow, - originalWindowIds, - 'naked-link-browser-default', - 'Enter must navigate the default Link in the current tab exactly once.', - ); - await _expectTrustedKey(webDriver, 'keyboard Enter Link'); - - stdout.writeln('Trusted browser Link ownership checks passed.'); - } finally { - await driver.close(); - } -} - -Future> _waitForSemanticsAnchors( - WebDriver driver, { - int count = 3, -}) async { - final deadline = DateTime.now().add(const Duration(seconds: 10)); - while (DateTime.now().isBefore(deadline)) { - final anchors = await driver - .findElements(_semanticsAnchorSelector) - .toList(); - if (anchors.length == count) { - final hrefs = await Future.wait( - anchors.map((anchor) => anchor.attributes['href']), - ); - if (hrefs.every((href) => href != null)) return anchors; - } - await Future.delayed(const Duration(milliseconds: 20)); - } - final domSnapshot = await driver.execute(r''' - return JSON.stringify({ - anchors: Array.from(document.querySelectorAll('a')).map( - (element) => element.outerHTML, - ), - linkRoles: Array.from(document.querySelectorAll('[role="link"]')).map( - (element) => element.outerHTML, - ), - semanticsHosts: Array.from( - document.querySelectorAll('flt-semantics-host'), - ).map((element) => element.innerHTML), - }); - ''', const []); - throw StateError( - 'Timed out waiting for $count visible semantics Link anchors. ' - 'DOM snapshot: $domSnapshot', - ); -} - -Future _anchorFor(List anchors, String marker) async { - for (final anchor in anchors) { - final href = await anchor.attributes['href']; - if (href?.contains(marker) ?? false) return anchor; - } - throw StateError('No visible semantics Link anchor contained $marker.'); -} - -Future _expectNativeHrefs(WebDriver driver, List markers) async { - final deadline = DateTime.now().add(const Duration(seconds: 10)); - while (DateTime.now().isBefore(deadline)) { - final anchors = await driver.findElements(_nativeAnchorSelector).toList(); - final hrefs = await Future.wait( - anchors.map((anchor) => anchor.attributes['href']), - ); - if (markers.every( - (marker) => hrefs.any((href) => href?.contains(marker) ?? false), - )) { - return; - } - await Future.delayed(const Duration(milliseconds: 20)); - } - throw StateError('Timed out waiting for native Link hrefs: $markers.'); -} - -Future _click(WebDriver driver, WebElement anchor) async { - await driver.mouse.moveToElementCenter(anchor); - await driver.mouse.click(); -} - -Future _modifiedPrimaryClick(WebDriver driver, WebElement anchor) async { - final client = HttpClient(); - try { - final request = await client.postUrl( - driver.uri.resolve('session/${driver.id}/actions'), - ); - request.headers.contentType = ContentType.json; - final body = utf8.encode( - jsonEncode({ - 'actions': [ - { - 'type': 'key', - 'id': 'modifier-keys', - 'actions': [ - {'type': 'keyDown', 'value': Keyboard.control}, - {'type': 'pause'}, - {'type': 'pause'}, - {'type': 'keyUp', 'value': Keyboard.control}, - ], - }, - { - 'type': 'pointer', - 'id': 'modified-primary-pointer', - 'parameters': {'pointerType': 'mouse'}, - 'actions': [ - { - 'type': 'pointerMove', - 'duration': 0, - 'origin': {_w3cElementKey: anchor.id}, - 'x': 0, - 'y': 0, - }, - {'type': 'pointerDown', 'button': MouseButton.primary.value}, - {'type': 'pointerUp', 'button': MouseButton.primary.value}, - {'type': 'pause'}, - ], - }, - ], - }), - ); - request.contentLength = body.length; - request.add(body); - final response = await request.close(); - final responseBody = await utf8.decoder.bind(response).join(); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw StateError( - 'Modified primary action failed (${response.statusCode}): ' - '$responseBody', - ); - } - } finally { - client.close(force: true); - } -} - -Future _auxiliaryClick(WebDriver driver, WebElement anchor) async { - final centerJson = await driver.execute( - r''' - const rect = arguments[0].getBoundingClientRect(); - return JSON.stringify({ - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }); - ''', - [anchor], - ); - final center = jsonDecode(centerJson! as String) as Map; - - await _dispatchCdpMouseEvent( - driver, - type: 'mousePressed', - x: center['x']! as num, - y: center['y']! as num, - buttons: 4, - ); - await _dispatchCdpMouseEvent( - driver, - type: 'mouseReleased', - x: center['x']! as num, - y: center['y']! as num, - buttons: 0, - ); -} - -Future _dispatchCdpMouseEvent( - WebDriver driver, { - required String type, - required num x, - required num y, - required int buttons, -}) async { - final client = HttpClient(); - try { - final request = await client.postUrl( - driver.uri.resolve('session/${driver.id}/goog/cdp/execute'), - ); - request.headers.contentType = ContentType.json; - final body = utf8.encode( - jsonEncode({ - 'cmd': 'Input.dispatchMouseEvent', - 'params': { - 'type': type, - 'x': x, - 'y': y, - 'button': 'middle', - 'buttons': buttons, - 'clickCount': 1, - 'pointerType': 'mouse', - }, - }), - ); - request.contentLength = body.length; - request.add(body); - final response = await request.close(); - final responseBody = await utf8.decoder.bind(response).join(); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw StateError( - 'CDP $type failed (${response.statusCode}): $responseBody', - ); - } - } finally { - client.close(force: true); - } -} - -Future _captureNextClickTrust(WebDriver driver) { - return driver.execute( - 'sessionStorage.setItem("__nakedLinkClickTrusted", "pending"); ' - 'window.addEventListener("click", function(event) { ' - 'sessionStorage.setItem("__nakedLinkClickTrusted", String(event.isTrusted)); ' - '}, {capture: true, once: true});', - const [], - ); -} - -Future _captureNextAuxiliaryClickTrust(WebDriver driver) { - return driver.execute( - 'sessionStorage.setItem("__nakedLinkAuxClickTrusted", "pending"); ' - 'window.addEventListener("auxclick", function(event) { ' - 'sessionStorage.setItem("__nakedLinkAuxClickTrusted", ' - 'String(event.isTrusted && event.button === 1)); ' - '}, {capture: true, once: true});', - const [], - ); -} - -Future _captureNextKeyTrust(WebDriver driver) { - return driver.execute( - 'sessionStorage.setItem("__nakedLinkKeyTrusted", "pending"); ' - 'window.addEventListener("keydown", function(event) { ' - 'if (event.key === "Enter") { ' - 'sessionStorage.setItem("__nakedLinkKeyTrusted", String(event.isTrusted)); ' - '} ' - '}, {capture: true, once: true});', - const [], - ); -} - -Future _expectTrustedClick(WebDriver driver, String description) async { - final isTrusted = await driver.execute( - 'return sessionStorage.getItem("__nakedLinkClickTrusted");', - const [], - ); - if (isTrusted != 'true') { - throw StateError('The $description click was not a trusted browser event.'); - } -} - -Future _expectTrustedAuxiliaryClick( - WebDriver driver, - String description, -) async { - final isTrusted = await driver.execute( - 'return sessionStorage.getItem("__nakedLinkAuxClickTrusted");', - const [], - ); - if (isTrusted != 'true') { - throw StateError( - 'The $description did not emit a trusted middle-button auxclick.', - ); - } -} - -Future _expectTrustedKey(WebDriver driver, String description) async { - final isTrusted = await driver.execute( - 'return sessionStorage.getItem("__nakedLinkKeyTrusted");', - const [], - ); - if (isTrusted != 'true') { - throw StateError('The $description key event was not trusted.'); - } -} - -Future _waitForLinkRemoval(WebDriver driver, String marker) async { - final deadline = DateTime.now().add(const Duration(seconds: 5)); - while (DateTime.now().isBefore(deadline)) { - final visible = await driver - .findElements(_semanticsAnchorSelector) - .toList(); - final native = await driver.findElements(_nativeAnchorSelector).toList(); - final anchors = [...visible, ...native]; - final hrefs = await Future.wait( - anchors.map((anchor) => anchor.attributes['href']), - ); - if (hrefs.every((href) => !(href?.contains(marker) ?? false))) return; - await Future.delayed(const Duration(milliseconds: 20)); - } - throw StateError('The disabled Link retained a visible or native anchor.'); -} - -Future> _windowIds(WebDriver driver) async => - (await driver.windows.toList()).map((window) => window.id).toSet(); - -Future _expectNoNavigation( - WebDriver driver, - Window originalWindow, - Set expectedWindowIds, - Uri expectedUrl, - String message, -) async { - final actualWindowIds = await _windowIds(driver); - if (actualWindowIds.length != expectedWindowIds.length || - !actualWindowIds.containsAll(expectedWindowIds)) { - throw StateError( - '$message Expected windows $expectedWindowIds, got $actualWindowIds.', - ); - } - await originalWindow.setAsActive(); - final actualUrl = Uri.parse(await driver.currentUrl); - if (actualUrl != expectedUrl) { - throw StateError('$message Expected $expectedUrl, got $actualUrl.'); - } -} - -Future _expectAuxiliaryNavigation( - WebDriver driver, - Window originalWindow, - Set originalWindowIds, - String marker, -) async { - final deadline = DateTime.now().add(const Duration(seconds: 5)); - while (DateTime.now().isBefore(deadline)) { - final windows = await driver.windows.toList(); - final secondary = windows.where( - (window) => !originalWindowIds.contains(window.id), - ); - if (secondary.isNotEmpty) { - final secondaryWindow = secondary.single; - await secondaryWindow.setAsActive(); - final destination = Uri.parse(await driver.currentUrl); - if (!destination.toString().contains(marker)) { - throw StateError( - 'Auxiliary navigation opened $destination, not $marker.', - ); - } - await secondaryWindow.close(); - await originalWindow.setAsActive(); - return; - } - await Future.delayed(const Duration(milliseconds: 20)); - } - throw StateError('An auxiliary Link activation did not open a new context.'); -} - -Future _expectCurrentTabNavigation( - WebDriver driver, - Window originalWindow, - Set expectedWindowIds, - String marker, - String message, -) async { - final deadline = DateTime.now().add(const Duration(seconds: 5)); - while (DateTime.now().isBefore(deadline)) { - final actualWindowIds = await _windowIds(driver); - if (actualWindowIds.length == expectedWindowIds.length && - actualWindowIds.containsAll(expectedWindowIds)) { - await originalWindow.setAsActive(); - final destination = Uri.parse(await driver.currentUrl); - if (destination.toString().contains(marker)) return; - } - await Future.delayed(const Duration(milliseconds: 20)); - } - final windows = await driver.windows.toList(); - final destinations = {}; - for (final window in windows) { - await window.setAsActive(); - destinations[window.id] = await driver.currentUrl; - } - if (windows.any((window) => window.id == originalWindow.id)) { - await originalWindow.setAsActive(); - } - throw StateError( - '$message Expected windows $expectedWindowIds and marker $marker; ' - 'actual destinations: $destinations.', - ); -} diff --git a/packages/naked_ui/CHANGELOG.md b/packages/naked_ui/CHANGELOG.md index 2ed22d96..0a68819e 100644 --- a/packages/naked_ui/CHANGELOG.md +++ b/packages/naked_ui/CHANGELOG.md @@ -2,24 +2,11 @@ ### Features -- Add `NakedLink` with a required destination URI, native web anchors through - Flutter's official `url_launcher.Link`, current-tab HTTP(S) web defaults, - and official `FollowLink` defaults for scheme-less routes, browser/OS handler - schemes such as `mailto:`, `tel:`, and custom schemes, and non-web platforms. - Primary, keyboard, and semantic activation of the `javascript:` scheme - remains on `url_launcher_web`'s guarded launch path. The widget includes - optional subtree - `NakedLinkResolver` routing and non-canceling `onActivated` observation; - exposes Link semantics plus observable hover/focus/press/disabled state; and - supports caller-owned focus nodes, primary and semantic activation, and - Enter/Numpad Enter. Space and browser-owned auxiliary actions remain - unclaimed; callers own URI validation, visited state, styling, and localized - copy. -- Safely clear Link interaction callbacks after dynamic disabling, restore - hover when re-enabled under a stationary pointer, suppress held-key repeats, - preserve stateful descendants across availability changes, retain disabled - URIs in state, and remove disabled destination metadata from semantics and - the web DOM. +- Add dependency-free `NakedLink` with Link semantics, optional destination + metadata, caller-owned activation, observable hover/focus/press/disabled + state, and caller-owned focus nodes. Primary tap, semantic tap, Enter, and + Numpad Enter activate; Space and held-key repeats remain unclaimed. Inert + Links expose no Link role, destination, focus stop, or tap action. ## 1.0.0-beta.10 diff --git a/packages/naked_ui/README.md b/packages/naked_ui/README.md index 98a7756d..fe12eda4 100644 --- a/packages/naked_ui/README.md +++ b/packages/naked_ui/README.md @@ -17,7 +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 +- NakedLink — link semantics and Enter-only activation - NakedCheckbox — toggle behavior and semantics - NakedRadio — single‑select radio with group management - NakedSelect — controlled/uncontrolled dropdown with keyboard navigation @@ -94,21 +94,20 @@ 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 -HTTP(S) navigation opens in the current tab, while scheme-less routes, -browser/OS handler schemes such as `mailto:`, `tel:`, and custom schemes use its -`FollowLink` path. Primary, keyboard, and semantic activation of the -`javascript:` scheme remains on `url_launcher_web`'s guarded launch path. -Non-web defaults also use `FollowLink`. Enter and Numpad Enter activate, while -Space remains available to the page. Validate destinations before constructing -a Link—Naked UI accepts every `Uri` unchanged. +Use a Link for navigation rather than styling a Button like text. Naked UI owns +the link interaction contract; the caller owns routing or launching. Enter and +Numpad Enter activate, while Space remains available to the page. A Link is +interactive only when `enabled` is true and `onPressed` is non-null. + +`linkUrl` is optional semantics metadata. On Flutter web it also becomes an +anchor `href`, so omit it when `onPressed` performs navigation; otherwise one +DOM activation can have two navigation owners. Validate destinations before +passing them to either API. Modified-click policy belongs to the caller or an +opt-in anchor/launcher layer. ```dart NakedLink( - linkUrl: Uri.parse('https://example.com/docs'), - onActivated: (url) => debugPrint('Activated $url'), + onPressed: () => Navigator.of(context).pushNamed('/docs'), child: const Text('Documentation'), builder: (context, state, child) => DecoratedBox( decoration: BoxDecoration( @@ -122,27 +121,6 @@ NakedLink( ) ``` -### 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. diff --git a/packages/naked_ui/test/flutter_test_config.dart b/packages/naked_ui/flutter_test_config.dart similarity index 100% rename from packages/naked_ui/test/flutter_test_config.dart rename to packages/naked_ui/flutter_test_config.dart diff --git a/packages/naked_ui/lib/src/mixins/naked_mixins.dart b/packages/naked_ui/lib/src/mixins/naked_mixins.dart index 23c4c6b9..f0490176 100644 --- a/packages/naked_ui/lib/src/mixins/naked_mixins.dart +++ b/packages/naked_ui/lib/src/mixins/naked_mixins.dart @@ -84,11 +84,8 @@ mixin WidgetStatesMixin on State { } /// Change-detecting state update. Returns true if the value actually changed. - /// - /// Set [rebuild] to false only while synchronizing state during a framework - /// lifecycle callback that is already followed by a build. @protected - bool updateState(WidgetState state, bool value, {bool rebuild = true}) { + bool updateState(WidgetState state, bool value) { final before = _widgetStates.contains(state); if (before == value) return false; @@ -98,7 +95,7 @@ mixin WidgetStatesMixin on State { _widgetStates.remove(state); } - if (mounted && rebuild) { + if (mounted) { // ignore: no-empty-block setState(() {}); // Trigger rebuild when widget state changes } @@ -144,8 +141,8 @@ mixin WidgetStatesMixin on State { /// Update disabled state. Returns true if the state actually changed. @protected - bool updateDisabledState(bool value, {bool rebuild = true}) { - return updateState(WidgetState.disabled, value, rebuild: rebuild); + bool updateDisabledState(bool value) { + return updateState(WidgetState.disabled, value); } /// Update error state. Returns true if the state actually changed. diff --git a/packages/naked_ui/lib/src/naked_link.dart b/packages/naked_ui/lib/src/naked_link.dart index 173fbc4d..dc6fb7d1 100644 --- a/packages/naked_ui/lib/src/naked_link.dart +++ b/packages/naked_ui/lib/src/naked_link.dart @@ -1,69 +1,18 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:url_launcher/link.dart' as launcher; -import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'mixins/naked_mixins.dart'; import 'utilities/intents.dart'; import 'utilities/naked_focusable_detector.dart'; import 'utilities/naked_state_scope.dart'; import 'utilities/state.dart'; -import 'utilities/web_event_modifiers_stub.dart' - if (dart.library.js_interop) 'utilities/web_event_modifiers_web.dart' - as web_event_modifiers; - -/// The result of resolving an ordinary [NakedLink] activation. -enum NakedLinkResolution { - /// The resolver has requested navigation and platform navigation is skipped. - handled, - - /// [NakedLink] continues with its platform-default navigation behavior. - platformDefault, -} -/// Resolves an ordinary [NakedLink] activation for a subtree. -/// -/// The callback receives the activating Link's [BuildContext] and its exact -/// destination URI. It is synchronous so a [NakedLink] can reliably prevent -/// duplicate platform navigation when it returns [NakedLinkResolution.handled]. -typedef NakedLinkResolveCallback = - NakedLinkResolution Function(BuildContext context, Uri linkUrl); - -/// Supplies navigation policy to descendant [NakedLink] widgets. -/// -/// The closest resolver wins. Resolvers observe only ordinary primary, Enter, -/// Numpad Enter, and semantic activation; browser-owned auxiliary actions such -/// as modified and middle clicks bypass this scope. -class NakedLinkResolver extends InheritedWidget { - /// Creates a subtree navigation resolver. - const NakedLinkResolver({ - required this.resolve, - required super.child, - super.key, - }); - - /// Resolves a descendant Link's ordinary activation. - final NakedLinkResolveCallback resolve; - - /// Returns the closest [NakedLinkResolver], if one is in scope. - static NakedLinkResolver? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType(); - - @override - bool updateShouldNotify(NakedLinkResolver oldWidget) => - resolve != oldWidget.resolve; -} - -/// An immutable snapshot of a [NakedLink]'s interaction state and destination. +/// An immutable snapshot of a [NakedLink]'s interaction state. class NakedLinkState extends NakedState { /// Creates a snapshot with the current interaction [states] and [linkUrl]. NakedLinkState({required super.states, required this.linkUrl}); - /// The Link's destination, including while the Link is disabled. - final Uri linkUrl; + /// The optional destination supplied to the Link. + final Uri? linkUrl; /// Returns the nearest [NakedLinkState] provided by [NakedStateScope]. static NakedLinkState of(BuildContext context) => NakedState.of(context); @@ -93,44 +42,23 @@ class NakedLinkState extends NakedState { int get hashCode => Object.hash(statesHashCode, linkUrl); } -/// A headless navigation Link with observable interaction state. -/// -/// Primary pointer tap, Enter, Numpad Enter, and semantic tap use [linkUrl] -/// while [enabled]. [onActivated] observes each accepted ordinary activation, -/// then the nearest [NakedLinkResolver] decides whether to handle it or use -/// platform-default navigation. Space is not bound by this widget, so a -/// surrounding page retains its normal scrolling behavior. Secondary, -/// middle, and modified primary clicks remain browser-owned. +/// A headless Link with caller-owned activation and no launcher dependency. /// -/// Naked UI delegates default navigation and the native web anchor to -/// `url_launcher`'s Link coordinator. The [builder] owns all visual styling. A -/// supplied [focusNode] remains caller-owned and is never disposed by Naked UI. -/// Callers own destination validation and must not pass untrusted URIs. +/// Primary tap, Enter, Numpad Enter, and semantic tap call [onPressed] while +/// the Link is enabled. Space remains unclaimed. The [builder] owns all visual +/// styling, and a supplied [focusNode] remains caller-owned. /// -/// ```dart -/// NakedLink( -/// linkUrl: Uri.parse('https://example.com/docs'), -/// child: const Text('Documentation'), -/// builder: (context, state, child) => DecoratedBox( -/// decoration: BoxDecoration( -/// border: Border.all( -/// color: state.isFocused -/// ? const Color(0xFF2563EB) -/// : const Color(0x00000000), -/// ), -/// ), -/// child: child!, -/// ), -/// ) -/// ``` +/// [linkUrl] is optional semantics metadata. Flutter web maps a non-null value +/// to an anchor `href`, so omit it when [onPressed] performs navigation; a DOM +/// activation can otherwise call the callback and follow the anchor. class NakedLink extends StatefulWidget { /// Creates a Link with either [child] or [builder] as its visual surface. const NakedLink({ super.key, this.child, this.builder, - required this.linkUrl, - this.onActivated, + this.onPressed, + this.linkUrl, this.enabled = true, this.focusNode, this.autofocus = false, @@ -153,24 +81,20 @@ class NakedLink extends StatefulWidget { /// Builds the Link using the current immutable state. final ValueWidgetBuilder? builder; - /// Observes an accepted ordinary activation before its navigation resolves. - /// - /// This callback cannot cancel navigation. Use [NakedLinkResolver] when a - /// subtree needs to handle a destination itself. - final ValueChanged? onActivated; + /// Performs application-owned navigation when the Link activates. + final VoidCallback? onPressed; - /// The destination exposed to assistive technologies and the web DOM. + /// An optional destination exposed through Flutter's Link semantics. /// - /// Naked UI accepts every URI unchanged. Callers are responsible for - /// validating its destination and must not pass untrusted values. - final Uri linkUrl; + /// On Flutter web, this becomes an anchor `href` while the Link is enabled. + /// Omit it when [onPressed] performs navigation so a DOM activation does not + /// have two navigation owners. + final Uri? linkUrl; - /// Whether the Link may activate. + /// Whether the Link may activate when [onPressed] is also non-null. final bool enabled; /// The optional caller-owned focus node. - /// - /// Naked UI borrows this node and never disposes it. final FocusNode? focusNode; /// Whether the Link should request focus when first built. @@ -196,21 +120,19 @@ class NakedLink extends StatefulWidget { /// The optional caller-localized accessible name. /// - /// When non-empty, this replaces descendant naming semantics so the Link is - /// announced once. Null or whitespace-only values let visible child text - /// supply the name. + /// A non-empty value replaces descendant naming semantics. Null or + /// whitespace-only values let visible child text supply the name. final String? semanticLabel; - /// Optional caller-localized accessible hint. + /// Optional caller-localized description of a non-obvious result. final String? semanticHint; /// Whether to hide the Link and its subtree from semantics. /// - /// This is an advanced escape hatch. Callers remain responsible for - /// providing an equivalent accessible navigation path. + /// Callers remain responsible for an equivalent accessible navigation path. final bool excludeSemantics; - bool get _effectiveEnabled => enabled; + bool get _effectiveEnabled => enabled && onPressed != null; @override State createState() => _NakedLinkState(); @@ -218,137 +140,32 @@ class NakedLink extends StatefulWidget { class _NakedLinkState extends State with WidgetStatesMixin { - // url_launcher's web delegate contributes Link semantics whenever it wraps - // the Link. Keep that wrapper out of the disabled tree and use this key to - // preserve the consumer subtree as it is added or removed. - final _contentKey = GlobalKey(debugLabel: 'NakedLink content'); - var _modifiedPointerActivation = false; - - @override - void initState() { - super.initState(); - web_event_modifiers.ensureInitialized(); - } - - bool get _hasPointerModifier { - final keyboard = HardwareKeyboard.instance; - return keyboard.isAltPressed || - keyboard.isControlPressed || - keyboard.isMetaPressed || - keyboard.isShiftPressed; - } - - bool get _isWebJavascriptLink => - kIsWeb && widget.linkUrl.scheme.toLowerCase() == 'javascript'; - - bool get _isModifiedWebClick { - return kIsWeb && web_event_modifiers.consumeModifiedClick(); - } - - void _handleModifiedActivation(launcher.FollowLink followLink) { - _modifiedPointerActivation = false; - if (_isWebJavascriptLink) { - _followPlatformDefault(followLink); - } else { - unawaited(followLink()); - } - } - - void _handleOrdinaryActivation(launcher.FollowLink followLink) { + void _handleActivation() { if (!widget._effectiveEnabled) return; if (widget.enableFeedback) { Feedback.forTap(context); } - - widget.onActivated?.call(widget.linkUrl); - final resolution = - NakedLinkResolver.maybeOf(context)?.resolve(context, widget.linkUrl) ?? - NakedLinkResolution.platformDefault; - if (resolution == NakedLinkResolution.platformDefault) { - _followPlatformDefault(followLink); - } - } - - void _followPlatformDefault(launcher.FollowLink followLink) { - final scheme = widget.linkUrl.scheme.toLowerCase(); - final usesWebLauncher = - _isWebJavascriptLink || - (kIsWeb && (scheme == 'http' || scheme == 'https')); - if (usesWebLauncher) { - // Keep javascript URIs on url_launcher_web's guarded path. Its Link - // delegate otherwise exposes the URI directly through a DOM anchor. - unawaited(_launchWebLink()); - return; - } - unawaited(followLink()); - } - - Future _launchWebLink() async { - try { - final launched = await url_launcher.launchUrl( - widget.linkUrl, - webOnlyWindowName: '_self', - ); - if (launched) return; - - FlutterError.reportError( - FlutterErrorDetails( - exception: StateError('Could not launch Link ${widget.linkUrl}.'), - stack: StackTrace.current, - library: 'naked_ui', - context: ErrorDescription('while following a NakedLink'), - ), - ); - } catch (error, stackTrace) { - FlutterError.reportError( - FlutterErrorDetails( - exception: error, - stack: stackTrace, - library: 'naked_ui', - context: ErrorDescription('while following a NakedLink'), - ), - ); - } - } - - void _handlePointerActivation(launcher.FollowLink followLink) { - final modified = _modifiedPointerActivation || _hasPointerModifier; - _modifiedPointerActivation = false; - if (modified) { - _handleModifiedActivation(followLink); - return; - } - _handleOrdinaryActivation(followLink); + widget.onPressed!(); } void _handlePressStart(TapDownDetails details) { - _modifiedPointerActivation = _hasPointerModifier; - if (_modifiedPointerActivation) return; updatePressState(true, widget.onPressChange); } void _handlePressEnd() { - if (_modifiedPointerActivation) return; updatePressState(false, widget.onPressChange); } - void _handlePressCancel() { - final modified = _modifiedPointerActivation; - _modifiedPointerActivation = false; - if (!modified) updatePressState(false, widget.onPressChange); - } - void _clearInteractionStates() { - final endedPress = updateState(WidgetState.pressed, false, rebuild: false); - final endedHover = updateState(WidgetState.hovered, false, rebuild: false); - final endedFocus = updateState(WidgetState.focused, false, rebuild: false); + final endedPress = updateState(WidgetState.pressed, false); + final endedHover = updateState(WidgetState.hovered, false); + final endedFocus = updateState(WidgetState.focused, false); if (!endedPress && !endedHover && !endedFocus) return; final onPressChange = widget.onPressChange; final onHoverChange = widget.onHoverChange; final onFocusChange = widget.onFocusChange; - WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (endedPress) onPressChange?.call(false); @@ -366,37 +183,24 @@ class _NakedLinkState extends State void didUpdateWidget(covariant NakedLink oldWidget) { super.didUpdateWidget(oldWidget); - final wasEnabled = oldWidget.enabled; + final wasEnabled = oldWidget.enabled && oldWidget.onPressed != null; if (wasEnabled == widget._effectiveEnabled) return; - updateDisabledState(!widget._effectiveEnabled, rebuild: false); + updateDisabledState(!widget._effectiveEnabled); if (!widget._effectiveEnabled) _clearInteractionStates(); } - Widget _buildLink(launcher.FollowLink? followLink) { + @override + Widget build(BuildContext context) { final isEnabled = widget._effectiveEnabled; final semanticLabel = widget.semanticLabel; final hasSemanticLabel = semanticLabel?.trim().isNotEmpty ?? false; - final pointerActivation = isEnabled - ? () => _handlePointerActivation(followLink!) - : null; - final semanticActivation = isEnabled - ? () { - // Flutter web converts a trusted DOM click on the semantics anchor - // into SemanticsAction.tap. Preserve the browser-owned modified - // click while keeping assistive-technology taps ordinary. - if (_isModifiedWebClick) { - _handleModifiedActivation(followLink!); - } else { - _handleOrdinaryActivation(followLink!); - } - } - : null; + Widget result = GestureDetector( onTapDown: isEnabled ? _handlePressStart : null, onTapUp: isEnabled ? (_) => _handlePressEnd() : null, - onTapCancel: isEnabled ? _handlePressCancel : null, - onTap: pointerActivation, + onTapCancel: isEnabled ? _handlePressEnd : null, + onTap: isEnabled ? _handleActivation : null, behavior: HitTestBehavior.opaque, excludeFromSemantics: true, child: NakedStateScopeBuilder( @@ -414,18 +218,16 @@ class _NakedLinkState extends State label: hasSemanticLabel ? semanticLabel : null, hint: widget.semanticHint, excludeSemantics: hasSemanticLabel, - onTap: semanticActivation, + onTap: isEnabled ? _handleActivation : null, child: result, ); } result = NakedFocusableDetector( - key: _contentKey, enabled: isEnabled, autofocus: widget.autofocus, canRequestFocus: isEnabled, includeSemantics: !widget.excludeSemantics, - restoreHoverOnEnable: true, onFocusChange: (focused) { updateFocusState(focused, widget.onFocusChange); }, @@ -437,28 +239,11 @@ class _NakedLinkState extends State ? (widget.mouseCursor ?? SystemMouseCursors.click) : SystemMouseCursors.basic, shortcuts: NakedIntentActions.link.shortcuts, - actions: NakedIntentActions.link.actions( - onPressed: () => _handleOrdinaryActivation(followLink!), - ), + actions: NakedIntentActions.link.actions(onPressed: _handleActivation), debugLabel: 'NakedLink', child: result, ); - return result; - } - - @override - Widget build(BuildContext context) { - final Widget result; - if (widget._effectiveEnabled) { - result = launcher.Link( - uri: widget.linkUrl, - builder: (context, followLink) => _buildLink(followLink), - ); - } else { - result = _buildLink(null); - } - return widget.excludeSemantics ? ExcludeSemantics(child: result) : result; } } diff --git a/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart b/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart index 6e8d9137..e5463599 100644 --- a/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart +++ b/packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart @@ -1,5 +1,3 @@ -import 'package:flutter/foundation.dart' show Listenable, defaultTargetPlatform; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../mixins/naked_mixins.dart'; @@ -18,7 +16,6 @@ class NakedFocusableDetector extends StatefulWidget { this.descendantsAreTraversable = true, this.skipTraversal = false, this.includeSemantics = true, - this.restoreHoverOnEnable = false, this.onFocusChange, this.onHoverChange, this.onEnableChange, @@ -55,11 +52,6 @@ class NakedFocusableDetector extends StatefulWidget { /// Whether to include focus semantics. final bool includeSemantics; - /// Whether to restore hover after re-enabling under a stationary pointer. - /// - /// This is opt-in so existing component hover behavior remains unchanged. - final bool restoreHoverOnEnable; - /// Called when the focus state changes. final ValueChanged? onFocusChange; @@ -94,8 +86,6 @@ class NakedFocusableDetector extends StatefulWidget { class _NakedFocusableDetectorState extends State with FocusNodeMixin { bool _wasEnabled = true; - bool _pointerInside = false; - bool _hoverReported = false; @override FocusNode? get widgetProvidedNode => widget.focusNode; @@ -117,62 +107,12 @@ class _NakedFocusableDetectorState extends State _wasEnabled = widget.enabled; } - void _handlePointerEnter(PointerEnterEvent event) { - _pointerInside = true; - if (widget.enabled) _reportHover(true); - } - - void _handlePointerExit(PointerExitEvent event) { - _pointerInside = false; - if (widget.enabled) _reportHover(false); - } - - void _reportHover(bool hovered) { - if (_hoverReported == hovered) return; - _hoverReported = hovered; - widget.onHoverChange?.call(hovered); - } - - void _restoreHoverAfterFrame() { - if (!_pointerInside || widget.onHoverChange == null) return; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && - widget.enabled && - widget.restoreHoverOnEnable && - _pointerInside) { - _reportHover(true); - } - }); - } - - void _clearHoverAfterFrame() { - if (!_hoverReported) return; - _hoverReported = false; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onHoverChange?.call(false); - }); - } - @override void didUpdateWidget(NakedFocusableDetector oldWidget) { super.didUpdateWidget(oldWidget); if (widget.enabled != oldWidget.enabled) { _handleEnabledChange(); - if (widget.restoreHoverOnEnable) { - if (widget.enabled) { - _restoreHoverAfterFrame(); - } else { - _clearHoverAfterFrame(); - } - } - } - - if ((!widget.restoreHoverOnEnable && oldWidget.restoreHoverOnEnable) || - (widget.onHoverChange == null && oldWidget.onHoverChange != null)) { - _pointerInside = false; - _hoverReported = false; } } @@ -188,16 +128,7 @@ class _NakedFocusableDetectorState extends State // Traditional: disabled = unfocusable. : widget.enabled && widget.canRequestFocus; - // Focus derives these values from a cached FocusNode snapshot. The custom - // semantics wrapper reads the current node and its ancestors instead. - final focusChild = widget.includeSemantics - ? _NakedFocusSemantics( - canRequestFocus: effectiveCanRequestFocus, - child: widget.child, - ) - : widget.child; - - // Start with Focus wrapping the child. + // Start with Focus wrapping the child Widget result = Focus( focusNode: effectiveFocusNode, autofocus: widget.autofocus, @@ -208,90 +139,35 @@ class _NakedFocusableDetectorState extends State skipTraversal: widget.skipTraversal, descendantsAreFocusable: widget.descendantsAreFocusable, descendantsAreTraversable: widget.descendantsAreTraversable, - includeSemantics: false, - child: focusChild, + includeSemantics: widget.includeSemantics, + child: widget.child, ); // Wrap with MouseRegion if hover detection is needed + // When disabled, MouseRegion still exists (for cursor) but doesn't trigger callbacks if (widget.onHoverChange != null) { - void Function(PointerEnterEvent)? onEnter; - void Function(PointerExitEvent)? onExit; - if (widget.restoreHoverOnEnable) { - onEnter = _handlePointerEnter; - onExit = _handlePointerExit; - } else if (widget.enabled) { - onEnter = (_) => widget.onHoverChange!(true); - onExit = (_) => widget.onHoverChange!(false); - } - result = MouseRegion( - onEnter: onEnter, - onExit: onExit, + onEnter: widget.enabled ? (_) => widget.onHoverChange!(true) : null, + onExit: widget.enabled ? (_) => widget.onHoverChange!(false) : null, cursor: widget.mouseCursor ?? MouseCursor.defer, child: result, ); } - // Keep the wrapper stable across enabled changes so stateful descendants - // are not recreated. An empty map disables local actions. - if (widget.actions != null && widget.actions!.isNotEmpty) { - result = Actions( - actions: widget.enabled - ? widget.actions! - : const >{}, - child: result, - ); + // Add Actions if provided and enabled + if (widget.enabled && + widget.actions != null && + widget.actions!.isNotEmpty) { + result = Actions(actions: widget.actions!, child: result); } - // Add Shortcuts last (outermost). An empty map disables local shortcuts - // without changing the widget-tree shape. - if (widget.shortcuts != null && widget.shortcuts!.isNotEmpty) { - result = Shortcuts( - shortcuts: widget.enabled - ? widget.shortcuts! - : const {}, - child: result, - ); + // Add Shortcuts last (outermost) if provided and enabled + if (widget.enabled && + widget.shortcuts != null && + widget.shortcuts!.isNotEmpty) { + result = Shortcuts(shortcuts: widget.shortcuts!, child: result); } return result; } } - -class _NakedFocusSemantics extends StatelessWidget { - const _NakedFocusSemantics({ - required this.canRequestFocus, - required this.child, - }); - - final bool canRequestFocus; - final Widget child; - - @override - Widget build(BuildContext context) { - final focusNode = Focus.of(context); - final focusChain = Listenable.merge([ - focusNode, - ...focusNode.ancestors, - ]); - - return ListenableBuilder( - listenable: focusChain, - builder: (context, child) { - final effectiveCanRequestFocus = - canRequestFocus && focusNode.canRequestFocus; - return Semantics( - onFocus: - defaultTargetPlatform != TargetPlatform.iOS && - effectiveCanRequestFocus - ? focusNode.requestFocus - : null, - focusable: effectiveCanRequestFocus, - focused: effectiveCanRequestFocus ? focusNode.hasPrimaryFocus : null, - child: child, - ); - }, - child: child, - ); - } -} diff --git a/packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart b/packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart deleted file mode 100644 index 48321d0d..00000000 --- a/packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart +++ /dev/null @@ -1,5 +0,0 @@ -/// Installs the browser click observer when running on the web. -void ensureInitialized() {} - -/// Consumes a pending modified browser click. -bool consumeModifiedClick() => false; diff --git a/packages/naked_ui/lib/src/utilities/web_event_modifiers_web.dart b/packages/naked_ui/lib/src/utilities/web_event_modifiers_web.dart deleted file mode 100644 index 5be36a32..00000000 --- a/packages/naked_ui/lib/src/utilities/web_event_modifiers_web.dart +++ /dev/null @@ -1,55 +0,0 @@ -@JS() -library; - -import 'dart:async'; -import 'dart:js_interop'; - -@JS('window') -external _BrowserWindow get _window; - -extension type _BrowserWindow._(JSObject _) implements JSObject { - external void addEventListener( - String type, - JSFunction listener, [ - JSAny options, - ]); -} - -extension type _BrowserEvent._(JSObject _) implements JSObject { - external bool get altKey; - external bool get ctrlKey; - external bool get metaKey; - external bool get shiftKey; -} - -var _initialized = false; -var _modifiedClickInProgress = false; -var _clickGeneration = 0; -late final JSFunction _clickListener; - -/// Installs the browser click observer when running on the web. -void ensureInitialized() { - if (_initialized) return; - _initialized = true; - _clickListener = _recordClick.toJS; - _window.addEventListener('click', _clickListener, true.toJS); -} - -void _recordClick(_BrowserEvent event) { - final generation = ++_clickGeneration; - _modifiedClickInProgress = - event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; - // Browsers may run microtasks between DOM listeners, so keep the modifier - // available until Flutter's target listener has dispatched its semantics - // action. Consumption normally clears it sooner. - Timer.run(() { - if (_clickGeneration == generation) _modifiedClickInProgress = false; - }); -} - -/// Consumes a pending modified browser click. -bool consumeModifiedClick() { - final modified = _modifiedClickInProgress; - _modifiedClickInProgress = false; - return modified; -} diff --git a/packages/naked_ui/pubspec.yaml b/packages/naked_ui/pubspec.yaml index b88e3414..08eb3fb6 100644 --- a/packages/naked_ui/pubspec.yaml +++ b/packages/naked_ui/pubspec.yaml @@ -14,13 +14,11 @@ environment: dependencies: flutter: sdk: flutter - url_launcher: ^6.3.2 dev_dependencies: flutter_test: sdk: flutter meta: ^1.15.0 - url_launcher_platform_interface: ^2.3.2 flutter: diff --git a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart index 17246c05..e37345a8 100644 --- a/packages/naked_ui/test/semantics/naked_link_semantics_test.dart +++ b/packages/naked_ui/test/semantics/naked_link_semantics_test.dart @@ -2,391 +2,212 @@ import 'dart:ui' show 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'; -final _destination = Uri.parse('https://example.com/docs'); - void main() { group('NakedLink semantics', () { - testWidgets('enabled Link exposes exact name role URL hint and action', ( + testWidgets('enabled Link exposes its exact role, name, URL, and action', ( tester, ) async { final handle = tester.ensureSemantics(); final linkUrl = Uri.parse('https://example.com/docs'); - try { - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: linkUrl, - semanticLabel: 'Documentation', - semanticHint: 'Opens in a new window', - onActivated: (_) {}, - child: const Text('Visible documentation'), - ), + await tester.pumpWidget( + _testApp( + NakedLink( + linkUrl: linkUrl, + semanticLabel: 'Documentation', + semanticHint: 'Opens in a new window', + onPressed: () {}, + child: const Text('Visible documentation'), ), - ); - - final data = _singleLinkData(tester); - expect(data.label, 'Documentation'); - expect(data.hint, 'Opens in a new window'); - expect(data.linkUrl, linkUrl); - expect(data.flagsCollection.isLink, isTrue); - expect(data.flagsCollection.isButton, isFalse); - expect(data.flagsCollection.isEnabled, Tristate.isTrue); - expect(data.flagsCollection.isFocused, Tristate.isFalse); - expect(data.hasAction(SemanticsAction.tap), isTrue); - } finally { - handle.dispose(); - } + ), + ); + + final data = _singleLinkData(tester); + expect(data.label, 'Documentation'); + expect(data.hint, 'Opens in a new window'); + expect(data.linkUrl, linkUrl); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.flagsCollection.isEnabled, Tristate.isTrue); + expect(data.flagsCollection.isFocused, Tristate.isFalse); + expect(data.hasAction(SemanticsAction.tap), isTrue); + handle.dispose(); }); - testWidgets('visible text supplies the name when no override is given', ( + testWidgets('visible text names the Link for null or blank overrides', ( tester, ) async { final handle = tester.ensureSemantics(); - try { + for (final semanticLabel in [null, ' ']) { await tester.pumpWidget( _testApp( NakedLink( - linkUrl: _destination, - onActivated: (_) {}, + semanticLabel: semanticLabel, + onPressed: () {}, child: const Text('Visible name'), ), ), ); expect(_singleLinkData(tester).label, 'Visible name'); - } finally { - handle.dispose(); } + handle.dispose(); }); - testWidgets('blank semantic label falls back to visible text', ( + testWidgets('semantic label replaces all descendant naming', ( tester, ) async { final handle = tester.ensureSemantics(); - try { - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - semanticLabel: ' \t\n ', - onActivated: (_) {}, - child: const Text('Visible name'), - ), - ), - ); - - expect(_singleLinkData(tester).label, 'Visible name'); - } finally { - handle.dispose(); - } - }); - - testWidgets('rich text supplies one complete Link name', (tester) async { - final handle = tester.ensureSemantics(); - - try { - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - onActivated: (_) {}, - child: const Text.rich( - TextSpan( - children: [ - TextSpan(text: 'Read '), - TextSpan(text: 'docs'), - ], + await tester.pumpWidget( + _testApp( + NakedLink( + semanticLabel: 'Accessible documentation', + onPressed: () {}, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Visible documentation'), + Semantics( + label: 'Decorative arrow', + image: true, + child: const SizedBox(width: 16, height: 16), ), - ), + ], ), ), - ); - - expect(_singleLinkData(tester).label, 'Read docs'); - } finally { - handle.dispose(); - } - }); - - testWidgets('semantic label replaces child naming without duplication', ( - tester, - ) async { - final handle = tester.ensureSemantics(); - - try { - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - semanticLabel: 'Accessible documentation', - onActivated: (_) {}, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Visible documentation'), - Semantics( - label: 'Decorative arrow', - image: true, - child: const SizedBox(width: 16, height: 16), - ), - ], - ), - ), - ), - ); - - final data = _singleLinkData(tester); - expect(data.label, 'Accessible documentation'); - final allLabels = _allSemanticsData(tester) - .map((value) => value.label) - .where((label) => label.isNotEmpty) - .toList(); - expect( - allLabels.where((label) => label == 'Accessible documentation'), - hasLength(1), - ); - expect(allLabels, isNot(contains('Visible documentation'))); - expect(allLabels, isNot(contains('Decorative arrow'))); - } finally { - handle.dispose(); - } - }); - - testWidgets('caller can exclude a decorative external icon', ( - tester, - ) async { - final handle = tester.ensureSemantics(); - - try { - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - semanticHint: 'Opens in a new window', - onActivated: (_) {}, - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('External documentation'), - ExcludeSemantics( - child: Icon(Icons.open_in_new, semanticLabel: 'External'), - ), - ], - ), - ), - ), - ); - - final data = _singleLinkData(tester); - expect(data.label, 'External documentation'); - expect(data.hint, 'Opens in a new window'); - expect( - _allSemanticsData(tester).where((value) => value.label == 'External'), - isEmpty, - ); - } finally { - handle.dispose(); - } + ), + ); + + final labels = _allSemanticsData( + tester, + ).map((data) => data.label).where((label) => label.isNotEmpty).toList(); + expect(_singleLinkData(tester).label, 'Accessible documentation'); + expect( + labels.where((label) => label == 'Accessible documentation'), + hasLength(1), + ); + expect(labels, isNot(contains('Visible documentation'))); + expect(labels, isNot(contains('Decorative arrow'))); + handle.dispose(); }); - testWidgets('focus flags follow the known focus node', (tester) async { + testWidgets('focus state follows the supplied focus node', (tester) async { final handle = tester.ensureSemantics(); final focusNode = FocusNode(debugLabel: 'semantic link'); addTearDown(focusNode.dispose); - try { - await tester.pumpWidget( - _testApp( - NakedLink( - focusNode: focusNode, - linkUrl: _destination, - onActivated: (_) {}, - child: const Text('Documentation'), - ), - ), - ); - expect( - _singleLinkData(tester).flagsCollection.isFocused, - Tristate.isFalse, - ); - - focusNode.requestFocus(); - await tester.pump(); - expect(focusNode.hasFocus, isTrue); - expect( - _singleLinkData(tester).flagsCollection.isFocused, - Tristate.isTrue, - ); - } finally { - handle.dispose(); - } - }); - - testWidgets('semantic tap uses the same activation path exactly once', ( - tester, - ) async { - final handle = tester.ensureSemantics(); - final events = []; - - try { - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - onActivated: (url) => events.add('observer:$url'), - child: const Text('Documentation'), - ), - resolve: (_, url) { - events.add('resolver:$url'); - return NakedLinkResolution.handled; - }, + await tester.pumpWidget( + _testApp( + NakedLink( + focusNode: focusNode, + onPressed: () {}, + child: const Text('Documentation'), ), - ); - - final node = _singleLinkNode(tester); - node.owner!.performAction(node.id, SemanticsAction.tap); - await tester.pump(); - expect(events, ['observer:$_destination', 'resolver:$_destination']); - } finally { - handle.dispose(); - } + ), + ); + expect( + _singleLinkData(tester).flagsCollection.isFocused, + Tristate.isFalse, + ); + + focusNode.requestFocus(); + await tester.pump(); + expect( + _singleLinkData(tester).flagsCollection.isFocused, + Tristate.isTrue, + ); + handle.dispose(); }); - testWidgets('semantic tap remains ordinary while a modifier is held', ( + testWidgets('semantic tap uses the activation callback exactly once', ( tester, ) async { final handle = tester.ensureSemantics(); - final events = []; + var callbackCount = 0; - try { - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - onActivated: (url) => events.add('observer:$url'), - child: const Text('Documentation'), - ), - resolve: (_, url) { - events.add('resolver:$url'); - return NakedLinkResolution.handled; - }, + await tester.pumpWidget( + _testApp( + NakedLink( + onPressed: () => callbackCount++, + child: const Text('Documentation'), ), - ); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - final node = _singleLinkNode(tester); - node.owner!.performAction(node.id, SemanticsAction.tap); - await tester.pump(); + ), + ); - expect(events, ['observer:$_destination', 'resolver:$_destination']); - } finally { - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - handle.dispose(); - } + final node = _singleLinkNode(tester); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pump(); + expect(callbackCount, 1); + handle.dispose(); }); - testWidgets('a resolver does not alter the semantic contract', ( + testWidgets('inert Links expose no role, URL, focus, or tap action', ( tester, ) async { final handle = tester.ensureSemantics(); + final linkUrl = Uri.parse('https://example.com/docs'); - try { - await tester.pumpWidget( - _testApp( - NakedLinkResolver( - resolve: (_, _) => NakedLinkResolution.handled, - child: NakedLink( - linkUrl: Uri.parse('https://example.com/docs'), - semanticLabel: 'Documentation', - child: const Text('Visible documentation'), + await tester.pumpWidget( + _testApp( + Column( + children: [ + NakedLink( + linkUrl: linkUrl, + semanticLabel: 'Missing callback', + child: const Text('Missing callback child'), ), - ), + NakedLink( + enabled: false, + linkUrl: linkUrl, + semanticLabel: 'Explicitly disabled', + onPressed: () {}, + child: const Text('Explicitly disabled child'), + ), + ], ), - ); + ), + ); - final data = _singleLinkData(tester); - expect(data.label, 'Documentation'); - expect(data.flagsCollection.isLink, isTrue); + expect(_linkNodes(tester), isEmpty); + for (final label in ['Missing callback', 'Explicitly disabled']) { + final data = _dataWithLabel(tester, label); + expect(data.flagsCollection.isLink, isFalse); expect(data.flagsCollection.isButton, isFalse); - expect(data.flagsCollection.isEnabled, Tristate.isTrue); - expect(data.flagsCollection.isFocused, Tristate.isFalse); - expect(data.hasAction(SemanticsAction.tap), isTrue); - } finally { - handle.dispose(); + expect(data.flagsCollection.isEnabled, Tristate.isFalse); + expect(data.flagsCollection.isFocused, Tristate.none); + expect(data.linkUrl, isNull); + expect(data.hasAction(SemanticsAction.tap), isFalse); } + handle.dispose(); }); - testWidgets( - 'disabled destination exposes unavailable text without URL or action', - (tester) async { - final handle = tester.ensureSemantics(); - final linkUrl = Uri.parse('https://example.com/docs'); - var activations = 0; - - try { - await tester.pumpWidget( - _testApp( - NakedLink( - enabled: false, - linkUrl: linkUrl, - semanticLabel: 'Unavailable documentation', - onActivated: (_) => activations++, - child: const Text('Documentation'), - ), - ), - ); - - final data = tester - .getSemantics(find.text('Documentation')) - .getSemanticsData(); - expect(data.label, 'Unavailable documentation'); - expect(data.linkUrl, isNull); - expect(data.flagsCollection.isLink, isFalse); - expect(data.flagsCollection.isButton, isFalse); - expect(data.flagsCollection.isEnabled, Tristate.isFalse); - expect(data.hasAction(SemanticsAction.tap), isFalse); - await tester.tap(find.text('Documentation')); - await tester.pump(); - expect(activations, 0); - } finally { - handle.dispose(); - } - }, - ); - - testWidgets('Arabic label and hint remain exact in RTL', (tester) async { + testWidgets('localized label and hint remain exact in RTL', (tester) async { final handle = tester.ensureSemantics(); - try { - await tester.pumpWidget( - _testApp( - Directionality( - textDirection: TextDirection.rtl, - child: NakedLink( - linkUrl: _destination, - semanticLabel: 'الوثائق', - semanticHint: 'يفتح في نافذة جديدة', - onActivated: (_) {}, - child: const Text('المستندات'), - ), + await tester.pumpWidget( + _testApp( + Directionality( + textDirection: TextDirection.rtl, + child: NakedLink( + semanticLabel: 'الوثائق', + semanticHint: 'يفتح في نافذة جديدة', + onPressed: () {}, + child: const Text('المستندات'), ), ), - ); + ), + ); - final data = _singleLinkData(tester); - expect(data.label, 'الوثائق'); - expect(data.hint, 'يفتح في نافذة جديدة'); - expect(data.textDirection, TextDirection.rtl); - } finally { - handle.dispose(); - } + final data = _singleLinkData(tester); + expect(data.label, 'الوثائق'); + expect(data.hint, 'يفتح في نافذة جديدة'); + expect(data.textDirection, TextDirection.rtl); + handle.dispose(); }); testWidgets('excludeSemantics removes Link and descendant semantics', ( @@ -394,57 +215,34 @@ void main() { ) async { final handle = tester.ensureSemantics(); - try { - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - semanticLabel: 'Documentation', - onActivated: (_) {}, - child: const Text('Visible documentation'), - ), - ), - ); - expect(_linkNodes(tester), hasLength(1)); - - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - semanticLabel: 'Documentation', - excludeSemantics: true, - onActivated: (_) {}, - child: const Text('Visible documentation'), - ), - ), - ); - - expect(_linkNodes(tester), isEmpty); - expect( - _allSemanticsData(tester).where( - (value) => - value.label == 'Documentation' || - value.label == 'Visible documentation', + await tester.pumpWidget( + _testApp( + NakedLink( + semanticLabel: 'Documentation', + excludeSemantics: true, + onPressed: () {}, + child: const Text('Visible documentation'), ), - isEmpty, - ); - } finally { - handle.dispose(); - } + ), + ); + + expect(_linkNodes(tester), isEmpty); + expect( + _allSemanticsData(tester).where( + (data) => + data.label == 'Documentation' || + data.label == 'Visible documentation', + ), + isEmpty, + ); + handle.dispose(); }); }); } -Widget _testApp(Widget child, {NakedLinkResolveCallback? resolve}) { +Widget _testApp(Widget child) { return MaterialApp( - home: Scaffold( - body: Center( - child: NakedLinkResolver( - resolve: resolve ?? (_, _) => NakedLinkResolution.handled, - child: child, - ), - ), - ), + home: Scaffold(body: Center(child: child)), ); } @@ -457,30 +255,33 @@ SemanticsNode _singleLinkNode(WidgetTester tester) { SemanticsData _singleLinkData(WidgetTester tester) => _singleLinkNode(tester).getSemanticsData(); +SemanticsData _dataWithLabel(WidgetTester tester, String label) { + final matches = _allSemanticsData( + tester, + ).where((data) => data.label == label).toList(); + expect(matches, hasLength(1)); + return matches.single; +} + List _linkNodes(WidgetTester tester) { - final root = tester.getSemantics(find.byType(Scaffold)); final nodes = []; - - void collect(SemanticsNode node) { - if (node.getSemanticsData().flagsCollection.isLink) { - nodes.add(node); - } - node.visitChildren((child) { - collect(child); - return true; - }); - } - - collect(root); + _visitSemantics(tester, (node) { + if (node.getSemanticsData().flagsCollection.isLink) nodes.add(node); + }); return nodes; } List _allSemanticsData(WidgetTester tester) { - final root = tester.getSemantics(find.byType(Scaffold)); final data = []; + _visitSemantics(tester, (node) => data.add(node.getSemanticsData())); + return data; +} + +void _visitSemantics(WidgetTester tester, ValueChanged visit) { + final root = tester.getSemantics(find.byType(Scaffold)); void collect(SemanticsNode node) { - data.add(node.getSemanticsData()); + visit(node); node.visitChildren((child) { collect(child); return true; @@ -488,5 +289,4 @@ List _allSemanticsData(WidgetTester tester) { } collect(root); - return data; } diff --git a/packages/naked_ui/test/src/naked_link_test.dart b/packages/naked_ui/test/src/naked_link_test.dart index 604c7e54..4b2bd992 100644 --- a/packages/naked_ui/test/src/naked_link_test.dart +++ b/packages/naked_ui/test/src/naked_link_test.dart @@ -7,60 +7,45 @@ import 'package:naked_ui/naked_ui.dart'; import '../test_helpers.dart'; -final _destination = Uri.parse('https://example.com/docs'); - void main() { - group('NakedLink public state contract', () { + group('NakedLink public contract', () { test('requires either a child or builder', () { - expect(() => NakedLink(linkUrl: _destination), throwsAssertionError); + expect(() => NakedLink(onPressed: () {}), throwsAssertionError); }); - testWidgets('renders its child without a builder', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: NakedLink( - linkUrl: _destination, - child: const Text('Documentation'), - ), - ), - ); - - expect(find.text('Documentation'), findsOneWidget); - }); - - testWidgets('builder and scope receive one immutable state snapshot', ( + testWidgets('works without a Material ancestor and exposes state', ( tester, ) async { final linkUrl = Uri.parse('https://example.com/docs'); NakedLinkState? builderState; NakedLinkState? scopedState; - Widget? receivedChild; await tester.pumpWidget( - MaterialApp( - home: NakedLink( - linkUrl: linkUrl, - child: const Text('Documentation'), - builder: (context, state, child) { - builderState = state; - scopedState = NakedLinkState.of(context); - receivedChild = child; - return child!; - }, + WidgetsApp( + color: const Color(0xffffffff), + builder: (context, child) => Center( + child: NakedLink( + linkUrl: linkUrl, + onPressed: () {}, + child: const Text('Documentation'), + builder: (context, state, child) { + builderState = state; + scopedState = NakedLinkState.of(context); + return child!; + }, + ), ), ), ); - expect(builderState, isNotNull); + expect(find.text('Documentation'), findsOneWidget); expect(scopedState, same(builderState)); - expect(receivedChild, isA()); expect(builderState!.linkUrl, linkUrl); expect(builderState!.states, isEmpty); expect( () => builderState!.states.add(WidgetState.hovered), throwsUnsupportedError, ); - expect(builderState!.states, isEmpty); }); test('state equality and hash include states and URL metadata', () { @@ -83,241 +68,8 @@ void main() { }); }); - group('NakedLink resolver contract', () { - testWidgets('nearest resolver follows the observer with the exact URI', ( - tester, - ) async { - final linkUrl = Uri.parse('custom-scheme:destination'); - final events = []; - - await tester.pumpWidget( - _testApp( - NakedLinkResolver( - resolve: (context, resolvedUrl) { - events.add('outer:$resolvedUrl'); - expect(resolvedUrl, linkUrl); - return NakedLinkResolution.handled; - }, - child: NakedLinkResolver( - resolve: (context, resolvedUrl) { - events.add('inner:$resolvedUrl'); - expect(context, same(tester.element(find.byType(NakedLink)))); - return NakedLinkResolution.handled; - }, - child: NakedLink( - linkUrl: linkUrl, - onActivated: (activatedUrl) { - events.add('observer:$activatedUrl'); - }, - child: const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Link')); - await tester.pump(); - - expect(events, ['observer:$linkUrl', 'inner:$linkUrl']); - }); - - testWidgets('platformDefault falls through to platform navigation', ( - tester, - ) async { - final platformCalls = []; - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - const launcherChannel = MethodChannel('plugins.flutter.io/url_launcher'); - messenger.setMockMethodCallHandler(launcherChannel, (call) async { - platformCalls.add(call); - return true; - }); - addTearDown( - () => messenger.setMockMethodCallHandler(launcherChannel, null), - ); - - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: Uri.parse('https://example.com/platform-default'), - child: const SizedBox(width: 160, height: 48, child: Text('Link')), - ), - resolve: (_, _) => NakedLinkResolution.platformDefault, - ), - ); - - await tester.tap(find.text('Link')); - await tester.pump(); - await tester.pump(); - - expect(platformCalls.map((call) => call.method), contains('launch')); - }); - - testWidgets('no resolver is equivalent to platformDefault', (tester) async { - final platformCalls = []; - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - const launcherChannel = MethodChannel('plugins.flutter.io/url_launcher'); - messenger.setMockMethodCallHandler(launcherChannel, (call) async { - platformCalls.add(call); - return true; - }); - addTearDown( - () => messenger.setMockMethodCallHandler(launcherChannel, null), - ); - - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: Uri.parse('https://example.com/no-resolver'), - child: const SizedBox(width: 160, height: 48, child: Text('Link')), - ), - includeResolver: false, - ), - ); - - await tester.tap(find.text('Link')); - await tester.pump(); - await tester.pump(); - - expect(platformCalls.map((call) => call.method), contains('launch')); - }); - - testWidgets('resolver exceptions surface without default fallback', ( - tester, - ) async { - final platformCalls = []; - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - const launcherChannel = MethodChannel('plugins.flutter.io/url_launcher'); - messenger.setMockMethodCallHandler(launcherChannel, (call) async { - platformCalls.add(call); - return true; - }); - addTearDown( - () => messenger.setMockMethodCallHandler(launcherChannel, null), - ); - final events = []; - - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: Uri.parse('https://example.com/resolver-error'), - onActivated: (_) => events.add('observer'), - child: const SizedBox(width: 160, height: 48, child: Text('Link')), - ), - resolve: (_, _) { - events.add('resolver'); - throw StateError('resolver failure'); - }, - ), - ); - - await tester.tap(find.text('Link')); - await tester.pump(); - - expect(events, ['observer', 'resolver']); - expect(tester.takeException(), isA()); - expect(platformCalls, isEmpty); - }); - - testWidgets('disabled Link invokes neither observer nor resolver', ( - tester, - ) async { - var observerCalls = 0; - var resolverCalls = 0; - - await tester.pumpWidget( - _testApp( - NakedLink( - enabled: false, - linkUrl: Uri.parse('https://example.com/disabled'), - onActivated: (_) => observerCalls++, - child: const SizedBox(width: 160, height: 48, child: Text('Link')), - ), - resolve: (_, _) { - resolverCalls++; - return NakedLinkResolution.handled; - }, - ), - ); - - await tester.tap(find.text('Link')); - await tester.pump(); - - expect(observerCalls, 0); - expect(resolverCalls, 0); - }); - }); - - group('NakedLink activation contract', () { - testWidgets('enabled is the only availability switch and retains the URI', ( - tester, - ) async { - const enabledKey = ValueKey('enabled'); - const explicitDisabledKey = ValueKey('explicit-disabled'); - var callbackCount = 0; - NakedLinkState? enabledState; - NakedLinkState? explicitDisabledState; - - await tester.pumpWidget( - _testApp( - Column( - mainAxisSize: MainAxisSize.min, - children: [ - NakedLink( - key: enabledKey, - linkUrl: _destination, - onActivated: (_) => callbackCount++, - builder: (context, state, child) { - enabledState = state; - return const SizedBox( - width: 160, - height: 48, - child: Text('Enabled'), - ); - }, - ), - NakedLink( - key: explicitDisabledKey, - enabled: false, - linkUrl: Uri.parse('https://example.com/unavailable'), - onActivated: (_) => callbackCount++, - builder: (context, state, child) { - explicitDisabledState = state; - return const SizedBox( - width: 160, - height: 48, - child: Text('Explicitly disabled'), - ); - }, - ), - ], - ), - ), - ); - - expect(enabledState!.isDisabled, isFalse); - expect(enabledState!.linkUrl, _destination); - expect(explicitDisabledState!.isDisabled, isTrue); - expect( - explicitDisabledState!.linkUrl, - Uri.parse('https://example.com/unavailable'), - ); - - await tester.tap(find.byKey(explicitDisabledKey)); - await tester.pump(); - expect(callbackCount, 0); - tester.expectCursor(SystemMouseCursors.click, on: enabledKey); - tester.expectCursor(SystemMouseCursors.basic, on: explicitDisabledKey); - }); - - testWidgets('primary tap updates press state and activates exactly once', ( + group('NakedLink activation', () { + testWidgets('primary tap updates press state and activates once', ( tester, ) async { const linkKey = ValueKey('link'); @@ -329,8 +81,7 @@ void main() { _testApp( NakedLink( key: linkKey, - linkUrl: _destination, - onActivated: (_) => callbackCount++, + onPressed: () => callbackCount++, onPressChange: pressChanges.add, builder: (context, value, child) { state = value; @@ -350,7 +101,6 @@ void main() { await tester.pump(); expect(state!.isPressed, isTrue); expect(pressChanges, [true]); - expect(callbackCount, 0); await gesture.up(); await tester.pump(); @@ -359,7 +109,7 @@ void main() { expect(callbackCount, 1); }); - testWidgets('canceled primary gesture clears press without activating', ( + testWidgets('canceled and secondary gestures do not activate', ( tester, ) async { const linkKey = ValueKey('link'); @@ -370,8 +120,7 @@ void main() { _testApp( NakedLink( key: linkKey, - linkUrl: _destination, - onActivated: (_) => callbackCount++, + onPressed: () => callbackCount++, onPressChange: pressChanges.add, child: const SizedBox(width: 160, height: 48, child: Text('Link')), ), @@ -389,24 +138,6 @@ void main() { expect(pressChanges, [true, false]); expect(callbackCount, 0); - }); - - testWidgets('secondary and middle clicks remain unclaimed', (tester) async { - const linkKey = ValueKey('link'); - var callbackCount = 0; - final pressChanges = []; - - await tester.pumpWidget( - _testApp( - NakedLink( - key: linkKey, - linkUrl: _destination, - onActivated: (_) => callbackCount++, - onPressChange: pressChanges.add, - child: const SizedBox(width: 160, height: 48, child: Text('Link')), - ), - ), - ); await tester.tapAt( tester.getCenter(find.byKey(linkKey)), @@ -414,161 +145,14 @@ void main() { buttons: kSecondaryMouseButton, ); await tester.pump(); - - expect(callbackCount, 0); - expect(pressChanges, isEmpty); - - await tester.tapAt( - tester.getCenter(find.byKey(linkKey)), - kind: PointerDeviceKind.mouse, - buttons: kMiddleMouseButton, - ); - await tester.pump(); - + expect(pressChanges, [true, false]); expect(callbackCount, 0); - expect(pressChanges, isEmpty); }); - testWidgets( - 'modified primary activation bypasses the observer and resolver', - (tester) async { - const linkKey = ValueKey('link'); - var observerCalls = 0; - var resolverCalls = 0; - - await tester.pumpWidget( - _testApp( - NakedLink( - key: linkKey, - linkUrl: Uri.parse('/modified-primary'), - onActivated: (_) => observerCalls++, - child: const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ), - ), - resolve: (_, _) { - resolverCalls++; - return NakedLinkResolution.handled; - }, - ), - ); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.tapAt( - tester.getCenter(find.byKey(linkKey)), - kind: PointerDeviceKind.mouse, - ); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(); - - expect(observerCalls, 0); - expect(resolverCalls, 0); - }, - ); - - testWidgets( - 'modifier pressed after pointer down keeps activation browser-owned', - (tester) async { - const linkKey = ValueKey('link'); - var observerCalls = 0; - var resolverCalls = 0; - - await tester.pumpWidget( - _testApp( - NakedLink( - key: linkKey, - linkUrl: Uri.parse('/modified-after-down'), - onActivated: (_) => observerCalls++, - child: const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ), - ), - resolve: (_, _) { - resolverCalls++; - return NakedLinkResolution.handled; - }, - ), - ); - - final gesture = await tester.startGesture( - tester.getCenter(find.byKey(linkKey)), - kind: PointerDeviceKind.mouse, - ); - var pointerIsDown = true; - addTearDown(() async { - if (pointerIsDown) await gesture.cancel(); - }); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - try { - await gesture.up(); - pointerIsDown = false; - await tester.pump(); - - expect(observerCalls, 0); - expect(resolverCalls, 0); - } finally { - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - } - }, - ); - - testWidgets('Enter and Numpad Enter activate while Space does not', ( + testWidgets('Enter keys activate, repeats and Space do not', ( tester, ) async { - final focusNode = FocusNode(debugLabel: 'link test'); - addTearDown(focusNode.dispose); - final events = []; - NakedLinkState? state; - - await tester.pumpWidget( - _testApp( - NakedLink( - focusNode: focusNode, - linkUrl: _destination, - onActivated: (_) => events.add('observer'), - builder: (context, value, child) { - state = value; - return const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ); - }, - ), - resolve: (_, _) { - events.add('resolver'); - return NakedLinkResolution.handled; - }, - ), - ); - focusNode.requestFocus(); - await tester.pump(); - await tester.pump(); - expect(focusNode.hasFocus, isTrue); - - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - expect(events, ['observer', 'resolver']); - - await tester.sendKeyEvent(LogicalKeyboardKey.numpadEnter); - await tester.pump(); - expect(events, ['observer', 'resolver', 'observer', 'resolver']); - - await tester.sendKeyEvent(LogicalKeyboardKey.space); - await tester.pump(); - expect(events, ['observer', 'resolver', 'observer', 'resolver']); - expect(state!.isPressed, isFalse); - }); - - testWidgets('a held Enter key activates only once per key sequence', ( - tester, - ) async { - final focusNode = FocusNode(debugLabel: 'repeating link'); + final focusNode = FocusNode(debugLabel: 'link activation'); addTearDown(focusNode.dispose); var callbackCount = 0; @@ -576,8 +160,7 @@ void main() { _testApp( NakedLink( focusNode: focusNode, - linkUrl: _destination, - onActivated: (_) => callbackCount++, + onPressed: () => callbackCount++, child: const Text('Link'), ), ), @@ -587,37 +170,16 @@ void main() { await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); await tester.sendKeyRepeatEvent(LogicalKeyboardKey.enter); - await tester.sendKeyRepeatEvent(LogicalKeyboardKey.enter); await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); - await tester.pump(); - expect(callbackCount, 1); - }); - - testWidgets('long-press selection wins over Link activation', ( - tester, - ) async { - var callbackCount = 0; - - await tester.pumpWidget( - _testApp( - NakedLink( - linkUrl: _destination, - onActivated: (_) => callbackCount++, - child: const SelectableText('Selectable documentation text'), - ), - ), - ); - await tester.longPress(find.text('Selectable documentation text')); + await tester.sendKeyEvent(LogicalKeyboardKey.numpadEnter); + await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pump(); - - final editable = tester.widget(find.byType(EditableText)); - expect(editable.controller.selection.isCollapsed, isFalse); - expect(callbackCount, 0); + expect(callbackCount, 2); }); - testWidgets('feedback occurs only for accepted enabled activation', ( + testWidgets('feedback occurs only for accepted opted-in activation', ( tester, ) async { final oldPlatform = debugDefaultTargetPlatformOverride; @@ -632,8 +194,7 @@ void main() { }); try { - var enabled = true; - var feedback = true; + var enableFeedback = true; late StateSetter rebuild; await tester.pumpWidget( _testApp( @@ -641,15 +202,9 @@ void main() { builder: (context, setState) { rebuild = setState; return NakedLink( - enabled: enabled, - enableFeedback: feedback, - linkUrl: _destination, - onActivated: (_) {}, - child: const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ), + enableFeedback: enableFeedback, + onPressed: () {}, + child: const Text('Link'), ); }, ), @@ -658,30 +213,13 @@ void main() { await tester.tap(find.text('Link')); await tester.pump(); - expect( - platformCalls.where((call) => call.method == 'SystemSound.play'), - hasLength(1), - ); - rebuild(() => feedback = false); - await tester.pump(); - await tester.tap(find.text('Link')); - await tester.pump(); - expect( - platformCalls.where((call) => call.method == 'SystemSound.play'), - hasLength(1), - ); + expect(_systemSoundCalls(platformCalls), hasLength(1)); - rebuild(() { - feedback = true; - enabled = false; - }); + rebuild(() => enableFeedback = false); await tester.pump(); await tester.tap(find.text('Link')); await tester.pump(); - expect( - platformCalls.where((call) => call.method == 'SystemSound.play'), - hasLength(1), - ); + expect(_systemSoundCalls(platformCalls), hasLength(1)); } finally { messenger.setMockMethodCallHandler(SystemChannels.platform, null); debugDefaultTargetPlatformOverride = oldPlatform; @@ -690,7 +228,7 @@ void main() { }); group('NakedLink interaction state and lifecycle', () { - testWidgets('hover and focus transitions update callbacks and scope', ( + testWidgets('hover and focus update callbacks and builder state', ( tester, ) async { const linkKey = ValueKey('link'); @@ -705,13 +243,11 @@ void main() { NakedLink( key: linkKey, focusNode: focusNode, - linkUrl: _destination, - onActivated: (_) {}, + onPressed: () {}, onHoverChange: hoverChanges.add, onFocusChange: focusChanges.add, builder: (context, value, child) { - state = NakedLinkState.of(context); - expect(state, same(value)); + state = value; return const SizedBox( width: 160, height: 48, @@ -733,34 +269,32 @@ void main() { focusNode.requestFocus(); await tester.pump(); await tester.pump(); - expect(focusNode.hasFocus, isTrue); - expect(focusChanges, [true]); expect(state!.isFocused, isTrue); + expect(focusChanges, [true]); await mouse.moveTo(const Offset(-100, -100)); - await tester.pump(); - expect(state!.isHovered, isFalse); - expect(hoverChanges, [true, false]); - focusNode.unfocus(); await tester.pump(); await tester.pump(); + expect(state!.isHovered, isFalse); expect(state!.isFocused, isFalse); + expect(hoverChanges, [true, false]); expect(focusChanges, [true, false]); }); - testWidgets('effective disabled state controls traversal and cursor', ( + testWidgets('effective disabled state blocks traversal and activation', ( tester, ) async { + const explicitKey = ValueKey('explicit-disabled'); + const callbackKey = ValueKey('callback-disabled'); const enabledKey = ValueKey('enabled'); - const explicitDisabledKey = ValueKey('explicit-disabled'); - const customKey = ValueKey('custom'); - final enabledNode = FocusNode(debugLabel: 'enabled link'); - final explicitDisabledNode = FocusNode(debugLabel: 'explicit disabled'); - final nextNode = FocusNode(debugLabel: 'next'); + final explicitNode = FocusNode(debugLabel: 'explicit disabled'); + final callbackNode = FocusNode(debugLabel: 'callback disabled'); + final enabledNode = FocusNode(debugLabel: 'enabled'); + addTearDown(explicitNode.dispose); + addTearDown(callbackNode.dispose); addTearDown(enabledNode.dispose); - addTearDown(explicitDisabledNode.dispose); - addTearDown(nextNode.dispose); + var callbackCount = 0; await tester.pumpWidget( _testApp( @@ -768,31 +302,22 @@ void main() { mainAxisSize: MainAxisSize.min, children: [ NakedLink( - key: explicitDisabledKey, + key: explicitKey, enabled: false, - focusNode: explicitDisabledNode, - linkUrl: _destination, - onActivated: (_) {}, - child: const SizedBox(child: Text('Explicit disabled')), + focusNode: explicitNode, + onPressed: () => callbackCount++, + child: const Text('Explicit disabled'), ), NakedLink( - key: enabledKey, - focusNode: enabledNode, - linkUrl: _destination, - onActivated: (_) {}, - child: const SizedBox(child: Text('Enabled')), + key: callbackKey, + focusNode: callbackNode, + child: const Text('Callback disabled'), ), NakedLink( - key: customKey, - mouseCursor: SystemMouseCursors.help, - linkUrl: _destination, - onActivated: (_) {}, - child: const SizedBox(child: Text('Custom')), - ), - TextButton( - focusNode: nextNode, - onPressed: () {}, - child: const Text('Next'), + key: enabledKey, + focusNode: enabledNode, + onPressed: () => callbackCount++, + child: const Text('Enabled'), ), ], ), @@ -802,51 +327,28 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); expect(enabledNode.hasFocus, isTrue); - expect(explicitDisabledNode.hasFocus, isFalse); - - tester.expectCursor(SystemMouseCursors.click, on: enabledKey); - tester.expectCursor(SystemMouseCursors.basic, on: explicitDisabledKey); - tester.expectCursor(SystemMouseCursors.help, on: customKey); - }); - - testWidgets('disabled Link rejects focus in directional navigation', ( - tester, - ) async { - final focusNode = FocusNode(debugLabel: 'directional disabled Link'); - addTearDown(focusNode.dispose); + expect(explicitNode.hasFocus, isFalse); + expect(callbackNode.hasFocus, isFalse); - await tester.pumpWidget( - MaterialApp( - home: MediaQuery( - data: const MediaQueryData( - navigationMode: NavigationMode.directional, - ), - child: NakedLink( - enabled: false, - focusNode: focusNode, - linkUrl: _destination, - onActivated: (_) {}, - child: const Text('Unavailable Link'), - ), - ), - ), - ); - - focusNode.requestFocus(); + await tester.tap(find.text('Explicit disabled')); + await tester.tap(find.text('Callback disabled')); await tester.pump(); - - expect(focusNode.hasFocus, isFalse); + expect(callbackCount, 0); + tester.expectCursor(SystemMouseCursors.basic, on: explicitKey); + tester.expectCursor(SystemMouseCursors.basic, on: callbackKey); + tester.expectCursor(SystemMouseCursors.click, on: enabledKey); }); - testWidgets('disabling retains the destination and clears hover', ( + testWidgets('callback removal disables and clears interaction state', ( tester, ) async { const linkKey = ValueKey('link'); final focusNode = FocusNode(debugLabel: 'dynamic link'); addTearDown(focusNode.dispose); final hoverChanges = []; - var callbackCount = 0; - var enabled = true; + final focusChanges = []; + final pressChanges = []; + VoidCallback? callback = () {}; NakedLinkState? state; late StateSetter rebuild; @@ -858,10 +360,10 @@ void main() { return NakedLink( key: linkKey, focusNode: focusNode, - enabled: enabled, - linkUrl: _destination, - onActivated: (_) => callbackCount++, + onPressed: callback, onHoverChange: hoverChanges.add, + onFocusChange: focusChanges.add, + onPressChange: pressChanges.add, builder: (context, value, child) { state = value; return const SizedBox( @@ -880,124 +382,40 @@ void main() { await mouse.addPointer(location: Offset.zero); addTearDown(mouse.removePointer); await mouse.moveTo(tester.getCenter(find.byKey(linkKey))); - await tester.pump(); - expect(state!.isHovered, isTrue); - focusNode.requestFocus(); await tester.pump(); - rebuild(() => enabled = false); - await tester.pump(); - - expect(state!.isDisabled, isTrue); - expect(state!.linkUrl, _destination); - expect(state!.isHovered, isFalse); - expect(state!.isPressed, isFalse); - expect(hoverChanges, [true, false]); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.tap(find.text('Link')); - await tester.pump(); - expect(callbackCount, 0); - tester.expectCursor(SystemMouseCursors.basic, on: linkKey); - }); - - testWidgets('disabling while hovered permits a parent-setState callback', ( - tester, - ) async { - const linkKey = ValueKey('link'); - var enabled = true; - var hovered = false; - late StateSetter rebuild; - - await tester.pumpWidget( - _testApp( - StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedLink( - key: linkKey, - enabled: enabled, - linkUrl: Uri.parse('https://example.com/docs'), - onActivated: (_) {}, - onHoverChange: (value) => setState(() => hovered = value), - child: const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ), - ); - }, - ), - ), - ); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(location: Offset.zero); - addTearDown(mouse.removePointer); - await mouse.moveTo(tester.getCenter(find.byKey(linkKey))); - await tester.pump(); - expect(hovered, isTrue); - - rebuild(() => enabled = false); - await tester.pump(); - await tester.pump(); - - expect(tester.takeException(), isNull); - expect(hovered, isFalse); - }); - - testWidgets('disabling while pressed permits a parent-setState callback', ( - tester, - ) async { - const linkKey = ValueKey('link'); - var enabled = true; - var pressed = false; - late StateSetter rebuild; - - await tester.pumpWidget( - _testApp( - StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedLink( - key: linkKey, - enabled: enabled, - linkUrl: Uri.parse('https://example.com/docs'), - onActivated: (_) {}, - onPressChange: (value) => setState(() => pressed = value), - child: const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ), - ); - }, - ), - ), - ); + expect(state!.isHovered, isTrue); + expect(state!.isFocused, isTrue); - final gesture = await tester.startGesture( + final press = await tester.startGesture( tester.getCenter(find.byKey(linkKey)), - kind: PointerDeviceKind.mouse, ); - addTearDown(gesture.cancel); await tester.pump(); - expect(pressed, isTrue); + expect(state!.isPressed, isTrue); - rebuild(() => enabled = false); + rebuild(() => callback = null); await tester.pump(); await tester.pump(); + expect(state!.isDisabled, isTrue); + expect(state!.isHovered, isFalse); + expect(state!.isFocused, isFalse); + expect(state!.isPressed, isFalse); + expect(hoverChanges, [true, false]); + expect(focusChanges, [true, false]); + expect(pressChanges, [true, false]); expect(tester.takeException(), isNull); - expect(pressed, isFalse); + await press.cancel(); }); - testWidgets('reenabling under a stationary pointer restores hover', ( + testWidgets('focus node replacement preserves caller ownership', ( tester, ) async { - const linkKey = ValueKey('link'); - var enabled = true; - final hoverChanges = []; - NakedLinkState? state; + final firstNode = FocusNode(debugLabel: 'first external link'); + final secondNode = FocusNode(debugLabel: 'second external link'); + addTearDown(firstNode.dispose); + addTearDown(secondNode.dispose); + var currentNode = firstNode; late StateSetter rebuild; await tester.pumpWidget( @@ -1006,172 +424,39 @@ void main() { builder: (context, setState) { rebuild = setState; return NakedLink( - key: linkKey, - enabled: enabled, - linkUrl: Uri.parse('https://example.com/docs'), - onActivated: (_) {}, - onHoverChange: hoverChanges.add, - builder: (context, value, child) { - state = value; - return const SizedBox( - width: 160, - height: 48, - child: Text('Link'), - ); - }, + autofocus: true, + focusNode: currentNode, + onPressed: () {}, + child: const Text('Link'), ); }, ), ), ); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(location: Offset.zero); - addTearDown(mouse.removePointer); - await mouse.moveTo(tester.getCenter(find.byKey(linkKey))); await tester.pump(); - expect(state!.isHovered, isTrue); - - rebuild(() => enabled = false); - await tester.pump(); - await tester.pump(); - expect(state!.isHovered, isFalse); + expect(firstNode.hasFocus, isTrue); - rebuild(() => enabled = true); + rebuild(() => currentNode = secondNode); await tester.pump(); await tester.pump(); - - expect(state!.isHovered, isTrue); - expect(hoverChanges, [true, false, true]); - }); - - testWidgets('availability changes preserve the stateful child subtree', ( - tester, - ) async { - var enabled = true; - var initCount = 0; - var disposeCount = 0; - late StateSetter rebuild; - - await tester.pumpWidget( - _testApp( - StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedLink( - enabled: enabled, - linkUrl: _destination, - onActivated: (_) {}, - child: _LifecycleProbe( - onInit: () => initCount++, - onDispose: () => disposeCount++, - ), - ); - }, - ), - ), - ); - - expect(initCount, 1); - expect(disposeCount, 0); - - rebuild(() => enabled = false); - await tester.pump(); - rebuild(() => enabled = true); - await tester.pump(); - - expect(initCount, 1); - expect(disposeCount, 0); + expect(firstNode.hasFocus, isFalse); + expect(secondNode.hasFocus, isTrue); await tester.pumpWidget(const SizedBox.shrink()); - expect(disposeCount, 1); + final listener = () {}; + expect(() => firstNode.addListener(listener), returnsNormally); + firstNode.removeListener(listener); + expect(() => secondNode.addListener(listener), returnsNormally); + secondNode.removeListener(listener); }); - - testWidgets( - 'autofocus works and focus-node replacement preserves ownership', - (tester) async { - final firstNode = FocusNode(debugLabel: 'first external link'); - final secondNode = FocusNode(debugLabel: 'second external link'); - addTearDown(firstNode.dispose); - addTearDown(secondNode.dispose); - var currentNode = firstNode; - late StateSetter rebuild; - - await tester.pumpWidget( - _testApp( - StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedLink( - autofocus: true, - focusNode: currentNode, - linkUrl: _destination, - onActivated: (_) {}, - child: const SizedBox(child: Text('Link')), - ); - }, - ), - ), - ); - await tester.pump(); - expect(firstNode.hasFocus, isTrue); - - rebuild(() => currentNode = secondNode); - await tester.pump(); - await tester.pump(); - expect(firstNode.hasFocus, isFalse); - expect(secondNode.hasFocus, isTrue); - - await tester.pumpWidget(const SizedBox.shrink()); - final listener = () {}; - expect(() => firstNode.addListener(listener), returnsNormally); - firstNode.removeListener(listener); - expect(() => secondNode.addListener(listener), returnsNormally); - secondNode.removeListener(listener); - }, - ); }); } -Widget _testApp( - Widget child, { - NakedLinkResolveCallback? resolve, - bool includeResolver = true, -}) { - final content = includeResolver - ? NakedLinkResolver( - resolve: resolve ?? (_, _) => NakedLinkResolution.handled, - child: child, - ) - : child; +Iterable _systemSoundCalls(Iterable calls) => + calls.where((call) => call.method == 'SystemSound.play'); + +Widget _testApp(Widget child) { return MaterialApp( - home: Scaffold(body: Center(child: content)), + home: Scaffold(body: Center(child: child)), ); } - -class _LifecycleProbe extends StatefulWidget { - const _LifecycleProbe({required this.onInit, required this.onDispose}); - - final VoidCallback onInit; - final VoidCallback onDispose; - - @override - State<_LifecycleProbe> createState() => _LifecycleProbeState(); -} - -class _LifecycleProbeState extends State<_LifecycleProbe> { - @override - void initState() { - super.initState(); - widget.onInit(); - } - - @override - void dispose() { - widget.onDispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => const Text('Stateful child'); -} diff --git a/packages/naked_ui/test/src/naked_link_web_test.dart b/packages/naked_ui/test/src/naked_link_web_test.dart deleted file mode 100644 index aa6f96a1..00000000 --- a/packages/naked_ui/test/src/naked_link_web_test.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:naked_ui/naked_ui.dart'; -import 'package:url_launcher_platform_interface/link.dart'; -import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; - -import 'web_modifier_listener_stub.dart' - if (dart.library.js_interop) 'web_modifier_listener_web.dart' - as web_modifier_listener; - -void main() { - late UrlLauncherPlatform originalPlatform; - late _FakeUrlLauncherPlatform platform; - - setUp(() { - originalPlatform = UrlLauncherPlatform.instance; - platform = _FakeUrlLauncherPlatform(); - UrlLauncherPlatform.instance = platform; - }); - - tearDown(() { - UrlLauncherPlatform.instance = originalPlatform; - }); - - testWidgets('web delegates browser and OS handler schemes to FollowLink', ( - tester, - ) async { - final destinations = [ - Uri.parse('mailto:person@example.com'), - Uri.parse('tel:+15551234567'), - Uri.parse('custom-scheme:destination'), - ]; - - for (final destination in destinations) { - platform.clear(); - await tester.pumpWidget(_testApp(destination)); - await tester.tap(find.text('Open destination')); - await tester.pump(); - - expect(platform.followedUrls, [destination]); - expect(platform.launchCalls, isEmpty); - } - }, skip: !kIsWeb); - - testWidgets('web keeps modified handler schemes browser-owned', ( - tester, - ) async { - final destination = Uri.parse('mailto:person@example.com'); - - await tester.pumpWidget(_testApp(destination)); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - try { - await tester.tap(find.text('Open destination')); - await tester.pump(); - - expect(platform.followedUrls, [destination]); - expect(platform.launchCalls, isEmpty); - } finally { - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - } - }, skip: !kIsWeb); - - testWidgets('web retains DOM modifiers through target click listeners', ( - tester, - ) async { - await tester.pumpWidget(_testApp(Uri.parse('https://example.com/docs'))); - - expect(await web_modifier_listener.probeCaptureToTarget(), isTrue); - await tester.pump(Duration.zero); - }, skip: !kIsWeb); - - testWidgets('web opens HTTP and HTTPS destinations in the current tab', ( - tester, - ) async { - final destinations = [ - Uri.parse('http://example.com/docs'), - Uri.parse('https://example.com/docs'), - ]; - - for (final destination in destinations) { - platform.clear(); - await tester.pumpWidget(_testApp(destination)); - await tester.tap(find.text('Open destination')); - await tester.pump(); - - expect(platform.followedUrls, isEmpty); - expect(platform.launchCalls, hasLength(1)); - expect(platform.launchCalls.single.$1, destination.toString()); - expect(platform.launchCalls.single.$2.webOnlyWindowName, '_self'); - } - }, skip: !kIsWeb); - - testWidgets('web routes javascript to url_launcher launchUrl', ( - tester, - ) async { - final destination = Uri.parse('javascript:alert("blocked")'); - - await tester.pumpWidget(_testApp(destination)); - await tester.tap(find.text('Open destination')); - await tester.pump(); - - expect(platform.followedUrls, isEmpty); - expect(platform.launchCalls, hasLength(1)); - expect(platform.launchCalls.single.$1, destination.toString()); - }, skip: !kIsWeb); - - testWidgets('web routes modified javascript to url_launcher launchUrl', ( - tester, - ) async { - final destination = Uri.parse('javascript:alert("blocked")'); - - await tester.pumpWidget(_testApp(destination)); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - try { - await tester.tap(find.text('Open destination')); - await tester.pump(); - - expect(platform.followedUrls, isEmpty); - expect(platform.launchCalls, hasLength(1)); - expect(platform.launchCalls.single.$1, destination.toString()); - } finally { - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - } - }, skip: !kIsWeb); - - testWidgets('web delegates scheme-less routes to Link followLink', ( - tester, - ) async { - final destination = Uri.parse('/account/settings'); - - await tester.pumpWidget(_testApp(destination)); - await tester.tap(find.text('Open destination')); - await tester.pump(); - - expect(platform.followedUrls, [destination]); - expect(platform.launchCalls, isEmpty); - }, skip: !kIsWeb); -} - -Widget _testApp(Uri destination) { - return MaterialApp( - home: Scaffold( - body: NakedLink( - linkUrl: destination, - child: const SizedBox( - width: 160, - height: 48, - child: Text('Open destination'), - ), - ), - ), - ); -} - -class _FakeUrlLauncherPlatform extends UrlLauncherPlatform { - final followedUrls = []; - final launchCalls = <(String, LaunchOptions)>[]; - - @override - LinkDelegate get linkDelegate { - return (link) => Builder( - builder: (context) => link.builder( - context, - link.isDisabled - ? null - : () async { - followedUrls.add(link.uri!); - }, - ), - ); - } - - @override - Future launchUrl(String url, LaunchOptions options) async { - launchCalls.add((url, options)); - return true; - } - - void clear() { - followedUrls.clear(); - launchCalls.clear(); - } -} diff --git a/packages/naked_ui/test/src/web_modifier_listener_stub.dart b/packages/naked_ui/test/src/web_modifier_listener_stub.dart deleted file mode 100644 index 572679d5..00000000 --- a/packages/naked_ui/test/src/web_modifier_listener_stub.dart +++ /dev/null @@ -1 +0,0 @@ -Future probeCaptureToTarget() async => false; diff --git a/packages/naked_ui/test/src/web_modifier_listener_web.dart b/packages/naked_ui/test/src/web_modifier_listener_web.dart deleted file mode 100644 index 3dc792f1..00000000 --- a/packages/naked_ui/test/src/web_modifier_listener_web.dart +++ /dev/null @@ -1,59 +0,0 @@ -@JS() -library; - -import 'dart:async'; -import 'dart:js_interop'; - -import 'package:naked_ui/src/utilities/web_event_modifiers_web.dart'; - -@JS('document') -external _Document get _document; - -extension type _EventTarget._(JSObject _) implements JSObject { - external void addEventListener( - String type, - JSFunction listener, [ - JSAny options, - ]); - - external bool dispatchEvent(_MouseEvent event); -} - -extension type _Document._(JSObject _) implements JSObject { - external _Element createElement(String localName); - external _Element get body; -} - -extension type _Element._(JSObject _) implements _EventTarget, JSObject { - external void append(_Element child); - external void remove(); -} - -@JS('MouseEvent') -extension type _MouseEvent._(JSObject _) implements JSObject { - external factory _MouseEvent(String type, [_MouseEventInit eventInit]); -} - -@JS('Object') -extension type _MouseEventInit._(JSObject _) implements JSObject { - external factory _MouseEventInit({bool bubbles, bool ctrlKey}); -} - -Future probeCaptureToTarget() async { - ensureInitialized(); - final target = _document.createElement('button'); - final result = Completer(); - target.addEventListener( - 'click', - ((JSAny _) => result.complete(consumeModifiedClick())).toJS, - ); - _document.body.append(target); - try { - target.dispatchEvent( - _MouseEvent('click', _MouseEventInit(bubbles: true, ctrlKey: true)), - ); - return await result.future; - } finally { - target.remove(); - } -} diff --git a/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart b/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart index 9e3fd350..82603d92 100644 --- a/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart +++ b/packages/naked_ui/test/utilities/naked_focusable_detector_test.dart @@ -1,8 +1,5 @@ -import 'dart:ui' show Tristate; - 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:naked_ui/src/utilities/naked_focusable_detector.dart'; @@ -126,103 +123,6 @@ void main() { final focus = tester.widget(focusFinder); expect(focus.canRequestFocus, isTrue); // Should still be focusable }); - - testWidgets('updates focus semantics immediately when disabled', ( - tester, - ) async { - var enabled = true; - late StateSetter rebuild; - final semantics = tester.ensureSemantics(); - - await tester.pumpWidget( - MaterialApp( - home: StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedFocusableDetector( - enabled: enabled, - child: Semantics( - label: 'target', - child: const Text('Target'), - ), - ); - }, - ), - ), - ); - - var data = tester.getSemantics(find.text('Target')).getSemanticsData(); - expect(data.hasAction(SemanticsAction.focus), isTrue); - - rebuild(() => enabled = false); - await tester.pump(); - - data = tester.getSemantics(find.text('Target')).getSemanticsData(); - expect(data.hasAction(SemanticsAction.focus), isFalse); - expect(data.flagsCollection.isFocused, Tristate.none); - semantics.dispose(); - }); - - testWidgets('ancestor focus exclusion removes focus semantics', ( - tester, - ) async { - final semantics = tester.ensureSemantics(); - - await tester.pumpWidget( - MaterialApp( - home: ExcludeFocus( - child: NakedFocusableDetector( - child: Semantics(label: 'target', child: const Text('Target')), - ), - ), - ), - ); - await tester.pump(); - - final data = tester - .getSemantics(find.text('Target')) - .getSemanticsData(); - expect(data.hasAction(SemanticsAction.focus), isFalse); - expect(data.flagsCollection.isFocused, Tristate.none); - semantics.dispose(); - }); - - testWidgets('updates focus semantics when ancestor exclusion changes', ( - tester, - ) async { - var excluding = false; - late StateSetter rebuild; - final semantics = tester.ensureSemantics(); - final target = NakedFocusableDetector( - child: Semantics(label: 'target', child: const Text('Target')), - ); - - await tester.pumpWidget( - MaterialApp( - home: StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return ExcludeFocus(excluding: excluding, child: target); - }, - ), - ), - ); - - SemanticsData data() => - tester.getSemantics(find.text('Target')).getSemanticsData(); - - expect(data().hasAction(SemanticsAction.focus), isTrue); - - rebuild(() => excluding = true); - await tester.pump(); - expect(data().hasAction(SemanticsAction.focus), isFalse); - expect(data().flagsCollection.isFocused, Tristate.none); - - rebuild(() => excluding = false); - await tester.pump(); - expect(data().hasAction(SemanticsAction.focus), isTrue); - semantics.dispose(); - }); }); group('Hover detection', () { @@ -284,98 +184,6 @@ void main() { expect(hoverState, isFalse); }); - testWidgets('reports hover after reenable under a stationary pointer', ( - tester, - ) async { - var enabled = true; - final hoverChanges = []; - late StateSetter rebuild; - - await tester.pumpWidget( - MaterialApp( - home: Center( - child: StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedFocusableDetector( - enabled: enabled, - restoreHoverOnEnable: true, - onHoverChange: hoverChanges.add, - child: Container( - width: 100, - height: 100, - color: Colors.red, - ), - ); - }, - ), - ), - ), - ); - - final gesture = await tester.createGesture( - kind: PointerDeviceKind.mouse, - ); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(find.byType(Container))); - await tester.pump(); - expect(hoverChanges, [true]); - - rebuild(() => enabled = false); - await tester.pump(); - rebuild(() => enabled = true); - await tester.pump(); - await tester.pump(); - - expect(hoverChanges, [true, false, true]); - }); - - testWidgets('does not restore hover after reenable by default', ( - tester, - ) async { - var enabled = true; - final hoverChanges = []; - late StateSetter rebuild; - - await tester.pumpWidget( - MaterialApp( - home: Center( - child: StatefulBuilder( - builder: (context, setState) { - rebuild = setState; - return NakedFocusableDetector( - enabled: enabled, - onHoverChange: hoverChanges.add, - child: Container( - width: 100, - height: 100, - color: Colors.red, - ), - ); - }, - ), - ), - ), - ); - - final gesture = await tester.createGesture( - kind: PointerDeviceKind.mouse, - ); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(find.byType(Container))); - await tester.pump(); - - rebuild(() => enabled = false); - await tester.pump(); - rebuild(() => enabled = true); - await tester.pump(); - await tester.pump(); - - expect(hoverChanges, [true]); - }); - testWidgets('sets custom mouse cursor', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/pubspec.lock b/pubspec.lock index d07be7ce..88c081f3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -540,70 +540,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - url_launcher: - dependency: transitive - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" - url: "https://pub.dev" - source: hosted - version: "6.3.30" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.dev" - source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" vector_graphics: dependency: transitive description: @@ -693,5 +629,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.9.0 <4.0.0" flutter: ">=3.41.0" diff --git a/tool/run_android_integration.sh b/tool/run_android_integration.sh index 587941af..721c0ec1 100755 --- a/tool/run_android_integration.sh +++ b/tool/run_android_integration.sh @@ -24,7 +24,6 @@ adb -s "$device_id" get-state --dart-define="NAKED_UI_FLUTTER_VERSION=$NAKED_UI_FLUTTER_VERSION" test -s build/integration_test_screenshots/dialog__open__android__reference.png - test -s build/integration_test_screenshots/link__disabled__android__reference.png test -s build/integration_test_screenshots/alert_dialog__destructive_action__android__reference.png test -s build/integration_test_screenshots/toggle_group__vertical_disabled__android__reference.png test -s build/integration_test_screenshots/manifest.json